Skip to content
Brok UI

Loading…

No results

Hotkeys

Open source

A global keyboard-shortcut registry: single keys, Mod combos (Cmd on macOS, Ctrl elsewhere) and two-key sequences, with scopes where the topmost wins, typing-safe defaults, a searchable cheat-sheet dialog and platform-aware kbd hints.

Version
v1.4.4
Stability
stable
License
MIT
Related
Command
Kbd
Dialog

Preview

Press a shortcut.

previews.components.hotkeys.default.blade.php Blade
{{-- Controls register their own shortcuts with x-hotkey: a plain key, a
     two-key sequence and a Mod chord. `?` opens the help dialog, which lists
     every registered shortcut through the hotkeys help list. The kbd hints
     come from hotkeys.hint and show Cmd on macOS, Ctrl elsewhere. --}}
<div
    x-data="{ last: '', labels: @js(['create' => __('Create issue'), 'projects' => __('Go to projects'), 'save' => __('Save'), 'idle' => __('Press a shortcut.'), 'ran' => __('Ran:')]) }"
    class="flex w-full max-w-md flex-col gap-4"
>
    <div class="flex flex-wrap items-center gap-2">
        <x-ui.button variant="outline" x-hotkey="c" data-hotkey-group="{{ __('Issues') }}" @click="last = labels.create">
            {{ __('Create issue') }}
            <x-ui.hotkeys.hint keys="c" />
        </x-ui.button>
        <x-ui.button variant="outline" x-hotkey="g p" data-hotkey-group="{{ __('Navigation') }}" @click="last = labels.projects">
            {{ __('Go to projects') }}
            <x-ui.hotkeys.hint keys="g p" />
        </x-ui.button>
        <x-ui.button variant="outline" x-hotkey.inputs="mod+enter" data-hotkey-group="{{ __('Issues') }}" @click="last = labels.save">
            {{ __('Save') }}
            <x-ui.hotkeys.hint keys="mod+enter" />
        </x-ui.button>
    </div>

    <x-ui.input :aria-label="__('Issue title')" :placeholder="__('Type here: c and g p are ignored, Mod+Enter saves')" />

    <p role="status" class="text-sm text-muted-foreground" data-slot="hotkeys-demo-status" x-text="last === '' ? labels.idle : `${labels.ran} ${last}`">{{ __('Press a shortcut.') }}</p>

    <x-ui.dialog>
        <x-ui.dialog.trigger x-hotkey="?" data-hotkey-group="{{ __('General') }}" data-hotkey-description="{{ __('Show keyboard shortcuts') }}" class="inline-flex w-fit items-center gap-2 rounded-md text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
            {{ __('Keyboard shortcuts') }}
            <x-ui.hotkeys.hint keys="?" />
        </x-ui.dialog.trigger>
        <x-ui.dialog.content size="sm">
            <x-ui.dialog.header>
                <x-ui.dialog.title>{{ __('Keyboard shortcuts') }}</x-ui.dialog.title>
            </x-ui.dialog.header>
            <x-ui.hotkeys class="mt-4" />
        </x-ui.dialog.content>
    </x-ui.dialog>
</div>

Installation

terminal
php artisan ui:add hotkeys

Note

This component ships an Alpine behavior module at resources/js/ui/hotkeys.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './hotkeys.js';

Registry contract

php artisan ui:add hotkeys writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/hotkeys.blade.php
  • blade resources/views/components/ui/hotkeys/hint.blade.php
  • blade resources/views/components/ui/hotkeys/dialog.blade.php
  • js resources/js/ui/hotkeys.js
  • js resources/js/ui/hotkeys-help.js
Registry dependencies
dialog input kbd switch
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

hotkeys.md
# Brok UI: Hotkeys (`hotkeys`)

A global keyboard-shortcut registry: single keys, Mod combos (Cmd on macOS, Ctrl elsewhere) and two-key sequences, with scopes where the topmost wins, typing-safe defaults, a searchable cheat-sheet dialog and platform-aware kbd hints.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add hotkeys
```

## Usage

```blade
{{-- Controls register their own shortcuts with x-hotkey: a plain key, a
     two-key sequence and a Mod chord. `?` opens the help dialog, which lists
     every registered shortcut through the hotkeys help list. The kbd hints
     come from hotkeys.hint and show Cmd on macOS, Ctrl elsewhere. --}}
<div
    x-data="{ last: '', labels: @js(['create' => __('Create issue'), 'projects' => __('Go to projects'), 'save' => __('Save'), 'idle' => __('Press a shortcut.'), 'ran' => __('Ran:')]) }"
    class="flex w-full max-w-md flex-col gap-4"
>
    <div class="flex flex-wrap items-center gap-2">
        <x-ui.button variant="outline" x-hotkey="c" data-hotkey-group="{{ __('Issues') }}" @click="last = labels.create">
            {{ __('Create issue') }}
            <x-ui.hotkeys.hint keys="c" />
        </x-ui.button>
        <x-ui.button variant="outline" x-hotkey="g p" data-hotkey-group="{{ __('Navigation') }}" @click="last = labels.projects">
            {{ __('Go to projects') }}
            <x-ui.hotkeys.hint keys="g p" />
        </x-ui.button>
        <x-ui.button variant="outline" x-hotkey.inputs="mod+enter" data-hotkey-group="{{ __('Issues') }}" @click="last = labels.save">
            {{ __('Save') }}
            <x-ui.hotkeys.hint keys="mod+enter" />
        </x-ui.button>
    </div>

    <x-ui.input :aria-label="__('Issue title')" :placeholder="__('Type here: c and g p are ignored, Mod+Enter saves')" />

    <p role="status" class="text-sm text-muted-foreground" data-slot="hotkeys-demo-status" x-text="last === '' ? labels.idle : `${labels.ran} ${last}`">{{ __('Press a shortcut.') }}</p>

    <x-ui.dialog>
        <x-ui.dialog.trigger x-hotkey="?" data-hotkey-group="{{ __('General') }}" data-hotkey-description="{{ __('Show keyboard shortcuts') }}" class="inline-flex w-fit items-center gap-2 rounded-md text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
            {{ __('Keyboard shortcuts') }}
            <x-ui.hotkeys.hint keys="?" />
        </x-ui.dialog.trigger>
        <x-ui.dialog.content size="sm">
            <x-ui.dialog.header>
                <x-ui.dialog.title>{{ __('Keyboard shortcuts') }}</x-ui.dialog.title>
            </x-ui.dialog.header>
            <x-ui.hotkeys class="mt-4" />
        </x-ui.dialog.content>
    </x-ui.dialog>
</div>
```

## Props

- `headingTag` (h2|h3|h4|h5|h6, default `h3`) — Element for each group heading in the help list.
- `emptyText` (string, default `No keyboard shortcuts are available.`) — Shown when no registered shortcut carries a description.
- `characterKeysToggle` (bool, default `true`) — Render a switch in the help list that turns single-key shortcuts ("?", "g p") off, stored in localStorage (WCAG 2.1.4). It shows only when such a shortcut is registered.
- `searchable` (bool, default `false`) — Renders a search field that filters the list by description, group and keys; every word must match.
- `searchLabel` (string, default `Search shortcuts`) — Accessible name and placeholder of the search field.
- `noResultsText` (string, default `No shortcuts match your search.`) — Text shown when a search matches nothing.
- `entries` (array, default `[]`) — Shortcuts handled outside the registry, listed with the registered ones: [['keys' => 'mod+b', 'description' => 'Bold', 'group' => 'Editor']]. Descriptions and groups are translated; a duplicate of a registered shortcut is dropped.
- `keys` (string, default ``) — Declared by @props in the registry Blade source.
- `decorative` (bool, default `true`) — Declared by @props in the registry Blade source.
- `title` (string, default `Keyboard shortcuts`) — Declared by @props in the registry Blade source.
- `description` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `group` (string, default `General`) — Declared by @props in the registry Blade source.
- `openEvent` (string, default `hotkeys:open`) — Declared by @props in the registry Blade source.
- `size` (string, default `md`) — Declared by @props in the registry Blade source.

## Use when

- Use to orient users and help them move across pages, sections, or commands.
- A keyboard-first app needs shortcuts that several items register with one registry instead of each adding its own window listener.
- You need two-key navigation sequences (g then p), a shortcuts help dialog, or kbd hints that show Cmd on macOS and Ctrl elsewhere.

## Avoid when

- Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
- A shortcut only matters while one field has focus; bind keydown on that field instead.
- Arrow-key movement inside a composite widget (menu, listbox, grid); that belongs to the widget's own keyboard model.

## Anti-patterns

- Hiding primary wayfinding in novelty interactions

## Rules

- Use the `<brok:hotkeys>` tag (or `<x-ui.hotkeys>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/hotkeys
- Registry JSON (files, props, contract): https://brokui.dev/r/open/hotkeys.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

