mirror of
https://github.com/wagtail/wagtail.git
synced 2024-11-25 05:02:57 +01:00
Create new Stimulus KeyboardController (w-kbd)
This commit is contained in:
parent
b0697ac0c4
commit
63f7e336db
59
client/src/controllers/KeyboardController.stories.js
Normal file
59
client/src/controllers/KeyboardController.stories.js
Normal file
@ -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 (
|
||||
<StimulusWrapper debug={debug} definitions={definitions}>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-small button-secondary"
|
||||
data-controller="w-kbd"
|
||||
data-w-kbd-key-value="mod+j"
|
||||
onClick={() => {
|
||||
setCount((stateCount) => stateCount + 1);
|
||||
}}
|
||||
>
|
||||
Add to count
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-small button-secondary"
|
||||
data-controller="w-kbd"
|
||||
aria-keyshortcuts=";"
|
||||
onClick={() => {
|
||||
setCount(0);
|
||||
}}
|
||||
>
|
||||
Clear count
|
||||
</button>
|
||||
<div>
|
||||
<p>
|
||||
Add to count with <kbd> Command ⌘</kbd> + <kbd>j</kbd> on macOS or{' '}
|
||||
<kbd>Ctrl</kbd> + <kbd>j</kbd> on Windows.
|
||||
</p>
|
||||
<p>
|
||||
Clear found with <kbd>;</kbd>
|
||||
</p>
|
||||
</div>
|
||||
<p id="counter">
|
||||
Click count: <strong>{count}</strong>
|
||||
</p>
|
||||
</StimulusWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const Base = Template.bind({});
|
129
client/src/controllers/KeyboardController.test.js
Normal file
129
client/src/controllers/KeyboardController.test.js
Normal file
@ -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 = `<main>${html}</main>`;
|
||||
|
||||
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(
|
||||
`<button id="btn" data-controller="w-kbd" data-w-kbd-key-value="j">Go</button>`,
|
||||
);
|
||||
|
||||
// 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(
|
||||
`<button id="btn" data-controller="w-kbd" data-w-kbd-key-value="ctrl+j">Go</button>`,
|
||||
);
|
||||
|
||||
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(
|
||||
`<button id="btn" data-controller="w-kbd" data-w-kbd-key-value="command+j">Go</button>`,
|
||||
);
|
||||
|
||||
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(
|
||||
`<button id="btn" data-controller="w-kbd" data-w-kbd-key-value="mod+j">Go</button>`,
|
||||
);
|
||||
|
||||
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(
|
||||
`<button id="btn" data-controller="w-kbd" aria-keyshortcuts="l">Go</button>`,
|
||||
);
|
||||
|
||||
simulateKey({ key: 'l' });
|
||||
|
||||
expect(buttonClickMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
55
client/src/controllers/KeyboardController.ts
Normal file
55
client/src/controllers/KeyboardController.ts
Normal file
@ -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
|
||||
* <button type="button" data-controller="w-kbd" data-w-kbd-key="[">Trigger me with the <kbd>[</kbd> key.</button>
|
||||
* ```
|
||||
*
|
||||
* @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
|
||||
* <button type="button" data-controller="w-kbd" aria-keyshortcuts="alt+p">Trigger me with alt+p.</button>
|
||||
* ```
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
@ -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' },
|
||||
|
Loading…
Reference in New Issue
Block a user