Home | History | Annotate | Download | only in iron-a11y-keys-behavior
      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 
     13 <script>
     14   (function() {
     15     'use strict';
     16 
     17     /**
     18      * Chrome uses an older version of DOM Level 3 Keyboard Events
     19      *
     20      * Most keys are labeled as text, but some are Unicode codepoints.
     21      * Values taken from: http://www.w3.org/TR/2007/WD-DOM-Level-3-Events-20071221/keyset.html#KeySet-Set
     22      */
     23     var KEY_IDENTIFIER = {
     24       'U+0008': 'backspace',
     25       'U+0009': 'tab',
     26       'U+001B': 'esc',
     27       'U+0020': 'space',
     28       'U+007F': 'del'
     29     };
     30 
     31     /**
     32      * Special table for KeyboardEvent.keyCode.
     33      * KeyboardEvent.keyIdentifier is better, and KeyBoardEvent.key is even better
     34      * than that.
     35      *
     36      * Values from: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent.keyCode#Value_of_keyCode
     37      */
     38     var KEY_CODE = {
     39       8: 'backspace',
     40       9: 'tab',
     41       13: 'enter',
     42       27: 'esc',
     43       33: 'pageup',
     44       34: 'pagedown',
     45       35: 'end',
     46       36: 'home',
     47       32: 'space',
     48       37: 'left',
     49       38: 'up',
     50       39: 'right',
     51       40: 'down',
     52       46: 'del',
     53       106: '*'
     54     };
     55 
     56     /**
     57      * MODIFIER_KEYS maps the short name for modifier keys used in a key
     58      * combo string to the property name that references those same keys
     59      * in a KeyboardEvent instance.
     60      */
     61     var MODIFIER_KEYS = {
     62       'shift': 'shiftKey',
     63       'ctrl': 'ctrlKey',
     64       'alt': 'altKey',
     65       'meta': 'metaKey'
     66     };
     67 
     68     /**
     69      * KeyboardEvent.key is mostly represented by printable character made by
     70      * the keyboard, with unprintable keys labeled nicely.
     71      *
     72      * However, on OS X, Alt+char can make a Unicode character that follows an
     73      * Apple-specific mapping. In this case, we fall back to .keyCode.
     74      */
     75     var KEY_CHAR = /[a-z0-9*]/;
     76 
     77     /**
     78      * Matches a keyIdentifier string.
     79      */
     80     var IDENT_CHAR = /U\+/;
     81 
     82     /**
     83      * Matches arrow keys in Gecko 27.0+
     84      */
     85     var ARROW_KEY = /^arrow/;
     86 
     87     /**
     88      * Matches space keys everywhere (notably including IE10's exceptional name
     89      * `spacebar`).
     90      */
     91     var SPACE_KEY = /^space(bar)?/;
     92 
     93     /**
     94      * Matches ESC key.
     95      *
     96      * Value from: http://w3c.github.io/uievents-key/#key-Escape
     97      */
     98     var ESC_KEY = /^escape$/;
     99 
    100     /**
    101      * Transforms the key.
    102      * @param {string} key The KeyBoardEvent.key
    103      * @param {Boolean} [noSpecialChars] Limits the transformation to
    104      * alpha-numeric characters.
    105      */
    106     function transformKey(key, noSpecialChars) {
    107       var validKey = '';
    108       if (key) {
    109         var lKey = key.toLowerCase();
    110         if (lKey === ' ' || SPACE_KEY.test(lKey)) {
    111           validKey = 'space';
    112         } else if (ESC_KEY.test(lKey)) {
    113           validKey = 'esc';
    114         } else if (lKey.length == 1) {
    115           if (!noSpecialChars || KEY_CHAR.test(lKey)) {
    116             validKey = lKey;
    117           }
    118         } else if (ARROW_KEY.test(lKey)) {
    119           validKey = lKey.replace('arrow', '');
    120         } else if (lKey == 'multiply') {
    121           // numpad '*' can map to Multiply on IE/Windows
    122           validKey = '*';
    123         } else {
    124           validKey = lKey;
    125         }
    126       }
    127       return validKey;
    128     }
    129 
    130     function transformKeyIdentifier(keyIdent) {
    131       var validKey = '';
    132       if (keyIdent) {
    133         if (keyIdent in KEY_IDENTIFIER) {
    134           validKey = KEY_IDENTIFIER[keyIdent];
    135         } else if (IDENT_CHAR.test(keyIdent)) {
    136           keyIdent = parseInt(keyIdent.replace('U+', '0x'), 16);
    137           validKey = String.fromCharCode(keyIdent).toLowerCase();
    138         } else {
    139           validKey = keyIdent.toLowerCase();
    140         }
    141       }
    142       return validKey;
    143     }
    144 
    145     function transformKeyCode(keyCode) {
    146       var validKey = '';
    147       if (Number(keyCode)) {
    148         if (keyCode >= 65 && keyCode <= 90) {
    149           // ascii a-z
    150           // lowercase is 32 offset from uppercase
    151           validKey = String.fromCharCode(32 + keyCode);
    152         } else if (keyCode >= 112 && keyCode <= 123) {
    153           // function keys f1-f12
    154           validKey = 'f' + (keyCode - 112);
    155         } else if (keyCode >= 48 && keyCode <= 57) {
    156           // top 0-9 keys
    157           validKey = String(keyCode - 48);
    158         } else if (keyCode >= 96 && keyCode <= 105) {
    159           // num pad 0-9
    160           validKey = String(keyCode - 96);
    161         } else {
    162           validKey = KEY_CODE[keyCode];
    163         }
    164       }
    165       return validKey;
    166     }
    167 
    168     /**
    169       * Calculates the normalized key for a KeyboardEvent.
    170       * @param {KeyboardEvent} keyEvent
    171       * @param {Boolean} [noSpecialChars] Set to true to limit keyEvent.key
    172       * transformation to alpha-numeric chars. This is useful with key
    173       * combinations like shift + 2, which on FF for MacOS produces
    174       * keyEvent.key = @
    175       * To get 2 returned, set noSpecialChars = true
    176       * To get @ returned, set noSpecialChars = false
    177      */
    178     function normalizedKeyForEvent(keyEvent, noSpecialChars) {
    179       // Fall back from .key, to .keyIdentifier, to .keyCode, and then to
    180       // .detail.key to support artificial keyboard events.
    181       return transformKey(keyEvent.key, noSpecialChars) ||
    182         transformKeyIdentifier(keyEvent.keyIdentifier) ||
    183         transformKeyCode(keyEvent.keyCode) ||
    184         transformKey(keyEvent.detail ? keyEvent.detail.key : keyEvent.detail, noSpecialChars) || '';
    185     }
    186 
    187     function keyComboMatchesEvent(keyCombo, event) {
    188       // For combos with modifiers we support only alpha-numeric keys
    189       var keyEvent = normalizedKeyForEvent(event, keyCombo.hasModifiers);
    190       return keyEvent === keyCombo.key &&
    191         (!keyCombo.hasModifiers || (
    192           !!event.shiftKey === !!keyCombo.shiftKey &&
    193           !!event.ctrlKey === !!keyCombo.ctrlKey &&
    194           !!event.altKey === !!keyCombo.altKey &&
    195           !!event.metaKey === !!keyCombo.metaKey)
    196         );
    197     }
    198 
    199     function parseKeyComboString(keyComboString) {
    200       if (keyComboString.length === 1) {
    201         return {
    202           combo: keyComboString,
    203           key: keyComboString,
    204           event: 'keydown'
    205         };
    206       }
    207       return keyComboString.split('+').reduce(function(parsedKeyCombo, keyComboPart) {
    208         var eventParts = keyComboPart.split(':');
    209         var keyName = eventParts[0];
    210         var event = eventParts[1];
    211 
    212         if (keyName in MODIFIER_KEYS) {
    213           parsedKeyCombo[MODIFIER_KEYS[keyName]] = true;
    214           parsedKeyCombo.hasModifiers = true;
    215         } else {
    216           parsedKeyCombo.key = keyName;
    217           parsedKeyCombo.event = event || 'keydown';
    218         }
    219 
    220         return parsedKeyCombo;
    221       }, {
    222         combo: keyComboString.split(':').shift()
    223       });
    224     }
    225 
    226     function parseEventString(eventString) {
    227       return eventString.trim().split(' ').map(function(keyComboString) {
    228         return parseKeyComboString(keyComboString);
    229       });
    230     }
    231 
    232     /**
    233      * `Polymer.IronA11yKeysBehavior` provides a normalized interface for processing
    234      * keyboard commands that pertain to [WAI-ARIA best practices](http://www.w3.org/TR/wai-aria-practices/#kbd_general_binding).
    235      * The element takes care of browser differences with respect to Keyboard events
    236      * and uses an expressive syntax to filter key presses.
    237      *
    238      * Use the `keyBindings` prototype property to express what combination of keys
    239      * will trigger the callback. A key binding has the format
    240      * `"KEY+MODIFIER:EVENT": "callback"` (`"KEY": "callback"` or
    241      * `"KEY:EVENT": "callback"` are valid as well). Some examples:
    242      *
    243      *      keyBindings: {
    244      *        'space': '_onKeydown', // same as 'space:keydown'
    245      *        'shift+tab': '_onKeydown',
    246      *        'enter:keypress': '_onKeypress',
    247      *        'esc:keyup': '_onKeyup'
    248      *      }
    249      *
    250      * The callback will receive with an event containing the following information in `event.detail`:
    251      *
    252      *      _onKeydown: function(event) {
    253      *        console.log(event.detail.combo); // KEY+MODIFIER, e.g. "shift+tab"
    254      *        console.log(event.detail.key); // KEY only, e.g. "tab"
    255      *        console.log(event.detail.event); // EVENT, e.g. "keydown"
    256      *        console.log(event.detail.keyboardEvent); // the original KeyboardEvent
    257      *      }
    258      *
    259      * Use the `keyEventTarget` attribute to set up event handlers on a specific
    260      * node.
    261      *
    262      * See the [demo source code](https://github.com/PolymerElements/iron-a11y-keys-behavior/blob/master/demo/x-key-aware.html)
    263      * for an example.
    264      *
    265      * @demo demo/index.html
    266      * @polymerBehavior
    267      */
    268     Polymer.IronA11yKeysBehavior = {
    269       properties: {
    270         /**
    271          * The EventTarget that will be firing relevant KeyboardEvents. Set it to
    272          * `null` to disable the listeners.
    273          * @type {?EventTarget}
    274          */
    275         keyEventTarget: {
    276           type: Object,
    277           value: function() {
    278             return this;
    279           }
    280         },
    281 
    282         /**
    283          * If true, this property will cause the implementing element to
    284          * automatically stop propagation on any handled KeyboardEvents.
    285          */
    286         stopKeyboardEventPropagation: {
    287           type: Boolean,
    288           value: false
    289         },
    290 
    291         _boundKeyHandlers: {
    292           type: Array,
    293           value: function() {
    294             return [];
    295           }
    296         },
    297 
    298         // We use this due to a limitation in IE10 where instances will have
    299         // own properties of everything on the "prototype".
    300         _imperativeKeyBindings: {
    301           type: Object,
    302           value: function() {
    303             return {};
    304           }
    305         }
    306       },
    307 
    308       observers: [
    309         '_resetKeyEventListeners(keyEventTarget, _boundKeyHandlers)'
    310       ],
    311 
    312 
    313       /**
    314        * To be used to express what combination of keys  will trigger the relative
    315        * callback. e.g. `keyBindings: { 'esc': '_onEscPressed'}`
    316        * @type {Object}
    317        */
    318       keyBindings: {},
    319 
    320       registered: function() {
    321         this._prepKeyBindings();
    322       },
    323 
    324       attached: function() {
    325         this._listenKeyEventListeners();
    326       },
    327 
    328       detached: function() {
    329         this._unlistenKeyEventListeners();
    330       },
    331 
    332       /**
    333        * Can be used to imperatively add a key binding to the implementing
    334        * element. This is the imperative equivalent of declaring a keybinding
    335        * in the `keyBindings` prototype property.
    336        */
    337       addOwnKeyBinding: function(eventString, handlerName) {
    338         this._imperativeKeyBindings[eventString] = handlerName;
    339         this._prepKeyBindings();
    340         this._resetKeyEventListeners();
    341       },
    342 
    343       /**
    344        * When called, will remove all imperatively-added key bindings.
    345        */
    346       removeOwnKeyBindings: function() {
    347         this._imperativeKeyBindings = {};
    348         this._prepKeyBindings();
    349         this._resetKeyEventListeners();
    350       },
    351 
    352       /**
    353        * Returns true if a keyboard event matches `eventString`.
    354        *
    355        * @param {KeyboardEvent} event
    356        * @param {string} eventString
    357        * @return {boolean}
    358        */
    359       keyboardEventMatchesKeys: function(event, eventString) {
    360         var keyCombos = parseEventString(eventString);
    361         for (var i = 0; i < keyCombos.length; ++i) {
    362           if (keyComboMatchesEvent(keyCombos[i], event)) {
    363             return true;
    364           }
    365         }
    366         return false;
    367       },
    368 
    369       _collectKeyBindings: function() {
    370         var keyBindings = this.behaviors.map(function(behavior) {
    371           return behavior.keyBindings;
    372         });
    373 
    374         if (keyBindings.indexOf(this.keyBindings) === -1) {
    375           keyBindings.push(this.keyBindings);
    376         }
    377 
    378         return keyBindings;
    379       },
    380 
    381       _prepKeyBindings: function() {
    382         this._keyBindings = {};
    383 
    384         this._collectKeyBindings().forEach(function(keyBindings) {
    385           for (var eventString in keyBindings) {
    386             this._addKeyBinding(eventString, keyBindings[eventString]);
    387           }
    388         }, this);
    389 
    390         for (var eventString in this._imperativeKeyBindings) {
    391           this._addKeyBinding(eventString, this._imperativeKeyBindings[eventString]);
    392         }
    393 
    394         // Give precedence to combos with modifiers to be checked first.
    395         for (var eventName in this._keyBindings) {
    396           this._keyBindings[eventName].sort(function (kb1, kb2) {
    397             var b1 = kb1[0].hasModifiers;
    398             var b2 = kb2[0].hasModifiers;
    399             return (b1 === b2) ? 0 : b1 ? -1 : 1;
    400           })
    401         }
    402       },
    403 
    404       _addKeyBinding: function(eventString, handlerName) {
    405         parseEventString(eventString).forEach(function(keyCombo) {
    406           this._keyBindings[keyCombo.event] =
    407             this._keyBindings[keyCombo.event] || [];
    408 
    409           this._keyBindings[keyCombo.event].push([
    410             keyCombo,
    411             handlerName
    412           ]);
    413         }, this);
    414       },
    415 
    416       _resetKeyEventListeners: function() {
    417         this._unlistenKeyEventListeners();
    418 
    419         if (this.isAttached) {
    420           this._listenKeyEventListeners();
    421         }
    422       },
    423 
    424       _listenKeyEventListeners: function() {
    425         if (!this.keyEventTarget) {
    426           return;
    427         }
    428         Object.keys(this._keyBindings).forEach(function(eventName) {
    429           var keyBindings = this._keyBindings[eventName];
    430           var boundKeyHandler = this._onKeyBindingEvent.bind(this, keyBindings);
    431 
    432           this._boundKeyHandlers.push([this.keyEventTarget, eventName, boundKeyHandler]);
    433 
    434           this.keyEventTarget.addEventListener(eventName, boundKeyHandler);
    435         }, this);
    436       },
    437 
    438       _unlistenKeyEventListeners: function() {
    439         var keyHandlerTuple;
    440         var keyEventTarget;
    441         var eventName;
    442         var boundKeyHandler;
    443 
    444         while (this._boundKeyHandlers.length) {
    445           // My kingdom for block-scope binding and destructuring assignment..
    446           keyHandlerTuple = this._boundKeyHandlers.pop();
    447           keyEventTarget = keyHandlerTuple[0];
    448           eventName = keyHandlerTuple[1];
    449           boundKeyHandler = keyHandlerTuple[2];
    450 
    451           keyEventTarget.removeEventListener(eventName, boundKeyHandler);
    452         }
    453       },
    454 
    455       _onKeyBindingEvent: function(keyBindings, event) {
    456         if (this.stopKeyboardEventPropagation) {
    457           event.stopPropagation();
    458         }
    459 
    460         // if event has been already prevented, don't do anything
    461         if (event.defaultPrevented) {
    462           return;
    463         }
    464 
    465         for (var i = 0; i < keyBindings.length; i++) {
    466           var keyCombo = keyBindings[i][0];
    467           var handlerName = keyBindings[i][1];
    468           if (keyComboMatchesEvent(keyCombo, event)) {
    469             this._triggerKeyHandler(keyCombo, handlerName, event);
    470             // exit the loop if eventDefault was prevented
    471             if (event.defaultPrevented) {
    472               return;
    473             }
    474           }
    475         }
    476       },
    477 
    478       _triggerKeyHandler: function(keyCombo, handlerName, keyboardEvent) {
    479         var detail = Object.create(keyCombo);
    480         detail.keyboardEvent = keyboardEvent;
    481         var event = new CustomEvent(keyCombo.event, {
    482           detail: detail,
    483           cancelable: true
    484         });
    485         this[handlerName].call(this, event);
    486         if (event.defaultPrevented) {
    487           keyboardEvent.preventDefault();
    488         }
    489       }
    490     };
    491   })();
    492 </script>
    493