1 // ========================================================================== 2 // Project: SproutCore - JavaScript Application Framework 3 // Copyright: ©2006-2011 Strobe Inc. and contributors. 4 // Portions ©2008-2011 Apple Inc. All rights reserved. 5 // License: Licensed under MIT license (see license.js) 6 // ========================================================================== 7 8 sc_require('views/segment'); 9 10 /** 11 @class 12 13 SegmentedView is a special type of button that can display multiple 14 segments. Each segment has a value assigned to it. When the user clicks 15 on the segment, the value of that segment will become the new value of 16 the control. 17 18 You can also optionally configure a target/action that will fire whenever 19 the user clicks on an item. This will give your code an opportunity to take 20 some action depending on the new value. (of course, you can always bind to 21 the value as well, which is generally the preferred approach.) 22 23 # Defining Your Segments 24 25 You define your segments by providing a items array, much like you provide 26 to a RadioView. Your items array can be as simple as an array of strings 27 or as complex as full model objects. Based on how you configure your 28 itemKey properties, the segmented view will read the properties it needs 29 from the array and construct the button. 30 31 You can define the following properties on objects you pass in: 32 33 - *itemTitleKey* - the title of the button 34 - *itemValueKey* - the value of the button 35 - *itemWidthKey* - the preferred width. if omitted, it autodetects 36 - *itemIconKey* - an icon 37 - *itemActionKey* - an optional action to fire when pressed 38 - *itemTargetKey* - an optional target for the action 39 - *itemLayerIdKey* - an optional target for the action 40 - *segmentViewClass* - class to be used for creating segments 41 42 @extends SC.View 43 @extends SC.Control 44 @since SproutCore 1.0 45 */ 46 SC.SegmentedView = SC.View.extend(SC.Control, 47 /** @scope SC.SegmentedView.prototype */ { 48 49 /** @private 50 @ field 51 @type Boolean 52 @default YES 53 */ 54 acceptsFirstResponder: function () { 55 if (SC.FOCUS_ALL_CONTROLS) { return this.get('isEnabledInPane'); } 56 return NO; 57 }.property('isEnabledInPane').cacheable(), 58 59 /** @private 60 @type String 61 @default 'tablist' 62 @readOnly 63 */ 64 //ariaRole: 'tablist', 65 ariaRole: 'group', // workaround for <rdar://problem/10444670>; switch back to 'tablist' later with <rdar://problem/10463928> (also see segment.js) 66 67 /** @private 68 @type Array 69 @default ['sc-segmented-view'] 70 @see SC.View#classNames 71 */ 72 classNames: ['sc-segmented-view'], 73 74 /** 75 @type String 76 @default 'square' 77 @see SC.ButtonView#theme 78 */ 79 theme: 'square', 80 81 /** 82 The value of the segmented view. 83 84 The SegmentedView's value will always be the value of the currently 85 selected button or buttons. Setting this value will change the selected 86 button or buttons. 87 88 If you set this value to something that has no matching button, then 89 no buttons will be selected. 90 91 Note: if allowsMultipleSelection is set to true, then the value must be 92 an Array. 93 94 @type Object | Array 95 @default null 96 */ 97 value: null, 98 99 /** 100 If YES, clicking a selected button again will deselect it, setting the 101 segmented views value to null. 102 103 @type Boolean 104 @default NO 105 */ 106 allowsEmptySelection: NO, 107 108 /** 109 If YES, then clicking on a tab will not deselect the other segments, it 110 will simply add or remove it from the selection. 111 112 @type Boolean 113 @default NO 114 */ 115 allowsMultipleSelection: NO, 116 117 /** 118 If YES, it will set the segment value even if an action is defined. 119 120 @type Boolean 121 @default NO 122 */ 123 selectSegmentWhenTriggeringAction: NO, 124 125 /** 126 @type Boolean 127 @default YES 128 */ 129 localize: YES, 130 131 /** 132 Aligns the segments of the segmented view within its frame horizontally. 133 Possible values: 134 135 - SC.ALIGN_LEFT 136 - SC.ALIGN_RIGHT 137 - SC.ALIGN_CENTER 138 139 @type String 140 @default SC.ALIGN_CENTER 141 */ 142 align: SC.ALIGN_CENTER, 143 144 /** 145 Change the layout direction to make this a vertical set of tabs instead 146 of horizontal ones. Possible values: 147 148 - SC.LAYOUT_HORIZONTAL 149 - SC.LAYOUT_VERTICAL 150 151 @type String 152 @default SC.LAYOUT_HORIZONTAL 153 */ 154 layoutDirection: SC.LAYOUT_HORIZONTAL, 155 156 157 // .......................................................... 158 // SEGMENT DEFINITION 159 // 160 161 /** 162 The array of items to display. This may be a simple array of strings, objects 163 or SC.Objects. If you pass objects or SC.Objects, you must also set the 164 various itemKey properties to tell the SegmentedView how to extract the 165 information it needs. 166 167 Note: only SC.Object items support key-value coding and therefore may be 168 observed by the view for changes to titles, values, icons, widths, 169 isEnabled values & tooltips. 170 171 @type Array 172 @default null 173 */ 174 items: null, 175 176 /** 177 The key that contains the title for each item. 178 179 @type String 180 @default null 181 */ 182 itemTitleKey: null, 183 184 /** 185 The key that contains the value for each item. 186 187 @type String 188 @default null 189 */ 190 itemValueKey: null, 191 192 /** 193 A key that determines if this item in particular is enabled. Note if the 194 control in general is not enabled, no items will be enabled, even if the 195 item's enabled property returns YES. 196 197 @type String 198 @default null 199 */ 200 itemIsEnabledKey: null, 201 202 /** 203 The key that contains the icon for each item. If omitted, no icons will 204 be displayed. 205 206 @type String 207 @default null 208 */ 209 itemIconKey: null, 210 211 /** 212 The key that contains the desired width for each item. If omitted, the 213 width will autosize. 214 215 @type String 216 @default null 217 */ 218 itemWidthKey: null, 219 220 /** 221 The key that contains the action for this item. If defined, then 222 selecting this item will fire the action in addition to changing the 223 value. See also itemTargetKey. 224 225 @type String 226 @default null 227 */ 228 itemActionKey: null, 229 230 /** 231 The key that contains the target for this item. If this and itemActionKey 232 are defined, then this will be the target of the action fired. 233 234 @type String 235 @default null 236 */ 237 itemTargetKey: null, 238 239 /** 240 The key that contains the layerId for each item. 241 @type String 242 */ 243 itemLayerIdKey: null, 244 245 /** 246 The key that contains the key equivalent for each item. If defined then 247 pressing that key equivalent will be like selecting the tab. Also, 248 pressing the Alt or Option key for 3 seconds will display the key 249 equivalent in the tab. 250 251 @type String 252 @default null 253 */ 254 itemKeyEquivalentKey: null, 255 256 /** 257 If YES, overflowing items are placed into a menu and an overflow segment is 258 added to popup that menu. 259 260 @type Boolean 261 @default YES 262 */ 263 shouldHandleOverflow: YES, 264 265 /** 266 The title to use for the overflow segment if it appears. 267 268 NOTE: This will not be HTML escaped and must never be assigned to user inserted text! 269 270 @type String 271 @default '»' 272 */ 273 overflowTitle: '»', 274 275 /** 276 The toolTip to use for the overflow segment if it appears. 277 278 @type String 279 @default 'More…' 280 */ 281 overflowToolTip: 'More…', 282 283 /** 284 The icon to use for the overflow segment if it appears. 285 286 @type String 287 @default null 288 */ 289 overflowIcon: null, 290 291 /** 292 The view class used when creating segments. 293 294 @type SC.View 295 @default SC.SegmentView 296 */ 297 segmentViewClass: SC.SegmentView, 298 299 /** 300 Set to YES if you would like your SegmentedView to size itself based on its 301 visible segments. Useful if you're using SegmentedView in a flowed context 302 (for example if its parent view has `childViewLayout: SC.View.HORIZONTAL_STACK`). 303 304 The view will not auto-resize unless you define an initial value for the layout 305 property which will be auto-resized (i.e. `width` when in the default horizontal 306 orientation). This is to prevent the view from inappropriately adding width to a 307 flexible (`{ left: 0, right: 0 }`) layout. 308 309 Has no effect if `shouldHandleOverflow` is NO. 310 311 @type Boolean 312 @default NO 313 */ 314 shouldAutoResize: NO, 315 316 /** @private 317 The following properties are used to map items to child views. Item keys 318 are looked up on the item based on this view's value for each 'itemKey'. 319 If a value in the item is found, then that value is mapped to a child 320 view using the matching viewKey. 321 322 @type Array 323 */ 324 itemKeys: ['itemTitleKey', 'itemValueKey', 'itemIsEnabledKey', 'itemIconKey', 'itemWidthKey', 'itemToolTipKey', 'itemKeyEquivalentKey', 'itemLayerIdKey'], 325 326 /** @private */ 327 viewKeys: ['title', 'value', 'isEnabled', 'icon', 'width', 'toolTip', 'keyEquivalent', 'layerId'], 328 329 /** @private 330 Call itemsDidChange once to initialize segment child views for the items that exist at 331 creation time. 332 */ 333 init: function () { 334 sc_super(); 335 336 // Initialize. 337 this.shouldHandleOverflowDidChange(); 338 this.itemsDidChange(); 339 }, 340 341 shouldHandleOverflowDidChange: function () { 342 var overflowView = this.get('overflowView'); 343 344 if (this.get('shouldHandleOverflow')) { 345 var title = this.get('overflowTitle'), 346 toolTip = this.get('overflowToolTip'), 347 icon = this.get('overflowIcon'); 348 349 overflowView = this.get('segmentViewClass').create({ 350 controlSize: this.get('controlSize'), 351 escapeHTML: false, 352 localize: this.get('localize'), 353 title: title, 354 toolTip: toolTip, 355 icon: icon, 356 isLastSegment: YES, 357 isOverflowSegment: YES, 358 layoutDirection: this.get('layoutDirection') 359 }); 360 this.appendChild(overflowView); 361 this.set('overflowView', overflowView); 362 363 // remeasure should show/hide it as needed 364 this.invokeLast(this.remeasure); 365 } else { 366 if (overflowView) { // There will not be an overflow view on initialization. 367 this.removeChildAndDestroy(overflowView); 368 this.set('overflowView', null); 369 } 370 } 371 }.observes('shouldHandleOverflow'), 372 373 /** @private 374 Called whenever the number of items changes. This method populates SegmentedView's childViews, taking 375 care to re-use existing childViews if possible. 376 */ 377 itemsDidChange: function () { 378 var items = this.get('items') || [], 379 localItem, // Used to avoid altering the original items 380 previousItem, 381 childViews = this.get('childViews'), 382 childView, 383 overflowView = this.get('overflowView'), 384 value = this.get('value'), // The value can change if items that were once selected are removed 385 isSelected, 386 itemKeys = this.get('itemKeys'), 387 itemKey, 388 segmentViewClass = this.get('segmentViewClass'), 389 i, j; 390 391 // Update childViews 392 var childViewsLength = this.get('shouldHandleOverflow') ? childViews.get('length') - 1 : childViews.get('length'); 393 if (childViewsLength > items.get('length')) { // We've lost segments (ie. childViews) 394 395 // Remove unneeded segments from the end back 396 for (i = childViewsLength - 1; i >= items.get('length'); i--) { 397 childView = childViews.objectAt(i); 398 localItem = childView.get('localItem'); 399 400 // Remove observers from items we are losing off the end 401 if (localItem instanceof SC.Object) { 402 403 for (j = itemKeys.get('length') - 1; j >= 0; j--) { 404 itemKey = this.get(itemKeys.objectAt(j)); 405 406 if (itemKey) { 407 localItem.removeObserver(itemKey, this, this.itemContentDidChange); 408 } 409 } 410 } 411 412 // If a selected childView has been removed then update our value 413 if (SC.isArray(value)) { 414 value.removeObject(localItem); 415 } else if (value === localItem) { 416 value = null; 417 } 418 419 this.removeChildAndDestroy(childView); 420 } 421 422 // Update our value which may have changed 423 this.set('value', value); 424 425 } else if (childViewsLength < items.get('length')) { // We've gained segments 426 427 // Create the new segments 428 for (i = childViewsLength; i < items.get('length'); i++) { 429 430 // We create a default SC.ButtonView-like object for each segment 431 childView = segmentViewClass.create({ 432 controlSize: this.get('controlSize'), 433 localize: this.get('localize'), 434 layoutDirection: this.get('layoutDirection') 435 }); 436 437 // Attach the child 438 this.insertBefore(childView, overflowView); 439 } 440 } 441 442 // Because the items array can be altered with insertAt or removeAt, we can't be sure that the items 443 // continue to match 1-to-1 the existing views, so once we have the correct number of childViews, 444 // simply update them all 445 childViews = this.get('childViews'); 446 447 for (i = 0; i < items.get('length'); i++) { 448 localItem = items.objectAt(i); 449 childView = childViews.objectAt(i); 450 previousItem = childView.get('localItem'); 451 452 if (previousItem instanceof SC.Object && !items.contains(previousItem)) { 453 // If the old item is no longer in the view, remove its observers 454 for (j = itemKeys.get('length') - 1; j >= 0; j--) { 455 itemKey = this.get(itemKeys.objectAt(j)); 456 457 if (itemKey) { 458 previousItem.removeObserver(itemKey, this, this.itemContentDidChange); 459 } 460 } 461 } 462 463 // Skip null/undefined items (but don't skip empty strings) 464 if (SC.none(localItem)) continue; 465 466 // Normalize the item (may be a String, Object or SC.Object) 467 if (SC.typeOf(localItem) === SC.T_STRING) { 468 469 localItem = SC.Object.create({ 470 'title': localItem.humanize().titleize(), 471 'value': localItem 472 }); 473 474 // Update our keys accordingly 475 this.set('itemTitleKey', 'title'); 476 this.set('itemValueKey', 'value'); 477 } else if (SC.typeOf(localItem) === SC.T_HASH) { 478 479 localItem = SC.Object.create(localItem); 480 } else if (localItem instanceof SC.Object) { 481 482 // We don't need to make any changes to SC.Object items, but we can observe them 483 for (j = itemKeys.get('length') - 1; j >= 0; j--) { 484 itemKey = this.get(itemKeys.objectAt(j)); 485 486 if (itemKey) { 487 localItem.removeObserver(itemKey, this, this.itemContentDidChange); 488 localItem.addObserver(itemKey, this, this.itemContentDidChange, i); 489 } 490 } 491 } else { 492 SC.Logger.error('SC.SegmentedView items may be Strings, Objects (ie. Hashes) or SC.Objects only'); 493 } 494 495 // Determine whether this segment is selected based on the view's existing value(s) 496 isSelected = NO; 497 if (SC.isArray(value) ? value.indexOf(localItem.get(this.get('itemValueKey'))) >= 0 : value === localItem.get(this.get('itemValueKey'))) { 498 isSelected = YES; 499 } 500 childView.set('isSelected', isSelected); 501 502 // Assign segment specific properties based on position 503 childView.set('index', i); 504 childView.set('isFirstSegment', i === 0); 505 childView.set('isMiddleSegment', i < items.get('length') - 1 && i > 0); 506 childView.set('isLastSegment', i === items.get('length') - 1); 507 508 // Be sure to update the view's properties for the (possibly new) matched item 509 childView.updateItem(this, localItem); 510 } 511 512 // Force a segment remeasure to check overflow 513 if (this.get('shouldHandleOverflow')) { 514 this.invokeLast(this.remeasure); 515 } 516 }.observes('*items.[]'), 517 518 /** @private 519 This observer method is called whenever any of the relevant properties of an item change. This only applies 520 to SC.Object based items that may be observed. 521 */ 522 itemContentDidChange: function (item, key, alwaysNull, index) { 523 var childViews = this.get('childViews'), 524 childView; 525 526 childView = childViews.objectAt(index); 527 if (childView) { 528 529 // Update the childView 530 childView.updateItem(this, item); 531 532 // Reset our measurements (which depend on width/height or title) and adjust visible views 533 if (this.get('shouldHandleOverflow')) { 534 this.invokeLast(this.remeasure); 535 } 536 } 537 }, 538 539 /** @private 540 Whenever the view resizes, we need to check to see if we're overflowing. 541 */ 542 viewDidResize: function () { 543 this._sc_viewFrameDidChange(); 544 545 var isHorizontal = this.get('layoutDirection') === SC.LAYOUT_HORIZONTAL, 546 visibleDim = isHorizontal ? this.$().width() : this.$().height(); 547 548 // Only overflow if we've gone below the minimum dimension required to fit all the segments 549 if (this.get('shouldHandleOverflow') && (this.get('isOverflowing') || visibleDim <= this.cachedMinimumDim)) { 550 this.invokeLast(this.remeasure); 551 } 552 }, 553 554 /** @private 555 Whenever visibility changes, we need to check to see if we're overflowing. 556 */ 557 isVisibleInWindowDidChange: function () { 558 if (this.get('shouldHandleOverflow')) { 559 this.invokeLast(this.remeasure); 560 } 561 }.observes('isVisibleInWindow'), 562 563 /** @private 564 Calling this method forces the segments to be remeasured and will also adjust the 565 segments for overflow if necessary. 566 */ 567 remeasure: function () { 568 if (!this.get('shouldHandleOverflow')) { return; } 569 570 var childViews = this.get('childViews'), 571 overflowView; 572 573 if (this.get('isVisibleInWindow')) { 574 // Make all the views visible so that they can be measured 575 overflowView = this.get('overflowView'); 576 overflowView.set('isVisible', YES); 577 578 for (var i = childViews.get('length') - 1; i >= 0; i--) { 579 childViews.objectAt(i).set('isVisible', YES); 580 } 581 582 this.cachedDims = this.segmentDimensions(); 583 this.cachedOverflowDim = this.overflowSegmentDim(); 584 585 this.adjustOverflow(); 586 } 587 }, 588 589 /** @private 590 This method is called to adjust the segment views to see if we need to handle for overflow. 591 */ 592 adjustOverflow: function () { 593 if (!this.get('shouldHandleOverflow')) { return; } 594 595 var childViews = this.get('childViews'), 596 childView, 597 value = this.get('value'), 598 overflowView = this.get('overflowView'), 599 isHorizontal = this.get('layoutDirection') === SC.LAYOUT_HORIZONTAL, 600 visibleDim = isHorizontal ? this.$().width() : this.$().height(), // The inner width/height of the div 601 curElementsDim = 0, 602 dimToFit, length, i, 603 isOverflowing = NO, 604 wantsAutoResize = this.get('shouldAutoResize'), 605 layoutProperty = isHorizontal ? 'width' : 'height', 606 canAutoResize = !SC.none(this.getPath('layout.%@'.fmt(layoutProperty))), 607 willAutoResize = wantsAutoResize && canAutoResize; 608 609 // If child views and cachedDims lengths are out of sync here, it means adjustOverflow 610 // got called in between itemsDidChange and remeasure. Since we know that the remeasure is 611 // scheduled, just return and let the remeasure + adjustOverflow happen later. 612 if (childViews.get('length') !== this.cachedDims.length + 1) { return; } 613 614 // This variable is useful to optimize when we are overflowing 615 isOverflowing = NO; 616 overflowView.set('isSelected', NO); 617 618 // Clear out the overflow items (these are the items not currently visible) 619 this.overflowItems = []; 620 621 length = this.cachedDims.length; 622 for (i = 0; i < length; i++) { 623 childView = childViews.objectAt(i); 624 curElementsDim += this.cachedDims[i]; 625 626 // Check and see if this item kicks us over into overflow. 627 if (!isOverflowing && !willAutoResize) { 628 // (don't leave room for the overflow segment on the last item) 629 dimToFit = (i === length - 1) ? curElementsDim : curElementsDim + this.cachedOverflowDim; 630 if (dimToFit > visibleDim) isOverflowing = YES; 631 } 632 633 // Update the view depending on overflow state. 634 if (isOverflowing) { 635 // Add the localItem to the overflowItems 636 this.overflowItems.pushObject(childView.get('localItem')); 637 638 childView.set('isVisible', NO); 639 640 // If the first item is already overflowed, make the overflowView first segment 641 if (i === 0) overflowView.set('isFirstSegment', YES); 642 643 // If the overflowed segment was selected, show the overflowView as selected instead 644 if (SC.isArray(value) ? value.indexOf(childView.get('value')) >= 0 : value === childView.get('value')) { 645 overflowView.set('isSelected', YES); 646 } 647 } else { 648 childView.set('isVisible', YES); 649 650 // If the first item is not overflowed, don't make the overflowView first segment 651 if (i === 0) overflowView.set('isFirstSegment', NO); 652 } 653 } 654 655 // Show/hide the overflow view as needed. 656 overflowView.set('isVisible', isOverflowing); 657 658 // Set the overflowing property. 659 this.setIfChanged('isOverflowing', isOverflowing); 660 661 // Autosize if needed. 662 if (willAutoResize) { 663 this.adjust(layoutProperty, this.isOverflowing ? this.cachedMinimumDim : curElementsDim); 664 } 665 666 // Store the minimum dimension (height/width) before overflow 667 this.cachedMinimumDim = curElementsDim + this.cachedOverflowDim; 668 }, 669 670 /** 671 Return the dimensions (either heights or widths depending on the layout direction) of the DOM 672 elements of the segments. This will be measured by the view to determine which segments should 673 be overflowed. 674 675 It ignores the last segment (the overflow segment). 676 */ 677 segmentDimensions: function () { 678 var cv = this.get('childViews'), 679 v, f, 680 dims = [], 681 isHorizontal = this.get('layoutDirection') === SC.LAYOUT_HORIZONTAL; 682 683 for (var i = 0, length = cv.length; i < length - 1; i++) { 684 v = cv[i]; 685 f = v.get('frame'); 686 dims[i] = isHorizontal ? f.width : f.height; 687 } 688 689 return dims; 690 }, 691 692 /** 693 Return the dimension (height or width depending on the layout direction) over the overflow segment. 694 */ 695 overflowSegmentDim: function () { 696 var cv = this.get('childViews'), 697 v, f, 698 isHorizontal = this.get('layoutDirection') === SC.LAYOUT_HORIZONTAL; 699 700 v = cv.length && cv[cv.length - 1]; 701 if (v) { 702 f = v.get('frame'); 703 return isHorizontal ? f.width : f.height; 704 } 705 706 return 0; 707 }, 708 709 /** 710 Return the index of the segment view that is the target of the mouse click. 711 */ 712 indexForClientPosition: function (x, y) { 713 var cv = this.get('childViews'), 714 length, i, 715 v, rect, 716 point; 717 718 point = { x: x, y: y }; 719 for (i = 0, length = cv.length; i < length; i++) { 720 v = cv[i]; 721 722 rect = v.get('layer').getBoundingClientRect(); 723 rect = { 724 x: rect.left, 725 y: rect.top, 726 width: (rect.right - rect.left), 727 height: (rect.bottom - rect.top) 728 }; 729 730 // Return the index early if found 731 if (SC.pointInRect(point, rect)) return i; 732 } 733 734 // Default not found 735 return -1; 736 }, 737 738 // .......................................................... 739 // RENDERING/DISPLAY SUPPORT 740 // 741 742 /** 743 @type Array 744 @default ['align'] 745 @see SC.View#displayProperties 746 */ 747 displayProperties: ['align'], 748 749 /** 750 @type String 751 @default 'segmentedRenderDelegate' 752 */ 753 renderDelegateName: 'segmentedRenderDelegate', 754 755 // .......................................................... 756 // EVENT HANDLING 757 // 758 759 /** @private 760 Determines the index into the displayItems array where the passed mouse 761 event occurred. 762 */ 763 displayItemIndexForEvent: function (evt) { 764 var el = evt.target, 765 x = evt.clientX, 766 y = evt.clientY, 767 ret = -1; 768 769 if (el && el !== this.get('layer')) { 770 // Accessibility workaround: WebKit sends all event coords as 0,0 for all AXPress-triggered events. 771 // For example, triggering an element with VoiceOver in OS X. 772 // Note: by ensuring that the event target wasn't our own layer, we avoid the situation where an 773 // actual mouse clicked at 0,0 and hit only our layer. 774 if (x === 0 && y === 0) { 775 var offset = SC.offset(el); 776 777 // Generate point coordinates in the middle of the target element. 778 x = offset.x + Math.round(el.offsetWidth / 2); 779 y = offset.y + Math.round(el.offsetHeight / 2); 780 } 781 782 var renderDelegate = this.get('renderDelegate'); 783 if (renderDelegate && renderDelegate.indexForClientPosition) { 784 ret = renderDelegate.indexForClientPosition(this, x, y); 785 } else { 786 ret = this.indexForClientPosition(x, y); 787 } 788 } 789 790 return ret; 791 }, 792 793 /** @private */ 794 keyDown: function (evt) { 795 var childViews, 796 childView, 797 i, length, 798 value, isArray; 799 800 // handle tab key 801 if (evt.which === 9 || evt.keyCode === 9) { 802 var view = evt.shiftKey ? this.get('previousValidKeyView') : this.get('nextValidKeyView'); 803 if (view) view.becomeFirstResponder(); 804 else evt.allowDefault(); 805 return YES; // handled 806 } 807 808 // handle arrow keys 809 if (!this.get('allowsMultipleSelection')) { 810 childViews = this.get('childViews'); 811 812 length = childViews.get('length'); 813 value = this.get('value'); 814 isArray = SC.isArray(value); 815 816 // Select from the left to the right 817 if (evt.which === 39 || evt.which === 40) { 818 819 if (value) { 820 for (i = 0; i < length - 2; i++) { 821 childView = childViews.objectAt(i); 822 if (isArray ? (value.indexOf(childView.get('value')) >= 0) : (childView.get('value') === value)) { 823 this.triggerItemAtIndex(i + 1); 824 } 825 } 826 } else { 827 this.triggerItemAtIndex(0); 828 } 829 return YES; // handled 830 831 // Select from the right to the left 832 } else if (evt.which === 37 || evt.which === 38) { 833 834 if (value) { 835 for (i = 1; i < length - 1; i++) { 836 childView = childViews.objectAt(i); 837 if (isArray ? (value.indexOf(childView.get('value')) >= 0) : (childView.get('value') === value)) { 838 this.triggerItemAtIndex(i - 1); 839 } 840 } 841 } else { 842 this.triggerItemAtIndex(length - 2); 843 } 844 845 return YES; // handled 846 } 847 } 848 849 return NO; 850 }, 851 852 /** @private */ 853 mouseDown: function (evt) { 854 // Fast path, reject secondary clicks. 855 if (evt.which !== 1) return false; 856 857 var childViews = this.get('childViews'), 858 childView, 859 index; 860 861 if (!this.get('isEnabledInPane')) return YES; // nothing to do // TODO: return NO? 862 863 index = this.displayItemIndexForEvent(evt); 864 if (index >= 0) { 865 childView = childViews.objectAt(index); 866 if (childView.get('isEnabled')) childView.set('isActive', YES); 867 this.activeChildView = childView; 868 869 // if mouse was pressed on the overflow segment, popup the menu 870 var overflowIndex = this.get('shouldHandleOverflow') ? childViews.get('length') - 1 : null; 871 if (index === overflowIndex) this.showOverflowMenu(); 872 else this._isMouseDown = YES; 873 874 return YES; 875 } 876 // If this event originated outside of a segment, pass the event along up. 877 else { 878 return NO; 879 } 880 }, 881 882 /** @private */ 883 mouseUp: function (evt) { 884 var activeChildView, 885 index; 886 887 index = this.displayItemIndexForEvent(evt); 888 if (this._isMouseDown && (index >= 0)) { 889 890 this.triggerItemAtIndex(index); 891 892 // Clean up 893 activeChildView = this.activeChildView; 894 if (activeChildView) { 895 activeChildView.set('isActive', NO); 896 this.activeChildView = null; 897 } 898 } 899 900 this._isMouseDown = NO; 901 return YES; 902 }, 903 904 /** @private */ 905 mouseMoved: function (evt) { 906 var childViews = this.get('childViews'), 907 activeChildView, 908 childView, 909 index; 910 911 if (this._isMouseDown) { 912 // Update the last segment 913 index = this.displayItemIndexForEvent(evt); 914 915 activeChildView = this.activeChildView; 916 childView = childViews.objectAt(index); 917 918 if (childView && childView !== activeChildView) { 919 // Changed 920 if (activeChildView) activeChildView.set('isActive', NO); 921 if (childView.get('isEnabled')) childView.set('isActive', YES); 922 this.activeChildView = childView; 923 924 var overflowIndex = this.get('shouldHandleOverflow') ? childViews.get('length') - 1 : null; 925 if (index === overflowIndex) { 926 this.showOverflowMenu(); 927 this._isMouseDown = NO; 928 } 929 } 930 } 931 return YES; 932 }, 933 934 /** @private */ 935 mouseEntered: function (evt) { 936 var childViews = this.get('childViews'), 937 childView, 938 index; 939 940 // if mouse was pressed down initially, start detection again 941 if (this._isMouseDown) { 942 index = this.displayItemIndexForEvent(evt); 943 944 // if mouse was pressed on the overflow segment, popup the menu 945 var overflowIndex = this.get('shouldHandleOverflow') ? childViews.get('length') - 1 : null; 946 if (index === overflowIndex) { 947 this.showOverflowMenu(); 948 this._isMouseDown = NO; 949 } else if (index >= 0) { 950 childView = childViews.objectAt(index); 951 if (childView.get('isEnabled')) childView.set('isActive', YES); 952 953 this.activeChildView = childView; 954 } 955 } 956 return YES; 957 }, 958 959 /** @private */ 960 mouseExited: function (evt) { 961 var activeChildView; 962 963 // if mouse was down, hide active index 964 if (this._isMouseDown) { 965 activeChildView = this.activeChildView; 966 if (activeChildView) activeChildView.set('isActive', NO); 967 968 this.activeChildView = null; 969 } 970 971 return YES; 972 }, 973 974 /** @private */ 975 touchStart: function (touch) { 976 var childViews = this.get('childViews'), 977 childView, 978 index; 979 980 if (!this.get('isEnabledInPane')) return YES; // nothing to do 981 982 index = this.displayItemIndexForEvent(touch); 983 984 if (index >= 0) { 985 childView = childViews.objectAt(index); 986 childView.set('isActive', YES); 987 this.activeChildView = childView; 988 989 // if touch was on the overflow segment, popup the menu 990 var overflowIndex = this.get('shouldHandleOverflow') ? childViews.get('length') - 1 : null; 991 if (index === overflowIndex) this.showOverflowMenu(); 992 else this._isTouching = YES; 993 } 994 995 return YES; 996 }, 997 998 /** @private */ 999 touchEnd: function (touch) { 1000 var activeChildView, 1001 index; 1002 1003 index = this.displayItemIndexForEvent(touch); 1004 1005 if (this._isTouching && (index >= 0)) { 1006 this.triggerItemAtIndex(index); 1007 1008 // Clean up 1009 activeChildView = this.activeChildView; 1010 activeChildView.set('isActive', NO); 1011 this.activeChildView = null; 1012 1013 this._isTouching = NO; 1014 } 1015 1016 return YES; 1017 }, 1018 1019 /** @private */ 1020 touchesDragged: function (evt, touches) { 1021 var isTouching = this.touchIsInBoundary(evt), 1022 childViews = this.get('childViews'), 1023 activeChildView, 1024 childView, 1025 index; 1026 1027 if (isTouching) { 1028 if (!this._isTouching) { 1029 this._touchDidEnter(evt); 1030 } 1031 index = this.displayItemIndexForEvent(evt); 1032 1033 activeChildView = this.activeChildView; 1034 childView = childViews[index]; 1035 1036 if (childView && childView !== activeChildView) { 1037 // Changed 1038 if (activeChildView) activeChildView.set('isActive', NO); 1039 childView.set('isActive', YES); 1040 1041 this.activeChildView = childView; 1042 1043 var overflowIndex = this.get('shouldHandleOverflow') ? childViews.get('length') - 1 : null; 1044 if (index === overflowIndex) { 1045 this.showOverflowMenu(); 1046 this._isMouseDown = NO; 1047 } 1048 } 1049 } else { 1050 if (this._isTouching) this._touchDidExit(evt); 1051 } 1052 1053 this._isTouching = isTouching; 1054 1055 return YES; 1056 }, 1057 1058 /** @private */ 1059 _touchDidExit: function (evt) { 1060 var activeChildView; 1061 1062 if (this.isTouching) { 1063 activeChildView = this.activeChildView; 1064 activeChildView.set('isActive', NO); 1065 this.activeChildView = null; 1066 } 1067 1068 return YES; 1069 }, 1070 1071 /** @private */ 1072 _touchDidEnter: function (evt) { 1073 var childViews = this.get('childViews'), 1074 childView, 1075 index; 1076 1077 index = this.displayItemIndexForEvent(evt); 1078 1079 var overflowIndex = this.get('shouldHandleOverflow') ? childViews.get('length') - 1 : null; 1080 if (index === overflowIndex) { 1081 this.showOverflowMenu(); 1082 this._isTouching = NO; 1083 } else if (index >= 0) { 1084 childView = childViews.objectAt(index); 1085 childView.set('isActive', YES); 1086 this.activeChildView = childView; 1087 } 1088 1089 return YES; 1090 }, 1091 1092 /** @private 1093 Simulates the user clicking on the segment at the specified index. This 1094 will update the value if possible and fire the action. 1095 */ 1096 triggerItemAtIndex: function (index) { 1097 var childViews = this.get('childViews'), 1098 childView, 1099 childValue, value, allowEmpty, allowMult; 1100 1101 childView = childViews.objectAt(index); 1102 1103 if (!childView.get('isEnabled')) return this; // nothing to do! 1104 1105 allowEmpty = this.get('allowsEmptySelection'); 1106 allowMult = this.get('allowsMultipleSelection'); 1107 1108 // get new value... bail if not enabled. Also save original for later. 1109 childValue = childView.get('value'); 1110 value = this.get('value'); 1111 1112 // if we do not allow multiple selection, either replace the current 1113 // selection or deselect it 1114 if (!allowMult) { 1115 // if we allow empty selection and the current value is the same as 1116 // the selected value, then deselect it. 1117 if (allowEmpty && value === childValue) { 1118 value = null; 1119 } else { 1120 // otherwise, simply replace the value. 1121 value = childValue; 1122 } 1123 } else { 1124 // Lazily create the value array. 1125 if (!value) { 1126 value = []; 1127 } else if (!SC.isArray(value)) { 1128 value = [value]; 1129 } 1130 1131 // if we do allow multiple selection, then add or remove item to the array. 1132 if (value.indexOf(childValue) >= 0) { 1133 if (value.get('length') > 1 || (value.objectAt(0) !== childValue) || allowEmpty) { 1134 value = value.without(childValue); 1135 } 1136 } else { 1137 value = value.concat(childValue); 1138 } 1139 } 1140 1141 // also, trigger target if needed. 1142 var actionKey = this.get('itemActionKey'), 1143 targetKey = this.get('itemTargetKey'), 1144 action, target = null, 1145 resp = this.getPath('pane.rootResponder'), 1146 item; 1147 1148 if (actionKey && (item = this.get('items').objectAt(index))) { 1149 // get the source item from the item array. use the index stored... 1150 action = item.get ? item.get(actionKey) : item[actionKey]; 1151 if (targetKey) { 1152 target = item.get ? item.get(targetKey) : item[targetKey]; 1153 } 1154 if (resp) resp.sendAction(action, target, this, this.get('pane'), value); 1155 } 1156 1157 if (value !== undefined && (!action || this.get('selectSegmentWhenTriggeringAction'))) { 1158 this.set('value', value); 1159 } 1160 1161 // if an action/target is defined on self use that also 1162 action = this.get('action'); 1163 if (action && resp) { 1164 resp.sendAction(action, this.get('target'), this, this.get('pane'), value); 1165 } 1166 }, 1167 1168 /** @private 1169 Invoked whenever an item is selected in the overflow menu. 1170 */ 1171 selectOverflowItem: function (menu) { 1172 var item = menu.get('selectedItem'); 1173 1174 this.triggerItemAtIndex(item.get('index')); 1175 1176 // Cleanup 1177 menu.removeObserver('selectedItem', this, 'selectOverflowItem'); 1178 1179 this.activeChildView.set('isActive', NO); 1180 this.activeChildView = null; 1181 }, 1182 1183 /** @private 1184 Presents the popup menu containing overflowed segments. 1185 */ 1186 showOverflowMenu: function () { 1187 var self = this, 1188 childViews = this.get('childViews'), 1189 itemValueKey = this.get('itemValueKey'), 1190 itemLayerIdKey = this.get('itemLayerIdKey'), 1191 overflowItems = this.overflowItems, 1192 overflowItemsLength, 1193 startIndex, 1194 isArray, 1195 value, 1196 item, 1197 layerId, 1198 layer, 1199 overflowElement; 1200 1201 // Check the currently selected item if it is in overflowItems 1202 overflowItemsLength = overflowItems.get('length'); 1203 startIndex = childViews.get('length') - 1 - overflowItemsLength; 1204 1205 value = this.get('value'); 1206 isArray = SC.isArray(value); 1207 for (var i = 0; i < overflowItemsLength; i++) { 1208 item = overflowItems.objectAt(i); 1209 1210 if (isArray ? value.indexOf(item.get(itemValueKey)) >= 0 : value === item.get(itemValueKey)) { 1211 item.set('isChecked', YES); 1212 } else { 1213 item.set('isChecked', NO); 1214 } 1215 1216 // Track the matching segment index 1217 item.set('index', startIndex + i); 1218 1219 // Add '-overflow-menu-item' to the existing layer id (if set), 1220 // and use that as the layer id on the menu. This prevents the original 1221 // segment from being removed when the menu closes. 1222 layerId = item.get(itemLayerIdKey); 1223 if (layerId) { 1224 item.set('overflowLayerId', layerId + '-overflow-menu-item'); 1225 } 1226 } 1227 1228 // TODO: we can't pass a shortcut key to the menu, because it isn't a property of SegmentedView (yet?) 1229 var menu = SC.MenuPane.create({ 1230 layout: { width: 200 }, 1231 items: overflowItems, 1232 itemTitleKey: this.get('itemTitleKey'), 1233 itemIconKey: this.get('itemIconKey'), 1234 itemIsEnabledKey: this.get('itemIsEnabledKey'), 1235 itemKeyEquivalentKey: this.get('itemKeyEquivalentKey'), 1236 itemCheckboxKey: 'isChecked', 1237 itemLayerIdKey: 'overflowLayerId', 1238 1239 // We need to be able to update our overflow segment even if the user clicks outside of the menu. Since 1240 // there is no callback method or observable property when the menu closes, override modalPaneDidClick(). 1241 modalPaneDidClick: function () { 1242 sc_super(); 1243 1244 // Cleanup 1245 this.removeObserver('selectedItem', self, 'selectOverflowItem'); 1246 1247 self.activeChildView.set('isActive', NO); 1248 self.activeChildView = null; 1249 } 1250 }); 1251 1252 layer = this.get('layer'); 1253 overflowElement = layer.childNodes[layer.childNodes.length - 1]; 1254 menu.popup(overflowElement); 1255 1256 menu.addObserver("selectedItem", this, 'selectOverflowItem'); 1257 }, 1258 1259 /** @private 1260 Whenever the value changes, update the segments accordingly. 1261 */ 1262 valueDidChange: function () { 1263 var value = this.get('value'), 1264 overflowItemsLength, 1265 childViews = this.get('childViews'), 1266 childViewsLength = this.get('shouldHandleOverflow') ? childViews.get('length') - 1 : childViews.get('length'), 1267 overflowIndex = Infinity, 1268 overflowView = this.get('overflowView'), 1269 childView; 1270 1271 // The index where childViews are all overflowed 1272 if (this.overflowItems) { 1273 overflowItemsLength = this.overflowItems.get('length'); 1274 overflowIndex = childViewsLength - overflowItemsLength; 1275 1276 // Clear out the selected value of the overflowView (if it's set) 1277 overflowView.set('isSelected', NO); 1278 } 1279 1280 for (var i = childViewsLength - 1; i >= 0; i--) { 1281 childView = childViews.objectAt(i); 1282 if (SC.isArray(value) ? value.indexOf(childView.get('value')) >= 0 : value === childView.get('value')) { 1283 childView.set('isSelected', YES); 1284 1285 // If we've gone over the overflow index, the child view is represented in overflow items 1286 if (i >= overflowIndex) overflowView.set('isSelected', YES); 1287 } else { 1288 childView.set('isSelected', NO); 1289 } 1290 } 1291 }.observes('value') 1292 1293 }); 1294