help-dialog.blade.php Blade
{{-- A keyboard-shortcuts help dialog. The shortcuts are registered from
     script with $hotkeys.register(); the list groups them and updates live. --}}
<div
    x-data
    x-init="
        $hotkeys.register('mod+k', () => {}, { description: @js(__('Open command palette')), group: @js(__('General')) });
        $hotkeys.register('g i', () => {}, { description: @js(__('Go to inbox')), group: @js(__('Navigation')) });
        $hotkeys.register('g p', () => {}, { description: @js(__('Go to projects')), group: @js(__('Navigation')) });
        $hotkeys.register('c', () => {}, { description: @js(__('Create issue')), group: @js(__('Issues')) });
        $hotkeys.register('mod+shift+c', () => {}, { description: @js(__('Copy issue link')), group: @js(__('Issues')) });
    "
    class="w-full max-w-sm rounded-lg border border-border bg-card p-6 text-card-foreground"
>
    <h2 class="mb-4 text-base font-semibold">{{ __('Keyboard shortcuts') }}</h2>
    <x-ui.hotkeys />
</div>
long-content.blade.php Blade
<div
    x-data
    x-init="
        $hotkeys.register('mod+shift+alt+arrowdown', () => {}, { description: @js(__('Move the selected issues to the bottom of the current cycle and notify every subscriber of the change')), group: @js(__('A deliberately long group name that must wrap without clipping')) });
        $hotkeys.register('g s', () => {}, { description: @js(__('Go to settings')), group: @js(__('A deliberately long group name that must wrap without clipping')) });
    "
    class="max-w-xs"
>
    <x-ui.hotkeys />
    <x-ui.hotkeys.hint keys="mod+shift+alt+arrowdown" :decorative="false" class="mt-4" />
</div>
scopes.blade.php Blade
{{-- Scopes: both panels bind E. The panel that holds focus wins; with
     neither focused, the panel that became live last wins. A binding inside
     an open modal wins over both, and bindings behind the modal stay quiet. --}}
<div
    x-data="{ last: '', labels: @js(['list' => __('List: archived'), 'detail' => __('Detail: edited'), 'idle' => __('Focus a panel, then press E.')]) }"
    class="flex w-full max-w-xl flex-col gap-4"
>
    <div class="grid gap-4 sm:grid-cols-2">
        <section data-hotkey-scope="list" tabindex="-1" aria-labelledby="hotkeys-scope-list" class="flex flex-col gap-2 rounded-lg border border-border p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
            <h3 id="hotkeys-scope-list" class="text-sm font-medium">{{ __('List') }}</h3>
            <x-ui.button size="sm" variant="outline" x-hotkey="e" data-hotkey-group="{{ __('List') }}" @click="last = labels.list">
                {{ __('Archive') }}
                <x-ui.hotkeys.hint keys="e" />
            </x-ui.button>
        </section>
        <section data-hotkey-scope="detail" tabindex="-1" aria-labelledby="hotkeys-scope-detail" class="flex flex-col gap-2 rounded-lg border border-border p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
            <h3 id="hotkeys-scope-detail" class="text-sm font-medium">{{ __('Detail') }}</h3>
            <x-ui.button size="sm" variant="outline" x-hotkey="e" data-hotkey-group="{{ __('Detail') }}" @click="last = labels.detail">
                {{ __('Edit') }}
                <x-ui.hotkeys.hint keys="e" />
            </x-ui.button>
        </section>
    </div>
    <p role="status" class="text-sm text-muted-foreground" data-slot="hotkeys-demo-status" x-text="last === '' ? labels.idle : last">{{ __('Focus a panel, then press E.') }}</p>
</div>
shortcuts-dialog.blade.php Blade
{{-- The keyboard-shortcuts cheat sheet. Press ? (outside a text field) or
     use the button, which dispatches hotkeys:open as a menu item would. The
     sheet lists the registered shortcuts by section, plus editor shortcuts
     passed as entries, and filters as you type. While a toast with an action
     key is visible (Archive item, then ?), its key is listed under
     Notifications until the toast closes. --}}
<div
    x-data
    x-init="
        $hotkeys.register('mod+k', () => {}, { description: @js(__('Open command menu')), group: @js(__('General')) });
        $hotkeys.register('g i', () => {}, { description: @js(__('Go to inbox')), group: @js(__('Navigation')) });
        $hotkeys.register('g p', () => {}, { description: @js(__('Go to projects')), group: @js(__('Navigation')) });
        $hotkeys.register('c', () => {}, { description: @js(__('Create task')), group: @js(__('Tasks')) });
        $hotkeys.register('x', () => {}, { description: @js(__('Select task')), group: @js(__('Lists')) });
    "
    class="flex flex-col items-start gap-3"
>
    <x-ui.button variant="outline" x-on:click="$dispatch('hotkeys:open')">
        {{ __('Keyboard shortcuts') }}
        <x-ui.hotkeys.hint keys="?" />
    </x-ui.button>

    <x-ui.button
        variant="outline"
        x-on:click="window.toast({
            message: {{ \Illuminate\Support\Js::from(__('Item archived.')) }},
            timeout: 0,
            action: { label: {{ \Illuminate\Support\Js::from(__('Undo archive')) }}, event: 'item-restore', key: 'z' },
        })"
    >
        {{ __('Archive item') }}
    </x-ui.button>

    <x-ui.toaster :event="null" />

    <x-ui.hotkeys.dialog :entries="[
        ['keys' => 'mod+b', 'description' => 'Bold', 'group' => 'Editor'],
        ['keys' => 'mod+i', 'description' => 'Italic', 'group' => 'Editor'],
    ]" />
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
headingTag h2 | h3 | h4 | h5 | h6 h3 Element for each group heading in the help list.
emptyText string No keyboard shortcuts are available. Shown when no registered shortcut carries a description.
characterKeysToggle bool true Render a switch in the help list that turns single-key shortcuts ("?", "g p") off, stored in localStorage (WCAG 2.1.4). It shows only when such a shortcut is registered.
searchable bool false Renders a search field that filters the list by description, group and keys; every word must match.
searchLabel string Search shortcuts Accessible name and placeholder of the search field.
noResultsText string No shortcuts match your search. Text shown when a search matches nothing.
entries array [] Shortcuts handled outside the registry, listed with the registered ones: [['keys' => 'mod+b', 'description' => 'Bold', 'group' => 'Editor']]. Descriptions and groups are translated; a duplicate of a registered shortcut is dropped.
keys string Declared by @props in the registry Blade source.
decorative bool true Declared by @props in the registry Blade source.
title string Keyboard shortcuts Declared by @props in the registry Blade source.
description mixed | null null Declared by @props in the registry Blade source.
group string General Declared by @props in the registry Blade source.
openEvent string hotkeys:open Declared by @props in the registry Blade source.
size string md Declared by @props in the registry Blade source.

Slots

  • x-ui.hotkeys.hint — Installed subcomponent from the registry item.
  • x-ui.hotkeys.dialog — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

dialog hotkeys hotkeys-character-keys hotkeys-empty hotkeys-group hotkeys-hint hotkeys-item hotkeys-item-off hotkeys-search

