Skip to content

Commit

Permalink
feature(when/key): adding options context and filter - #59
Browse files Browse the repository at this point in the history
  • Loading branch information
rodneyrehm committed Dec 12, 2015
1 parent e80d05b commit 0d026c2
Show file tree
Hide file tree
Showing 5 changed files with 133 additions and 36 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions build/metalsmith/assets/website.css
Expand Up @@ -341,6 +341,7 @@ main table {
border: 1px solid #E5E5E5;
border-collapse: collapse;
width: 100%;
word-break: normal;
}
main th,
main td {
Expand Down
57 changes: 31 additions & 26 deletions docs/api/when/key.md
Expand Up @@ -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 `<key-binding>` syntax:

| `<key-binding>` | primary key | keyCode | alt | ctrl | meta | shift |
Expand All @@ -22,27 +26,11 @@ In order to easily register keyboard events including modifier keys, `ally.when.
| `shift+*+enter` | <kbd>Enter</kbd> | 13 | ? | ? | ? | yes |
| `!shift+*+enter` | <kbd>Enter</kbd> | 13 | ? | ? | ? | no |
| `?shift+ctrl+enter` | <kbd>Enter</kbd> | 13 | no | yes | no | ? |
| `enter shift+8` | <kbd>Enter</kbd> | 13 | no | no | no | no |
| *(continued)* | <kbd>Backspace</kbd> | 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 `<key-binding>` `space` only executes if *no* modifier key was pressed. In order to make the callback execute regardless of modifier keys, use the `<key-binding>` `*+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 | <kbd>Alt</kbd>, <kbd>Option</kbd> or<kbd> ⌥</kbd> on Mac) |
| ctrl | <kbd>Ctrl</kbd>, <kbd>⌃</kbd> on Mac |
| meta | <kbd>Meta</kbd>, <kbd> ⌘</kbd> or <kbd></kbd> or <kbd>Command</kbd> on Mac, <kbd>⊞</kbd> or <kbd>Windows</kbd> on Windows |
| shift | <kbd>Shift</kbd>, <kbd>⇧</kbd> 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.


### `<key-binding>` syntax

The `<key-binding>` 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
Expand All @@ -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 | <kbd>Alt</kbd>, <kbd>Option</kbd> or<kbd> ⌥</kbd> on Mac) |
| ctrl | <kbd>Ctrl</kbd>, <kbd>⌃</kbd> on Mac |
| meta | <kbd>Meta</kbd>, <kbd> ⌘</kbd> or <kbd></kbd> or <kbd>Command</kbd> on Mac, <kbd>⊞</kbd> or <kbd>Windows</kbd> on Windows |
| shift | <kbd>Shift</kbd>, <kbd>⇧</kbd> on Mac |


## Usage

Expand All @@ -76,20 +75,23 @@ handle.disengage();

### Arguments

The method accepts an object of `<key-binding>: <callback>` mappings. See [Callback Signature](#Callback-Signature) for details

`<key-binding>`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 <kbd>Enter</kbd> key, or as the string name `enter` which is resolved to the numeric code internally using [`ally.map.keycode`](../map/keycode.md). Multiple `<key-binding>: <callback>` combinations can be passed.
| Name | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| context | [`<selector>`](../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 | [`<selector>`](../concepts.md#Selector) | `null` | The elements and descendants to exclude when processing keyboard events. |
| [`<key-binding>`](#Key-binding-syntax) | function | *required* | Mapping of `<key-binding>` to callback function. See [Callback Signature](#Callback-Signature) for details. This argument is expected one or more times. |

### Returns

A [`<service>`](../concepts.md#Service) interface, providing the `handle.disengage()` method to stop the service.

### Throws

* [`TypeError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError) if `<key-binding>` 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 `<callback>` is not a `function`.
* [`TypeError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError) if `<key-binding>` contains illegal modifier names.
* [`TypeError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError) if no `<key-binding>: <callback>` combinations were passed.
* [`TypeError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError) if `<key-binding>` does not resolve to a keyCode.
* [`TypeError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError) if `<key-binding>` contains illegal modifier names.
* [`TypeError`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError) if `<callback>` is not a `function`.


## Callback signature

Expand All @@ -98,7 +100,7 @@ A [`<service>`](../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
Expand All @@ -109,17 +111,20 @@ The callback is executed in the context of `document.documentElement` (that's wh
## Changes

* `v#master` introduced the extended `<key-binding>` 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 `<key-binding>` `space` only executes if *no* modifier key was pressed. In order to make the callback execute regardless of modifier keys, use the `<key-binding>` `*+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
* `<key-binding>` syntax inspired by [PolymerElements/iron-a11y-keys](https://github.com/PolymerElements/iron-a11y-keys#grammar)
* The `<key-binding>` syntax is inspired by [PolymerElements/iron-a11y-keys](https://github.com/PolymerElements/iron-a11y-keys#grammar)


## Contributing
Expand Down
20 changes: 17 additions & 3 deletions 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
Expand All @@ -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');
Expand Down Expand Up @@ -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;
Expand All @@ -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 };
Expand Down
89 changes: 83 additions & 6 deletions test/unit/when.key.test.js
Expand Up @@ -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) {
Expand All @@ -17,12 +19,22 @@ define([
name: 'when/key',

beforeEach: function() {

fixture = customFixture([
/*eslint-disable indent */
'<div id="outer">',
'<div id="inner">',
'<input type="text" id="target">',
'</div>',
'</div>',
/*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() {
Expand Down Expand Up @@ -54,7 +66,6 @@ define([
});
}).to.throw(TypeError, 'Unknown modifier "shaft"');
},

lifecycle: function() {
var supportsSynthEvent = dispatchEvent.createKey('keydown', {
key: 'Enter',
Expand Down Expand Up @@ -93,7 +104,6 @@ define([

expect(events.join(', ')).to.equal('enter', 'after second enter event');
},

disengaging: function() {
var supportsSynthEvent = dispatchEvent.createKey('keydown', {
key: 'Enter',
Expand Down Expand Up @@ -138,7 +148,6 @@ define([

expect(events.join(', ')).to.equal('enter', 'after escape event');
},

defaultPrevented: function() {
var supportsSynthEvent = dispatchEvent.createKey('keydown', {
key: 'Enter',
Expand Down Expand Up @@ -176,7 +185,6 @@ define([

expect(events.join(', ')).to.equal('enter', 'after unprevented enter event');
},

modifiers: function() {
var supportsSynthEvent = dispatchEvent.createKey('keydown', {
key: 'Enter',
Expand Down Expand Up @@ -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');
Expand Down

0 comments on commit 0d026c2

Please sign in to comment.