Bind a chord
Two forms of the same registry. The directive is the usual one — bind it where the action lives. Reach for the service when the binding has no element to hang on, or when you need to add and remove it dynamically.
<!-- Global by default: fires wherever focus is. -->
<div [wrHotkey]="'mod+k'" (wrHotkeyMatch)="palette.open()">…</div>
<!-- Scoped: only while focus is inside the host. -->
<div [wrHotkey]="'escape'" [scoped]="true" (wrHotkeyMatch)="close()">…</div>import { WrHotkey } from 'ngwr/hotkey';
private readonly hotkey = inject(WrHotkey);
constructor() {
const handle = this.hotkey.bind('mod+k', () => this.palette.open());
inject(DestroyRef).onDestroy(() => handle.unbind());
} Specs are written as mod+k, shift+/, escape. mod is the point of the whole notation: it resolves to ⌘ on macOS and Ctrl everywhere else, so you write the binding once instead of branching on the platform.
Global or scoped
Both forms are GLOBAL by default — the listener sits on the document, not on the host. Pass scoped to narrow it to the element.
Typing never fires a shortcut: while an <input>, <textarea> or contenteditable has focus, bindings are skipped unless you opt in with allowInInput.
| Name | Description | Type | Default |
|---|---|---|---|
wrHotkey | The chord to listen for. mod resolves to Cmd on macOS and Ctrl elsewhere. | WrHotkeySpec | — |
scoped | Listen only while focus is inside the host element. Off by default — the binding is global, the same as WrHotkey.bind(). | boolean | false |
allowInInput | Keep firing while an <input> / <textarea> / contenteditable has focus. Off by default so typing never triggers shortcuts. | boolean | false |
preventDefault | Call preventDefault() on a match, so the browser does not also act on the chord. | boolean | true |
(wrHotkeyMatch) | Emits the original KeyboardEvent when the chord matches. | KeyboardEvent | — |
Show the hint
A shortcut nobody can see is a shortcut nobody uses. <wr-kbd> is a presentational keycap — one chip per key — and knows nothing about the registry.
<!-- Render the chord next to the action it triggers. -->
<button wr-btn>
Search
<wr-kbd>⌘</wr-kbd>
<wr-kbd>K</wr-kbd>
</button>Handle the rest yourself
Chords are the easy half. Type-ahead, arrow navigation and “ignore the browser’s own shortcuts” are plain keydown handlers — ngwr ships three small primitives for writing them.
import { KEYS, hasModifier, isComposing, isPrintableKey } from 'ngwr/utils';
protected onKeydown(event: KeyboardEvent): void {
// First, always: while an IME is converting, Enter, Escape and the arrows
// belong to its candidate window, not to you.
if (isComposing(event)) return;
// Compare against the constant, not the magic string — it is searchable.
if (event.key === KEYS.ESCAPE) return this.close();
// Let the browser keep its own chords (copy, reload, devtools…).
if (hasModifier(event)) return;
// Type-to-search: react only to characters, not to Tab / arrows / F-keys.
if (isPrintableKey(event)) this.query.update(q => q + event.key);
}KEYS spells the KeyboardEvent.key values correctly, so a typo like 'Esacpe' is a compile error rather than a handler that never fires; hasModifier is how you avoid stealing ⌘C; isPrintableKey separates “the user typed a character” from “the user pressed F5”.
What the components already do
Every interactive component follows its WAI-ARIA APG pattern, and until now that was a claim rather than a contract you could write a test against. Here it is, one table per focused element, read off each component's own keydown handler. Nothing below needs configuring — it is what the component does out of the box.
Two conventions run through all of it. Arrows that name a side of the screen mirror underdir="rtl" — the tab strip, the calendar grid, the tree and a context menu's cascade all swap ArrowLeft and ArrowRight, while
These tables are about KEYS. Where focus lands when an overlay opens, and where it goes back to for each of the four ways of closing one, is the other half — the overlay guide owns that, so the two are not maintained twice.
Dialog and drawer
| Key | Does | When |
|---|---|---|
| Closes, and focus returns to whatever was focused when it opened. | Unless closeOnEscape: false. Focus does not have to be inside the overlay — the CDK routes the key to the topmost one. | |
| Cycles inside the overlay. Focus cannot leave it while it is open. | ||
Activates the focused control, including the built-in ✕ and anything carrying [wrDialogClose] / [wrDrawerClose]. |
Select — closed, button trigger
| Key | Does | When |
|---|---|---|
| Opens the panel and seeds the cursor on the selected option. | ||
| Opens the panel — same seeding, no step. | ||
| Removes the last chip. | mode="multi" with a selection. A single-mode button trigger has NO keyboard clear and renders no ✕ — see the searchable field below, and give an optional filter an explicit "Any" option. | |
| Leaves the control. Nothing opens and nothing is committed. |
Select — open panel
| Key | Does | When |
|---|---|---|
| Moves the cursor by one enabled option, wrapping at the ends. | ||
| First / last enabled option. Both work, in every mode. | ||
| Selects the option under the cursor. A disabled or filtered-out option is refused. | ||
| Selects, exactly like Enter. | Button trigger only. In a searchable select Space belongs to the text field, or a two-word query could not be typed. | |
| Closes the panel. Nothing is committed. | ||
| Closes the panel AND lets focus leave. The option under the cursor is not committed — the cursor is seeded on open, so tabbing through would otherwise select a row nobody looked at. |
Select — searchable field
| Key | Does | When |
|---|---|---|
| Filter the options. The match is a substring, not a prefix. | ||
| On an EMPTY field, clears the selection. | Single mode with clearable. This is the keyboard twin of the ✕, which is tabindex="-1" and unreachable by key. | |
| On an EMPTY query, removes the last chip. | Any chip mode — a searchable multi, or tag. | |
| Commits the typed string as the value when nothing is highlighted. | freeText. With a highlighted option, Enter selects that option instead. | |
| Exactly as in the open panel above. |
Dropdown and context menu
| Key | Does | When |
|---|---|---|
| Opens the menu and focuses its first enabled item. | ||
| Next / previous enabled item, wrapping at both ends. | ||
| First / last item. | ||
Opens the focused row’s submenu and focuses its first item. Under dir="rtl" this is ArrowLeft — the panes cascade the other way, so the key that opens has to follow them. | <wr-context-menu> with a submenu. | |
Closes the current submenu and returns focus to the row that owns it (ArrowRight under dir="rtl"). | <wr-context-menu>, inside a submenu. | |
| Closes one level and returns focus to the trigger, or to the owning row. | ||
| Closes and lets focus leave naturally. |
Tabs
| Key | Does | When |
|---|---|---|
Next / previous tab in VISUAL order, wrapping at both ends — so the pair swaps meaning under dir="rtl". | ||
| First / last tab. These name a position, so they read the same in both directions. | ||
| Activates the focused tab. | Router mode (wrTabsRouting) only — there the arrows move focus alone, because the route selects the tab. Otherwise activation already follows focus. |
Tree
| Key | Does | When |
|---|---|---|
| Next / previous visible row. No wrap at the ends. | ||
| Expands a collapsed parent; on an already-open parent, steps into its first child. | ||
| Collapses an open parent; on a leaf or a closed node, jumps to the parent row. | ||
| First / last visible row. | ||
Selects the row. Holding Ctrl (or Cmd) adds to the selection instead of replacing it. | ||
| Closes the panel. | openOn="overlay" only — the default is inline. |
Pagination
| Key | Does | When |
|---|---|---|
| Moves between the previous button, each page cell, the next button and the page-size select. | ||
Goes to that page. The cells are <wr-btn role="button" tabindex="0">, named Go to page N. | ||
Nothing. <wr-pagination> is a role="navigation" landmark of independent destinations, not a composite widget. |
Table
| Key | Does | When |
|---|---|---|
| Walks the sort buttons, the column filters, the selection checkboxes, the expand toggles and the footer pager, in DOM order. | ||
| Activates whichever of those has focus. | ||
Scroll the body — the virtual viewport is itself a tab stop (tabindex="0"), so rows the window has not rendered yet stay reachable from the keyboard. | virtualScroll only. |
Four more components document their own grid on their own page, because the keys mean something only next to the thing they move: calendar, event calendar, date picker (whose popup IS a calendar) and image cropper.
See also
- ServiceWrHotkeyThe registry itself — bind and unbind chords imperatively.
- ComponentWrKbdThe keycap chip used to render a chord next to its action.
- UtilKEYSCanonical `KeyboardEvent.key` constants — searchable instead of magic strings.
- UtilhasModifierIs any modifier held? Use it to leave the browser’s own chords alone.
- UtilisPrintableKeyDid the key produce a character? The type-ahead predicate.
- UtilisComposingIs an input method still converting? Return early before you read a key.