Repository: wagtail/wagtail

Issue:
🎛️ Replace MouseTrap usage with built in Stimulus keyboard support
> ℹ️ **Part of the [Stimulus 🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

### Is your proposal related to a problem?

At the moment we pull in the Mousetrap library for only two keyboard shortcuts. While this library is small (5KB), it is no longer actively maintained and Stimulus 3.2 has built in support for keyboard shortcuts that match most of the Mousetrap features.

### Describe the solution you'd like (v1)

<details>
<summary>See details (older approach, not recommended now)</summary>

We should replace the two Mousetrap keyboard shortcut usages by extending the `w-action` / `ActionController` to support a `click` method and activate these on the relevant elements via Stimulus actions.

See https://stimulus.hotwired.dev/reference/actions#keyboardevent-filter

#### ✅  1. Extend `ActionController` to support a `click` method.

See `client/src/controllers/ActionController.ts`

This controller supports a `post` method that will post a form submit when a button is clicked. It would be good to extend this to support another method `click` that simply calls `.click()` on the controlled element.

Note: It may make sense to make a dedicated controller for this, but it seems a bit overkill, plus we want to avoid a keyboard shortcut controller as basically any action can be triggered by a keyboard shortcut.

- [x] Has been done via https://github.com/wagtail/wagtail/pull/10212

#### 2. Add a `mod` keyboard key

- On Mac this should to `command` whereas on Windows and Linux it maps to `ctrl`. As per the Mousetrap docs. https://craig.is/killing/mice
- This can be done by creating a custom key mapping - see https://github.com/hotwired/stimulus/issues/654 & the Stimulus docs above.
- Important: validate the code in the linked issue actually works, and it should also have unit tests added.
- This will need to be updated in `client/src/includes/initStimulus.ts`
- [ ] Add `mod` keyboard key to Stimulus controller

#### 3. Update existing usage (preview panel)

- Update `wagtail/admin/templates/wagtailadmin/shared/side_panel_toggles.html` to add the controller.
- Note: We could pull this up into the Python code, however there are already a lot of data attributes so it may make sense to keep adding to the HTML template.

```html
{% load wagtailadmin_tags %} {% for panel in side_panels %}
<button
  type="button"
  class="{{ nav_icon_button_classes }} expanded:w-text-primary"
  aria-label="{{ panel.toggle_aria_label }}"
  aria-expanded="false"
  data-tippy-content="{{ panel.title }}"
  data-tippy-offset="[0,0]"
  data-tippy-placement="bottom"
  data-side-panel-toggle="{{ panel.name }}"
  data-controller="w-action"
  data-action="{% if panel.name == 'preview' %}keyup.mod+p@window->w-action#click{% endif %}"
>
  {% icon name=panel.toggle_icon_name classname=nav_icon_classes %}
</button>
{% endfor %}
```

- [ ] Update existing non-Draftail simple keyboard shortcuts to Stimulus actions

#### 4. Update existing usage (save button)

Note: If parts 1-3 are done as one PR, that will be a great way to get early feedback.

The existing save button `mod+s` works on any button with `action-save`, which is over 12 files.

For example - `wagtail/admin/templates/wagtailadmin/pages/action_menu/page_locked.html`

We will likely need to add some backwards compatibility here as this is a behaviour that is very likely used by packages and other developers building on Wagtail.

Firstly though, each of the `action-save` classes should be removed and instead replaced with the suitable data attributes.

Example:

```html
{% load i18n wagtailadmin_tags %}
<button
  type="submit"
  class="button {% if is_revision %}warning{% endif %}"
  disabled
  data-controller="action"
  data-action="keyup.mod+s@window->w-action#click"
>
  {% if icon_name %}{% icon name=icon_name %}{% endif %} {% trans 'Page locked'
  %}
</button>
```

After that, we will likely need to add an `afterLoad` callback on `ActionController` to find any `.action-save` elements without the controller attribute and then add the data attributes.

You can see an example of this approach in `client/src/controllers/ProgressController.ts`.

- [ ] Update existing non-Draftail complex keyboard shortcuts to Stimulus actions

#### 5. Remove mousetrap 

* Mousetrap can now be removed from `wagtail/admin/templates/wagtailadmin/pages/_editor_js.html`
* Also, remove the actual file itself  `wagtail/admin/static_src/wagtailadmin/js/vendor/mousetrap.min.js`
* Finally, this removal will need to be called out in the release notes, it is possible that custom code has used Mousetrap, expecting it to be in the global `window` namespace
- [ ] Remove mousetrap library

</details>

### Describe the solution you'd like (v2) **Preferred**

It seems like waiting for https://github.com/hotwired/stimulus/pull/715 to be merged is not ideal, also, we probably want to build on a Stimulus controller approach to make it both easy to use simply and maybe one day have a nice 'presentation' of this one day.

Additionally, mousetrap is actually a pretty solid library and handles lots of combinations of keys, but we should be using this via an NPM package instead.

Hence, the proposal is as follows

1. Create a new controller `KeyboardController` which would have the identifier `w-keyboard`.
2. This controller would be used on the elements that receive the keyboard trigger and have a simple `value` only approach to start, the value would be `key`.
3. The controller would, by default, simply call `click` on the element.
4. We would use the `mousetrap` npm library https://www.npmjs.com/package/mousetrap instead of having it added via the editor_html file. Note: Happy if we want to use https://www.npmjs.com/package/hotkeys-js (but we need a solution for `mod` built in and this library does not have that see https://github.com/jaywcjlove/hotkeys-js/issues/390 
6. We would need a unit tested controller, and this can be built on do do more complex stuff down the line.

#### Example HTML

```html
{% load i18n wagtailadmin_tags %}
<button type="submit" class="button {% if is_revision %}warning{% endif %}" disabled data-controller="w-kbd" data-kbd-key-value="mod+s">
  {% if icon_name %}{% icon name=icon_name %}{% endif %} {% trans 'Page locked' %}
</button>
```

#### Basic controller code

```ts
import { Controller } from '@hotwired/stimulus';
import Mousetrap from 'mousetrap'; // Remember to add to package.json and run `npm install`

/**
 * Adds the ability for an element to to be triggered by a keyboard shortcut.
 *
 * @example
 * ```html
 * <button data-controller="w-kbd" data-w-kbd-key="a">Activate me with <kbd>a</kbd></button>
 * ```
 *
 * @example
 * ```html
 * <button data-controller="w-kbd" data-keyboard-key="mod+p">
 *   Trigger with <kbd>Command ⌘</kbd> + <kbd>p</kbd> on macOS or <kbd>Ctrl</kbd> + <kbd>p</kbd> on Windows
 * </button>
 * ```
 */
export class KeyboardController extends Controller<HTMLButtonElement> {
  static values = {
    key: String,
  };

  declare keyValue: string;

  initialize() {
    this.handleKey = this.handleKey.bind(this);
  }

  handleKey(event: Event) {
    event.preventDefault();
    this.element.click();
  }

  keyValueChanged(key, previousKey) {
    // See https://stimulus.hotwired.dev/reference/values#change-callbacks

    if (previousKey && previousKey !== key) {
      Mousetrap.unbind(previousKey, this.handleKey);
    }

    Mousetrap.bind(key, this.handleKey);
  }
}
```

**Related implementations**

* https://stimulus-use.github.io/stimulus-use/#/use-hotkeys 
* https://github.com/leastbad/stimulus-hotkeys/blob/master/src/index.js
* https://github.com/leastbad/stimulus-shortcut/blob/master/src/index.js


### Describe alternatives you've considered

- Leave as is, but we are only using this library for two small keyboard shortcuts and the current approach is fully supported by Stimulus without too much difference.

### Additional context

- This is part of a larger goal to migrate to Stimulus
- A robust solution here may also be the starting point to solve for #3949
- Long term we may use this approach for other keyboard shortcuts implemented in ad-hoc ways (e.g. the comment button), or even new keyboard shortcuts like opening the side menu
- Not a good first issue but good for anyone semi-confident with Jest testing, JavaScript and who has taken the time to read the Stimulus docs in full.
- This is also part of a larger goal to avoid `wagtail/admin/templates/wagtailadmin/pages/_editor_js.html` being used everywhere and minimise global usage in JS See #2936
- Long term, we may further build on `ActionController` for other event dispatching via keyboard shortcuts such as 'focus' or something else.
- An earlier approach at pulling out the keyboard shorts, pre-Stimulus, can be found in this closed PR https://github.com/wagtail/wagtail/pull/8900

🎛️ Replace MouseTrap usage with built in Stimulus keyboard support
> ℹ️ **Part of the [Stimulus 🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

### Is your proposal related to a problem?

At the moment we pull in the Mousetrap library for only two keyboard shortcuts. While this library is small (5KB), it is no longer actively maintained and Stimulus 3.2 has built in support for keyboard shortcuts that match most of the Mousetrap features.

### Describe the solution you'd like (v1)

<details>
<summary>See details (older approach, not recommended now)</summary>

We should replace the two Mousetrap keyboard shortcut usages by extending the `w-action` / `ActionController` to support a `click` method and activate these on the relevant elements via Stimulus actions.

See https://stimulus.hotwired.dev/reference/actions#keyboardevent-filter

#### ✅  1. Extend `ActionController` to support a `click` method.

See `client/src/controllers/ActionController.ts`

This controller supports a `post` method that will post a form submit when a button is clicked. It would be good to extend this to support another method `click` that simply calls `.click()` on the controlled element.

Note: It may make sense to make a dedicated controller for this, but it seems a bit overkill, plus we want to avoid a keyboard shortcut controller as basically any action can be triggered by a keyboard shortcut.

- [x] Has been done via https://github.com/wagtail/wagtail/pull/10212

#### 2. Add a `mod` keyboard key

- On Mac this should to `command` whereas on Windows and Linux it maps to `ctrl`. As per the Mousetrap docs. https://craig.is/killing/mice
- This can be done by creating a custom key mapping - see https://github.com/hotwired/stimulus/issues/654 & the Stimulus docs above.
- Important: validate the code in the linked issue actually works, and it should also have unit tests added.
- This will need to be updated in `client/src/includes/initStimulus.ts`
- [ ] Add `mod` keyboard key to Stimulus controller

#### 3. Update existing usage (preview panel)

- Update `wagtail/admin/templates/wagtailadmin/shared/side_panel_toggles.html` to add the controller.
- Note: We could pull this up into the Python code, however there are already a lot of data attributes so it may make sense to keep adding to the HTML template.

```html
{% load wagtailadmin_tags %} {% for panel in side_panels %}
<button
  type="button"
  class="{{ nav_icon_button_classes }} expanded:w-text-primary"
  aria-label="{{ panel.toggle_aria_label }}"
  aria-expanded="false"
  data-tippy-content="{{ panel.title }}"
  data-tippy-offset="[0,0]"
  data-tippy-placement="bottom"
  data-side-panel-toggle="{{ panel.name }}"
  data-controller="w-action"
  data-action="{% if panel.name == 'preview' %}keyup.mod+p@window->w-action#click{% endif %}"
>
  {% icon name=panel.toggle_icon_name classname=nav_icon_classes %}
</button>
{% endfor %}
```

- [ ] Update existing non-Draftail simple keyboard shortcuts to Stimulus actions

#### 4. Update existing usage (save button)

Note: If parts 1-3 are done as one PR, that will be a great way to get early feedback.

The existing save button `mod+s` works on any button with `action-save`, which is over 12 files.

For example - `wagtail/admin/templates/wagtailadmin/pages/action_menu/page_locked.html`

We will likely need to add some backwards compatibility here as this is a behaviour that is very likely used by packages and other developers building on Wagtail.

Firstly though, each of the `action-save` classes should be removed and instead replaced with the suitable data attributes.

Example:

```html
{% load i18n wagtailadmin_tags %}
<button
  type="submit"
  class="button {% if is_revision %}warning{% endif %}"
  disabled
  data-controller="action"
  data-action="keyup.mod+s@window->w-action#click"
>
  {% if icon_name %}{% icon name=icon_name %}{% endif %} {% trans 'Page locked'
  %}
</button>
```

After that, we will likely need to add an `afterLoad` callback on `ActionController` to find any `.action-save` elements without the controller attribute and then add the data attributes.

You can see an example of this approach in `client/src/controllers/ProgressController.ts`.

- [ ] Update existing non-Draftail complex keyboard shortcuts to Stimulus actions

#### 5. Remove mousetrap 

* Mousetrap can now be removed from `wagtail/admin/templates/wagtailadmin/pages/_editor_js.html`
* Also, remove the actual file itself  `wagtail/admin/static_src/wagtailadmin/js/vendor/mousetrap.min.js`
* Finally, this removal will need to be called out in the release notes, it is possible that custom code has used Mousetrap, expecting it to be in the global `window` namespace
- [ ] Remove mousetrap library

</details>

### Describe the solution you'd like (v2) **Preferred**

It seems like waiting for https://github.com/hotwired/stimulus/pull/715 to be merged is not ideal, also, we probably want to build on a Stimulus controller approach to make it both easy to use simply and maybe one day have a nice 'presentation' of this one day.

Additionally, mousetrap is actually a pretty solid library and handles lots of combinations of keys, but we should be using this via an NPM package instead.

Hence, the proposal is as follows

1. Create a new controller `KeyboardController` which would have the identifier `w-keyboard`.
2. This controller would be used on the elements that receive the keyboard trigger and have a simple `value` only approach to start, the value would be `key`.
3. The controller would, by default, simply call `click` on the element.
4. We would use the `mousetrap` npm library https://www.npmjs.com/package/mousetrap instead of having it added via the editor_html file. Note: Happy if we want to use https://www.npmjs.com/package/hotkeys-js (but we need a solution for `mod` built in and this library does not have that see https://github.com/jaywcjlove/hotkeys-js/issues/390 
6. We would need a unit tested controller, and this can be built on do do more complex stuff down the line.

#### Example HTML

```html
{% load i18n wagtailadmin_tags %}
<button type="submit" class="button {% if is_revision %}warning{% endif %}" disabled data-controller="w-kbd" data-kbd-key-value="mod+s">
  {% if icon_name %}{% icon name=icon_name %}{% endif %} {% trans 'Page locked' %}
</button>
```

#### Basic controller code

```ts
import { Controller } from '@hotwired/stimulus';
import Mousetrap from 'mousetrap'; // Remember to add to package.json and run `npm install`

/**
 * Adds the ability for an element to to be triggered by a keyboard shortcut.
 *
 * @example
 * ```html
 * <button data-controller="w-kbd" data-w-kbd-key="a">Activate me with <kbd>a</kbd></button>
 * ```
 *
 * @example
 * ```html
 * <button data-controller="w-kbd" data-keyboard-key="mod+p">
 *   Trigger with <kbd>Command ⌘</kbd> + <kbd>p</kbd> on macOS or <kbd>Ctrl</kbd> + <kbd>p</kbd> on Windows
 * </button>
 * ```
 */
export class KeyboardController extends Controller<HTMLButtonElement> {
  static values = {
    key: String,
  };

  declare keyValue: string;

  initialize() {
    this.handleKey = this.handleKey.bind(this);
  }

  handleKey(event: Event) {
    event.preventDefault();
    this.element.click();
  }

  keyValueChanged(key, previousKey) {
    // See https://stimulus.hotwired.dev/reference/values#change-callbacks

    if (previousKey && previousKey !== key) {
      Mousetrap.unbind(previousKey, this.handleKey);
    }

    Mousetrap.bind(key, this.handleKey);
  }
}
```

**Related implementations**

* https://stimulus-use.github.io/stimulus-use/#/use-hotkeys 
* https://github.com/leastbad/stimulus-hotkeys/blob/master/src/index.js
* https://github.com/leastbad/stimulus-shortcut/blob/master/src/index.js


### Describe alternatives you've considered

- Leave as is, but we are only using this library for two small keyboard shortcuts and the current approach is fully supported by Stimulus without too much difference.

### Additional context

- This is part of a larger goal to migrate to Stimulus
- A robust solution here may also be the starting point to solve for #3949
- Long term we may use this approach for other keyboard shortcuts implemented in ad-hoc ways (e.g. the comment button), or even new keyboard shortcuts like opening the side menu
- Not a good first issue but good for anyone semi-confident with Jest testing, JavaScript and who has taken the time to read the Stimulus docs in full.
- This is also part of a larger goal to avoid `wagtail/admin/templates/wagtailadmin/pages/_editor_js.html` being used everywhere and minimise global usage in JS See #2936
- Long term, we may further build on `ActionController` for other event dispatching via keyboard shortcuts such as 'focus' or something else.
- An earlier approach at pulling out the keyboard shorts, pre-Stimulus, can be found in this closed PR https://github.com/wagtail/wagtail/pull/8900



Discussion:
I have started working on this issue.
Awesome @suyash5053 reach out if you need a hand or if something is not clear.

Be sure to read through Stimulus' docs on how keyboard filters work.

https://stimulus.hotwired.dev/reference/actions#keyboardevent-filter

Also, if it helps, feel free to do an initial PR that covers item 1 or 1 & 2 before handling the others. Even if it's a draft PR it may be good to get some early feedback.
Hey @suyash5053 
Still working on this?
Hey @Lovelyfin00 , yup I'm still working on this. 
Note to all - this is probably blocked until we get some traction on https://github.com/hotwired/stimulus/issues/654 & https://github.com/hotwired/stimulus/pull/715 on the Stimulus side.

To avoid unnecessary code churn, we can wait for now, but we may want to revisit this if we do not get any movement on the Stimulus repo for some time.
OK, I have updated this issue with a suggested "Describe the solution you'd like (v2) Preferred" section.

This is probably a better way for long term usage of keyboard shortcuts in Wagtail, still relies on Mousetrap (as a package) but gives us an abstract way to manage this and will be easier to replace the library with something else later if we want.

It also makes it much easier to add a keyboard shortcut to an element without having to know much about Stimulus action keyboard listeners and will support a broader range of keyboard shortcut declarations.
Should the `KeyboardController` be made inside the `client/src/controllers` directory?
@NXPY123 - yep, all our Stimulus controllers go in that same folder. 
I stumbled upon https://github.com/ai/keyux the other day and thought of this issue ([source](https://twitter.com/sitnikcode/status/1760677051127021887))
> I stumbled upon https://github.com/ai/keyux the other day and thought of this issue ([source](https://twitter.com/sitnikcode/status/1760677051127021887))

Is this being suggested over Mousetrap?
@NXPY123 for now let's keep the plan as is, a smaller migration to still use MouseTrap but via NPM and Stimulus. 

I've had a quick look at the library and it's still quite new, it solves some additional problems that Mousetrap doesn't. However, it doesn't seem to account for a dynamic `mod` key per OS. 

I do like it's use of aria-keyshortcuts, we may borrow that idea in our code either on this adoption or a future enhancement. 
https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-keyshortcuts

I also like that it visually indicates what button is being activated when the shortcut key is pressed.

Anyway, it's a nice approach and we could adopt it easily in the future if needed.

My thinking generally is that the Stimulus attributes and approach solves for;

-  a. A consistent way to do things in the HTML (data attributes).
- b. A reliable way to ensure that the behaviour attached to the html gets run no matter how the html is added. Initial page load, dynamic inlinePanel, dynamic StreamField blocks or Async modal - whatever. 
- c. Easy-ish to change any underlying implementation or library used. If we want to adopt hotkey.js or keyux or roll our own keyboard code, almost no html should need to change. 

Cc @zerolab 
The third advantage you mentioned is huge, especially considering that it lets us modify and add new controllers in a central location with a proper hierarchy and easy access. It makes it incredibly flexible with allowance for any future adaptions of new packages. I think it would also help people new to that part of the codebase get started quickly and also debug issues easily.
Another alternative to consider for future is tinykeys https://github.com/jamiebuilds/tinykeys
I have started working on this issue.
Awesome @suyash5053 reach out if you need a hand or if something is not clear.

Be sure to read through Stimulus' docs on how keyboard filters work.

https://stimulus.hotwired.dev/reference/actions#keyboardevent-filter

Also, if it helps, feel free to do an initial PR that covers item 1 or 1 & 2 before handling the others. Even if it's a draft PR it may be good to get some early feedback.
Hey @suyash5053 
Still working on this?
Hey @Lovelyfin00 , yup I'm still working on this. 
Note to all - this is probably blocked until we get some traction on https://github.com/hotwired/stimulus/issues/654 & https://github.com/hotwired/stimulus/pull/715 on the Stimulus side.

To avoid unnecessary code churn, we can wait for now, but we may want to revisit this if we do not get any movement on the Stimulus repo for some time.
OK, I have updated this issue with a suggested "Describe the solution you'd like (v2) Preferred" section.

This is probably a better way for long term usage of keyboard shortcuts in Wagtail, still relies on Mousetrap (as a package) but gives us an abstract way to manage this and will be easier to replace the library with something else later if we want.

It also makes it much easier to add a keyboard shortcut to an element without having to know much about Stimulus action keyboard listeners and will support a broader range of keyboard shortcut declarations.
Should the `KeyboardController` be made inside the `client/src/controllers` directory?
@NXPY123 - yep, all our Stimulus controllers go in that same folder. 
I stumbled upon https://github.com/ai/keyux the other day and thought of this issue ([source](https://twitter.com/sitnikcode/status/1760677051127021887))
> I stumbled upon https://github.com/ai/keyux the other day and thought of this issue ([source](https://twitter.com/sitnikcode/status/1760677051127021887))

Is this being suggested over Mousetrap?
@NXPY123 for now let's keep the plan as is, a smaller migration to still use MouseTrap but via NPM and Stimulus. 

I've had a quick look at the library and it's still quite new, it solves some additional problems that Mousetrap doesn't. However, it doesn't seem to account for a dynamic `mod` key per OS. 

I do like it's use of aria-keyshortcuts, we may borrow that idea in our code either on this adoption or a future enhancement. 
https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-keyshortcuts

I also like that it visually indicates what button is being activated when the shortcut key is pressed.

Anyway, it's a nice approach and we could adopt it easily in the future if needed.

My thinking generally is that the Stimulus attributes and approach solves for;

-  a. A consistent way to do things in the HTML (data attributes).
- b. A reliable way to ensure that the behaviour attached to the html gets run no matter how the html is added. Initial page load, dynamic inlinePanel, dynamic StreamField blocks or Async modal - whatever. 
- c. Easy-ish to change any underlying implementation or library used. If we want to adopt hotkey.js or keyux or roll our own keyboard code, almost no html should need to change. 

Cc @zerolab 
The third advantage you mentioned is huge, especially considering that it lets us modify and add new controllers in a central location with a proper hierarchy and easy access. It makes it incredibly flexible with allowance for any future adaptions of new packages. I think it would also help people new to that part of the codebase get started quickly and also debug issues easily.
Another alternative to consider for future is tinykeys https://github.com/jamiebuilds/tinykeys

Resolving patch:
diff --git a/wagtail/admin/ui/side_panels.py b/wagtail/admin/ui/side_panels.py
--- a/wagtail/admin/ui/side_panels.py
+++ b/wagtail/admin/ui/side_panels.py
@@ -15,6 +15,7 @@ class SidePanelToggle(Component):
         icon_name = ""
         has_counter = True
         counter_classname = ""
+        keyboard_shortcut = None
 
         def __init__(self, panel):
             self.panel = panel
@@ -338,6 +339,7 @@ class SidePanelToggle(BaseSidePanel.SidePanelToggle):
         aria_label = gettext_lazy("Toggle preview")
         icon_name = "mobile-alt"
         has_counter = False
+        keyboard_shortcut = "mod+p"
 
     name = "preview"
     title = gettext_lazy("Preview")


Test patch:
diff --git a/client/src/controllers/KeyboardController.test.js b/client/src/controllers/KeyboardController.test.js
new file mode 100644
--- /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 = `<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);
+    });
+  });
+});
diff --git a/wagtail/admin/tests/pages/test_preview.py b/wagtail/admin/tests/pages/test_preview.py
--- a/wagtail/admin/tests/pages/test_preview.py
+++ b/wagtail/admin/tests/pages/test_preview.py
@@ -447,10 +447,16 @@ def test_show_preview_panel_on_create_with_single_mode(self):
         self.assertEqual(response.status_code, 200)
 
         # Should show the preview panel
-        self.assertContains(response, 'data-side-panel-toggle="preview"')
         self.assertContains(response, 'data-side-panel="preview"')
         self.assertContains(response, 'data-action="%s"' % preview_url)
 
+        # Should have the preview side panel toggle button
+        soup = self.get_soup(response.content)
+        toggle_button = soup.find("button", {"data-side-panel-toggle": "preview"})
+        self.assertIsNotNone(toggle_button)
+        self.assertEqual("w-tooltip w-kbd", toggle_button["data-controller"])
+        self.assertEqual("mod+p", toggle_button["data-w-kbd-key-value"])
+
         # Should show the iframe
         self.assertContains(
             response,
diff --git a/wagtail/snippets/tests/test_preview.py b/wagtail/snippets/tests/test_preview.py
--- a/wagtail/snippets/tests/test_preview.py
+++ b/wagtail/snippets/tests/test_preview.py
@@ -297,10 +297,16 @@ def test_show_preview_panel_on_create_with_single_mode(self):
         self.assertEqual(response.status_code, 200)
 
         # Should show the preview panel
-        self.assertContains(response, 'data-side-panel-toggle="preview"')
         self.assertContains(response, 'data-side-panel="preview"')
         self.assertContains(response, 'data-action="%s"' % preview_url)
 
+        # Should have the preview side panel toggle button
+        soup = self.get_soup(response.content)
+        toggle_button = soup.find("button", {"data-side-panel-toggle": "preview"})
+        self.assertIsNotNone(toggle_button)
+        self.assertEqual("w-tooltip w-kbd", toggle_button["data-controller"])
+        self.assertEqual("mod+p", toggle_button["data-w-kbd-key-value"])
+
         # Should show the iframe
         self.assertContains(
             response,
diff --git a/wagtail/snippets/tests/test_snippets.py b/wagtail/snippets/tests/test_snippets.py
--- a/wagtail/snippets/tests/test_snippets.py
+++ b/wagtail/snippets/tests/test_snippets.py
@@ -1095,7 +1095,7 @@ def test_get(self):
         # The publish button should have name="action-publish"
         self.assertContains(
             response,
-            '<button\n    type="submit"\n    name="action-publish"\n    value="action-publish"\n    class="button action-save button-longrunning"\n    data-controller="w-progress"\n    data-action="w-progress#activate"\n',
+            '<button\n    type="submit"\n    name="action-publish"\n    value="action-publish"\n    class="button action-save button-longrunning"\n    data-controller="w-progress w-kbd"\n    data-action="w-progress#activate"\n    data-w-kbd-key-value="mod+s"\n',
         )
         # The status side panel should be rendered so that the
         # publishing schedule can be configured
@@ -1906,7 +1906,7 @@ def test_get(self):
         # The publish button should have name="action-publish"
         self.assertContains(
             response,
-            '<button\n    type="submit"\n    name="action-publish"\n    value="action-publish"\n    class="button action-save button-longrunning"\n    data-controller="w-progress"\n    data-action="w-progress#activate"\n',
+            '<button\n    type="submit"\n    name="action-publish"\n    value="action-publish"\n    class="button action-save button-longrunning"\n    data-controller="w-progress w-kbd"\n    data-action="w-progress#activate"\n    data-w-kbd-key-value="mod+s"\n',
         )
 
         # The status side panel should show "No publishing schedule set" info
@@ -4382,7 +4382,7 @@ def test_get_with_draft_state_snippet(self):
         # The publish button should have name="action-publish"
         self.assertContains(
             response,
-            '<button\n    type="submit"\n    name="action-publish"\n    value="action-publish"\n    class="button action-save button-longrunning warning"\n    data-controller="w-progress"\n    data-action="w-progress#activate"\n',
+            '<button\n    type="submit"\n    name="action-publish"\n    value="action-publish"\n    class="button action-save button-longrunning warning"\n    data-controller="w-progress w-kbd"\n    data-action="w-progress#activate"\n    data-w-kbd-key-value="mod+s"\n',
         )
 
         # Should not show the Unpublish action menu item
@@ -4428,10 +4428,16 @@ def test_get_with_previewable_snippet(self):
 
         # Should show the preview panel
         preview_url = self.get_url("preview_on_edit")
-        self.assertContains(response, 'data-side-panel-toggle="preview"')
         self.assertContains(response, 'data-side-panel="preview"')
         self.assertContains(response, f'data-action="{preview_url}"')
 
+        # Should have the preview side panel toggle button
+        soup = self.get_soup(response.content)
+        toggle_button = soup.find("button", {"data-side-panel-toggle": "preview"})
+        self.assertIsNotNone(toggle_button)
+        self.assertEqual("w-tooltip w-kbd", toggle_button["data-controller"])
+        self.assertEqual("mod+p", toggle_button["data-w-kbd-key-value"])
+
     def test_replace_revision(self):
         get_response = self.get()
         text_from_revision = get_response.context["form"].initial["text"]