1<!-- 2@license 3Copyright (c) 2015 The Polymer Project Authors. All rights reserved. 4This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 5The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 6The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 7Code distributed by Google as part of the polymer project is also 8subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 9--> 10 11<link rel="import" href="../polymer/polymer.html"> 12<link rel="import" href="../iron-selector/iron-multi-selectable.html"> 13<link rel="import" href="../iron-a11y-keys-behavior/iron-a11y-keys-behavior.html"> 14 15<script> 16 17 /** 18 * `Polymer.IronMenuBehavior` implements accessible menu behavior. 19 * 20 * @demo demo/index.html 21 * @polymerBehavior Polymer.IronMenuBehavior 22 */ 23 Polymer.IronMenuBehaviorImpl = { 24 25 properties: { 26 27 /** 28 * Returns the currently focused item. 29 * @type {?Object} 30 */ 31 focusedItem: { 32 observer: '_focusedItemChanged', 33 readOnly: true, 34 type: Object 35 }, 36 37 /** 38 * The attribute to use on menu items to look up the item title. Typing the first 39 * letter of an item when the menu is open focuses that item. If unset, `textContent` 40 * will be used. 41 */ 42 attrForItemTitle: { 43 type: String 44 }, 45 46 disabled: { 47 type: Boolean, 48 value: false, 49 observer: '_disabledChanged', 50 }, 51 }, 52 53 _SEARCH_RESET_TIMEOUT_MS: 1000, 54 55 _previousTabIndex: 0, 56 57 hostAttributes: { 58 'role': 'menu', 59 }, 60 61 observers: [ 62 '_updateMultiselectable(multi)' 63 ], 64 65 listeners: { 66 'focus': '_onFocus', 67 'keydown': '_onKeydown', 68 'iron-items-changed': '_onIronItemsChanged' 69 }, 70 71 keyBindings: { 72 'up': '_onUpKey', 73 'down': '_onDownKey', 74 'esc': '_onEscKey', 75 'shift+tab:keydown': '_onShiftTabDown' 76 }, 77 78 attached: function() { 79 this._resetTabindices(); 80 }, 81 82 /** 83 * Selects the given value. If the `multi` property is true, then the selected state of the 84 * `value` will be toggled; otherwise the `value` will be selected. 85 * 86 * @param {string|number} value the value to select. 87 */ 88 select: function(value) { 89 // Cancel automatically focusing a default item if the menu received focus 90 // through a user action selecting a particular item. 91 if (this._defaultFocusAsync) { 92 this.cancelAsync(this._defaultFocusAsync); 93 this._defaultFocusAsync = null; 94 } 95 var item = this._valueToItem(value); 96 if (item && item.hasAttribute('disabled')) return; 97 this._setFocusedItem(item); 98 Polymer.IronMultiSelectableBehaviorImpl.select.apply(this, arguments); 99 }, 100 101 /** 102 * Resets all tabindex attributes to the appropriate value based on the 103 * current selection state. The appropriate value is `0` (focusable) for 104 * the default selected item, and `-1` (not keyboard focusable) for all 105 * other items. 106 */ 107 _resetTabindices: function() { 108 var selectedItem = this.multi ? (this.selectedItems && this.selectedItems[0]) : this.selectedItem; 109 110 this.items.forEach(function(item) { 111 item.setAttribute('tabindex', item === selectedItem ? '0' : '-1'); 112 }, this); 113 }, 114 115 /** 116 * Sets appropriate ARIA based on whether or not the menu is meant to be 117 * multi-selectable. 118 * 119 * @param {boolean} multi True if the menu should be multi-selectable. 120 */ 121 _updateMultiselectable: function(multi) { 122 if (multi) { 123 this.setAttribute('aria-multiselectable', 'true'); 124 } else { 125 this.removeAttribute('aria-multiselectable'); 126 } 127 }, 128 129 /** 130 * Given a KeyboardEvent, this method will focus the appropriate item in the 131 * menu (if there is a relevant item, and it is possible to focus it). 132 * 133 * @param {KeyboardEvent} event A KeyboardEvent. 134 */ 135 _focusWithKeyboardEvent: function(event) { 136 this.cancelDebouncer('_clearSearchText'); 137 138 var searchText = this._searchText || ''; 139 var key = event.key && event.key.length == 1 ? event.key : 140 String.fromCharCode(event.keyCode); 141 searchText += key.toLocaleLowerCase(); 142 143 var searchLength = searchText.length; 144 145 for (var i = 0, item; item = this.items[i]; i++) { 146 if (item.hasAttribute('disabled')) { 147 continue; 148 } 149 150 var attr = this.attrForItemTitle || 'textContent'; 151 var title = (item[attr] || item.getAttribute(attr) || '').trim(); 152 153 if (title.length < searchLength) { 154 continue; 155 } 156 157 if (title.slice(0, searchLength).toLocaleLowerCase() == searchText) { 158 this._setFocusedItem(item); 159 break; 160 } 161 } 162 163 this._searchText = searchText; 164 this.debounce('_clearSearchText', this._clearSearchText, 165 this._SEARCH_RESET_TIMEOUT_MS); 166 }, 167 168 _clearSearchText: function() { 169 this._searchText = ''; 170 }, 171 172 /** 173 * Focuses the previous item (relative to the currently focused item) in the 174 * menu, disabled items will be skipped. 175 * Loop until length + 1 to handle case of single item in menu. 176 */ 177 _focusPrevious: function() { 178 var length = this.items.length; 179 var curFocusIndex = Number(this.indexOf(this.focusedItem)); 180 181 for (var i = 1; i < length + 1; i++) { 182 var item = this.items[(curFocusIndex - i + length) % length]; 183 if (!item.hasAttribute('disabled')) { 184 var owner = Polymer.dom(item).getOwnerRoot() || document; 185 this._setFocusedItem(item); 186 187 // Focus might not have worked, if the element was hidden or not 188 // focusable. In that case, try again. 189 if (Polymer.dom(owner).activeElement == item) { 190 return; 191 } 192 } 193 } 194 }, 195 196 /** 197 * Focuses the next item (relative to the currently focused item) in the 198 * menu, disabled items will be skipped. 199 * Loop until length + 1 to handle case of single item in menu. 200 */ 201 _focusNext: function() { 202 var length = this.items.length; 203 var curFocusIndex = Number(this.indexOf(this.focusedItem)); 204 205 for (var i = 1; i < length + 1; i++) { 206 var item = this.items[(curFocusIndex + i) % length]; 207 if (!item.hasAttribute('disabled')) { 208 var owner = Polymer.dom(item).getOwnerRoot() || document; 209 this._setFocusedItem(item); 210 211 // Focus might not have worked, if the element was hidden or not 212 // focusable. In that case, try again. 213 if (Polymer.dom(owner).activeElement == item) { 214 return; 215 } 216 } 217 } 218 }, 219 220 /** 221 * Mutates items in the menu based on provided selection details, so that 222 * all items correctly reflect selection state. 223 * 224 * @param {Element} item An item in the menu. 225 * @param {boolean} isSelected True if the item should be shown in a 226 * selected state, otherwise false. 227 */ 228 _applySelection: function(item, isSelected) { 229 if (isSelected) { 230 item.setAttribute('aria-selected', 'true'); 231 } else { 232 item.removeAttribute('aria-selected'); 233 } 234 Polymer.IronSelectableBehavior._applySelection.apply(this, arguments); 235 }, 236 237 /** 238 * Discretely updates tabindex values among menu items as the focused item 239 * changes. 240 * 241 * @param {Element} focusedItem The element that is currently focused. 242 * @param {?Element} old The last element that was considered focused, if 243 * applicable. 244 */ 245 _focusedItemChanged: function(focusedItem, old) { 246 old && old.setAttribute('tabindex', '-1'); 247 if (focusedItem && !focusedItem.hasAttribute('disabled') && !this.disabled) { 248 focusedItem.setAttribute('tabindex', '0'); 249 focusedItem.focus(); 250 } 251 }, 252 253 /** 254 * A handler that responds to mutation changes related to the list of items 255 * in the menu. 256 * 257 * @param {CustomEvent} event An event containing mutation records as its 258 * detail. 259 */ 260 _onIronItemsChanged: function(event) { 261 if (event.detail.addedNodes.length) { 262 this._resetTabindices(); 263 } 264 }, 265 266 /** 267 * Handler that is called when a shift+tab keypress is detected by the menu. 268 * 269 * @param {CustomEvent} event A key combination event. 270 */ 271 _onShiftTabDown: function(event) { 272 var oldTabIndex = this.getAttribute('tabindex'); 273 274 Polymer.IronMenuBehaviorImpl._shiftTabPressed = true; 275 276 this._setFocusedItem(null); 277 278 this.setAttribute('tabindex', '-1'); 279 280 this.async(function() { 281 this.setAttribute('tabindex', oldTabIndex); 282 Polymer.IronMenuBehaviorImpl._shiftTabPressed = false; 283 // NOTE(cdata): polymer/polymer#1305 284 }, 1); 285 }, 286 287 /** 288 * Handler that is called when the menu receives focus. 289 * 290 * @param {FocusEvent} event A focus event. 291 */ 292 _onFocus: function(event) { 293 if (Polymer.IronMenuBehaviorImpl._shiftTabPressed) { 294 // do not focus the menu itself 295 return; 296 } 297 298 // Do not focus the selected tab if the deepest target is part of the 299 // menu element's local DOM and is focusable. 300 var rootTarget = /** @type {?HTMLElement} */( 301 Polymer.dom(event).rootTarget); 302 if (rootTarget !== this && typeof rootTarget.tabIndex !== "undefined" && !this.isLightDescendant(rootTarget)) { 303 return; 304 } 305 306 // clear the cached focus item 307 this._defaultFocusAsync = this.async(function() { 308 // focus the selected item when the menu receives focus, or the first item 309 // if no item is selected 310 var selectedItem = this.multi ? (this.selectedItems && this.selectedItems[0]) : this.selectedItem; 311 312 this._setFocusedItem(null); 313 314 if (selectedItem) { 315 this._setFocusedItem(selectedItem); 316 } else if (this.items[0]) { 317 // We find the first none-disabled item (if one exists) 318 this._focusNext(); 319 } 320 }); 321 }, 322 323 /** 324 * Handler that is called when the up key is pressed. 325 * 326 * @param {CustomEvent} event A key combination event. 327 */ 328 _onUpKey: function(event) { 329 // up and down arrows moves the focus 330 this._focusPrevious(); 331 event.detail.keyboardEvent.preventDefault(); 332 }, 333 334 /** 335 * Handler that is called when the down key is pressed. 336 * 337 * @param {CustomEvent} event A key combination event. 338 */ 339 _onDownKey: function(event) { 340 this._focusNext(); 341 event.detail.keyboardEvent.preventDefault(); 342 }, 343 344 /** 345 * Handler that is called when the esc key is pressed. 346 * 347 * @param {CustomEvent} event A key combination event. 348 */ 349 _onEscKey: function(event) { 350 // esc blurs the control 351 this.focusedItem.blur(); 352 }, 353 354 /** 355 * Handler that is called when a keydown event is detected. 356 * 357 * @param {KeyboardEvent} event A keyboard event. 358 */ 359 _onKeydown: function(event) { 360 if (!this.keyboardEventMatchesKeys(event, 'up down esc')) { 361 // all other keys focus the menu item starting with that character 362 this._focusWithKeyboardEvent(event); 363 } 364 event.stopPropagation(); 365 }, 366 367 // override _activateHandler 368 _activateHandler: function(event) { 369 Polymer.IronSelectableBehavior._activateHandler.call(this, event); 370 event.stopPropagation(); 371 }, 372 373 /** 374 * Updates this element's tab index when it's enabled/disabled. 375 * @param {boolean} disabled 376 */ 377 _disabledChanged: function(disabled) { 378 if (disabled) { 379 this._previousTabIndex = this.hasAttribute('tabindex') ? this.tabIndex : 0; 380 this.removeAttribute('tabindex'); // No tabindex means not tab-able or select-able. 381 } else if (!this.hasAttribute('tabindex')) { 382 this.setAttribute('tabindex', this._previousTabIndex); 383 } 384 } 385 }; 386 387 Polymer.IronMenuBehaviorImpl._shiftTabPressed = false; 388 389 /** @polymerBehavior Polymer.IronMenuBehavior */ 390 Polymer.IronMenuBehavior = [ 391 Polymer.IronMultiSelectableBehavior, 392 Polymer.IronA11yKeysBehavior, 393 Polymer.IronMenuBehaviorImpl 394 ]; 395 396</script> 397