mirror of
https://github.com/datahub-project/datahub.git
synced 2025-12-14 11:36:59 +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 IvyTabsTablistComponent from 'ivy-tabs/components/ivy-tabs-tablist';
|
||||||
import { noop } from 'wherehows-web/utils/helpers/functions';
|
import { noop } from 'wherehows-web/utils/helpers/functions';
|
||||||
import { scheduleOnce } from '@ember/runloop';
|
import { scheduleOnce } from '@ember/runloop';
|
||||||
|
import { Keyboard } from 'wherehows-web/constants/keyboard';
|
||||||
const LEFT_ARROW = 37;
|
|
||||||
const RIGHT_ARROW = 39;
|
|
||||||
|
|
||||||
export default IvyTabsTablistComponent.extend({
|
export default IvyTabsTablistComponent.extend({
|
||||||
/**
|
/**
|
||||||
@ -21,10 +19,10 @@ export default IvyTabsTablistComponent.extend({
|
|||||||
*/
|
*/
|
||||||
keyDown(event: KeyboardEvent) {
|
keyDown(event: KeyboardEvent) {
|
||||||
switch (event.keyCode) {
|
switch (event.keyCode) {
|
||||||
case LEFT_ARROW:
|
case Keyboard.ArrowLeft:
|
||||||
this.selectPreviousTab();
|
this.selectPreviousTab();
|
||||||
break;
|
break;
|
||||||
case RIGHT_ARROW:
|
case Keyboard.ArrowRight:
|
||||||
this.selectNextTab();
|
this.selectNextTab();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import Component from '@ember/component';
|
import Component from '@ember/component';
|
||||||
import { computed, set, get } from '@ember/object';
|
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 { debounce } from '@ember/runloop';
|
||||||
|
import { Keyboard } from 'wherehows-web/constants/keyboard';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Number of milliseconds to wait before triggering a request for keywords
|
* 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
|
* Service to retrieve type ahead keywords for a dataset
|
||||||
* @type {Ember.Service}
|
* @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
|
// Keywords and search Category filter
|
||||||
currentFilter: 'datasets',
|
currentFilter: 'datasets',
|
||||||
@ -43,6 +51,25 @@ export default Component.extend({
|
|||||||
return queryResolver(...arguments);
|
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: {
|
actions: {
|
||||||
/**
|
/**
|
||||||
* When a search action is performed, invoke the parent search action with
|
* When a search action is performed, invoke the parent search action with
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
export enum Keyboard {
|
export enum Keyboard {
|
||||||
Escape = 27,
|
Escape = 27,
|
||||||
|
ArrowLeft = 37,
|
||||||
ArrowUp = 38,
|
ArrowUp = 38,
|
||||||
|
ArrowRight = 39,
|
||||||
ArrowDown = 40,
|
ArrowDown = 40,
|
||||||
Tab = 9,
|
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}}
|
{{notifications/banner-alerts}}
|
||||||
|
|
||||||
{{#if session.isAuthenticated}}
|
{{#if session.isAuthenticated}}
|
||||||
|
{{#hotkeys/global-hotkeys}}
|
||||||
{{partial "navbar"}}
|
{{partial "navbar"}}
|
||||||
{{!-- banner-alert-offset pushes the app view down to show the banner at the top above the 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"}}">
|
<div class="app-container {{if banners.isShowingBanners "banner-alert-offset"}}">
|
||||||
@ -19,6 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{notifications-service service=notifications}}
|
{{notifications-service service=notifications}}
|
||||||
|
{{/hotkeys/global-hotkeys}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{outlet "login"}}
|
{{outlet "login"}}
|
||||||
{{/if}}
|
{{/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 BannerService from 'wherehows-web/services/banners';
|
||||||
import Notifications from 'wherehows-web/services/notifications';
|
import Notifications from 'wherehows-web/services/notifications';
|
||||||
import UserLookup from 'wherehows-web/services/user-lookup';
|
import UserLookup from 'wherehows-web/services/user-lookup';
|
||||||
|
import HotKeys from 'wherehows-web/services/hot-keys';
|
||||||
|
|
||||||
// eslint-disable-next-line typescript/interface-name-prefix
|
// eslint-disable-next-line typescript/interface-name-prefix
|
||||||
interface Registry {
|
interface Registry {
|
||||||
@ -16,5 +17,6 @@ declare module '@ember/service' {
|
|||||||
notifications: Notifications;
|
notifications: Notifications;
|
||||||
'current-user': CurrentUser;
|
'current-user': CurrentUser;
|
||||||
'user-lookup': UserLookup;
|
'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