view.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715
  1. /**
  2. * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. 'use strict';
  6. import Collection from '../../utils/collection.js';
  7. import Region from './region.js';
  8. import Template from './template.js';
  9. import CKEditorError from '../../utils/ckeditorerror.js';
  10. import DOMEmitterMixin from './domemittermixin.js';
  11. import utils from '../../utils/utils.js';
  12. import isPlainObject from '../lib/lodash/isPlainObject.js';
  13. const bindToSymbol = Symbol( 'bindTo' );
  14. const bindIfSymbol = Symbol( 'bindIf' );
  15. /**
  16. * Basic View class.
  17. *
  18. * @memberOf core.ui
  19. * @mixes DOMEmitterMixin
  20. */
  21. export default class View {
  22. /**
  23. * Creates an instance of the {@link View} class.
  24. *
  25. * @param {core.ui.Model} model (View)Model of this View.
  26. * @param {core.Locale} [locale] The {@link core.Editor#locale editor's locale} instance.
  27. */
  28. constructor( model, locale ) {
  29. /**
  30. * Model of this view.
  31. *
  32. * @member {core.ui.Model} core.ui.View#model
  33. */
  34. this.model = model;
  35. /**
  36. * @readonly
  37. * @member {core.Locale} core.ui.View#locale
  38. */
  39. this.locale = locale;
  40. /**
  41. * Shorthand for {@link core.Locale#t}.
  42. *
  43. * Note: If locale instance hasn't been passed to the view this method may not be available.
  44. *
  45. * @see core.Locale#t
  46. * @method core.ui.View#t
  47. */
  48. this.t = locale && locale.t;
  49. /**
  50. * Regions of this view. See {@link core.ui.View#register}.
  51. *
  52. * @member {core.Collection} core.ui.View#regions
  53. */
  54. this.regions = new Collection( {
  55. idProperty: 'name'
  56. } );
  57. /**
  58. * Template of this view.
  59. *
  60. * @member {Object} core.ui.View#template
  61. */
  62. /**
  63. * Region selectors of this view. See {@link core.ui.View#register}.
  64. *
  65. * @private
  66. * @member {Object} core.ui.View#_regionSelectors
  67. */
  68. this._regionSelectors = {};
  69. /**
  70. * Element of this view.
  71. *
  72. * @private
  73. * @member {HTMLElement} core.ui.View.#_element
  74. */
  75. /**
  76. * An instance of Template to generate {@link core.ui.View#_el}.
  77. *
  78. * @private
  79. * @member {Template} core.ui.View#_template
  80. */
  81. }
  82. /**
  83. * Element of this view. The element is rendered on first reference
  84. * using {@link core.ui.View#template} definition.
  85. *
  86. * @type {HTMLElement}
  87. */
  88. get element() {
  89. if ( this._element ) {
  90. return this._element;
  91. }
  92. // No template means no element (a virtual view).
  93. if ( !this.template ) {
  94. return null;
  95. }
  96. // Prepare pre–defined listeners.
  97. this._extendTemplateWithListenerAttachers( this.template );
  98. // Prepare pre–defined attribute bindings.
  99. this._extendTemplateWithModelBinders( this.template );
  100. this._template = new Template( this.template );
  101. return ( this._element = this._template.render() );
  102. }
  103. set element( el ) {
  104. this._element = el;
  105. }
  106. /**
  107. * And entry point to the interface which allows binding attributes of {@link View#model}
  108. * to the DOM items like HTMLElement attributes or Text Node `textContent`, so their state
  109. * is synchronized with {@link View#model}.
  110. *
  111. * @readonly
  112. * @type core.ui.ViewModelBinding
  113. */
  114. get attributeBinder() {
  115. if ( this._attributeBinder ) {
  116. return this._attributeBinder;
  117. }
  118. const model = this.model;
  119. const binder = {
  120. /**
  121. * Binds {@link View#model} to HTMLElement attribute or Text Node `textContent`
  122. * so remains in sync with the Model when it changes.
  123. *
  124. * this.template = {
  125. * tag: 'p',
  126. * attributes: {
  127. * // class="..." attribute gets bound to this.model.a
  128. * 'class': bind.to( 'a' )
  129. * },
  130. * children: [
  131. * // <p>...</p> gets bound to this.model.b; always `toUpperCase()`.
  132. * { text: bind.to( 'b', ( value, node ) => value.toUpperCase() ) }
  133. * ]
  134. * }
  135. *
  136. * @property {attributeBinder.to}
  137. * @param {String} attribute Name of {@link View#model} used in the binding.
  138. * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
  139. * @return {core.ui.ViewModelBinding}
  140. */
  141. to( attribute, callback ) {
  142. return {
  143. type: bindToSymbol,
  144. model: model,
  145. attribute,
  146. callback
  147. };
  148. },
  149. /**
  150. * Binds {@link View#model} to HTMLElement attribute or Text Node `textContent`
  151. * so remains in sync with the Model when it changes. Unlike {@link View#attributeBinder.to},
  152. * it controls the presence of the attribute/`textContent` depending on the "falseness" of
  153. * {@link View#model} attribute.
  154. *
  155. * this.template = {
  156. * tag: 'input',
  157. * attributes: {
  158. * // <input checked> this.model.a is not undefined/null/false/''
  159. * // <input> this.model.a is undefined/null/false
  160. * checked: bind.if( 'a' )
  161. * },
  162. * children: [
  163. * {
  164. * // <input>"b-is-not-set"</input> when this.model.b is undefined/null/false/''
  165. * // <input></input> when this.model.b is not "falsy"
  166. * text: bind.if( 'b', 'b-is-not-set', ( value, node ) => !value )
  167. * }
  168. * ]
  169. * }
  170. *
  171. * @property {attributeBinder.if}
  172. * @param {String} attribute Name of {@link View#model} used in the binding.
  173. * @param {String} [valueIfTrue] Value set when {@link View#model} attribute is not undefined/null/false/''.
  174. * @param {Function} [callback] Allows processing of the value. Accepts `Node` and `value` as arguments.
  175. * @return {core.ui.ViewModelBinding}
  176. */
  177. if( attribute, valueIfTrue, callback ) {
  178. return {
  179. type: bindIfSymbol,
  180. model: model,
  181. attribute,
  182. valueIfTrue,
  183. callback
  184. };
  185. }
  186. };
  187. return ( this._attributeBinder = binder );
  188. }
  189. /**
  190. * Initializes the view.
  191. *
  192. * Note: {@link core.Controller} supports if a promise is returned by this method,
  193. * what means that view initialization may be asynchronous.
  194. */
  195. init() {
  196. this._initRegions();
  197. }
  198. /**
  199. * Registers a region in {@link core.ui.View#regions}.
  200. *
  201. * let view = new View();
  202. *
  203. * // region.name == "foo", region.element == view.element.firstChild
  204. * view.register( 'foo', el => el.firstChild );
  205. *
  206. * // region.name == "bar", region.element == view.element.querySelector( 'span' )
  207. * view.register( new Region( 'bar' ), 'span' );
  208. *
  209. * // region.name == "bar", region.element == view.element.querySelector( '#div#id' )
  210. * view.register( 'bar', 'div#id', true );
  211. *
  212. * // region.name == "baz", region.element == null
  213. * view.register( 'baz', true );
  214. *
  215. * @param {String|Region} stringOrRegion The name or an instance of the Region
  216. * to be registered. If `String`, the region will be created on the fly.
  217. * @param {String|Function|true} regionSelector The selector to retrieve region's element
  218. * in DOM when the region instance is initialized (see {@link Region#init}, {@link core.ui.View#init}).
  219. * @param {Boolean} [override] When set `true` it will allow overriding of registered regions.
  220. */
  221. register( ...args ) {
  222. let region, regionName;
  223. if ( typeof args[ 0 ] === 'string' ) {
  224. regionName = args[ 0 ];
  225. region = this.regions.get( regionName ) || new Region( regionName );
  226. } else if ( args[ 0 ] instanceof Region ) {
  227. regionName = args[ 0 ].name;
  228. region = args[ 0 ];
  229. } else {
  230. /**
  231. * A name of the region or an instance of Region is required.
  232. *
  233. * @error ui-view-register-wrongtype
  234. */
  235. throw new CKEditorError( 'ui-view-register-wrongtype' );
  236. }
  237. const regionSelector = args[ 1 ];
  238. if ( !regionSelector || !isValidRegionSelector( regionSelector ) ) {
  239. /**
  240. * The selector must be String, Function or `true`.
  241. *
  242. * @error ui-view-register-badselector
  243. */
  244. throw new CKEditorError( 'ui-view-register-badselector' );
  245. }
  246. const registered = this.regions.get( regionName );
  247. if ( !registered ) {
  248. this.regions.add( region );
  249. } else {
  250. if ( registered !== region ) {
  251. if ( !args[ 2 ] ) {
  252. /**
  253. * Overriding is possible only when `override` flag is set.
  254. *
  255. * @error ui-view-register-override
  256. */
  257. throw new CKEditorError( 'ui-view-register-override' );
  258. }
  259. this.regions.remove( registered );
  260. this.regions.add( region );
  261. }
  262. }
  263. this._regionSelectors[ regionName ] = regionSelector;
  264. }
  265. /**
  266. * Applies template to existing DOM element in the context of a View.
  267. *
  268. * const element = document.createElement( 'div' );
  269. * const view = new View( new Model( { divClass: 'my-div' } ) );
  270. *
  271. * view.applyTemplateToElement( element, {
  272. * attrs: {
  273. * id: 'first-div',
  274. * class: view.bindToAttribute( 'divClass' )
  275. * },
  276. * on: {
  277. * click: 'elementClicked' // Will be fired by the View instance.
  278. * }
  279. * children: [
  280. * 'Div text.'
  281. * ]
  282. * } );
  283. *
  284. * element.outerHTML == "<div id="first-div" class="my-div">Div text.</div>"
  285. *
  286. * See: {@link Template#apply}.
  287. *
  288. * @param {DOMElement} element DOM Element to initialize.
  289. * @param {core.ui.TemplateDefinition} def Template definition to be applied.
  290. */
  291. applyTemplateToElement( element, def ) {
  292. // Prepare pre–defined listeners.
  293. this._extendTemplateWithListenerAttachers( def );
  294. // Prepare pre–defined attribute bindings.
  295. this._extendTemplateWithModelBinders( def );
  296. new Template( def ).apply( element );
  297. }
  298. /**
  299. * Destroys the view instance. The process includes:
  300. *
  301. * 1. Removal of child views from {@link core.ui.View#regions}.
  302. * 2. Destruction of the {@link core.ui.View#regions}.
  303. * 3. Removal of {#link #_el} from DOM.
  304. */
  305. destroy() {
  306. let childView;
  307. this.stopListening();
  308. for ( let region of this.regions ) {
  309. while ( ( childView = region.views.get( 0 ) ) ) {
  310. region.views.remove( childView );
  311. }
  312. this.regions.remove( region ).destroy();
  313. }
  314. if ( this.template ) {
  315. this.element.remove();
  316. }
  317. this.model = this.regions = this.template = this.locale = this.t = null;
  318. this._regionSelectors = this._element = this._template = null;
  319. }
  320. /**
  321. * Initializes {@link core.ui.View#regions} of this view by passing a DOM element
  322. * generated from {@link core.ui.View#_regionSelectors} into {@link Region#init}.
  323. *
  324. * @protected
  325. */
  326. _initRegions() {
  327. let region, regionEl, regionSelector;
  328. for ( region of this.regions ) {
  329. regionSelector = this._regionSelectors[ region.name ];
  330. if ( typeof regionSelector == 'string' ) {
  331. regionEl = this.element.querySelector( regionSelector );
  332. } else if ( typeof regionSelector == 'function' ) {
  333. regionEl = regionSelector( this.element );
  334. } else {
  335. regionEl = null;
  336. }
  337. region.init( regionEl );
  338. }
  339. }
  340. /**
  341. * For a given event name or callback, returns a function which,
  342. * once executed in a context of an element, attaches native DOM listener
  343. * to the element. The listener executes given callback or fires View's event
  344. * of given name.
  345. *
  346. * @protected
  347. * @param {String|Function} evtNameOrCallback Event name to be fired on View or callback to execute.
  348. * @returns {Function} A listener attacher function to be executed in the context of an element.
  349. */
  350. _getDOMListenerAttacher( evtNameOrCallback ) {
  351. /**
  352. * Attaches a native DOM listener to given element. The listener executes the
  353. * callback or fires View's event.
  354. *
  355. * Note: If the selector is supplied, it narrows the scope to relevant targets only.
  356. * So instead of
  357. *
  358. * children: [
  359. * { tag: 'span', on: { click: 'foo' } }
  360. * { tag: 'span', on: { click: 'foo' } }
  361. * ]
  362. *
  363. * a single, more efficient listener can be attached that uses **event delegation**:
  364. *
  365. * children: [
  366. * { tag: 'span' }
  367. * { tag: 'span' }
  368. * ],
  369. * on: {
  370. * 'click@span': 'foo',
  371. * }
  372. *
  373. * @param {HTMLElement} el Element, to which the native DOM Event listener is attached.
  374. * @param {String} domEventName The name of native DOM Event.
  375. * @param {String} [selector] If provided, the selector narrows the scope to relevant targets only.
  376. */
  377. return ( el, domEvtName, selector ) => {
  378. // Use View's listenTo, so the listener is detached, when the View dies.
  379. this.listenTo( el, domEvtName, ( evt, domEvt ) => {
  380. if ( !selector || domEvt.target.matches( selector ) ) {
  381. if ( typeof evtNameOrCallback == 'function' ) {
  382. evtNameOrCallback( domEvt );
  383. } else {
  384. this.fire( evtNameOrCallback, domEvt );
  385. }
  386. }
  387. } );
  388. };
  389. }
  390. /**
  391. * For given {@link core.ui.TemplateValueSchema} found by (@link _extendTemplateWithModelBinders} containing
  392. * {@link core.ui.ViewModelBinding} it returns a function, which when called by {@link Template#render}
  393. * or {@link Template#apply} activates the binding and sets its initial value.
  394. *
  395. * Note: {@link core.ui.TemplateValueSchema} can be for HTMLElement attributes or Text Node `textContent`.
  396. *
  397. * @protected
  398. * @param {core.ui.TemplateValueSchema}
  399. * @return {Function}
  400. */
  401. _getModelBinder( valueSchema ) {
  402. valueSchema = normalizeBinderValueSchema( valueSchema );
  403. /**
  404. * Assembles the value using {@link core.ui.TemplateValueSchema} and stores it in a form of
  405. * an Array. Each entry of an Array corresponds to one of {@link core.ui.TemplateValueSchema}
  406. * items.
  407. *
  408. * @private
  409. * @param {Node} node
  410. * @return {Array}
  411. */
  412. const getBoundValue = ( node ) => {
  413. let model, modelValue;
  414. return valueSchema.map( schemaItem => {
  415. model = schemaItem.model;
  416. if ( model ) {
  417. modelValue = model[ schemaItem.attribute ];
  418. if ( schemaItem.callback ) {
  419. modelValue = schemaItem.callback( modelValue, node );
  420. }
  421. return modelValue;
  422. } else {
  423. return schemaItem;
  424. }
  425. } );
  426. };
  427. /**
  428. * Attaches a listener to {@link View#model}, which updates DOM with a value constructed from
  429. * {@link core.ui.TemplateValueSchema} when {@link View#model} attribute value changes.
  430. *
  431. * This function is called by {@link Template#render} or {@link Template#apply}.
  432. *
  433. * @param {Node} node DOM Node to be updated when {@link View#model} changes.
  434. * @param {Function} domUpdater A function provided by {@link Template} which updates corresponding
  435. * DOM attribute or `textContent`.
  436. */
  437. return ( node, domUpdater ) => {
  438. // Check if valueSchema is a single bind.if, like:
  439. // { class: bind.if( 'foo' ) }
  440. const isPlainBindIf = valueSchema.length == 1 && valueSchema[ 0 ].type == bindIfSymbol;
  441. // A function executed each time bound model attribute changes.
  442. const onModelChange = () => {
  443. let value = getBoundValue( node );
  444. if ( isPlainBindIf ) {
  445. value = value[ 0 ];
  446. } else {
  447. value = value.reduce( binderValueReducer, '' );
  448. }
  449. const isSet = isPlainBindIf ? !!value : value;
  450. const valueToSet = isPlainBindIf ?
  451. ( valueSchema[ 0 ].valueIfTrue || '' ) : value;
  452. if ( isSet ) {
  453. domUpdater.set( valueToSet );
  454. } else {
  455. domUpdater.remove();
  456. }
  457. };
  458. valueSchema
  459. .filter( schemaItem => schemaItem.model )
  460. .forEach( schemaItem => {
  461. this.listenTo( schemaItem.model, 'change:' + schemaItem.attribute, onModelChange );
  462. } );
  463. // Set initial values.
  464. onModelChange();
  465. };
  466. }
  467. /**
  468. * Iterates over "attributes" and "text" properties in {@link TemplateDefinition} and
  469. * locates existing {@link core.ui.ViewModelBinding} created by {@link core.ui.View#attributeBinder}.
  470. * Then, for each such a binding, it creates corresponding entry in {@link Template#_modelBinders},
  471. * which can be then activated by {@link Template#render} or {@link Template#apply}.
  472. *
  473. * @protected
  474. * @param {core.ui.TemplateDefinition} def
  475. */
  476. _extendTemplateWithModelBinders( def ) {
  477. const attributes = def.attributes;
  478. const text = def.text;
  479. let binders = def._modelBinders;
  480. let attrName, attrValue;
  481. if ( !binders && isPlainObject( def ) ) {
  482. Object.defineProperty( def, '_modelBinders', {
  483. enumerable: false,
  484. writable: true,
  485. value: {
  486. attributes: {}
  487. }
  488. } );
  489. binders = def._modelBinders;
  490. }
  491. if ( attributes ) {
  492. for ( attrName in attributes ) {
  493. attrValue = attributes[ attrName ];
  494. if ( hasModelBinding( attrValue ) ) {
  495. binders.attributes[ attrName ] = this._getModelBinder( attrValue );
  496. }
  497. }
  498. }
  499. if ( text && hasModelBinding( text ) ) {
  500. binders.text = this._getModelBinder( text );
  501. }
  502. // Repeat recursively for the children.
  503. if ( def.children ) {
  504. def.children.forEach( this._extendTemplateWithModelBinders, this );
  505. }
  506. }
  507. /**
  508. * Iterates over "on" property in {@link TemplateDefinition} to recursively
  509. * replace each listener declaration with a function which, once executed in a context
  510. * of an element, attaches native DOM listener to that element.
  511. *
  512. * @protected
  513. * @param {core.ui.TemplateDefinition} def Template definition.
  514. */
  515. _extendTemplateWithListenerAttachers( def ) {
  516. const on = def.on;
  517. // Don't create attachers if they're already here or in the context of the same (this) View instance.
  518. if ( on && ( !on._listenerAttachers || on._listenerView != this ) ) {
  519. let domEvtName, evtNameOrCallback;
  520. Object.defineProperty( on, '_listenerAttachers', {
  521. enumerable: false,
  522. writable: true,
  523. value: {}
  524. } );
  525. for ( domEvtName in on ) {
  526. evtNameOrCallback = on[ domEvtName ];
  527. // Listeners allow definition with an array:
  528. //
  529. // on: {
  530. // 'DOMEventName@selector': [ 'event1', callback ],
  531. // 'DOMEventName': [ callback, 'event2', 'event3' ]
  532. // ...
  533. // }
  534. if ( Array.isArray( evtNameOrCallback ) ) {
  535. on._listenerAttachers[ domEvtName ] = on[ domEvtName ].map( this._getDOMListenerAttacher, this );
  536. }
  537. // Listeners allow definition with a string containing event name:
  538. //
  539. // on: {
  540. // 'DOMEventName@selector': 'event1',
  541. // 'DOMEventName': 'event2'
  542. // ...
  543. // }
  544. else {
  545. on._listenerAttachers[ domEvtName ] = this._getDOMListenerAttacher( evtNameOrCallback );
  546. }
  547. }
  548. // Set this property to be known that these attachers has already been created
  549. // in the context of this particular View instance.
  550. Object.defineProperty( on, '_listenerView', {
  551. enumerable: false,
  552. writable: true,
  553. value: this
  554. } );
  555. }
  556. // Repeat recursively for the children.
  557. if ( def.children ) {
  558. def.children.forEach( this._extendTemplateWithListenerAttachers, this );
  559. }
  560. }
  561. }
  562. utils.mix( View, DOMEmitterMixin );
  563. const validSelectorTypes = new Set( [ 'string', 'boolean', 'function' ] );
  564. /**
  565. * Check whether region selector is valid.
  566. *
  567. * @ignore
  568. * @private
  569. * @param {*} selector Selector to be checked.
  570. * @returns {Boolean}
  571. */
  572. function isValidRegionSelector( selector ) {
  573. return validSelectorTypes.has( typeof selector ) && selector !== false;
  574. }
  575. /**
  576. * Normalizes given {@link core.ui.TemplateValueSchema} it's always in an Array–like format:
  577. *
  578. * { attributeName/text: 'bar' } ->
  579. * { attributeName/text: [ 'bar' ] }
  580. *
  581. * { attributeName/text: { model: ..., modelAttributeName: ..., callback: ... } } ->
  582. * { attributeName/text: [ { model: ..., modelAttributeName: ..., callback: ... } ] }
  583. *
  584. * { attributeName/text: [ 'bar', { model: ..., modelAttributeName: ... }, 'baz' ] }
  585. *
  586. * @ignore
  587. * @private
  588. * @param {core.ui.TemplateValueSchema} valueSchema
  589. * @returns {Array}
  590. */
  591. function normalizeBinderValueSchema( valueSchema ) {
  592. return Array.isArray( valueSchema ) ? valueSchema : [ valueSchema ];
  593. }
  594. /**
  595. * Checks whether given {@link core.ui.TemplateValueSchema} contains a
  596. * {@link core.ui.ViewModelBinding}.
  597. *
  598. * @ignore
  599. * @private
  600. * @param {core.ui.TemplateValueSchema} valueSchema
  601. * @returns {Boolean}
  602. */
  603. function hasModelBinding( valueSchema ) {
  604. if ( Array.isArray( valueSchema ) ) {
  605. return valueSchema.some( hasModelBinding );
  606. } else if ( valueSchema.model ) {
  607. return true;
  608. }
  609. return false;
  610. }
  611. /**
  612. * A helper which concatenates the value avoiding unwanted
  613. * leading white spaces.
  614. *
  615. * @ignore
  616. * @private
  617. * @param {String} prev
  618. * @param {String} cur
  619. * @returns {String}
  620. */
  621. function binderValueReducer( prev, cur ) {
  622. return prev === '' ? `${cur}` : `${prev} ${cur}`;
  623. }
  624. /**
  625. * Describes Model binding created by {@link View#attributeBinder}.
  626. *
  627. * @typedef core.ui.ViewModelBinding
  628. * @type Object
  629. * @property {Symbol} type
  630. * @property {core.ui.Model} model
  631. * @property {String} attribute
  632. * @property {String} [valueIfTrue]
  633. * @property {Function} [callback]
  634. */