0
0
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:
Neeraj P Yetheendran 2024-03-08 19:00:56 +05:30 committed by LB (Ben Johnston)
parent b0697ac0c4
commit 63f7e336db
4 changed files with 245 additions and 0 deletions

View 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({});

View 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);
});
});
});

View 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);
}
}

View File

@ -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' },