@mixedrays/keyrove
v2.4.0
Published
Framework-agnostic keyboard navigation for lists and grids, driven by data-* attributes. Any key can move focus; arrows are the default.
Maintainers
Readme
@mixedrays/keyrove
Framework-agnostic keyboard navigation for lists and grids, driven by data-*
attributes.
Documentation · API reference · Examples
Arrow keys are the default binding, not the whole library: the keys that move
focus are data-* attributes on the root, so any
KeyboardEvent.code
can drive a group. And because keyrove moves real DOM focus and only calls
preventDefault() on the keys it is bound to, native Tab /
Shift+Tab navigation keeps working alongside it.
pnpm add @mixedrays/keyroveFeatures
- Framework-agnostic: takes DOM events and React, Vue, or Svelte synthetic events, with no adapter and no dependencies.
- Configurable key bindings: every move — next/prev, the grid's row moves,
Home/End and the page jumps — has a
data-keyrove-*-keyattribute taking anyKeyboardEvent.code, with exact modifier combos and platform-awaremod. - Focus keys:
data-keyrove-focus-keygives an element a combo of its own —ctrl+shift+KeyE, or justKeyE— that focuses it from anywhere under the listener: another group, a nested root, even a text field when the combo holds a modifier. It need not be an item, so a panel reached by its key stays out of the arrow order. - Lists and grids: arrows, Home/End and
PageUp/PageDown out of the box;
data-keyrove-colsfolds the items into rows — Up/Down move a whole row, Left/Right move a cell — anddata-keyrove-loopwraps a list at its ends. - Horizontal and RTL groups:
data-keyrove-orientation="horizontal"re-points a list's defaults at ←/→ — and a grid's default cell arrows follow the reading direction too, flipped under RTL from the nearestdir. - Native focus behavior: moves real DOM focus and calls
preventDefault()only on the keys it is bound to, so unbound keys and Tab/Shift+Tab are left untouched. - Roving tabindex:
data-keyrove-roving-tabindexmoves thetabindex="0"tab stop with focus, so Tab enters and leaves a group instead of walking through every item in it. - Skippable items:
data-keyrove-skipanddisabledkeep headings, separators, and dead entries in the DOM but out of the navigation order. - Nested roots:
data-keyrove-rootscopes a group and the nearest one wins, so a single delegated listener can serve a list inside a list. - Editable control awareness: the caret and value keys stay with inputs,
textareas, selects, and
contenteditableregions — while inputs those keys do nothing on, like a checkbox or a button, keep navigating. - Typeahead:
createTypeahead()adds case-insensitive type-to-focus, matchingdata-keyrove-typeaheador the item's own text. - Markup you don't own: every attribute has an option of the same name, so
a group can be described in JavaScript instead —
keyRove(e, { items: '[role="menuitem"]', loop: true })navigates a component library's menu that carries no keyrove attributes at all. Each field falls back to its attribute on its own, so the two can be mixed.
Usage
Mark navigable elements with data-keyrove-item, give them a tab stop, and pass
the container's keydown event to keyRove.
<ul id="menu">
<li data-keyrove-item tabindex="0">Inbox</li>
<li data-keyrove-item tabindex="0">Drafts</li>
<li data-keyrove-item tabindex="0">Sent</li>
</ul>import { keyRove } from '@mixedrays/keyrove';
document.querySelector('#menu').addEventListener('keydown', (e) => keyRove(e));keyRove accepts anything shaped like a keydown event, so React, Vue and
Svelte synthetic events work without an adapter:
<ul onKeyDown={(e) => keyRove(e)}>
{items.map((item) => (
<li key={item.id} data-keyrove-item tabIndex={0}>
{item.label}
</li>
))}
</ul>Keys
ArrowDown and ArrowUp move forward and back by default. Rebind either on the
root — KeyJ/KeyK, KeyW/KeyS, ArrowRight/ArrowLeft for a toolbar:
<div data-keyrove-next-key="KeyJ" data-keyrove-prev-key="KeyK">…</div>For the toolbar case there is a shorthand that also respects the text
direction: data-keyrove-orientation="horizontal" maps the default keys to
ArrowRight/ArrowLeft, flipped under RTL. An explicit
data-keyrove-next-key/data-keyrove-prev-key still wins over it.
A binding is a combo: zero or more of mod+ / ctrl+ / alt+ / shift+ /
meta+ (any order, any case) followed by a
KeyboardEvent.code.
mod resolves to meta on Apple platforms and ctrl elsewhere, and the longer
control, option, cmd and command spell the same modifiers. Matching is
exact — declared modifiers are required, undeclared ones are forbidden — so a
bare ArrowDown binding leaves shortcuts like Ctrl+ArrowDown
with their browser defaults. Keys are matched on e.code, the physical key, so
bindings hold across keyboard layouts. The matcher is exported as
matchesCombo(e, combo) for your own handlers.
<div
data-keyrove-next-key="ctrl+ArrowRight"
data-keyrove-prev-key="ctrl+ArrowLeft"
>
…
</div>Next and prev always mean one item through the DOM order. Declare
data-keyrove-cols and the same items fold into rows: next-key/prev-key
keep moving one item — a cell there, on the reading-direction arrows by
default — while data-keyrove-next-row-key/data-keyrove-prev-row-key move a
whole row, defaulting to ArrowDown/ArrowUp.
Anything not bound is left entirely alone, browser defaults included. Home,
End, PageUp and PageDown are defaults like the arrows —
data-keyrove-home-key, data-keyrove-end-key, data-keyrove-page-up-key and
data-keyrove-page-down-key rebind them — and whatever they are bound to they
act only once focus is already inside an item: they move within a group, never
into one. In a grid, bare Home/End jump to the ends of the focused row
(data-keyrove-home-row-key/data-keyrove-end-row-key) and
ctrl+Home/ctrl+End to the grid's first and last cell.
At the ends of a list the bound keys are consumed but focus stays put. Add
data-keyrove-loop on the root and next on the last item wraps to the first,
and vice versa. Grids keep their edges — they never wrap.
Keys pressed inside an editable element — textarea, select,
[contenteditable], or an input whose keys act natively (text entry,
number, range, radio, …) — are never handled: arrows and Home/End
keep moving the caret or value, and a letter binding like KeyJ does not
swallow typing into a field that sits within an item. Inputs where those keys
are inert — a checkbox, a button — still navigate.
Focus keys
Every move above is relative to where focus is. data-keyrove-focus-key is the
absolute kind: the combo focuses its element from anywhere the keydown reaches
the listener — a sibling group, a nested root, or, when the combo holds
Ctrl/Alt/Meta, a text field.
<div id="panels">
<section
data-keyrove-root
data-keyrove-focus-key="ctrl+shift+KeyE"
tabindex="-1"
>
…
</section>
<section
data-keyrove-root
data-keyrove-focus-key="ctrl+shift+KeyB"
tabindex="-1"
>
…
</section>
</div>The element need not be an item. An item stays in its group's arrow order, and
the jump carries the roving tab stop like any move. Any other element — the
panels above — is reached by its key alone, from outside any group: from is
null and no tab stop moves. Each panel is a root, so an arrow pressed on it
enters its own items rather than the first item under the listener.
The listener's placement is the reach — on document, the keys are page-wide.
A focus key sits ahead of the root's bindings and the defaults, so it wins any
collision; two elements naming one combo resolve to the first in DOM order; a
skipped or disabled element's key is inert. The move reports 'focus'.
Tab still works
keyrove moves focus with element.focus() and never touches Tab, so
sequential focus navigation is unaffected. Items with tabindex="0" stay
ordinary tab stops that arrows also reach. Opt into
data-keyrove-roving-tabindex when a group should instead be a single tab stop
that Tab moves past rather than through.
Typeahead
createTypeahead adds type-to-focus: printable characters accumulate in a
buffer (reset after 500 ms of silence), and focus jumps to the first item
whose label starts with what was typed, case-insensitively.
import { keyRove, createTypeahead } from '@mixedrays/keyrove';
const typeahead = createTypeahead(); // { resetMs?, matchMode?, onMove? }
list.addEventListener('keydown', (e) => keyRove(e) || typeahead(e));The buffer is state, which keyRove itself never holds, so create one handler
per listener and chain it after keyRove: bound keys win, and a KeyJ binding
keeps navigating instead of entering the buffer. The label is the item's
data-keyrove-typeahead attribute, falling back to its trimmed text. Matching
reads e.key — the typed character — unlike key bindings, which stay on the
physical e.code. Typing inside editable elements is never captured, modified
presses (Ctrl/Alt/Meta) are left to their shortcuts, and a space only counts
once a match is underway. The handler returns
{ action: 'typeahead', from, to } or null, the same contract as keyRove,
and its onMove fires after a real move exactly as keyRove's does, so both
handlers can feed the same follow-focus logic.
Repeated characters normally extend the prefix: S, then S, looks for an
item starting with ss. For menus that cycle through same-letter items, pass
{ matchMode: 'cycle' }: a single character moves to the next item after the
focused one that starts with it, wrapping, so repeated S presses step through
the S items at any pace. A different character typed before the buffer
resets still refines the prefix.
Attributes
| Attribute | On | Default | Meaning |
| ------------------------------ | ---- | ----------- | -------------------------------------------------------------------------------------- |
| data-keyrove-item | item | — | Marks an element as navigable. |
| data-keyrove-skip | item | — | Passed over when moving; stays in the DOM order. |
| data-keyrove-roving-tabindex | item | — | Moves the tabindex="0" tab stop with focus. |
| data-keyrove-root | root | — | Marks the navigation root explicitly, instead of using the listener's element. |
| data-keyrove-cols | root | 1 | Column count; above 1 the group navigates as a grid. |
| data-keyrove-page-length | root | 10 | Items per page jump — whole rows in a grid. |
| data-keyrove-next-key | root | axis arrow | Combo for the next item — the next cell, in a grid. E.g. KeyJ or ctrl+ArrowRight. |
| data-keyrove-prev-key | root | axis arrow | Combo for the previous item. |
| data-keyrove-next-row-key | root | ArrowDown | Combo for the next row, same column. Grids only. |
| data-keyrove-prev-row-key | root | ArrowUp | Combo for the previous row. Grids only. |
| data-keyrove-home-key | root | Home | Combo for the first item — the grid's first cell, ctrl+Home there by default. |
| data-keyrove-end-key | root | End | Combo for the last item — the grid's last cell, ctrl+End there by default. |
| data-keyrove-home-row-key | root | Home | Combo for the focused row's first cell. Grids only. |
| data-keyrove-end-row-key | root | End | Combo for the focused row's last cell. Grids only. |
| data-keyrove-page-up-key | root | PageUp | Combo for the page jump back. |
| data-keyrove-page-down-key | root | PageDown | Combo for the page jump forward. |
| data-keyrove-focus-key | any | — | Combo focusing this element, from anywhere under the listener, e.g. ctrl+shift+KeyE. |
| data-keyrove-loop | root | — | Next/prev wrap past the ends of a list. Grids never wrap. |
| data-keyrove-orientation | root | — | horizontal maps a list's default keys to ArrowRight/ArrowLeft, RTL-aware. |
| data-keyrove-typeahead | item | text | Label for type-to-focus, when the item's own text is not it. |
The boolean attributes — data-keyrove-item, data-keyrove-skip,
data-keyrove-roving-tabindex, data-keyrove-root, and data-keyrove-loop —
are enabled when bare or set to "true"; set one to "false" to disable it.
The next/prev defaults follow the group's axis: ArrowDown/ArrowUp in a
vertical list, the reading-direction arrows in a horizontal list or a grid.
Every attribute name is also exported as a constant (KEYROVE_ATTR_ITEM,
KEYROVE_ATTR_COLS, KEYROVE_ATTR_NEXT_ROW_KEY, KEYROVE_ATTR_LOOP, …).
Options and return value
const result = keyRove(e, {
onMove: ({ action, from, to }) => {},
});Beside onMove, the options object takes the group's settings themselves —
items, root, cols, loop, orientation, pageLength, keys,
focusKeys, skip and rovingTabindex — each named after the attribute it
stands for and each falling back to that attribute on its own. So a group can
be described in markup, in JavaScript, or in any mixture:
keyRove(e); // everything from the markup
keyRove(e, { loop: true }); // items from the markup, looping from here
keyRove(e, { items: '[role="menuitem"]', loop: true }); // nothing from the markupitems takes a selector run inside the root, or (root) => Element[]; skip
a selector or a test of your own; keys the combo per move, read move by move,
so { keys: { next: 'KeyJ' } } leaves the others to their attributes and
defaults. Options are read on every keypress, so an object built at the call
site is as live as an attribute is. createTypeahead takes the settings it
needs — items, root, skip, rovingTabindex, plus a label of its own —
under the same names, so one object configures both handlers.
onMove fires after focus has moved, and only when it actually moved: a
consumed key with nowhere to go — the end of a list, the edge of a grid —
fires nothing. action names the move: 'next' | 'prev' | 'home' | 'end' |
'pageUp' | 'pageDown', the grid-only 'nextRow' | 'prevRow' | 'homeRow' |
'endRow', and 'focus' for a focus key. from is the item focus left
(null when the group was entered from outside, or for a focus key on an
element that is not an item) and to the element it landed on.
keyRove returns null when it left the key untouched, and { action, from,
to } when it consumed it — with to: null for a consumed no-op at an edge. A
non-null result means the key is claimed, so handlers chain with ||:
element.addEventListener('keydown', (e) => keyRove(e) || myOwnHandler(e));toggleTabIndex({ root, isActive }) is exported for cases where you manage the
tab stop yourself — restoring it after re-rendering a list, for instance.
License
MIT
