Merge pull request #1329 from cptran777/global-key-bindings

Global key bindings
This commit is contained in:
Charlie Tran 2018-08-20 11:30:47 -07:00 committed by GitHub
commit db4515e14e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 244 additions and 24 deletions

View File

@ -0,0 +1,66 @@
import Component from '@ember/component';
import { get } from '@ember/object';
import { service } from '@ember-decorators/service';
import ComputedProperty from '@ember/object/computed';
import HotKeys from 'wherehows-web/services/hot-keys';
export default class GlobalHotkeys extends Component {
/**
* Sets the class names binded to the html element generated by this component
* @type {Array<string>}
*/
classNames = ['global-hotkey-binder'];
/**
* Allows us to bind the tabindex attribute to our element to make it focusable. This will
* allow it to capture keyup events
* @type {Array<string>}
*/
attributeBindings = ['tabindex'];
/**
* Sets the tabindex for our rendered element through attributeBindings
* @type {number}
*/
tabindex = 0;
/**
* Contains a set of elements that we deem to be inEligible in any circumstance. Targets
* with these tags will never be passed through for global hotkeys
* @type {Set<string>}
*/
inEligibleTargets = new Set(['INPUT', 'TEXTAREA']);
/**
* Service that assists with actually triggering the actions tied to a particular Eligible
* target hotkey
* @type {Ember.Service}
*/
@service
hotKeys: ComputedProperty<HotKeys>;
/**
* Returns true if target exists, is not an input, and is not an editable div
* @param {HTMLElement} target - target element
* @returns {boolean}
*/
isEligibleTarget(target: HTMLElement): boolean {
return (
!!target &&
!get(this, 'inEligibleTargets').has(target.tagName) &&
!(target.tagName === 'DIV' && target.attributes.getNamedItem('contenteditable'))
);
}
/**
* Method for handling the global keyup.
* @param {KeyboardEvent} e - KeyboardEvent triggered by user input
*/
keyUp(e: KeyboardEvent) {
const target = <HTMLElement>e.target;
if (this.isEligibleTarget(target)) {
get(this, 'hotKeys').applyKeyMapping(e.keyCode);
}
}
}

View File