Behavior

  • One document keydown listener serves every binding. Register with Alpine.store('hotkeys').register(keys, handler, options), $hotkeys.register(...) or x-hotkey="keys" on a control (the shortcut clicks it, or focuses a text field). register() returns a handle { id, unregister() } (not a function, which Alpine would call from x-init); $hotkeys.register() and x-hotkey unregister when their element is removed. An unregistered binding never runs, not even as the second key of a sequence that was already waiting.
  • Scopes: a binding inside an element with data-hotkey-scope belongs to that scope ($hotkeys.register() and x-hotkey both use the closest one; pass scope: 'global' to opt out); a scope is live while rendered and not inert. An x-hotkey control must itself be rendered and outside an inert subtree, so a hidden button never receives the click; a binding that must reach hidden UI (the command palette opening its closed dialog) registers through the store. The topmost scope wins: the scope holding focus (deepest first), then the one that became live last, then the global layer.
  • An open modal overlay (aria-modal="true") blocks every binding that was not registered from inside it, so shortcuts never act behind a dialog, sheet or drawer focus trap. It stops blocking as soon as it starts to close (the x-show on the panel or its nearest ancestor is false, or a dialog's beforeLeave hook runs), not when its leave transition ends.
  • Shortcuts are ignored while typing in inputs, textareas, selects and contenteditable regions, and for plain letters inside menus and listboxes (typeahead), unless the binding sets inputs: true (x-hotkey.inputs). Key repeat and IME composition are ignored unless repeat: true.
  • A sequence waits one second for its second key. A single-key binding on the same key fires only when no second key follows.
  • list() entries carry active and inactiveReason (null, 'character-keys' or 'unavailable': enabled() says no, the x-hotkey control is hidden, disabled or removed, or the scope is not on the page); an open modal and the inert page behind it do not count. list({ active: true }) returns only the shortcuts that can fire.
  • The help list (<x-ui.hotkeys>) shows every registered binding with a description that can fire, grouped by its group, and updates live; it leaves out unavailable ones and marks single-key shortcuts Off (data-active="false") while they are switched off. It reads the registry again each time it comes into view, and builds the list only while it is in view: a closed help dialog does not rebuild it on every change of the registry. $store.hotkeys.version counts changes to the bindings and the spoken words. $hotkeys.format(keys), spoken(keys) and aria(keys) give display labels, screen-reader text and an aria-keyshortcuts value in DOM key names (ArrowUp, Escape, PageUp, Plus).
  • <x-ui.hotkeys.hint keys="mod+k"> renders a shortcut as kbd keys: + joins a chord (mod+shift+k), a space separates sequence steps (g p); mod is Cmd on macOS and Ctrl elsewhere. It is decorative (aria-hidden) by default; :decorative="false" adds screen-reader text when the hint is the only place the shortcut is named.
  • <x-ui.hotkeys.dialog> is a keyboard-shortcuts cheat sheet: a dialog with the searchable list, opened by ? (or its keys prop) and by the hotkeys:open window event. Place it once in the app layout; the search field takes focus when it opens.
  • The search filters live as you type and hides empty sections; the list still updates as shortcuts register or unregister.
  • hotkeys.dialog props: keys (default ?; an empty string registers none), openEvent (default hotkeys:open; an empty string opts out), title (Keyboard shortcuts, also the description of its opening shortcut), description, group (General, the section its own shortcut is listed under), searchable (true), entries, characterKeysToggle and size (md).
  • hotkeys.hint hides a shortcut without Ctrl, Cmd or Alt ("c", "g i", "?") while $store.hotkeys.characterKeys is false, because the registry ignores it then, and shows it again when the switch is on. $store.hotkeys.isCharacter(keys) and showsHint(keys) expose the same rule for other hints.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, behaviorTest, authoredStateFixtures, responsive, rtl, darkMode, localized.

Guidance

Navigation and orientation

Orient users and move between destinations.

Use when

  • Use to orient users and help them move across pages, sections, or commands.
  • A keyboard-first app needs shortcuts that several items register with one registry instead of each adding its own window listener.
  • You need two-key navigation sequences (g then p), a shortcuts help dialog, or kbd hints that show Cmd on macOS and Ctrl elsewhere.

Avoid when

  • Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
  • A shortcut only matters while one field has focus; bind keydown on that field instead.
  • Arrow-key movement inside a composite widget (menu, listbox, grid); that belongs to the widget's own keyboard model.

Use instead

  • Visible links and local navigation

Anti-patterns

  • Hiding primary wayfinding in novelty interactions
Anatomy
root group item hint search dialog
Theming hooks
kbd hotkeys item divider group heading

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
managed
  • x-hotkey sets aria-keyshortcuts on single-chord controls; sequences cannot be expressed there, so name them in the help list.
  • The help list reads each shortcut as words (Command+K, G then P) while the kbd glyphs are hidden from assistive technology.
  • Keep every shortcut supplemental: the same action must stay reachable by pointer and Tab.
  • Single-key shortcuts (no Ctrl, Cmd or Alt) can be switched off from the help list or with $store.hotkeys.characterKeys = false, and never fire while typing (WCAG 2.1.4 Character Key Shortcuts).
  • Key hints keep left-to-right key order under dir="rtl" (Ctrl then K), as printed on the keyboard.
  • The cheat sheet is a modal dialog with a title, focus trap and Escape; the search field has an accessible name, and a polite live region says when nothing matches.
  • Typing ? in a text field never opens the dialog, because single-key shortcuts ignore typing.
  • Screen-reader names of key combinations ("G then I", "Command+K", "Space") are translated: the help list and a non-decorative hint pass then, Command, Control, Option, Windows, Alt, Shift, Space, Enter, Escape, Tab, Backspace, Delete, Up Arrow, Down Arrow, Left Arrow, Right Arrow, Home, End, Page Up and Page Down through __() to $store.hotkeys.localize(). Add them to the app's lang JSON.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="hotkeys-{{ $record->id }}">
    {{-- Controls register their own shortcuts with x-hotkey: a plain key, a
         two-key sequence and a Mod chord. `?` opens the help dialog, which lists
         every registered shortcut through the hotkeys help list. The kbd hints
         come from hotkeys.hint and show Cmd on macOS, Ctrl elsewhere. --}}
    <div
        x-data="{ last: '', labels: @js(['create' => __('Create issue'), 'projects' => __('Go to projects'), 'save' => __('Save'), 'idle' => __('Press a shortcut.'), 'ran' => __('Ran:')]) }"
        class="flex w-full max-w-md flex-col gap-4"
    >
        <div class="flex flex-wrap items-center gap-2">
            <x-ui.button variant="outline" x-hotkey="c" data-hotkey-group="{{ __('Issues') }}" @click="last = labels.create">
                {{ __('Create issue') }}
                <x-ui.hotkeys.hint keys="c" />
            </x-ui.button>
            <x-ui.button variant="outline" x-hotkey="g p" data-hotkey-group="{{ __('Navigation') }}" @click="last = labels.projects">
                {{ __('Go to projects') }}
                <x-ui.hotkeys.hint keys="g p" />
            </x-ui.button>
            <x-ui.button variant="outline" x-hotkey.inputs="mod+enter" data-hotkey-group="{{ __('Issues') }}" @click="last = labels.save">
                {{ __('Save') }}
                <x-ui.hotkeys.hint keys="mod+enter" />
            </x-ui.button>
        </div>
    
        <x-ui.input :aria-label="__('Issue title')" :placeholder="__('Type here: c and g p are ignored, Mod+Enter saves')" />
    
        <p role="status" class="text-sm text-muted-foreground" data-slot="hotkeys-demo-status" x-text="last === '' ? labels.idle : `${labels.ran} ${last}`">{{ __('Press a shortcut.') }}</p>
    
        <x-ui.dialog>
            <x-ui.dialog.trigger x-hotkey="?" data-hotkey-group="{{ __('General') }}" data-hotkey-description="{{ __('Show keyboard shortcuts') }}" class="inline-flex w-fit items-center gap-2 rounded-md text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
                {{ __('Keyboard shortcuts') }}
                <x-ui.hotkeys.hint keys="?" />
            </x-ui.dialog.trigger>
            <x-ui.dialog.content size="sm">
                <x-ui.dialog.header>
                    <x-ui.dialog.title>{{ __('Keyboard shortcuts') }}</x-ui.dialog.title>
                </x-ui.dialog.header>
                <x-ui.hotkeys class="mt-4" />
            </x-ui.dialog.content>
        </x-ui.dialog>
    </div>
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/hotkeys.blade.php Blade
{{--
    Hotkeys help list. Renders every shortcut registered with the hotkey
    registry (the `hotkeys` store, `x-hotkey`, `$hotkeys.register()`) that
    carries a description, grouped by its `group`. Put it in a dialog opened by
    `?` for a keyboard-shortcuts help panel. The list is live: shortcuts that
    register or unregister while it is open appear or disappear. Only
    shortcuts that can fire are listed (not those of a hidden, disabled or
    removed control); single-key shortcuts stay listed, marked off, while the
    switch below has them turned off.
--}}
@props([
    // Heading element for each group name ('h2'…'h6'); match the host outline.
    'headingTag' => 'h3',
    'emptyText' => 'No keyboard shortcuts are available.',
    // Render the switch that turns single-key shortcuts ("?", "g p") off —
    // the WCAG 2.1.4 mechanism. It shows only when such a shortcut exists.
    'characterKeysToggle' => true,
    // A search field that filters by description, group and keys.
    'searchable' => false,
    'searchLabel' => 'Search shortcuts',
    'noResultsText' => 'No shortcuts match your search.',
    // Shortcuts handled outside the registry (an editor's Mod+B), listed
    // with the registered ones: [['keys' => 'mod+b', 'description' => 'Bold', 'group' => 'Editor']].
    'entries' => [],
])

@php
    $headingTag = in_array($headingTag, ['h2', 'h3', 'h4', 'h5', 'h6'], true) ? $headingTag : 'h3';
    $entries = collect(is_iterable($entries) ? $entries : [])
        ->map(fn ($entry) => [
            'keys' => (string) data_get($entry, 'keys', ''),
            'description' => __((string) data_get($entry, 'description', '')),
            'group' => __((string) data_get($entry, 'group', '')),
        ])
        ->filter(fn (array $entry) => $entry['keys'] !== '' && $entry['description'] !== '')
        ->values()
        ->all();
    // Spoken names for screen-reader text, translated through __() so an app
    // can localise "G then I" or "Command+K". Keyed by the English name the
    // hotkeys registry uses; the same list lives in hint.blade.php.
    $spokenWords = collect([
        'then', 'Command', 'Control', 'Option', 'Windows', 'Alt', 'Shift',
        'Space', 'Enter', 'Escape', 'Tab', 'Backspace', 'Delete',
        'Up Arrow', 'Down Arrow', 'Left Arrow', 'Right Arrow', 'Home', 'End', 'Page Up', 'Page Down',
    ])->mapWithKeys(fn (string $word): array => [$word => __($word)])->all();
@endphp

<div
    data-slot="hotkeys"
    x-data="uiHotkeys({ entries: @js($entries), words: @js($spokenWords) })"
    {{ $attributes->merge(['class' => 'flex min-w-0 flex-col gap-4 text-foreground']) }}
>
    @if ($searchable)
        <x-ui.input
            type="search"
            size="sm"
            data-slot="hotkeys-search"
            x-model="query"
            autocomplete="off"
            :aria-label="__($searchLabel)"
            :placeholder="__($searchLabel)"
        />
    @endif
    <template x-for="group in groups" :key="group.name">
        <section data-slot="hotkeys-group" class="min-w-0">
            <template x-if="group.name !== ''">
                <{{ $headingTag }} class="mb-1 text-xs font-medium text-muted-foreground" x-text="group.name"></{{ $headingTag }}>
            </template>
            <dl class="divide-y divide-border">
                <template x-for="item in group.items" :key="item.id">
                    <div data-slot="hotkeys-item" x-bind:data-active="item.active ? 'true' : 'false'" class="flex min-w-0 items-center justify-between gap-4 py-2 data-[active=false]:text-muted-foreground">
                        <dt class="min-w-0 break-words text-sm" x-text="item.description"></dt>
                        <dd class="flex shrink-0 items-center gap-2">
                            <span data-slot="hotkeys-item-off" x-show="!item.active" class="text-xs">{{ __('Off') }}</span>
                            <span class="sr-only" x-text="item.spoken"></span>
                            <span aria-hidden="true" dir="ltr" class="inline-flex items-center gap-1">
                                <template x-for="(step, index) in item.steps" :key="index">
                                    <span class="inline-flex items-center gap-1">
                                        <template x-if="index > 0">
                                            <span class="text-xs text-muted-foreground">{{ __('then') }}</span>
                                        </template>
                                        <x-ui.kbd.group>
                                            <template x-for="(label, key) in step" :key="key">
                                                <x-ui.kbd x-text="label"></x-ui.kbd>
                                            </template>
                                        </x-ui.kbd.group>
                                    </span>
                                </template>
                            </span>
                        </dd>
                    </div>
                </template>
            </dl>
        </section>
    </template>
    <div aria-live="polite" aria-atomic="true">
        <p data-slot="hotkeys-empty" x-show="groups.length === 0" class="text-sm text-muted-foreground" x-text="query.trim() !== '' && total > 0 ? @js(__($noResultsText)) : @js(__($emptyText))">{{ __($emptyText) }}</p>
    </div>
    @if ($characterKeysToggle)
        <div data-slot="hotkeys-character-keys" x-show="hasCharacterKeys" class="border-t border-border pt-4">
            <x-ui.switch size="sm" x-model="$store.hotkeys.characterKeys">
                <span class="text-sm">{{ __('Single-key shortcuts') }}</span>
            </x-ui.switch>
        </div>
    @endif
</div>
resources/views/components/ui/hotkeys/hint.blade.php Blade
{{--
    Hotkey hint: renders a shortcut as <kbd> keys for this platform. `mod` shows
    as ⌘ on macOS and Ctrl elsewhere; a two-step sequence ("g p") shows both
    steps. The server renders the non-macOS labels; the registry swaps them on
    macOS once Alpine starts.

    Decorative by default (aria-hidden): put `aria-keyshortcuts` on the control
    it describes (`x-hotkey` does that for you). Set :decorative="false" when
    the hint is the only place the shortcut is named.

    A shortcut without Ctrl, Cmd or Alt ("g i", "?") is hidden while the user
    has switched character-key shortcuts off ($store.hotkeys.characterKeys),
    because it does nothing then; it shows again when they switch them on.
--}}
@props([
    'keys' => '',
    'decorative' => true,
])

@php
    $aliases = [
        'cmd' => 'meta', 'command' => 'meta', 'super' => 'meta', 'win' => 'meta',
        'control' => 'ctrl', 'ctl' => 'ctrl', 'option' => 'alt', 'opt' => 'alt',
        'esc' => 'escape', 'return' => 'enter', 'up' => 'arrowup', 'down' => 'arrowdown',
        'left' => 'arrowleft', 'right' => 'arrowright', 'space' => ' ', 'spacebar' => ' ',
        'del' => 'delete', 'plus' => '+',
    ];
    $keyLabels = [
        ' ' => 'Space', 'enter' => 'Enter', 'escape' => 'Esc', 'tab' => 'Tab', 'backspace' => 'Backspace',
        'delete' => 'Delete', 'arrowup' => '↑', 'arrowdown' => '↓', 'arrowleft' => '←', 'arrowright' => '→',
        'home' => 'Home', 'end' => 'End', 'pageup' => 'Page Up', 'pagedown' => 'Page Down',
    ];
    $modifierLabels = ['ctrl' => 'Ctrl', 'meta' => 'Win', 'alt' => 'Alt', 'shift' => 'Shift'];
    // Spoken names for screen-reader text, translated through __() so an app
    // can localise "G then I" or "Command+K". Keyed by the English name the
    // hotkeys registry uses; the same list lives in hotkeys.blade.php.
    $spokenWords = collect([
        'then', 'Command', 'Control', 'Option', 'Windows', 'Alt', 'Shift',
        'Space', 'Enter', 'Escape', 'Tab', 'Backspace', 'Delete',
        'Up Arrow', 'Down Arrow', 'Left Arrow', 'Right Arrow', 'Home', 'End', 'Page Up', 'Page Down',
    ])->mapWithKeys(fn (string $word): array => [$word => __($word)])->all();
    $spokenModifiers = ['meta' => $spokenWords['Windows'], 'ctrl' => $spokenWords['Control'], 'alt' => $spokenWords['Alt'], 'shift' => $spokenWords['Shift']];
    $spokenKeys = [
        ' ' => 'Space', 'enter' => 'Enter', 'escape' => 'Escape', 'tab' => 'Tab', 'backspace' => 'Backspace', 'delete' => 'Delete',
        'arrowup' => 'Up Arrow', 'arrowdown' => 'Down Arrow', 'arrowleft' => 'Left Arrow', 'arrowright' => 'Right Arrow',
        'home' => 'Home', 'end' => 'End', 'pageup' => 'Page Up', 'pagedown' => 'Page Down',
    ];

    $steps = [];
    $spoken = [];
    foreach (array_slice(preg_split('/\s+/', trim((string) $keys), -1, PREG_SPLIT_NO_EMPTY), 0, 2) as $step) {
        $parts = explode('+', mb_strtolower($step));
        if (count($parts) > 1 && end($parts) === '' && $parts[count($parts) - 2] === '') {
            array_splice($parts, -2, 2, ['+']);
        }
        $chord = ['ctrl' => false, 'meta' => false, 'alt' => false, 'shift' => false];
        $key = '';
        foreach ($parts as $part) {
            $part = $aliases[$part] ?? $part;
            if ($part === 'mod') {
                $chord['ctrl'] = true;
            } elseif (array_key_exists($part, $chord)) {
                $chord[$part] = true;
            } else {
                $key = $part;
            }
        }
        $label = $keyLabels[$key] ?? (mb_strlen($key) === 1 ? mb_strtoupper($key) : ucfirst($key));
        $steps[] = [...array_values(array_map(fn ($m) => $modifierLabels[$m], array_keys(array_filter($chord)))), $label];
        $spokenOrder = ['meta', 'ctrl', 'alt', 'shift'];
        $spokenKey = isset($spokenKeys[$key]) ? $spokenWords[$spokenKeys[$key]] : $label;
        $spoken[] = implode('+', [...array_map(fn ($m) => $spokenModifiers[$m], array_values(array_filter($spokenOrder, fn ($m) => $chord[$m]))), $spokenKey]);
    }
    $then = $spokenWords['then'];
@endphp

<span
    data-slot="hotkeys-hint"
    data-keys="{{ $keys }}"
    x-data="uiHotkeyHint({ keys: @js((string) $keys), then: @js($then), words: @js($decorative ? null : $spokenWords) })"
    x-show="shown"
    {{ $attributes->merge(['class' => 'inline-flex shrink-0 items-center gap-1']) }}
>
    @unless ($decorative)
        <span class="sr-only" x-text="spokenText">{{ implode(' '.$then.' ', $spoken) }}</span>
    @endunless
    {{-- Keys read left to right in every language, so the order holds under RTL. --}}
    <span aria-hidden="true" dir="ltr" class="inline-flex items-center gap-1">
        @foreach ($steps as $s => $labels)
            @if ($s > 0)
                <span class="text-xs text-muted-foreground">{{ $then }}</span>
            @endif
            <x-ui.kbd.group>
                @foreach ($labels as $i => $label)
                    <x-ui.kbd x-text="steps[{{ $s }}]?.[{{ $i }}] ?? {{ \Illuminate\Support\Js::from($label) }}">{{ $label }}</x-ui.kbd>
                @endforeach
            </x-ui.kbd.group>
        @endforeach
    </span>
</span>
resources/views/components/ui/hotkeys/dialog.blade.php Blade
{{--
    Keyboard-shortcuts dialog: a searchable cheat sheet of every shortcut in
    the hotkey registry, grouped by section. Place it once in the app layout.
    It opens with "?" (the `keys` prop, registered in the registry itself, so
    it is listed and can be switched off with the other single-key shortcuts)
    and with a window event, so a menu item can open it:

        <x-ui.dropdown.item x-on:click="$dispatch('hotkeys:open')">Keyboard shortcuts</x-ui.dropdown.item>
--}}
@props([
    // Shortcut that opens the dialog; '' registers none.
    'keys' => '?',
    'title' => 'Keyboard shortcuts',
    'description' => null,
    // Registry group the opening shortcut is listed under.
    'group' => 'General',
    // Window event that opens the dialog; '' opts out.
    'openEvent' => 'hotkeys:open',
    'searchable' => true,
    'entries' => [],
    'characterKeysToggle' => true,
    'size' => 'md',
])

@php
    $title = __($title);
    $openEvent = filled($openEvent) ? preg_replace('/[^a-zA-Z0-9:_-]/', '', (string) $openEvent) : null;
    $keys = filled($keys) ? (string) $keys : null;

    // Blade cannot branch inside a component tag, so the optional
    // listeners are built here and merged onto the dialog root.
    $rootAttributes = [];
    if ($keys) {
        $rootAttributes['x-init'] = '$hotkeys.register('.\Illuminate\Support\Js::from($keys).', () => show(), { description: '.\Illuminate\Support\Js::from($title).', group: '.\Illuminate\Support\Js::from(__($group)).", scope: 'global' })";
    }
    if ($openEvent) {
        $rootAttributes['x-on:'.$openEvent.'.window'] = 'show()';
    }
@endphp

{{-- The dialog root already carries data-slot="dialog"; this part marks
     itself with data-hotkeys-dialog. --}}
<x-ui.dialog data-hotkeys-dialog {{ $attributes->merge($rootAttributes) }}>
    <x-ui.dialog.content :size="$size" scrollable>
        <x-ui.dialog.close />
        <x-ui.dialog.header>
            <x-ui.dialog.title>{{ $title }}</x-ui.dialog.title>
            @if (filled($description))
                <x-ui.dialog.description>{{ __($description) }}</x-ui.dialog.description>
            @endif
        </x-ui.dialog.header>
        <x-ui.hotkeys
            class="mt-4"
            heading-tag="h3"
            :searchable="$searchable"
            :entries="$entries"
            :character-keys-toggle="$characterKeysToggle"
        />
    </x-ui.dialog.content>
</x-ui.dialog>
resources/js/ui/hotkeys.js JS
/**
 * Global hotkey registry.
 *
 * One document-level keydown listener serves every shortcut in the app. Items
 * and apps register through the `hotkeys` Alpine store, the `$hotkeys` magic or
 * the `x-hotkey` directive:
 *
 *   Alpine.store('hotkeys').register('mod+k', () => …, { description: 'Search' })
 *   <div x-init="$hotkeys.register('c', () => create())">…</div>
 *   <button x-hotkey="g p">Projects</button>
 *
 * register() returns a handle ({ id, unregister() }), not a function, because
 * Alpine calls a function that an x-init expression returns. `$hotkeys` binds
 * the registration to its element: it unregisters when the element is removed.
 *
 * Key syntax: `+` joins a chord (`mod+shift+k`), a space separates the two
 * steps of a sequence (`g p`). `mod` is Cmd on macOS and Ctrl elsewhere.
 *
 * Scopes: a binding is global unless it belongs to a scope element
 * (`data-hotkey-scope`). A scope is live while it is rendered and not inert.
 * When several live bindings share a key, the topmost scope wins: the scope
 * that holds focus (deepest first), then the scope that became live last, then
 * the global layer. An open modal overlay (`aria-modal="true"`) blocks every
 * binding that was not registered from inside it, so shortcuts never act
 * behind a dialog's focus trap. A modal stops blocking as soon as it starts
 * to close (its `x-show` is false or a dialog's `closing` is true), not when
 * its leave transition ends, so a key pressed right after a choice works.
 *
 * Typing: shortcuts are ignored while focus is in a text input, textarea,
 * select or contenteditable region (and plain letters inside a menu or listbox,
 * which use them for typeahead) unless a binding opts in with `inputs: true`.
 */

const MODIFIERS = ['meta', 'ctrl', 'alt', 'shift'];

const ALIASES = {
    cmd: 'meta',
    command: 'meta',
    super: 'meta',
    win: 'meta',
    control: 'ctrl',
    ctl: 'ctrl',
    option: 'alt',
    opt: 'alt',
    esc: 'escape',
    return: 'enter',
    up: 'arrowup',
    down: 'arrowdown',
    left: 'arrowleft',
    right: 'arrowright',
    space: ' ',
    spacebar: ' ',
    del: 'delete',
    plus: '+',
};

const NON_TEXT_INPUTS = ['checkbox', 'radio', 'button', 'submit', 'reset', 'range', 'color', 'file', 'image'];

// Where the "single-key shortcuts" preference is kept (WCAG 2.1.4).
const CHARACTER_KEYS_STORAGE = 'hotkeys:character-keys';

// The second key of a sequence must follow within this window.
const SEQUENCE_TIMEOUT = 1000;

function detectMac() {
    const platform = navigator.userAgentData?.platform || navigator.platform || navigator.userAgent || '';

    return /mac|iphone|ipad|ipod/i.test(platform);
}

/** "mod+shift+k" → { key: 'k', meta, ctrl, alt, shift } for this platform. */
function parseChord(step, mac) {
    const parts = step.toLowerCase().split('+');
    // A trailing "+" is the plus key itself ("ctrl++").
    if (parts.length > 1 && parts[parts.length - 1] === '' && parts[parts.length - 2] === '') {
        parts.splice(-2, 2, '+');
    }
    const chord = { key: '', meta: false, ctrl: false, alt: false, shift: false };
    for (const raw of parts) {
        const part = ALIASES[raw] ?? raw;
        if (part === 'mod') {
            chord[mac ? 'meta' : 'ctrl'] = true;
        } else if (MODIFIERS.includes(part)) {
            chord[part] = true;
        } else {
            chord.key = part;
        }
    }

    return chord;
}

function parseKeys(keys, mac) {
    return String(keys)
        .trim()
        .split(/\s+/)
        .filter(Boolean)
        .slice(0, 2)
        .map((step) => parseChord(step, mac));
}

/** A chord with no Ctrl, Cmd or Alt on a printable key ("?", "g", "shift+c"). */
function isCharacterChord(chord) {
    return chord.key.length === 1 && !chord.meta && !chord.ctrl && !chord.alt;
}

function eventKey(event) {
    // Option (Alt) on macOS turns a letter into a symbol; fall back to the
    // physical key so `alt+k` still matches.
    if (event.altKey && /^Key[A-Z]$/.test(event.code || '')) return event.code.slice(3).toLowerCase();
    if (event.altKey && /^Digit[0-9]$/.test(event.code || '')) return event.code.slice(5);

    return String(event.key || '').toLowerCase();
}

/** A printable symbol already encodes Shift ("?" is Shift+/ on most layouts). */
function isSymbol(key) {
    return key.length === 1 && !/[a-z0-9 ]/.test(key);
}

function matches(chord, event) {
    if (!chord || eventKey(event) !== chord.key) return false;
    if (event.metaKey !== chord.meta || event.ctrlKey !== chord.ctrl || event.altKey !== chord.alt) return false;

    return isSymbol(chord.key) || event.shiftKey === chord.shift;
}

function isTyping(target, event) {
    if (!(target instanceof Element)) return false;
    if (target.isContentEditable) return true;
    const tag = target.tagName;
    if (tag === 'TEXTAREA' || tag === 'SELECT') return true;
    if (tag === 'INPUT') return !NON_TEXT_INPUTS.includes((target.getAttribute('type') || 'text').toLowerCase());

    // Menus and listboxes use plain letters for typeahead.
    const printable = String(event.key || '').length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey;

    return printable && Boolean(target.closest('[role="menu"], [role="menubar"], [role="listbox"], [role="tree"], [role="grid"]'));
}

function rendered(el) {
    if (el.getClientRects().length > 0) return true;

    // A `display: contents` wrapper has no box of its own.
    return getComputedStyle(el).display === 'contents'
        && Array.from(el.children).some((child) => child.getClientRects().length > 0);
}

function live(el) {
    return el.isConnected && !el.closest('[inert]') && rendered(el);
}

function isModalDialog(el) {
    try {
        return el.matches(':modal');
    } catch {
        return false;
    }
}

/** Visible text of a control, without its decorative key hints. */
function textOf(el) {
    const clone = el.cloneNode(true);
    clone.querySelectorAll('[aria-hidden="true"], [data-slot="kbd"], [data-slot="kbd-group"], .sr-only').forEach((node) => node.remove());

    return clone.textContent.replace(/\s+/g, ' ').trim();
}

/**
 * True while a modal is leaving. Overlays keep their aria-modal panel
 * rendered during the leave transition, but the `x-show` that hides it (on
 * the panel or its nearest ancestor with one) is already false, or a
 * dialog's `beforeLeave` hook runs (its Alpine `closing` is true).
 */
function leaving(el) {
    const Alpine = window.Alpine;
    if (!Alpine) return false;
    try {
        if (Alpine.$data(el).closing === true) return true;
        const shown = el.closest('[x-show]');

        return shown !== null && !Alpine.evaluate(shown, shown.getAttribute('x-show'));
    } catch {
        return false;
    }
}

/** The modal overlay on top, if any: the one holding focus, else the last one. */
function topModal() {
    const modals = Array.from(document.querySelectorAll('[aria-modal="true"], dialog[open]'))
        .filter((el) => (el.tagName !== 'DIALOG' || isModalDialog(el)) && live(el) && !leaving(el));
    if (modals.length === 0) return null;

    return modals.find((el) => el.contains(document.activeElement)) ?? modals[modals.length - 1];
}

const LABELS = {
    mac: { meta: '⌘', ctrl: '⌃', alt: '⌥', shift: '⇧' },
    other: { meta: 'Win', ctrl: 'Ctrl', alt: 'Alt', shift: 'Shift' },
};

const SPOKEN = {
    mac: { meta: 'Command', ctrl: 'Control', alt: 'Option', shift: 'Shift' },
    other: { meta: 'Windows', ctrl: 'Control', alt: 'Alt', shift: 'Shift' },
};

const KEY_LABELS = {
    ' ': 'Space',
    enter: 'Enter',
    escape: 'Esc',
    tab: 'Tab',
    backspace: 'Backspace',
    delete: 'Delete',
    arrowup: '↑',
    arrowdown: '↓',
    arrowleft: '←',
    arrowright: '→',
    home: 'Home',
    end: 'End',
    pageup: 'Page Up',
    pagedown: 'Page Down',
};

const KEY_SPOKEN = { arrowup: 'Up Arrow', arrowdown: 'Down Arrow', arrowleft: 'Left Arrow', arrowright: 'Right Arrow', escape: 'Escape' };

// aria-keyshortcuts uses DOM key names (https://w3c.github.io/aria/#aria-keyshortcuts),
// not the display labels above: ArrowUp, not ↑; Escape, not Esc; Plus for "+".
const ARIA_MODIFIERS = { meta: 'Meta', ctrl: 'Control', alt: 'Alt', shift: 'Shift' };

const ARIA_KEYS = {
    ' ': 'Space', '+': 'Plus', escape: 'Escape', pageup: 'PageUp', pagedown: 'PageDown',
    arrowup: 'ArrowUp', arrowdown: 'ArrowDown', arrowleft: 'ArrowLeft', arrowright: 'ArrowRight',
};

function ariaKey(key) {
    if (ARIA_KEYS[key]) return ARIA_KEYS[key];

    // Letters and digits as printed; other names in DOM form ("f5" → "F5", "enter" → "Enter").
    return key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1);
}

