diff --git a/modules/angular2/src/core/application.js b/modules/angular2/src/core/application.js index b9be9110da..ac014dca49 100644 --- a/modules/angular2/src/core/application.js +++ b/modules/angular2/src/core/application.js @@ -18,6 +18,7 @@ import {EmulatedUnscopedShadowDomStrategy} from 'angular2/src/render/dom/shadow_ import {XHR} from 'angular2/src/services/xhr'; import {XHRImpl} from 'angular2/src/services/xhr_impl'; 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 {Binding} from 'angular2/src/di/binding'; import {ComponentUrlMapper} from 'angular2/src/core/compiler/component_url_mapper'; @@ -81,7 +82,7 @@ function _injectorBindings(appComponentType): List { [appComponentRefToken]), bind(LifeCycle).toFactory((exceptionHandler) => new LifeCycle(exceptionHandler, null, assertionsEnabled()),[ExceptionHandler]), bind(EventManager).toFactory((zone) => { - var plugins = [new HammerGesturesPlugin(), new DomEventsPlugin()]; + var plugins = [new HammerGesturesPlugin(), new KeyEventsPlugin(), new DomEventsPlugin()]; return new EventManager(plugins, zone); }, [VmTurnZone]), bind(ShadowDomStrategy).toFactory( diff --git a/modules/angular2/src/dom/browser_adapter.dart b/modules/angular2/src/dom/browser_adapter.dart index ae948627de..9b71e23097 100644 --- a/modules/angular2/src/dom/browser_adapter.dart +++ b/modules/angular2/src/dom/browser_adapter.dart @@ -14,6 +14,87 @@ class _IdentitySanitizer implements NodeTreeSanitizer { 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 { static void makeCurrent() { setRootDomAdapter(new BrowserDomAdapter()); @@ -203,4 +284,8 @@ class BrowserDomAdapter extends GenericBrowserDomAdapter { String getHref(AnchorElement element) { return element.href; } + String getEventKey(KeyboardEvent event) { + int keyCode = event.keyCode; + return _keyCodeToKeyMap.containsKey(keyCode) ? _keyCodeToKeyMap[keyCode] : 'Unidentified'; + } } diff --git a/modules/angular2/src/dom/browser_adapter.es6 b/modules/angular2/src/dom/browser_adapter.es6 index 5620d250fb..4a40bd4499 100644 --- a/modules/angular2/src/dom/browser_adapter.es6 +++ b/modules/angular2/src/dom/browser_adapter.es6 @@ -1,5 +1,5 @@ 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 {GenericBrowserDomAdapter} from './generic_browser_adapter'; @@ -9,6 +9,49 @@ var _attrToPropMap = { '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 { static makeCurrent() { setRootDomAdapter(new BrowserDomAdapter()); @@ -286,4 +329,28 @@ export class BrowserDomAdapter extends GenericBrowserDomAdapter { getHref(el:Element): string { 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; + } } diff --git a/modules/angular2/src/dom/dom_adapter.js b/modules/angular2/src/dom/dom_adapter.js index 17906bed44..95302d532b 100644 --- a/modules/angular2/src/dom/dom_adapter.js +++ b/modules/angular2/src/dom/dom_adapter.js @@ -258,6 +258,9 @@ export class DomAdapter { getHref(element): string { throw _abstract(); } + getEventKey(event): string { + throw _abstract(); + } resolveAndSetHref(element, baseUrl:string, href:string) { throw _abstract(); } diff --git a/modules/angular2/src/render/dom/events/key_events.js b/modules/angular2/src/render/dom/events/key_events.js new file mode 100644 index 0000000000..616a4649b8 --- /dev/null +++ b/modules/angular2/src/render/dom/events/key_events.js @@ -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)); + } + }; + } +} diff --git a/modules/angular2/test/render/dom/events/key_events_spec.js b/modules/angular2/test/render/dom/events/key_events_spec.js new file mode 100644 index 0000000000..6eba3c1c79 --- /dev/null +++ b/modules/angular2/test/render/dom/events/key_events_spec.js @@ -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' + }); + + }); + + }); +} diff --git a/modules/examples/e2e_test/key_events/key_events_spec.es6 b/modules/examples/e2e_test/key_events/key_events_spec.es6 new file mode 100644 index 0000000000..5496194cfd --- /dev/null +++ b/modules/examples/e2e_test/key_events/key_events_spec.es6 @@ -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(''); + }); + +}); diff --git a/modules/examples/src/key_events/index.html b/modules/examples/src/key_events/index.html new file mode 100644 index 0000000000..fbceec8090 --- /dev/null +++ b/modules/examples/src/key_events/index.html @@ -0,0 +1,26 @@ + + + Key events + + + + Loading... + + + $SCRIPTS$ + + diff --git a/modules/examples/src/key_events/index.js b/modules/examples/src/key_events/index.js new file mode 100644 index 0000000000..0c6604b6d5 --- /dev/null +++ b/modules/examples/src/key_events/index.js @@ -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:
+
{{lastKey}}

+ Click in the following area and press shift.enter:
+
{{shiftEnter ? 'You pressed shift.enter!' : ''}}
` +}) +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); +}