@ -1,9 +1,7 @@
import IvyTabsTablistComponent from 'ivy-tabs/components/ivy-tabs-tablist';
import { noop } from 'wherehows-web/utils/helpers/functions';
import { scheduleOnce } from '@ember/runloop';
const LEFT_ARROW = 37;
const RIGHT_ARROW = 39;
import { Keyboard } from 'wherehows-web/constants/keyboard';
export default IvyTabsTablistComponent.extend({
/**
@ -21,10 +19,10 @@ export default IvyTabsTablistComponent.extend({
*/
keyDown(event: KeyboardEvent) {
switch (event.keyCode) {
case LEFT_ARROW:
case Keyboard.ArrowLeft:
this.selectPreviousTab();
break;
case RIGHT_ARROW:
case Keyboard.ArrowRight:
this.selectNextTab();
break;
default:

View File

@ -1,7 +1,8 @@
import Component from '@ember/component';
import { computed, set, get } from '@ember/object';
import { inject } from '@ember/service';
import { inject as service } from '@ember/service';
import { debounce } from '@ember/runloop';
import { Keyboard } from 'wherehows-web/constants/keyboard';
/**
* Number of milliseconds to wait before triggering a request for keywords
@ -14,7 +15,14 @@ export default Component.extend({
* Service to retrieve type ahead keywords for a dataset
* @type {Ember.Service}
*/
keywords: inject('search-keywords'),
keywords: service('search-keywords'),
/**
* Service to bind the search bar form to a global hotkey, allowing us to instantly jump to the
* search whenever the user presses the '/' key
* @type {Ember.Service}
*/
hotKeys: service(),
// Keywords and search Category filter
currentFilter: 'datasets',
@ -43,6 +51,25 @@ export default Component.extend({
return queryResolver(...arguments);
},
/**
* Sets to the focus to the input in the search bar
*/
setSearchBarFocus() {
const searchBar = document.getElementsByClassName('nacho-global-search__text-input')[1];
searchBar && searchBar.focus();
},
didInsertElement() {
this._super(...arguments);
// Registering this hotkey allows us to jump to focus the search bar when pressing '/'
get(this, 'hotKeys').registerKeyMapping(Keyboard.Slash, this.setSearchBarFocus.bind(this));
},
willDestroyElement() {
this._super(...arguments);
get(this, 'hotKeys').unregisterKeyMapping(Keyboard.Slash);
},
actions: {
/**
* When a search action is performed, invoke the parent search action with

View File

@ -1,7 +1,10 @@
export enum Keyboard {
Escape = 27,
ArrowLeft = 37,
ArrowUp = 38,
ArrowRight = 39,
ArrowDown = 40,
Tab = 9,
Enter = 13
Enter = 13,
Slash = 191
}

View File

@ -0,0 +1,47 @@
import Service from '@ember/service';
import { Keyboard } from 'wherehows-web/constants/keyboard';
import { get } from '@ember/object';
import { assert } from '@ember/debug';
import { noop } from 'wherehows-web/utils/helpers/functions';
import { IObject } from 'wherehows-web/typings/generic';
export default class HotKeys extends Service {
/**
* Used to map our various keycodes to methods that have been registered by various components
* that incorporate this service
* @type {Object<function>}
*/
keyMappings: IObject<() => void> = {};
/**
* Called by various components, binds a keycode from a keyboard event to a specified action on
* that particular component
* @param keyCode - keycode given by the keyboard event that will triggered
* @param action - function to bind to this key code
*/
registerKeyMapping(keyCode: Keyboard, action: () => void): void {
assert('Global keys should be mapped to a function', typeof action === 'function');
get(this, 'keyMappings')[keyCode] = action;
}
/**
* In a situation where a component no longer should be calling an action for a specific hotkey,
* this lets us clear that out
* @param keyCode - keycode that has been registered
*/
unregisterKeyMapping(keyCode: Keyboard): void {
get(this, 'keyMappings')[keyCode] = noop;
}
/**
* When the user has a keyup event, this will bubble up to our application body and trigger a
* handler on our global-hotkeys component that handles these. That component then determines
* if the key came from an elligible source for hotkeys and then calls this function to attempt
* to call a mapped action to that key
* @param keyCode - keycode tied to keyboard event that triggered this method
*/
applyKeyMapping(keyCode: Keyboard): void {
const action = get(this, 'keyMappings')[keyCode];
action && action();
}
}

View File

@ -1,24 +1,26 @@
{{notifications/banner-alerts}}
{{#if session.isAuthenticated}}
{{partial "navbar"}}
{{!-- banner-alert-offset pushes the app view down to show the banner at the top above the navbar --}}
<div class="app-container {{if banners.isShowingBanners "banner-alert-offset"}}">
{{#hero-container}}
<header class="nacho-hero__header">
Search for datasets
</header>
<section class="nacho-hero__content">
{{search-bar-form didSearch=(action "didSearch")}}
{{#hotkeys/global-hotkeys}}
{{partial "navbar"}}
{{!-- banner-alert-offset pushes the app view down to show the banner at the top above the navbar --}}
<div class="app-container {{if banners.isShowingBanners "banner-alert-offset"}}">
{{#hero-container}}
<header class="nacho-hero__header">
Search for datasets
</header>
<section class="nacho-hero__content">
{{search-bar-form didSearch=(action "didSearch")}}
</section>
{{/hero-container}}
<section class="container-fluid">
{{partial "main"}}
</section>
{{/hero-container}}
</div>
<section class="container-fluid">
{{partial "main"}}
</section>
</div>
{{notifications-service service=notifications}}
{{notifications-service service=notifications}}
{{/hotkeys/global-hotkeys}}
{{else}}
{{outlet "login"}}
{{/if}}

View File

@ -0,0 +1 @@
{{yield}}

View File

@ -6,6 +6,7 @@ declare module '@ember/service' {
import BannerService from 'wherehows-web/services/banners';
import Notifications from 'wherehows-web/services/notifications';
import UserLookup from 'wherehows-web/services/user-lookup';
import HotKeys from 'wherehows-web/services/hot-keys';
// eslint-disable-next-line typescript/interface-name-prefix
interface Registry {
@ -16,5 +17,6 @@ declare module '@ember/service' {
notifications: Notifications;
'current-user': CurrentUser;
'user-lookup': UserLookup;
'hot-keys': HotKeys;
}
}

View File

@ -0,0 +1,43 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, triggerKeyEvent } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import Service from '@ember/service';
import { Keyboard } from 'wherehows-web/constants/keyboard';
module('Integration | Component | hotkeys/global-hotkeys', function(hooks) {
setupRenderingTest(hooks);
test('it renders', async function(assert) {
await render(hbs`{{hotkeys/global-hotkeys}}`);
assert.equal(this.element.textContent.trim(), '');
});
test('it detects target elligibility', async function(assert) {
const HotKeyService = Service.extend({
applyKeyMapping(keyCode) {
assert.ok(true, 'Applied key mapping is called for key: ' + keyCode);
}
});
this.owner.register('service:hot-keys', HotKeyService);
assert.expect(2);
await render(hbs`<div class="ember-application">
{{#hotkeys/global-hotkeys}}
<div id="pika-test"></div>
{{/hotkeys/global-hotkeys}}
</div>`);
assert.ok(this.element, 'Still renders without errors');
triggerKeyEvent('#pika-test', 'keyup', Keyboard.Slash);
await render(hbs`<div class="ember-application">
{{#hotkeys/global-hotkeys}}
<input id="pika-test">
{{/hotkeys/global-hotkeys}}
</div>`);
// This is expected to not call our apply method, hence only 2 assertions in this test
triggerKeyEvent('#pika-test', 'keyup', Keyboard.Slash);
});
});

View File

@ -0,0 +1,31 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { Keyboard } from 'wherehows-web/constants/keyboard';
module('Unit | Service | hot-keys', function(hooks) {
setupTest(hooks);
test('it exists', function(assert) {
const service = this.owner.lookup('service:hot-keys');
assert.ok(service);
});
test('it operates as intended', function(assert) {
const service = this.owner.lookup('service:hot-keys');
const theFloorIsLava = () => {
assert.ok(true, 'The registered function was successfully called');
};
const theChairsArePeople = () => {
assert.ok(false, 'This function should not run after being unregistered');
};
assert.expect(2);
assert.ok(service);
service.registerKeyMapping(Keyboard.ArrowUp, theFloorIsLava);
service.applyKeyMapping(Keyboard.ArrowUp);
service.registerKeyMapping(Keyboard.Enter, theChairsArePeople);
service.unregisterKeyMapping(Keyboard.Enter);
service.applyKeyMapping(Keyboard.Enter);
});
});