function keyLabel(key) {
    if (KEY_LABELS[key]) return KEY_LABELS[key];

    return key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1);
}

let nextId = 0;
let scopeSeq = 0;
// Scope element → the order in which it became live. Read at keydown time.
const scopeOrder = new WeakMap();
let liveScopes = new Set();

function createStore() {
    return {
        mac: detectMac(),
        bindings: [],
        // Counts changes to the bindings and the spoken words, so a reader
        // (the help list) can tell the registry changed without walking it.
        version: 0,
        // Single-key shortcuts can be switched off (WCAG 2.1.4 Character Key
        // Shortcuts); the help list renders the switch. Kept in localStorage.
        characterKeys: true,
        // Spoken words for screen-reader text, keyed by their English name
        // ({ then: 'dan', Command: 'Command', Space: 'Spatiebalk' }). The Blade
        // parts pass them through __() with localize(); missing words stay English.
        words: {},
        // The first step of a sequence while it waits for the second one.
        pending: null,
        pendingTimer: null,

        /**
         * Register a shortcut. Returns a handle: { id, unregister() }.
         *
         * options: description, group, scope ('global' | scope name | Element),
         * el (the registering element; a binding from inside an open modal stays
         * live there), inputs (fire while typing), repeat (fire on key repeat),
         * preventDefault (default true), enabled (() => boolean), visible
         * (fire only while `el` is rendered and not inert; x-hotkey sets it).
         */
        register(keys, handler, options = {}) {
            const steps = parseKeys(keys, this.mac);
            if (steps.length === 0 || typeof handler !== 'function') return { id: null, unregister: () => {} };
            const binding = {
                id: options.id ?? `hotkey-${++nextId}`,
                keys: String(keys).trim(),
                steps,
                handler,
                el: options.el ?? null,
                scope: options.scope ?? 'global',
                description: options.description ?? '',
                group: options.group ?? '',
                inputs: options.inputs === true,
                repeat: options.repeat === true,
                preventDefault: options.preventDefault !== false,
                enabled: typeof options.enabled === 'function' ? options.enabled : null,
                visible: options.visible === true && Boolean(options.el),
                character: isCharacterChord(steps[0]),
            };
            this.bindings.push(binding);
            this.version++;

            return { id: binding.id, unregister: () => this.unregister(binding.id) };
        },

        unregister(id) {
            const index = this.bindings.findIndex((binding) => binding.id === id);
            if (index !== -1) {
                this.bindings.splice(index, 1);
                this.version++;
            }
            // A sequence waiting for its second key must not complete with it.
            if (this.pending) {
                this.pending.candidates = this.pending.candidates.filter((binding) => binding.id !== id);
                if (this.pending.single?.id === id) this.pending.single = null;
            }
        },

        isRegistered(binding) {
            return this.bindings.includes(binding);
        },

        /**
         * Registered shortcuts that carry a description, for a help dialog.
         * Each entry: { id, keys, description, group, steps (display labels
         * per step), spoken (screen-reader text), character, active,
         * inactiveReason }. Later duplicates of the same keys and description
         * are dropped.
         *
         * `active` says whether the shortcut can fire now, leaving an open
         * modal out of the question (the help dialog is usually one).
         * `inactiveReason` is null when active, 'character-keys' for a
         * single-key shortcut while those are switched off, and 'unavailable'
         * when its `enabled()` says no, its control is hidden, disabled or
         * removed, or its scope is not on the page. `list({ active: true })`
         * returns the active shortcuts only.
         */
        list(options = {}) {
            const seen = new Set();

            return this.bindings
                .filter((binding) => binding.description !== '' && (!binding.el || binding.el.isConnected))
                .map((binding) => ({ binding, inactiveReason: this.inactiveReason(binding) }))
                .filter(({ inactiveReason }) => !options.active || inactiveReason === null)
                .filter(({ binding }) => {
                    const key = `${binding.keys}|${binding.description}`;
                    if (seen.has(key)) return false;
                    seen.add(key);

                    return true;
                })
                .map(({ binding, inactiveReason }) => ({
                    id: binding.id,
                    keys: binding.keys,
                    description: binding.description,
                    group: binding.group,
                    steps: this.format(binding.keys),
                    spoken: this.spoken(binding.keys),
                    character: binding.character,
                    active: inactiveReason === null,
                    inactiveReason,
                }));
        },

        /**
         * Why a binding cannot fire now, ignoring an open modal: null,
         * 'character-keys' or 'unavailable' (see list()). Inert is ignored
         * too, because an open modal makes the page behind it inert.
         */
        inactiveReason(binding) {
            if (binding.el && !binding.el.isConnected) return 'unavailable';
            if (binding.visible && !rendered(binding.el)) return 'unavailable';
            if (binding.enabled && !binding.enabled()) return 'unavailable';
            if (binding.scope !== 'global' && binding.scope != null) {
                const present = (scope) => scope.isConnected && rendered(scope);
                const found = typeof binding.scope === 'string'
                    ? Array.from(document.querySelectorAll('[data-hotkey-scope]')).some((scope) => scope.dataset.hotkeyScope === binding.scope && present(scope))
                    : present(binding.scope);
                if (!found) return 'unavailable';
            }
            if (binding.character && !this.characterKeys) return 'character-keys';

            return null;
        },

        /** list(options) grouped by `group`, in first-registered order. */
        groups(options = {}) {
            const groups = [];
            for (const item of this.list(options)) {
                let group = groups.find((entry) => entry.name === item.group);
                if (!group) {
                    group = { name: item.group, items: [] };
                    groups.push(group);
                }
                group.items.push(item);
            }

            return groups;
        },

        /** Display labels per step: 'mod+k' → [['⌘', 'K']] on macOS, [['Ctrl', 'K']] elsewhere. */
        format(keys) {
            const labels = LABELS[this.mac ? 'mac' : 'other'];

            return parseKeys(keys, this.mac).map((chord) => [
                ...(this.mac ? ['ctrl', 'alt', 'shift', 'meta'] : ['ctrl', 'meta', 'alt', 'shift'])
                    .filter((modifier) => chord[modifier])
                    .map((modifier) => labels[modifier]),
                keyLabel(chord.key),
            ]);
        },

        /** Merge translated spoken words (see `words`) into the registry. */
        localize(words) {
            if (!words || typeof words !== 'object') return;
            if (Object.entries(words).every(([english, word]) => this.words[english] === word)) return;
            this.words = { ...this.words, ...words };
            this.version++;
        },

        /** Screen-reader text: 'mod+k' → 'Command+K'; 'g p' → 'G then P', in the words given to localize(). */
        spoken(keys, then = null) {
            const names = SPOKEN[this.mac ? 'mac' : 'other'];
            const word = (english) => this.words[english] ?? english;

            return parseKeys(keys, this.mac)
                .map((chord) => [
                    ...MODIFIERS.filter((modifier) => chord[modifier]).map((modifier) => word(names[modifier])),
                    word(KEY_SPOKEN[chord.key] ?? keyLabel(chord.key)),
                ].join('+'))
                .join(` ${then ?? word('then')} `);
        },

        /**
         * Whether a shortcut starts with a character key (no Ctrl, Cmd or
         * Alt): 'g p' and '?' do, 'mod+k' does not. Those are the ones the
         * character-keys switch turns off.
         */
        isCharacter(keys) {
            const [first] = parseKeys(keys, this.mac);

            return Boolean(first) && isCharacterChord(first);
        },

        /** Whether a hint for these keys should show: false for a character-key shortcut while they are switched off. */
        showsHint(keys) {
            return this.characterKeys || !this.isCharacter(keys);
        },

        /** aria-keyshortcuts value for a single chord; null for a sequence. */
        aria(keys) {
            const steps = parseKeys(keys, this.mac);
            if (steps.length !== 1) return null;
            const chord = steps[0];

            return [...MODIFIERS.filter((modifier) => chord[modifier]).map((modifier) => ARIA_MODIFIERS[modifier]), ariaKey(chord.key)].join('+');
        },

        /** Resolve which scopes are live now and how they rank. */
        context() {
            const modal = topModal();
            const scopes = Array.from(document.querySelectorAll('[data-hotkey-scope]')).filter(live);
            const now = new Set(scopes);
            for (const scope of scopes) {
                if (!liveScopes.has(scope)) scopeOrder.set(scope, ++scopeSeq);
            }
            liveScopes = now;

            const rank = new Map();
            for (const scope of scopes) rank.set(scope, scopeOrder.get(scope) ?? 0);
            // Scopes around focus outrank the rest, the deepest one first.
            let depth = 1;
            const chain = [];
            for (let node = document.activeElement?.closest('[data-hotkey-scope]'); node; node = node.parentElement?.closest('[data-hotkey-scope]')) {
                chain.push(node);
            }
            for (const scope of chain.reverse()) {
                if (rank.has(scope)) rank.set(scope, 1e6 + depth++);
            }

            return { modal, scopes, rank };
        },

        /** Rank of a binding right now; -1 when it cannot fire. */
        rankOf(binding, context) {
            if (binding.character && !this.characterKeys) return -1;
            if (binding.enabled && !binding.enabled()) return -1;
            if (binding.el && !binding.el.isConnected) return -1;
            if (binding.visible && !live(binding.el)) return -1;

            if (binding.scope === 'global' || binding.scope == null) {
                if (context.modal && !(binding.el && context.modal.contains(binding.el))) return -1;

                return 0;
            }

            const candidates = typeof binding.scope === 'string'
                ? context.scopes.filter((scope) => scope.dataset.hotkeyScope === binding.scope)
                : context.scopes.filter((scope) => scope === binding.scope);
            const ranks = candidates
                .filter((scope) => !context.modal || context.modal.contains(scope))
                .map((scope) => context.rank.get(scope));

            return ranks.length ? Math.max(...ranks) : -1;
        },

        best(bindings, context) {
            let winner = null;
            let winnerRank = -1;
            for (const binding of bindings) {
                const rank = this.rankOf(binding, context);
                // `>=`: among equals the latest registration wins.
                if (rank >= 0 && rank >= winnerRank) {
                    winner = binding;
                    winnerRank = rank;
                }
            }

            return winner;
        },

        run(binding, event) {
            if (!this.isRegistered(binding)) return;
            if (event && binding.preventDefault) event.preventDefault();
            binding.handler(event ?? null, binding);
        },

        clearPending() {
            window.clearTimeout(this.pendingTimer);
            this.pendingTimer = null;
            this.pending = null;
        },

        handle(event) {
            if (event.defaultPrevented || event.isComposing || event.keyCode === 229) return;
            if (['Shift', 'Control', 'Alt', 'Meta', 'CapsLock'].includes(event.key)) return;

            const target = event.composedPath?.()[0] ?? event.target;
            const typing = isTyping(target, event);
            const allowed = (binding) => (!typing || binding.inputs) && (!event.repeat || binding.repeat);
            const context = this.context();

            if (this.pending) {
                const { candidates } = this.pending;
                this.clearPending();
                const second = this.best(candidates.filter((binding) => this.isRegistered(binding) && allowed(binding) && matches(binding.steps[1], event)), context);
                if (second) {
                    this.run(second, event);

                    return;
                }
            }

            const eligible = this.bindings.filter(allowed);
            const single = this.best(eligible.filter((binding) => binding.steps.length === 1 && matches(binding.steps[0], event)), context);
            const sequences = eligible.filter((binding) => binding.steps.length === 2
                && matches(binding.steps[0], event)
                && this.rankOf(binding, context) >= 0);

            if (sequences.length === 0) {
                if (single) this.run(single, event);

                return;
            }

            // Wait for the second key; a single-key binding on the same key
            // fires only when no second key follows in time.
            this.pending = { candidates: sequences, single };
            this.pendingTimer = window.setTimeout(() => {
                const fallback = this.pending?.single ?? null;
                this.clearPending();
                if (fallback && this.rankOf(fallback, this.context()) >= 0) this.run(fallback, null);
            }, SEQUENCE_TIMEOUT);
        },
    };
}

