1 <!-- 2 @license 3 Copyright (c) 2015 The Polymer Project Authors. All rights reserved. 4 This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 5 The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 6 The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 7 Code distributed by Google as part of the polymer project is also 8 subject 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 47 hostAttributes: { 48 'role': 'menu', 49 'tabindex': '0' 50 }, 51 52 observers: [ 53 '_updateMultiselectable(multi)' 54 ], 55 56 listeners: { 57 'focus': '_onFocus', 58 'keydown': '_onKeydown', 59 'iron-items-changed': '_onIronItemsChanged' 60 }, 61 62 keyBindings: { 63 'up': '_onUpKey', 64 'down': '_onDownKey', 65 'esc': '_onEscKey', 66 'shift+tab:keydown': '_onShiftTabDown' 67 }, 68 69 attached: function() { 70 this._resetTabindices(); 71 }, 72 73 /** 74 * Selects the given value. If the `multi` property is true, then the selected state of the 75 * `value` will be toggled; otherwise the `value` will be selected. 76 * 77 * @param {string|number} value the value to select. 78 */ 79 select: function(value) { 80 // Cancel automatically focusing a default item if the menu received focus 81 // through a user action selecting a particular item. 82 if (this._defaultFocusAsync) { 83 this.cancelAsync(this._defaultFocusAsync); 84 this._defaultFocusAsync = null; 85 } 86 var item = this._valueToItem(value); 87 if (item && item.hasAttribute('disabled')) return; 88 this._setFocusedItem(item); 89 Polymer.IronMultiSelectableBehaviorImpl.select.apply(this, arguments); 90 }, 91 92 /** 93 * Resets all tabindex attributes to the appropriate value based on the 94 * current selection state. The appropriate value is `0` (focusable) for 95 * the default selected item, and `-1` (not keyboard focusable) for all 96 * other items. 97 */ 98 _resetTabindices: function() { 99 var selectedItem = this.multi ? (this.selectedItems && this.selectedItems[0]) : this.selectedItem; 100 101 this.items.forEach(function(item) { 102 item.setAttribute('tabindex', item === selectedItem ? '0' : '-1'); 103 }, this); 104 }, 105 106 /** 107 * Sets appropriate ARIA based on whether or not the menu is meant to be 108 * multi-selectable. 109 * 110 * @param {boolean} multi True if the menu should be multi-selectable. 111 */ 112 _updateMultiselectable: function(multi) { 113 if (multi) { 114 this.setAttribute('aria-multiselectable', 'true'); 115 } else { 116 this.removeAttribute('aria-multiselectable'); 117 } 118 }, 119 120 /** 121 * Given a KeyboardEvent, this method will focus the appropriate item in the 122 * menu (if there is a relevant item, and it is possible to focus it). 123 * 124 * @param {KeyboardEvent} event A KeyboardEvent. 125 */ 126 _focusWithKeyboardEvent: function(event) { 127 for (var i = 0, item; item = this.items[i]; i++) { 128 var attr = this.attrForItemTitle || 'textContent'; 129 var title = item[attr] || item.getAttribute(attr); 130 131 if (!item.hasAttribute('disabled') && title && 132 title.trim().charAt(0).toLowerCase() === String.fromCharCode(event.keyCode).toLowerCase()) { 133 this._setFocusedItem(item); 134 break; 135 } 136 } 137 }, 138 139 /** 140 * Focuses the previous item (relative to the currently focused item) in the 141 * menu, disabled items will be skipped. 142 */ 143 _focusPrevious: function() { 144 var length = this.items.length; 145 var curFocusIndex = Number(this.indexOf(this.focusedItem)); 146 for (var i = 1; i < length; i++) { 147 var item = this.items[(curFocusIndex - i + length) % length]; 148 if (!item.hasAttribute('disabled')) { 149 this._setFocusedItem(item); 150 return; 151 } 152 } 153 }, 154 155 /** 156 * Focuses the next item (relative to the currently focused item) in the 157 * menu, disabled items will be skipped. 158 */ 159 _focusNext: function() { 160 var length = this.items.length; 161 var curFocusIndex = Number(this.indexOf(this.focusedItem)); 162 for (var i = 1; i < length; i++) { 163 var item = this.items[(curFocusIndex + i) % length]; 164 if (!item.hasAttribute('disabled')) { 165 this._setFocusedItem(item); 166 return; 167 } 168 } 169 }, 170 171 /** 172 * Mutates items in the menu based on provided selection details, so that 173 * all items correctly reflect selection state. 174 * 175 * @param {Element} item An item in the menu. 176 * @param {boolean} isSelected True if the item should be shown in a 177 * selected state, otherwise false. 178 */ 179 _applySelection: function(item, isSelected) { 180 if (isSelected) { 181 item.setAttribute('aria-selected', 'true'); 182 } else { 183 item.removeAttribute('aria-selected'); 184 } 185 Polymer.IronSelectableBehavior._applySelection.apply(this, arguments); 186 }, 187 188 /** 189 * Discretely updates tabindex values among menu items as the focused item 190 * changes. 191 * 192 * @param {Element} focusedItem The element that is currently focused. 193 * @param {?Element} old The last element that was considered focused, if 194 * applicable. 195 */ 196 _focusedItemChanged: function(focusedItem, old) { 197 old && old.setAttribute('tabindex', '-1'); 198 if (focusedItem) { 199 focusedItem.setAttribute('tabindex', '0'); 200 focusedItem.focus(); 201 } 202 }, 203 204 /** 205 * A handler that responds to mutation changes related to the list of items 206 * in the menu. 207 * 208 * @param {CustomEvent} event An event containing mutation records as its 209 * detail. 210 */ 211 _onIronItemsChanged: function(event) { 212 if (event.detail.addedNodes.length) { 213 this._resetTabindices(); 214 } 215 }, 216 217 /** 218 * Handler that is called when a shift+tab keypress is detected by the menu. 219 * 220 * @param {CustomEvent} event A key combination event. 221 */ 222 _onShiftTabDown: function(event) { 223 var oldTabIndex = this.getAttribute('tabindex'); 224 225 Polymer.IronMenuBehaviorImpl._shiftTabPressed = true; 226 227 this._setFocusedItem(null); 228 229 this.setAttribute('tabindex', '-1'); 230 231 this.async(function() { 232 this.setAttribute('tabindex', oldTabIndex); 233 Polymer.IronMenuBehaviorImpl._shiftTabPressed = false; 234 // NOTE(cdata): polymer/polymer#1305 235 }, 1); 236 }, 237 238 /** 239 * Handler that is called when the menu receives focus. 240 * 241 * @param {FocusEvent} event A focus event. 242 */ 243 _onFocus: function(event) { 244 if (Polymer.IronMenuBehaviorImpl._shiftTabPressed) { 245 // do not focus the menu itself 246 return; 247 } 248 249 // Do not focus the selected tab if the deepest target is part of the 250 // menu element's local DOM and is focusable. 251 var rootTarget = /** @type {?HTMLElement} */( 252 Polymer.dom(event).rootTarget); 253 if (rootTarget !== this && typeof rootTarget.tabIndex !== "undefined" && !this.isLightDescendant(rootTarget)) { 254 return; 255 } 256 257 // clear the cached focus item 258 this._defaultFocusAsync = this.async(function() { 259 // focus the selected item when the menu receives focus, or the first item 260 // if no item is selected 261 var selectedItem = this.multi ? (this.selectedItems && this.selectedItems[0]) : this.selectedItem; 262 263 this._setFocusedItem(null); 264 265 if (selectedItem) { 266 this._setFocusedItem(selectedItem); 267 } else if (this.items[0]) { 268 // We find the first none-disabled item (if one exists) 269 this._focusNext(); 270 } 271 }); 272 }, 273 274 /** 275 * Handler that is called when the up key is pressed. 276 * 277 * @param {CustomEvent} event A key combination event. 278 */ 279 _onUpKey: function(event) { 280 // up and down arrows moves the focus 281 this._focusPrevious(); 282 event.detail.keyboardEvent.preventDefault(); 283 }, 284 285 /** 286 * Handler that is called when the down key is pressed. 287 * 288 * @param {CustomEvent} event A key combination event. 289 */ 290 _onDownKey: function(event) { 291 this._focusNext(); 292 event.detail.keyboardEvent.preventDefault(); 293 }, 294 295 /** 296 * Handler that is called when the esc key is pressed. 297 * 298 * @param {CustomEvent} event A key combination event. 299 */ 300 _onEscKey: function(event) { 301 // esc blurs the control 302 this.focusedItem.blur(); 303 }, 304 305 /** 306 * Handler that is called when a keydown event is detected. 307 * 308 * @param {KeyboardEvent} event A keyboard event. 309 */ 310 _onKeydown: function(event) { 311 if (!this.keyboardEventMatchesKeys(event, 'up down esc')) { 312 // all other keys focus the menu item starting with that character 313 this._focusWithKeyboardEvent(event); 314 } 315 event.stopPropagation(); 316 }, 317 318 // override _activateHandler 319 _activateHandler: function(event) { 320 Polymer.IronSelectableBehavior._activateHandler.call(this, event); 321 event.stopPropagation(); 322 } 323 }; 324 325 Polymer.IronMenuBehaviorImpl._shiftTabPressed = false; 326 327 /** @polymerBehavior Polymer.IronMenuBehavior */ 328 Polymer.IronMenuBehavior = [ 329 Polymer.IronMultiSelectableBehavior, 330 Polymer.IronA11yKeysBehavior, 331 Polymer.IronMenuBehaviorImpl 332 ]; 333 334 </script> 335