feat(keyEvents): support for <div (keyup.enter)="callback()">

This commit adds a plugin for the event manager, to allow a key name to
be appended to the event name (for keyup and keydown events), so that
the callback is only called for that key.

Here are some examples:
 (keydown.shift.enter)
 (keyup.space)
 (keydown.control.shift.a)
 (keyup.f1)

Key names mostly follow the DOM Level 3 event key values:
http://www.w3.org/TR/DOM-Level-3-Events-key/#key-value-tables

There are some limitations to be worked on (cf details
in https://github.com/angular/angular/pull/1136) but for now, this
implementation is reliable for the following keys (by "reliable" I mean
compatible with Chrome and Firefox and not depending on the keyboard
layout):
- alt, control, shift, meta (those keys can be combined with other keys)
- tab, enter, backspace, pause, scrolllock, capslock, numlock
- insert, delete, home, end, pageup, pagedown
- arrowup, arrowdown, arrowleft, arrowright
- latin letters (a-z), function keys (f1-f12)
- numbers on the numeric keypad (but those keys are not correctly simulated
by Chromedriver)

There is a sample to play with in examples/src/key_events/.

close #523
close #1136
This commit is contained in:
David-Emmanuel Divernois 2015-04-10 13:22:00 +02:00
parent f45281a10a
commit 8fa1539bac
9 changed files with 465 additions and 2 deletions

View File

@ -18,6 +18,7 @@ import {EmulatedUnscopedShadowDomStrategy} from 'angular2/src/render/dom/shadow_
import {XHR} from 'angular2/src/services/xhr'; import {XHR} from 'angular2/src/services/xhr';
import {XHRImpl} from 'angular2/src/services/xhr_impl'; import {XHRImpl} from 'angular2/src/services/xhr_impl';
import {EventManager, DomEventsPlugin} from 'angular2/src/render/dom/events/event_manager'; import {EventManager, DomEventsPlugin} from 'angular2/src/render/dom/events/event_manager';
import {KeyEventsPlugin} from 'angular2/src/render/dom/events/key_events';
import {HammerGesturesPlugin} from 'angular2/src/render/dom/events/hammer_gestures'; import {HammerGesturesPlugin} from 'angular2/src/render/dom/events/hammer_gestures';
import {Binding} from 'angular2/src/di/binding'; import {Binding} from 'angular2/src/di/binding';
import {ComponentUrlMapper} from 'angular2/src/core/compiler/component_url_mapper'; import {ComponentUrlMapper} from 'angular2/src/core/compiler/component_url_mapper';
@ -81,7 +82,7 @@ function _injectorBindings(appComponentType): List<Binding> {
[appComponentRefToken]), [appComponentRefToken]),
bind(LifeCycle).toFactory((exceptionHandler) => new LifeCycle(exceptionHandler, null, assertionsEnabled()),[ExceptionHandler]), bind(LifeCycle).toFactory((exceptionHandler) => new LifeCycle(exceptionHandler, null, assertionsEnabled()),[ExceptionHandler]),
bind(EventManager).toFactory((zone) => { bind(EventManager).toFactory((zone) => {
var plugins = [new HammerGesturesPlugin(), new DomEventsPlugin()]; var plugins = [new HammerGesturesPlugin(), new KeyEventsPlugin(), new DomEventsPlugin()];
return new EventManager(plugins, zone); return new EventManager(plugins, zone);
}, [VmTurnZone]), }, [VmTurnZone]),
bind(ShadowDomStrategy).toFactory( bind(ShadowDomStrategy).toFactory(

View File

@ -14,6 +14,87 @@ class _IdentitySanitizer implements NodeTreeSanitizer {
final _identitySanitizer = new _IdentitySanitizer(); final _identitySanitizer = new _IdentitySanitizer();
final _keyCodeToKeyMap = const {
8: 'Backspace',
9: 'Tab',
12: 'Clear',
13: 'Enter',
16: 'Shift',
17: 'Control',
18: 'Alt',
19: 'Pause',
20: 'CapsLock',
27: 'Escape',
32: ' ',
33: 'PageUp',
34: 'PageDown',
35: 'End',
36: 'Home',
37: 'ArrowLeft',
38: 'ArrowUp',
39: 'ArrowRight',
40: 'ArrowDown',
45: 'Insert',
46: 'Delete',
65: 'a',
66: 'b',
67: 'c',
68: 'd',
69: 'e',
70: 'f',
71: 'g',
72: 'h',
73: 'i',
74: 'j',
75: 'k',
76: 'l',
77: 'm',
78: 'n',
79: 'o',
80: 'p',
81: 'q',
82: 'r',
83: 's',
84: 't',
85: 'u',
86: 'v',
87: 'w',
88: 'x',
89: 'y',
90: 'z',
91: 'OS',
93: 'ContextMenu',
96: '0',
97: '1',
98: '2',
99: '3',
100: '4',
101: '5',
102: '6',
103: '7',
104: '8',
105: '9',
106: '*',
107: '+',
109: '-',
110: '.',
111: '/',
112: 'F1',
113: 'F2',
114: 'F3',
115: 'F4',
116: 'F5',
117: 'F6',
118: 'F7',
119: 'F8',
120: 'F9',
121: 'F10',
122: 'F11',
123: 'F12',
144: 'NumLock',
145: 'ScrollLock'
};
class BrowserDomAdapter extends GenericBrowserDomAdapter { class BrowserDomAdapter extends GenericBrowserDomAdapter {
static void makeCurrent() { static void makeCurrent() {
setRootDomAdapter(new BrowserDomAdapter()); setRootDomAdapter(new BrowserDomAdapter());
@ -203,4 +284,8 @@ class BrowserDomAdapter extends GenericBrowserDomAdapter {
String getHref(AnchorElement element) { String getHref(AnchorElement element) {
return element.href; return element.href;
} }
String getEventKey(KeyboardEvent event) {
int keyCode = event.keyCode;
return _keyCodeToKeyMap.containsKey(keyCode) ? _keyCodeToKeyMap[keyCode] : 'Unidentified';
}
} }

View File

@ -1,5 +1,5 @@
import {List, MapWrapper, ListWrapper} from 'angular2/src/facade/collection'; import {List, MapWrapper, ListWrapper} from 'angular2/src/facade/collection';
import {isPresent} from 'angular2/src/facade/lang'; import {isBlank, isPresent} from 'angular2/src/facade/lang';
import {setRootDomAdapter} from './dom_adapter'; import {setRootDomAdapter} from './dom_adapter';
import {GenericBrowserDomAdapter} from './generic_browser_adapter'; import {GenericBrowserDomAdapter} from './generic_browser_adapter';
@ -9,6 +9,49 @@ var _attrToPropMap = {
'tabindex': 'tabIndex' 'tabindex': 'tabIndex'
}; };
const DOM_KEY_LOCATION_NUMPAD = 3;
// Map to convert some key or keyIdentifier values to what will be returned by getEventKey
var _keyMap = {
// The following values are here for cross-browser compatibility and to match the W3C standard
// cf http://www.w3.org/TR/DOM-Level-3-Events-key/
'\b': 'Backspace',
'\t': 'Tab',
'\x7F': 'Delete',
'\x1B': 'Escape',
'Del': 'Delete',
'Esc': 'Escape',
'Left': 'ArrowLeft',
'Right': 'ArrowRight',
'Up': 'ArrowUp',
'Down':'ArrowDown',
'Menu': 'ContextMenu',
'Scroll' : 'ScrollLock',
'Win': 'OS'
};
// There is a bug in Chrome for numeric keypad keys:
// https://code.google.com/p/chromium/issues/detail?id=155654
// 1, 2, 3 ... are reported as A, B, C ...
var _chromeNumKeyPadMap = {
'A': '1',
'B': '2',
'C': '3',
'D': '4',
'E': '5',
'F': '6',
'G': '7',
'H': '8',
'I': '9',
'J': '*',
'K': '+',
'M': '-',
'N': '.',
'O': '/',
'\x60': '0',
'\x90': 'NumLock'
};
export class BrowserDomAdapter extends GenericBrowserDomAdapter { export class BrowserDomAdapter extends GenericBrowserDomAdapter {
static makeCurrent() { static makeCurrent() {
setRootDomAdapter(new BrowserDomAdapter()); setRootDomAdapter(new BrowserDomAdapter());
@ -286,4 +329,28 @@ export class BrowserDomAdapter extends GenericBrowserDomAdapter {
getHref(el:Element): string { getHref(el:Element): string {
return el.href; return el.href;
} }
getEventKey(event): string {
var key = event.key;
if (isBlank(key)) {
key = event.keyIdentifier;
// keyIdentifier is defined in the old draft of DOM Level 3 Events implemented by Chrome and Safari
// cf http://www.w3.org/TR/2007/WD-DOM-Level-3-Events-20071221/events.html#Events-KeyboardEvents-Interfaces
if (isBlank(key)) {
return 'Unidentified';
}
if (key.startsWith('U+')) {
key = String.fromCharCode(parseInt(key.substring(2), 16));
if (event.location === DOM_KEY_LOCATION_NUMPAD && _chromeNumKeyPadMap.hasOwnProperty(key)) {
// There is a bug in Chrome for numeric keypad keys:
// https://code.google.com/p/chromium/issues/detail?id=155654
// 1, 2, 3 ... are reported as A, B, C ...
key = _chromeNumKeyPadMap[key];
}
}
}
if (_keyMap.hasOwnProperty(key)) {
key = _keyMap[key];
}
return key;
}
} }

View File

@ -258,6 +258,9 @@ export class DomAdapter {
getHref(element): string { getHref(element): string {
throw _abstract(); throw _abstract();
} }
getEventKey(event): string {
throw _abstract();
}
resolveAndSetHref(element, baseUrl:string, href:string) { resolveAndSetHref(element, baseUrl:string, href:string) {
throw _abstract(); throw _abstract();
} }

View File

@ -0,0 +1,93 @@
import {DOM} from 'angular2/src/dom/dom_adapter';
import {isPresent, isBlank, StringWrapper, RegExpWrapper, BaseException, NumberWrapper} from 'angular2/src/facade/lang';
import {StringMapWrapper, ListWrapper} from 'angular2/src/facade/collection';
import {EventManagerPlugin} from './event_manager';
var modifierKeys = ['alt', 'control', 'meta', 'shift'];
var modifierKeyGetters = {
'alt': (event) => event.altKey,
'control': (event) => event.ctrlKey,
'meta': (event) => event.metaKey,
'shift': (event) => event.shiftKey
}
export class KeyEventsPlugin extends EventManagerPlugin {
constructor() {
super();
}
supports(eventName: string): boolean {
return isPresent(KeyEventsPlugin.parseEventName(eventName));
}
addEventListener(element, eventName: string, handler: Function,
shouldSupportBubble: boolean) {
var parsedEvent = KeyEventsPlugin.parseEventName(eventName);
var outsideHandler = KeyEventsPlugin.eventCallback(element, shouldSupportBubble,
StringMapWrapper.get(parsedEvent, 'fullKey'), handler, this.manager.getZone());
this.manager.getZone().runOutsideAngular(() => {
DOM.on(element, StringMapWrapper.get(parsedEvent, 'domEventName'), outsideHandler);
});
}
static parseEventName(eventName: string) /* {'domEventName': string, 'fullKey': string} */ {
eventName = eventName.toLowerCase();
var parts = eventName.split('.');
var domEventName = ListWrapper.removeAt(parts, 0);
if ((parts.length === 0) || !(StringWrapper.equals(domEventName, 'keydown') || StringWrapper.equals(domEventName, 'keyup'))) {
return null;
}
var key = ListWrapper.removeLast(parts);
var fullKey = '';
ListWrapper.forEach(modifierKeys, (modifierName) => {
if (ListWrapper.contains(parts, modifierName)) {
ListWrapper.remove(parts, modifierName);
fullKey += modifierName + '.';
}
});
fullKey += key;
if (parts.length != 0 || key.length === 0) {
// returning null instead of throwing to let another plugin process the event
return null;
}
return {
'domEventName': domEventName,
'fullKey': fullKey
};
}
static getEventFullKey(event): string {
var fullKey = '';
var key = DOM.getEventKey(event);
key = key.toLowerCase();
if (StringWrapper.equals(key, ' ')) {
key = 'space'; // for readability
} else if (StringWrapper.equals(key, '.')) {
key = 'dot'; // because '.' is used as a separator in event names
}
ListWrapper.forEach(modifierKeys, (modifierName) => {
if (modifierName != key) {
var modifierGetter = StringMapWrapper.get(modifierKeyGetters, modifierName);
if (modifierGetter(event)) {
fullKey += modifierName + '.';
}
}
});
fullKey += key;
return fullKey;
}
static eventCallback(element, shouldSupportBubble, fullKey, handler, zone) {
return (event) => {
var correctElement = shouldSupportBubble || event.target === element;
if (correctElement && KeyEventsPlugin.getEventFullKey(event) === fullKey) {
zone.run(() => handler(event));
}
};
}
}

View File

@ -0,0 +1,69 @@
import {describe, ddescribe, it, iit, xit, xdescribe, expect, beforeEach, el} from 'angular2/test_lib';
import {KeyEventsPlugin} from 'angular2/src/render/dom/events/key_events';
export function main() {
describe('KeyEvents', () => {
it('should ignore unrecognized events', () => {
expect(KeyEventsPlugin.parseEventName('keydown')).toEqual(null);
expect(KeyEventsPlugin.parseEventName('keyup')).toEqual(null);
expect(KeyEventsPlugin.parseEventName('keydown.unknownmodifier.enter')).toEqual(null);
expect(KeyEventsPlugin.parseEventName('keyup.unknownmodifier.enter')).toEqual(null);
expect(KeyEventsPlugin.parseEventName('unknownevent.control.shift.enter')).toEqual(null);
expect(KeyEventsPlugin.parseEventName('unknownevent.enter')).toEqual(null);
});
it('should correctly parse event names', () => {
// key with no modifier
expect(KeyEventsPlugin.parseEventName('keydown.enter')).toEqual({
'domEventName': 'keydown',
'fullKey': 'enter'
});
expect(KeyEventsPlugin.parseEventName('keyup.enter')).toEqual({
'domEventName': 'keyup',
'fullKey': 'enter'
});
// key with modifiers:
expect(KeyEventsPlugin.parseEventName('keydown.control.shift.enter')).toEqual({
'domEventName': 'keydown',
'fullKey': 'control.shift.enter'
});
expect(KeyEventsPlugin.parseEventName('keyup.control.shift.enter')).toEqual({
'domEventName': 'keyup',
'fullKey': 'control.shift.enter'
});
// key with modifiers in a different order:
expect(KeyEventsPlugin.parseEventName('keydown.shift.control.enter')).toEqual({
'domEventName': 'keydown',
'fullKey': 'control.shift.enter'
});
expect(KeyEventsPlugin.parseEventName('keyup.shift.control.enter')).toEqual({
'domEventName': 'keyup',
'fullKey': 'control.shift.enter'
});
// key that is also a modifier:
expect(KeyEventsPlugin.parseEventName('keydown.shift.control')).toEqual({
'domEventName': 'keydown',
'fullKey': 'shift.control'
});
expect(KeyEventsPlugin.parseEventName('keyup.shift.control')).toEqual({
'domEventName': 'keyup',
'fullKey': 'shift.control'
});
expect(KeyEventsPlugin.parseEventName('keydown.control.shift')).toEqual({
'domEventName': 'keydown',
'fullKey': 'control.shift'
});
expect(KeyEventsPlugin.parseEventName('keyup.control.shift')).toEqual({
'domEventName': 'keyup',
'fullKey': 'control.shift'
});
});
});
}

View File

@ -0,0 +1,69 @@
var testUtil = require('angular2/src/test_lib/e2e_util');
describe('key_events', function () {
var URL = 'examples/src/key_events/index.html';
afterEach(testUtil.verifyNoBrowserErrors);
beforeEach(() => {
browser.get(URL);
});
it('should display correct key names', function() {
var firstArea = element.all(by.css('.sample-area')).get(0);
expect(firstArea.getText()).toBe('(none)');
// testing different key categories:
firstArea.sendKeys(protractor.Key.ENTER);
expect(firstArea.getText()).toBe('enter');
firstArea.sendKeys(protractor.Key.SHIFT, protractor.Key.ENTER);
expect(firstArea.getText()).toBe('shift.enter');
firstArea.sendKeys(protractor.Key.CONTROL, protractor.Key.SHIFT, protractor.Key.ENTER);
expect(firstArea.getText()).toBe('control.shift.enter');
firstArea.sendKeys(' ');
expect(firstArea.getText()).toBe('space');
firstArea.sendKeys('a');
expect(firstArea.getText()).toBe('a');
firstArea.sendKeys(protractor.Key.CONTROL, 'b');
expect(firstArea.getText()).toBe('control.b');
firstArea.sendKeys(protractor.Key.F1);
expect(firstArea.getText()).toBe('f1');
firstArea.sendKeys(protractor.Key.ALT, protractor.Key.F1);
expect(firstArea.getText()).toBe('alt.f1');
firstArea.sendKeys(protractor.Key.CONTROL, protractor.Key.F1);
expect(firstArea.getText()).toBe('control.f1');
// There is an issue with protractor.Key.NUMPAD0 (and other NUMPADx):
// chromedriver does not correctly set the location property on the event to
// specify that the key is on the numeric keypad (event.location = 3)
// so the following test fails:
// firstArea.sendKeys(protractor.Key.NUMPAD0);
// expect(firstArea.getText()).toBe('0');
});
it('should correctly react to the specified key', function() {
var secondArea = element.all(by.css('.sample-area')).get(1);
secondArea.sendKeys(protractor.Key.SHIFT, protractor.Key.ENTER);
expect(secondArea.getText()).toEqual('You pressed shift.enter!');
});
it('should not react to incomplete keys', function() {
var secondArea = element.all(by.css('.sample-area')).get(1);
secondArea.sendKeys(protractor.Key.ENTER);
expect(secondArea.getText()).toEqual('');
});
it('should not react to keys with more modifiers', function() {
var secondArea = element.all(by.css('.sample-area')).get(1);
secondArea.sendKeys(protractor.Key.CONTROL, protractor.Key.SHIFT, protractor.Key.ENTER);
expect(secondArea.getText()).toEqual('');
});
});

View File

@ -0,0 +1,26 @@
<!doctype html>
<html>
<title>Key events</title>
<style>
.sample-area {
text-align: center;
margin: 5px;
height: 50px;
line-height: 50px;
border-radius: 5px;
border: 1px solid #d0d0d0;
}
.sample-area:focus {
border: 1px solid blue;
color: blue;
font-weight: bold;
}
</style>
<body>
<key-events-app>
Loading...
</key-events-app>
$SCRIPTS$
</body>
</html>

View File

@ -0,0 +1,50 @@
import {bootstrap, Component, Template} from 'angular2/angular2';
import {KeyEventsPlugin} from 'angular2/src/render/dom/events/key_events';
// 2 imports for the Dart version:
import {reflector} from 'angular2/src/reflection/reflection';
import {ReflectionCapabilities} from 'angular2/src/reflection/reflection_capabilities';
@Component({
selector: 'key-events-app',
})
@Template({
inline: `Click in the following area and press a key to display its name:<br>
<div (keydown)="onKeyDown($event)" class="sample-area" tabindex="0">{{lastKey}}</div><br>
Click in the following area and press shift.enter:<br>
<div
(keydown.shift.enter)="onShiftEnter($event)"
(click)="resetShiftEnter()"
class="sample-area"
tabindex="0"
>{{shiftEnter ? 'You pressed shift.enter!' : ''}}</div>`
})
class KeyEventsApp {
lastKey: string;
shiftEnter: boolean;
constructor() {
this.lastKey = '(none)';
this.shiftEnter = false;
}
onKeyDown(event) {
this.lastKey = KeyEventsPlugin.getEventFullKey(event);
event.preventDefault();
}
onShiftEnter(event) {
this.shiftEnter = true;
event.preventDefault();
}
resetShiftEnter() {
this.shiftEnter = false;
}
}
export function main() {
reflector.reflectionCapabilities = new ReflectionCapabilities(); // for the Dart version
bootstrap(KeyEventsApp);
}