1 // Copyright: ©2006-2010 Sprout Systems, Inc. and contributors. 2 // Portions ©2008-2011 Apple Inc. All rights reserved. 3 // License: Licensed under MIT license (see license.js) 4 // ========================================================================== 5 6 sc_require('views/popup_button'); 7 sc_require('mixins/select_view_menu'); 8 sc_require('ext/menu'); 9 10 /** 11 * @class 12 * @extends SC.PopupButtonView 13 * @version 2.0 14 * @author Alex Iskander 15 */ 16 SC.SelectView = SC.PopupButtonView.extend({ 17 /** @scope SC.SelectView.prototype */ 18 19 // 20 // Properties 21 // 22 theme: 'popup', 23 renderDelegateName: 'selectRenderDelegate', 24 25 /** 26 The array of items to populate the menu. This can be a simple array of strings, 27 objects or hashes. If you pass objects or hashes, you can also set the 28 various itemKey properties to tell the menu how to extract the information 29 it needs. 30 31 @type Array 32 @default [] 33 */ 34 items: null, 35 36 /** 37 Binding default for an array of items 38 39 @property 40 @default SC.Binding.multiple() 41 */ 42 itemsBindingDefault: SC.Binding.multiple(), 43 44 /** 45 They key in the items which maps to the title. 46 This only applies for items that are hashes or SC.Objects. 47 48 @property 49 @type {String} 50 @default null 51 */ 52 itemTitleKey: null, 53 54 /** 55 If you set this to a non-null value, then the value of this key will 56 be used to sort the items. If this is not set, then itemTitleKey will 57 be used. 58 59 @property 60 @type: {String} 61 @default null 62 */ 63 itemSortKey: null, 64 65 /** 66 They key in the items which maps to the value. 67 This only applies for items that are hashes or SC.Objects. 68 69 @property 70 @type {String} 71 @default null 72 */ 73 itemValueKey: null, 74 75 /** 76 Key used to extract icons from the items array. 77 78 @property 79 @type {String} 80 @default null 81 */ 82 itemIconKey: null, 83 84 /** 85 Key to use to identify separators. 86 87 Items that have this property set to YES will be drawn as separators. 88 89 @property 90 @type {String} 91 @default null 92 */ 93 itemSeparatorKey: "isSeparator", 94 95 /** 96 Key used to indicate if the item is to be enabled. 97 98 @property 99 @type {String} 100 @default null 101 */ 102 itemIsEnabledKey: "isEnabled", 103 104 /** 105 Set this to non-null to place an empty option at the top of the menu. 106 107 @property 108 @type String 109 @default null 110 */ 111 emptyName: null, 112 113 /** 114 If true, titles will be escaped to avoid scripting attacks. 115 116 @type Boolean 117 @default YES 118 */ 119 escapeHTML: YES, 120 121 /** 122 If true, the empty name and the default title will be localized. 123 124 @type Boolean 125 @default YES 126 */ 127 localize: YES, 128 129 /** 130 if true, it means that no sorting will occur, items will appear 131 in the same order as in the array 132 133 @type Boolean 134 @default YES 135 */ 136 disableSort: YES, 137 138 /** 139 The menu that will pop up when this button is clicked. 140 141 The default menu has its properties bound to the SC.SelectView, 142 meaning that it will get all its items from the SelectView. 143 You may override the menu entirely with one of your own; if you 144 mix in SC.SelectViewMenu, it'll get the bindings and the extended 145 MenuItemView that draws its checkbox when it is the selected item. 146 147 @property 148 @type {SC.MenuPane} 149 @default SC.AutoResizingMenuPane.extend(SC.SelectViewMenu) 150 */ 151 menu: SC.AutoResizingMenuPane.extend(SC.SelectViewMenu), 152 153 /** 154 The currently selected item. If no item is selected, `null`. 155 156 @private 157 @type SC.Object 158 @default null 159 @isReadOnly 160 */ 161 selectedItem: null, 162 selectedItemBinding: '*menu.rootMenu.selectedItem', 163 164 165 /** 166 This is a property to enable/disable focus rings in buttons. 167 For SelectView, it is a default. 168 169 @property 170 @type {Boolean} 171 @default YES 172 */ 173 supportsFocusRing: YES, 174 175 176 /** 177 * @private 178 */ 179 init: function() { 180 sc_super(); 181 182 // call valueDidChange to get the initial item, if any 183 this._scsv_valueDidChange(); 184 }, 185 186 /** @private */ 187 _itemTitleKey: function() { 188 return this.get('itemTitleKey') || 'title'; 189 }.property('itemTitleKey').cacheable(), 190 191 /** @private */ 192 _itemValueKey: function() { 193 return this.get('itemValueKey') || 'value'; 194 }.property('itemValueKey').cacheable(), 195 196 /** @private */ 197 _itemIsEnabledKey: function() { 198 return this.get('itemIsEnabledKey') || 'isEnabled'; 199 }.property('itemIsEnabledKey').cacheable(), 200 201 /** 202 @private 203 204 This gets the value for a specific menu item. 205 206 This method therefore accepts both the menu items as created for the menupane's displayItems 207 AND the raw items provided by the developer in `items`. 208 */ 209 _scsv_getValueForMenuItem: function(item) { 210 var valueKey = this.get('_itemValueKey'); 211 212 if (!item.isDisplayItem && !this.get('itemValueKey')) { 213 return item; 214 } else if (item.get) { 215 return item.get(valueKey); 216 } else { 217 return item[valueKey]; 218 } 219 }, 220 221 /** 222 * When the selected item changes, we need to update our value. 223 * @private 224 */ 225 _scsv_selectedItemDidChange: function() { 226 var sel = this.get('selectedItem'), 227 last = this._scsv_lastSelection; 228 229 // selected item could be a menu item from SC.MenuPane's displayItems, or it could 230 // be a raw item. So, we have to use _scsv_getValueForMenuItem to resolve it. 231 if (sel) { 232 this.setIfChanged('value', this._scsv_getValueForMenuItem(sel)); 233 } 234 235 // add/remove observers for the title and value so we can invalidate. 236 if (last && last.addObserver && sel !== last) { 237 last.removeObserver('*', this, '_scsv_selectedItemPropertyDidChange'); 238 } 239 240 if (sel && sel.addObserver && sel !== last) { 241 sel.addObserver('*', this, '_scsv_selectedItemPropertyDidChange'); 242 } 243 244 this._scsv_lastSelection = sel; 245 }.observes('selectedItem'), 246 247 // called when either title or value changes on the selected item 248 _scsv_selectedItemPropertyDidChange: function(item) { 249 this.notifyPropertyChange('title'); 250 this.notifyPropertyChange('icon'); 251 this.set('value', this._scsv_getValueForMenuItem(item)); 252 }, 253 254 /** 255 The title of the button, derived from the selected item. 256 */ 257 title: function() { 258 var sel = this.get('selectedItem'); 259 260 if (!sel) { 261 return this.get('defaultTitle'); 262 } else { 263 var itemTitleKey = this.get('_itemTitleKey'); 264 if (itemTitleKey) { 265 if (sel.get) return sel.get(itemTitleKey); 266 else if (SC.typeOf(sel) == SC.T_HASH) return sel[itemTitleKey]; 267 } 268 return sel.toString(); 269 } 270 }.property('selectedItem').cacheable(), 271 272 /** @private */ 273 defaultTitle: function() { 274 var emptyName = this.get('emptyName'); 275 if (emptyName) { 276 emptyName = this.get('localize') ? SC.String.loc(emptyName) : emptyName; 277 emptyName = this.get('escapeHTML') ? SC.RenderContext.escapeHTML(emptyName) : emptyName; 278 } 279 return emptyName || ''; 280 }.property('emptyName').cacheable(), 281 282 /** 283 The icon of the button, derived from the selected item. 284 */ 285 icon: function() { 286 var sel = this.get('selectedItem'), 287 itemIconKey = this.get('itemIconKey'); 288 289 if (sel && itemIconKey) { 290 if (sel.get) return sel.get(itemIconKey); 291 else if (SC.typeOf(sel) == SC.T_HASH) return sel[itemIconKey]; 292 } 293 return null; 294 }.property('selectedItem').cacheable(), 295 296 /** 297 Returns an array of normalized display items. 298 299 Adds the empty name to the items if applicable. 300 301 `displayItems` should never be set directly; instead, set `items` and 302 `displayItems` will update automatically. 303 304 @type Array 305 @returns {Array} array of display items. 306 @isReadOnly 307 */ 308 displayItems: function () { 309 var items = this.get('items'), 310 emptyName = this.get('emptyName'), 311 len, 312 ret = [], idx, item, itemType; 313 314 if (!items) len = 0; 315 else len = items.get('length'); 316 317 for (idx = 0; idx < len; idx++) { 318 item = items.objectAt(idx); 319 320 // fast track out if we can't do anything with this item 321 if (!item || (!ret.length && item[this.get('itemSeparatorKey')])) continue; 322 323 itemType = SC.typeOf(item); 324 if (itemType === SC.T_STRING) { 325 item = this._addDisplayItem(item, item); 326 } else if (itemType === SC.T_HASH) { 327 item = SC.Object.create(item); 328 } 329 item.contentIndex = idx; 330 331 ret.push(item); 332 } 333 334 ret = this.sortObjects(ret); 335 336 if (emptyName) { 337 if (len) ret.unshift(this._addDisplayItem(null, null, true)); 338 ret.unshift(this._addDisplayItem(emptyName, null)); 339 } 340 341 return ret; 342 }.property().cacheable(), 343 344 /** @private */ 345 _scsv_itemsDidChange: function () { 346 this.notifyPropertyChange('displayItems'); 347 }.observes('*items.[]'), 348 349 /** @private */ 350 _addDisplayItem: function (title, value, isSeparator) { 351 var item = SC.Object.create({ 352 isDisplayItem: true 353 }); 354 355 item[this.get('_itemTitleKey')] = title; 356 item[this.get('_itemValueKey')] = value; 357 item[this.get('_itemIsEnabledKey')] = true; 358 item[this.get('itemSeparatorKey')] = !!isSeparator; 359 360 return item; 361 }, 362 363 /** 364 365 override this method to implement your own sorting of the menu. By 366 default, menu items are sorted using the value shown or the sortKey 367 368 @param {SC.Array} objects the unsorted array of objects to display. 369 @returns {SC.Array} sorted array of objects 370 */ 371 sortObjects: function (objects) { 372 if (!this.get('disableSort')) { 373 var nameKey = this.get('itemSortKey') || this.get('_itemTitleKey'); 374 objects = objects.sort(function(a, b) { 375 if (nameKey) { 376 a = a.get ? a.get(nameKey) : a[nameKey]; 377 b = b.get ? b.get(nameKey) : b[nameKey]; 378 } 379 return (a<b) ? -1 : ((a>b) ? 1 : 0); 380 }); 381 } 382 return objects; 383 }, 384 385 /** 386 * When the value changes, we need to update selectedItem. 387 * @private 388 */ 389 _scsv_valueDidChange: function() { 390 var displayItems = this.get('displayItems'); 391 if (!displayItems) return; 392 393 var len = displayItems.get ? displayItems.get('length') : displayItems.length, 394 idx, item; 395 396 for (idx = 0; idx < len; idx++) { 397 item = displayItems.objectAt(idx); 398 399 if (this.isValueEqualTo(item)) { 400 this.setIfChanged('selectedItem', item); 401 return; 402 } 403 } 404 405 // if we got here, this means no item is selected 406 this.setIfChanged('selectedItem', null); 407 }.observes('value', 'displayItems'), 408 409 /** 410 Check is the passed item is equal to the current value. 411 412 @param {Object} object to check 413 @returns {Boolean} 414 */ 415 isValueEqualTo: function(item) { 416 var a = this.get('value'), 417 b = this._scsv_getValueForMenuItem(item); 418 419 return a === b; 420 }, 421 422 /** 423 SelectView must set the selectView property on the menu so that the menu's 424 properties get bound to the SelectView's. The bindings get set up by 425 the SelectViewMenu mixin, which should be mixed in to any SelectView menu. 426 427 In addition, the initial selected item and the initial minimum menu width are set. 428 @private 429 */ 430 createMenu: function(klass) { 431 var attrs = { 432 selectView: this, 433 selectedItem: this.get('selectedItem'), 434 minimumMenuWidth: this.get('minimumMenuWidth'), 435 escapeHTML: this.get('escapeHTML'), 436 localize: this.get('localize') 437 }; 438 439 return klass.create(attrs); 440 }, 441 442 /** 443 The amount by which to offset the menu's left position when displaying it. 444 This can be used to make sure the selected menu item is directly on top of 445 the label in the SelectView. 446 447 By default, this comes from the render delegate's menuLeftOffset property. 448 If you are writing a theme, you should set the value there. 449 450 @property 451 @type Number 452 @default 'menuLeftOffset' from render delegate if present, or 0. 453 */ 454 menuLeftOffset: SC.propertyFromRenderDelegate('menuLeftOffset', 0), 455 456 /** 457 The amount by which to offset the menu's top position when displaying it. 458 This is added to any amount calculated based on the 'top' of a menu item. 459 460 This can be used to make sure the selected menu item's label is directly on 461 top of the SelectView's label. 462 463 By default, this comes from the render delegate's menuTopOffset property. 464 If you are writing a theme, you should set the value there. 465 466 @property 467 @type Number 468 @default 'menuTopOffset' from render delegate if present, or 0. 469 */ 470 menuTopOffset: SC.propertyFromRenderDelegate('menuTopOffset', 0), 471 472 /** 473 An amount to add to the menu's minimum width. For instance, this could be 474 set to a negative value to let arrows on the side of the SelectView be visible. 475 476 By default, this comes from the render delegate's menuMinimumWidthOffset property. 477 If you are writing a theme, you should set the value there. 478 479 @property 480 @type Number 481 @default 'menuWidthOffset' from render delegate if present, or 0. 482 */ 483 menuMinimumWidthOffset: SC.propertyFromRenderDelegate('menuMinimumWidthOffset', 0), 484 485 /** 486 The prefer matrix for menu positioning. It is calculated so that the selected 487 menu item is positioned directly over the SelectView. 488 489 @property 490 @type Array 491 @private 492 */ 493 menuPreferMatrix: function() { 494 var menu = this.get('menu'), 495 leftPosition = this.get('menuLeftOffset'), 496 topPosition = this.get('menuTopOffset'); 497 498 if (!menu) { 499 return [leftPosition, topPosition, 2]; 500 } 501 502 var idx = this.get('_selectedItemIndex'), itemViews = menu.get('menuItemViews'); 503 if (idx > -1) { 504 var layout = itemViews[idx].get('layout'); 505 return [leftPosition, topPosition - layout.top + (layout.height/2), 2]; 506 } 507 508 return [leftPosition, topPosition, 2]; 509 510 }.property('value', 'menu').cacheable(), 511 512 /** 513 Used to calculate things like the menu's top position. 514 515 @private 516 */ 517 _selectedItemIndex: function() { 518 var menu = this.get('menu'); 519 if (!menu) { 520 return -1; 521 } 522 523 // We have to find the selected item, and then get its 'top' position so we 524 // can position the menu correctly. 525 var itemViews = menu.get('menuItemViews'), 526 len = itemViews.length, 527 idx, view; 528 529 for (idx = 0; idx < len; idx++) { 530 view = itemViews[idx]; 531 532 if (this.isValueEqualTo(view.get('content'))) break; 533 } 534 535 if (idx < len) { 536 return idx; 537 } 538 539 return -1; 540 }.property('value', 'menu').cacheable(), 541 542 /** 543 The minimum width for the child menu. For instance, this property can make the 544 menu always cover the entire SelectView--or, alternatively, cover all but the 545 arrows on the side. 546 547 By default, it is calculated by adding the menuMinimumWidthOffset to the view's 548 width. If you are writing a theme and want to change the width so the menu covers 549 a specific part of the select view, change your render delegate's menuMinimumWidthOffset 550 property. 551 552 @type Number 553 @property 554 */ 555 minimumMenuWidth: function() { 556 return this.get('frame').width + this.get('menuMinimumWidthOffset'); 557 }.property('frame', 'menuMinimumWidthOffset').cacheable(), 558 559 // 560 // KEY HANDLING 561 // 562 /** 563 @private 564 565 Handle Key event - Down arrow key 566 */ 567 keyDown: function(event) { 568 if ( this.interpretKeyEvents(event) ) { 569 return YES; 570 } 571 else { 572 sc_super(); 573 } 574 }, 575 576 /** 577 @private 578 Pressing the Up or Down arrow key should display the menu pane. Pressing escape should 579 resign first responder. 580 */ 581 moveUp: function(evt) { 582 this._action(); 583 return YES; 584 }, 585 /** @private */ 586 moveDown: function(evt) { 587 this._action(); 588 return YES; 589 }, 590 cancel: function(evt) { 591 this.resignFirstResponder(); 592 }, 593 594 /** @private 595 Function overridden - tied to the isEnabled state 596 */ 597 acceptsFirstResponder: function() { 598 return this.get('isEnabled'); 599 }.property('isEnabled').cacheable() 600 601 }); 602