document.addEventListener('alpine:init', () => {
    const Alpine = window.Alpine;
    if (Alpine.store('hotkeys')) return;

    Alpine.store('hotkeys', createStore());
    const store = Alpine.store('hotkeys');
    try {
        store.characterKeys = window.localStorage.getItem(CHARACTER_KEYS_STORAGE) !== 'off';
    } catch {
        // Storage can be blocked; the preference then lasts for the page.
    }
    Alpine.effect(() => {
        const value = store.characterKeys ? 'on' : 'off';
        try {
            window.localStorage.setItem(CHARACTER_KEYS_STORAGE, value);
        } catch {
            // See above.
        }
    });
    document.addEventListener('keydown', (event) => store.handle(event));

    // `$hotkeys` is the store, except that register() defaults `el` to the
    // calling element and `scope` to its enclosing data-hotkey-scope (as
    // x-hotkey does), and unregisters when that element is removed.
    Alpine.magic('hotkeys', (el, { cleanup }) => new Proxy(store, {
        get(target, property) {
            if (property !== 'register') return target[property];

            return (keys, handler, options = {}) => {
                const scope = el.closest('[data-hotkey-scope]') ?? 'global';
                const handle = target.register(keys, handler, { el, scope, ...options });
                cleanup(() => handle.unregister());

                return handle;
            };
        },
    }));

    /**
     * x-hotkey="mod+enter" on a control: the shortcut clicks it (or focuses it
     * when it is a text field). Modifiers: .inputs fires while typing, .focus
     * always focuses, .global ignores the surrounding scope. Description:
     * data-hotkey-description, else aria-label, else the text. Group:
     * data-hotkey-group.
     *
     * The control must be rendered and outside an inert subtree: a button
     * hidden by x-show or inside a closed dialog never receives the click.
     * (A binding that must reach hidden UI, like the command palette opening
     * its closed dialog, registers through the store instead; store bindings
     * are not tied to the visibility of their element.)
     */
    Alpine.directive('hotkey', (el, { modifiers, expression }, { cleanup }) => {
        const keys = String(expression || '').trim();
        if (keys === '') return;
        const scope = modifiers.includes('global') ? null : el.closest('[data-hotkey-scope]');
        const description = el.dataset.hotkeyDescription
            ?? el.getAttribute('aria-label')
            ?? textOf(el);

        const handle = store.register(keys, () => {
            if (modifiers.includes('focus') || isTyping(el, { key: '' })) {
                el.focus();
                if (typeof el.select === 'function') el.select();

                return;
            }
            el.click();
        }, {
            el,
            scope: scope ?? 'global',
            description,
            group: el.dataset.hotkeyGroup ?? '',
            inputs: modifiers.includes('inputs'),
            enabled: () => !el.disabled && el.getAttribute('aria-disabled') !== 'true',
            visible: true,
        });

        const aria = store.aria(keys);
        if (aria && !el.hasAttribute('aria-keyshortcuts')) el.setAttribute('aria-keyshortcuts', aria);

        cleanup(() => handle.unregister());
    });

    /** <x-ui.hotkeys.hint>: swaps the server-rendered labels for this platform's. */
    Alpine.data('uiHotkeyHint', (config = {}) => ({
        steps: [],
        spokenText: '',

        /** Hidden while character-key shortcuts are off and this one needs no modifier. */
        get shown() {
            return store.showsHint(config.keys ?? '');
        },

        init() {
            store.localize(config.words);
            this.steps = store.format(config.keys ?? '');
            this.spokenText = store.spoken(config.keys ?? '', config.then);
        },
    }));
});
resources/js/ui/hotkeys-help.js JS
/**
 * Hotkeys help list (<x-ui.hotkeys>, also inside <x-ui.hotkeys.dialog>).
 *
 * Reads the registry that hotkeys.js keeps in the `hotkeys` Alpine store and
 * groups every shortcut that has a description and can fire: a shortcut whose
 * control is hidden, disabled or removed, or whose scope is not on the page,
 * is left out. A single-key shortcut stays listed while those are switched
 * off, marked off (`data-active="false"`), next to the switch that turns
 * them back on. The list is read again each time it comes into view (a help
 * dialog opening), because visibility is not reactive. It is built only while
 * it is in view and kept per revision: a closed help dialog does not rebuild
 * it (layout reads, enabled() calls) on every change of the hotkeys store.
 * Split from hotkeys.js so the key handling and the help list stay separate
 * capabilities.
 */
