+
January
February
March
diff --git a/packages/docs/content/docs/about.md b/packages/docs/content/docs/about.md
index 36f3ecc..6335a22 100644
--- a/packages/docs/content/docs/about.md
+++ b/packages/docs/content/docs/about.md
@@ -5,16 +5,15 @@ group: Guide
order: 5
---
-keyrove is a framework-agnostic, dependency-free library for keyboard navigation
-in lists, grids and trees; the [introduction](/docs/introduction) explains what
-it does and how it fits together. It is developed in the open under the MIT
-licence, and issues and pull requests are welcome.
+keyrove provides keyboard navigation for lists, grids and trees, in any
+framework and with no runtime dependencies. See the [introduction](/docs/introduction)
+for setup and behavior. The project is open source under the MIT license;
+issues and pull requests are welcome.
## This site
-Every page is also available as markdown — append `.md` to any URL, or use
-**View as Markdown** in the right-hand rail. The pages are generated from the
-files under `packages/docs/content` in the same repository, which is what the
-**View source** link on each page opens.
+To read a page as Markdown, append `.md` to its URL or choose **View as
+Markdown** in the right-hand rail. **View source** opens its source file under
+`packages/docs/content` in the repository.
diff --git a/packages/docs/content/docs/api.md b/packages/docs/content/docs/api.md
index f854786..c6e6044 100644
--- a/packages/docs/content/docs/api.md
+++ b/packages/docs/content/docs/api.md
@@ -1,20 +1,23 @@
---
title: API reference
-description: Every export in the package — the handler, the typeahead helper, the combo matcher, the attributes, and the types.
+description: Reference for handlers, configuration, key bindings, focus behavior, attributes and exported types.
group: Guide
order: 4
---
-| Export | What it is |
-| --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
-| [`keyRove(event, options?)`](#keyrove-event-options) | The handler. Moves focus for one keydown and reports what it did. |
-| [`options`](#options) | The group's settings in JavaScript, where you would rather not use markup. |
-| [`createTypeahead(options?)`](#createtypeahead-options) | Builds a type-to-focus handler to chain after `keyRove`. |
-| [`matchesCombo(event, combo)`](#matchescombo-event-combo) | The combo matcher behind every binding, for your own handlers. |
-| [`toggleTabIndex({ root, isActive })`](#toggletabindex-root-isactive) | Sets `tabindex` to `0` or `-1` on one element. |
-| [`data-keyrove-*`](#attributes) | The attributes: the whole configuration, on items and on the root. |
-| [`KEYROVE_ATTR_*`](#constants) | One constant per attribute name. |
-| [Types](#types) | `KeyRoveEvent`, `MoveResult`, `Move`, `Options` and the typeahead types. |
+| Export | Purpose |
+| ------------------------------------------------------------------------ | ------------------------------------------------- |
+| [`keyRove(event, options?)`](#keyrove-event-options) | Handles a keydown and reports the focus move. |
+| [`options`](#options) | Configures the group in JavaScript. |
+| [`rove(element, action, options?)`](#rove-element-action-options) | Moves focus by action name, without a keypress. |
+| [`createTypeahead(options?)`](#createtypeahead-options) | Creates a type-to-focus handler. |
+| [`matchesCombo(event, combo)`](#matchescombo-event-combo) | Tests a key combination in your own handlers. |
+| [`initRovingTabindex(root, options?)`](#initrovingtabindex-root-options) | Initializes or repairs a group's roving tab stop. |
+| [`followFocus(event, options?)`](#followfocus-event-options) | Updates the roving tab stop on `focusin`. |
+| [`toggleTabIndex({ root, isActive })`](#toggletabindex-root-isactive) | Sets one element's `tabindex` to `0` or `-1`. |
+| [`data-keyrove-*`](#attributes) | Configures items and roots in markup. |
+| [`KEYROVE_ATTR_*`](#constants) | Constants for attribute names. |
+| [Types](#types) | Event, configuration and result types. |
## keyRove(event, options?)
@@ -29,11 +32,9 @@ list.addEventListener('keydown', (e) => keyRove(e));
### Keys
-A group is one sequence of items in DOM order. `next` and `prev` always move
-one item through it: a list item, or a grid cell. `data-keyrove-cols` folds the
-sequence into rows and adds a second pair, `next-row` and `prev-row`, that
-moves a whole row and keeps the column. Every move has a `*-key` attribute on
-the root; the tables show the default each answers to.
+Items form a sequence in DOM order. `next` and `prev` move one item. In a
+grid, `cols` divides that sequence into rows, and `next-row`/`prev-row` move
+one row in the same column. Each move has a `*-key` attribute on the root.
In a list:
@@ -62,13 +63,56 @@ follow the [reading direction](#horizontal-groups-and-rtl)):
| `PageDown` | `data-keyrove-page-down-key` | Forward `page-length` rows |
| `PageUp` | `data-keyrove-page-up-key` | Back `page-length` rows |
-Set the attribute on the root and that key takes over the move. The default it
-replaced goes back to its browser behaviour, and nothing else changes. See
-[custom keys](/docs/examples/custom-keys) for worked examples.
+Set a move's key attribute to replace its default binding. Set it to `none`
+to disable the binding, for example `data-keyrove-page-down-key="none"`.
+The replaced key returns to the browser unless another binding handles it.
+See [custom keys](/docs/examples/custom-keys).
+
+### Exit and enter
+
+Bind `exit` on an inner root to leave it, or `enter` on the outer root to
+focus a group inside the current item. Neither action has a default key.
+
+| Bound by | `keys` field | Moves |
+| ------------------------ | ------------ | ---------------------------------------------------- |
+| `data-keyrove-exit-key` | `exit` | From inside a nested root to the group around it |
+| `data-keyrove-enter-key` | `enter` | From the focused item into the root nested inside it |
+
+```html
+
+
+ Build failed
+
+ Retry
+ View logs
+
+
+
+```
+
+- **Exit** focuses the containing item in the outer group. If none is eligible,
+ it chooses the nearest eligible item after the root, then the nearest before
+ it. It never exits beyond the listener's element.
+- **Enter** uses the first nested root inside the focused item. It chooses
+ that group's first navigable roving item with `tabindex="0"`, or its first
+ navigable item. It does not try later roots if the first has no destination.
+- Both exclude skipped and disabled items and items belonging to deeper roots.
+- Each group keeps its own roving tab stop. A move updates an existing stop in
+ the destination group, leaving the source group's stop in place. This lets
+ enter return to the item last used in the inner group. Initialize each
+ group's stop with [roving tabindex](/docs/examples/roving-tabindex).
+
+When no destination is found, the key remains unhandled. This includes exit
+from the listener's own group and enter on an item with no nested root. If a
+chosen target cannot receive focus, the key is consumed with `to: null` and
+`onMove` does not run, as with other focus moves.
+
+The reported actions are `'exit'` and `'enter'`. See
+[nested roots](/docs/examples/nested-roots#getting-back-out) for the demo.
### Combos
-Every `*-key` value is a combo, matched by
+Every `*-key` value is a combo, or a list of them, matched by
[`matchesCombo`](#matchescombo-event-combo):
- Zero or more of `mod+`, `ctrl+`, `alt+`, `shift+`, `meta+`, in any order and
@@ -86,13 +130,26 @@ Every `*-key` value is a combo, matched by
Ctrl +
J keeps its browser
default. A `ctrl+KeyJ` binding never fires on a plain
J .
+- A comma separates the combos of a list, and a move answers to any of them:
+ `data-keyrove-next-key="ArrowDown, KeyJ"` keeps the arrow and adds
+
J . No code contains a comma (the comma key is
+ `Comma`), and whitespace around the commas is ignored. Each entry is matched
+ on its own, so an empty entry or one naming an unknown modifier matches
+ nothing and leaves the others working. A list is literal like any explicit
+ binding: to keep the default key, name it.
- The defaults are exact combos too. A modified
PageDown is left alone, and so are
Ctrl +
Home and
Ctrl +
End in a list, which has
no grid-wide scope for them.
- A combo naming an unknown modifier, or ending in a dangling `+`, matches
- nothing. An empty attribute is unset.
+ nothing, not even a keydown with an empty `code`. An empty or blank value,
+ or one of nothing but commas, is unset: an attribute leaves the move its
+ default key, and a `keys` value leaves it to the attribute.
+- `none`, trimmed and in any case, is not a combo. On its own it binds the move
+ to no key; inside a list it is an entry that matches nothing. On a
+ [focus key](#focus-keys) it is unset, since an element has no default key to
+ free.
### Precedence
@@ -103,12 +160,11 @@ order, and the first match wins:
2. The root's explicit `*-key` bindings.
3. The defaults of the moves left unbound.
-So an explicit binding that names another move's default key takes the press,
-and that default stands down: with `data-keyrove-next-key="Home"`,
-
Home moves to the next item and nothing jumps to the
-first. A replaced default is not re-added anywhere; the freed key goes back to
-its browser behaviour. A move the layout lacks, such as a row move on a list,
-is not in the table at all, so binding it does nothing.
+An explicit binding wins over another action's default. For example,
+`data-keyrove-next-key="Home"` makes
Home move to the next item instead of the
+first. Replaced defaults are not restored elsewhere. `none` removes a move's
+binding. Bindings for actions the layout does not support, such as row moves
+in a list, are ignored.
### Roots
@@ -120,16 +176,18 @@ the listener is attached to (`currentTarget`). A listener on `document` or
- The root's attributes configure the group. They are read on every keypress,
so changing one takes effect at once; see
[responsive grid](/docs/examples/responsive-grid).
-- The items are every `data-keyrove-item` in the root's subtree, in DOM order,
- nested roots included. An outer group's order runs straight through an inner
- group's items, and nothing hides them: a group that must stay out of
- another's order belongs outside that root.
-- Resolving from the target rather than the listener is what lets one
- delegated listener serve several roots, and lets roots
- [nest](/docs/examples/nested-roots). While focus is inside an inner root,
- only that root's bindings apply; a key it does not bind keeps its browser
- default rather than reaching the group around it. The one thing heard across
- roots is a [focus key](#focus-keys).
+- Items are all enabled `data-keyrove-item` elements under the root, in DOM
+ order, except those carrying `disabled`. This includes items in nested
+ roots. Use sibling roots to keep attribute-selected sequences separate.
+ With [roving tabindex](/docs/examples/roving-tabindex), each root keeps its
+ own tab stop: a move onto a nested root's item moves that root's stop and
+ leaves the outer group's in place.
+- One delegated listener can serve several roots. Inside a
+ [nested root](/docs/examples/nested-roots), only that root's movement
+ bindings apply; unbound keys do not fall through to the outer group.
+ [Focus keys](#focus-keys) can cross root boundaries, and
+ [exit and enter](#exit-and-enter) move between a nested root and the group
+ around it.
- An item counts as focused when focus is anywhere inside it
(`:focus-within`), so an item wrapping a link or a button is still the
position after
Tab lands on that inner control.
@@ -146,40 +204,48 @@ on where focus is:
- **Nothing in the group focused.** Only the moves that can enter a group are
consumed: the four directional moves, which land on the first navigable item
(the last, for prev on a looping list), and a [focus key](#focus-keys),
- which lands on its element. Home, End, the row ends and the page moves act only
+ which lands on its element.
Home ,
End , the row ends and the page moves act only
once focus is inside an item; pressed here, they keep their browser default.
-- **A group with no items.** Every key keeps its browser default.
+- **A group with no items.** Movement keys keep their browser defaults.
+ Focus shortcuts can still reach elements that are not items.
+- **[Exit and enter](#exit-and-enter).** Unhandled when no destination is found;
+ consumed when a target is chosen, even if it cannot receive focus.
+
+Give targets native focusability or a `tabindex`. If a target cannot take
+focus, such as a hidden or inert element, the key is consumed without moving.
+The roving stop is restored and `onMove` does not fire.
-Keys keyrove is not bound to are never touched.
Tab ,
-
Shift +
Tab ,
-
Enter ,
Space and
-
Escape reach your handlers and the browser as they would
-without keyrove, which is why it composes with native focus navigation instead
-of replacing it. The [return value](#return-value) tells you which case a
-keypress fell into.
+Unbound keys keep their browser behavior and remain available to your
+handlers.
Tab ,
Shift +
Tab ,
Enter ,
Space and
Escape are unbound by default.
+The [return value](#return-value) tells your handler whether keyrove consumed
+the key. A key another handler already consumed is left alone.
### Edges and looping
At the ends of a list, next and prev are consumed without moving. Add
`data-keyrove-loop` to the root and they wrap instead: forward from the last
navigable item lands on the first, and back from the first lands on the last.
-A looping list is entered the same way round: the prev key pressed from outside
-lands on the _last_ item, matching the APG menu-button convention. Only lists
-loop; a grid keeps its edges, per the APG grid pattern. See
+Entering a looping list with prev focuses its last navigable item. Only
+next/prev in lists loop; grids keep their boundaries. See
[looping lists](/docs/examples/looping-lists).
A page jump travels as far as it can. One that would overshoot lands on the
last (or first) navigable item rather than doing nothing; in a grid that is the
grid's last (or first) navigable cell, whatever column the jump started in. The
-row ends are stricter: `Home` and `End` in a grid never fall back to a skipped
-cell, so a row of nothing but skipped cells is a consumed no-op.
+row ends are stricter: `Home` and `End` in a grid look only at the focused
+row, so a row of nothing but skipped cells is a consumed no-op.
+
+A skipped item is never a move's destination, including when every item is
+skipped. Then no move has anywhere to land. From outside the group, the
+directional keys are unhandled and keep their browser default. With focus
+already inside an item, such as a skipped item the user clicked, a bound move
+is a consumed no-op. Typeahead and roving initialization exclude skipped items
+the same way.
### Editable targets
-Keys pressed inside an editable element are never acted on, however they are
-bound: 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. Editable means:
+Movement bindings do not run inside editable elements. Those elements keep
+their caret, value and typing keys. Editable targets include:
- a `textarea` or a `select`;
- a `[contenteditable]` element and everything inside it, except a
@@ -189,26 +255,21 @@ inside an item is not swallowed by a printable-key binding. Editable means:
so navigating from them takes nothing away: a list of checkbox rows keeps
its arrows.
-One exception: a [focus key](#focus-keys) whose combo holds
-
Ctrl ,
Alt or
-
Meta fires from inside a field. That press is a command,
-not typing. The one chord that can be both is
-
Ctrl +
Alt : on Windows it is how
-
AltGr is reported, so a `ctrl+alt+` focus key fires
-while a user types € or @ on many European layouts.
+A [focus key](#focus-keys) with
Ctrl ,
Alt or
Meta can run inside an editable
+field. These shortcuts can still conflict with text input: Windows can report
+
AltGr as
Ctrl +
Alt , so a `ctrl+alt+` focus key may fire while typing characters
+such as € or @.
-While an input method is composing (`isComposing` on the event), nothing is
-acted on, chord or not: the arrows walk its candidate list and a chord can be
-part of the conversion, so every key stays with the input method.
+When `isComposing` is true, no binding runs, including focus shortcuts. All
+keys remain available to the input method.
-See [editable targets](/docs/examples/editable-targets) for the rule at work.
+See [editable targets](/docs/examples/editable-targets) for a demo.
### Horizontal groups and RTL
-Reading direction changes only which physical key is a move's _default_. It
-never changes what a move does; every move is defined in DOM order. Where an
-axis runs sideways, its default arrows follow the reading direction, read from
-the nearest `dir` attribute and otherwise from the computed direction:
+Reading direction changes the default horizontal arrows. Moves still follow
+DOM order. Direction comes from the nearest `dir` attribute; a missing `dir`
+or `dir="auto"` falls back to computed style.
- `data-keyrove-orientation="horizontal"` on a list makes `ArrowRight` and
`ArrowLeft` the next and prev defaults. Under RTL they swap, so a toolbar
@@ -227,82 +288,92 @@ work.
### Focus keys
-An element can name its own key. `data-keyrove-focus-key="ctrl+shift+KeyE"`
-focuses that element from anywhere the keydown reaches the listener: a sibling
-group, a nested root, or, when the combo holds
Ctrl ,
-
Alt or
Meta , a text field. The
-move reports `'focus'`. The value is a [combo](#combos) like any other, and a
-bare code such as `KeyE` works wherever the letter would not be typing.
+Set `data-keyrove-focus-key="ctrl+shift+KeyE"` on an element to focus it from
+anywhere under the listener, including sibling and nested roots. A combo with
+
Ctrl ,
Alt or
Meta also works inside an editable field. Bare codes work outside
+editable targets. The reported action is `'focus'`.
- Focus keys sit first in the [binding table](#precedence), so they win any
collision with the root's bindings or the defaults. Two elements naming one
- combo resolve to the first in DOM order. A skipped or disabled element's key
- is inert.
+ combo resolve to the first in DOM order. The attribute scan excludes targets
+ with an enabled `data-keyrove-skip` or a `disabled` attribute.
- The element need not be an item. An item stays in its group's order, so the
arrows reach it too; any other element is reached by its key alone.
- On an item, the move happens in the item's own group: the nearest root above
it, else the listener's element. An item that is itself a root belongs to
the group above it. `from` is the item of that group holding focus, or `null`
when focus was outside it.
-- On any other element, the move belongs to no group: `from` is `null`, and no
- roving tab stop moves. A panel holding a list is best made that list's root,
- so an arrow pressed on the panel after the jump enters the panel's own items.
+- On a non-item destination, `from` is `null` when jumping to it, and no
+ roving stop moves. If focus is already inside it, `from` is that element
+ and the result is a consumed no-op. Make a panel its inner list's root so
+ an arrow after the shortcut enters that list.
- Pressed while focus is already inside its element, the key is a consumed
no-op: claimed, with `to: null`.
+- The element has to be able to take focus. A panel usually needs
+ `tabindex="-1"`, which also keeps it out of the
+
Tab order. On an element that cannot take focus, the
+ key is the same consumed no-op.
- On an item, the roving tab stop moves within the item's group, as for an
arrow move: when the item focus leaves carries
`data-keyrove-roving-tabindex`, it drops to `tabindex="-1"` and the target
- takes `0`. From outside the group the stop stays where it was, and a nested
- group's stop is never touched.
+ takes `0`. When focus leaves an item of a nested group, that group keeps its
+ stop, and the target takes its own group's stop. From outside the group
+ keyRove leaves the stop where it was, though a
+ [`followFocus`](#followfocus-event-options) listener then moves it to the
+ item focus landed on.
See [focus keys](/docs/examples/focus-keys) for the pattern at work.
### Options
-Every setting a root's attributes carry can be named in the options object
-instead. Both sources are read for every keypress, one field at a time, options
-first — so a group can be described in markup, in JavaScript, or in any mixture
-of the two, and a call passing no options reads exactly the markup it always
-did. [Attributes and options](/docs/attributes-and-options) is the guide to
-choosing between them.
+Use options to configure a group in JavaScript. `keyRove` reads them on each
+call, falling back to attributes and defaults for omitted settings. The
+options object is typed [`KeyRoveOptions`](#keyroveoptions). See
+[configuration differences](/docs/attributes-and-options#configuration-differences)
+for naming, scope and replacement rules.
```ts
-keyRove(e); // everything from the markup
-keyRove(e, { loop: true }); // items from the markup, looping from here
-keyRove(e, { items: '[role="menuitem"]' }); // nothing from the markup
+keyRove(e); // attributes and defaults
+keyRove(e, { loop: true }); // override looping only
+keyRove(e, { items: '[role="menuitem"]' }); // override item lookup only
```
-| Option | Falls back to | Meaning |
-| ---------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
-| `items` | `data-keyrove-item` | The group's items: a selector run inside the root, or `(root) => Element[]`. Elements carrying `disabled` are never navigable. |
-| `root` | `data-keyrove-root` | Selector a [root](#roots) answers to, matched at or above the event's target. |
-| `cols` | `data-keyrove-cols` | Columns. Above 1 the group is a grid. |
-| `loop` | `data-keyrove-loop` | Whether next/prev wrap at the ends. Lists only. |
-| `orientation` | `data-keyrove-orientation` | `'horizontal'` re-points a list's default arrows; see [RTL](#horizontal-groups-and-rtl). |
-| `pageLength` | `data-keyrove-page-length` | Rows per page jump — items, in a list. |
-| `keys` | the `*-key` attributes | The [combo](#combos) each move answers to: `{ next: 'KeyJ', prev: 'KeyK' }`. Read move by move. |
-| `focusKeys` | `data-keyrove-focus-key` | Combo → element, or a selector resolved within the listener's reach. Replaces the attribute scan rather than adding to it. |
-| `skip` | `data-keyrove-skip` | Which items a move passes over: a selector or `(element) => boolean`. |
-| `rovingTabindex` | `data-keyrove-roving-tabindex` | Whether the group carries one tab stop. One boolean for the group, where the attribute is read per item. |
-
-The fallback is per _field_, not per call. With
-`keyRove(e, { keys: { next: 'KeyJ' } })` the next move answers to
-
J while
Home ,
-
End and the page keys keep their attributes, and their
-defaults where there is no attribute. `cols` and `pageLength` are ignored below
-1, as an unparseable attribute is.
-
-Options are read on every keypress, so an object built at the call site is as
-live as an attribute: `keyRove(e, { cols: columnsNow() })` re-folds the grid
-between presses. See [options in JavaScript](/docs/examples/javascript-options)
-for the whole thing at work, and
-[`createTypeahead`](#createtypeahead-options), which takes the same names for
-the settings it needs.
+| Option | Falls back to | Meaning |
+| ---------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `items` | `data-keyrove-item` | The group's items: a selector run inside the root, or `(root) => Element[]`. Elements carrying `disabled` are never navigable. |
+| `root` | `data-keyrove-root` | Selector a [root](#roots) answers to, matched at or above the event's target. |
+| `cols` | `data-keyrove-cols` | Columns. Above 1 the group is a grid. `'auto'` counts the root's CSS grid tracks on every keypress. |
+| `loop` | `data-keyrove-loop` | Whether next/prev wrap at the ends. Lists only. |
+| `orientation` | `data-keyrove-orientation` | `'horizontal'` re-points a list's default arrows; see [RTL](#horizontal-groups-and-rtl). |
+| `pageLength` | `data-keyrove-page-length` | Rows per page jump — items, in a list. |
+| `keys` | the `*-key` attributes | The [combo](#combos) or combos each move answers to: `{ next: 'ArrowDown, KeyJ' }`, or `'none'` for no key. Read move by move. Includes [`exit` and `enter`](#exit-and-enter). |
+| `focusKeys` | `data-keyrove-focus-key` | Combo, or a list of them, → element or a selector resolved within the listener's reach: `{ 'F6, ctrl+KeyE': '#panel' }`. Replaces the attribute scan rather than adding to it. |
+| `skip` | `data-keyrove-skip` | Which items a move passes over: a selector or `(element) => boolean`. |
+| `rovingTabindex` | `data-keyrove-roving-tabindex` | Whether the group carries one tab stop. One boolean for the group, where the attribute is read per item. |
+
+`keys` falls back per action. `{ keys: { next: 'KeyJ' } }` changes only next;
+other actions retain their attribute or default bindings. Empty, blank and
+comma-only bindings fall back; `'none'` disables the binding.
+
+A supplied `focusKeys` map, including `{}`, replaces the whole attribute scan.
+Its targets bypass skip checks, but disabled targets remain excluded. Selector
+targets resolve under the listener; element targets are used directly.
+
+Numeric `cols` and `pageLength` options are rounded down to whole numbers.
+Values below 1 or `NaN` fall back to the attribute, then the default if the
+attribute is invalid or below 1. Defaults are one column and ten items or rows
+per page.
+
+Supply computed values in the call, for example
+`keyRove(e, { cols: columnsNow() })`. See
+[options in JavaScript](/docs/examples/javascript-options) and the shared
+settings accepted by [`createTypeahead`](#createtypeahead-options).
### options.onMove
Fired _after_ focus has moved, and only when it actually moved. A consumed key
-with nowhere to go, at the end of a list or the edge of a grid, fires nothing.
+with nowhere to go, at the end of a list or the edge of a grid, fires nothing,
+and neither does a move whose target does not take focus.
```ts
keyRove(e, {
@@ -310,24 +381,24 @@ keyRove(e, {
});
```
-`action` names the move: `'next'`, `'prev'`, `'home'`, `'end'`, `'pageUp'`,
-`'pageDown'`; in grids only, `'nextRow'`, `'prevRow'`, `'homeRow'` and
-`'endRow'`; and `'focus'` for a [focus key](#focus-keys). A cell move and a
-row move never report the same token. `from` is the item focus left, or `null`
-when the group was entered from outside or by a focus key on an element that is
-not an item, and `to` is where focus landed.
+`action` is `'next'`, `'prev'`, `'home'`, `'end'`, `'pageUp'` or `'pageDown'`;
+`'nextRow'`, `'prevRow'`, `'homeRow'` or `'endRow'` in a grid; `'exit'` or
+`'enter'` between nested groups; or `'focus'` for a shortcut. `from` is the item
+focus left, or `null` when no item was focused or the destination is a non-item.
+`to` is the element that received focus.
### 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.
+ This includes a key another handler already consumed.
- `{ action, from, to }`: the key was consumed. `to` is the newly focused
element, or `null` for a consumed no-op, where the group owns the key but
- there is nowhere left to go.
+ there is nowhere left to go, or the target did not take focus.
-A non-null result means "claimed", which is what lets several handlers share
-one listener without stepping on each other:
+A non-null result means the key was consumed. Chain handlers with `||` to
+pass only unhandled keys to the next one:
```ts
element.addEventListener('keydown', (e) => keyRove(e) || myOwnHandler(e));
@@ -335,11 +406,65 @@ element.addEventListener('keydown', (e) => keyRove(e) || myOwnHandler(e));
The [listbox](/docs/examples/listbox) chains three handlers this way.
+A key is already consumed when an earlier handler called `preventDefault()`
+on the event. `keyRove` and typeahead return `null` for it without moving
+focus, so a component and an app shell can both call keyrove on the same
+bubbling event and focus moves once. keyrove never stops propagation, so
+ancestor listeners still receive the event. In a `||` chain, `null` still
+passes it to your next handler; check `e.defaultPrevented` there if your
+handler should skip consumed keys too.
+
+## rove(element, action, options?)
+
+Moves focus by action name, without a keypress. Use it for on-screen next and
+previous buttons, gamepads and remotes, or to focus a group's first item with
+`rove(list, 'home')`.
+
+```ts
+import { rove } from '@mixedrays/keyrove';
+
+nextButton.addEventListener('click', () => rove(results, 'next'));
+prevButton.addEventListener('click', () => rove(results, 'prev'));
+```
+
+`action` is a [stride action](#groupoptions-strideaction): `'next'`, `'prev'`,
+`'home'`, `'end'`, `'pageUp'` or `'pageDown'`, plus `'nextRow'`, `'prevRow'`,
+`'homeRow'` and `'endRow'` in a grid. Key bindings do not affect it. To focus
+a specific element, call `element.focus()`, and attach
+[`followFocus`](#followfocus-event-options) to a roving group.
+
+The group is found as for a keypress, with `element` as both target and
+listener: the nearest [root](#roots) at or above `element`, else `element`
+itself.
+
+The move starts from, in this order:
+
+1. The focused item. The move matches its key exactly: same result, roving
+ stop and `onMove`.
+2. In a roving group, the item holding the tab stop. A button takes focus when
+ pressed, so this lets repeated presses continue from the user's position.
+3. Otherwise, the move enters the group. `home`, `next`, `nextRow` and
+ `pageDown` go to the first navigable item; `end`, `prev`, `prevRow` and
+ `pageUp` go to the last. `homeRow` and `endRow` do nothing.
+
+Row actions apply only to grids; in a list they do nothing.
+
+It accepts the same [options](#options) as `keyRove`, including `onMove`, and
+ignores `keys` and `focusKeys`.
+
+It returns `{ action, from, to }`, as a keypress does (see
+[Return value](#return-value)). `from` is `null` when the move entered the
+group. `to` is `null` when there was nowhere to go or the target did not take
+focus. It returns `null` when there is no move to make: no group, no item to
+enter, or a row action in a list.
+
## 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.
+navigable item whose label starts with it. Case is ignored, and so are
+accents and other combining marks:
E reaches _Émilie_,
+and
É reaches _emilie_.
```ts
import { keyRove, createTypeahead } from '@mixedrays/keyrove';
@@ -354,17 +479,16 @@ navigates instead of entering the buffer. Create one handler per listener: the
buffer lives in the handler, which keeps `keyRove` itself stateless. See
[typeahead](/docs/examples/typeahead) for it at work.
-| Option | Default | Meaning |
-| ----------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
-| `label` | — | `(item) => string`, the text an item is matched by. Falls through to the attribute and then the item's text where it returns nothing. |
-| `resetMs` | `500` | Milliseconds of typing silence after which the buffer clears. |
-| `matchMode` | `'prefix'` | `'cycle'` moves each single-character press to the next matching item after focus, wrapping, so repeats cycle; [see below](#cycle-mode). |
-| `onMove` | — | Fired after focus has moved, and only then; see [`keyRove`'s option](#options-onmove). |
+| Option | Default | Meaning |
+| ---------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
+| `label` | — | `(item) => string`, the text an item is matched by. Falls through to the attribute and then the item's text where it returns nothing. |
+| `resetMs` | `500` | Milliseconds of typing silence after which the buffer clears. |
+| `matchMode` | `'prefix'` | `'cycle'` moves each single-character press to the next matching item after focus, wrapping, so repeats cycle; [see below](#cycle-mode). |
+| `foldDiacritics` | `true` | Ignore accents and other combining marks on both sides of the match. Turn it off where an accent tells two items apart. |
+| `onMove` | — | Fired after focus has moved, and only then; see [`keyRove`'s option](#options-onmove). |
-It also takes the [group settings](#options) that bear on finding an item —
-`items`, `root`, `skip` and `rovingTabindex` — under the same names and with
-the same fallbacks, so one object configures both handlers and they cannot
-disagree about what an item is:
+It shares `items`, `root`, `skip` and `rovingTabindex` with `keyRove`, using
+the same fallback rules. Pass one configuration to both handlers:
```ts
const config = { items: '[role="menuitem"]', loop: true };
@@ -373,15 +497,18 @@ const typeahead = createTypeahead(config);
el.addEventListener('keydown', (e) => keyRove(e, config) || typeahead(e));
```
-The settings that are about _moves_ — `keys`, `cols`, `loop`, `orientation`,
-`pageLength` — are not among its options: a typeahead has one way to reach an
-item, its label.
+Typeahead ignores movement options: `keys`, `cols`, `loop`, `orientation`
+and `pageLength`. Its options are captured when the handler is created;
+recreate it to change them. Item queries, skip predicates and attribute
+fallbacks still read the current DOM. The
+[complete roving setup](/docs/installation#complete-roving-setup) shows it
+beside navigation, initialization and focus tracking.
-The label is the item's `data-keyrove-typeahead` attribute, falling back to its
-`textContent`, trimmed and with runs of whitespace collapsed, when the attribute
-is absent or empty. Items carrying `data-keyrove-skip` or `disabled` are passed
-over. The root resolves [as in `keyRove`](#roots), so the same delegated
-listener serves both.
+Labels come from `label(item)`, then `data-keyrove-typeahead`, then
+`textContent`. An empty value falls through to the next source. Text content
+is trimmed and consecutive whitespace is collapsed. Skipped and disabled
+items are excluded; a `skip` option replaces the attribute test.
+Roots resolve [as in `keyRove`](#roots).
Bindings match the physical `e.code`; typeahead reads `e.key`, the character
the key produced in the user's layout. A press is buffered only when it is
@@ -402,8 +529,9 @@ the buffer, so a mistyped prefix matches nothing more until the reset clears
it.
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 match is the item already focused.
+untouched or [already consumed](#return-value), which leaves the buffer
+unchanged, and `{ action: 'typeahead', from, to }` when it was consumed, with
+`to: null` when the match is already focused or cannot receive focus.
`onMove` fires after focus has moved and only when it actually moved, so both
handlers can feed the same follow-focus logic. The roving tab stop moves with
the match, as for an arrow move.
@@ -421,8 +549,9 @@ cannot be found by typing "aa".
## matchesCombo(event, combo)
-Whether a keydown event matches a [combo](#combos). This is the matcher behind
-every key check keyrove makes, exported for your own handlers.
+Whether a keydown event matches a [combo](#combos), typed
+[`KeyCombo`](#keycombo). This is the matcher behind every key check keyrove
+makes, exported for your own handlers.
```ts
import { matchesCombo } from '@mixedrays/keyrove';
@@ -434,7 +563,98 @@ list.addEventListener('keydown', (e) => {
```
Matching is exact, so `'Escape'` above rejects
-
Ctrl +
Escape .
+
Ctrl +
Escape . A list matches any
+of its combos, which saves writing the any-of by hand:
+
+```ts
+if (!matchesCombo(e, 'Space, Enter')) return null;
+```
+
+## initRovingTabindex(root, options?)
+
+Initializes or repairs a [roving group's](/docs/examples/roving-tabindex) tab
+stop. Call it after rendering, and again after a render that may replace items.
+`keyRove` does not initialize the stop for you.
+
+```ts
+import { initRovingTabindex } from '@mixedrays/keyrove';
+
+initRovingTabindex(list);
+```
+
+Only the root's own roving items participate: items marked with
+`data-keyrove-roving-tabindex`, or those enabled by the options. Nested roots
+keep their own stops; initialize them separately.
+
+The stop is chosen in this order:
+
+1. A valid `initial` item: one of the group's navigable roving items.
+2. The first navigable roving item that already has `tabindex="0"`.
+3. The first navigable roving item.
+
+Skipped and disabled items cannot hold the stop. All other roving items get
+`-1`, including skipped and disabled ones. Only changed attributes are written.
+
+It returns the item holding the stop, or `null` when no roving item is
+navigable. Items that do not carry the stop keep whatever `tabindex` they have.
+
+It shares `items`, `root`, `skip` and `rovingTabindex` with the other helpers,
+so you can pass the same configuration:
+
+```ts
+const config = { items: '[role="menuitem"]', rovingTabindex: true };
+
+initRovingTabindex(menu, config);
+menu.addEventListener('keydown', (e) => keyRove(e, config));
+```
+
+Use `initial` to choose a stop explicitly, such as a listbox's selected option:
+
+```ts
+initRovingTabindex(listbox, {
+ initial: listbox.querySelector('[aria-selected="true"]'),
+});
+```
+
+A valid `initial` overrides the existing stop. Pass it for first setup or an
+intentional external selection change. Omit it on routine renders to preserve
+the user's position. Null, skipped, disabled or non-roving items are ignored,
+using the fallback order above. The
+[complete roving setup](/docs/installation#complete-roving-setup) shows where
+each call belongs.
+
+## followFocus(event, options?)
+
+Updates a roving group's tab stop when focus enters an item, including clicks,
+programmatic focus and
Tab entering a control inside an item. Attach it to
+`focusin` so returning with
Tab reaches the most recently focused item:
+
+```ts
+import { followFocus, keyRove } from '@mixedrays/keyrove';
+
+list.addEventListener('keydown', (e) => keyRove(e));
+list.addEventListener('focusin', (e) => followFocus(e));
+```
+
+- The item is found the way a keypress finds its position: the nearest
+ [root](#roots) above the target, and the item of its group holding focus.
+ Focus on a control inside an item counts as focus on the item.
+- Where that root's own items hold no focus, the group around it is asked,
+ as far as the listener's element. That covers a panel that is a root and
+ also an item of the group around it, and a control of a nested root that
+ sits inside an outer item.
+- The item takes the stop when it carries one and is not skipped, and every
+ other roving item of its group gets `-1`. A nested group's stop is its own
+ and is never touched.
+- Only attributes that change are written. After one of keyRove's own moves,
+ which has already carried the stop, the `focusin` it causes writes nothing.
+
+It returns the item now holding the stop, or `null` when focus is in no item
+that carries one. It takes the same [group settings](#options) as
+[`initRovingTabindex`](#initrovingtabindex-root-options): `items`, `root`,
+`skip` and `rovingTabindex`. See the
+[complete roving setup](/docs/installation#complete-roving-setup) for all
+the helpers wired together.
## toggleTabIndex({ root, isActive })
@@ -446,18 +666,18 @@ import { toggleTabIndex } from '@mixedrays/keyrove';
toggleTabIndex({ root: firstItem, isActive: true });
```
-Use it where you manage the tab stop yourself: establishing the first one in a
-[roving group](/docs/examples/roving-tabindex), restoring it after re-rendering
-a list, or moving it after a click, as the [listbox](/docs/examples/listbox)
-does. Descendant tab stops are left alone; roving tabindex only
-needs the item itself to carry the stop. A nullish `root` is a no-op, so a
+Use it where you manage one element's tab stop yourself. For a whole roving
+group, [`initRovingTabindex`](#initrovingtabindex-root-options) keeps exactly
+one `0` for you, and [`followFocus`](#followfocus-event-options) moves it with
+focus that keyRove did not move. Descendant tab stops are 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 needs no guard.
## Attributes
-Every attribute below has an [option](#options) of the same name, and each
-field falls back from the option to the attribute on its own. What follows is
-the markup half; a group can name any of it in JavaScript instead.
+The table below describes attribute configuration. The [options table](#options)
+maps it to JavaScript; [configuration differences](/docs/attributes-and-options#configuration-differences)
+explains scope and replacement exceptions.
On an item:
@@ -471,32 +691,105 @@ On an item:
On the root, read on every keypress:
-| Attribute | Default | Meaning |
-| ---------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `data-keyrove-root` | — | Marks the root explicitly, instead of the listener's element. |
-| `data-keyrove-cols` | `1` | Column count; above 1 the group navigates as a grid. Values below 1 fall back to the default. |
-| `data-keyrove-page-length` | `10` | Items per page jump; rows, in a grid. Values below 1 fall back to the default. |
-| `data-keyrove-loop` | — | Next and prev wrap past the ends of a list. Grids never wrap. See [looping lists](/docs/examples/looping-lists). |
-| `data-keyrove-orientation` | — | `horizontal` re-points a list's next/prev defaults at `ArrowRight`/`ArrowLeft`, RTL-aware. See [horizontal lists](/docs/examples/horizontal-lists). |
-| `data-keyrove-next-key` | `ArrowDown`; `ArrowRight` in a horizontal list or a grid | Next item; the next cell, in a grid. E.g. `KeyJ` or `ctrl+ArrowRight`. |
-| `data-keyrove-prev-key` | `ArrowUp`; `ArrowLeft` in a horizontal list or a grid | Previous item. |
-| `data-keyrove-next-row-key` | `ArrowDown` | Next row, same column. Grid only. |
-| `data-keyrove-prev-row-key` | `ArrowUp` | Previous row, same column. Grid only. |
-| `data-keyrove-home-key` | `Home`; `ctrl+Home` in a grid | First item; the grid's first cell. |
-| `data-keyrove-end-key` | `End`; `ctrl+End` in a grid | Last item; the grid's last cell. |
-| `data-keyrove-home-row-key` | `Home` | First cell of the focused row. Grid only. |
-| `data-keyrove-end-row-key` | `End` | Last cell of the focused row. Grid only. |
-| `data-keyrove-page-up-key` | `PageUp` | Page jump back. |
-| `data-keyrove-page-down-key` | `PageDown` | Page jump forward. |
+| Attribute | Default | Meaning |
+| ---------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `data-keyrove-root` | — | Marks the root explicitly, instead of the listener's element. |
+| `data-keyrove-cols` | `1` | Column count; above 1 the group navigates as a grid. Values below 1 fall back to the default. `auto` counts the tracks of the root's CSS grid on every keypress; see [responsive grid](/docs/examples/responsive-grid). |
+| `data-keyrove-page-length` | `10` | Items per page jump; rows, in a grid. Values below 1 fall back to the default. |
+| `data-keyrove-loop` | — | Next and prev wrap past the ends of a list. Grids never wrap. See [looping lists](/docs/examples/looping-lists). |
+| `data-keyrove-orientation` | — | `horizontal` re-points a list's next/prev defaults at `ArrowRight`/`ArrowLeft`, RTL-aware. See [horizontal lists](/docs/examples/horizontal-lists). |
+| `data-keyrove-next-key` | `ArrowDown`; `ArrowRight` in a horizontal list or a grid | Next item; the next cell, in a grid. E.g. `KeyJ` or `ctrl+ArrowRight`. |
+| `data-keyrove-prev-key` | `ArrowUp`; `ArrowLeft` in a horizontal list or a grid | Previous item. |
+| `data-keyrove-next-row-key` | `ArrowDown` | Next row, same column. Grid only. |
+| `data-keyrove-prev-row-key` | `ArrowUp` | Previous row, same column. Grid only. |
+| `data-keyrove-home-key` | `Home`; `ctrl+Home` in a grid | First item; the grid's first cell. |
+| `data-keyrove-end-key` | `End`; `ctrl+End` in a grid | Last item; the grid's last cell. |
+| `data-keyrove-home-row-key` | `Home` | First cell of the focused row. Grid only. |
+| `data-keyrove-end-row-key` | `End` | Last cell of the focused row. Grid only. |
+| `data-keyrove-page-up-key` | `PageUp` | Page jump back. |
+| `data-keyrove-page-down-key` | `PageDown` | Page jump forward. |
+| `data-keyrove-exit-key` | — | From inside this nested root to the group around it. See [exit and enter](#exit-and-enter). |
+| `data-keyrove-enter-key` | — | From the focused item into the root nested inside 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 sideways defaults swap under RTL; see
-[horizontal groups and RTL](#horizontal-groups-and-rtl). The boolean
-attributes (`item`, `skip`, `root`, `loop`, `roving-tabindex`) work by
-presence, so a bare attribute is enough.
+Every `*-key` attribute takes `none` as well as a combo, which leaves its move
+with no key and its default key to the browser.
+
+Horizontal default arrows reverse under RTL; see
+[horizontal groups and RTL](#horizontal-groups-and-rtl).
+
+### Attribute builders
+
+`rootAttributes(options?)` and `itemAttributes(options?)` return plain objects
+of `data-keyrove-*` attributes with string values and literal property names
+in their types. Spread them into JSX or Svelte markup, use Vue's `v-bind`, or
+apply them with `setAttribute`; see [framework examples](/docs/installation#react).
+
+```ts
+import { rootAttributes, itemAttributes } from '@mixedrays/keyrove';
+
+rootAttributes({ cols: 3, loop: false, keys: { next: 'KeyJ, ArrowDown' } });
+// {
+// 'data-keyrove-root': 'true',
+// 'data-keyrove-cols': '3',
+// 'data-keyrove-loop': 'false',
+// 'data-keyrove-next-key': 'KeyJ, ArrowDown'
+// }
+
+itemAttributes({ skip: false, rovingTabindex: true, typeahead: 'Inbox' });
+// {
+// 'data-keyrove-item': 'true',
+// 'data-keyrove-skip': 'false',
+// 'data-keyrove-roving-tabindex': 'true',
+// 'data-keyrove-typeahead': 'Inbox'
+// }
+```
+
+Both builders always emit their enabled marker, including when called with
+no arguments. Booleans become `"true"` or `"false"`, numbers become strings,
+and `undefined` fields are omitted. They do not set `tabindex`; give each item
+a tab stop or [initialize roving tabindex](/docs/examples/roving-tabindex#setting-the-initial-tab-stop).
+
+The root input shares its fields and types with `GroupOptions`. Move bindings
+accept the same combos, comma-separated lists and `'none'`. Bindings are open
+[`KeyCombo`](#keycombo) strings, as in `GroupOptions`; the builders do not
+validate their spelling.
+Per-item `skip` and `rovingTabindex` are booleans, unlike group-wide selectors
+and settings. The public input and output types are:
+
+```ts
+type RootAttributeOptions = Pick<
+ GroupOptions,
+ 'cols' | 'loop' | 'orientation' | 'pageLength' | 'keys'
+>;
+
+type ItemAttributeOptions = {
+ skip?: boolean;
+ rovingTabindex?: boolean;
+ focusKey?: KeyCombo;
+ typeahead?: string;
+};
+
+// RootAttributes and ItemAttributes name the returned object types.
+```
+
+`items`, the `root` selector, group-wide `skip` and `rovingTabindex`, and
+`focusKeys` stay options; they have no corresponding root attribute.
+
+For plain DOM code:
+
+```ts
+for (const [name, value] of Object.entries(rootAttributes({ loop: true }))) {
+ list.setAttribute(name, value);
+}
+```
+
+The builders have no DOM dependency and can run during server rendering.
+Unused builders are tree-shaken away with the rest of the package's unused
+exports.
### Constants
@@ -518,12 +811,20 @@ item.tabIndex = 0;
```ts
import type {
GroupOptions,
+ KeyCombo,
KeyRoveCode,
KeyRoveEvent,
+ KeyRoveOptions,
Move,
MoveAction,
MoveResult,
- Options,
+ InitRovingTabindexOptions,
+ ItemAttributeOptions,
+ ItemAttributes,
+ Options, // the earlier name of KeyRoveOptions
+ RootAttributeOptions,
+ RootAttributes,
+ RovingTabindexOptions,
StrideAction,
TypeaheadMove,
TypeaheadOptions,
@@ -533,8 +834,8 @@ import type {
### KeyRoveEvent
-The shape keyrove needs from a keydown event. A native `KeyboardEvent` and
-every framework's synthetic event satisfy it.
+The required event shape. Native keyboard events and compatible framework
+events, including React synthetic events, satisfy it.
```ts
type KeyRoveEvent = {
@@ -547,22 +848,25 @@ type KeyRoveEvent = {
shiftKey?: boolean;
metaKey?: boolean;
isComposing?: boolean;
+ defaultPrevented?: boolean;
key?: string;
};
```
-The modifier flags, `isComposing` and `key` are optional so a hand-built event
-object still qualifies. A missing flag reads as "not held" and a missing
-`isComposing` as "not composing", so an object of your own that bridges events
-must forward them, or every press matches as unmodified. Only
+The modifier flags, `isComposing`, `defaultPrevented` and `key` are optional so
+a hand-built event object still qualifies. A missing flag reads as "not held",
+a missing `isComposing` as "not composing" and a missing `defaultPrevented` as
+not [consumed](#return-value). An object of your own that bridges events must
+forward them, or every press matches as unmodified and unconsumed. Only
[typeahead](#createtypeahead-options) reads `key`; an event without it
navigates but never typeaheads.
### KeyRoveCode
-A `KeyboardEvent.code`. Any string is accepted; the union exists so editors
-complete the codes keyrove binds by default. It does not validate, and it does
-not constrain what you can bind.
+The `code` an event carries: the physical key, such as `KeyJ` or `ArrowDown`.
+Any string is accepted; the union exists so editors complete the codes
+keyrove binds by default. It does not validate. Bindings, which add modifiers
+and lists, use [`KeyCombo`](#keycombo).
```ts
type KeyRoveCode =
@@ -577,6 +881,25 @@ type KeyRoveCode =
| (string & {});
```
+### KeyCombo
+
+A binding string: one [combo](#combos), such as `'KeyJ'` or
+`'ctrl+ArrowDown'`, or a comma-separated list of them, such as
+`'ArrowDown, KeyJ'`. It types the `keys` values, `focusKey` in the attribute
+builders, and the second argument of
+[`matchesCombo`](#matchescombo-event-combo). Where a move is bound, `'none'`
+binds it to no key and returns the default key to the browser.
+
+```ts
+type KeyCombo = KeyRoveCode; // the same open string, in a binding's role
+
+const next: KeyCombo = 'ArrowDown, ctrl+KeyJ';
+const options: KeyRoveOptions = { keys: { next, pageDown: 'none' } };
+```
+
+Like `KeyRoveCode`, it accepts any string, including values read from
+attributes or data, and does not validate the grammar.
+
### MoveAction, MoveResult, Move
```ts
@@ -591,6 +914,8 @@ type MoveAction =
| 'prevRow'
| 'pageUp'
| 'pageDown'
+ | 'exit' // from a nested root to the group around it
+ | 'enter' // from an item into the root nested inside it
| 'focus'; // an element's own data-keyrove-focus-key
// what keyRove returns for a consumed keypress
@@ -602,31 +927,41 @@ type MoveResult = {
// what onMove receives: a move that actually happened
type Move = MoveResult & { to: Element };
+```
+
+### KeyRoveOptions
+
+What [`keyRove`](#options) and [`rove`](#rove-element-action-options) take:
+the group settings and `onMove`. `Options` is its earlier name and remains
+exported as the same type.
-type Options = GroupOptions & { onMove?: (move: Move) => void };
+```ts
+type KeyRoveOptions = GroupOptions & { onMove?: (move: Move) => void };
+
+type Options = KeyRoveOptions;
```
### GroupOptions, StrideAction
-A group's [settings](#options), every one of them optional and every one
-falling back to the attribute it stands for. `StrideAction` is the move a key
-can be bound to — every move but `focus`, whose key sits on its destination.
+All group settings are optional; see [options](#options) for fallbacks.
+`StrideAction` covers movement through the item sequence. It excludes `exit`,
+`enter` and `focus`.
```ts
type GroupOptions = {
items?: string | ((root: Element) => Element[]);
root?: string;
- cols?: number;
+ cols?: number | 'auto';
loop?: boolean;
orientation?: 'horizontal' | 'vertical';
pageLength?: number;
- keys?: Partial
>;
+ keys?: Partial>;
focusKeys?: Record;
skip?: string | ((element: Element) => boolean);
rovingTabindex?: boolean;
};
-type StrideAction = Exclude;
+type StrideAction = Exclude;
```
### TypeaheadOptions, TypeaheadResult, TypeaheadMove
@@ -642,6 +977,7 @@ type TypeaheadOptions = Pick<
label?: (item: Element) => string; // the text an item is matched by
resetMs?: number; // buffer lifetime, default 500
matchMode?: 'prefix' | 'cycle'; // how repeated characters match, default 'prefix'
+ foldDiacritics?: boolean; // ignore accents and other marks, default true
onMove?: (move: TypeaheadMove) => void;
};
@@ -654,3 +990,21 @@ type TypeaheadResult = {
// what onMove receives: a move that actually happened
type TypeaheadMove = TypeaheadResult & { to: Element };
```
+
+### RovingTabindexOptions, InitRovingTabindexOptions
+
+What [`followFocus`](#followfocus-event-options) and
+[`initRovingTabindex`](#initrovingtabindex-root-options) take: the group
+settings that decide which elements are the group's roving items, and for
+`initRovingTabindex`, the item to give the stop.
+
+```ts
+type RovingTabindexOptions = Pick<
+ GroupOptions,
+ 'items' | 'root' | 'skip' | 'rovingTabindex'
+>;
+
+type InitRovingTabindexOptions = RovingTabindexOptions & {
+ initial?: Element | null; // the item to hold the stop, when it can
+};
+```
diff --git a/packages/docs/content/docs/attributes-and-options.md b/packages/docs/content/docs/attributes-and-options.md
index b5bc4b1..88e39e2 100644
--- a/packages/docs/content/docs/attributes-and-options.md
+++ b/packages/docs/content/docs/attributes-and-options.md
@@ -1,15 +1,14 @@
---
title: Attributes and options
-description: The two places a group can be described — data attributes in the markup, or an options object at the call site — how they fall back to each other, and which to reach for.
+description: Configure groups with attributes, JavaScript options, or both, including fallback and replacement rules.
titleTag: Two ways to configure keyboard navigation — keyrove
group: Guide
order: 3
---
-A group's settings — what its items are, which keys move between them, how many
-columns it has — can be written in two places: as `data-keyrove-*` attributes in
-the markup, or as an options object passed to `keyRove`. The same settings, the
-same names, either place.
+Configure a group with `data-keyrove-*` attributes, JavaScript options, or both.
+Options override attributes one setting at a time. Some options have different
+names or scope; see the [differences below](#configuration-differences).
Here is one group both ways. It is the same list, navigated identically:
@@ -27,53 +26,105 @@ menu.addEventListener('keydown', (e) =>
);
```
-Neither is the advanced one, and neither came later in a way that makes the
-other legacy. They answer different questions, and the one to reach for is
-[whichever owns the markup](#which-to-reach-for).
+Choose based on [where you configure the group](#which-to-reach-for).
## They fall back field by field
-Both sources are read on every keypress, one setting at a time, options first.
-A setting the options object does not name falls back to its attribute, and
-then to its default — so neither source has to answer for the other's fields:
+`keyRove` reads the current settings on every keypress. An omitted option
+falls back to its attribute, then to the default:
```ts
-keyRove(e); // every setting from the markup
-keyRove(e, { loop: true }); // items from the markup, looping from here
-keyRove(e, { items: '[role="menuitem"]' }); // nothing from the markup
+keyRove(e); // attributes and defaults
+keyRove(e, { loop: true }); // override looping only
+keyRove(e, { items: '[role="menuitem"]' }); // override item lookup only
```
-The fallback is per _field_, not per call, and it reaches inside `keys` too.
-With `keyRove(e, { keys: { next: 'KeyJ' } })` the next move answers to
-J , while Home ,
-End and the page keys keep whatever the root's attributes
-say — and their defaults where it says nothing.
+`keys` falls back per action. With `{ keys: { next: 'KeyJ' } }`, J moves to
+the next item; every other action keeps its attribute or default binding.
+An empty, blank or comma-only binding also falls back. Use `'none'` to disable
+an action's key.
-Two consequences worth stating plainly:
+### Configuration differences
-- **`keyRove(e)` is unchanged.** A call that passes no options reads exactly the
- markup it always did. Nothing about the attribute API moved when options
- arrived.
-- **Mixing is ordinary, not a fallback.** `{ loop: true }` over a marked-up list
- is a normal thing to write, not a halfway state to be migrated out of.
+| Setting | Attributes | Options |
+| --------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
+| Items | `data-keyrove-item` marks each item. | `items` replaces that lookup with a selector or function that returns the items. |
+| Roots | `data-keyrove-root` marks each root. | `root` replaces that test with a selector for finding the nearest root. It is not a root element or boolean. |
+| Roving tabindex | `data-keyrove-roving-tabindex` enables roving on individual items. | `rovingTabindex` is one boolean for the group; `false` overrides item attributes. |
+| Skipping | `data-keyrove-skip` marks each skipped item. | `skip` replaces that test with a selector or predicate. Disabled items are always excluded from navigation. |
+| Move bindings | Each `data-keyrove-*-key` sets one action's binding. | `keys` overrides bindings per action; omitted or empty entries fall back. |
+| Focus shortcuts | `data-keyrove-focus-key` is scanned under the listener. Skipped and disabled targets are excluded. | `focusKeys` replaces the whole scan, including when it is `{}`. Explicit targets bypass skip checks, but disabled targets remain excluded. |
+| Typeahead label | `data-keyrove-typeahead` sets one item's label. | `label`, a `createTypeahead` option, is a function over every item. An empty result falls back to the attribute, then the item's text. |
-Every field, and the attribute it falls back to, is in the
-[API reference](/docs/api#options).
+For example, these two options have different replacement rules:
+
+```ts
+keyRove(e, { keys: { next: 'KeyJ' } }); // other actions still fall back
+keyRove(e, { focusKeys: { 'ctrl+KeyE': '#editor' } }); // no attribute scan
+```
+
+Roving tabindex also differs in scope. The attribute enrolls one item at a
+time, so mark every item that shares the tab stop. The option applies to the
+whole group:
+
+```html title="Attributes"
+
+```
+
+```ts title="Options"
+//
+// Every item shares the stop, whatever its roving attribute says.
+menu.addEventListener('keydown', (e) =>
+ keyRove(e, { items: 'li', rovingTabindex: true }),
+);
+```
+
+Either way, [set an initial tab stop](/docs/examples/roving-tabindex#setting-the-initial-tab-stop).
+
+The [API reference](/docs/api#options) lists every option and its fallback.
## Which to reach for
-**Markup, where you write the HTML.** The group is described where it is built,
-so a list becomes a grid by gaining an attribute and nothing in your JavaScript
-changes. One delegated listener can serve any number of groups that each
-describe themselves, which is what [nested roots](/docs/examples/nested-roots)
-and [several groups on one listener](/docs/installation#several-groups-one-listener)
-are built on. Every example on this site but one is written this way.
+**Use attributes when you write the HTML.** Keep each group's settings beside
+its items. One delegated listener can serve [several groups](/docs/installation#several-groups-one-listener)
+or [nested roots](/docs/examples/nested-roots).
+
+Use the [attribute builders](/docs/api#attribute-builders) to type-check those
+settings in templates. A root configuration can also be passed directly to
+`keyRove` without renaming its fields:
+
+```tsx
+import { itemAttributes, keyRove, rootAttributes } from '@mixedrays/keyrove';
+import type { RootAttributeOptions } from '@mixedrays/keyrove';
+
+const config = {
+ loop: true,
+ keys: { next: 'KeyJ', prev: 'KeyK' },
+} satisfies RootAttributeOptions;
+
+// Settings in markup; the handler reads them on each keypress.
+
+
+ Inbox
+
+
+ Drafts
+
+ ;
+
+// The same settings can instead be supplied as options: keyRove(e, config).
+```
+
+`rootAttributes` covers columns, looping, orientation, page length and move
+bindings, and always marks the root. `itemAttributes` always marks the item;
+its `skip`, `rovingTabindex`, `focusKey` and `typeahead` fields describe that
+item. The builders emit explicit `"false"` values and omit `undefined` fields.
-**Options, where you do not.** An attribute has to be on the element, which is
-no help when the HTML belongs to a component library, a CMS, or a framework
-component you are not going to fork. `items` takes a selector — or a reading of
-your own — so a group can be named by the roles or classes its markup already
-has:
+**Use options when you cannot change the HTML.** Select items by existing roles
+or classes in a component library or CMS:
```ts
const config = { items: '[role="menuitem"]', loop: true };
@@ -83,9 +134,8 @@ document
.addEventListener('keydown', (e) => keyRove(e, config));
```
-**Options, for settings you compute.** `keyRove` is called fresh for every
-keypress, so an object built at the call site is as live as an attribute is —
-there is no instance holding a stale copy:
+**Use options for computed settings.** Build the options object in the handler
+to supply a current value on each keypress:
```ts
el.addEventListener('keydown', (e) =>
@@ -93,17 +143,14 @@ el.addEventListener('keydown', (e) =>
);
```
-For markup you do own, the attribute is usually the better of the two:
-[responsive grid](/docs/examples/responsive-grid) tracks a column count that CSS
-decides, without an options object at all, by writing the attribute its layout
-implies.
+For a CSS grid, `data-keyrove-cols="auto"` or `{ cols: 'auto' }` reads the
+column count from the layout; see [responsive grid](/docs/examples/responsive-grid).
## One object, both handlers
-`createTypeahead` takes the settings that bear on finding an item — `items`,
-`root`, `skip` and `rovingTabindex` — under the same names and with the same
-fallbacks. So a group described in JavaScript hands the same object to both
-handlers, and they cannot disagree about what an item is:
+`createTypeahead` shares four group options with `keyRove`: `items`, `root`,
+`skip` and `rovingTabindex`. Pass the same configuration to both handlers so
+they use the same item and skip rules:
```ts
const config = { items: '[role="menuitem"]', loop: true };
@@ -112,9 +159,18 @@ const typeahead = createTypeahead(config);
el.addEventListener('keydown', (e) => keyRove(e, config) || typeahead(e));
```
-The settings that are about _moves_ — the keys, the columns, looping — are not
-among its options: a typeahead has one way to reach an item, its label, for
-which it takes a `label` of its own.
+Typeahead uses labels to find items. It has its own `label` option and ignores
+movement settings such as keys, columns and looping. Create the handler once
+per listener; if its options change, create a new handler. Item queries and
+attribute fallbacks still read the current DOM.
+
+[`initRovingTabindex`](/docs/api#initrovingtabindex-root-options) and
+[`followFocus`](/docs/api#followfocus-event-options) share the same four group
+options. Use them to initialize the tab stop and keep it with focus.
+
+[`rove`](/docs/api#rove-element-action-options) accepts the same options as
+`keyRove` and ignores `keys` and `focusKeys`. Pass the same configuration to
+move from code: `rove(el, 'next', config)`.
-[Options in JavaScript](/docs/examples/javascript-options) is the whole of this
-at work, on a menu that carries no keyrove attribute anywhere.
+[Options in JavaScript](/docs/examples/javascript-options) shows a complete
+menu configured this way.
diff --git a/packages/docs/content/docs/examples/basic.md b/packages/docs/content/docs/examples/basic.md
index 055f2f7..3ebcf12 100644
--- a/packages/docs/content/docs/examples/basic.md
+++ b/packages/docs/content/docs/examples/basic.md
@@ -1,63 +1,63 @@
---
title: Basic list
-description: The default behaviour — arrows step one item, Home and End jump to the ends, PageUp and PageDown move in blocks, and Tab still does what Tab does.
+description: Add keyboard navigation to a list while keeping its normal tab order.
titleTag: Arrow key navigation for lists — keyrove
group: Examples
order: 10
---
-A list needs two things: `data-keyrove-item` on every navigable element, and a
-keydown listener on the container. Tab to an item or
-click it, then use ↑ ↓ ,
-Home / End , and
-PageUp / PageDown .
+Add `data-keyrove-item` and `tabindex="0"` to each list item, then attach a
+`keydown` listener to the container.
+
+Tab to an item or click it to try the demo:
+
+- ↑ / ↓ move one item.
+- Home / End jump to the first or last item.
+- PageUp / PageDown move in blocks.
```ts
import { keyRove } from '@mixedrays/keyrove';
-document
- .querySelector('#countries')
- .addEventListener('keydown', (e) => keyRove(e));
+const list = document.querySelector('#countries')!;
+list.addEventListener('keydown', (e) => keyRove(e));
```
-Both routes in work because neither is taken away: `tabindex="0"` makes each
-item a real tab stop, and keyrove adds arrow movement on top.
-Tab still walks the list item by item here; see
-[roving tabindex](/docs/examples/roving-tabindex) to make the whole group one
-stop instead. The arrows are only the default keys: two attributes on the root
-put any key in their place, see [custom keys](/docs/examples/custom-keys).
+Tab still visits every item. Use
+[roving tabindex](/docs/examples/roving-tabindex) to make the list a single tab
+stop, or [custom keys](/docs/examples/custom-keys) to change the bindings.
## Page length
-`data-keyrove-page-length` sets how far PageUp and
-PageDown move. It defaults to `10`; the demo sets `5` so
-the jump is visible in twelve items. A jump past the end lands on the last item
-rather than doing nothing.
+Set `data-keyrove-page-length` on the container to choose how many items
+PageUp and PageDown move. The
+default is `10`; this twelve-item demo uses `5`. Jumps stop at the first or
+last item.
-The ends themselves are where a list stops: next on the last item is claimed
-but moves nothing. See [looping lists](/docs/examples/looping-lists) for
-wrapping round to the other end instead.
+At either end, pressing a key to move farther keeps focus in place and prevents
+the browser's default action. See [looping lists](/docs/examples/looping-lists)
+to wrap to the other end.
## Reacting to movement
-The optional second argument carries the group's
-[settings](/docs/attributes-and-options), where you would rather not write them
-as attributes, and `onMove` — fired _after_ focus has moved, and only when it
-actually moved.
+Pass `onMove` in the second argument to run a callback after focus moves.
+It only fires when focus actually changes. This argument also accepts
+[settings](/docs/attributes-and-options) in place of HTML attributes.
```ts
list.addEventListener('keydown', (e) => {
keyRove(e, {
- onMove: ({ action, from, to }) => console.log(action, '→', to),
+ onMove: ({ action, to }) => console.log(action, '→', to),
});
});
```
-Every demo on this site reports its moves that way. The log beside the list
-above shows the rest of the picture too: its green rows are the `onMove` calls,
-while the amber and grey ones are what `keyRove`
-[returned](/docs/api#return-value) — a key it claimed but could not act on at
-the end of the list, and a key that was never its own, left to the browser.
-Handlers sharing a listener chain on that same return value.
+The demo log shows three outcomes:
+
+- **Green:** focus moved; `onMove` fired.
+- **Amber:** keyrove handled the key, but focus stayed in place.
+- **Grey:** keyrove left the key to the browser.
+
+Amber and grey rows use `keyRove`'s [return value](/docs/api#return-value).
+Other handlers can use it to check whether keyrove handled the key.
diff --git a/packages/docs/content/docs/examples/custom-keys.md b/packages/docs/content/docs/examples/custom-keys.md
index 4b953ce..3eb06d9 100644
--- a/packages/docs/content/docs/examples/custom-keys.md
+++ b/packages/docs/content/docs/examples/custom-keys.md
@@ -1,20 +1,18 @@
---
title: Custom keys
-description: Arrows are the default, not the rule — rebinding any move to any key combo.
+description: Change navigation keys, add modifier combinations, bind several keys to one move, or disable a binding.
titleTag: Custom key bindings for navigation — keyrove
group: Examples
order: 12
---
-Nothing about keyrove is tied to the arrow keys. The keys that move focus are
-attributes on the root, and `ArrowDown` / `ArrowUp` are only what they fall
-back to. `data-keyrove-next-key` and `data-keyrove-prev-key` rebind them to any
-[`KeyboardEvent.code`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code),
-with or without [modifiers](#modifiers).
+Set `data-keyrove-next-key` and `data-keyrove-prev-key` on the root to change
+the navigation keys. Values are
+[`KeyboardEvent.code`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code)
+values, with optional [modifiers](#modifiers).
-A toolbar, for one, navigates left-to-right rather than up-and-down.
-← → move between the buttons here;
-the up and down arrows are left to scroll the page.
+This toolbar uses ← /→ to move between buttons. ↑ /↓ keep their
+browser behavior.
@@ -24,18 +22,12 @@ the up and down arrows are left to scroll the page.
```
-Rebinding is a markup change and nothing else. The `keyRove(e)` call is
-identical whatever the group answers to, and only the bound keys are acted on:
-with
← → bound, the up and down
-arrows go back to scrolling the page, and
Tab was never
-bound in the first place. The log is where to check that: press
-
↓ in the toolbar and it goes down as a key keyrove did
-not handle, left to the browser to scroll with.
+The handler stays `keyRove(e)`. Only bound keys are handled. Press
↓ in the
+toolbar to see an unhandled key in the log; the browser can still scroll.
## Horizontal lists
-For this exact pair there is a shorter spelling that also respects the text
-direction:
+For
← /
→ navigation that follows text direction, use:
```html
…
@@ -73,6 +65,28 @@ of supported keys to choose from:
Each root is read on its own, so two groups on the same page can answer to
different keys with one delegated listener serving both.
+## Several keys for one move
+
+A binding replaces the default, so the vim-style list above gives up the
+arrows:
↓ goes back to scrolling the page. To keep them,
+list both, separated by commas. The move answers to any key in the list:
+
+```html
+
+```
+
+```ts
+keyRove(e, { keys: { next: 'ArrowDown, KeyJ', prev: 'ArrowUp, KeyK' } });
+```
+
+A list is as literal as a single combo, so the arrow you keep is the one you
+name. Nothing flips under RTL, and nothing is added back for you.
+
## Modifiers
Prefix the code with any of `mod+`, `ctrl+`, `alt+`, `shift+`, `meta+`; the
@@ -89,7 +103,7 @@ elsewhere:
```
-Matching is exact in both directions. A bare `KeyJ` means "J with nothing else
+Matching is exact in both directions. A bare `KeyJ` means "
J with nothing else
held", so
Ctrl +
J keeps its
browser default, and a `ctrl+KeyJ` binding never fires on a plain
J . The full grammar is in the
@@ -101,12 +115,10 @@ These are `KeyboardEvent.code` values, physical keys, not `KeyboardEvent.key`
values: `KeyW` rather than `w`. The code does not change with the keyboard
layout, so a binding chosen for QWERTY lands on the same physical key on AZERTY.
-That cuts both ways, and it is the thing to weigh when picking a letter: a
-binding is to a _position_ on the keyboard. `KeyJ` and `KeyK` sit under the
-right hand on QWERTY, which is the whole point of the vim bindings; on Dvorak
-those same positions are `c` and `t`.
+Choose letter bindings with keyboard layout in mind. For example, the physical
+positions `KeyJ` and `KeyK` produce `c` and `t` on Dvorak.
-## Home, End and the page keys
+##
Home ,
End and the page keys
Home ,
End ,
PageUp and
PageDown are defaults
@@ -132,6 +144,35 @@ Whatever they are bound to, these moves act only once focus is inside an item.
Only the directional keys
[enter a group](/docs/api#consumed-and-untouched-keys).
+## Switching a move off
+
+Some groups should not have every move. The
+[APG toolbar](https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/) has no page
+keys, so
PageDown on one of its buttons should scroll
+the page, not jump to the last button. `none` binds a move to no key and
+hands its default back to the browser:
+
+```html
+
+ …
+
+```
+
+```ts
+keyRove(e, {
+ orientation: 'horizontal',
+ keys: { pageUp: 'none', pageDown: 'none' },
+});
+```
+
+Every other move keeps its key. An empty attribute is not the same thing: it
+counts as unset, so the move keeps its default.
+
## Grids
The attributes keep their meaning in a grid, and the row and grid-wide moves get
@@ -173,14 +214,10 @@ listener and outranks the root's bindings.
## Editable elements are exempt
-A key pressed inside a text field, `select` or `contenteditable` region is never
-handled, whatever it is bound to. Arrows and
-
Home /
End keep moving the caret,
-and a `KeyJ` binding does not swallow typing "j" into a field inside an item;
-navigation resumes once focus leaves the field. Which targets count, and the one
-exception for a chorded focus key, are in the
-[API reference](/docs/api#editable-targets);
-[editable targets](/docs/examples/editable-targets) shows the rule at work.
+Movement bindings do not run inside text fields, selects or editable content.
+Those elements keep their editing keys. Modified focus shortcuts are an
+exception; see [editable targets](/docs/examples/editable-targets) and the
+[API rules](/docs/api#editable-targets).
## A binding worth avoiding
diff --git a/packages/docs/content/docs/examples/editable-targets.md b/packages/docs/content/docs/examples/editable-targets.md
index d9b65c6..53528e4 100644
--- a/packages/docs/content/docs/examples/editable-targets.md
+++ b/packages/docs/content/docs/examples/editable-targets.md
@@ -1,24 +1,19 @@
---
title: Editable targets
-description: Fields inside items keep their keys — arrows move the caret, letters type, a slider slides — and navigation resumes the moment focus leaves them.
+description: Keep native editing keys inside fields while navigating the surrounding items.
titleTag: Keyboard navigation with input fields — keyrove
group: Examples
order: 21
---
-Items are not always plain text. A settings row holds a checkbox or a text
-field; a task row has a title to rename in place. keyrove tells the two kinds
-of keypress apart by where the press lands: inside an editable element nothing
-is handled, whatever the key is bound to. The arrows and
-
Home belong to the caret there, and a `KeyJ` binding
-does not swallow a typed "j".
+Movement bindings leave editable fields their native keys. Arrows can move a
+caret or change a value, and letter bindings do not capture typed text. Modified
+focus shortcuts are the [exception](#the-one-exception).
-Arrow down the rows, then
Tab into the control on one.
-In _Display name_ and _Signature_,
↓ moves the caret; on
-_Font size_ it nudges the slider; on a checkbox it moves rows again, because
-there it does nothing natively. The log beside the sheet tells the two apart:
-green while the row has focus and the arrow is a move, grey the moment the
-press lands in a field and keyrove stands down.
+Arrow between the demo's rows, then
Tab into a control. Text fields keep their
+editing behavior, _Font size_ keeps its slider keys, and
↓ on the checkbox
+moves to the next row. The log shows navigation in green and unhandled keys
+in grey.
@@ -28,11 +23,8 @@ document
.addEventListener('keydown', (e) => keyRove(e));
```
-The call is the one every other page makes; the exemption is keyrove's, not
-something the listener arranges. Each row is the item, and an item counts as
-focused while focus is anywhere inside it, so after
Tab
-lands on a control the row is still the position: the moment a key is handled
-again, it counts from there.
+No extra configuration is needed. A row remains the current item while focus
+is inside one of its controls. When navigation resumes, it starts from that row.
## Which elements are editable
@@ -53,32 +45,25 @@ nudges the slider.
## By target, not by key
-The exemption is decided by where the press landed, never by which key it was.
-Whatever a move is bound to, a letter, a chord, a function key, it is left
-alone inside a field: bind next to `ctrl+ArrowRight` and the caret's word jump
-still works in a text field inside an item; bind it to `KeyJ` and "j" still
-types there. Editing is the one context where every key is the user's own.
-`keyRove` returns `null` for all of them, so a handler chained after it sees
-the press too.
+The target determines whether movement keys are handled. In a text field,
+`ctrl+ArrowRight` still moves by word and `KeyJ` still types "j", even when
+those combos are bound to navigation. `keyRove` returns `null`, so any handler
+chained after it also receives the event.
## The one exception
-A [focus key](/docs/examples/focus-keys) whose combo holds
-
Ctrl ,
Alt or
-
Meta fires from inside a field. That press is a command
-rather than typing, and a focus key points _out_ of the field, so it is the way
-to leave one without reaching for the mouse. A bare focus key, or one holding
-only
Shift , stays typing.
-[Typeahead](/docs/examples/typeahead) draws the same line from the other side:
-it never buffers a letter typed into a field.
+A [focus key](/docs/examples/focus-keys) with
Ctrl ,
Alt or
Meta can move focus
+from an editable field. Bare keys and
Shift -only combinations remain available
+for typing. Choose shortcuts carefully: some modifiers also produce text,
+including
AltGr reported as
Ctrl +
Alt .
+
+[Typeahead](/docs/examples/typeahead) never captures typing inside a field.
## While an input method composes
-An input method editor, for Chinese, Japanese or Korean, turns keystrokes into
-candidates before any text lands, and the arrows walk its candidate list. While
-a composition is in progress, `isComposing` on the event, nothing is handled,
-chord or not; every key stays with the input method until the text is
-committed.
+While `isComposing` is true, keyrove leaves every key to the input method,
+including modified focus shortcuts. This lets users navigate candidates and
+finish entering text.
## Rows as tab stops
diff --git a/packages/docs/content/docs/examples/focus-keys.md b/packages/docs/content/docs/examples/focus-keys.md
index d040464..e01d6d3 100644
--- a/packages/docs/content/docs/examples/focus-keys.md
+++ b/packages/docs/content/docs/examples/focus-keys.md
@@ -1,23 +1,16 @@
---
title: Focus keys
-description: Give an element a key of its own, so one press focuses it from anywhere under the listener — another group, a nested root, even a text field.
+description: Assign shortcuts that focus items or panels across groups, including from editable fields.
titleTag: Keyboard shortcuts that focus an element — keyrove
group: Examples
order: 19
---
-Every move so far is relative: next, previous, a row, a page, an end, each
-starts from wherever focus is. A focus key is absolute. Put
-`data-keyrove-focus-key` with a combo on an element, and one press focuses that
-element from anywhere the keydown reaches the listener. Use it for anything a
-user should be able to jump to, not only walk to: the panels of an editor-like
-layout, the tabs of a strip, the tools of a palette.
+Set `data-keyrove-focus-key` on an element to focus it with a shortcut from
+anywhere under the listener.
-
Ctrl +
Shift +
1 ,
-
2 and
3 pick a panel here, each
-panel carrying its own. Click into the text area first and press one anyway:
-the chord still lands, because a press holding
Ctrl is a
-command, not typing.
+
Ctrl +
Shift +
1 ,
2 or
3 focuses a panel in this demo. Try a shortcut from the text
+area: focus keys with
Ctrl ,
Alt or
Meta also work inside editable fields.
@@ -27,19 +20,13 @@ document
.addEventListener('keydown', (e) => keyRove(e));
```
-The panels are laid out the way an editor lays them out, which is the case a
-focus key is for. There is no "down" from a sidebar that spans both rows, and
-nothing an arrow could call next that a reader would predict: a relative move
-needs an order to be relative to, and this layout does not have one. Naming
-the panel is all that is left, so the panels are not items at all, and no arrow
-walks between them.
+The panels are not navigation items, so arrows do not move between them.
+Each panel has its own [combo](/docs/api#combos). A bare code also works:
+`data-keyrove-focus-key="KeyE"` focuses an element with
E outside editable
+fields.
-The call is the one every other page makes. The value is a
-[combo](/docs/api#combos) like any `*-key` attribute's, and a bare code works
-too: `data-keyrove-focus-key="KeyE"` makes
E pick the
-element wherever the letter would not be typing. The move reports `'focus'` to
-`onMove` and in the [return value](/docs/api#return-value), so a consumer can
-tell a jump from a step.
+The move reports `action: 'focus'` to `onMove` and in the
+[return value](/docs/api#return-value).
## As far as the listener hears
@@ -55,10 +42,8 @@ scope control:
## A key and an order
-The panels have no order worth walking, so they are not items. A tool palette
-has both: an order, top to bottom, and a key for every tool that anyone who has
-used a design app already knows. So each tool is an item _and_ carries a focus
-key.
+An element can have both `data-keyrove-item` and a focus key. This palette
+supports arrow navigation and direct shortcuts to tools.
@@ -75,19 +60,14 @@ the tool you last reached, whichever way you reached it, because the
[roving tab stop](/docs/examples/roving-tabindex) follows a jump as it follows
an arrow.
-The keys are the ones design tools use, not the tools' initials: Move is
-
V , Ellipse is
O , and the
-eyedropper is
I . That is the line between a focus key
-and [typeahead](/docs/examples/typeahead): typeahead finds an item by how its
-label is spelled, while a focus key binds whatever key you choose to one
-element. Each tool also carries `aria-keyshortcuts`, and the cap beside its
-name is drawn from that attribute rather than written a second time, so what
-the palette shows is what a screen reader announces; see
-[telling users about it](#telling-users-about-it).
+Focus keys use the bindings you choose:
V for Move,
O for Ellipse,
I for
+Eyedropper. [Typeahead](/docs/examples/typeahead) instead matches labels.
-The key only moves focus. A real palette would pick the tool as well, and that
-is the app's own business: `onMove` reports the jump as `'focus'`, with the
-tool as `to`, which is the place to do it.
+Each tool also has `aria-keyshortcuts`. The demo uses it to display the shortcut
+beside the label; see [telling users about it](#telling-users-about-it).
+
+A focus key only moves focus. To select or activate the tool, add your own
+logic, for example in `onMove` using the destination `to`.
## Item or not
@@ -98,11 +78,12 @@ decides what else reaches it:
arrows reach it too, and the key is a shortcut to a place they already go.
The jump is a move in that group: `from` is the item focus left, or `null`
from outside the group, and a
- [roving tab stop](/docs/examples/roving-tabindex) follows it as it follows an
- arrow.
+ [roving tab stop](/docs/examples/roving-tabindex) moves when focus leaves a
+ roving item. Attach `followFocus` to `focusin` to update the stop when the
+ shortcut enters from outside the group.
- **Any other element**, like the panels, is reached by its key alone. No arrow
- lands on it, and the jump belongs to no group: `from` is `null`, and no tab
- stop moves.
+ lands on it. A jump to it reports `from: null` and moves no group tab stop.
+ If focus is already inside it, `from` is that element and `to` is `null`.
Either way, pressing the key while focus is already inside its element is a
consumed no-op: the key is claimed, and focus stays where it is. The
@@ -127,14 +108,15 @@ under the listener, whichever panel that is in.
```
+The panel's `tabindex="-1"` is what lets it take focus while keeping it out of
+the
Tab order. Without it, or on any element that cannot
+take focus, the key is still claimed, but focus stays where it was and
+`onMove` does not fire.
+
## From inside a text field
-Moves are never handled inside
-[editable targets](/docs/examples/editable-targets), whatever they are bound
-to: arrows and
Home move the caret there, and letters
-type. A focus key
-points _out_ of the field, so it gets the line typeahead draws between a command
-and typing:
+Movement bindings leave [editable targets](/docs/examples/editable-targets)
+their editing keys. Focus shortcuts are the exception:
- A combo holding
Ctrl ,
Alt or
Meta fires from inside a field.
@@ -143,38 +125,32 @@ and typing:
In the demo, `ctrl+shift+Digit1` reaches out of the text area; a bare `Digit1`
would type a "1" there and focus the panel from everywhere else.
-That leaves collisions with the field's own commands and typing to you:
-
Ctrl +
B means bold in a rich-text
-editor,
Alt +letter types accented characters on macOS,
-and on Windows
AltGr is reported as
-
Ctrl +
Alt , so a `ctrl+alt+` chord
-fires while a user types € or @ on many European layouts.
-
Ctrl +
Shift chords, like the
-demo's, tend to be free.
+Choose shortcuts that do not conflict with editing commands or text input.
+For example,
Ctrl +
B can mean bold;
Alt +letter can type accented characters on
+macOS; and Windows can report
AltGr as
Ctrl +
Alt . A `ctrl+alt+` focus shortcut
+can therefore fire during text entry. No focus key runs while `isComposing`
+is true.
## Precedence and ties
-A focus key is the most specific binding there is: it names one element, where a
-root's `*-key` names a whole group and a default names nothing in particular. So
-focus keys sit first in the [binding table](/docs/api#precedence) and win any
-collision. An item bound to `Home` takes
Home and the
-default stands down, just as a root binding would. Had an inner root's rebinding
-of the same combo won instead, the outer item's key would have failed only while
-focus was inside that root, and silently; fixed precedence makes a collision
-show up every time.
-
-Two elements naming the same combo resolve to the first in DOM order; the second
-is unreachable by that key. Elements carrying `data-keyrove-skip` or `disabled`
-are not destinations, so a focus key on one is inert and the key keeps its
-browser default. As with every binding, the wider the listener, the more a bare-letter
-key can shadow; a chord is the safer choice on a `document` listener.
+Focus keys take precedence over explicit movement bindings and defaults.
+For example, an element's `Home` focus key overrides the usual
Home action.
+
+When two elements declare the same combo, the first in DOM order wins.
+The attribute scan excludes skipped and disabled targets. An explicit
+`focusKeys` map replaces that scan and bypasses skip checks, but still excludes
+disabled targets; see
+[configuration differences](/docs/attributes-and-options#configuration-differences).
+
+If a target is excluded, another binding may handle the key. If none matches,
+the browser keeps it. Consider the listener's scope when assigning shortcuts:
+a bare letter on a document listener applies across the page.
## Telling users about it
-keyrove reads the combo; it does not announce it. The
+Add
[`aria-keyshortcuts`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-keyshortcuts)
-attribute exists for that, and it is worth setting alongside the focus key so
-assistive technology can say the shortcut out loud. The two use different
-spellings, ARIA names keys where keyrove names physical codes, so
+to expose a shortcut to assistive technology. keyrove does not set it for you.
+ARIA uses key names, while keyrove uses physical codes:
`data-keyrove-focus-key="ctrl+shift+KeyE"` pairs with
`aria-keyshortcuts="Control+Shift+E"`.
diff --git a/packages/docs/content/docs/examples/grid.md b/packages/docs/content/docs/examples/grid.md
index 1316e1c..fb3be57 100644
--- a/packages/docs/content/docs/examples/grid.md
+++ b/packages/docs/content/docs/examples/grid.md
@@ -1,15 +1,13 @@
---
title: Grid
-description: Declaring a column count folds the items into rows — cell moves and row moves, each on its own rebindable pair.
+description: Set the column count and navigate by cell, row, row ends, grid ends or pages.
titleTag: Arrow key navigation for grids — keyrove
group: Examples
order: 14
---
-Add `data-keyrove-cols` to the root and the same list navigates as a grid:
-
↑ ↓ move a whole row, so focus
-lands on the item directly above or below, and
←
-
→ move one cell.
+Set `data-keyrove-cols` above `1` to navigate a grid.
↑ /
↓ move one row in
+the same column;
← /
→ move one cell in DOM order.
@@ -46,11 +44,8 @@ on screen.
- Rebind any move and the replaced default goes back to its browser behaviour;
see [custom keys](/docs/examples/custom-keys#grids).
-The log under the demo is where the table's last column shows itself. A move
-that runs into an edge is still keyrove's — it comes back claimed, with nowhere
-to go, which is the amber row — and that is why holding
↓
-at the foot of the grid does not fall through to the browser and scroll the page
-instead.
+At an edge, the key is still consumed and the log shows an amber no-op.
+Holding
↓ at the bottom of the grid therefore does not scroll the page.
## Right-to-left grids
diff --git a/packages/docs/content/docs/examples/horizontal-lists.md b/packages/docs/content/docs/examples/horizontal-lists.md
index 5d73b56..05a28e6 100644
--- a/packages/docs/content/docs/examples/horizontal-lists.md
+++ b/packages/docs/content/docs/examples/horizontal-lists.md
@@ -1,17 +1,14 @@
---
title: Horizontal lists
-description: One attribute turns a list sideways — Left and Right become the default keys, and they flip with the reading direction so forward follows the text.
+description: Use Left/Right for a horizontal list, with defaults that follow text direction.
titleTag: Horizontal list keyboard navigation — keyrove
group: Examples
order: 13
---
-Filters along the top of an inbox, a segmented control, a strip of tabs: the
-items sit side by side, and
↑ ↓
-are the wrong keys for them. `data-keyrove-orientation="horizontal"` on the
-root makes
→ the next key and
←
-the previous one. The up and down arrows go back to the browser, which here
-means scrolling the page.
+Set `data-keyrove-orientation="horizontal"` on a list's root to use
+
← /
→ . In left-to-right text,
→ moves to the next item and
← to the
+previous one.
↑ /
↓ keep their browser behavior.
@@ -21,29 +18,19 @@ document
.addEventListener('keydown', (e) => keyRove(e));
```
-Nothing else about the group changes.
Home and
-
End still jump to the ends,
PageUp
-and
PageDown still move a page, and
-
Tab was never bound. The attribute swaps which physical
-arrows are the _defaults_ for next and prev; the moves themselves are defined
-in DOM order, as everywhere. The value is the literal `horizontal`, and a list
-is vertical unless it says so.
+
Home /
End still jump to the ends,
PageUp /
PageDown still move a page, and
Tab
+keeps its default behavior. Moves follow DOM order. Lists are vertical unless
+the orientation is set to the literal value `horizontal`.
## Right to left
-The reason to prefer the attribute over binding `ArrowRight` and `ArrowLeft`
-by hand is the reading direction. Under `dir="rtl"` the DOM order renders right
-to left, so forward is to the left, and the defaults flip with it:
-
← is next and
→ is previous, each
-arrow still moving focus the way it points on screen.
+Under `dir="rtl"`, the defaults reverse:
← is next and
→ is previous.
+This keeps navigation aligned with items laid out in right-to-left order.
-Both bars keep a log, and putting one against the other is the shortest way to
-see the flip:
→ is reported as `next` on the bar above
-and as `prev` on this one. A row names the move keyrove made rather than the
-key that asked for it, and which of the two a key means is what the direction
-decides.
+Compare the logs:
→ reports `next` in the first bar and `prev` in the RTL
+bar. The action names describe movement through DOM order.
The direction comes from the nearest `dir` attribute at or above the root, and
otherwise from the computed style, so a `dir` on `` is enough for every
@@ -64,10 +51,8 @@ key in either direction.
```
-That is the [custom keys](/docs/examples/custom-keys) toolbar, and the
-difference between the two spellings is exactly the right-to-left case. Reach
-for the attribute when the keys are the reading-direction arrows; spell them
-out when they are anything else, or when they must not follow the text.
+Use orientation for arrows that follow text direction. Use explicit bindings
+for fixed keys; see [custom keys](/docs/examples/custom-keys).
## Grids
@@ -80,13 +65,9 @@ the cell arrows flip.
## Wrapping a strip
-A strip of tabs is the horizontal list most pages have, and the
-[APG tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) wraps it:
-