From 0d026c2b6ce758028c6762208642cddcc7930157 Mon Sep 17 00:00:00 2001 From: Rodney Rehm Date: Sat, 5 Dec 2015 18:34:56 +0100 Subject: [PATCH] feature(when/key): adding options context and filter - #59 --- CHANGELOG.md | 2 +- build/metalsmith/assets/website.css | 1 + docs/api/when/key.md | 57 +++++++++--------- src/when/key.js | 20 ++++++- test/unit/when.key.test.js | 89 +++++++++++++++++++++++++++-- 5 files changed, 133 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3800a09c..7dc4b28c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ The following lists show the changes to the library grouped by domain. #### Keyboard support -* changing [`ally.when.key`][ally/when/key] to handle modifier keys - [issue #59](https://github.com/medialize/ally.js/issues/59) +* changing [`ally.when.key`][ally/when/key] to handle modifier keys and respect `context` and `filter` options - [issue #59](https://github.com/medialize/ally.js/issues/59) * changing [`ally.map.keycode`][ally/map/keycode] to provide alphanumeric keys and aliasing #### Internals diff --git a/build/metalsmith/assets/website.css b/build/metalsmith/assets/website.css index 3e340125..549e1370 100644 --- a/build/metalsmith/assets/website.css +++ b/build/metalsmith/assets/website.css @@ -341,6 +341,7 @@ main table { border: 1px solid #E5E5E5; border-collapse: collapse; width: 100%; + word-break: normal; } main th, main td { diff --git a/docs/api/when/key.md b/docs/api/when/key.md index 3f492835..94657222 100644 --- a/docs/api/when/key.md +++ b/docs/api/when/key.md @@ -12,6 +12,10 @@ Executes a callback when a given key has been pressed. This is a convenience API to avoid adding and removing keyboard event handlers and having to filter for specific keys in application code. Callbacks are executed synchronously while handling [`keydown`](https://developer.mozilla.org/en-US/docs/Web/Events/keydown) events to maintain the ability to [`event.preventDefault()`](https://developer.mozilla.org/en/docs/Web/API/Event/preventDefault). +Keyboard events are dispatched to the currently focused element (`document.activeElement`). This allows us to handle keyboard events only when the user is engaged in a particular widget. + +### Key binding syntax + In order to easily register keyboard events including modifier keys, `ally.when.key` understands the following `` syntax: | `` | primary key | keyCode | alt | ctrl | meta | shift | @@ -22,27 +26,11 @@ In order to easily register keyboard events including modifier keys, `ally.when. | `shift+*+enter` | Enter | 13 | ? | ? | ? | yes | | `!shift+*+enter` | Enter | 13 | ? | ? | ? | no | | `?shift+ctrl+enter` | Enter | 13 | no | yes | no | ? | +| `enter shift+8` | Enter | 13 | no | no | no | no | +| *(continued)* | Backspace | 8 | no | no | no | yes | Legend: `no` means the modifier key is not pressed, `yes` means the modifier key is pressed, `?` means the state of the modifier key is ignored. -* **NOTE:** The callback for the `` `space` only executes if *no* modifier key was pressed. In order to make the callback execute regardless of modifier keys, use the `` `*+space`. - -### Modifier keys - -The modifier keys may have different names/symbols depending on operating system and physical keyboard: - -| modifier key name | physical key on keyboard | -|-------------------|--------------------------| -| alt | Alt, Option or on Mac) | -| ctrl | Ctrl, on Mac | -| meta | Meta, or or Command on Mac, or Windows on Windows | -| shift | Shift, on Mac | - -* **NOTE:** The modifiers `alt`, `ctrl`, `meta` usually engage system-level or browser-level functionality. Do not use these modifiers lightly. For example `alt+a` prints the letter `å` on a Mac with German keyboard layout, `meta+q` exits the application and `meta+space` engages Spotlight. - - -### `` syntax - The `` syntax is defined by the following [EBNF](https://en.wikipedia.org/wiki/Extended_Backus%E2%80%93Naur_Form) grammar, with `property of map/keycode` referring to the property names of [`ally.map.keycode`](../map/keycode.md): ```ebnf @@ -55,6 +43,17 @@ modifier = ( [ "!" | "?" ], modifier-name ) | "*" ; modifier-name = "alt" | "ctrl" | "meta" | "shift" ; ``` +### Modifier keys + +The modifier keys may have different names/symbols depending on operating system and physical keyboard: + +| modifier key name | physical key on keyboard | +|-------------------|--------------------------| +| alt | Alt, Option or on Mac) | +| ctrl | Ctrl, on Mac | +| meta | Meta, or or Command on Mac, or Windows on Windows | +| shift | Shift, on Mac | + ## Usage @@ -76,9 +75,11 @@ handle.disengage(); ### Arguments -The method accepts an object of `: ` mappings. See [Callback Signature](#Callback-Signature) for details - -``s can be specified as a numeric code ([`keyCode`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode)) e.g. `13` for the Enter key, or as the string name `enter` which is resolved to the numeric code internally using [`ally.map.keycode`](../map/keycode.md). Multiple `: ` combinations can be passed. +| Name | Type | Default | Description | +| ---- | ---- | ------- | ----------- | +| context | [``](../concepts.md#Selector) | [`documentElement`](https://developer.mozilla.org/en-US/docs/Web/API/Document/documentElement) | The scope of the DOM in which keyboard events will be processed. The first element of a collection is used. | +| filter | [``](../concepts.md#Selector) | `null` | The elements and descendants to exclude when processing keyboard events. | +| [``](#Key-binding-syntax) | function | *required* | Mapping of `` to callback function. See [Callback Signature](#Callback-Signature) for details. This argument is expected one or more times. | ### Returns @@ -86,10 +87,11 @@ A [``](../concepts.md#Service) interface, providing the `handle.disenga ### Throws -* [`TypeError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError) if `` does not contain a key component, or is neither numeric nor found in [`ally.map.keycode`](../map/keycode.md). -* [`TypeError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError) if `` is not a `function`. -* [`TypeError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError) if `` contains illegal modifier names. * [`TypeError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError) if no `: ` combinations were passed. +* [`TypeError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError) if `` does not resolve to a keyCode. +* [`TypeError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError) if `` contains illegal modifier names. +* [`TypeError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError) if `` is not a `function`. + ## Callback signature @@ -98,7 +100,7 @@ A [``](../concepts.md#Service) interface, providing the `handle.disenga | event | [`KeyboardEvent`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent) | *required* | The original `keydown` event. | | disengage | function | *required* | The service's `handle.disengage()` method. | -The callback is executed in the context of `document.documentElement` (that's where `this` inside the callback points to). The callback is passed the `handle.disengage()` method to allow simplified "execute once" behaviors. The callback's return value is ignored. +The callback is executed in the context of `context` (that's where `this` inside the callback points to). The callback is passed the `handle.disengage()` method to allow simplified "execute once" behaviors. The callback's return value is ignored. ## Examples @@ -109,17 +111,20 @@ The callback is executed in the context of `document.documentElement` (that's wh ## Changes * `v#master` introduced the extended `` syntax (thereby changing the default modifier key behavior). +* `v#master` introduced the options `context` and `filter`. ## Notes * **NOTE:** Firefox has a long standing issue with keyboard events propagating to the document while browser UI like autocomplete is being interacted with [Gecko 286933](https://bugzilla.mozilla.org/show_bug.cgi?id=286933). +* **WARNING:** The callback for the `` `space` only executes if *no* modifier key was pressed. In order to make the callback execute regardless of modifier keys, use the `` `*+space`. +* **NOTE:** The modifiers `alt`, `ctrl`, `meta` usually engage system-level or browser-level functionality. Do not use these modifiers lightly. For example `alt+a` prints the letter `å` on a Mac with German keyboard layout, `meta+q` exits the application and `meta+space` engages Spotlight. ## Related resources * [`ally.map.keycode`](../map/keycode.md) used for resolving named keys -* `` syntax inspired by [PolymerElements/iron-a11y-keys](https://github.com/PolymerElements/iron-a11y-keys#grammar) +* The `` syntax is inspired by [PolymerElements/iron-a11y-keys](https://github.com/PolymerElements/iron-a11y-keys#grammar) ## Contributing diff --git a/src/when/key.js b/src/when/key.js index 8bf256c3..cf653dff 100644 --- a/src/when/key.js +++ b/src/when/key.js @@ -1,5 +1,7 @@ import keyBinding from './key.binding'; +import nodeArray from '../util/node-array'; +import {getParentComparator} from '../util/compare-position'; // Bug 286933 - Key events in the autocomplete popup should be hidden from page scripts // @browser-issue Gecko https://bugzilla.mozilla.org/show_bug.cgi?id=286933 @@ -8,6 +10,11 @@ export default function(map = {}) { let disengage; const bindings = {}; + const context = nodeArray(map.context)[0] || document.documentElement; + delete map.context; + const filter = nodeArray(map.filter); + delete map.filter; + const mapKeys = Object.keys(map); if (!mapKeys.length) { throw new TypeError('when/key requires at least one option key'); @@ -43,12 +50,19 @@ export default function(map = {}) { return; } + if (filter.length) { + // ignore elements within the exempted sub-trees + const isParentOfElement = getParentComparator({element: event.target, includeSelf: true}); + if (filter.some(isParentOfElement)) { + return; + } + } + const key = event.keyCode || event.which; if (!bindings[key]) { return; } - const context = this; bindings[key].forEach(function(_event) { if (!_event.matchModifiers(event)) { return; @@ -58,10 +72,10 @@ export default function(map = {}) { }); }; - document.documentElement.addEventListener('keydown', handleKeyDown, false); + context.addEventListener('keydown', handleKeyDown, false); disengage = function() { - document.documentElement.removeEventListener('keydown', handleKeyDown, false); + context.removeEventListener('keydown', handleKeyDown, false); }; return { disengage }; diff --git a/test/unit/when.key.test.js b/test/unit/when.key.test.js index 4d5dceb7..4aa1a00c 100644 --- a/test/unit/when.key.test.js +++ b/test/unit/when.key.test.js @@ -2,11 +2,13 @@ define([ 'intern!object', 'intern/chai!expect', '../helper/dispatch-event', + '../helper/fixtures/custom.fixture', 'ally/when/key', 'ally/when/key.binding', -], function(registerSuite, expect, dispatchEvent, whenKey, keyBinding) { +], function(registerSuite, expect, dispatchEvent, customFixture, whenKey, keyBinding) { registerSuite(function() { + var fixture; var handle; var preventDefaultKeydown = function(event) { @@ -17,12 +19,22 @@ define([ name: 'when/key', beforeEach: function() { - + fixture = customFixture([ + /*eslint-disable indent */ + '
', + '
', + '', + '
', + '
', + /*eslint-enable indent */ + ].join('')); }, afterEach: function() { // make sure a failed test cannot leave listeners behind handle && handle.disengage({ force: true }); document.documentElement.removeEventListener('keydown', preventDefaultKeydown, true); + fixture.remove(); + fixture = null; }, 'invalid invocation': function() { @@ -54,7 +66,6 @@ define([ }); }).to.throw(TypeError, 'Unknown modifier "shaft"'); }, - lifecycle: function() { var supportsSynthEvent = dispatchEvent.createKey('keydown', { key: 'Enter', @@ -93,7 +104,6 @@ define([ expect(events.join(', ')).to.equal('enter', 'after second enter event'); }, - disengaging: function() { var supportsSynthEvent = dispatchEvent.createKey('keydown', { key: 'Enter', @@ -138,7 +148,6 @@ define([ expect(events.join(', ')).to.equal('enter', 'after escape event'); }, - defaultPrevented: function() { var supportsSynthEvent = dispatchEvent.createKey('keydown', { key: 'Enter', @@ -176,7 +185,6 @@ define([ expect(events.join(', ')).to.equal('enter', 'after unprevented enter event'); }, - modifiers: function() { var supportsSynthEvent = dispatchEvent.createKey('keydown', { key: 'Enter', @@ -234,6 +242,75 @@ define([ expect(events.join(', ')).to.equal('enter, shift enter, shift ctrl enter', 'after shift+ctrl+enter event'); }, + context: function() { + var supportsSynthEvent = dispatchEvent.createKey('keydown', { + key: 'Enter', + keyCode: 13, + }); + + if (supportsSynthEvent.keyCode !== 13) { + this.skip('Synthetic enter events not supported'); + } + + var events = []; + handle = whenKey({ + context: '#outer', + enter: function() { + events.push('enter'); + }, + }); + + expect(events.join(', ')).to.equal('', 'before events'); + + dispatchEvent.key(fixture.root, 'keydown', { + key: 'Enter', + keyCode: 13, + }); + + expect(events.join(', ')).to.equal('', 'after outer enter event'); + + dispatchEvent.key(document.getElementById('target'), 'keydown', { + key: 'Enter', + keyCode: 13, + }); + + expect(events.join(', ')).to.equal('enter', 'after inner enter event'); + }, + filter: function() { + var supportsSynthEvent = dispatchEvent.createKey('keydown', { + key: 'Enter', + keyCode: 13, + }); + + if (supportsSynthEvent.keyCode !== 13) { + this.skip('Synthetic enter events not supported'); + } + + var events = []; + handle = whenKey({ + filter: '#inner', + enter: function() { + events.push('enter'); + }, + }); + + expect(events.join(', ')).to.equal('', 'before events'); + + dispatchEvent.key(document.getElementById('target'), 'keydown', { + key: 'Enter', + keyCode: 13, + }); + + expect(events.join(', ')).to.equal('', 'after inner enter event'); + + dispatchEvent.key(fixture.root, 'keydown', { + key: 'Enter', + keyCode: 13, + }); + + expect(events.join(', ')).to.equal('enter', 'after outer enter event'); + }, + 'parse token "enter"': function() { var events = keyBinding('enter'); expect(events).to.be.a('array');