mirror of
https://github.com/datahub-project/datahub.git
synced 2025-12-12 18:47:45 +00:00
Merge pull request #1329 from cptran777/global-key-bindings
Global key bindings
This commit is contained in:
commit
db4515e14e
66
wherehows-web/app/components/hotkeys/global-hotkeys.ts
Normal file
66
wherehows-web/app/components/hotkeys/global-hotkeys.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
47
wherehows-web/app/services/hot-keys.ts
Normal file
47
wherehows-web/app/services/hot-keys.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
{{notifications/banner-alerts}}
|
||||
|
||||
{{#if session.isAuthenticated}}
|
||||
{{#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"}}">
|
||||
@ -19,6 +20,7 @@
|
||||
</div>
|
||||
|
||||
{{notifications-service service=notifications}}
|
||||
{{/hotkeys/global-hotkeys}}
|
||||
{{else}}
|
||||
{{outlet "login"}}
|
||||
{{/if}}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
{{yield}}
|
||||
2
wherehows-web/app/typings/app/services.d.ts
vendored
2
wherehows-web/app/typings/app/services.d.ts
vendored
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
31
wherehows-web/tests/unit/services/hot-keys-test.js
Normal file
31
wherehows-web/tests/unit/services/hot-keys-test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user