diff --git a/client/src/controllers/KeyboardController.stories.js b/client/src/controllers/KeyboardController.stories.js new file mode 100644 index 0000000000..7d5bcc784e --- /dev/null +++ b/client/src/controllers/KeyboardController.stories.js @@ -0,0 +1,59 @@ +import React, { useState } from 'react'; + +import { StimulusWrapper } from '../../storybook/StimulusWrapper'; +import { KeyboardController } from './KeyboardController'; + +export default { + title: 'Stimulus / KeyboardController', + argTypes: { + debug: { control: 'boolean', defaultValue: false }, + }, +}; + +const definitions = [ + { identifier: 'w-kbd', controllerConstructor: KeyboardController }, +]; + +const Template = ({ debug = false }) => { + const [count, setCount] = useState(0); + return ( + + { + setCount((stateCount) => stateCount + 1); + }} + > + Add to count + + { + setCount(0); + }} + > + Clear count + + + + Add to count with Command ⌘ + j on macOS or{' '} + Ctrl + j on Windows. + + + Clear found with ; + + + + Click count: {count} + + + ); +}; + +export const Base = Template.bind({}); diff --git a/client/src/controllers/KeyboardController.test.js b/client/src/controllers/KeyboardController.test.js new file mode 100644 index 0000000000..acf8d65d1a --- /dev/null +++ b/client/src/controllers/KeyboardController.test.js @@ -0,0 +1,129 @@ +import { Application } from '@hotwired/stimulus'; +import Mousetrap from 'mousetrap'; + +import { KeyboardController } from './KeyboardController'; + +describe('KeyboardController', () => { + let app; + const buttonClickMock = jest.fn(); + + /** + * Simulates a keydown, keypress, and keyup event for the provided key. + */ + const simulateKey = ( + { key, which = key.charCodeAt(0), ctrlKey = false, metaKey = false }, + target = document.body, + ) => + Object.fromEntries( + ['keydown', 'keypress', 'keyup'].map((type) => [ + type, + target.dispatchEvent( + new KeyboardEvent(type, { + bubbles: true, + cancelable: true, + key: key, + which, + ctrlKey, + metaKey, + }), + ), + ]), + ); + + const setup = async (html) => { + document.body.innerHTML = `${html}`; + + app = Application.start(); + app.register('w-kbd', KeyboardController); + + await Promise.resolve(); + }; + + beforeAll(() => { + HTMLButtonElement.prototype.click = buttonClickMock; + }); + + afterEach(() => { + app?.stop(); + jest.clearAllMocks(); + Mousetrap.reset(); + }); + + describe('basic keyboard shortcut usage', () => { + it('should call the click event when the `j` key is pressed after being registered', async () => { + expect(buttonClickMock).not.toHaveBeenCalled(); + + await setup( + `Go`, + ); + + // Simulate the keydown event & check that the default was prevented + expect(simulateKey({ key: 'j' })).toHaveProperty('keypress', false); + + expect(buttonClickMock).toHaveBeenCalledTimes(1); + expect(buttonClickMock.mock.contexts).toEqual([ + document.getElementById('btn'), + ]); + }); + + it('should call the click event when `ctrl+j` is pressed after being registered', async () => { + expect(buttonClickMock).not.toHaveBeenCalled(); + + await setup( + `Go`, + ); + + simulateKey({ key: 'j', which: 74, ctrlKey: true }); + + expect(buttonClickMock).toHaveBeenCalledTimes(1); + expect(buttonClickMock.mock.contexts).toEqual([ + document.getElementById('btn'), + ]); + }); + + it('should call the click event when `command+j` is pressed after being registered', async () => { + expect(buttonClickMock).not.toHaveBeenCalled(); + + await setup( + `Go`, + ); + + simulateKey({ key: 'j', which: 74, metaKey: true }); + + expect(buttonClickMock).toHaveBeenCalledTimes(1); + expect(buttonClickMock.mock.contexts).toEqual([ + document.getElementById('btn'), + ]); + }); + + it('should call the click event when `mod+j` is pressed after being registered', async () => { + expect(buttonClickMock).not.toHaveBeenCalled(); + + await setup( + `Go`, + ); + + simulateKey({ key: 'j', which: 74, metaKey: true }); + simulateKey({ key: 'j', which: 74, ctrlKey: true }); + + expect(buttonClickMock).toHaveBeenCalledTimes(1); + expect([buttonClickMock.mock.contexts[0]]).toEqual([ + document.getElementById('btn'), + ]); + }); + }); + + describe('aria-keyshortcuts usage', () => { + it('should take the aria-keyshortcuts attribute if the data-w-kbd-key-value is not set', async () => { + expect(buttonClickMock).not.toHaveBeenCalled(); + + await setup( + `Go`, + ); + + simulateKey({ key: 'l' }); + + expect(buttonClickMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/client/src/controllers/KeyboardController.ts b/client/src/controllers/KeyboardController.ts new file mode 100644 index 0000000000..f8c1b8c0ec --- /dev/null +++ b/client/src/controllers/KeyboardController.ts @@ -0,0 +1,55 @@ +import { Controller } from '@hotwired/stimulus'; +import Mousetrap from 'mousetrap'; + +/** + * Adds the ability to trigger a button click event using a + * keyboard shortcut declared on the controlled element. + * + * @see https://craig.is/killing/mice + * + * @example + * ```html + * Trigger me with the [ key. + * ``` + * + * @example - use aria-keyshortcuts (when the key string is compatible with Mousetrap's syntax) + * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-keyshortcuts + * ```html + * Trigger me with alt+p. + * ``` + */ +export class KeyboardController extends Controller< + HTMLButtonElement | HTMLAnchorElement +> { + static values = { key: { default: '', type: String } }; + + /** Keyboard shortcut string. */ + declare keyValue: string; + + initialize() { + this.handleKey = this.handleKey.bind(this); + if (!this.keyValue) { + const ariaKeyShortcuts = this.element.getAttribute('aria-keyshortcuts'); + if (ariaKeyShortcuts) { + this.keyValue = ariaKeyShortcuts; + } + } + } + + handleKey(event: Event) { + if (event.preventDefault) event.preventDefault(); + this.element.click(); + } + + /** + * When a key is set or changed, bind the handler to the keyboard shortcut. + * This will override the shortcut, if already set. + */ + keyValueChanged(key: string, previousKey: string) { + if (previousKey && previousKey !== key) { + Mousetrap.unbind(previousKey); + } + + Mousetrap.bind(key, this.handleKey); + } +} diff --git a/client/src/controllers/index.ts b/client/src/controllers/index.ts index 1a19366bb8..e2b50a5737 100644 --- a/client/src/controllers/index.ts +++ b/client/src/controllers/index.ts @@ -13,6 +13,7 @@ import { DismissibleController } from './DismissibleController'; import { DrilldownController } from './DrilldownController'; import { DropdownController } from './DropdownController'; import { InitController } from './InitController'; +import { KeyboardController } from './KeyboardController'; import { LinkController } from './LinkController'; import { OrderableController } from './OrderableController'; import { ProgressController } from './ProgressController'; @@ -46,6 +47,7 @@ export const coreControllerDefinitions: Definition[] = [ { controllerConstructor: DrilldownController, identifier: 'w-drilldown' }, { controllerConstructor: DropdownController, identifier: 'w-dropdown' }, { controllerConstructor: InitController, identifier: 'w-init' }, + { controllerConstructor: KeyboardController, identifier: 'w-kbd' }, { controllerConstructor: LinkController, identifier: 'w-link' }, { controllerConstructor: OrderableController, identifier: 'w-orderable' }, { controllerConstructor: ProgressController, identifier: 'w-progress' },
+ Add to count with Command ⌘ + j on macOS or{' '} + Ctrl + j on Windows. +
+ Clear found with ; +
+ Click count: {count} +