import './hotkeys.js';
document.addEventListener('alpine:init', () => {
    const Alpine = window.Alpine;
    const store = () => Alpine.store('hotkeys');

    /**
     * <x-ui.hotkeys>: the registered shortcuts, grouped, for a help dialog.
     * `entries` adds shortcuts that are handled elsewhere (an editor's Mod+B)
     * as { keys, description, group }; `query` filters by description, group
     * and keys, every word must match.
     */
    Alpine.data('uiHotkeys', (config = {}) => {
        // The built list and the key it was built for. Kept outside the
        // reactive data so a read of the cache adds no dependency.
        let cache = [];
        let cacheKey = null;

        return {
            query: '',
            entries: Array.isArray(config.entries) ? config.entries : [],
            // Bumped when the list comes into view, so it is read again then.
            revision: 0,
            // False while the list is out of view (a closed help dialog).
            inView: typeof IntersectionObserver === 'undefined',
            _observer: null,

            init() {
                // Translated spoken words ("then", "Command", "Space") for the
                // screen-reader text of every listed shortcut.
                store().localize(config.words);
                if (typeof IntersectionObserver !== 'undefined') {
                    this._observer = new IntersectionObserver((records) => {
                        const inView = records[records.length - 1].isIntersecting;
                        if (inView) this.revision++;
                        this.inView = inView;
                    });
                    this._observer.observe(this.$el);
                }
            },

            destroy() {
                this._observer?.disconnect();
                this._observer = null;
            },

            /**
             * The listed shortcuts, grouped. Built again only while the list
             * is in view and the revision, the registered bindings or the
             * single-key switch changed; otherwise the last build is returned.
             */
            get allGroups() {
                if (!this.inView && cacheKey !== null) return cache;
                const key = `${this.revision}|${store().version}|${store().characterKeys}`;
                if (key === cacheKey) return cache;
                cache = this.build();
                cacheKey = key;

                return cache;
            },

            build() {
                const groups = store()
                    .groups()
                    .map((group) => ({ name: group.name, items: group.items.filter((item) => item.inactiveReason !== 'unavailable') }))
                    .filter((group) => group.items.length > 0);
                const seen = new Set(groups.flatMap((group) => group.items.map((item) => `${item.keys}|${item.description}`)));
                this.entries.forEach((entry, index) => {
                    const keys = String(entry?.keys ?? '').trim();
                    const description = String(entry?.description ?? '').trim();
                    if (keys === '' || description === '' || seen.has(`${keys}|${description}`)) return;
                    seen.add(`${keys}|${description}`);
                    const name = String(entry.group ?? '');
                    let group = groups.find((candidate) => candidate.name === name);
                    if (!group) {
                        group = { name, items: [] };
                        groups.push(group);
                    }
                    group.items.push({
                        id: `hotkeys-entry-${index}`,
                        keys,
                        description,
                        group: name,
                        steps: store().format(keys),
                        spoken: store().spoken(keys),
                        character: false,
                        active: true,
                        inactiveReason: null,
                    });
                });

                return groups;
            },

            get groups() {
                const words = this.query.toLowerCase().split(/\s+/).filter(Boolean);
                if (words.length === 0) return this.allGroups;

                return this.allGroups
                    .map((group) => ({
                        name: group.name,
                        items: group.items.filter((item) => {
                            const haystack = [group.name, item.description, item.keys, item.spoken, item.steps.flat().join(' ')]
                                .join(' ')
                                .toLowerCase();

                            return words.every((word) => haystack.includes(word));
                        }),
                    }))
                    .filter((group) => group.items.length > 0);
            },

            get total() {
                return this.allGroups.reduce((sum, group) => sum + group.items.length, 0);
            },

            get hasCharacterKeys() {
                return this.allGroups.some((group) => group.items.some((item) => item.character));
            },
        };
    });

});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.4.4
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0