Core

Keyboard

Shortcuts, keycaps and key primitives live in three separate entry points — ngwr/hotkey, ngwr/keyboard and ngwr/utils — because they have nothing to do with each other at runtime. One task usually needs all three, so this page walks the whole job end to end: bind a chord, show the hint, handle the leftovers yourself.

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.

Focus me, then pressEsc— caught 0×

Typing never fires a shortcut: while an <input>, <textarea> or contenteditable has focus, bindings are skipped unless you opt in with allowInInput.

NameDescriptionTypeDefault
wrHotkeyThe chord to listen for. mod resolves to Cmd on macOS and Ctrl elsewhere.WrHotkeySpec
scopedListen only while focus is inside the host element. Off by default — the binding is global, the same as WrHotkey.bind().booleanfalse
allowInInputKeep firing while an <input> / <textarea> / contenteditable has focus. Off by default so typing never triggers shortcuts.booleanfalse
preventDefaultCall preventDefault() on a match, so the browser does not also act on the chord.booleantrue
(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>
KorCtrlK

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 Home and End name a position and never do. And a composite widget is ONE tab stop: the arrows rove inside it, so tabbing through a month does not mean tabbing through 42 cells. The exception is pagination, and it is deliberate — a pager is a landmark of independent destinations, not a composite.

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

Focus is anywhere inside the open overlay.
KeyDoesWhen
EscapeCloses, 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.
Tab/Shift + TabCycles inside the overlay. Focus cannot leave it while it is open.
Enter/SpaceActivates the focused control, including the built-in ✕ and anything carrying [wrDialogClose] / [wrDrawerClose].

Select — closed, button trigger

Focus is on the `role="combobox"` trigger; the panel is closed.
KeyDoesWhen
Enter/SpaceOpens the panel and seeds the cursor on the selected option.
ArrowDown/ArrowUpOpens the panel — same seeding, no step.
BackspaceRemoves 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.
TabLeaves the control. Nothing opens and nothing is committed.

Select — open panel

The panel is open; focus stays on the trigger and `aria-activedescendant` names the cursor.
KeyDoesWhen
ArrowDown/ArrowUpMoves the cursor by one enabled option, wrapping at the ends.
Home/EndFirst / last enabled option. Both work, in every mode.
EnterSelects the option under the cursor. A disabled or filtered-out option is refused.
SpaceSelects, 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.
EscapeCloses the panel. Nothing is committed.
TabCloses 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

Focus is in the search `<input>` — `mode="search"`, `mode="tag"`, or a searchable multi.
KeyDoesWhen
Printable keysFilter the options. The match is a substring, not a prefix.
BackspaceOn 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.
BackspaceOn an EMPTY query, removes the last chip.Any chip mode — a searchable multi, or tag.
EnterCommits the typed string as the value when nothing is highlighted.freeText. With a highlighted option, Enter selects that option instead.
ArrowDown/ArrowUp/Home/End/Escape/TabExactly as in the open panel above.

Dropdown and context menu

Focus is on the trigger, then on a menu item — a menu moves REAL focus, not a cursor.
KeyDoesWhen
Enter/Space/ArrowDown/ArrowUpOpens the menu and focuses its first enabled item.
ArrowDown/ArrowUpNext / previous enabled item, wrapping at both ends.
Home/EndFirst / last item.
ArrowRightOpens 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.
ArrowLeftCloses the current submenu and returns focus to the row that owns it (ArrowRight under dir="rtl").<wr-context-menu>, inside a submenu.
EscapeCloses one level and returns focus to the trigger, or to the owning row.
TabCloses and lets focus leave naturally.

Tabs

Focus is on a tab header. The strip is ONE tab stop; the arrows rove within it.
KeyDoesWhen
ArrowRight/ArrowLeftNext / previous tab in VISUAL order, wrapping at both ends — so the pair swaps meaning under dir="rtl".
Home/EndFirst / last tab. These name a position, so they read the same in both directions.
Enter/SpaceActivates 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

Focus is on the tree; a roving cursor names the current row.
KeyDoesWhen
ArrowDown/ArrowUpNext / previous visible row. No wrap at the ends.
ArrowRightExpands a collapsed parent; on an already-open parent, steps into its first child.
ArrowLeftCollapses an open parent; on a leaf or a closed node, jumps to the parent row.
Home/EndFirst / last visible row.
Enter/SpaceSelects the row. Holding Ctrl (or Cmd) adds to the selection instead of replacing it.
EscapeCloses the panel.openOn="overlay" only — the default is inline.

Pagination

Focus is on one page cell. Every cell is its own tab stop — there is no roving cursor, and the arrows do nothing.
KeyDoesWhen
Tab/Shift + TabMoves between the previous button, each page cell, the next button and the page-size select.
Enter/SpaceGoes to that page. The cells are <wr-btn role="button" tabindex="0">, named Go to page N.
ArrowLeft/ArrowRightNothing. <wr-pagination> is a role="navigation" landmark of independent destinations, not a composite widget.

Table

Focus is inside `<wr-table>`. There is no grid cursor — the interactive parts are ordinary tab stops.
KeyDoesWhen
Tab/Shift + TabWalks the sort buttons, the column filters, the selection checkboxes, the expand toggles and the footer pager, in DOM order.
Enter/SpaceActivates whichever of those has focus.
ArrowUp/ArrowDown/PageUp/PageDownScroll 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