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. to is the newly focused item, or null for 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 };