• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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