API reference
Every export in the package — the handler, the helper, the key bindings, the attributes, and the types.
keyRove(event, options?)
Handles keyboard navigation within the event's navigation root, moving focus to the element the pressed key resolves to.
import { keyRove } from '@mixedrays/keyrove';
list.addEventListener('keydown', (e) => keyRove(e));
The root is the nearest ancestor of the event target carrying
data-keyrove-root, falling back to the element the listener is attached to
(currentTarget). That fallback is what lets one delegated listener serve
several independent groups — see
several groups, one listener.
Because it is the nearest ancestor, roots can also be
nested: the innermost one governs while focus is
inside it.
preventDefault() is called for every key keyrove acts on, so the page does not
scroll while you move through a list. "Acts on" means there was a position to
move from or to: at the end of a list or the edge of a grid the key is still
consumed — a group owns its bound keys up to its own boundary — but on a group
where no item holds focus, or one with no items at all, a bound key keeps its
browser default rather than being swallowed. Keys it does not act on are left
entirely untouched — Tab,
Shift+Tab,
Enter, Space and
Escape among them, which is why keyrove composes with
native focus navigation instead of replacing it.
Keys arriving from an editable target — a textarea, a select, a
[contenteditable] element (descendants included; a contenteditable="false"
island opts back out), or an input whose keys act natively (text entry,
number, range, radio, …) — are never acted on: the caret or value keeps
the arrows and Home/End, and
typing into a field inside an item is not swallowed by a printable-key
binding. Inputs where the bound keys are inert — a checkbox, a button —
still navigate, so a list of checkbox rows keeps its arrows.
Keys
The forward and back keys are configurable; the rest are fixed.
| Key | Bound by | In a list | In a grid |
|---|---|---|---|
ArrowDown (default) |
data-keyrove-next-key |
Next item | Next row, same column |
ArrowUp (default) |
data-keyrove-prev-key |
Previous item | Previous row, same column |
ArrowRight |
fixed | — | Next cell in the row |
ArrowLeft |
fixed | — | Previous cell in the row |
Home |
fixed | First navigable item | First navigable item |
End |
fixed | Last navigable item | Last navigable item |
PageDown |
fixed | Forward page-length items |
Forward page-length rows |
PageUp |
fixed | Back page-length items |
Back page-length rows |
The first two rows show what a group answers to with no configuration. Set
data-keyrove-next-key or data-keyrove-prev-key on the root and that key
takes over the row, while the default it replaced goes back to its browser
behaviour — see custom keys. The value 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. Matching is
exact — declared modifiers are required, undeclared ones are forbidden — so
KeyJ matches only while Ctrl is not held, and
shortcuts like Ctrl+ArrowDown
keep their browser defaults inside a group bound to the bare arrows. The fixed
keys are bare combos too: a modified Home or
PageDown is left alone.
data-keyrove-orientation="horizontal" re-points the two defaults at
ArrowRight/ArrowLeft without spelling the combos out — and flips the pair
under RTL, resolved from the nearest dir attribute and falling back to the
computed direction, so a toolbar reads "forward" the way its text does. It
only supplies defaults: an explicit data-keyrove-next-key or
data-keyrove-prev-key wins over it, and the freed vertical arrows go back
to their browser behaviour. A grid ignores the attribute — its cell moves
already cover the horizontal axis.
At the ends of a list, next and prev are consumed without moving — see the
return value. With data-keyrove-loop on the root they wrap
instead: forward from the last navigable item lands on the first, and back
from the first lands on the last. A looping group is also entered as a circle
— the prev key pressed from outside lands on the last item, matching the
APG menu-button convention. Wrapping is for lists only; a grid keeps its
edges, per the APG grid pattern.
The two Arrow cell moves in a grid stand down when the press already matched
the next or previous binding, so one keypress never fires both.
Home, End, PageUp, and PageDown only act once focus is genuinely inside
an item: they move within a group rather than into one. The next and previous
keys differ on purpose — pressed on a group where nothing is focused yet, they
move focus to the first item (the last, for the prev key on a looping group),
which is how you enter a list from the keyboard.
An item counts as focused when focus is anywhere inside it (:focus-within), so
an item that wraps a link or a button is still the navigation position after
Tab lands on that inner control.
options.onMove
Fired 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.
keyRove(e, {
onMove: ({ action, from, to }) => console.log(action, from, to),
});
action names the move ('next', 'prev', 'home', 'end', 'pageUp',
'pageDown'); from is the item focus left — null when an arrow entered
the group from outside — and to the item it landed on. In a grid, a left or
right cell move reports as prev and next.
Return value
keyRove reports what it did with the key, so handlers compose:
null— the key was not keyrove's and is untouched, browser default included.{ action, from, to }— the key was consumed.tois the newly focused item, ornullfor a consumed no-op: a bound key pressed at an edge, where the group owns the key but there is nowhere left to go.
A non-null result means "claimed", which is what lets several handlers share one listener without stepping on each other:
element.addEventListener('keydown', (e) => keyRove(e) || myOwnHandler(e));
One keypress resolves to at most one action. A custom binding that collides
with a fixed key — say data-keyrove-next-key="Home" — takes the press, and
the fixed key stands down.
createTypeahead(options?)
Builds a keydown handler that focuses items as their labels are typed — printable characters accumulate in a buffer, and focus jumps to the first navigable item whose label starts with it, case-insensitively.
import { keyRove, createTypeahead } from '@mixedrays/keyrove';
const typeahead = createTypeahead();
list.addEventListener('keydown', (e) => keyRove(e) || typeahead(e));
A factory rather than a plain handler on purpose: a buffer is state, and
holding it in the returned closure keeps keyRove itself stateless. Create
one handler per listener; the buffer resets after options.resetMs
(default 500) milliseconds of typing silence, with no timer to clean up.
The label is the item's data-keyrove-typeahead attribute, falling back to
its trimmed textContent when the attribute is absent or empty. Items
carrying data-keyrove-skip or disabled are passed over, and the root
resolves exactly as in keyRove — nearest data-keyrove-root, else
currentTarget — so the same delegated listener serves both.
Unlike bindings, which match the physical e.code, typeahead reads e.key —
the produced character, which is what the pressed key means in the user's
keyboard layout. A press is buffered only when it is genuinely typing: single
characters without Ctrl/Alt/Meta (Shift stays — it is how
capitals are typed), never inside an
editable element,
and a space only once a match is underway, so the spacebar keeps scrolling
and clicking. A character that matches nothing is left to its browser
default.
The handler follows the keyRove contract: null when the key was left
untouched, { action: 'typeahead', from, to } when it was consumed — with
to: null when the buffer grew but still names the already-focused item.
options.onMove fires after focus has moved and only when it actually moved,
mirroring keyRove's option, so both handlers can feed the
same follow-focus logic. Chain the handler after navigation, as above, so a
printable binding like KeyJ navigates instead of entering the buffer. The
roving tab stop moves with the match, exactly as for an arrow move.
matchesCombo(event, combo)
Whether a keydown event matches a combo — the matcher behind every key check keyrove itself makes, exported for your own handlers.
import { matchesCombo } from '@mixedrays/keyrove';
list.addEventListener('keydown', (e) => {
if (matchesCombo(e, 'Escape')) closePanel();
if (matchesCombo(e, 'mod+KeyK')) openPalette();
});
A combo is zero or more of mod+ / ctrl+ / alt+ / shift+ / meta+ (any
order, any case) followed by a KeyboardEvent.code; whitespace around the
parts is ignored. mod resolves to meta on Apple platforms and ctrl
elsewhere. Matching is exact: every declared modifier must be held and every
undeclared one must not be, so 'Escape' above rejects
Ctrl+Escape. The code part is
matched case-sensitively against e.code — the physical key, unaffected by
keyboard layout. No code value contains a + (the plus key itself is Equal
or NumpadAdd), so the separator is unambiguous; a combo that names an unknown
modifier or ends in a dangling + matches nothing.
toggleTabIndex({ root, isActive })
Sets tabindex to 0 or -1 on a single element.
import { toggleTabIndex } from '@mixedrays/keyrove';
toggleTabIndex({ root: firstItem, isActive: true });
Exported for the cases where you manage the tab stop yourself — establishing the first one in a roving group, or restoring it after re-rendering a list. Descendant tab stops are deliberately left alone; roving tabindex only needs the item itself to carry the stop.
A nullish root is a no-op, so a query that found nothing does not need
guarding at the call site.
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-length |
root | 1 |
A value above 1 switches the group to grid navigation. |
data-keyrove-page-length |
root | 10 |
Items per page jump — whole rows in a grid. |
data-keyrove-next-key |
root | ArrowDown |
Combo that moves forward, e.g. KeyJ or ctrl+ArrowRight. |
data-keyrove-prev-key |
root | ArrowUp |
Combo that moves back. |
data-keyrove-loop |
root | — | Next/prev wrap past the ends of a list. Grids never wrap. |
data-keyrove-orientation |
root | — | horizontal maps the 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. |
Root attributes are read on every keypress rather than cached, so changing one takes effect immediately — see responsive grids.
Elements carrying disabled are excluded from navigation without needing
data-keyrove-skip.
Constants
Each attribute name is also exported, so markup built in JavaScript need not hardcode strings:
import {
KEYROVE_ATTR_ITEM,
KEYROVE_ATTR_SKIP,
KEYROVE_ATTR_ROOT,
KEYROVE_ATTR_NEXT_KEY,
KEYROVE_ATTR_PREV_KEY,
KEYROVE_ATTR_PAGE_LENGTH,
KEYROVE_ATTR_COLS_LENGTH,
KEYROVE_ATTR_ROVING_TABINDEX,
KEYROVE_ATTR_LOOP,
KEYROVE_ATTR_ORIENTATION,
KEYROVE_ATTR_TYPEAHEAD,
} from '@mixedrays/keyrove';
Types
import type {
KeyRoveCode,
KeyRoveEvent,
Move,
MoveAction,
MoveResult,
Options,
TypeaheadMove,
TypeaheadOptions,
TypeaheadResult,
} from '@mixedrays/keyrove';
KeyRoveEvent
The shape keyrove needs from a keydown event — structural rather than a union of
KeyboardEvent | React.KeyboardEvent, so the package stays dependency-free
while accepting both.
type KeyRoveEvent = {
code: KeyRoveCode;
target: EventTarget | null;
currentTarget: EventTarget | null;
preventDefault: () => void;
ctrlKey?: boolean;
altKey?: boolean;
shiftKey?: boolean;
metaKey?: boolean;
key?: string;
};
The modifier flags are optional so a hand-built event object still qualifies,
and a missing flag reads as "not held". Native and framework events carry all
four; if you bridge events through an object of your own, forward them — an
object without them matches every binding as though no modifier were pressed.
key is optional for the same reason and only
typeahead reads it: matching typed text needs the
layout-dependent character, where bindings deliberately stay on the physical
code. An event without it navigates as ever; it just never typeaheads.
KeyRoveCode
A KeyboardEvent.code. The union arm keeps it assignable from a plain string
— which is how both the DOM and React type code, and what the code part of
any data-keyrove-*-key combo is — while editors still complete the codes
keyrove handles by default. It documents intent; it does not validate, and it
does not constrain what you can bind.
type KeyRoveCode =
| 'ArrowUp'
| 'ArrowDown'
| 'ArrowLeft'
| 'ArrowRight'
| 'Home'
| 'End'
| 'PageUp'
| 'PageDown'
| (string & {});
MoveAction, MoveResult, Move
type MoveAction = 'home' | 'end' | 'next' | 'prev' | 'pageUp' | 'pageDown';
// what keyRove returns for a consumed keypress
type MoveResult = {
action: MoveAction;
from: Element | null;
to: Element | null;
};
// what onMove receives: a move that actually happened
type Move = MoveResult & { to: Element };
type Options = { onMove?: (move: Move) => void };
TypeaheadOptions, TypeaheadResult, TypeaheadMove
What createTypeahead takes and its handler
returns. The result is MoveResult's shape with its own action, which is
what lets the two handlers share one listener with ||.
type TypeaheadOptions = {
resetMs?: number; // buffer lifetime, default 500
onMove?: (move: TypeaheadMove) => void;
};
type TypeaheadResult = {
action: 'typeahead';
from: Element | null;
to: Element | null; // null: the buffer grew but still names the focused item
};
// what onMove receives: a move that actually happened
type TypeaheadMove = TypeaheadResult & { to: Element };