From 2f0580ac22f06ecc10d642fe822128a8a77382a8 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Tue, 22 Sep 2026 08:16:15 +0200 Subject: [PATCH 01/26] fix: never match a combo with no code, and read blank bindings as unset --- packages/docs/content/docs/api.md | 4 ++- .../src/__tests__/bindings/bindings.test.ts | 6 ++++ .../keyRove/keyRove.focusKey.test.ts | 13 ++++++++ .../__tests__/keyRove/keyRove.keys.test.ts | 18 ++++++++++ .../__tests__/keyRove/keyRove.options.test.ts | 33 +++++++++++++++++++ .../keyrove/src/__tests__/utils/utils.test.ts | 13 ++++++++ packages/keyrove/src/bindings.ts | 6 ++-- packages/keyrove/src/config.ts | 13 +++++--- packages/keyrove/src/utils.ts | 6 ++++ 9 files changed, 104 insertions(+), 8 deletions(-) diff --git a/packages/docs/content/docs/api.md b/packages/docs/content/docs/api.md index f854786..546e452 100644 --- a/packages/docs/content/docs/api.md +++ b/packages/docs/content/docs/api.md @@ -92,7 +92,9 @@ Every `*-key` value is a combo, matched by 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 is + unset: an attribute leaves the move its default key, and a `keys` value + leaves it to the attribute. ### Precedence diff --git a/packages/keyrove/src/__tests__/bindings/bindings.test.ts b/packages/keyrove/src/__tests__/bindings/bindings.test.ts index 5a99c40..1c55c4b 100644 --- a/packages/keyrove/src/__tests__/bindings/bindings.test.ts +++ b/packages/keyrove/src/__tests__/bindings/bindings.test.ts @@ -221,6 +221,12 @@ describe('buildBindings', () => { build(), ); }); + + it('drops a focus key with a blank combo', () => { + expect(build({ focus: [{ combo: ' ', target: item('a') }] })).toEqual( + build(), + ); + }); }); describe('rebinding every move', () => { diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts index 9f6aedc..1433c62 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts @@ -273,6 +273,19 @@ describe('keyRove', () => { expect(activeId()).toBe('a'); expect(results).toEqual([null]); }); + + it('ignores a blank attribute, even for a keydown with no code', () => { + const results: RoveResult[] = []; + renderList([createItem('a'), createItem('b', { focusKey: ' ' })], { + onResult: (result) => results.push(result), + }); + byId('a').focus(); + + pressKey(''); + + expect(activeId()).toBe('a'); + expect(results).toEqual([null]); + }); }); describe('focus key reach', () => { diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.keys.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.keys.test.ts index 3a32ab4..1803fb4 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.keys.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.keys.test.ts @@ -124,6 +124,24 @@ describe('keyRove', () => { expect(result).toMatchObject({ action: intent }); }, ); + + it.each([ + ['an empty', ''], + ['a blank', ' '], + ])('treats %s attribute as unset, keeping the default', (_, value) => { + renderList([createItem('a'), createItem('b'), createItem('c')], { + containerAttrs: { [KEYROVE_ATTR_NEXT_KEY]: value }, + }); + document.getElementById('a')!.focus(); + + // Android's virtual keyboards send keydowns with an empty code. + const noCode = pressKey(''); + expect(activeId()).toBe('a'); + expect(noCode.defaultPrevented).toBe(false); + + pressKey('ArrowDown'); + expect(activeId()).toBe('b'); + }); }); describe('custom navigation keys', () => { diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.options.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.options.test.ts index 451737a..c5d83c5 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.options.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.options.test.ts @@ -95,6 +95,26 @@ describe('keyRove', () => { expect(activeId()).toBe('b'); }); + it.each([ + ['an empty', ''], + ['a blank', ' '], + ])( + 'reads %s option value as unset, deferring to the attribute', + (_, value) => { + renderList([createItem('a'), createItem('b')], { + containerAttrs: { [KEYROVE_ATTR_NEXT_KEY]: 'KeyJ' }, + options: { keys: { next: value } }, + }); + document.getElementById('a')!.focus(); + + pressKey('ArrowDown'); + expect(activeId()).toBe('a'); + + pressKey('KeyJ'); + expect(activeId()).toBe('b'); + }, + ); + it('leaves the moves an option does not name to their attributes', () => { renderList([createItem('a'), createItem('b'), createItem('c')], { options: { keys: { next: 'KeyJ' } }, @@ -291,6 +311,19 @@ describe('keyRove', () => { expect(activeId()).toBe('b'); }); + it('ignores a blank combo', () => { + renderMenu(`${menuItems('a', 'b')}
`, { + items: '[role="menuitem"]', + focusKeys: { ' ': '#panel' }, + }); + document.getElementById('a')!.focus(); + + const event = pressKey(''); + + expect(event.defaultPrevented).toBe(false); + expect(activeId()).toBe('a'); + }); + it('ignores a selector matching nothing', () => { renderMenu(menuItems('a', 'b'), { items: '[role="menuitem"]', diff --git a/packages/keyrove/src/__tests__/utils/utils.test.ts b/packages/keyrove/src/__tests__/utils/utils.test.ts index d606830..220f4c4 100644 --- a/packages/keyrove/src/__tests__/utils/utils.test.ts +++ b/packages/keyrove/src/__tests__/utils/utils.test.ts @@ -160,6 +160,19 @@ describe('matchesCombo', () => { ); }); + // Android's virtual keyboards send keydowns with an empty code. + it.each([ + ['empty', '', {}], + ['blank', ' ', {}], + ['a lone "+"', '+', {}], + ['a dangling "ctrl+"', 'ctrl+', { ctrlKey: true }], + ])( + 'never matches a combo with no code, %s, even an event with no code', + (_, combo, modifiers) => { + expect(matchesCombo(keyEvent('', modifiers), combo)).toBe(false); + }, + ); + it('tolerates whitespace around combo parts', () => { expect( matchesCombo( diff --git a/packages/keyrove/src/bindings.ts b/packages/keyrove/src/bindings.ts index 7dba701..b3afba9 100644 --- a/packages/keyrove/src/bindings.ts +++ b/packages/keyrove/src/bindings.ts @@ -95,10 +95,10 @@ export const buildBindings = ({ // An element's own key names one element, where a root's names a group and // a default names nothing in particular: the most specific declaration in // the table, so it sits first — it wins any collision, and two elements - // naming one combo resolve to the first in DOM order. A bare attribute is unset, as it - // is for the root keys. + // naming one combo resolve to the first in DOM order. A bare or blank combo + // is unset, as it is for the root keys. const named: Binding[] = focus - .filter(({ combo }) => combo) + .filter(({ combo }) => combo.trim()) .map(({ combo, target }) => ({ combo, intent: 'focus', diff --git a/packages/keyrove/src/config.ts b/packages/keyrove/src/config.ts index 3fd4f08..424f657 100644 --- a/packages/keyrove/src/config.ts +++ b/packages/keyrove/src/config.ts @@ -110,14 +110,19 @@ const readLayout = ( * naming `next` leaves every other move to its attribute. Every move's * attribute is named after it, so the name is derived rather than listed — * `nextRow` reads `data-keyrove-next-row-key`. + * + * A blank value is unset in either source: an empty or whitespace-only option + * falls through to the attribute, and such an attribute to the default. */ const readExplicitBinding = (root: Element, { keys }: GroupOptions): ExplicitBinding => (intent) => - keys?.[intent] ?? - root.getAttribute( - `data-keyrove-${intent.replace(/[A-Z]/g, '-$&').toLowerCase()}-key`, - ); + keys?.[intent]?.trim() || + root + .getAttribute( + `data-keyrove-${intent.replace(/[A-Z]/g, '-$&').toLowerCase()}-key`, + ) + ?.trim(); /** * The focus keys in reach of a keypress: the `focusKeys` map where one is diff --git a/packages/keyrove/src/utils.ts b/packages/keyrove/src/utils.ts index bb5cadf..dc4bfa6 100644 --- a/packages/keyrove/src/utils.ts +++ b/packages/keyrove/src/utils.ts @@ -44,12 +44,18 @@ const MODIFIER_ALIASES = new Map([ * one must not be, so a bare `"ArrowDown"` means "ArrowDown with no modifiers" * and leaves shortcuts like Ctrl+ArrowDown alone. The code is matched on * `e.code` — the physical key, independent of keyboard layout. + * + * A combo with no code — empty, blank, or ending in a dangling `+` — matches + * nothing, not even an event whose own code is empty, as Android's virtual + * keyboards send. */ export const matchesCombo = (e: KeyRoveEvent, combo: string): boolean => { const parts = combo.split('+'); const code = parts.pop()?.trim(); const declared = { ctrl: false, alt: false, shift: false, meta: false }; + if (!code) return false; + for (const part of parts) { const name = part.trim().toLowerCase(); const modifier = From ebfdb143cae2925ead0b47a077bec8d88a281b61 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Tue, 22 Sep 2026 08:37:01 +0200 Subject: [PATCH 02/26] feat: switch a move off with 'none' value, from its attribute or the keys option --- packages/docs/content/docs/api.md | 22 +- .../docs/content/docs/examples/custom-keys.md | 29 +++ packages/keyrove/README.md | 17 ++ .../src/__tests__/bindings/bindings.test.ts | 68 +++++++ .../__tests__/keyRove/keyRove.keys.test.ts | 190 ++++++++++++++++-- packages/keyrove/src/bindings.ts | 24 ++- packages/keyrove/src/types.ts | 6 +- 7 files changed, 322 insertions(+), 34 deletions(-) diff --git a/packages/docs/content/docs/api.md b/packages/docs/content/docs/api.md index 546e452..23643f7 100644 --- a/packages/docs/content/docs/api.md +++ b/packages/docs/content/docs/api.md @@ -63,8 +63,10 @@ follow the [reading direction](#horizontal-groups-and-rtl)): | `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. +replaced goes back to its browser behaviour, and nothing else changes. Set it to +`none` and the move has no key at all: a toolbar, which has no page moves, takes +`data-keyrove-page-down-key="none"` to leave PageDown to +the page. See [custom keys](/docs/examples/custom-keys) for worked examples. ### Combos @@ -95,6 +97,9 @@ Every `*-key` value is a combo, matched by nothing, not even a keydown with an empty `code`. An empty or blank value 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. It binds the move to no key. + On a [focus key](#focus-keys) it is unset, since an element has no default + key to free. ### Precedence @@ -109,8 +114,10 @@ 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. +its browser behaviour. `none` replaces a default with nothing: the move leaves +the table, and its default key is freed the same way. 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. ### Roots @@ -282,7 +289,7 @@ keyRove(e, { items: '[role="menuitem"]' }); // nothing from the markup | `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. | +| `keys` | the `*-key` attributes | The [combo](#combos) each move answers to: `{ next: 'KeyJ', prev: 'KeyK' }`, or `'none'` for no key. 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. | @@ -495,6 +502,9 @@ 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. +Every `*-key` attribute takes `none` as well as a combo, which leaves its move +with no key and its default key to the browser. + 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 @@ -622,7 +632,7 @@ type GroupOptions = { loop?: boolean; orientation?: 'horizontal' | 'vertical'; pageLength?: number; - keys?: Partial>; + keys?: Partial>; focusKeys?: Record; skip?: string | ((element: Element) => boolean); rovingTabindex?: boolean; diff --git a/packages/docs/content/docs/examples/custom-keys.md b/packages/docs/content/docs/examples/custom-keys.md index 4b953ce..35732ac 100644 --- a/packages/docs/content/docs/examples/custom-keys.md +++ b/packages/docs/content/docs/examples/custom-keys.md @@ -132,6 +132,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 diff --git a/packages/keyrove/README.md b/packages/keyrove/README.md index e50802d..fc6a1c7 100644 --- a/packages/keyrove/README.md +++ b/packages/keyrove/README.md @@ -181,6 +181,21 @@ into one. In a grid, bare `Home`/`End` jump to the ends of the focused row (`data-keyrove-home-row-key`/`data-keyrove-end-row-key`) and `ctrl+Home`/`ctrl+End` to the grid's first and last cell. +A move can also be switched off. `none` binds it to no key and hands its +default back to the browser, so a toolbar, which has no page moves, leaves +PageDown to the page: + +```html +
+ … +
+``` + At the ends of a list the bound keys are consumed but focus stays put. Add `data-keyrove-loop` on the root and next on the last item wraps to the first, and vice versa. Grids keep their edges — they never wrap. @@ -351,6 +366,8 @@ are enabled when bare or set to `"true"`; set one to `"false"` to disable it. The next/prev defaults follow the group's axis: `ArrowDown`/`ArrowUp` in a vertical list, the reading-direction arrows in a horizontal list or a grid. +Every `*-key` attribute and `keys` field also takes `none`, which switches its +move off and frees the default key. Every attribute name is also exported as a constant (`KEYROVE_ATTR_ITEM`, `KEYROVE_ATTR_COLS`, `KEYROVE_ATTR_NEXT_ROW_KEY`, `KEYROVE_ATTR_LOOP`, …). diff --git a/packages/keyrove/src/__tests__/bindings/bindings.test.ts b/packages/keyrove/src/__tests__/bindings/bindings.test.ts index 1c55c4b..31815ea 100644 --- a/packages/keyrove/src/__tests__/bindings/bindings.test.ts +++ b/packages/keyrove/src/__tests__/bindings/bindings.test.ts @@ -283,6 +283,74 @@ describe('buildBindings', () => { }); }); + describe('unbinding with none', () => { + const intents = (bindings: ReturnType) => + bindings.map(({ intent }) => intent); + + it.each([ + ['a list', LIST, ['next', 'prev', 'home', 'end', 'pageUp', 'pageDown']], + [ + 'a grid', + GRID, + [ + 'next', + 'prev', + 'nextRow', + 'prevRow', + 'home', + 'end', + 'homeRow', + 'endRow', + 'pageUp', + 'pageDown', + ], + ], + ] as const)( + 'drops each move of %s from the table, key and all', + (_, layout, moves) => { + const full = build({ layout }); + + for (const intent of moves) { + const bindings = build({ layout, explicit: { [intent]: 'none' } }); + const freed = full.find((binding) => binding.intent === intent)!; + + expect(intents(bindings)).not.toContain(intent); + expect(combos(bindings)).not.toContain(freed.combo); + expect(bindings).toEqual(full.filter((b) => b !== freed)); + } + }, + ); + + it('reads the value trimmed and in any case', () => { + expect( + intents(build({ explicit: { pageDown: ' NONE ', pageUp: 'None' } })), + ).toEqual(['prev', 'next', 'home', 'end']); + }); + + it('leaves the other side of an RTL axis on its flipped default', () => { + const rtl = vi.fn(() => true); + const bindings = buildBindings({ + explicit: lookup({ next: 'none' }), + layout: { ...LIST, horizontal: true }, + rtl, + }); + + expect(rtl).toHaveBeenCalled(); + expect(bindings.find(({ intent }) => intent === 'prev')).toEqual({ + combo: 'ArrowRight', + intent: 'prev', + enters: true, + }); + expect(intents(bindings)).not.toContain('next'); + }); + + it('drops a focus key named none, which has no default to take away', () => { + const target = document.createElement('div'); + + expect(build({ focus: [{ combo: 'none', target }] })).toEqual(build()); + }); + }); + describe('layout', () => { it('ignores grid-only moves on a list', () => { const bindings = build({ diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.keys.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.keys.test.ts index 1803fb4..255ee5b 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.keys.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.keys.test.ts @@ -6,6 +6,7 @@ import { KEYROVE_ATTR_HOME_ROW_KEY, KEYROVE_ATTR_NEXT_KEY, KEYROVE_ATTR_NEXT_ROW_KEY, + KEYROVE_ATTR_ORIENTATION, KEYROVE_ATTR_PAGE_DOWN_KEY, KEYROVE_ATTR_PAGE_LENGTH, KEYROVE_ATTR_PAGE_UP_KEY, @@ -25,6 +26,22 @@ import type { RoveResult } from './testUtils'; afterEach(resetTestState); +// Every stride with the constant that rebinds it. keyRove derives the +// attribute name from the move instead of listing it, so this pins the two +// spellings together; `satisfies` fails here when a move is added without one. +const KEY_ATTRIBUTES = { + next: KEYROVE_ATTR_NEXT_KEY, + prev: KEYROVE_ATTR_PREV_KEY, + nextRow: KEYROVE_ATTR_NEXT_ROW_KEY, + prevRow: KEYROVE_ATTR_PREV_ROW_KEY, + home: KEYROVE_ATTR_HOME_KEY, + end: KEYROVE_ATTR_END_KEY, + homeRow: KEYROVE_ATTR_HOME_ROW_KEY, + endRow: KEYROVE_ATTR_END_ROW_KEY, + pageUp: KEYROVE_ATTR_PAGE_UP_KEY, + pageDown: KEYROVE_ATTR_PAGE_DOWN_KEY, +} satisfies Record; + describe('keyRove', () => { describe('modifier keys', () => { it('leaves modified presses of a bound key alone', () => { @@ -91,23 +108,6 @@ describe('keyRove', () => { }); describe('key attributes', () => { - // Every stride with the constant that rebinds it. keyRove derives the - // attribute name from the move instead of listing it, so this pins the two - // spellings together; `satisfies` fails here when a move is added without - // one. - const KEY_ATTRIBUTES = { - next: KEYROVE_ATTR_NEXT_KEY, - prev: KEYROVE_ATTR_PREV_KEY, - nextRow: KEYROVE_ATTR_NEXT_ROW_KEY, - prevRow: KEYROVE_ATTR_PREV_ROW_KEY, - home: KEYROVE_ATTR_HOME_KEY, - end: KEYROVE_ATTR_END_KEY, - homeRow: KEYROVE_ATTR_HOME_ROW_KEY, - endRow: KEYROVE_ATTR_END_ROW_KEY, - pageUp: KEYROVE_ATTR_PAGE_UP_KEY, - pageDown: KEYROVE_ATTR_PAGE_DOWN_KEY, - } satisfies Record; - it.each(Object.entries(KEY_ATTRIBUTES))( 'binds %s through %s', (intent, attribute) => { @@ -144,6 +144,162 @@ describe('keyRove', () => { }); }); + describe('unbinding with none', () => { + type Press = [code: string, modifiers?: { ctrlKey: boolean }]; + + // The default each stride answers to, from a grid's middle cell and from + // a list's middle item, where every one of them would move focus. + const GRID_DEFAULTS = { + next: ['ArrowRight'], + prev: ['ArrowLeft'], + nextRow: ['ArrowDown'], + prevRow: ['ArrowUp'], + home: ['Home', { ctrlKey: true }], + end: ['End', { ctrlKey: true }], + homeRow: ['Home'], + endRow: ['End'], + pageUp: ['PageUp'], + pageDown: ['PageDown'], + } satisfies Record; + const LIST_DEFAULTS = { + next: ['ArrowDown'], + prev: ['ArrowUp'], + home: ['Home'], + end: ['End'], + pageUp: ['PageUp'], + pageDown: ['PageDown'], + } satisfies Partial>; + + const SOURCES = ['attribute', 'keys option'] as const; + + // `none` for one move, from the attribute or from the `keys` option. + const unbinding = ( + intent: StrideAction, + source: (typeof SOURCES)[number], + ) => + source === 'attribute' + ? { containerAttrs: { [KEY_ATTRIBUTES[intent]]: 'none' } } + : { options: { keys: { [intent]: 'none' } } }; + + const gridCases = SOURCES.flatMap((source) => + Object.entries(GRID_DEFAULTS).map( + ([intent, press]) => [intent, source, press] as const, + ), + ); + const listCases = SOURCES.flatMap((source) => + Object.entries(LIST_DEFAULTS).map( + ([intent, press]) => [intent, source, press] as const, + ), + ); + + it.each(gridCases)( + 'frees a grid move %s from the %s, handing its key back', + (intent, source, [code, modifiers]) => { + const results: RoveResult[] = []; + const { containerAttrs = {}, options } = unbinding( + intent as StrideAction, + source, + ); + renderGrid(9, 3, { + containerAttrs, + options, + onResult: (r) => results.push(r), + }); + document.getElementById('4')!.focus(); + + const event = pressKey(code, undefined, modifiers); + + expect(activeId()).toBe('4'); + expect(results).toEqual([null]); + expect(event.defaultPrevented).toBe(false); + }, + ); + + it.each(listCases)( + 'frees a list move %s from the %s, handing its key back', + (intent, source, [code]) => { + const results: RoveResult[] = []; + const { containerAttrs = {}, options } = unbinding( + intent as StrideAction, + source, + ); + renderList( + ['a', 'b', 'c', 'd', 'e'].map((id) => createItem(id)), + { + containerAttrs: { + [KEYROVE_ATTR_PAGE_LENGTH]: '2', + ...containerAttrs, + }, + options, + onResult: (r) => results.push(r), + }, + ); + document.getElementById('c')!.focus(); + + const event = pressKey(code); + + expect(activeId()).toBe('c'); + expect(results).toEqual([null]); + expect(event.defaultPrevented).toBe(false); + }, + ); + + it('leaves every other move working', () => { + renderList( + ['a', 'b', 'c', 'd', 'e'].map((id) => createItem(id)), + { + containerAttrs: { + [KEYROVE_ATTR_PAGE_LENGTH]: '2', + [KEYROVE_ATTR_PAGE_DOWN_KEY]: 'none', + }, + }, + ); + document.getElementById('e')!.focus(); + + pressKey('PageUp'); + expect(activeId()).toBe('c'); + + pressKey('ArrowDown'); + expect(activeId()).toBe('d'); + }); + + it('leaves the other side of an RTL horizontal list on its flipped arrow', () => { + renderList([createItem('a'), createItem('b'), createItem('c')], { + containerAttrs: { + dir: 'rtl', + [KEYROVE_ATTR_ORIENTATION]: 'horizontal', + [KEYROVE_ATTR_NEXT_KEY]: 'none', + }, + }); + document.getElementById('b')!.focus(); + + const freed = pressKey('ArrowLeft'); + expect(activeId()).toBe('b'); + expect(freed.defaultPrevented).toBe(false); + + pressKey('ArrowRight'); + expect(activeId()).toBe('a'); + }); + + it('lets the keys option unbind a move the attribute binds', () => { + renderList( + ['a', 'b', 'c'].map((id) => createItem(id)), + { + containerAttrs: { [KEYROVE_ATTR_PAGE_DOWN_KEY]: 'KeyN' }, + options: { keys: { pageDown: 'none' } }, + }, + ); + document.getElementById('a')!.focus(); + + const rebound = pressKey('KeyN'); + const freed = pressKey('PageDown'); + + expect(activeId()).toBe('a'); + expect(rebound.defaultPrevented).toBe(false); + expect(freed.defaultPrevented).toBe(false); + }); + }); + describe('custom navigation keys', () => { it('navigates with the configured next/prev keys', () => { renderList([createItem('a'), createItem('b'), createItem('c')], { diff --git a/packages/keyrove/src/bindings.ts b/packages/keyrove/src/bindings.ts index b3afba9..6206db6 100644 --- a/packages/keyrove/src/bindings.ts +++ b/packages/keyrove/src/bindings.ts @@ -29,6 +29,11 @@ type DefaultRow = [ // `next`/`prev` and their row forms. Every other stride moves only within one. const ENTERING = /^(next|prev)/; +// The value that binds a move to no key. It is no `KeyboardEvent.code`, so it +// can never stand for a real key, and it reads as a boolean attribute's value +// does: trimmed, in any case. +const isNone = (combo: string) => combo.trim().toLowerCase() === 'none'; + // The rows between the item and page moves: a grid adds its row moves and // takes bare Home/End for the row ends, leaving ctrl+ for the whole grid's. const GRID_ROWS: DefaultRow[] = [ @@ -68,8 +73,9 @@ const defaultTable = ( * * A replaced default is not re-added — the freed key goes back to its browser * behaviour — and an explicit combo colliding with another move's default wins - * by sitting earlier in the table. A move the layout lacks (a row move on a - * list) is not in its table, so binding it does nothing. + * by sitting earlier in the table. `none` replaces a default with nothing: the + * move leaves the table and its key is freed. A move the layout lacks (a row + * move on a list) is not in its table, so binding it does nothing. */ export const buildBindings = ({ explicit, @@ -77,8 +83,9 @@ export const buildBindings = ({ layout, rtl, }: BuildBindingsArgs): Binding[] => { - // Direction is read only when a default that could flip is in play: an - // unbound side of a horizontal `next`/`prev` axis. + // Direction is read only when a default that could flip is in play: a side + // of a horizontal `next`/`prev` axis with no explicit value. A side set to + // `none` has no key to flip. const flip = layout.horizontal && !(explicit('next') && explicit('prev')) && rtl(); const rebound: Binding[] = []; @@ -88,17 +95,18 @@ export const buildBindings = ({ const combo = explicit(intent); const enters = ENTERING.test(intent); - if (combo) rebound.push({ combo, intent, enters }); - else defaults.push({ combo: fallback, intent, enters }); + if (!combo) defaults.push({ combo: fallback, intent, enters }); + else if (!isNone(combo)) rebound.push({ combo, intent, enters }); } // An element's own key names one element, where a root's names a group and // a default names nothing in particular: the most specific declaration in // the table, so it sits first — it wins any collision, and two elements // naming one combo resolve to the first in DOM order. A bare or blank combo - // is unset, as it is for the root keys. + // is unset, as it is for the root keys, and so is `none`: an element has no + // default key to take away. const named: Binding[] = focus - .filter(({ combo }) => combo.trim()) + .filter(({ combo }) => combo.trim() && !isNone(combo)) .map(({ combo, target }) => ({ combo, intent: 'focus', diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index c9c4cd0..af6a209 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -151,9 +151,9 @@ export type GroupOptions = { /** * The combo each move answers to: `{ next: 'KeyJ', prev: 'KeyK' }`. Read * move by move, so a move left out keeps its attribute and then its default - * key. + * key. `'none'` binds a move to no key, freeing its default. */ - keys?: Partial>; + keys?: Partial>; /** * Elements reachable by a combo of their own: combo → the element, or a * selector resolved within the listener's reach. Replaces the focus-key @@ -266,7 +266,7 @@ export type Binding = /** * Looks up the combo explicitly bound to a move, straight off the root's * `*-key` attribute — nullish where the attribute is unset and the move keeps - * its default key. + * its default key, and `none` where the move is bound to no key. */ export type ExplicitBinding = ( intent: StrideAction, From 1f51379e3f8cecb4f98bcb4e9ccf238c55e39976 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Tue, 22 Sep 2026 08:48:46 +0200 Subject: [PATCH 03/26] feat: accept a comma-separated list of combos per move --- packages/docs/content/docs/api.md | 53 +++++++++------ .../content/docs/attributes-and-options.md | 3 +- .../docs/content/docs/examples/custom-keys.md | 22 +++++++ .../docs/content/docs/examples/listbox.md | 2 +- packages/docs/src/demos.ts | 2 +- packages/keyrove/README.md | 18 +++++- .../src/__tests__/bindings/bindings.test.ts | 6 ++ .../keyRove/keyRove.focusKey.test.ts | 16 +++++ .../__tests__/keyRove/keyRove.keys.test.ts | 64 +++++++++++++++++++ .../__tests__/keyRove/keyRove.options.test.ts | 15 +++++ .../keyrove/src/__tests__/utils/utils.test.ts | 49 ++++++++++++++ packages/keyrove/src/bindings.ts | 5 +- packages/keyrove/src/config.ts | 17 ++--- packages/keyrove/src/types.ts | 13 ++-- packages/keyrove/src/utils.ts | 53 +++++++++------ 15 files changed, 278 insertions(+), 60 deletions(-) diff --git a/packages/docs/content/docs/api.md b/packages/docs/content/docs/api.md index 23643f7..7a19141 100644 --- a/packages/docs/content/docs/api.md +++ b/packages/docs/content/docs/api.md @@ -70,7 +70,7 @@ the page. See [custom keys](/docs/examples/custom-keys) for worked examples. ### 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 @@ -88,18 +88,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, not even a keydown with an empty `code`. An empty or blank value 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. It binds the move to no key. - On a [focus key](#focus-keys) it is unset, since an element has no default - key to free. + 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 @@ -281,18 +289,18 @@ keyRove(e, { loop: true }); // items from the markup, looping from here keyRove(e, { items: '[role="menuitem"]' }); // nothing from the markup ``` -| 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' }`, or `'none'` for no key. 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. | +| 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) or combos each move answers to: `{ next: 'ArrowDown, KeyJ' }`, or `'none'` for no key. Read move by move. | +| `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. | The fallback is per _field_, not per call. With `keyRove(e, { keys: { next: 'KeyJ' } })` the next move answers to @@ -443,7 +451,12 @@ 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; +``` ## toggleTabIndex({ root, isActive }) diff --git a/packages/docs/content/docs/attributes-and-options.md b/packages/docs/content/docs/attributes-and-options.md index b5bc4b1..2cb6970 100644 --- a/packages/docs/content/docs/attributes-and-options.md +++ b/packages/docs/content/docs/attributes-and-options.md @@ -47,7 +47,8 @@ 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. +say — and their defaults where it says nothing. An empty `keys` value falls +through the same way; `'none'` is what switches a move off. Two consequences worth stating plainly: diff --git a/packages/docs/content/docs/examples/custom-keys.md b/packages/docs/content/docs/examples/custom-keys.md index 35732ac..0b25617 100644 --- a/packages/docs/content/docs/examples/custom-keys.md +++ b/packages/docs/content/docs/examples/custom-keys.md @@ -73,6 +73,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 diff --git a/packages/docs/content/docs/examples/listbox.md b/packages/docs/content/docs/examples/listbox.md index 46ed559..31b690b 100644 --- a/packages/docs/content/docs/examples/listbox.md +++ b/packages/docs/content/docs/examples/listbox.md @@ -36,7 +36,7 @@ const select = (option) => { }; const pick = (e) => { - if (!matchesCombo(e, 'Space') && !matchesCombo(e, 'Enter')) return null; + if (!matchesCombo(e, 'Space, Enter')) return null; const option = e.target.closest('[role="option"]'); if (!option) return null; diff --git a/packages/docs/src/demos.ts b/packages/docs/src/demos.ts index 5f8c4a0..1fe925e 100644 --- a/packages/docs/src/demos.ts +++ b/packages/docs/src/demos.ts @@ -337,7 +337,7 @@ const wireSelection = (surface: HTMLElement, log: Log): Handler => { }); return (e) => { - if (!matchesCombo(e, 'Space') && !matchesCombo(e, 'Enter')) return null; + if (!matchesCombo(e, 'Space, Enter')) return null; const option = (e.target as Element).closest(OPTION); if (!option) return null; diff --git a/packages/keyrove/README.md b/packages/keyrove/README.md index fc6a1c7..f22b0b8 100644 --- a/packages/keyrove/README.md +++ b/packages/keyrove/README.md @@ -43,7 +43,8 @@ pnpm add @mixedrays/keyrove - **Configurable key bindings:** every move — next/prev, the grid's row moves, Home/End and the page jumps — takes any `KeyboardEvent.code`, as a `data-keyrove-*-key` attribute or under the `keys` option, with exact - modifier combos and platform-aware `mod`. + modifier combos, platform-aware `mod`, several keys per move + (`ArrowDown, KeyJ`), and `none` to switch a move off. - **Focus keys:** `data-keyrove-focus-key` (or the `focusKeys` option) gives an element a combo of its own — `ctrl+shift+KeyE`, or just `KeyE` — that focuses it from anywhere under the listener: another group, a nested root, even a @@ -141,6 +142,18 @@ Every `data-keyrove-*-key` attribute below is a field of `keys` named after its move — `data-keyrove-next-row-key` is `keys.nextRow` — and `keys` is read move by move, so a move it leaves out keeps its attribute and then its default. +A binding replaces the default. To add a key rather than swap one, list several +combos, comma-separated, and the move answers to any of them: + +```html +
+ … +
+``` + For the toolbar case there is a shorthand that also respects the text direction: `data-keyrove-orientation="horizontal"`, or `orientation: 'horizontal'`, maps the default keys to `ArrowRight`/`ArrowLeft`, @@ -155,7 +168,8 @@ exact — declared modifiers are required, undeclared ones are forbidden — so bare `ArrowDown` binding leaves shortcuts like Ctrl+ArrowDown with their browser defaults. Keys are matched on `e.code`, the physical key, so bindings hold across keyboard layouts. The matcher is exported as -`matchesCombo(e, combo)` for your own handlers. +`matchesCombo(e, combo)` for your own handlers, and takes a list the same way: +`matchesCombo(e, 'Space, Enter')`. ```html
{ build(), ); }); + + it('drops a focus key whose combo lists nothing but commas', () => { + expect(build({ focus: [{ combo: ' , ', target: item('a') }] })).toEqual( + build(), + ); + }); }); describe('rebinding every move', () => { diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts index 1433c62..033f7e3 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts @@ -274,6 +274,22 @@ describe('keyRove', () => { expect(results).toEqual([null]); }); + it('answers to every combo the attribute lists', () => { + renderList([ + createItem('a'), + createItem('b'), + createItem('c', { focusKey: 'F6, ctrl+KeyE' }), + ]); + byId('a').focus(); + + pressKey('F6'); + expect(activeId()).toBe('c'); + + byId('a').focus(); + pressKey('KeyE', undefined, { ctrlKey: true }); + expect(activeId()).toBe('c'); + }); + it('ignores a blank attribute, even for a keydown with no code', () => { const results: RoveResult[] = []; renderList([createItem('a'), createItem('b', { focusKey: ' ' })], { diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.keys.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.keys.test.ts index 255ee5b..8eab525 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.keys.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.keys.test.ts @@ -144,6 +144,70 @@ describe('keyRove', () => { }); }); + describe('several keys per move', () => { + it.each([ + [ + 'attribute', + { containerAttrs: { [KEYROVE_ATTR_NEXT_KEY]: 'ArrowDown, KeyJ' } }, + ], + ['keys option', { options: { keys: { next: 'ArrowDown, KeyJ' } } }], + ])('moves on every key the %s lists', (_, source) => { + renderList( + ['a', 'b', 'c'].map((id) => createItem(id)), + source, + ); + document.getElementById('a')!.focus(); + + pressKey('ArrowDown'); + expect(activeId()).toBe('b'); + + pressKey('KeyJ'); + expect(activeId()).toBe('c'); + }); + + it('frees the default a list leaves out', () => { + renderList([createItem('a'), createItem('b')], { + containerAttrs: { [KEYROVE_ATTR_NEXT_KEY]: 'KeyJ, ctrl+KeyN' }, + }); + document.getElementById('a')!.focus(); + + const freed = pressKey('ArrowDown'); + expect(activeId()).toBe('a'); + expect(freed.defaultPrevented).toBe(false); + + pressKey('KeyN', undefined, { ctrlKey: true }); + expect(activeId()).toBe('b'); + }); + + it('treats a list of nothing but commas as unset, keeping the default', () => { + renderList([createItem('a'), createItem('b'), createItem('c')], { + containerAttrs: { [KEYROVE_ATTR_NEXT_KEY]: ' , ' }, + options: { keys: { prev: ',' } }, + }); + document.getElementById('b')!.focus(); + + pressKey('ArrowDown'); + expect(activeId()).toBe('c'); + + pressKey('ArrowUp'); + expect(activeId()).toBe('b'); + }); + + it('reads none inside a list as an entry naming no key', () => { + renderList([createItem('a'), createItem('b')], { + containerAttrs: { [KEYROVE_ATTR_NEXT_KEY]: 'KeyJ, none' }, + }); + document.getElementById('a')!.focus(); + + const freed = pressKey('ArrowDown'); + expect(activeId()).toBe('a'); + expect(freed.defaultPrevented).toBe(false); + + pressKey('KeyJ'); + expect(activeId()).toBe('b'); + }); + }); + describe('unbinding with none', () => { type Press = [code: string, modifiers?: { ctrlKey: boolean }]; diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.options.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.options.test.ts index c5d83c5..ad4c461 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.options.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.options.test.ts @@ -311,6 +311,21 @@ describe('keyRove', () => { expect(activeId()).toBe('b'); }); + it('answers to every combo a key lists', () => { + renderMenu(`${menuItems('a', 'b')}
`, { + items: '[role="menuitem"]', + focusKeys: { 'F6, ctrl+KeyE': '#panel' }, + }); + + document.getElementById('a')!.focus(); + pressKey('F6'); + expect(activeId()).toBe('panel'); + + document.getElementById('a')!.focus(); + pressKey('KeyE', undefined, { ctrlKey: true }); + expect(activeId()).toBe('panel'); + }); + it('ignores a blank combo', () => { renderMenu(`${menuItems('a', 'b')}
`, { items: '[role="menuitem"]', diff --git a/packages/keyrove/src/__tests__/utils/utils.test.ts b/packages/keyrove/src/__tests__/utils/utils.test.ts index 220f4c4..07803e2 100644 --- a/packages/keyrove/src/__tests__/utils/utils.test.ts +++ b/packages/keyrove/src/__tests__/utils/utils.test.ts @@ -173,6 +173,55 @@ describe('matchesCombo', () => { }, ); + describe('lists', () => { + it('matches any entry of a comma-separated list', () => { + expect(matchesCombo(keyEvent('Space'), 'Space, Enter')).toBe(true); + expect(matchesCombo(keyEvent('Enter'), 'Space, Enter')).toBe(true); + expect(matchesCombo(keyEvent('Escape'), 'Space, Enter')).toBe(false); + }); + + it('matches modifiers exactly, entry by entry', () => { + const combo = 'ctrl+KeyJ, ArrowDown'; + + expect(matchesCombo(keyEvent('KeyJ', { ctrlKey: true }), combo)).toBe( + true, + ); + expect(matchesCombo(keyEvent('ArrowDown'), combo)).toBe(true); + expect(matchesCombo(keyEvent('KeyJ'), combo)).toBe(false); + expect( + matchesCombo(keyEvent('ArrowDown', { ctrlKey: true }), combo), + ).toBe(false); + }); + + it('ignores whitespace around the commas', () => { + expect(matchesCombo(keyEvent('Enter'), ' Space ,Enter ')).toBe(true); + expect( + matchesCombo(keyEvent('KeyE', { ctrlKey: true }), 'F6 , ctrl + KeyE'), + ).toBe(true); + }); + + it.each(['KeyJ,', ', KeyJ', 'KeyJ,,', ' , KeyJ , '])( + 'passes over the empty entries of %j', + (combo) => { + expect(matchesCombo(keyEvent('KeyJ'), combo)).toBe(true); + expect(matchesCombo(keyEvent(''), combo)).toBe(false); + }, + ); + + it('never matches a list of nothing but commas', () => { + expect(matchesCombo(keyEvent(''), ',')).toBe(false); + expect(matchesCombo(keyEvent(''), ' , , ')).toBe(false); + }); + + it('lets the valid entries match beside an invalid one', () => { + expect(matchesCombo(keyEvent('KeyK'), 'hyper+KeyJ, KeyK')).toBe(true); + expect(matchesCombo(keyEvent('KeyK'), 'KeyK, ctrl+')).toBe(true); + expect( + matchesCombo(keyEvent('KeyJ', { ctrlKey: true }), 'hyper+KeyJ, KeyK'), + ).toBe(false); + }); + }); + it('tolerates whitespace around combo parts', () => { expect( matchesCombo( diff --git a/packages/keyrove/src/bindings.ts b/packages/keyrove/src/bindings.ts index 6206db6..1d9e0bb 100644 --- a/packages/keyrove/src/bindings.ts +++ b/packages/keyrove/src/bindings.ts @@ -9,6 +9,7 @@ * precedence of a keypress is one ordered list. */ +import { isComboSet } from './utils.js'; import type { Binding, BuildBindingsArgs, @@ -102,11 +103,11 @@ export const buildBindings = ({ // An element's own key names one element, where a root's names a group and // a default names nothing in particular: the most specific declaration in // the table, so it sits first — it wins any collision, and two elements - // naming one combo resolve to the first in DOM order. A bare or blank combo + // naming one combo resolve to the first in DOM order. A combo naming nothing // is unset, as it is for the root keys, and so is `none`: an element has no // default key to take away. const named: Binding[] = focus - .filter(({ combo }) => combo.trim() && !isNone(combo)) + .filter(({ combo }) => isComboSet(combo) && !isNone(combo)) .map(({ combo, target }) => ({ combo, intent: 'focus', diff --git a/packages/keyrove/src/config.ts b/packages/keyrove/src/config.ts index 424f657..64522ba 100644 --- a/packages/keyrove/src/config.ts +++ b/packages/keyrove/src/config.ts @@ -21,7 +21,7 @@ import { } from './attributes.js'; import { attributeItems, attributeRoving } from './group.js'; import { attributeSkip } from './position.js'; -import { hasEnabledAttribute, parseAttributeInt } from './utils.js'; +import { hasEnabledAttribute, isComboSet, parseAttributeInt } from './utils.js'; import type { ExplicitBinding, FocusKey, @@ -111,18 +111,19 @@ const readLayout = ( * attribute is named after it, so the name is derived rather than listed — * `nextRow` reads `data-keyrove-next-row-key`. * - * A blank value is unset in either source: an empty or whitespace-only option - * falls through to the attribute, and such an attribute to the default. + * A value naming no combo is unset in either source: an empty, blank or + * comma-only option falls through to the attribute, and such an attribute to + * the default. */ const readExplicitBinding = (root: Element, { keys }: GroupOptions): ExplicitBinding => (intent) => - keys?.[intent]?.trim() || - root - .getAttribute( + [ + keys?.[intent], + root.getAttribute( `data-keyrove-${intent.replace(/[A-Z]/g, '-$&').toLowerCase()}-key`, - ) - ?.trim(); + ), + ].find(isComboSet); /** * The focus keys in reach of a keypress: the `focusKeys` map where one is diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index af6a209..da5adeb 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -149,15 +149,16 @@ export type GroupOptions = { /** Rows per page jump — items, in a list. Defaults to 10. */ pageLength?: number; /** - * The combo each move answers to: `{ next: 'KeyJ', prev: 'KeyK' }`. Read - * move by move, so a move left out keeps its attribute and then its default - * key. `'none'` binds a move to no key, freeing its default. + * The combo each move answers to, or a comma-separated list of them: + * `{ next: 'ArrowDown, KeyJ', prev: 'KeyK' }`. Read move by move, so a move + * left out keeps its attribute and then its default key. `'none'` binds a + * move to no key, freeing its default. */ keys?: Partial>; /** - * Elements reachable by a combo of their own: combo → the element, or a - * selector resolved within the listener's reach. Replaces the focus-key - * scan rather than adding to it. + * Elements reachable by a combo of their own: combo, or a comma-separated + * list of them, → the element, or a selector resolved within the listener's + * reach. Replaces the focus-key scan rather than adding to it. */ focusKeys?: Record; /** Which items a move passes over. Defaults to the skip attribute. */ diff --git a/packages/keyrove/src/utils.ts b/packages/keyrove/src/utils.ts index dc4bfa6..309939e 100644 --- a/packages/keyrove/src/utils.ts +++ b/packages/keyrove/src/utils.ts @@ -32,25 +32,11 @@ const MODIFIER_ALIASES = new Map([ ['command', 'meta'], ]); -/** - * Whether the event matches a key combo like `"ctrl+ArrowDown"` or `"KeyJ"`. - * - * Grammar: zero or more of `mod+` / `ctrl+` / `alt+` / `shift+` / `meta+` - * (any order, any case) followed by a `KeyboardEvent.code`. `mod` resolves to - * `meta` on Apple platforms and `ctrl` elsewhere; `control`, `option`, `cmd` - * and `command` are the longer spellings of `ctrl`, `alt` and `meta`. - * - * Matching is exact: every declared modifier must be held and every undeclared - * one must not be, so a bare `"ArrowDown"` means "ArrowDown with no modifiers" - * and leaves shortcuts like Ctrl+ArrowDown alone. The code is matched on - * `e.code` — the physical key, independent of keyboard layout. - * - * A combo with no code — empty, blank, or ending in a dangling `+` — matches - * nothing, not even an event whose own code is empty, as Android's virtual - * keyboards send. - */ -export const matchesCombo = (e: KeyRoveEvent, combo: string): boolean => { - const parts = combo.split('+'); +// One entry of a combo list, matched exactly. An entry with no code — empty, +// blank, or ending in a dangling `+` — matches nothing, not even an event whose +// own code is empty, as Android's virtual keyboards send. +const matchesEntry = (e: KeyRoveEvent, entry: string): boolean => { + const parts = entry.split('+'); const code = parts.pop()?.trim(); const declared = { ctrl: false, alt: false, shift: false, meta: false }; @@ -79,6 +65,35 @@ export const matchesCombo = (e: KeyRoveEvent, combo: string): boolean => { ); }; +/** + * Whether the event matches a key combo like `"ctrl+ArrowDown"` or `"KeyJ"`, + * or any of a comma-separated list of them: `"ArrowDown, KeyJ"`. + * + * Grammar: zero or more of `mod+` / `ctrl+` / `alt+` / `shift+` / `meta+` + * (any order, any case) followed by a `KeyboardEvent.code`. `mod` resolves to + * `meta` on Apple platforms and `ctrl` elsewhere; `control`, `option`, `cmd` + * and `command` are the longer spellings of `ctrl`, `alt` and `meta`. No code + * contains a comma — the comma key is `Comma` — so it is free to separate + * entries. + * + * Matching is exact: every declared modifier must be held and every undeclared + * one must not be, so a bare `"ArrowDown"` means "ArrowDown with no modifiers" + * and leaves shortcuts like Ctrl+ArrowDown alone. The code is matched on + * `e.code` — the physical key, independent of keyboard layout. + * + * Each entry of a list is matched on its own: an empty entry, or one naming an + * unknown modifier, matches nothing and does not stop the others matching. + */ +export const matchesCombo = (e: KeyRoveEvent, combo: string): boolean => + combo.split(',').some((entry) => matchesEntry(e, entry)); + +/** + * Whether a binding value names anything: an entry that is not blank. An + * empty, blank or comma-only value is unset, wherever a binding is read. + */ +export const isComboSet = (value: string | null | undefined): value is string => + !!value && /[^\s,]/.test(value); + /** * Whether Ctrl, Alt or Meta is held: a command rather than typing. Shift on * its own is typing — it is how capitals are entered — so it does not count. From fccaf3ed3d73ede5c8683fbe83270f943e321201 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Tue, 22 Sep 2026 08:54:34 +0200 Subject: [PATCH 04/26] fix: report no move when the target does not take focus --- packages/docs/content/docs/api.md | 14 +- .../docs/content/docs/examples/focus-keys.md | 5 + .../createTypeahead/createTypeahead.test.ts | 28 ++++ .../__tests__/keyRove/keyRove.result.test.ts | 136 +++++++++++++++++- packages/keyrove/src/group.ts | 49 ++++++- 5 files changed, 225 insertions(+), 7 deletions(-) diff --git a/packages/docs/content/docs/api.md b/packages/docs/content/docs/api.md index 7a19141..88c8fcf 100644 --- a/packages/docs/content/docs/api.md +++ b/packages/docs/content/docs/api.md @@ -167,6 +167,11 @@ on where focus is: once focus is inside an item; pressed here, they keep their browser default. - **A group with no items.** Every key keeps its browser default. +Items and focus-key targets have to be able to take focus: natively focusable, +or given a `tabindex`. A target that refuses it, such as a bare `
` or an +element that is `inert` or hidden, is a consumed no-op like an edge. Focus stays +where it was, the roving tab stop stays with it, and `onMove` does not fire. + Keys keyrove is not bound to are never touched. Tab, Shift+Tab, Enter, Space and @@ -266,6 +271,10 @@ bare code such as `KeyE` works wherever the letter would not be typing. so an arrow pressed on the panel after the jump enters the panel's own items. - 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 @@ -319,7 +328,8 @@ the settings it needs. ### 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, { @@ -341,7 +351,7 @@ not an item, and `to` is where focus landed. - `null`: the key was not keyrove's and is untouched, browser default included. - `{ action, from, to }`: the key was consumed. `to` is the newly focused 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: diff --git a/packages/docs/content/docs/examples/focus-keys.md b/packages/docs/content/docs/examples/focus-keys.md index d040464..26faf2f 100644 --- a/packages/docs/content/docs/examples/focus-keys.md +++ b/packages/docs/content/docs/examples/focus-keys.md @@ -127,6 +127,11 @@ 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 diff --git a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts index b276fca..d1f0090 100644 --- a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts +++ b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts @@ -686,4 +686,32 @@ describe('createTypeahead', () => { ]); }); }); + + describe('a match that does not take focus', () => { + it('is a consumed no-op that leaves the roving stop alone', () => { + const onMove = vi.fn(); + const items = [ + createItem('a', 'Drafts', { roving: true }), + createItem('b', 'Sent', { roving: true, tabindex: '-1' }), + ]; + const { results } = renderList(items, { options: { onMove } }); + // Inert or hidden in a browser: focusable by its attributes, and still + // refusing focus. + vi.spyOn(items[1], 'focus').mockImplementation(() => {}); + items[0].focus(); + + const event = pressKey('s'); + + expect(activeId()).toBe('a'); + expect(event.defaultPrevented).toBe(true); + expect(onMove).not.toHaveBeenCalled(); + expect(results).toEqual([ + { action: 'typeahead', from: items[0], to: null }, + ]); + expect(items.map((item) => item.getAttribute('tabindex'))).toEqual([ + '0', + '-1', + ]); + }); + }); }); diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.result.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.result.test.ts index 7037846..bc1daa3 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.result.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.result.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; -import { KEYROVE_ATTR_NEXT_KEY } from '../../keyRove'; +import { + keyRove, + KEYROVE_ATTR_FOCUS_KEY, + KEYROVE_ATTR_NEXT_KEY, + KEYROVE_ATTR_ROVING_TABINDEX, +} from '../../keyRove'; import { activeId, createItem, @@ -172,4 +177,133 @@ describe('keyRove', () => { ]); }); }); + + describe('a target that does not take focus', () => { + // A bare `
` item: no `tabindex`, so `focus()` does nothing. + const bareItem = (id: string) => { + const item = createItem(id); + item.removeAttribute('tabindex'); + + return item; + }; + + const byId = (id: string) => document.getElementById(id)!; + + it('is a consumed no-op: focus stays, onMove stays quiet, to is null', () => { + const onMove = vi.fn(); + const results: RoveResult[] = []; + renderList([createItem('a'), bareItem('b')], { + options: { onMove }, + onResult: (r) => results.push(r), + }); + byId('a').focus(); + + const event = pressKey('ArrowDown'); + + expect(activeId()).toBe('a'); + expect(onMove).not.toHaveBeenCalled(); + expect(results).toEqual([{ action: 'next', from: byId('a'), to: null }]); + expect(event.defaultPrevented).toBe(true); + }); + + it('is the same no-op for a focus key on an element that is not focusable', () => { + const onMove = vi.fn(); + const results: RoveResult[] = []; + const container = renderList([createItem('a')], { + options: { onMove }, + onResult: (r) => results.push(r), + }); + const panel = document.createElement('section'); + panel.setAttribute(KEYROVE_ATTR_FOCUS_KEY, 'KeyP'); + container.appendChild(panel); + byId('a').focus(); + + pressKey('KeyP'); + + expect(activeId()).toBe('a'); + expect(onMove).not.toHaveBeenCalled(); + expect(results).toEqual([{ action: 'focus', from: null, to: null }]); + }); + + it('puts the roving tab stop back where it was', () => { + const items = ['a', 'b', 'c'].map((id) => + createItem(id, { roving: true, tabindex: '-1' }), + ); + items[0].setAttribute('tabindex', '0'); + items[2].removeAttribute('tabindex'); + renderList(items); + // Inert or hidden in a browser: focusable by its attributes, and still + // refusing focus. + vi.spyOn(items[1], 'focus').mockImplementation(() => {}); + vi.spyOn(items[2], 'focus').mockImplementation(() => {}); + byId('a').focus(); + + pressKey('ArrowDown'); + pressKey('End'); + + expect(activeId()).toBe('a'); + expect(items.map((item) => item.getAttribute('tabindex'))).toEqual([ + '0', + '-1', + null, + ]); + }); + + it('still moves to a bare item the roving stop makes focusable', () => { + const onMove = vi.fn(); + const b = bareItem('b'); + b.setAttribute(KEYROVE_ATTR_ROVING_TABINDEX, 'true'); + renderList([createItem('a', { roving: true }), b], { + options: { onMove }, + }); + byId('a').focus(); + + pressKey('ArrowDown'); + + expect(activeId()).toBe('b'); + expect(b.getAttribute('tabindex')).toBe('0'); + expect(onMove).toHaveBeenCalledTimes(1); + }); + + it('counts focus handed on to a control inside the target as a move', () => { + const onMove = vi.fn(); + const b = bareItem('b'); + const button = document.createElement('button'); + b.appendChild(button); + renderList([createItem('a'), b], { options: { onMove } }); + // A composite item that forwards focus to its own control. + b.focus = () => button.focus(); + byId('a').focus(); + + pressKey('ArrowDown'); + + expect(document.activeElement).toBe(button); + expect(onMove).toHaveBeenCalledWith({ + action: 'next', + from: byId('a'), + to: b, + }); + }); + + it('counts focus that lands inside a shadow root', () => { + const onMove = vi.fn(); + const host = document.createElement('div'); + document.body.appendChild(host); + const shadow = host.attachShadow({ mode: 'open' }); + const list = document.createElement('div'); + const [a, b] = [createItem('a'), createItem('b')]; + list.append(a, b); + shadow.appendChild(list); + list.addEventListener('keydown', (e) => keyRove(e, { onMove })); + + pressKey('ArrowDown', list); + + expect(shadow.activeElement).toBe(a); + expect(onMove).toHaveBeenCalledWith({ + action: 'next', + from: null, + to: a, + }); + }); + }); }); diff --git a/packages/keyrove/src/group.ts b/packages/keyrove/src/group.ts index 5cf1e5a..915c364 100644 --- a/packages/keyrove/src/group.ts +++ b/packages/keyrove/src/group.ts @@ -117,6 +117,38 @@ export const readGroup = ( }; }; +/** + * Whether focus landed on `to` or inside it — an item may hand its focus on + * to a control of its own. Asked of `to`'s own tree: inside a shadow root the + * document sees only the host. + */ +const tookFocus = (to: Element): boolean => { + const active = (to.getRootNode() as Partial) + .activeElement; + + return !!active && to.contains(active); +}; + +/** + * Moves the roving tab stop from one item to another, and hands back how to + * put both `tabindex` values back exactly as they were — absent included. + */ +const carryStop = (from: Element, to: Element) => { + const before = [from, to].map( + (element) => [element, element.getAttribute('tabindex')] as const, + ); + + toggleTabIndex({ root: from, isActive: false }); + toggleTabIndex({ root: to, isActive: true }); + + return () => { + for (const [element, value] of before) { + if (value === null) element.removeAttribute('tabindex'); + else element.setAttribute('tabindex', value); + } + }; +}; + /** * Claims the key and lands focus on `to`, reporting the move. * @@ -128,6 +160,9 @@ export const readGroup = ( * Otherwise the roving tab stop follows when `isRoving` accepts the item being * left — by default, when it carries the attribute — `to` is focused, and * `onMove` fires with the move that happened. + * + * A `to` that does not take focus — not focusable, inert, hidden — is the same + * consumed no-op, with the tab stop put back where it was. */ export const moveFocus = ({ e, @@ -141,13 +176,19 @@ export const moveFocus = ({ if (!to || to === from) return { action, from, to: null }; - if (from && isRoving(from)) { - toggleTabIndex({ root: from, isActive: false }); - toggleTabIndex({ root: to, isActive: true }); - } + // The stop moves before focus does: `tabindex="0"` is what makes a bare item + // focusable in the first place. + const putBack = from && isRoving(from) ? carryStop(from, to) : undefined; (to as HTMLElement).focus(); + // `focus()` fails silently, so whether focus moved is read off the tree. + if (!tookFocus(to)) { + putBack?.(); + + return { action, from, to: null }; + } + const move = { action, from, to }; onMove?.(move); From ae9672dec7a4f535fa181cb8902033001839cf68 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Tue, 22 Sep 2026 08:59:44 +0200 Subject: [PATCH 05/26] feat: count a CSS grid's columns with data-keyrove-cols="auto" --- packages/docs/content/_demos/responsive.html | 2 +- packages/docs/content/docs/api.md | 38 ++--- .../content/docs/attributes-and-options.md | 3 +- .../docs/examples/javascript-options.md | 2 +- .../content/docs/examples/responsive-grid.md | 104 +++++++------ packages/docs/src/demos.ts | 22 --- packages/keyrove/README.md | 2 +- .../__tests__/keyRove/keyRove.grid.test.ts | 143 ++++++++++++++++++ packages/keyrove/src/config.ts | 47 +++++- packages/keyrove/src/types.ts | 7 +- 10 files changed, 272 insertions(+), 98 deletions(-) diff --git a/packages/docs/content/_demos/responsive.html b/packages/docs/content/_demos/responsive.html index 4873797..3dd9f8c 100644 --- a/packages/docs/content/_demos/responsive.html +++ b/packages/docs/content/_demos/responsive.html @@ -30,7 +30,7 @@ } -
+
diff --git a/packages/docs/content/docs/api.md b/packages/docs/content/docs/api.md index 88c8fcf..ed17654 100644 --- a/packages/docs/content/docs/api.md +++ b/packages/docs/content/docs/api.md @@ -302,7 +302,7 @@ keyRove(e, { items: '[role="menuitem"]' }); // nothing from the markup | ---------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `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. | +| `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. | @@ -503,23 +503,23 @@ 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. | The boolean attributes — `data-keyrove-item`, `data-keyrove-skip`, `data-keyrove-roving-tabindex`, `data-keyrove-root`, and `data-keyrove-loop` — @@ -651,7 +651,7 @@ can be bound to — every move but `focus`, whose key sits on its destination. type GroupOptions = { items?: string | ((root: Element) => Element[]); root?: string; - cols?: number; + cols?: number | 'auto'; loop?: boolean; orientation?: 'horizontal' | 'vertical'; pageLength?: number; diff --git a/packages/docs/content/docs/attributes-and-options.md b/packages/docs/content/docs/attributes-and-options.md index 2cb6970..52bc95a 100644 --- a/packages/docs/content/docs/attributes-and-options.md +++ b/packages/docs/content/docs/attributes-and-options.md @@ -96,8 +96,7 @@ 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. +decides, without an options object at all, with `data-keyrove-cols="auto"`. ## One object, both handlers diff --git a/packages/docs/content/docs/examples/javascript-options.md b/packages/docs/content/docs/examples/javascript-options.md index 9d8fe33..5a8f6d4 100644 --- a/packages/docs/content/docs/examples/javascript-options.md +++ b/packages/docs/content/docs/examples/javascript-options.md @@ -84,5 +84,5 @@ Reach for options where the markup is not yours, or where a setting is computed keypresses, since the object is read fresh on every one. For markup you do own, the attributes usually read better: [responsive grid](/docs/examples/responsive-grid) tracks a column count without -an options object at all, by writing the attribute its layout implies. +an options object at all, with `data-keyrove-cols="auto"`. [Attributes and options](/docs/attributes-and-options) weighs the two. diff --git a/packages/docs/content/docs/examples/responsive-grid.md b/packages/docs/content/docs/examples/responsive-grid.md index 7de89de..3845fc1 100644 --- a/packages/docs/content/docs/examples/responsive-grid.md +++ b/packages/docs/content/docs/examples/responsive-grid.md @@ -1,6 +1,6 @@ --- title: Responsive grid -description: Let a container query decide the column count and hand it to keyrove before each keypress, so rows fold the way the layout does. +description: Let CSS decide the column count and data-keyrove-cols="auto" count it on each keypress, so rows fold the way the layout does. titleTag: Keyboard navigation for a responsive grid — keyrove group: Examples order: 15 @@ -13,61 +13,45 @@ is on screen right now: it is what keyrove ↓ to the wrong cell. Rather than spelling the breakpoints out a second time in JavaScript, let the -stylesheet own them and read the result back. A +stylesheet own them and have keyrove count the result. `data-keyrove-cols="auto"` +does that: on every keypress it reads the grid's computed +`grid-template-columns`, which lists one size per column however the rule was +written. Here a [container query](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_queries) -sets one custom property, `--cols`, and both the grid's layout and keyrove -follow it. Drag the corner of the panel to narrow it, or narrow the window, then -arrow around: the rows fold as the columns do. The log names the month focus -landed on, so the count is readable without counting cells — six columns across, -↓ takes January to July; two columns across, the same key -takes it to March. +picks the count. Drag the corner of the panel to narrow it, or narrow the +window, then arrow around: the rows fold as the columns do. The log names the +month focus landed on, so the count is readable without counting cells. At six +columns across, ↓ takes January to July; at two, the +same key takes it to March.
```ts import { keyRove } from '@mixedrays/keyrove'; -const months = document.querySelector('#months'); +//
+document + .querySelector('#months') + .addEventListener('keydown', (e) => keyRove(e)); +``` -months.addEventListener('keydown', (e) => { - months.setAttribute( - 'data-keyrove-cols', - getComputedStyle(months).getPropertyValue('--cols'), - ); - keyRove(e); -}); +Or, where the markup is not yours, as an option: + +```ts +keyRove(e, { items: '.month', cols: 'auto' }); ``` The demo's grid carries `data-keyrove-root` only because the site's listener sits on the panel around it. With the listener on the grid itself, as above, the attribute is not needed. -## One line before the call +## Counted on every keypress -`keyRove(e)` is unchanged. The line above it copies `--cols` into -`data-keyrove-cols`, and because keyrove reads the attribute fresh on every -keypress, refreshing it in the same handler is all the synchronisation there is: -no resize listener, no observer to disconnect, nothing that can be stale by the -time it is read. `getComputedStyle` resolves one property on one element once -per keypress, which costs nothing you would notice. - -If something else needs the attribute right _between_ keypresses, a test -asserting on it or another script reading it, a `ResizeObserver` on the panel -makes the same copy whenever the size changes, and once when it starts -observing: - -```ts -const picker = document.querySelector('#month-picker'); - -const syncColumns = () => { - months.setAttribute( - 'data-keyrove-cols', - getComputedStyle(months).getPropertyValue('--cols'), - ); -}; - -new ResizeObserver(syncColumns).observe(picker); -``` +`keyRove(e)` is unchanged, and so is the rule that it reads a group fresh on +every keypress. `auto` is counted at that moment, so there is nothing to +synchronise: no resize listener, no observer to disconnect, nothing that can be +stale by the time it is read. `getComputedStyle` resolves one property on one +element once per keypress, which costs nothing you would notice. ## The stylesheet owns the breakpoints @@ -84,18 +68,40 @@ its own size. ## When the count is implicit -A grid built on `repeat(auto-fill, minmax(8rem, 1fr))` has no `--cols` to read; -the browser decides how many tracks fit. Count them instead: the computed -`grid-template-columns` of a grid container lists one size per column, however -the rule was written. +A grid built on `repeat(auto-fill, minmax(8rem, 1fr))` never states a count; +the browser decides how many tracks fit. `auto` needs nothing more for it: the +computed value lists the tracks the browser made, so they are counted the same +way. + +## What auto counts + +- The root has to be the grid container, and each item one cell of it: a grid + item spanning one track, in DOM order. Spanning items, `subgrid` rows and + wrappers around each row break the fold. With an `items` selector the items + can sit anywhere under the root, and the same holds for them. +- Named lines in the template, such as `[full-start]`, are not columns and are + not counted. +- A root that is not a grid container, such as a flex row or an element with + nothing laid out, counts as one column, and the group navigates as a list. + +## Layouts that are not grids + +A `flex-wrap` row has no track list to count, so `auto` sees one column. Copy +the count in yourself instead, right before the call. Here the stylesheet +publishes it as `--cols`: ```ts months.addEventListener('keydown', (e) => { - const tracks = getComputedStyle(months).gridTemplateColumns.split(' '); - - months.setAttribute('data-keyrove-cols', String(tracks.length)); + months.setAttribute( + 'data-keyrove-cols', + getComputedStyle(months).getPropertyValue('--cols'), + ); keyRove(e); }); ``` -Same listener, same timing; only where the number comes from has changed. +keyrove reads the attribute fresh on every keypress, so refreshing it in the +same handler is all the synchronisation this needs. If something else needs +the attribute between keypresses, such as a test asserting on it, a +`ResizeObserver` on the container can make the same copy whenever the size +changes. diff --git a/packages/docs/src/demos.ts b/packages/docs/src/demos.ts index 1fe925e..d186dac 100644 --- a/packages/docs/src/demos.ts +++ b/packages/docs/src/demos.ts @@ -1,5 +1,4 @@ import { - KEYROVE_ATTR_COLS, KEYROVE_ATTR_FOCUS_KEY, KEYROVE_ATTR_ITEM, KEYROVE_ATTR_ROOT, @@ -599,25 +598,6 @@ const firstItem = (surface: HTMLElement, { items: named }: GroupOptions) => { ); }; -/** - * Columns decided by CSS. - * - * The responsive demo lets a container query choose its column count and - * publishes it as `--cols` on the grid. keyrove reads `data-keyrove-cols`, so - * the attribute is brought level with the property right before each keypress - * — the line the page's own snippet shows — rather than watched for resizes. - * The grid is a descendant of the surface rather than the surface itself - * because the query needs a container above the element it lays out. - */ -const syncColumns = (surface: HTMLElement) => { - const grids = surface.querySelectorAll(`[${KEYROVE_ATTR_COLS}]`); - - for (const grid of grids) { - const cols = getComputedStyle(grid).getPropertyValue('--cols'); - if (cols) grid.setAttribute(KEYROVE_ATTR_COLS, cols); - } -}; - /** Wires every demo on the current page. */ export const mountDemos = () => { const demos = Array.from(document.querySelectorAll('.demo')); @@ -648,8 +628,6 @@ export const mountDemos = () => { // The first handler to claim the key ends the chain, which is the `||` of // the pages' own snippets. surface.addEventListener('keydown', (e) => { - syncColumns(surface); - // The first handler to claim the key ends the chain, which is the `||` // of the pages' own snippets — kept rather than discarded, because what // it answered with is what the history has to report. diff --git a/packages/keyrove/README.md b/packages/keyrove/README.md index f22b0b8..a43591f 100644 --- a/packages/keyrove/README.md +++ b/packages/keyrove/README.md @@ -353,7 +353,7 @@ presses. | `data-keyrove-skip` | `skip` | item | — | Passed over when moving; stays in the DOM order. As an option: a selector or `(element) => boolean`. | | `data-keyrove-roving-tabindex` | `rovingTabindex` | item | — | Moves the `tabindex="0"` tab stop with focus. As an option: one boolean for the whole group. | | `data-keyrove-root` | `root` | root | — | Marks the navigation root explicitly, instead of using the listener's element. As an option: the selector a root answers to. | -| `data-keyrove-cols` | `cols` | root | `1` | Column count; above 1 the group navigates as a grid. | +| `data-keyrove-cols` | `cols` | root | `1` | Column count; above 1 the group navigates as a grid. `auto` counts the root's CSS grid tracks on every keypress. | | `data-keyrove-page-length` | `pageLength` | root | `10` | Items per page jump — whole rows in a grid. | | `data-keyrove-next-key` | `keys.next` | root | axis arrow | Combo for the next item — the next cell, in a grid. E.g. `KeyJ` or `ctrl+ArrowRight`. | | `data-keyrove-prev-key` | `keys.prev` | root | axis arrow | Combo for the previous item. | diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.grid.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.grid.test.ts index bb5de06..3caf063 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.grid.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.grid.test.ts @@ -14,6 +14,7 @@ import { renderList, resetTestState, } from './testUtils'; +import type { RenderOptions } from './testUtils'; afterEach(resetTestState); @@ -240,6 +241,148 @@ describe('keyRove', () => { ); }); + describe('columns counted from CSS (cols="auto")', () => { + // jsdom lays nothing out, so the resolved `grid-template-columns` a + // browser would report is stubbed in. + const resolveColumns = (template: string) => + vi.spyOn(window, 'getComputedStyle').mockImplementation( + () => + ({ + direction: 'ltr', + getPropertyValue: (property: string) => + property === 'grid-template-columns' ? template : '', + }) as CSSStyleDeclaration, + ); + + const renderAuto = (renderOptions: RenderOptions = {}) => + renderList( + Array.from({ length: 9 }, (_, i) => createItem(`${i}`)), + { + ...renderOptions, + containerAttrs: { + [KEYROVE_ATTR_COLS]: 'auto', + ...renderOptions.containerAttrs, + }, + }, + ); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('folds rows by the tracks on screen', () => { + resolveColumns('120px 120px 120px'); + renderAuto(); + document.getElementById('0')!.focus(); + + pressKey('ArrowDown'); + expect(activeId()).toBe('3'); + + pressKey('ArrowRight'); + expect(activeId()).toBe('4'); + }); + + it('does not count named lines as tracks', () => { + resolveColumns('[full-start] 96.5px 96.5px [mid] 96.5px [full-end]'); + renderAuto(); + document.getElementById('0')!.focus(); + + pressKey('ArrowDown'); + + expect(activeId()).toBe('3'); + }); + + it('re-counts on every keypress, as the layout changes', () => { + const style = resolveColumns('100px 100px 100px'); + renderAuto(); + document.getElementById('0')!.focus(); + + pressKey('ArrowDown'); + expect(activeId()).toBe('3'); + + style.mockImplementation( + () => + ({ + direction: 'ltr', + getPropertyValue: () => '100px 100px', + }) as unknown as CSSStyleDeclaration, + ); + pressKey('ArrowDown'); + expect(activeId()).toBe('5'); + }); + + it('reads the attribute in any case', () => { + resolveColumns('100px 100px 100px'); + renderAuto({ containerAttrs: { [KEYROVE_ATTR_COLS]: ' AUTO ' } }); + document.getElementById('0')!.focus(); + + pressKey('ArrowDown'); + + expect(activeId()).toBe('3'); + }); + + it.each([ + ['a root that is no grid', 'none'], + ['a value with nothing laid out', ''], + ['a declared value left unresolved', 'repeat(3, minmax(0, 1fr))'], + ])('makes a list of %s', (_, template) => { + resolveColumns(template); + renderAuto(); + document.getElementById('0')!.focus(); + + pressKey('ArrowDown'); + expect(activeId()).toBe('1'); + + const cellMove = pressKey('ArrowRight'); + expect(cellMove.defaultPrevented).toBe(false); + }); + + it('makes a list where jsdom resolves the grid without layout', () => { + const root = renderAuto(); + root.style.display = 'grid'; + root.style.gridTemplateColumns = 'repeat(3, 1fr)'; + document.getElementById('0')!.focus(); + + pressKey('ArrowDown'); + + expect(activeId()).toBe('1'); + }); + + it('makes a list without getComputedStyle', () => { + vi.stubGlobal('getComputedStyle', undefined); + renderAuto(); + document.getElementById('0')!.focus(); + + pressKey('ArrowDown'); + + expect(activeId()).toBe('1'); + }); + + it("takes 'auto' as an option, over a numeric attribute", () => { + resolveColumns('100px 100px 100px'); + renderAuto({ + containerAttrs: { [KEYROVE_ATTR_COLS]: '2' }, + options: { cols: 'auto' }, + }); + document.getElementById('0')!.focus(); + + pressKey('ArrowDown'); + + expect(activeId()).toBe('3'); + }); + + it("lets a numeric option win over an 'auto' attribute", () => { + // Three tracks on screen; the option's two columns fold the rows. + resolveColumns('100px 100px 100px'); + renderAuto({ options: { cols: 2 } }); + document.getElementById('0')!.focus(); + + pressKey('ArrowDown'); + + expect(activeId()).toBe('2'); + }); + }); + describe('grid key bindings', () => { it('rebinds the cell moves with next-key/prev-key, freeing the arrows', () => { renderGrid(9, 3, { diff --git a/packages/keyrove/src/config.ts b/packages/keyrove/src/config.ts index 64522ba..9473abd 100644 --- a/packages/keyrove/src/config.ts +++ b/packages/keyrove/src/config.ts @@ -66,6 +66,51 @@ const count = (value: number | undefined): number | undefined => { return whole >= 1 ? whole : undefined; }; +// A track in a resolved `grid-template-columns`: a size in pixels. +const TRACK = /^\d*\.?\d+px$/; + +/** + * The columns a grid container lays out: the tracks of its resolved + * `grid-template-columns`, which lists every track as a pixel size however the + * rule was written, `repeat(auto-fill, …)` included. Named lines + * (`[full-start]`) are not tracks. + * + * A value that is not a list of pixel sizes counts nothing — `none` on a root + * that is no grid, or the declared value where nothing is laid out — and + * neither does an environment without `getComputedStyle`: the group is then a + * list. + */ +const countTracks = (root: Element): number => { + if (typeof getComputedStyle === 'undefined') return 1; + + const tracks = getComputedStyle(root) + .getPropertyValue('grid-template-columns') + .replace(/\[[^\]]*\]/g, ' ') + .trim() + .split(/\s+/); + + return tracks.every((track) => TRACK.test(track)) ? tracks.length : 1; +}; + +/** + * The group's column count. `auto`, from either source and in any case in the + * attribute, counts the tracks on screen on every keypress; a number is taken + * as it stands, where it is usable. + */ +const readColumns = (root: Element, cols: GroupOptions['cols']): number => { + if (cols === 'auto') return countTracks(root); + + const option = count(cols); + + if (option) return option; + + if (root.getAttribute(KEYROVE_ATTR_COLS)?.trim().toLowerCase() === 'auto') { + return countTracks(root); + } + + return parseAttributeInt(root, KEYROVE_ATTR_COLS, 1); +}; + /** * Whether an element is a group's root, where a `root` selector names one. * Undefined otherwise, which leaves `resolveRoot` reading the attribute. @@ -85,7 +130,7 @@ const readLayout = ( root: Element, { cols, orientation, loop }: GroupOptions, ): Layout => { - const columns = count(cols) ?? parseAttributeInt(root, KEYROVE_ATTR_COLS, 1); + const columns = readColumns(root, cols); if (columns > 1) { return { kind: 'grid', cols: columns, horizontal: true, loop: false }; diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index da5adeb..2ea6286 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -138,8 +138,11 @@ export type GroupOptions = { * in when nothing above the target matches. */ root?: string; - /** Columns. Above 1 the group is a grid. Defaults to the cols attribute. */ - cols?: number; + /** + * Columns. Above 1 the group is a grid. `'auto'` counts the tracks of the + * root's CSS grid on every keypress. Defaults to the cols attribute. + */ + cols?: number | 'auto'; /** * Whether `next`/`prev` wrap at the ends. Lists only, as for the attribute. */ From cb1ea71688189f7f9060ade916f1f40273516505 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Tue, 22 Sep 2026 09:08:57 +0200 Subject: [PATCH 06/26] feat: ignore accents in typeahead matching --- packages/docs/content/docs/api.md | 18 ++- .../docs/content/docs/examples/typeahead.md | 15 ++ packages/keyrove/README.md | 11 +- .../createTypeahead.options.test.ts | 140 +++++++++++++++++- packages/keyrove/src/createTypeahead.ts | 36 +++-- packages/keyrove/src/types.ts | 6 + 6 files changed, 203 insertions(+), 23 deletions(-) diff --git a/packages/docs/content/docs/api.md b/packages/docs/content/docs/api.md index ed17654..97d2a1a 100644 --- a/packages/docs/content/docs/api.md +++ b/packages/docs/content/docs/api.md @@ -366,7 +366,9 @@ The [listbox](/docs/examples/listbox) chains three handlers this way. 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'; @@ -381,12 +383,13 @@ 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 @@ -677,6 +680,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; }; diff --git a/packages/docs/content/docs/examples/typeahead.md b/packages/docs/content/docs/examples/typeahead.md index b2c295e..e170c4b 100644 --- a/packages/docs/content/docs/examples/typeahead.md +++ b/packages/docs/content/docs/examples/typeahead.md @@ -100,6 +100,21 @@ _Do not disturb_. The attribute is the whole label rather than a prefix added to the text, and an empty one falls back to the text, so a template can set it conditionally. +## Accented labels + +Accents are ignored on both sides: E reaches _Émilie_, +A reaches _Ángel_, and on a keyboard that can type it, +É reaches a plain _Emilie_ too. Each letter is compared +with its marks taken off, whether the label comes from the text, the attribute +or a `label` function, so no label needs folding by hand. A letter that is not +a base letter plus a mark, such as _ø_, _ł_ or _ß_, is matched as itself. + +Where an accent is what tells two items apart, turn the folding off: + +```ts +const typeahead = createTypeahead({ foldDiacritics: false }); +``` + ## What it reports The handler returns `null` when it left the key alone and diff --git a/packages/keyrove/README.md b/packages/keyrove/README.md index a43591f..67f3f0b 100644 --- a/packages/keyrove/README.md +++ b/packages/keyrove/README.md @@ -76,9 +76,9 @@ pnpm add @mixedrays/keyrove - **Editable control awareness:** the caret and value keys stay with inputs, textareas, selects, and `contenteditable` regions — while inputs those keys do nothing on, like a checkbox or a button, keep navigating. -- **Typeahead:** `createTypeahead()` adds case-insensitive type-to-focus, - matching a `label` of your own, `data-keyrove-typeahead`, or the item's own - text — and takes the same options object as `keyRove`. +- **Typeahead:** `createTypeahead()` adds type-to-focus that ignores case and + accents, matching a `label` of your own, `data-keyrove-typeahead`, or the + item's own text — and takes the same options object as `keyRove`. ## Usage @@ -283,12 +283,13 @@ through. `createTypeahead` adds type-to-focus: printable characters accumulate in a buffer (reset after 500 ms of silence), and focus jumps to the first item -whose label starts with what was typed, case-insensitively. +whose label starts with what was typed, ignoring case and accents: `e` +reaches "Émilie". `foldDiacritics: false` keeps accents apart. ```ts import { keyRove, createTypeahead } from '@mixedrays/keyrove'; -const typeahead = createTypeahead(); // { resetMs?, matchMode?, label?, onMove?, … } +const typeahead = createTypeahead(); // { resetMs?, matchMode?, label?, foldDiacritics?, onMove?, … } list.addEventListener('keydown', (e) => keyRove(e) || typeahead(e)); ``` diff --git a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.options.test.ts b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.options.test.ts index a498248..e838457 100644 --- a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.options.test.ts +++ b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.options.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { createTypeahead } from '../../createTypeahead'; -import { keyRove } from '../../keyRove'; +import { keyRove, KEYROVE_ATTR_TYPEAHEAD } from '../../keyRove'; import { activeId, pressKey, resetTestState } from './testUtils'; import type { TypeaheadOptions } from '../../index'; @@ -76,6 +76,142 @@ describe('createTypeahead', () => { }); }); + describe('accents', () => { + // Items with ids, so labels with accents need not become ids themselves. + const people = (...labels: string[]) => + labels + .map( + (label, i) => + ``, + ) + .join(''); + + const renderPeople = ( + labels: string[], + options: Omit = {}, + ) => + renderMenu(people(...labels), { items: '[role="menuitem"]', ...options }); + + it('reaches an accented label from its bare letter', () => { + renderPeople(['Anna', 'Émilie', 'Ángel']); + document.getElementById('p0')!.focus(); + + pressKey('e'); + expect(activeId()).toBe('p1'); + }); + + it('reaches a bare label from an accented letter, in either case', () => { + renderPeople(['Anna', 'emilie']); + document.getElementById('p0')!.focus(); + + pressKey('É'); + + expect(activeId()).toBe('p1'); + }); + + it('folds a whole prefix, not only its first letter', () => { + const now = vi.spyOn(Date, 'now'); + renderPeople(['Emma', 'Émilie']); + document.getElementById('p0')!.focus(); + + now.mockReturnValue(1000); + pressKey('e'); + now.mockReturnValue(1100); + pressKey('m'); + now.mockReturnValue(1200); + pressKey('í'); + + expect(activeId()).toBe('p1'); + }); + + it('matches a label written with a combining mark', () => { + // "E" followed by U+0301, as some sources store it. + renderPeople(['Anna', 'E\u0301milie']); + document.getElementById('p0')!.focus(); + + pressKey('é'); + + expect(activeId()).toBe('p1'); + }); + + it('folds what the label option returns', () => { + renderPeople(['Anna', 'Nobody'], { + label: (item) => (item.id === 'p1' ? 'Ōsaka' : ''), + }); + document.getElementById('p0')!.focus(); + + pressKey('o'); + + expect(activeId()).toBe('p1'); + }); + + it('folds the typeahead attribute', () => { + const container = renderPeople(['Anna', 'Nobody']); + container + .querySelector('#p1')! + .setAttribute(KEYROVE_ATTR_TYPEAHEAD, 'Çelik'); + document.getElementById('p0')!.focus(); + + pressKey('c'); + + expect(activeId()).toBe('p1'); + }); + + it('cycles on an accented letter and its bare form alike', () => { + const now = vi.spyOn(Date, 'now'); + renderPeople(['Anna', 'Émilie', 'Eva', 'Elodie'], { matchMode: 'cycle' }); + document.getElementById('p0')!.focus(); + + now.mockReturnValue(1000); + pressKey('é'); + expect(activeId()).toBe('p1'); + + now.mockReturnValue(1100); + pressKey('e'); + expect(activeId()).toBe('p2'); + + now.mockReturnValue(1200); + pressKey('é'); + expect(activeId()).toBe('p3'); + }); + + it('leaves a letter with no decomposition as it is', () => { + renderPeople(['Anna', 'Øystein', 'Olga']); + document.getElementById('p0')!.focus(); + + pressKey('o'); + + expect(activeId()).toBe('p2'); + }); + + it('leaves a lone combining mark to the page', () => { + renderPeople(['Anna', 'Émilie']); + document.getElementById('p0')!.focus(); + + const mark = pressKey('\u0301'); + expect(mark.defaultPrevented).toBe(false); + expect(activeId()).toBe('p0'); + + // Nothing joined the buffer, so the next letter starts a fresh prefix. + pressKey('e'); + expect(activeId()).toBe('p1'); + }); + + it('keeps accents apart with foldDiacritics: false, still ignoring case', () => { + const now = vi.spyOn(Date, 'now'); + renderPeople(['Anna', 'Émilie', 'emma'], { foldDiacritics: false }); + document.getElementById('p0')!.focus(); + + now.mockReturnValue(1000); + pressKey('e'); + expect(activeId()).toBe('p2'); + + now.mockReturnValue(2000); + pressKey('é'); + expect(activeId()).toBe('p1'); + }); + }); + describe('skip named in options', () => { it('passes over the items its test matches', () => { renderMenu( diff --git a/packages/keyrove/src/createTypeahead.ts b/packages/keyrove/src/createTypeahead.ts index e266521..ffe8963 100644 --- a/packages/keyrove/src/createTypeahead.ts +++ b/packages/keyrove/src/createTypeahead.ts @@ -30,18 +30,27 @@ const getLabel = (item: Element, label?: (item: Element) => string) => item.textContent?.replace(/\s+/g, ' ').trim() || ''; +// What typed text and labels are compared as. Case always folds; with +// `foldDiacritics`, so do combining marks — decomposed (NFD) and dropped — so +// "e" reaches "Émilie" and "É" reaches "emilie". A letter with no +// decomposition, like ø, ł or ß, stays itself. Lower-cased first, since +// lower-casing can itself add a mark: "İ" becomes "i" plus a dot above. +const lowerCase = (text: string) => text.toLowerCase(); +const foldMarks = (text: string) => + text.toLowerCase().normalize('NFD').replace(/\p{M}/gu, ''); + /** * Creates a keydown handler that focuses items as their labels are typed. * * Printable characters accumulate in a buffer (reset after `resetMs` of * silence), and focus moves to the first navigable item whose label — the * `label` option, falling back to the `data-keyrove-typeahead` attribute and - * then to trimmed `textContent` — starts with it, case-insensitively. Typing - * inside editable elements is never captured, and modified presses - * (Ctrl/Alt/Meta) are left to their shortcuts. In `cycle` mode a single - * character moves to the next match after the focused item instead, wrapping, - * and repeating it cycles through those matches rather than growing the - * buffer. + * then to trimmed `textContent` — starts with it, ignoring case and, by + * default, accents and other combining marks. Typing inside editable elements + * is never captured, and modified presses (Ctrl/Alt/Meta) are left to their + * shortcuts. In `cycle` mode a single character moves to the next match after + * the focused item instead, wrapping, and repeating it cycles through those + * matches rather than growing the buffer. * * Which elements are items, which of them are passed over, what scopes a group * and whether it carries one tab stop are settings of the group rather than of @@ -51,6 +60,8 @@ const getLabel = (item: Element, label?: (item: Element) => string) => * @param options.resetMs - Buffer lifetime between keystrokes. Default 500. * @param options.matchMode - Whether repeated characters extend the prefix * (`'prefix'`) or cycle through its matches (`'cycle'`). Default `'prefix'`. + * @param options.foldDiacritics - Whether accents and other combining marks + * are ignored on both sides, so "e" matches "Émilie". Default `true`. * @param options.onMove - Fired after focus moved — only when it actually did. * @returns A handler with the `keyRove` contract: `null` when the key was * left untouched; `{ action: 'typeahead', from, to }` when it was consumed, @@ -61,9 +72,12 @@ export const createTypeahead = ({ label, resetMs = 500, matchMode = 'prefix', + foldDiacritics = true, onMove, ...group }: TypeaheadOptions = {}) => { + const fold = foldDiacritics ? foldMarks : lowerCase; + // The group's settings cannot change for the life of the handler, so they // are resolved once here rather than on every keystroke. const isRoot = rootTest(group); @@ -104,8 +118,13 @@ export const createTypeahead = ({ // buttons. Mid-buffer it types on, so multi-word labels stay reachable. if (e.key === ' ' && !buffer) return null; + // Folded, a lone combining mark is nothing at all, and an empty buffer + // would match every label, so the key is left to the page. + const character = fold(e.key); + + if (!character) return null; + lastPressTime = now; - const character = e.key.toLowerCase(); // Cycling keeps a repeated character a one-character prefix instead of // growing the buffer, so "s", "s" goes on naming the S items. @@ -123,8 +142,7 @@ export const createTypeahead = ({ : 0; const target = [...items.slice(start), ...items.slice(0, start)].find( (item) => - !isSkipped(item) && - getLabel(item, label).toLowerCase().startsWith(buffer), + !isSkipped(item) && fold(getLabel(item, label)).startsWith(buffer), ); // No match leaves the key untouched — the character still joined the diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index 2ea6286..d5d5206 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -221,6 +221,12 @@ export type TypeaheadOptions = Pick< * starting with it, wrapping, so repeats cycle. Defaults to `'prefix'`. */ matchMode?: 'prefix' | 'cycle'; + /** + * Whether accents and other combining marks are ignored on both sides of + * the match, so "e" reaches "Émilie" and "É" reaches "emilie". Turn it off + * where an accent tells two items apart. Defaults to `true`. + */ + foldDiacritics?: boolean; /** Fired after focus has moved — and only when it actually moved. */ onMove?: (move: TypeaheadMove) => void; }; From ab5f15371733ec0c5f55b0fd4907962ba6c5da64 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Tue, 22 Sep 2026 09:25:39 +0200 Subject: [PATCH 07/26] feat: add initRovingTabindex to give a roving group one tab stop --- packages/docs/content/docs/api.md | 86 ++++- .../content/docs/attributes-and-options.md | 4 + .../content/docs/examples/roving-tabindex.md | 25 +- packages/keyrove/README.md | 13 +- .../initRovingTabindex.test.ts | 302 ++++++++++++++++++ packages/keyrove/src/group.ts | 28 ++ packages/keyrove/src/index.ts | 2 + packages/keyrove/src/initRovingTabindex.ts | 67 ++++ packages/keyrove/src/types.ts | 10 + 9 files changed, 510 insertions(+), 27 deletions(-) create mode 100644 packages/keyrove/src/__tests__/initRovingTabindex/initRovingTabindex.test.ts create mode 100644 packages/keyrove/src/initRovingTabindex.ts diff --git a/packages/docs/content/docs/api.md b/packages/docs/content/docs/api.md index 97d2a1a..e4eede8 100644 --- a/packages/docs/content/docs/api.md +++ b/packages/docs/content/docs/api.md @@ -5,16 +5,17 @@ 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 | 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. | +| [`initRovingTabindex(root, options?)`](#initrovingtabindex-root-options) | Gives a roving group exactly one tab stop, and keeps it whole across renders. | +| [`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. | ## keyRove(event, options?) @@ -471,6 +472,48 @@ of its combos, which saves writing the any-of by hand: if (!matchesCombo(e, 'Space, Enter')) return null; ``` +## initRovingTabindex(root, options?) + +Gives a [roving group](/docs/examples/roving-tabindex) exactly one tab stop. +keyrove moves an existing stop and never creates one, so call this once the +group renders, and again after any render that may have replaced its items. + +```ts +import { initRovingTabindex } from '@mixedrays/keyrove'; + +initRovingTabindex(list); +``` + +It repairs the group rather than resetting it: + +- The group's roving items are its own items that carry the stop: + `data-keyrove-item` with `data-keyrove-roving-tabindex`, or whatever the + options name. Items of a [nested root](#roots) belong to that root's group + and are left alone; call it on that root to set up its stop. +- A navigable item, neither skipped nor disabled, that already has + `tabindex="0"` keeps the stop, the first in DOM order where there are + several. So the stop keyboard moves carried, or the one a template put on the + selected option, survives the call. +- Failing that, the first navigable item gets the stop. +- Every other roving item gets `-1`, skipped and disabled ones included. Only + attributes that change are written, so a call after a render that changed + nothing changes nothing. + +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 takes the [group settings](#options) that decide what an item is — `items`, +`root`, `skip` and `rovingTabindex` — under the same names and with the same +fallbacks as `keyRove` and `createTypeahead`, so a group described in +JavaScript hands every export the same object: + +```ts +const config = { items: '[role="menuitem"]', rovingTabindex: true }; + +initRovingTabindex(menu, config); +menu.addEventListener('keydown', (e) => keyRove(e, config)); +``` + ## toggleTabIndex({ root, isActive }) Sets `tabindex` to `0` or `-1` on a single element. @@ -481,11 +524,11 @@ 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, such as moving the +stop after a click, as the [listbox](/docs/examples/listbox) does. For a whole +roving group, [`initRovingTabindex`](#initrovingtabindex-root-options) keeps +exactly one `0` for you. 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 @@ -562,6 +605,7 @@ import type { MoveAction, MoveResult, Options, + RovingTabindexOptions, StrideAction, TypeaheadMove, TypeaheadOptions, @@ -693,3 +737,15 @@ type TypeaheadResult = { // what onMove receives: a move that actually happened type TypeaheadMove = TypeaheadResult & { to: Element }; ``` + +### RovingTabindexOptions + +What [`initRovingTabindex`](#initrovingtabindex-root-options) takes: the group +settings that decide which elements are the group's roving items. + +```ts +type RovingTabindexOptions = Pick< + GroupOptions, + 'items' | 'root' | 'skip' | 'rovingTabindex' +>; +``` diff --git a/packages/docs/content/docs/attributes-and-options.md b/packages/docs/content/docs/attributes-and-options.md index 52bc95a..b91543b 100644 --- a/packages/docs/content/docs/attributes-and-options.md +++ b/packages/docs/content/docs/attributes-and-options.md @@ -116,5 +116,9 @@ The settings that are about _moves_ — the keys, the columns, looping — are n among its options: a typeahead has one way to reach an item, its label, for which it takes a `label` of its own. +[`initRovingTabindex`](/docs/api#initrovingtabindex-root-options) takes the +same four settings, so the object that describes a roving group also places +its tab stop. + [Options in JavaScript](/docs/examples/javascript-options) is the whole of this at work, on a menu that carries no keyrove attribute anywhere. diff --git a/packages/docs/content/docs/examples/roving-tabindex.md b/packages/docs/content/docs/examples/roving-tabindex.md index 6eb0c3a..a8072d8 100644 --- a/packages/docs/content/docs/examples/roving-tabindex.md +++ b/packages/docs/content/docs/examples/roving-tabindex.md @@ -31,20 +31,27 @@ last focused. It takes three rules: ## Setting the initial tab stop keyrove moves an existing tab stop; it does not create one. If the list is -rendered from data, set the first one yourself, either in the template or with -the exported helper: +rendered from data, give it one once it renders: ```ts -import { toggleTabIndex } from '@mixedrays/keyrove'; +import { initRovingTabindex } from '@mixedrays/keyrove'; -const first = list.querySelector( - '[data-keyrove-item]:not([data-keyrove-skip])', -); -toggleTabIndex({ root: first, isActive: true }); +initRovingTabindex(list); ``` -The same call restores the tab stop after a re-render drops it, which is the -usual reason a roving group stops being reachable by Tab. +The first item that is neither skipped nor disabled gets `tabindex="0"`, and +every other roving item gets `-1`. + +Call it again after every render that may have replaced items. A re-render +that drops the item holding the stop is the usual reason a roving group stops +being reachable by Tab. The call repairs rather than +resets: while the item holding the stop is still there and navigable, the stop +stays on it, so Tab away and back still returns to where +the user left off. Only when that item is gone does the stop go to the first +item. A template that renders `tabindex="0"` on the selected item keeps it the +same way. A [nested group](/docs/examples/nested-roots) keeps its own stop, +untouched; call the function on its root to set that one up. + A group with every item at `tabindex="-1"` cannot be reached with Tab at all, which is the one way this pattern can leave a page _less_ navigable than the plain tab order it replaced. Exactly one `0` per diff --git a/packages/keyrove/README.md b/packages/keyrove/README.md index 67f3f0b..197efef 100644 --- a/packages/keyrove/README.md +++ b/packages/keyrove/README.md @@ -279,6 +279,12 @@ ordinary tab stops that arrows _also_ reach. Opt into instead be a single tab stop that Tab moves past rather than through. +keyrove moves that stop but never creates it. Call `initRovingTabindex(list)` +once the group renders, and again after re-renders: it gives the group exactly +one `tabindex="0"`, keeping the one it has while that item is still there, and +leaves nested groups' stops alone. It takes `items`, `root`, `skip` and +`rovingTabindex`, the same as `keyRove` and `createTypeahead`. + ## Typeahead `createTypeahead` adds type-to-focus: printable characters accumulate in a @@ -373,7 +379,7 @@ presses. `keyRove` takes every option but `label`. `createTypeahead` takes `items`, `root`, `skip`, `rovingTabindex` and `label` — the settings that bear on -finding an item. +finding an item. `initRovingTabindex` takes the same four without `label`. The boolean attributes — `data-keyrove-item`, `data-keyrove-skip`, `data-keyrove-roving-tabindex`, `data-keyrove-root`, and `data-keyrove-loop` — @@ -412,8 +418,9 @@ non-null result means the key is claimed, so handlers chain with `||`: element.addEventListener('keydown', (e) => keyRove(e) || myOwnHandler(e)); ``` -`toggleTabIndex({ root, isActive })` is exported for cases where you manage the -tab stop yourself — restoring it after re-rendering a list, for instance. +`toggleTabIndex({ root, isActive })` is exported for cases where you manage one +element's tab stop yourself, such as moving it after a click. For a whole +roving group, `initRovingTabindex(root, options?)` keeps exactly one stop. ## License diff --git a/packages/keyrove/src/__tests__/initRovingTabindex/initRovingTabindex.test.ts b/packages/keyrove/src/__tests__/initRovingTabindex/initRovingTabindex.test.ts new file mode 100644 index 0000000..2fe75d7 --- /dev/null +++ b/packages/keyrove/src/__tests__/initRovingTabindex/initRovingTabindex.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it, afterEach } from 'vitest'; +import { + initRovingTabindex, + keyRove, + KEYROVE_ATTR_ITEM, + KEYROVE_ATTR_ROOT, + KEYROVE_ATTR_ROVING_TABINDEX, + KEYROVE_ATTR_SKIP, +} from '../../index'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +type Spec = { tabindex?: string | null; roving?: boolean }; + +/** A roving item: `tabindex="-1"` unless the spec says otherwise. */ +const item = (id: string, { tabindex = '-1', roving = true }: Spec = {}) => { + const el = document.createElement('button'); + el.id = id; + el.setAttribute(KEYROVE_ATTR_ITEM, 'true'); + if (roving) el.setAttribute(KEYROVE_ATTR_ROVING_TABINDEX, 'true'); + if (tabindex !== null) el.setAttribute('tabindex', tabindex); + + return el; +}; + +const group = (...children: Element[]) => { + const root = document.createElement('div'); + root.append(...children); + document.body.appendChild(root); + + return root; +}; + +const nestedRoot = (...children: Element[]) => { + const root = document.createElement('div'); + root.setAttribute(KEYROVE_ATTR_ROOT, ''); + root.append(...children); + + return root; +}; + +/** Every element's `tabindex`, by id, for the ids given. */ +const tabindexes = (...ids: string[]) => + Object.fromEntries( + ids.map((id) => [ + id, + document.getElementById(id)!.getAttribute('tabindex'), + ]), + ); + +describe('initRovingTabindex', () => { + describe('placing the stop', () => { + it('does nothing without a root', () => { + expect(initRovingTabindex(null)).toBeNull(); + expect(initRovingTabindex(undefined)).toBeNull(); + }); + + it('gives the first navigable roving item the stop in a fresh group', () => { + const ignored = item('ignored', { tabindex: '0' }); + ignored.setAttribute(KEYROVE_ATTR_ITEM, 'false'); + const skipped = item('skipped'); + skipped.setAttribute(KEYROVE_ATTR_SKIP, 'true'); + const first = item('first'); + first.setAttribute(KEYROVE_ATTR_SKIP, 'false'); + const later = item('later'); + const disabled = item('disabled', { tabindex: '0' }); + disabled.setAttribute('disabled', ''); + const plain = item('plain', { tabindex: '0', roving: false }); + const root = group(ignored, skipped, first, later, disabled, plain); + + expect(initRovingTabindex(root)).toBe(first); + expect( + tabindexes('ignored', 'skipped', 'first', 'later', 'disabled', 'plain'), + ).toEqual({ + ignored: '0', + skipped: '-1', + first: '0', + later: '-1', + disabled: '-1', + plain: '0', + }); + }); + + it('gives every roving item -1 and returns null when none is navigable', () => { + const skipped = item('skipped', { tabindex: '0' }); + skipped.setAttribute(KEYROVE_ATTR_SKIP, ''); + const disabled = item('disabled'); + disabled.setAttribute('disabled', ''); + const root = group(skipped, disabled); + + expect(initRovingTabindex(root)).toBeNull(); + expect(tabindexes('skipped', 'disabled')).toEqual({ + skipped: '-1', + disabled: '-1', + }); + }); + + it('gives a rendered item without a tabindex its -1', () => { + const root = group( + item('a', { tabindex: null }), + item('b', { tabindex: null }), + ); + + initRovingTabindex(root); + + expect(tabindexes('a', 'b')).toEqual({ a: '0', b: '-1' }); + }); + }); + + describe('repairing rather than resetting', () => { + it('keeps the stop on an item that still holds it', () => { + const root = group( + item('a'), + item('b'), + item('c', { tabindex: '0' }), + item('d'), + ); + // A re-render appends an item. + root.append(item('e', { tabindex: null })); + + expect(initRovingTabindex(root)).toBe(document.getElementById('c')); + expect(tabindexes('a', 'b', 'c', 'd', 'e')).toEqual({ + a: '-1', + b: '-1', + c: '0', + d: '-1', + e: '-1', + }); + }); + + it('moves the stop to the first navigable item when its holder was removed', () => { + const root = group(item('a'), item('b'), item('c', { tabindex: '0' })); + document.getElementById('c')!.remove(); + + expect(initRovingTabindex(root)).toBe(document.getElementById('a')); + expect(tabindexes('a', 'b')).toEqual({ a: '0', b: '-1' }); + }); + + it('moves the stop off a holder that is now skipped or disabled', () => { + const skipped = item('skipped', { tabindex: '0' }); + skipped.setAttribute(KEYROVE_ATTR_SKIP, ''); + const disabled = item('disabled', { tabindex: '0' }); + disabled.setAttribute('disabled', ''); + const root = group(skipped, disabled, item('a'), item('b')); + + expect(initRovingTabindex(root)).toBe(document.getElementById('a')); + expect(tabindexes('skipped', 'disabled', 'a', 'b')).toEqual({ + skipped: '-1', + disabled: '-1', + a: '0', + b: '-1', + }); + }); + + it('collapses several stops to the first navigable one of them', () => { + const root = group( + item('a'), + item('b', { tabindex: '0' }), + item('c', { tabindex: '0' }), + ); + + expect(initRovingTabindex(root)).toBe(document.getElementById('b')); + expect(tabindexes('a', 'b', 'c')).toEqual({ a: '-1', b: '0', c: '-1' }); + }); + + it('writes nothing to a group that is already whole', () => { + const root = group(item('a'), item('b', { tabindex: '0' }), item('c')); + const observer = new MutationObserver(() => {}); + observer.observe(root, { attributes: true, subtree: true }); + + initRovingTabindex(root); + + expect(observer.takeRecords()).toEqual([]); + observer.disconnect(); + }); + + it('keeps the stop keyboard moves carried, across a re-render', () => { + const root = group(item('a', { tabindex: '0' }), item('b'), item('c')); + root.addEventListener('keydown', (e) => keyRove(e)); + document.getElementById('a')!.focus(); + const down = () => + document.activeElement!.dispatchEvent( + new KeyboardEvent('keydown', { code: 'ArrowDown', bubbles: true }), + ); + + down(); + down(); + root.append(item('d', { tabindex: null })); + initRovingTabindex(root); + + expect(tabindexes('a', 'b', 'c', 'd')).toEqual({ + a: '-1', + b: '-1', + c: '0', + d: '-1', + }); + }); + }); + + describe('nested groups', () => { + it("leaves a nested group's stop alone", () => { + const root = group( + nestedRoot(item('n0', { tabindex: '0' }), item('n1')), + item('o0'), + item('o1'), + ); + + expect(initRovingTabindex(root)).toBe(document.getElementById('o0')); + expect(tabindexes('n0', 'n1', 'o0', 'o1')).toEqual({ + n0: '0', + n1: '-1', + o0: '0', + o1: '-1', + }); + }); + + it('manages only the nested group when called on it', () => { + const inner = nestedRoot(item('n0'), item('n1', { tabindex: '0' })); + group( + item('o0', { tabindex: '0' }), + inner, + item('o1', { tabindex: '0' }), + ); + + expect(initRovingTabindex(inner)).toBe(document.getElementById('n1')); + expect(tabindexes('o0', 'o1', 'n0', 'n1')).toEqual({ + o0: '0', + o1: '0', + n0: '-1', + n1: '0', + }); + }); + + it('counts an item that is itself a root in the group around it', () => { + const panel = nestedRoot(item('n0', { tabindex: '0' }), item('n1')); + panel.id = 'panel'; + panel.setAttribute(KEYROVE_ATTR_ITEM, ''); + panel.setAttribute(KEYROVE_ATTR_ROVING_TABINDEX, ''); + panel.setAttribute('tabindex', '-1'); + const root = group(panel, item('o1', { tabindex: '0' })); + + expect(initRovingTabindex(root)).toBe(document.getElementById('o1')); + expect(initRovingTabindex(panel)).toBe(document.getElementById('n0')); + expect(tabindexes('panel', 'o1', 'n0', 'n1')).toEqual({ + panel: '-1', + o1: '0', + n0: '0', + n1: '-1', + }); + }); + + it('recognises nested roots by the root option', () => { + const inner = document.createElement('div'); + inner.className = 'group'; + inner.append(item('n0', { tabindex: '0' })); + const root = group(inner, item('o0')); + + initRovingTabindex(root, { root: '.group' }); + + expect(tabindexes('n0', 'o0')).toEqual({ n0: '0', o0: '0' }); + }); + }); + + describe('a group described in options', () => { + it('reads the items and roving the options name, with no attributes', () => { + const root = group(); + root.innerHTML = ['a', 'b', 'c'] + .map((id) => ``) + .join(''); + + const stop = initRovingTabindex(root, { + items: '[role="menuitem"]', + rovingTabindex: true, + }); + + expect(stop).toBe(document.getElementById('a')); + expect(tabindexes('a', 'b', 'c')).toEqual({ a: '0', b: '-1', c: '-1' }); + }); + + it('passes over the items the skip option names', () => { + const heading = item('heading'); + heading.className = 'heading'; + const root = group(heading, item('a')); + + expect(initRovingTabindex(root, { skip: '.heading' })).toBe( + document.getElementById('a'), + ); + }); + + it('leaves marked-up items alone under rovingTabindex: false', () => { + const root = group( + item('a', { tabindex: '0' }), + item('b', { tabindex: '0' }), + ); + + expect(initRovingTabindex(root, { rovingTabindex: false })).toBeNull(); + expect(tabindexes('a', 'b')).toEqual({ a: '0', b: '0' }); + }); + }); +}); diff --git a/packages/keyrove/src/group.ts b/packages/keyrove/src/group.ts index 915c364..77b2930 100644 --- a/packages/keyrove/src/group.ts +++ b/packages/keyrove/src/group.ts @@ -117,6 +117,34 @@ export const readGroup = ( }; }; +/** + * The items a root governs itself: its items, less those of a root nested + * inside it. An item belongs to the nearest root above its *parent* — the + * resolution a focus key's move uses — so an item that is itself a root + * belongs to the group around it, and its own items to it. + * + * Deliberately not `readGroup`'s items, which keep a nested root's items so + * the outer order runs straight through them. This is the set one group's + * roving tab stop is shared across: exactly one `0` among them, and a nested + * group's stop left alone. + */ +export const ownItems = ( + root: Element, + readItems: ReadItems = attributeItems, + isRoot: IsRoot = attributeRoot, +): Element[] => + readItems(root).filter((item) => { + for ( + let element = item.parentElement; + element && element !== root; + element = element.parentElement + ) { + if (isRoot(element)) return false; + } + + return true; + }); + /** * Whether focus landed on `to` or inside it — an item may hand its focus on * to a control of its own. Asked of `to`'s own tree: inside a shadow root the diff --git a/packages/keyrove/src/index.ts b/packages/keyrove/src/index.ts index 46ed155..ed7250d 100644 --- a/packages/keyrove/src/index.ts +++ b/packages/keyrove/src/index.ts @@ -1,5 +1,6 @@ export * from './keyRove.js'; export * from './createTypeahead.js'; +export { initRovingTabindex } from './initRovingTabindex.js'; export { matchesCombo, toggleTabIndex } from './utils.js'; // Named rather than `export *`, so the internal types in `types.ts` stay // internal and the public surface is visible at a glance. @@ -11,6 +12,7 @@ export type { MoveAction, MoveResult, Options, + RovingTabindexOptions, StrideAction, TypeaheadMove, TypeaheadOptions, diff --git a/packages/keyrove/src/initRovingTabindex.ts b/packages/keyrove/src/initRovingTabindex.ts new file mode 100644 index 0000000..83799b2 --- /dev/null +++ b/packages/keyrove/src/initRovingTabindex.ts @@ -0,0 +1,67 @@ +/** + * The roving tab stop, set up and kept whole across renders. + * + * keyrove moves an existing stop and never creates one, so a group rendered + * from data needs its `0` placed once — and placed again whenever a render + * replaces the item holding it. This is that one call, safe to make after + * every render: it repairs the group rather than resetting it, so a stop the + * user left somewhere valid stays there. + */ + +import { itemsReader, rootTest, rovingTest, skipTest } from './config.js'; +import { attributeRoot, ownItems } from './group.js'; +import { toggleTabIndex } from './utils.js'; +import type { RovingTabindexOptions } from './types.js'; + +/** + * Gives a roving group exactly one tab stop, keeping the one it has where it + * still can. + * + * The group's roving items are its own items, read as `keyRove` reads them — + * the item attribute or `items`, `disabled` aside — that carry the stop: + * the roving-tabindex attribute, or every item under `rovingTabindex: true`. + * Items of a root nested inside are another group's, and are left alone. + * + * Of those, the stop goes to the first navigable one — neither skipped nor + * disabled — that already has `tabindex="0"`, so a stop keyboard moves have + * carried, or a template put on the selected item, survives the call. Failing + * that, it goes to the first navigable item. Every other roving item gets + * `-1`, disabled and skipped ones included. Only attributes that change are + * written. + * @param root - The group's root. Nullish is a no-op. + * @param options - The group settings that decide what its items are; see + * {@link RovingTabindexOptions}. The attributes answer where they are left out. + * @returns The item holding the stop, or `null` when no roving item is + * navigable. + */ +export const initRovingTabindex = ( + root: Element | null | undefined, + options: RovingTabindexOptions = {}, +): Element | null => { + if (!root) return null; + + const isRoving = rovingTest(options); + const isSkipped = skipTest(options); + const items = ownItems( + root, + itemsReader(options), + rootTest(options) ?? attributeRoot, + ).filter(isRoving); + const navigable = items.filter( + (item) => !isSkipped(item) && !item.hasAttribute('disabled'), + ); + const stop = + navigable.find((item) => item.getAttribute('tabindex') === '0') ?? + navigable[0] ?? + null; + + for (const item of items) { + const isActive = item === stop; + + if (item.getAttribute('tabindex') !== (isActive ? '0' : '-1')) { + toggleTabIndex({ root: item, isActive }); + } + } + + return stop; +}; diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index d5d5206..aa053ad 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -244,6 +244,16 @@ export type TypeaheadResult = ActionResult<'typeahead'>; /** The argument a typeahead `onMove` receives: a move that actually happened. */ export type TypeaheadMove = TypeaheadResult & { to: Element }; +/** + * What `initRovingTabindex` takes: the group settings that decide which + * elements are a group's roving items. The same fields {@link GroupOptions} + * names, falling back the same way, so one object serves every export. + */ +export type RovingTabindexOptions = Pick< + GroupOptions, + 'items' | 'root' | 'skip' | 'rovingTabindex' +>; + /** * How a group folds its DOM-ordered sequence — read once off the root and * handed to both pure layers, so neither re-derives it. From 60f63b008753e430fb6a95f324f75a52de8bdf1a Mon Sep 17 00:00:00 2001 From: mixedrays Date: Tue, 22 Sep 2026 09:46:44 +0200 Subject: [PATCH 08/26] feat: add followFocus to move the roving tab stop on focusin --- packages/docs/content/docs/api.md | 53 ++++- .../content/docs/attributes-and-options.md | 7 +- .../docs/content/docs/examples/listbox.md | 18 +- .../content/docs/examples/roving-tabindex.md | 21 ++ packages/docs/src/demos.ts | 12 +- packages/keyrove/README.md | 17 +- .../__tests__/followFocus/followFocus.test.ts | 224 ++++++++++++++++++ packages/keyrove/src/followFocus.ts | 86 +++++++ packages/keyrove/src/group.ts | 15 ++ packages/keyrove/src/index.ts | 1 + packages/keyrove/src/initRovingTabindex.ts | 11 +- packages/keyrove/src/types.ts | 4 +- 12 files changed, 428 insertions(+), 41 deletions(-) create mode 100644 packages/keyrove/src/__tests__/followFocus/followFocus.test.ts create mode 100644 packages/keyrove/src/followFocus.ts diff --git a/packages/docs/content/docs/api.md b/packages/docs/content/docs/api.md index e4eede8..69f0859 100644 --- a/packages/docs/content/docs/api.md +++ b/packages/docs/content/docs/api.md @@ -12,6 +12,7 @@ order: 4 | [`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. | | [`initRovingTabindex(root, options?)`](#initrovingtabindex-root-options) | Gives a roving group exactly one tab stop, and keeps it whole across renders. | +| [`followFocus(event, options?)`](#followfocus-event-options) | Moves the roving tab stop to wherever focus lands, on `focusin`. | | [`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. | @@ -279,8 +280,9 @@ bare code such as `KeyE` works wherever the letter would not be typing. - 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`. 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. A nested group's stop is never touched. See [focus keys](/docs/examples/focus-keys) for the pattern at work. @@ -514,6 +516,40 @@ initRovingTabindex(menu, config); menu.addEventListener('keydown', (e) => keyRove(e, config)); ``` +## followFocus(event, options?) + +Moves a roving group's tab stop to the item focus landed in. keyRove carries +the stop on the moves it makes, but focus also arrives by a click, by +`element.focus()` from your code, and by Tab onto a +control inside an item. Each of those would leave the stop behind, and +Tab away and back would return somewhere else. +`followFocus` on `focusin` covers all of them: + +```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`. + ## toggleTabIndex({ root, isActive }) Sets `tabindex` to `0` or `-1` on a single element. @@ -524,10 +560,10 @@ import { toggleTabIndex } from '@mixedrays/keyrove'; toggleTabIndex({ root: firstItem, isActive: true }); ``` -Use it where you manage one element's tab stop yourself, such as moving the -stop after a click, as the [listbox](/docs/examples/listbox) does. For a whole -roving group, [`initRovingTabindex`](#initrovingtabindex-root-options) keeps -exactly one `0` for you. Descendant tab stops are left alone; roving tabindex +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. @@ -740,8 +776,9 @@ type TypeaheadMove = TypeaheadResult & { to: Element }; ### RovingTabindexOptions -What [`initRovingTabindex`](#initrovingtabindex-root-options) takes: the group -settings that decide which elements are the group's roving items. +What [`initRovingTabindex`](#initrovingtabindex-root-options) and +[`followFocus`](#followfocus-event-options) take: the group settings that +decide which elements are the group's roving items. ```ts type RovingTabindexOptions = Pick< diff --git a/packages/docs/content/docs/attributes-and-options.md b/packages/docs/content/docs/attributes-and-options.md index b91543b..7fc21b4 100644 --- a/packages/docs/content/docs/attributes-and-options.md +++ b/packages/docs/content/docs/attributes-and-options.md @@ -116,9 +116,10 @@ The settings that are about _moves_ — the keys, the columns, looping — are n among its options: a typeahead has one way to reach an item, its label, for which it takes a `label` of its own. -[`initRovingTabindex`](/docs/api#initrovingtabindex-root-options) takes the -same four settings, so the object that describes a roving group also places -its tab stop. +[`initRovingTabindex`](/docs/api#initrovingtabindex-root-options) and +[`followFocus`](/docs/api#followfocus-event-options) take the same four +settings, so the object that describes a roving group also places its tab stop +and keeps it with focus. [Options in JavaScript](/docs/examples/javascript-options) is the whole of this at work, on a menu that carries no keyrove attribute anywhere. diff --git a/packages/docs/content/docs/examples/listbox.md b/packages/docs/content/docs/examples/listbox.md index 31b690b..e7c451f 100644 --- a/packages/docs/content/docs/examples/listbox.md +++ b/packages/docs/content/docs/examples/listbox.md @@ -21,9 +21,9 @@ focus returns to where you left it. ```ts import { createTypeahead, + followFocus, keyRove, matchesCombo, - toggleTabIndex, } from '@mixedrays/keyrove'; const listbox = document.querySelector('#assignee'); @@ -51,14 +51,11 @@ listbox.addEventListener('keydown', (e) => { keyRove(e) || typeahead(e) || pick(e); }); +listbox.addEventListener('focusin', (e) => followFocus(e)); + listbox.addEventListener('click', (e) => { const option = e.target.closest('[role="option"]'); - if (!option) return; - - const stop = listbox.querySelector('[tabindex="0"]'); - toggleTabIndex({ root: stop, isActive: false }); - toggleTabIndex({ root: option, isActive: true }); - select(option); + if (option) select(option); }); ``` @@ -94,10 +91,11 @@ key can be told apart. default. `pick` keeps the contract of the two handlers before it, `null` for a key it left alone, so a fourth handler could chain on. - **The mouse.** A click focuses an option natively, which `tabindex="-1"` - allows, but it leaves the tab stop where the keyboard last put it, and + allows. keyRove moves the stop only on the moves it makes, so on its own the + click would leave the stop where the keyboard last put it, and Tab away and back would return to the wrong option. - keyrove moves the stop only on the moves it makes, so the click handler moves - it with `toggleTabIndex` and then picks. + `followFocus` on `focusin` moves the stop to wherever focus lands, the click + included, and the click handler is left with the picking. ## Selection that follows focus diff --git a/packages/docs/content/docs/examples/roving-tabindex.md b/packages/docs/content/docs/examples/roving-tabindex.md index a8072d8..3e4fa73 100644 --- a/packages/docs/content/docs/examples/roving-tabindex.md +++ b/packages/docs/content/docs/examples/roving-tabindex.md @@ -57,6 +57,27 @@ A group with every item at `tabindex="-1"` cannot be reached with page _less_ navigable than the plain tab order it replaced. Exactly one `0` per group, always. +## Focus that keyrove did not move + +keyrove carries the stop on the moves it makes. Focus arrives other ways too: +a click on an item, which `tabindex="-1"` allows, `element.focus()` from your +code, and Tab onto a link or a field inside an item. +Each of those leaves the stop where it was, so Tab away +and back returns to the old item rather than the one the user was on. +`followFocus` on `focusin` moves the stop to wherever focus lands: + +```ts +import { followFocus, keyRove } from '@mixedrays/keyrove'; + +list.addEventListener('keydown', (e) => keyRove(e)); +list.addEventListener('focusin', (e) => followFocus(e)); +``` + +It reads the group the way `keyRove` does, so it leaves a nested group's stop +alone, gives no stop to a skipped item, and takes the same options object +where the group is described in JavaScript. The +[listbox](/docs/examples/listbox) uses it for clicks. + ## Choosing between the two Neither is more correct; they answer different questions. diff --git a/packages/docs/src/demos.ts b/packages/docs/src/demos.ts index d186dac..1b9fbc2 100644 --- a/packages/docs/src/demos.ts +++ b/packages/docs/src/demos.ts @@ -6,6 +6,7 @@ import { type GroupOptions, type MoveResult, createTypeahead, + followFocus, keyRove, matchesCombo, toggleTabIndex, @@ -309,9 +310,9 @@ const wireGroupExit = (surface: HTMLElement) => { * * Selection is the widget's state rather than keyrove's, so this is the page's * own snippet made live: Space or Enter picks the focused option, a click picks - * and carries the roving tab stop with it, and either is reported to the log - * beside the moves. Returns the keydown half, to chain after navigation and - * typeahead. + * it, and either is reported to the log beside the moves. `followFocus` carries + * the roving tab stop to wherever focus lands, the click included. Returns the + * keydown half, to chain after navigation and typeahead. */ const wireSelection = (surface: HTMLElement, log: Log): Handler => { const OPTION = '[role="option"]'; @@ -322,13 +323,12 @@ const wireSelection = (surface: HTMLElement, log: Log): Handler => { } }; + surface.addEventListener('focusin', (e) => followFocus(e)); + surface.addEventListener('click', (e) => { const option = (e.target as Element).closest(OPTION); if (!option) return; - const stop = surface.querySelector('[tabindex="0"]'); - toggleTabIndex({ root: stop, isActive: false }); - toggleTabIndex({ root: option, isActive: true }); select(option); // No key to name: the pointer did this one. diff --git a/packages/keyrove/README.md b/packages/keyrove/README.md index 197efef..8960ab2 100644 --- a/packages/keyrove/README.md +++ b/packages/keyrove/README.md @@ -285,6 +285,15 @@ one `tabindex="0"`, keeping the one it has while that item is still there, and leaves nested groups' stops alone. It takes `items`, `root`, `skip` and `rovingTabindex`, the same as `keyRove` and `createTypeahead`. +keyRove carries the stop on its own moves only. For a click, a call to +`element.focus()`, or Tab onto a control inside an item, attach +`followFocus` to `focusin` and the stop follows focus there too: + +```ts +list.addEventListener('keydown', (e) => keyRove(e)); +list.addEventListener('focusin', (e) => followFocus(e)); +``` + ## Typeahead `createTypeahead` adds type-to-focus: printable characters accumulate in a @@ -379,7 +388,8 @@ presses. `keyRove` takes every option but `label`. `createTypeahead` takes `items`, `root`, `skip`, `rovingTabindex` and `label` — the settings that bear on -finding an item. `initRovingTabindex` takes the same four without `label`. +finding an item. `initRovingTabindex` and `followFocus` take the same four +without `label`. The boolean attributes — `data-keyrove-item`, `data-keyrove-skip`, `data-keyrove-roving-tabindex`, `data-keyrove-root`, and `data-keyrove-loop` — @@ -419,8 +429,9 @@ element.addEventListener('keydown', (e) => keyRove(e) || myOwnHandler(e)); ``` `toggleTabIndex({ root, isActive })` is exported for cases where you manage one -element's tab stop yourself, such as moving it after a click. For a whole -roving group, `initRovingTabindex(root, options?)` keeps exactly one stop. +element's tab stop yourself. For a whole roving group, +`initRovingTabindex(root, options?)` keeps exactly one stop, and +`followFocus(event, options?)` moves it with focus keyRove did not move. ## License diff --git a/packages/keyrove/src/__tests__/followFocus/followFocus.test.ts b/packages/keyrove/src/__tests__/followFocus/followFocus.test.ts new file mode 100644 index 0000000..04a2b67 --- /dev/null +++ b/packages/keyrove/src/__tests__/followFocus/followFocus.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it, afterEach } from 'vitest'; +import { + followFocus, + keyRove, + KEYROVE_ATTR_ITEM, + KEYROVE_ATTR_ROOT, + KEYROVE_ATTR_ROVING_TABINDEX, + KEYROVE_ATTR_SKIP, +} from '../../index'; +import type { RovingTabindexOptions } from '../../index'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +type Spec = { tabindex?: string; roving?: boolean }; + +/** A roving item: `tabindex="-1"` unless the spec says otherwise. */ +const item = (id: string, { tabindex = '-1', roving = true }: Spec = {}) => { + const el = document.createElement('button'); + el.id = id; + el.setAttribute(KEYROVE_ATTR_ITEM, 'true'); + if (roving) el.setAttribute(KEYROVE_ATTR_ROVING_TABINDEX, 'true'); + el.setAttribute('tabindex', tabindex); + + return el; +}; + +const nestedRoot = (...children: Element[]) => { + const root = document.createElement('div'); + root.setAttribute(KEYROVE_ATTR_ROOT, ''); + root.append(...children); + + return root; +}; + +/** + * A group listening for focusin, as a page wires it: one listener on the + * group's element, nested roots included. Every result is kept. + */ +const listen = (children: Element[], options?: RovingTabindexOptions) => { + const root = document.createElement('div'); + root.append(...children); + document.body.appendChild(root); + const results: (Element | null)[] = []; + root.addEventListener('focusin', (e) => + results.push(followFocus(e, options)), + ); + + return { root, results }; +}; + +const byId = (id: string) => document.getElementById(id)!; + +const tabindexes = (...ids: string[]) => + Object.fromEntries(ids.map((id) => [id, byId(id).getAttribute('tabindex')])); + +describe('followFocus', () => { + it('moves the stop to an item focused without a key: a click, or .focus()', () => { + const { results } = listen([ + item('a', { tabindex: '0' }), + item('b'), + item('c'), + ]); + + byId('c').focus(); + + expect(results).toEqual([byId('c')]); + expect(tabindexes('a', 'b', 'c')).toEqual({ a: '-1', b: '-1', c: '0' }); + }); + + it('moves the stop to the item around a focused control', () => { + const b = item('b'); + const input = document.createElement('input'); + b.append(input); + const { results } = listen([item('a', { tabindex: '0' }), b]); + + input.focus(); + + expect(results).toEqual([b]); + expect(tabindexes('a', 'b')).toEqual({ a: '-1', b: '0' }); + }); + + it("never touches a nested group's stop from the outer group", () => { + listen([ + item('o0', { tabindex: '0' }), + nestedRoot(item('n0', { tabindex: '0' }), item('n1')), + item('o1'), + ]); + + byId('o1').focus(); + + expect(tabindexes('o0', 'o1', 'n0', 'n1')).toEqual({ + o0: '-1', + o1: '0', + n0: '0', + n1: '-1', + }); + }); + + it("moves only the nested group's stop for focus inside it", () => { + listen([ + item('o0', { tabindex: '0' }), + nestedRoot(item('n0', { tabindex: '0' }), item('n1')), + item('o1'), + ]); + + byId('n1').focus(); + + expect(tabindexes('o0', 'o1', 'n0', 'n1')).toEqual({ + o0: '0', + o1: '-1', + n0: '-1', + n1: '0', + }); + }); + + it('gives the stop to a panel that is a root and an item of the group around it', () => { + const panel = nestedRoot(item('n0', { tabindex: '0' }), item('n1')); + panel.id = 'panel'; + panel.setAttribute(KEYROVE_ATTR_ITEM, ''); + panel.setAttribute(KEYROVE_ATTR_ROVING_TABINDEX, ''); + panel.setAttribute('tabindex', '-1'); + const { results } = listen([item('o0', { tabindex: '0' }), panel]); + + panel.focus(); + + expect(results).toEqual([panel]); + expect(tabindexes('o0', 'panel', 'n0', 'n1')).toEqual({ + o0: '-1', + panel: '0', + n0: '0', + n1: '-1', + }); + }); + + it("gives the stop to an outer item around a nested root's own control", () => { + const control = document.createElement('input'); + control.id = 'control'; + const message = item('message'); + message.append(nestedRoot(control, item('n0', { tabindex: '0' }))); + const { results } = listen([item('o0', { tabindex: '0' }), message]); + + control.focus(); + + expect(results).toEqual([message]); + expect(tabindexes('o0', 'message', 'n0')).toEqual({ + o0: '-1', + message: '0', + n0: '0', + }); + }); + + it('is a no-op for an item that does not carry the stop', () => { + const { results } = listen([ + item('a', { tabindex: '0' }), + item('plain', { tabindex: '0', roving: false }), + ]); + + byId('plain').focus(); + + expect(results).toEqual([null]); + expect(tabindexes('a', 'plain')).toEqual({ a: '0', plain: '0' }); + }); + + it('is a no-op for a skipped item, which never holds the stop', () => { + const skipped = item('skipped'); + skipped.setAttribute(KEYROVE_ATTR_SKIP, ''); + const { results } = listen([item('a', { tabindex: '0' }), skipped]); + + skipped.focus(); + + expect(results).toEqual([null]); + expect(tabindexes('a', 'skipped')).toEqual({ a: '0', skipped: '-1' }); + }); + + it('is a no-op for focus outside every item', () => { + const outside = document.createElement('button'); + const { results } = listen([item('a', { tabindex: '0' }), outside]); + + outside.focus(); + + expect(results).toEqual([null]); + expect(tabindexes('a')).toEqual({ a: '0' }); + }); + + it('reads a group described in options as it reads the attribute form', () => { + const menu = ['a', 'b', 'c'].map((id) => { + const el = document.createElement('div'); + el.id = id; + el.setAttribute('role', 'menuitem'); + el.setAttribute('tabindex', id === 'a' ? '0' : '-1'); + + return el; + }); + const { results } = listen(menu, { + items: '[role="menuitem"]', + rovingTabindex: true, + }); + + byId('b').focus(); + + expect(results).toEqual([byId('b')]); + expect(tabindexes('a', 'b', 'c')).toEqual({ a: '-1', b: '0', c: '-1' }); + }); + + it('agrees with keyRove, writing nothing after a move it already carried', () => { + const { root, results } = listen([item('a', { tabindex: '0' }), item('b')]); + root.addEventListener('keydown', (e) => keyRove(e)); + byId('a').focus(); + const observer = new MutationObserver(() => {}); + observer.observe(root, { attributes: true, subtree: true }); + + byId('a').dispatchEvent( + new KeyboardEvent('keydown', { code: 'ArrowDown', bubbles: true }), + ); + + // keyRove's own toggle, and nothing from followFocus after it. + expect(observer.takeRecords()).toHaveLength(2); + expect(results).toEqual([byId('a'), byId('b')]); + expect(tabindexes('a', 'b')).toEqual({ a: '-1', b: '0' }); + observer.disconnect(); + }); +}); diff --git a/packages/keyrove/src/followFocus.ts b/packages/keyrove/src/followFocus.ts new file mode 100644 index 0000000..013b97c --- /dev/null +++ b/packages/keyrove/src/followFocus.ts @@ -0,0 +1,86 @@ +/** + * The roving tab stop, following focus however it arrives. + * + * keyrove carries the stop on the moves it makes. Focus also arrives by + * pointer, by `element.focus()` from app code, and by Tab onto a control + * inside an item that is not the stop, and each of those leaves the stop + * behind, so Tab away and back returns somewhere else. A `focusin` listener + * closes that gap. It keeps the `keyRove` call shape and reads the group the + * way a keypress does, so one config object serves every handler. + */ + +import { itemsReader, rootTest, rovingTest, skipTest } from './config.js'; +import { + attributeRoot, + listenerElement, + ownItems, + placeStop, + readGroup, + resolveRoot, +} from './group.js'; +import type { KeyRoveEvent, RovingTabindexOptions } from './types.js'; + +/** + * Moves the roving tab stop to the item focus landed in. Attach it to + * `focusin`, beside `keyRove` on `keydown`. + * + * The item is found the way a keypress finds its position: the nearest root + * above the target, and the item of its group holding focus, the outermost + * where items nest. Where that root has none — focus on a panel that is itself + * a root and an item of the group around it, or on a control of a nested root + * inside an outer item — the group around it is asked next, as far as the + * listener's element. + * + * The item takes the stop when it carries it and is not skipped; 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, so the `focusin` + * that follows one of keyRove's own moves, which has already carried the stop, + * writes nothing. + * @param e - The focusin event, native or framework-synthetic. + * @param options - The group settings that decide what its items are; see + * {@link RovingTabindexOptions}. The attributes answer where they are left out. + * @returns The item now holding the stop, or `null` when focus is in no item + * that carries one. + */ +export const followFocus = ( + e: Pick, + options: RovingTabindexOptions = {}, +): Element | null => { + const isRoot = rootTest(options); + const readItems = itemsReader(options); + const scope = listenerElement(e.currentTarget); + + let from = e.target as Element | null; + + while (from) { + const root = resolveRoot(from, e.currentTarget, isRoot); + + if (!root) return null; + + const { focused } = readGroup(root, readItems); + + if (focused) { + const isRoving = rovingTest(options); + + if (!isRoving(focused) || skipTest(options)(focused)) return null; + + const items = ownItems(root, readItems, isRoot ?? attributeRoot).filter( + isRoving, + ); + + if (!items.includes(focused)) return null; + + placeStop(items, focused); + + return focused; + } + + // No item of this group holds focus. The group around it may, but never + // one past the listener's reach. + if (!scope || root === scope || !scope.contains(root)) return null; + + from = root.parentElement; + } + + return null; +}; diff --git a/packages/keyrove/src/group.ts b/packages/keyrove/src/group.ts index 77b2930..9098d0e 100644 --- a/packages/keyrove/src/group.ts +++ b/packages/keyrove/src/group.ts @@ -145,6 +145,21 @@ export const ownItems = ( return true; }); +/** + * Gives `stop` the group's one `tabindex="0"` and every other of its roving + * `items` `-1` — or all of them `-1` where there is no stop. Only attributes + * that change are written, so a group already in order takes no mutations. + */ +export const placeStop = (items: Element[], stop: Element | null) => { + for (const item of items) { + const isActive = item === stop; + + if (item.getAttribute('tabindex') !== (isActive ? '0' : '-1')) { + toggleTabIndex({ root: item, isActive }); + } + } +}; + /** * Whether focus landed on `to` or inside it — an item may hand its focus on * to a control of its own. Asked of `to`'s own tree: inside a shadow root the diff --git a/packages/keyrove/src/index.ts b/packages/keyrove/src/index.ts index ed7250d..2d99432 100644 --- a/packages/keyrove/src/index.ts +++ b/packages/keyrove/src/index.ts @@ -1,5 +1,6 @@ export * from './keyRove.js'; export * from './createTypeahead.js'; +export { followFocus } from './followFocus.js'; export { initRovingTabindex } from './initRovingTabindex.js'; export { matchesCombo, toggleTabIndex } from './utils.js'; // Named rather than `export *`, so the internal types in `types.ts` stay diff --git a/packages/keyrove/src/initRovingTabindex.ts b/packages/keyrove/src/initRovingTabindex.ts index 83799b2..14379fe 100644 --- a/packages/keyrove/src/initRovingTabindex.ts +++ b/packages/keyrove/src/initRovingTabindex.ts @@ -9,8 +9,7 @@ */ import { itemsReader, rootTest, rovingTest, skipTest } from './config.js'; -import { attributeRoot, ownItems } from './group.js'; -import { toggleTabIndex } from './utils.js'; +import { attributeRoot, ownItems, placeStop } from './group.js'; import type { RovingTabindexOptions } from './types.js'; /** @@ -55,13 +54,7 @@ export const initRovingTabindex = ( navigable[0] ?? null; - for (const item of items) { - const isActive = item === stop; - - if (item.getAttribute('tabindex') !== (isActive ? '0' : '-1')) { - toggleTabIndex({ root: item, isActive }); - } - } + placeStop(items, stop); return stop; }; diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index aa053ad..b415f6b 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -245,8 +245,8 @@ export type TypeaheadResult = ActionResult<'typeahead'>; export type TypeaheadMove = TypeaheadResult & { to: Element }; /** - * What `initRovingTabindex` takes: the group settings that decide which - * elements are a group's roving items. The same fields {@link GroupOptions} + * What `initRovingTabindex` and `followFocus` take: the group settings that + * decide which elements are a group's roving items. The same fields {@link GroupOptions} * names, falling back the same way, so one object serves every export. */ export type RovingTabindexOptions = Pick< From fff9e67547a5d416c2959e1455f8b5fd4785a684 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Tue, 22 Sep 2026 10:15:05 +0200 Subject: [PATCH 09/26] fix: tell a document or window listener apart by value, not by name --- .../keyRove/keyRove.navigation.test.ts | 40 +++++++++++++++++++ packages/keyrove/src/group.ts | 12 +++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts index 6bae66a..3b79714 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts @@ -275,5 +275,45 @@ describe('keyRove', () => { expect(activeId()).toBe('b'); }, ); + + // A form exposes each named control as a property of its own. jsdom does + // not, so the property a browser would add is defined by hand. + const formWithControl = (name: string, ...children: Element[]) => { + const form = document.createElement('form'); + const control = document.createElement('input'); + control.type = 'hidden'; + control.name = name; + form.append(control, ...children); + Object.defineProperty(form, name, { value: control, configurable: true }); + document.body.appendChild(form); + form.addEventListener('keydown', (e) => keyRove(e)); + + return form; + }; + + it.each(['document', 'documentElement', 'nodeType', 'window'])( + 'navigates under a form listener with a control named %s', + (name) => { + formWithControl(name, createItem('a'), createItem('b')); + document.getElementById('a')!.focus(); + + pressKey('ArrowDown'); + + expect(activeId()).toBe('b'); + }, + ); + + it('reaches focus keys anywhere under such a form, past an inner root', () => { + const inner = document.createElement('div'); + inner.setAttribute(KEYROVE_ATTR_ROOT, ''); + inner.append(createItem('a'), createItem('b')); + const outside = createItem('outside', { focusKey: 'ctrl+KeyO' }); + formWithControl('document', inner, outside); + document.getElementById('a')!.focus(); + + pressKey('KeyO', undefined, { ctrlKey: true }); + + expect(activeId()).toBe('outside'); + }); }); }); diff --git a/packages/keyrove/src/group.ts b/packages/keyrove/src/group.ts index 9098d0e..f238157 100644 --- a/packages/keyrove/src/group.ts +++ b/packages/keyrove/src/group.ts @@ -51,6 +51,9 @@ export const holdsFocus = (element: Element): boolean => { return !!active && element.contains(active); }; +// `Node.DOCUMENT_NODE`, spelled out so reading it needs no global `Node`. +const DOCUMENT_NODE = 9; + /** * The element a listener sits on. A listener on the document, or the window, * has no element of its own, so the document element stands in: `` @@ -60,10 +63,15 @@ export const listenerElement = ( listener: EventTarget | null | undefined, ): Element | null => { if (!listener) return null; - if ('documentElement' in listener) { + + // Told apart by values, not by which properties exist: a form exposes its + // controls as named properties, so `` makes + // `'document' in form` true. A control can stand in for `nodeType` or + // `window` too, but it is an element, never 9 or the form itself. + if ((listener as Node).nodeType === DOCUMENT_NODE) { return (listener as Document).documentElement; } - if ('document' in listener) { + if ((listener as Window).window === listener) { return (listener as Window).document.documentElement; } From 683e36174c32f17f92b4b12f086d09ab8422b955 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Tue, 22 Sep 2026 10:21:54 +0200 Subject: [PATCH 10/26] feat: let initRovingTabindex take the item to hold the tab stop --- packages/docs/content/docs/api.md | 40 ++++++-- .../docs/content/docs/examples/listbox.md | 3 + .../content/docs/examples/roving-tabindex.md | 14 +++ packages/keyrove/README.md | 9 +- .../initRovingTabindex.test.ts | 93 +++++++++++++++++++ packages/keyrove/src/index.ts | 1 + packages/keyrove/src/initRovingTabindex.ts | 22 +++-- packages/keyrove/src/types.ts | 14 +++ 8 files changed, 177 insertions(+), 19 deletions(-) diff --git a/packages/docs/content/docs/api.md b/packages/docs/content/docs/api.md index 69f0859..d8607d8 100644 --- a/packages/docs/content/docs/api.md +++ b/packages/docs/content/docs/api.md @@ -492,10 +492,12 @@ It repairs the group rather than resetting it: `data-keyrove-item` with `data-keyrove-roving-tabindex`, or whatever the options name. Items of a [nested root](#roots) belong to that root's group and are left alone; call it on that root to set up its stop. -- A navigable item, neither skipped nor disabled, that already has - `tabindex="0"` keeps the stop, the first in DOM order where there are - several. So the stop keyboard moves carried, or the one a template put on the - selected option, survives the call. +- The `initial` option's item gets the stop, when it is one of the group's + navigable items, neither skipped nor disabled. +- Failing that, a navigable item that already has `tabindex="0"` keeps the + stop, the first in DOM order where there are several. So the stop keyboard + moves carried, or the one a template put on the selected option, survives + the call. - Failing that, the first navigable item gets the stop. - Every other roving item gets `-1`, skipped and disabled ones included. Only attributes that change are written, so a call after a render that changed @@ -516,6 +518,22 @@ initRovingTabindex(menu, config); menu.addEventListener('keydown', (e) => keyRove(e, config)); ``` +Where the item that should hold the stop is known to your code but not marked +in the DOM, such as a listbox's selected option, name it in `initial`: + +```ts +initRovingTabindex(listbox, { + initial: listbox.querySelector('[aria-selected="true"]'), +}); +``` + +`initial` wins over a stop the group already has, so pass it when you mean to +place the stop: the first render, or a selection changed from outside the +widget. Leave it out of the other re-renders, and the stop stays where the user +left it. Anything that is not one of the group's navigable roving items, +`null` included, is passed over for the rule above, so a query that found +nothing needs no guard. + ## followFocus(event, options?) Moves a roving group's tab stop to the item focus landed in. keyRove carries @@ -640,6 +658,7 @@ import type { Move, MoveAction, MoveResult, + InitRovingTabindexOptions, Options, RovingTabindexOptions, StrideAction, @@ -774,15 +793,20 @@ type TypeaheadResult = { type TypeaheadMove = TypeaheadResult & { to: Element }; ``` -### RovingTabindexOptions +### RovingTabindexOptions, InitRovingTabindexOptions -What [`initRovingTabindex`](#initrovingtabindex-root-options) and -[`followFocus`](#followfocus-event-options) take: the group settings that -decide which elements are the group's roving items. +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/examples/listbox.md b/packages/docs/content/docs/examples/listbox.md index e7c451f..09a1266 100644 --- a/packages/docs/content/docs/examples/listbox.md +++ b/packages/docs/content/docs/examples/listbox.md @@ -81,6 +81,9 @@ key can be told apart. Tab treats the whole list as one control. [Roving tabindex](/docs/examples/roving-tabindex) is the arrangement the APG describes for a composite widget, and keyrove carries the stop from here on. + Options rendered from data can leave the `tabindex` out and have + `initRovingTabindex` set the stop from the selection instead: + `initRovingTabindex(listbox, { initial: listbox.querySelector('[aria-selected="true"]') })`. - **Typeahead.** `createTypeahead()` second in the chain, after navigation and before the widget's own keys, so a letter jumps and a bound key never becomes typing. See [typeahead](/docs/examples/typeahead). diff --git a/packages/docs/content/docs/examples/roving-tabindex.md b/packages/docs/content/docs/examples/roving-tabindex.md index 3e4fa73..3a494eb 100644 --- a/packages/docs/content/docs/examples/roving-tabindex.md +++ b/packages/docs/content/docs/examples/roving-tabindex.md @@ -52,6 +52,20 @@ item. A template that renders `tabindex="0"` on the selected item keeps it the same way. A [nested group](/docs/examples/nested-roots) keeps its own stop, untouched; call the function on its root to set that one up. +Where the item that should start with the stop is known to your code but not +marked in the DOM, such as a tab list's active tab, name it in `initial`: + +```ts +initRovingTabindex(tabs, { + initial: tabs.querySelector('[aria-selected="true"]'), +}); +``` + +It takes the stop even from an item that already holds it, so pass it when you +mean to place the stop, not on every render. An `initial` that is `null`, +skipped, disabled or not in the group is passed over, and the call behaves as +if it were not there. + A group with every item at `tabindex="-1"` cannot be reached with Tab at all, which is the one way this pattern can leave a page _less_ navigable than the plain tab order it replaced. Exactly one `0` per diff --git a/packages/keyrove/README.md b/packages/keyrove/README.md index 8960ab2..3578648 100644 --- a/packages/keyrove/README.md +++ b/packages/keyrove/README.md @@ -283,7 +283,14 @@ keyrove moves that stop but never creates it. Call `initRovingTabindex(list)` once the group renders, and again after re-renders: it gives the group exactly one `tabindex="0"`, keeping the one it has while that item is still there, and leaves nested groups' stops alone. It takes `items`, `root`, `skip` and -`rovingTabindex`, the same as `keyRove` and `createTypeahead`. +`rovingTabindex`, the same as `keyRove` and `createTypeahead`, and an `initial` +item that takes the stop, such as a listbox's selected option: + +```ts +initRovingTabindex(listbox, { + initial: listbox.querySelector('[aria-selected="true"]'), +}); +``` keyRove carries the stop on its own moves only. For a click, a call to `element.focus()`, or Tab onto a control inside an item, attach diff --git a/packages/keyrove/src/__tests__/initRovingTabindex/initRovingTabindex.test.ts b/packages/keyrove/src/__tests__/initRovingTabindex/initRovingTabindex.test.ts index 2fe75d7..8643e7a 100644 --- a/packages/keyrove/src/__tests__/initRovingTabindex/initRovingTabindex.test.ts +++ b/packages/keyrove/src/__tests__/initRovingTabindex/initRovingTabindex.test.ts @@ -199,6 +199,99 @@ describe('initRovingTabindex', () => { }); }); + describe('choosing the item', () => { + it('gives the stop to initial over the one the group has', () => { + const root = group(item('a', { tabindex: '0' }), item('b'), item('c')); + const c = document.getElementById('c'); + + expect(initRovingTabindex(root, { initial: c })).toBe(c); + expect(tabindexes('a', 'b', 'c')).toEqual({ a: '-1', b: '-1', c: '0' }); + }); + + it('gives the stop to initial in a group with none', () => { + const root = group( + item('a', { tabindex: null }), + item('b', { tabindex: null }), + ); + const b = document.getElementById('b'); + + expect(initRovingTabindex(root, { initial: b })).toBe(b); + expect(tabindexes('a', 'b')).toEqual({ a: '-1', b: '0' }); + }); + + it('falls back to keep-or-first when initial is nullish', () => { + const root = group(item('a'), item('b', { tabindex: '0' })); + + expect(initRovingTabindex(root, { initial: null })).toBe( + document.getElementById('b'), + ); + expect(initRovingTabindex(root, { initial: undefined })).toBe( + document.getElementById('b'), + ); + expect(tabindexes('a', 'b')).toEqual({ a: '-1', b: '0' }); + }); + + it('falls back to keep-or-first when initial is not a navigable roving item', () => { + const skipped = item('skipped'); + skipped.setAttribute(KEYROVE_ATTR_SKIP, ''); + const disabled = item('disabled'); + disabled.setAttribute('disabled', ''); + const plain = item('plain', { roving: false }); + const inside = document.createElement('span'); + const root = group( + skipped, + disabled, + plain, + item('a'), + item('b', { tabindex: '0' }), + nestedRoot(item('n0', { tabindex: '0' })), + ); + document.getElementById('a')!.append(inside); + const outside = item('outside'); + document.body.append(outside); + + for (const initial of [ + skipped, + disabled, + plain, + inside, + document.getElementById('n0'), + outside, + ]) { + expect(initRovingTabindex(root, { initial })).toBe( + document.getElementById('b'), + ); + } + expect(tabindexes('skipped', 'disabled', 'plain', 'a', 'b')).toEqual({ + skipped: '-1', + disabled: '-1', + plain: '-1', + a: '-1', + b: '0', + }); + expect(tabindexes('n0', 'outside')).toEqual({ n0: '0', outside: '-1' }); + }); + + it('takes initial beside the settings of a group described in options', () => { + const root = group(); + root.innerHTML = ['a', 'b', 'c'] + .map( + (id) => + `
${id}
`, + ) + .join(''); + + const stop = initRovingTabindex(root, { + items: '[role="option"]', + rovingTabindex: true, + initial: root.querySelector('[aria-selected="true"]'), + }); + + expect(stop).toBe(document.getElementById('b')); + expect(tabindexes('a', 'b', 'c')).toEqual({ a: '-1', b: '0', c: '-1' }); + }); + }); + describe('nested groups', () => { it("leaves a nested group's stop alone", () => { const root = group( diff --git a/packages/keyrove/src/index.ts b/packages/keyrove/src/index.ts index 2d99432..ea8fa47 100644 --- a/packages/keyrove/src/index.ts +++ b/packages/keyrove/src/index.ts @@ -7,6 +7,7 @@ export { matchesCombo, toggleTabIndex } from './utils.js'; // internal and the public surface is visible at a glance. export type { GroupOptions, + InitRovingTabindexOptions, KeyRoveCode, KeyRoveEvent, Move, diff --git a/packages/keyrove/src/initRovingTabindex.ts b/packages/keyrove/src/initRovingTabindex.ts index 14379fe..2d51d74 100644 --- a/packages/keyrove/src/initRovingTabindex.ts +++ b/packages/keyrove/src/initRovingTabindex.ts @@ -10,7 +10,7 @@ import { itemsReader, rootTest, rovingTest, skipTest } from './config.js'; import { attributeRoot, ownItems, placeStop } from './group.js'; -import type { RovingTabindexOptions } from './types.js'; +import type { InitRovingTabindexOptions } from './types.js'; /** * Gives a roving group exactly one tab stop, keeping the one it has where it @@ -21,21 +21,22 @@ import type { RovingTabindexOptions } from './types.js'; * the roving-tabindex attribute, or every item under `rovingTabindex: true`. * Items of a root nested inside are another group's, and are left alone. * - * Of those, the stop goes to the first navigable one — neither skipped nor - * disabled — that already has `tabindex="0"`, so a stop keyboard moves have - * carried, or a template put on the selected item, survives the call. Failing - * that, it goes to the first navigable item. Every other roving item gets - * `-1`, disabled and skipped ones included. Only attributes that change are - * written. + * Of those, the stop goes to `initial` when it is a navigable one — neither + * skipped nor disabled. Failing that, it goes to the first navigable one that + * already has `tabindex="0"`, so a stop keyboard moves have carried, or a + * template put on the selected item, survives the call; and failing that, to + * the first navigable item. Every other roving item gets `-1`, disabled and + * skipped ones included. Only attributes that change are written. * @param root - The group's root. Nullish is a no-op. - * @param options - The group settings that decide what its items are; see - * {@link RovingTabindexOptions}. The attributes answer where they are left out. + * @param options - The group settings that decide what its items are, and the + * `initial` item; see {@link InitRovingTabindexOptions}. The attributes answer + * where the settings are left out. * @returns The item holding the stop, or `null` when no roving item is * navigable. */ export const initRovingTabindex = ( root: Element | null | undefined, - options: RovingTabindexOptions = {}, + options: InitRovingTabindexOptions = {}, ): Element | null => { if (!root) return null; @@ -50,6 +51,7 @@ export const initRovingTabindex = ( (item) => !isSkipped(item) && !item.hasAttribute('disabled'), ); const stop = + navigable.find((item) => item === options.initial) ?? navigable.find((item) => item.getAttribute('tabindex') === '0') ?? navigable[0] ?? null; diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index b415f6b..7686027 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -254,6 +254,20 @@ export type RovingTabindexOptions = Pick< 'items' | 'root' | 'skip' | 'rovingTabindex' >; +/** + * What `initRovingTabindex` takes: the group settings, and the item the stop + * should go to. + */ +export type InitRovingTabindexOptions = RovingTabindexOptions & { + /** + * The item to hold the stop, such as a listbox's selected option. It wins + * over a stop the group already has. Nullish, or anything that is not one of + * the group's navigable roving items, is passed over for the usual rule, so + * a query that found nothing needs no guard. + */ + initial?: Element | null; +}; + /** * How a group folds its DOM-ordered sequence — read once off the root and * handed to both pure layers, so neither re-derives it. From 3b0f04064809bf64420a0df30f3d7c65afd0d815 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Tue, 22 Sep 2026 11:56:39 +0200 Subject: [PATCH 11/26] docs: update docs, README and package.json descriptions for clarity and consistency --- README.md | 5 +- packages/docs/content/docs/examples/basic.md | 66 +++--- .../content/docs/examples/looping-lists.md | 117 +++++----- packages/docs/content/docs/introduction.md | 210 +++++++----------- packages/docs/content/index.md | 100 ++++----- packages/keyrove/README.md | 4 +- packages/keyrove/package.json | 2 +- 7 files changed, 214 insertions(+), 290 deletions(-) diff --git a/README.md b/README.md index 8592c5b..1863ae5 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,8 @@ [![minzipped size](https://img.shields.io/bundlejs/size/%40mixedrays%2Fkeyrove?color=4f46e5&label=minzipped%20size)](https://bundlejs.com/?q=%40mixedrays%2Fkeyrove) [![license](https://img.shields.io/npm/l/@mixedrays/keyrove?color=4f46e5)](https://github.com/mixedrays/keyrove/blob/main/LICENSE) -Framework-agnostic keyboard navigation for lists, grids and trees, driven by -`data-*` attributes or a plain options object. Any key can move focus — arrows -are only the default — and native Tab navigation keeps working. +Keyboard navigation for lists, grids and trees. Configure it with data +attributes or JavaScript options, in any framework. ## Getting started 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/looping-lists.md b/packages/docs/content/docs/examples/looping-lists.md index 1f90908..8040afd 100644 --- a/packages/docs/content/docs/examples/looping-lists.md +++ b/packages/docs/content/docs/examples/looping-lists.md @@ -1,40 +1,37 @@ --- title: Looping lists -description: Wrapping a list at its ends, so next past the last item lands on the first — and why a grid, and most long lists, should keep their edges. +description: Wrap navigation from the last item to the first, and back again. titleTag: Wrapping list navigation at the ends — keyrove group: Examples order: 11 --- -A list stops at its ends. Next on the last item is claimed and moves nothing: -the key belonged to the group, so the browser never sees it, and focus stays -where it is. For a menu that is the wrong answer — five entries a user holds as -a ring should not have a bottom to get stuck at. +By default, navigation stops at the first and last items. Add +`data-keyrove-loop` to the container to wrap between them: -`data-keyrove-loop` on the root wraps the two directional moves instead. -↓ from _Sign out_ lands on _Profile_, and -↑ from _Profile_ lands on _Sign out_. +- ↓ from _Sign out_ moves to _Profile_. +- ↑ from _Profile_ moves to _Sign out_.
```ts -document - .querySelector('#account-menu') - .addEventListener('keydown', (e) => keyRove(e)); +import { keyRove } from '@mixedrays/keyrove'; + +const menu = document.querySelector('#account-menu')!; +menu.addEventListener('keydown', (e) => keyRove(e)); ``` -The call is the one every other page makes. Wrapping is a property of the -group, read off the root on each keypress like every other attribute, so a list -can start or stop looping between two presses with nothing to re-initialise. +keyrove reads the attribute on every keypress. You can turn looping on or off +without changing the listener or reinitialising the group. ## Boolean value -The bare spelling above enables looping, as does `data-keyrove-loop="true"`. -Set it to `"false"` to turn wrapping off; this works for every boolean keyrove -attribute, including `data-keyrove-item`, `data-keyrove-root`, +Both `data-keyrove-loop` and `data-keyrove-loop="true"` enable looping. +Set `data-keyrove-loop="false"` to disable it. The same rule applies to all +boolean keyrove attributes, including `data-keyrove-item`, `data-keyrove-root`, `data-keyrove-skip`, and `data-keyrove-roving-tabindex`. -This makes generated markup direct and predictable: +In JSX, pass a boolean directly: ```tsx
    …
@@ -42,69 +39,61 @@ This makes generated markup direct and predictable: ## Entering at either end -The four directional keys also _enter_ a group: pressed while nothing inside it -is focused, they move focus to the first item. A looping list is entered as the -circle it is, so the prev key enters at the _last_ item instead — down opens the -menu at the top, up opens it at the bottom, the convention the +When no item is focused and the group's listener receives a directional key, +keyrove focuses the first navigable item. In a looping list, `prev` enters at +the last navigable item instead. With the default bindings: + +- ↓ enters at the first item. +- ↑ enters at the last item. + +This matches the optional arrow-key entry behavior in the [APG menu button](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/) -describes for opening a menu from its trigger. +pattern. -That only applies to the keys that enter. The moves that act within a group are -unchanged by looping, and by the attribute's absence just as much. +Home, End, and the page keys still require focus inside an item. ## Only next and prev wrap -Everything else keeps its edges, whatever the list is bound to: +Looping changes only `next` and `prev`: -- Home and End are absolute — the - first and last navigable items, so there is no end for them to go past. -- PageUp and PageDown clamp: a - jump that overshoots lands on that end rather than carrying on round it. A - page is a request to travel as far as possible, not a stride to be continued. +- Home and End move to the first + and last navigable items. +- PageUp and PageDown stop at + either end if a jump would go past it. -Wrapping follows the bindings rather than the arrow keys. Rebind next and prev -to J and K and those are the keys -that wrap, while the arrows go back to scrolling the page — see +If you rebind `next` and `prev` to J and +K, those keys wrap and the arrows return to their browser +defaults. See [custom keys](/docs/examples/custom-keys). ## Skipped items keep their place -A wrap lands on the first or last _navigable_ item. A heading marked -`data-keyrove-skip` at either end of the list is stepped over exactly as one in -the middle is, and so is anything `disabled` — see -[skipped items](/docs/examples/skipped-items). A list is a circle of the items -that can hold focus, not of every element in it. +A wrap lands on the first or last navigable item, passing over items marked +`data-keyrove-skip` or `disabled`. See [skipped items](/docs/examples/skipped-items). ## Nowhere to go -A group with a single navigable item is the edge case both directions at once: -the wrap resolves to the item that already has focus, so nothing moves. The -press is still consumed — `preventDefault()` is called, `onMove` does not fire, -and the [return value](/docs/api#return-value) is -`{ action: 'next', from, to: null }`, the same claimed no-op a list without the -attribute reports at its ends. +If the only navigable item already has focus, `next` and `prev` leave it there. +keyrove still calls `preventDefault()`, but `onMove` does not fire. +For `next`, the [return value](/docs/api#return-value) is +`{ action: 'next', from, to: null }`, just as at the end of a non-looping list. ## Grids keep their edges -`data-keyrove-loop` is a list attribute. Once `data-keyrove-cols` is above `1` -the group is a [grid](/docs/examples/grid) and the attribute is ignored: a cell -move at the grid's last cell, and a row move at the bottom row, are consumed -no-ops. Cell moves already flow across row ends, so within a row there is no -edge to wrap at — and the grid's own corners stay put, per the -[APG grid pattern](https://www.w3.org/WAI/ARIA/apg/patterns/grid/), where a -wrap would cost the reader their bearings in two dimensions rather than one. +Looping applies only to lists. When `data-keyrove-cols` is greater than `1`, +the group is a [grid](/docs/examples/grid) and ignores `data-keyrove-loop`. +Cell moves continue across row boundaries, but stop at the first or last cell; +row moves stop at the top or bottom. At these edges, keyrove prevents the +browser's default action without moving focus. See the +[APG grid pattern](https://www.w3.org/WAI/ARIA/apg/patterns/grid/) for keyboard +navigation guidance. ## When a list should wrap -Wrapping suits a group held as a ring rather than a line: a menu, a context -menu, a small picker, a set of tabs. Few enough items that both ends are on -screen at once, and where "past the last one" carries no information worth -reporting. - -It suits a long list much less. In a scrolling list the ends _are_ information -— reaching the bottom is how a user knows they have seen everything — and a -wrap answers a keypress that looked like one step by throwing the viewport back -to the top. Screen-reader users get the least warning of it: the item arrived at -is announced, the journey to it is not. Leaving the ends alone costs nothing in -the meantime, because a clamped list still consumes the key: the page does not -scroll out from under a reader holding ↓ at the bottom. +Wrapping works well for short menus, pickers, and tab lists where both ends +are visible. + +For long, scrolling lists, keeping the ends helps users track their position. +Wrapping can unexpectedly jump the viewport back to the top and may be hard to +notice with a screen reader. Without looping, holding ↓ +at the bottom keeps focus there and prevents the page from scrolling. diff --git a/packages/docs/content/docs/introduction.md b/packages/docs/content/docs/introduction.md index ea5b03b..36fddcd 100644 --- a/packages/docs/content/docs/introduction.md +++ b/packages/docs/content/docs/introduction.md @@ -1,15 +1,14 @@ --- title: Introduction -description: What keyrove does, which keys it moves focus with, where a group is described, how it sits beside native Tab navigation, and what it deliberately leaves to you. +description: Set up keyboard navigation, choose keys and options, and keep native Tab behavior. titleTag: Introduction to keyboard navigation — keyrove group: Guide order: 1 --- -keyrove makes a list, a grid or a tree keyboard-navigable. You mark the navigable -elements with an attribute and forward keydown events to one function; it works -out which element should receive focus next and moves it there. It does not -render anything, own any state, or wrap your components. +keyrove moves focus through lists, grids and trees. Mark the items you want to +navigate and pass `keydown` events to `keyRove`. It finds and focuses the next +item without rendering UI or wrapping your components. ```html title="Markup" ``` -You add the `tabindex="0"` yourself: keyrove moves focus but never makes an -element focusable. That `tabindex` is also what keeps the items reachable with -Tab, which keyrove leaves alone. If you would rather the -whole group were a single tab stop, use +Give non-native items `tabindex="0"` so they can receive focus. With the default +bindings, Tab visits each item. For one tab stop per group, use [roving tabindex](/docs/examples/roving-tabindex). -By default the group answers to ↑ and -↓. Add `data-keyrove-next-key` and -`data-keyrove-prev-key` to the root for anything else; see -[custom keys](/docs/examples/custom-keys). Every attribute name is also -exported as a [constant](/docs/api#constants), for markup built in JavaScript. +Lists use Up/Down by default. Set `data-keyrove-next-key` and +`data-keyrove-prev-key` on the root to [change the keys](/docs/examples/custom-keys). +Attribute names are also exported as [constants](/docs/api#constants). -If the markup is not yours to add attributes to, the same settings can be -passed to `keyRove` instead — `keyRove(e, { items: '[role="menuitem"]' })` -navigates a component you did not write. The wiring below is the same either -way; see [attributes and options](/docs/attributes-and-options). +Use options when you cannot add attributes to the markup. For example, +`keyRove(e, { items: '[role="menuitem"]' })` selects items by their role. +See [attributes and options](/docs/attributes-and-options) for the mapping +and fallback rules. ## React @@ -110,10 +106,10 @@ import { keyRove } from '@mixedrays/keyrove'; ## Several groups, one listener -The navigation root is the nearest ancestor carrying `data-keyrove-root`, -falling back to the element the listener is attached to. Mark each group as a -root, and a single delegated listener, on a panel or on `document`, serves any -number of independent groups without them seeing each other's items. +Mark each group with `data-keyrove-root` to use one listener on a shared +panel or `document`. Each event uses the nearest root at or above its target, +falling back to the listener's element when no root is marked. Sibling roots +keep their items and settings separate. ```html
@@ -134,6 +130,5 @@ number of independent groups without them seeing each other's items. document.querySelector('#panel').addEventListener('keydown', (e) => keyRove(e)); ``` -Roots can also sit inside one another, which is how a group that is part of -another group's flow keeps its own keys and columns; see +Roots can also nest. Each inner group uses its own keys and settings; see [nested roots](/docs/examples/nested-roots). diff --git a/packages/keyrove/README.md b/packages/keyrove/README.md index 99b8fa6..4ba3afc 100644 --- a/packages/keyrove/README.md +++ b/packages/keyrove/README.md @@ -12,16 +12,12 @@ attributes or JavaScript options, in any framework. [API reference](https://keyrove.pages.dev/docs/api) · [Examples](https://keyrove.pages.dev/docs/examples/basic) -A group — its items, its keys, its columns — is described in `data-keyrove-*` -attributes where you write the markup, or in an options object where you don't: -every attribute has an option of the same name, and the two mix field by field. +Configure items, keys and layout with attributes or options. Options override +attributes one setting at a time, with +[scope and replacement differences](https://keyrove.pages.dev/docs/attributes-and-options#configuration-differences). -Arrow keys are the default binding, not the whole library: the keys that move -focus are settings like any other, so any -[`KeyboardEvent.code`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code) -can drive a group. And because keyrove moves real DOM focus and only calls -`preventDefault()` on the keys it is bound to, native Tab / -Shift+Tab navigation keeps working alongside it. +keyrove moves DOM focus. Arrow keys are the defaults; bind other keys as needed. +Tab and unbound keys keep their browser behavior. ```sh pnpm add @mixedrays/keyrove @@ -29,56 +25,33 @@ pnpm add @mixedrays/keyrove ## Features -- **Framework-agnostic:** takes DOM events and React, Vue, or Svelte synthetic - events, with no adapter and no dependencies. -- **Attributes or options:** every attribute has an option of the same name, so - a group whose markup you don't own — a component library's menu, a CMS's - output — is described in JavaScript instead: - `keyRove(e, { items: '[role="menuitem"]', loop: true })` navigates a menu - that carries no keyrove attributes at all. Each field falls back to its - attribute on its own, so the two can be mixed. -- **Stateless:** `keyRove` is a plain function of one keydown event — no - instance to mount, nothing to dispose. It reads the group on every press, so - a list that re-renders needs no re-initialising. -- **Configurable key bindings:** every move — next/prev, the grid's row moves, - Home/End and the page jumps — takes any `KeyboardEvent.code`, as a - `data-keyrove-*-key` attribute or under the `keys` option, with exact - modifier combos, platform-aware `mod`, several keys per move - (`ArrowDown, KeyJ`), and `none` to switch a move off. -- **Focus keys:** `data-keyrove-focus-key` (or the `focusKeys` option) gives an - element a combo of its own — `ctrl+shift+KeyE`, or just `KeyE` — that focuses - it from anywhere under the listener: another group, a nested root, even a - text field when the combo holds a modifier. It need not be an item, so a panel - reached by its key stays out of the arrow order. -- **Lists, grids and trees:** arrows, Home/End and - PageUp/PageDown out of the box; `data-keyrove-cols` - folds the items into rows — Up/Down move a whole row, Left/Right move a cell - — and `data-keyrove-loop` wraps a list at its ends. A tree is a list whose - closed folders' rows are skipped, through `data-keyrove-skip` or a `skip` - selector such as `'[hidden] [role="treeitem"]'`, so the arrows walk the rows - on screen; opening and closing folders on →/← is a few - lines of your own, chained after `keyRove` with `||` — see the - [tree view](https://keyrove.pages.dev/docs/examples/tree-view) example. -- **Horizontal and RTL groups:** `data-keyrove-orientation="horizontal"` - re-points a list's defaults at ←/→ — and a grid's - default cell arrows follow the reading direction too, flipped under RTL from - the nearest `dir`. -- **Native focus behavior:** moves real DOM focus and calls `preventDefault()` - only on the keys it is bound to, so unbound keys and - Tab/Shift+Tab are left untouched. -- **Roving tabindex:** `data-keyrove-roving-tabindex` moves the `tabindex="0"` - tab stop with focus, so Tab enters and leaves a group instead of - walking through every item in it. -- **Skippable items:** `data-keyrove-skip` and `disabled` keep headings, - separators, and dead entries in the DOM but out of the navigation order. -- **Nested roots:** `data-keyrove-root` scopes a group and the nearest one - wins, so a single delegated listener can serve a list inside a list. -- **Editable control awareness:** the caret and value keys stay with inputs, - textareas, selects, and `contenteditable` regions — while inputs those keys - do nothing on, like a checkbox or a button, keep navigating. -- **Typeahead:** `createTypeahead()` adds type-to-focus that ignores case and - accents, matching a `label` of your own, `data-keyrove-typeahead`, or the - item's own text — and takes the same options object as `keyRove`. +- **Framework-agnostic:** accepts native and compatible framework events, + including React synthetic events. No runtime dependencies. +- **Attributes or options:** configure markup you control, or select existing + elements with `keyRove(e, { items: '[role="menuitem"]' })`. +- **Current DOM:** `keyRove` reads items and settings on every call, with no + navigation instance to update. Roving groups may need their tab stop repaired + after rendering; see [Tab still works](#tab-still-works). +- **Configurable keys:** bind moves to physical key codes and exact modifier + combinations, use platform-aware `mod`, list several combos per move, or + disable a binding with `none`. +- **Focus shortcuts:** focus an item or panel directly, across roots under a + shared listener. Ctrl/Alt/Meta focus shortcuts also work in editable fields. +- **Lists and grids:** move by item, row, ends or pages. Lists can loop; + grids keep their boundaries. +- **Trees:** navigate visible rows while your widget expands and collapses + branches. See the [tree example](https://keyrove.pages.dev/docs/examples/tree-view). +- **Horizontal and RTL navigation:** default horizontal arrows follow text + direction; explicit bindings remain literal. +- **Roving tabindex:** give a group one tab stop and move it with focus. +- **Skipped items:** pass over headings or unavailable cells while preserving + their positions. Disabled elements are removed from the item sequence. +- **Nested roots:** each root uses its own movement bindings under one + delegated listener. +- **Editable fields:** movement bindings leave text fields, selects and editable + content their native keys. Checkbox and button inputs still navigate. +- **Typeahead:** find items by label with case and accent handling, prefix + matching or repeated-character cycling. ## Usage @@ -99,8 +72,8 @@ import { keyRove } from '@mixedrays/keyrove'; document.querySelector('#menu').addEventListener('keydown', (e) => keyRove(e)); ``` -Where the markup is not yours to change, name the same settings in the call -instead. This is the same list, with nothing on its items but `tabindex="0"`: +To configure the same list without item attributes, pass an `items` selector. +Keep `tabindex="0"` on the list items: ```ts document @@ -108,11 +81,10 @@ document .addEventListener('keydown', (e) => keyRove(e, { items: 'li' })); ``` -Each setting falls back to its attribute on its own, so the two mix freely — -see [Attributes and options](#attributes-and-options). +See [Attributes and options](#attributes-and-options) for fallback rules. -`keyRove` accepts anything shaped like a keydown event, so React, Vue and -Svelte synthetic events work without an adapter: +`keyRove` accepts native keyboard events and compatible framework events, +including React's synthetic events: ```tsx
    keyRove(e)}> @@ -138,9 +110,9 @@ for a toolbar: keyRove(e, { keys: { next: 'KeyJ', prev: 'KeyK' } }); ``` -Every `data-keyrove-*-key` attribute below is a field of `keys` named after its -move — `data-keyrove-next-row-key` is `keys.nextRow` — and `keys` is read move -by move, so a move it leaves out keeps its attribute and then its default. +Move attributes map to fields in `keys`: for example, +`data-keyrove-next-row-key` maps to `keys.nextRow`. Omitted or empty bindings +fall back to the corresponding attribute, then the default. A binding replaces the default. To add a key rather than swap one, list several combos, comma-separated, and the move answers to any of them: @@ -180,20 +152,13 @@ bindings hold across keyboard layouts. The matcher is exported as
``` -Next and prev always mean one item through the DOM order. Declare -`data-keyrove-cols` and the same items fold into rows: `next-key`/`prev-key` -keep moving one item — a _cell_ there, on the reading-direction arrows by -default — while `data-keyrove-next-row-key`/`data-keyrove-prev-row-key` move a -whole row, defaulting to `ArrowDown`/`ArrowUp`. - -Anything not bound is left entirely alone, browser defaults included. `Home`, -`End`, `PageUp` and `PageDown` are defaults like the arrows — -`data-keyrove-home-key`, `data-keyrove-end-key`, `data-keyrove-page-up-key` and -`data-keyrove-page-down-key` rebind them — and whatever they are bound to they -act only once focus is already inside an item: they move within a group, never -into one. In a grid, bare `Home`/`End` jump to the ends of the focused row -(`data-keyrove-home-row-key`/`data-keyrove-end-row-key`) and -`ctrl+Home`/`ctrl+End` to the grid's first and last cell. +Next and prev move one item in DOM order. With `data-keyrove-cols` above 1, +items form a grid: next/prev move one cell on the reading-direction arrows, +and next-row/prev-row move one row on Down/Up. + +Home, End, PageUp and PageDown can also be rebound. They act only when focus +is already inside an item. In grids, Home/End move to row ends and +Ctrl+Home/End move to grid ends. Unbound keys keep their browser behavior. A move can also be switched off. `none` binds it to no key and hands its default back to the browser, so a toolbar, which has no page moves, leaves @@ -214,19 +179,17 @@ At the ends of a list the bound keys are consumed but focus stays put. Add `data-keyrove-loop` on the root and next on the last item wraps to the first, and vice versa. Grids keep their edges — they never wrap. -Keys pressed inside an editable element — `textarea`, `select`, -`[contenteditable]`, or an `input` whose keys act natively (text entry, -`number`, `range`, `radio`, …) — are never handled: arrows and `Home`/`End` -keep moving the caret or value, and a letter binding like `KeyJ` does not -swallow typing into a field that sits within an item. Inputs where those keys -are inert — a `checkbox`, a `button` — still navigate. +Movement bindings do not run in textareas, selects, editable content, or +inputs with native editing keys, including text, number, range and radio. +Checkbox and button inputs still navigate. See +[editable targets](https://keyrove.pages.dev/docs/examples/editable-targets) +for the full list and focus-shortcut exception. ## Focus keys -Every move above is relative to where focus is. `data-keyrove-focus-key` is the -absolute kind: the combo focuses its element from anywhere the keydown reaches -the listener — a sibling group, a nested root, or, when the combo holds -Ctrl/Alt/Meta, a text field. +Set `data-keyrove-focus-key` on an element to focus it from anywhere under +the listener. Ctrl/Alt/Meta shortcuts also work inside editable fields, except +during input-method composition. ```html
@@ -247,20 +210,22 @@ the listener — a sibling group, a nested root, or, when the combo holds
``` -The element need not be an item. An item stays in its group's arrow order, and -the jump carries the roving tab stop like any move. Any other element — the -panels above — is reached by its key alone, from outside any group: `from` is -`null` and no tab stop moves. Each panel is a root, so an arrow pressed on it -enters its own items rather than the first item under the listener. +An item with a focus key remains in its group's arrow order. A move from a +roving item also carries the tab stop; entry from outside the group needs +`followFocus` to update it. A non-item destination, such as a panel, reports +`from: null` when reached and does not move a group's tab stop. + +Each panel above is its inner list's root, so an arrow after the jump enters +that panel's items. Its `tabindex="-1"` allows focus without adding a Tab stop. +A target that cannot receive focus produces a consumed no-op. -The listener's placement is the reach — on `document`, the keys are page-wide. -A focus key sits ahead of the root's bindings and the defaults, so it wins any -collision; two elements naming one combo resolve to the first in DOM order; a -skipped or disabled element's key is inert. The move reports `'focus'`. +Focus keys take precedence over movement bindings. Attribute shortcuts resolve +ties in DOM order and exclude skipped and disabled targets. On `document`, +shortcuts work page-wide. The action is `'focus'`. -In JavaScript, `focusKeys` maps each combo to an element, or to a selector -resolved within the listener's reach. A map replaces the attribute scan rather -than adding to it, so one declaration answers for the whole listener: +`focusKeys` maps combos to elements or selectors under the listener. A supplied +map replaces the entire attribute scan, even when empty. Explicit targets +bypass skip checks, but disabled targets are still excluded: ```ts panels.addEventListener('keydown', (e) => @@ -272,19 +237,19 @@ panels.addEventListener('keydown', (e) => ## Tab still works -keyrove moves focus with `element.focus()` and never touches Tab, so -sequential focus navigation is unaffected. Items with `tabindex="0"` stay -ordinary tab stops that arrows _also_ reach. Opt into -`data-keyrove-roving-tabindex`, or `rovingTabindex: true`, when a group should -instead be a single tab stop that Tab moves past rather than -through. +With the default bindings, Tab and Shift+Tab keep their browser behavior. +Items with `tabindex="0"` are individual tab stops. For one stop per group, +mark every item with `data-keyrove-roving-tabindex`, or pass +`rovingTabindex: true`. + +Set one navigable item's tabindex to `0` and the others to `-1`, or call +`initRovingTabindex` after rendering. Repeat after renders that may replace +items. It preserves the first existing navigable stop, otherwise gives the +first navigable roving item the stop. Nested roots keep their own stops. -keyrove moves that stop but never creates it. Call `initRovingTabindex(list)` -once the group renders, and again after re-renders: it gives the group exactly -one `tabindex="0"`, keeping the one it has while that item is still there, and -leaves nested groups' stops alone. It takes `items`, `root`, `skip` and -`rovingTabindex`, the same as `keyRove` and `createTypeahead`, and an `initial` -item that takes the stop, such as a listbox's selected option: +The helper shares `items`, `root`, `skip` and `rovingTabindex` with the other +handlers. Pass `initial` to override the stop for first setup or an intentional +selection change; omit it during routine render updates: ```ts initRovingTabindex(listbox, { @@ -292,15 +257,17 @@ initRovingTabindex(listbox, { }); ``` -keyRove carries the stop on its own moves only. For a click, a call to -`element.focus()`, or Tab onto a control inside an item, attach -`followFocus` to `focusin` and the stop follows focus there too: +Attach `followFocus` to `focusin` so the stop also follows clicks, +programmatic focus and entry from outside the group: ```ts list.addEventListener('keydown', (e) => keyRove(e)); list.addEventListener('focusin', (e) => followFocus(e)); ``` +See [roving tabindex](https://keyrove.pages.dev/docs/examples/roving-tabindex) +for initialization and focus tracking. + ## Typeahead `createTypeahead` adds type-to-focus: printable characters accumulate in a @@ -316,23 +283,25 @@ const typeahead = createTypeahead(); // { resetMs?, matchMode?, label?, foldDiac list.addEventListener('keydown', (e) => keyRove(e) || typeahead(e)); ``` -The buffer is state, which `keyRove` itself never holds, so create one handler -per listener and chain it after `keyRove`: bound keys win, and a `KeyJ` binding -keeps navigating instead of entering the buffer. The label is what a `label` -option returns, falling back to the item's `data-keyrove-typeahead` attribute -and then its trimmed text. Matching -reads `e.key` — the typed character — unlike key bindings, which stay on the -physical `e.code`. Typing inside editable elements is never captured, modified -presses (Ctrl/Alt/Meta) are left to their shortcuts, and a space only counts -once a match is underway. The handler returns -`{ action: 'typeahead', from, to }` or `null`, the same contract as `keyRove`, -and its `onMove` fires after a real move exactly as `keyRove`'s does, so both -handlers can feed the same follow-focus logic. - -`createTypeahead` also 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 one object to both -handlers, and they cannot disagree about what an item is: +Create the typeahead handler once per listener and call it after `keyRove`. +Navigation bindings then take precedence over typing. + +Labels come from `label(item)`, then `data-keyrove-typeahead`, then the item's +text. Empty values fall through; text content is trimmed and whitespace is +collapsed. Matching uses the typed character (`e.key`), while navigation +bindings use the physical code (`e.code`). + +Typing inside editable fields and Ctrl/Alt/Meta combinations are ignored. +Space joins the buffer only after another character. An unmatched character +stays in the buffer but leaves its browser behavior unchanged. + +The result is `null` for an unhandled key or `{ action: 'typeahead', from, to }` +for a consumed one. `to: null` means focus did not move, including when the +match was already focused or could not receive focus. `onMove` runs only after +a successful move. + +Share `items`, `root`, `skip` and `rovingTabindex` with `keyRove` so both +handlers use the same group rules: ```ts const config = { items: '[role="menuitem"]', loop: true, rovingTabindex: true }; @@ -350,25 +319,22 @@ resets still refines the prefix. ## Attributes and options -Every setting can be written in two places: as an attribute in the markup, or -as an option in the call. Both are read on every keypress, one field at a time, -options first — a field the options object leaves out falls back to its -attribute, and then to its default. A call with no options reads exactly the -markup, and mixing the two is ordinary rather than a halfway state: +Options override attributes one setting at a time. Omitted options fall back +to attributes, then defaults: ```ts -keyRove(e); // everything from the markup -keyRove(e, { loop: true }); // items from the markup, looping from here -keyRove(e, { items: '[role="menuitem"]', loop: true }); // nothing from the markup +keyRove(e); // attributes and defaults +keyRove(e, { loop: true }); // override looping only +keyRove(e, { items: '[role="menuitem"]', loop: true }); // override these two settings ``` -Reach for attributes where you write the HTML: the group is described where it -is built, so a list becomes a grid by gaining an attribute, and one delegated -listener serves any number of groups that describe themselves. Reach for -options where you don't — a component library's menu, a CMS's output — and for -settings you compute: an object built at the call site is as live as an -attribute, so `keyRove(e, { cols: columnsNow() })` re-folds the grid between -presses. +Use attributes to keep settings beside markup you control. Use options for +existing markup or computed values, such as `{ cols: columnsNow() }`. + +`keys` falls back per action; `focusKeys` replaces the whole shortcut scan. +Some options also have different scope: `rovingTabindex` applies to a group, +while its attribute applies per item. See +[configuration differences](https://keyrove.pages.dev/docs/attributes-and-options#configuration-differences). | Attribute | Option | On | Default | Meaning | | ------------------------------ | ---------------- | ---- | ----------- | --------------------------------------------------------------------------------------------------------------------------------- | @@ -402,6 +368,10 @@ 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. +When every item is skipped, some navigation moves currently fall back to the +first or last item. See [edge behavior](https://keyrove.pages.dev/docs/api#edges-and-looping) +for the exceptions and how to prevent those moves. + The next/prev defaults follow the group's axis: `ArrowDown`/`ArrowUp` in a vertical list, the reading-direction arrows in a horizontal list or a grid. Every `*-key` attribute and `keys` field also takes `none`, which switches its @@ -418,13 +388,13 @@ const result = keyRove(e, { }); ``` -`onMove` sits in the same object as the settings. It fires after focus has -moved, and only when it actually moved: a consumed key with nowhere to go — the -end of a list, the edge of a grid — fires nothing. `action` names the move: -`'next' | 'prev' | 'home' | 'end' | 'pageUp' | 'pageDown'`, the grid-only -`'nextRow' | 'prevRow' | 'homeRow' | 'endRow'`, and `'focus'` for a focus key. `from` is the item focus left -(`null` when the group was entered from outside, or for a focus key on an -element that is not an item) and `to` the element it landed on. +`onMove` runs only after focus moves successfully. Consumed keys at an edge, +or targets that cannot take focus, do not trigger it. + +`action` names the move: `'next'`, `'prev'`, `'home'`, `'end'`, `'pageUp'`, +`'pageDown'`; the grid actions `'nextRow'`, `'prevRow'`, `'homeRow'`, `'endRow'`; +or `'focus'` for a shortcut. `from` is the previous item, or `null` when +entering from outside or jumping to a non-item. `to` is the destination. `keyRove` returns `null` when it left the key untouched, and `{ action, from, to }` when it consumed it — with `to: null` for a consumed From 9a20785ac4d05fe88026af9f9498f7626cb86e4d Mon Sep 17 00:00:00 2001 From: mixedrays Date: Tue, 22 Sep 2026 12:41:43 +0200 Subject: [PATCH 13/26] feat: add rove to make a move from code without a keypress --- packages/docs/content/docs/api.md | 45 ++ .../content/docs/attributes-and-options.md | 4 + packages/docs/content/docs/introduction.md | 1 + packages/keyrove/README.md | 19 +- .../keyrove/src/__tests__/rove/rove.test.ts | 416 ++++++++++++++++++ packages/keyrove/src/config.ts | 9 +- packages/keyrove/src/group.ts | 5 +- packages/keyrove/src/index.ts | 1 + packages/keyrove/src/keyRove.ts | 6 +- packages/keyrove/src/rove.ts | 113 +++++ packages/keyrove/src/types.ts | 6 +- 11 files changed, 611 insertions(+), 14 deletions(-) create mode 100644 packages/keyrove/src/__tests__/rove/rove.test.ts create mode 100644 packages/keyrove/src/rove.ts diff --git a/packages/docs/content/docs/api.md b/packages/docs/content/docs/api.md index 28bc9f6..42e2fc8 100644 --- a/packages/docs/content/docs/api.md +++ b/packages/docs/content/docs/api.md @@ -9,6 +9,7 @@ order: 4 | ------------------------------------------------------------------------ | ------------------------------------------------- | | [`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. | @@ -350,6 +351,50 @@ element.addEventListener('keydown', (e) => keyRove(e) || myOwnHandler(e)); The [listbox](/docs/examples/listbox) chains three handlers this way. +## 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. diff --git a/packages/docs/content/docs/attributes-and-options.md b/packages/docs/content/docs/attributes-and-options.md index b45f015..271c115 100644 --- a/packages/docs/content/docs/attributes-and-options.md +++ b/packages/docs/content/docs/attributes-and-options.md @@ -119,5 +119,9 @@ attribute fallbacks still read the current DOM. [`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) shows a complete menu configured this way. diff --git a/packages/docs/content/docs/introduction.md b/packages/docs/content/docs/introduction.md index 36fddcd..e7adeee 100644 --- a/packages/docs/content/docs/introduction.md +++ b/packages/docs/content/docs/introduction.md @@ -67,6 +67,7 @@ items or adding a column count requires no navigation instance to update. | Focus shortcuts | Focus an item or panel from anywhere under the listener | [Focus keys](/docs/examples/focus-keys) | | Typeahead | Add `createTypeahead()` to focus items by typing their labels | [Typeahead](/docs/examples/typeahead) | | Trees | Navigate visible rows; your handlers expand and collapse branches | [Tree view](/docs/examples/tree-view) | +| Moves from code | Call `rove(list, 'next')` from a button, gamepad or remote | [`rove`](/docs/api#rove-element-action-options) | Text fields, selects and editable content keep their editing keys. See [editable targets](/docs/examples/editable-targets) for the rules and exceptions. diff --git a/packages/keyrove/README.md b/packages/keyrove/README.md index 4ba3afc..c7c1d60 100644 --- a/packages/keyrove/README.md +++ b/packages/keyrove/README.md @@ -52,6 +52,8 @@ pnpm add @mixedrays/keyrove content their native keys. Checkbox and button inputs still navigate. - **Typeahead:** find items by label with case and accent handling, prefix matching or repeated-character cycling. +- **Moves from code:** run any move from a button, gamepad or remote with + `rove(list, 'next')`; see [Moves without a keypress](#moves-without-a-keypress). ## Usage @@ -359,7 +361,8 @@ while its attribute applies per item. See | `data-keyrove-orientation` | `orientation` | root | — | `horizontal` maps a list's default keys to `ArrowRight`/`ArrowLeft`, RTL-aware. | | `data-keyrove-typeahead` | `label` | item | text | Label for type-to-focus, when the item's own text is not it. As an option: `(item) => string`. | -`keyRove` takes every option but `label`. `createTypeahead` takes `items`, +`keyRove` takes every option but `label`. `rove` takes the same options and +ignores `keys` and `focusKeys`. `createTypeahead` takes `items`, `root`, `skip`, `rovingTabindex` and `label` — the settings that bear on finding an item. `initRovingTabindex` and `followFocus` take the same four without `label`. @@ -410,6 +413,20 @@ element's tab stop yourself. For a whole roving group, `initRovingTabindex(root, options?)` keeps exactly one stop, and `followFocus(event, options?)` moves it with focus keyRove did not move. +## Moves without a keypress + +`rove(element, action, options?)` moves focus by action name. Use it for +on-screen buttons, gamepads and remotes. Key bindings do not affect it, and it +returns the same result as `keyRove`: + +```ts +nextButton.addEventListener('click', () => rove(results, 'next')); +``` + +The move starts from the focused item. If focus is elsewhere, such as on the +button, a roving group starts from its tab stop. Otherwise, `next` enters at +the first item and `prev` at the last. + ## License MIT diff --git a/packages/keyrove/src/__tests__/rove/rove.test.ts b/packages/keyrove/src/__tests__/rove/rove.test.ts new file mode 100644 index 0000000..5969a7b --- /dev/null +++ b/packages/keyrove/src/__tests__/rove/rove.test.ts @@ -0,0 +1,416 @@ +import { describe, expect, it, afterEach } from 'vitest'; +import { + keyRove, + rove, + KEYROVE_ATTR_COLS, + KEYROVE_ATTR_ITEM, + KEYROVE_ATTR_NEXT_KEY, + KEYROVE_ATTR_PAGE_LENGTH, + KEYROVE_ATTR_ROOT, + KEYROVE_ATTR_ROVING_TABINDEX, + KEYROVE_ATTR_SKIP, +} from '../../index'; +import type { Move, MoveResult, Options, StrideAction } from '../../index'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +type Spec = { + tabindex?: string; + roving?: boolean; + skip?: boolean; + disabled?: boolean; +}; + +/** An item: a roving one at `tabindex="-1"`, unless the spec says otherwise. */ +const item = ( + id: string, + { tabindex = '-1', roving = true, skip, disabled }: Spec = {}, +) => { + const el = document.createElement('button'); + el.id = id; + el.setAttribute(KEYROVE_ATTR_ITEM, 'true'); + el.setAttribute('tabindex', tabindex); + if (roving) el.setAttribute(KEYROVE_ATTR_ROVING_TABINDEX, 'true'); + if (skip) el.setAttribute(KEYROVE_ATTR_SKIP, 'true'); + if (disabled) el.setAttribute('disabled', ''); + + return el; +}; + +const group = (children: Element[], attrs: Record = {}) => { + const root = document.createElement('div'); + for (const [name, value] of Object.entries(attrs)) { + root.setAttribute(name, value); + } + root.append(...children); + document.body.appendChild(root); + + return root; +}; + +const nestedRoot = (...children: Element[]) => { + const root = document.createElement('div'); + root.setAttribute(KEYROVE_ATTR_ROOT, ''); + root.append(...children); + + return root; +}; + +/** A button outside every group, standing for the one that asks for a move. */ +const outsideButton = () => { + const button = document.createElement('button'); + button.id = 'outside'; + document.body.appendChild(button); + button.focus(); + + return button; +}; + +const byId = (id: string) => document.getElementById(id)!; + +const activeId = () => document.activeElement?.id; + +/** Every item's `tabindex`, by id. */ +const tabindexes = (root: Element) => + Object.fromEntries( + Array.from(root.querySelectorAll(`[${KEYROVE_ATTR_ITEM}]`)).map((el) => [ + el.id, + el.getAttribute('tabindex'), + ]), + ); + +/** A result with its elements named by id, so it reads in a failure. */ +const named = (result: MoveResult | null) => + result && { + action: result.action, + from: result.from?.id ?? null, + to: result.to?.id ?? null, + }; + +type Key = [code: string, modifiers?: KeyboardEventInit]; + +/** + * A group wired to keyRove, with a way to make one move from a given item by + * its default key or by `rove`, each from the same starting state: focus and + * the stop on that item. Each run reports the result, where focus went, the + * stop, and what `onMove` saw. + */ +const twin = (children: Element[], attrs: Record = {}) => { + const root = group(children, attrs); + const moves: Move[] = []; + const options: Options = { onMove: (move) => moves.push(move) }; + let keyed: MoveResult | null = null; + root.addEventListener('keydown', (e) => { + keyed = keyRove(e, options); + }); + + const run = (start: string, move: () => MoveResult | null) => { + for (const el of root.querySelectorAll(`[${KEYROVE_ATTR_ITEM}]`)) { + el.setAttribute('tabindex', el.id === start ? '0' : '-1'); + } + byId(start).focus(); + moves.length = 0; + const result = move(); + + return { + result: named(result), + active: activeId(), + tabindexes: tabindexes(root), + moves: moves.map(named), + }; + }; + + return { + root, + byKey: (start: string, [code, modifiers]: Key) => + run(start, () => { + keyed = null; + document.activeElement!.dispatchEvent( + new KeyboardEvent('keydown', { + code, + bubbles: true, + cancelable: true, + ...modifiers, + }), + ); + + return keyed; + }), + byRove: (start: string, action: StrideAction) => + run(start, () => rove(root, action, options)), + }; +}; + +const LIST_KEYS: [StrideAction, Key][] = [ + ['next', ['ArrowDown']], + ['prev', ['ArrowUp']], + ['home', ['Home']], + ['end', ['End']], + ['pageUp', ['PageUp']], + ['pageDown', ['PageDown']], +]; + +const GRID_KEYS: [StrideAction, Key][] = [ + ['next', ['ArrowRight']], + ['prev', ['ArrowLeft']], + ['nextRow', ['ArrowDown']], + ['prevRow', ['ArrowUp']], + ['homeRow', ['Home']], + ['endRow', ['End']], + ['home', ['Home', { ctrlKey: true }]], + ['end', ['End', { ctrlKey: true }]], + ['pageUp', ['PageUp']], + ['pageDown', ['PageDown']], +]; + +describe('rove', () => { + describe('from a focused item, as its key does', () => { + it('makes every list move exactly as the key does, from every item', () => { + const ids = ['a', 'b', 'c', 'd', 'e', 'f']; + const { byKey, byRove } = twin( + ids.map((id) => item(id, { skip: id === 'a' || id === 'd' })), + { [KEYROVE_ATTR_PAGE_LENGTH]: '2' }, + ); + + for (const start of ids) { + for (const [action, key] of LIST_KEYS) { + expect(byRove(start, action), `${action} from ${start}`).toEqual( + byKey(start, key), + ); + } + } + }); + + it('makes every grid move exactly as the key does, from every cell', () => { + const ids = Array.from({ length: 9 }, (_, i) => `c${i}`); + const { byKey, byRove } = twin( + ids.map((id) => item(id, { skip: id === 'c4' })), + { [KEYROVE_ATTR_COLS]: '3', [KEYROVE_ATTR_PAGE_LENGTH]: '1' }, + ); + + for (const start of ids) { + for (const [action, key] of GRID_KEYS) { + expect(byRove(start, action), `${action} from ${start}`).toEqual( + byKey(start, key), + ); + } + } + }); + + it('carries the roving stop and reports the move', () => { + const root = group([item('a', { tabindex: '0' }), item('b'), item('c')]); + byId('a').focus(); + const moves: Move[] = []; + + const result = rove(root, 'end', { onMove: (move) => moves.push(move) }); + + expect(named(result)).toEqual({ action: 'end', from: 'a', to: 'c' }); + expect(moves.map(named)).toEqual([named(result)]); + expect(activeId()).toBe('c'); + expect(tabindexes(root)).toEqual({ a: '-1', b: '-1', c: '0' }); + }); + + it('reports a move with nowhere to go as to: null, and fires no onMove', () => { + const root = group([item('a'), item('b', { tabindex: '0' })]); + byId('b').focus(); + const moves: Move[] = []; + + const result = rove(root, 'next', { onMove: (move) => moves.push(move) }); + + expect(named(result)).toEqual({ action: 'next', from: 'b', to: null }); + expect(moves).toEqual([]); + expect(activeId()).toBe('b'); + }); + }); + + describe('keys have no say', () => { + it('moves next and prev whatever the attribute or keys option binds', () => { + const root = group([item('a'), item('b', { tabindex: '0' }), item('c')], { + [KEYROVE_ATTR_NEXT_KEY]: 'KeyJ', + }); + byId('b').focus(); + const options: Options = { keys: { prev: 'none' } }; + + expect(named(rove(root, 'next', options))?.to).toBe('c'); + expect(named(rove(root, 'prev', options))?.to).toBe('b'); + }); + }); + + describe('group settings', () => { + it('reads a group described in options, with no attributes at all', () => { + const root = group([]); + root.innerHTML = ['a', 'sep', 'b', 'c'] + .map( + (id) => + `
`, + ) + .join(''); + byId('c').focus(); + const options: Options = { + items: '[role="option"]', + skip: '.sep', + loop: true, + rovingTabindex: true, + }; + + expect(named(rove(root, 'next', options))).toEqual({ + action: 'next', + from: 'c', + to: 'a', + }); + expect(named(rove(root, 'next', options))?.to).toBe('b'); + expect( + Object.fromEntries( + ['a', 'sep', 'b', 'c'].map((id) => [ + id, + byId(id).getAttribute('tabindex'), + ]), + ), + ).toEqual({ a: '-1', sep: '-1', b: '0', c: '-1' }); + }); + + it('takes cols and pageLength from options', () => { + const ids = ['a', 'b', 'c', 'd', 'e', 'f']; + const root = group(ids.map((id) => item(id))); + byId('a').focus(); + + expect(named(rove(root, 'nextRow', { cols: 2 }))?.to).toBe('c'); + expect(named(rove(root, 'pageDown', { pageLength: 3 }))?.to).toBe('f'); + }); + + it('does nothing with a row move in a list, where it has no key either', () => { + const root = group([item('a', { tabindex: '0' }), item('b')]); + byId('a').focus(); + + for (const action of [ + 'nextRow', + 'prevRow', + 'homeRow', + 'endRow', + ] as const) { + expect(rove(root, action)).toBeNull(); + } + expect(activeId()).toBe('a'); + }); + + it('resolves the root above an element inside it, as a keypress does', () => { + const inner = nestedRoot(item('a', { tabindex: '0' }), item('b')); + group([item('o0'), inner, item('o1')]); + byId('a').focus(); + + expect(named(rove(byId('a'), 'end'))).toEqual({ + action: 'end', + from: 'a', + to: 'b', + }); + }); + }); + + describe('with no item focused', () => { + it("starts from a roving group's stop, where the user left off", () => { + const root = group([ + item('a'), + item('b', { tabindex: '0' }), + item('c'), + item('d'), + ]); + outsideButton(); + + expect(named(rove(root, 'next'))).toEqual({ + action: 'next', + from: 'b', + to: 'c', + }); + expect(tabindexes(root)).toEqual({ a: '-1', b: '-1', c: '0', d: '-1' }); + + // The button takes focus back, and the next press goes on from c. + outsideButton(); + expect(named(rove(root, 'next'))?.to).toBe('d'); + }); + + it.each([ + ['home', 'a'], + ['next', 'a'], + ['pageDown', 'a'], + ['end', 'c'], + ['prev', 'c'], + ['pageUp', 'c'], + ] as const)( + '%s enters a group with no position at its %s end', + (action, to) => { + const root = group( + [ + item('first', { tabindex: '0', roving: false, skip: true }), + item('a', { tabindex: '0', roving: false }), + item('b', { tabindex: '0', roving: false }), + item('c', { tabindex: '0', roving: false }), + item('last', { tabindex: '0', roving: false, skip: true }), + ], + { [KEYROVE_ATTR_PAGE_LENGTH]: '1' }, + ); + outsideButton(); + + expect(named(rove(root, action))).toEqual({ action, from: null, to }); + expect(activeId()).toBe(to); + }, + ); + + it('enters a grid by its row moves, and not by its row ends', () => { + const root = group( + ['a', 'b', 'c', 'd'].map((id) => item(id, { roving: false })), + { [KEYROVE_ATTR_COLS]: '2' }, + ); + outsideButton(); + + expect(rove(root, 'homeRow')).toBeNull(); + expect(rove(root, 'endRow')).toBeNull(); + expect(activeId()).toBe('outside'); + expect(named(rove(root, 'prevRow'))?.to).toBe('d'); + outsideButton(); + expect(named(rove(root, 'nextRow'))?.to).toBe('a'); + }); + + it('enters rather than starting from a stop on a disabled item', () => { + const root = group([ + item('a'), + item('b', { tabindex: '0', disabled: true }), + item('c'), + ]); + outsideButton(); + + expect(named(rove(root, 'next'))).toEqual({ + action: 'next', + from: null, + to: 'a', + }); + }); + + it("never starts from a nested group's stop", () => { + const root = group([ + item('o0', { tabindex: '0', roving: false }), + nestedRoot(item('n0', { tabindex: '0' }), item('n1')), + item('o1', { tabindex: '0', roving: false }), + ]); + outsideButton(); + + expect(named(rove(root, 'next'))).toEqual({ + action: 'next', + from: null, + to: 'o0', + }); + }); + + it('does nothing in an empty group, or without an element', () => { + const root = group([]); + outsideButton(); + + expect(rove(root, 'next')).toBeNull(); + expect(rove(root, 'end')).toBeNull(); + expect(rove(null, 'next')).toBeNull(); + expect(rove(undefined, 'home')).toBeNull(); + expect(activeId()).toBe('outside'); + }); + }); +}); diff --git a/packages/keyrove/src/config.ts b/packages/keyrove/src/config.ts index 9473abd..d8f1759 100644 --- a/packages/keyrove/src/config.ts +++ b/packages/keyrove/src/config.ts @@ -183,7 +183,7 @@ const readExplicitBinding = * declared as it is on the element beside the key; an element a map names is * named outright, and the group's `skip` has no say over it. */ -const readFocusKeys = ( +export const readFocusKeys = ( scope: Element, { focusKeys }: GroupOptions, ): FocusKey[] => { @@ -230,17 +230,16 @@ export const rovingTest = ({ rovingTabindex }: GroupOptions): IsRoving => rovingTabindex === undefined ? attributeRoving : () => rovingTabindex; /** - * Every setting one keypress needs, read once for the root it resolved in. - * `scope` is the listener's reach, which only the focus keys span. + * Every setting one move needs, read once for the root it resolved in. The + * focus keys are read apart, by {@link readFocusKeys}: they span the + * listener's reach rather than the root, and only a keypress looks them up. */ export const readConfig = ( root: Element, - scope: Element, options: GroupOptions, ): GroupConfig => ({ layout: readLayout(root, options), explicit: readExplicitBinding(root, options), - focus: readFocusKeys(scope, options), // Resolved on demand: read only when an unbound `next`/`prev` default on a // horizontal axis could flip, never otherwise. rtl: () => isRtl(root), diff --git a/packages/keyrove/src/group.ts b/packages/keyrove/src/group.ts index f238157..b2c9dbf 100644 --- a/packages/keyrove/src/group.ts +++ b/packages/keyrove/src/group.ts @@ -205,7 +205,8 @@ const carryStop = (from: Element, to: Element) => { * * Call it only once a handler has decided the press is its own: * `preventDefault` is unconditional here, because the group owns its keys up - * to its own boundary and the page must not scroll instead. A missing `to`, or + * to its own boundary and the page must not scroll instead. A move made from + * code passes no event, and has no key to claim. A missing `to`, or * one that is the focused item already, is a consumed no-op — focus and the * tab stop stay put, `onMove` stays quiet, and the result carries `to: null`. * Otherwise the roving tab stop follows when `isRoving` accepts the item being @@ -223,7 +224,7 @@ export const moveFocus = ({ isRoving = attributeRoving, onMove, }: MoveFocusArgs): ActionResult => { - e.preventDefault(); + e?.preventDefault(); if (!to || to === from) return { action, from, to: null }; diff --git a/packages/keyrove/src/index.ts b/packages/keyrove/src/index.ts index ea8fa47..3d35f82 100644 --- a/packages/keyrove/src/index.ts +++ b/packages/keyrove/src/index.ts @@ -2,6 +2,7 @@ export * from './keyRove.js'; export * from './createTypeahead.js'; export { followFocus } from './followFocus.js'; export { initRovingTabindex } from './initRovingTabindex.js'; +export { rove } from './rove.js'; export { matchesCombo, toggleTabIndex } from './utils.js'; // Named rather than `export *`, so the internal types in `types.ts` stay // internal and the public surface is visible at a glance. diff --git a/packages/keyrove/src/keyRove.ts b/packages/keyrove/src/keyRove.ts index c1146f8..8fb0313 100644 --- a/packages/keyrove/src/keyRove.ts +++ b/packages/keyrove/src/keyRove.ts @@ -1,5 +1,5 @@ import { buildBindings } from './bindings.js'; -import { readConfig, rootTest } from './config.js'; +import { readConfig, readFocusKeys, rootTest } from './config.js'; import { holdsFocus, listenerElement, @@ -59,7 +59,7 @@ export const keyRove = ( // groups and out of nested roots — so its lookup spans the listener's // element, not the root. const scope = listenerElement(e.currentTarget) ?? root; - const config = readConfig(root, scope, options); + const config = readConfig(root, options); const { onMove } = options; // First match wins: one keypress resolves to at most one action, and the @@ -67,7 +67,7 @@ export const keyRove = ( // bindings over the defaults. const binding = buildBindings({ explicit: config.explicit, - focus: config.focus, + focus: readFocusKeys(scope, options), layout: config.layout, rtl: config.rtl, }).find(({ combo }) => matchesCombo(e, combo)); diff --git a/packages/keyrove/src/rove.ts b/packages/keyrove/src/rove.ts new file mode 100644 index 0000000..77325c2 --- /dev/null +++ b/packages/keyrove/src/rove.ts @@ -0,0 +1,113 @@ +/** + * A move made from code rather than from a key. + * + * On-screen buttons, gamepads, remotes and voice ask for a move by name — + * "next", "end" — and a faked keydown would tie that to whichever key the + * group has bound. `rove` names the move outright and skips the binding table; + * from there on it runs the layers a keypress runs, so the group's items, + * skips, layout and roving stop apply exactly as they do for keys. + */ + +import { readConfig, rootTest } from './config.js'; +import { + attributeRoot, + moveFocus, + ownItems, + readGroup, + resolveRoot, +} from './group.js'; +import { resolveTarget } from './position.js'; +import type { MoveResult, Options, StrideAction } from './types.js'; + +// Where each move enters a group it has no position in: a forward move at the +// first item, as if from before it, and a backward one at the last, as if +// from past it. A row end has no row to go by. +const ENTRY: Record = { + home: 'home', + next: 'home', + nextRow: 'home', + pageDown: 'home', + end: 'end', + prev: 'end', + prevRow: 'end', + pageUp: 'end', + homeRow: null, + endRow: null, +}; + +/** + * Makes a move in a group, as its key would, without a keypress. + * + * The group is found the way a keypress finds it, with `element` as both the + * target and the listener: the nearest root at or above it, else `element` + * itself. The move goes from the item holding focus, as a key's does. With + * focus elsewhere — on the button that asked for the move, as often as not — + * a roving group still has a position: the item holding its tab stop, where + * the user left off. Failing both, the move enters the group: `home`, `next`, + * `nextRow` and `pageDown` at the first navigable item, `end`, `prev`, + * `prevRow` and `pageUp` at the last, and `homeRow` and `endRow`, which have + * no row to go by, not at all. + * + * The row moves are a grid's own, as they are in the key table, so in a list + * they do nothing. + * @param element - The group's root, or an element inside it. Nullish is a + * no-op. + * @param action - The move to make, by name. Whatever keys the group binds + * have no say in it. + * @param options - The object `keyRove` takes: the group's settings, falling + * back to its attributes, and `onMove`. The keys and focus keys are about + * keypresses, and are not read. + * @returns `null` when there is no move to make: no group, no position to go + * from and none to enter at, or a row move in a list. Otherwise what a + * keypress returns: `{ action, from, to }`, with `to: null` where the move has + * nowhere to go from `from`, and `from: null` where it entered the group. + */ +export const rove = ( + element: Element | null | undefined, + action: StrideAction, + options: Options = {}, +): MoveResult | null => { + const isRoot = rootTest(options); + const root = resolveRoot(element, element, isRoot); + + if (!root) return null; + + const config = readConfig(root, options); + + if (config.layout.kind === 'list' && action.endsWith('Row')) return null; + + const { items: elements, focused } = readGroup(root, config.readItems); + const from = + focused ?? + ownItems(root, config.readItems, isRoot ?? attributeRoot).find( + (item) => + config.isRoving(item) && + item.getAttribute('tabindex') === '0' && + elements.includes(item), + ) ?? + null; + const intent = from ? action : ENTRY[action]; + + if (!intent) return null; + + const target = resolveTarget({ + intent, + elements, + // An entry is `home` or `end`, which go by the group's ends, not by an + // index. + fromIndex: from ? elements.indexOf(from) : 0, + layout: config.layout, + pageLength: config.pageLength, + isSkipped: config.isSkipped, + }); + + if (!target && !from) return null; + + return moveFocus({ + action, + from, + to: target, + isRoving: config.isRoving, + onMove: options.onMove, + }); +}; diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index 7686027..c26e55d 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -179,14 +179,13 @@ export type Options = GroupOptions & { }; /** - * Every setting one keypress needs, resolved for the root it is navigating: + * Every setting one move needs, resolved for the root it is navigating: * options where they name a field, the root's attributes where they do not. * The layers below take these as given and never read a source of their own. */ export type GroupConfig = { layout: Layout; explicit: ExplicitBinding; - focus: FocusKey[]; rtl: () => boolean; pageLength: number; readItems: ReadItems; @@ -377,7 +376,8 @@ export type Group = { * handler's result comes back exactly typed. */ export type MoveFocusArgs = { - e: Pick; + /** The keypress to claim. A move made from code has none. */ + e?: Pick; action: Action; from: Element | null; to: Element | null | undefined; From aeb7599b8975996871b00ad9a4e47fc4be450a43 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Tue, 22 Sep 2026 17:11:45 +0200 Subject: [PATCH 14/26] feat: add exit and enter keys to move between nested roots --- packages/docs/content/_demos/nested.html | 1 + packages/docs/content/docs/api.md | 87 +++- .../content/docs/attributes-and-options.md | 2 +- .../docs/content/docs/examples/custom-keys.md | 10 +- .../content/docs/examples/editable-targets.md | 10 +- .../docs/content/docs/examples/focus-keys.md | 14 +- packages/docs/content/docs/examples/grid.md | 6 +- .../content/docs/examples/horizontal-lists.md | 12 +- .../docs/examples/javascript-options.md | 6 +- .../docs/content/docs/examples/listbox.md | 6 +- .../content/docs/examples/looping-lists.md | 2 +- .../content/docs/examples/nested-roots.md | 76 +++- .../content/docs/examples/responsive-grid.md | 2 +- .../content/docs/examples/roving-tabindex.md | 10 +- .../content/docs/examples/skipped-items.md | 4 +- .../docs/content/docs/examples/tree-view.md | 18 +- .../docs/content/docs/examples/typeahead.md | 10 +- packages/docs/content/docs/installation.md | 4 +- packages/docs/content/docs/introduction.md | 46 +-- packages/docs/content/index.md | 10 +- packages/docs/src/demos.ts | 32 -- packages/keyrove/README.md | 23 +- .../src/__tests__/bindings/bindings.test.ts | 55 ++- .../keyRove/keyRove.boundary.test.ts | 384 ++++++++++++++++++ packages/keyrove/src/attributes.ts | 2 + packages/keyrove/src/bindings.ts | 13 + packages/keyrove/src/boundary.ts | 132 ++++++ packages/keyrove/src/group.ts | 27 +- packages/keyrove/src/keyRove.ts | 29 ++ packages/keyrove/src/rove.ts | 15 +- packages/keyrove/src/types.ts | 39 +- 31 files changed, 902 insertions(+), 185 deletions(-) create mode 100644 packages/keyrove/src/__tests__/keyRove/keyRove.boundary.test.ts create mode 100644 packages/keyrove/src/boundary.ts diff --git a/packages/docs/content/_demos/nested.html b/packages/docs/content/_demos/nested.html index d027d3e..b2a5103 100644 --- a/packages/docs/content/_demos/nested.html +++ b/packages/docs/content/_demos/nested.html @@ -3,6 +3,7 @@ data-keyrove-root data-keyrove-next-key="ArrowRight" data-keyrove-prev-key="ArrowLeft" + data-keyrove-exit-key="Escape" > diff --git a/packages/docs/content/docs/api.md b/packages/docs/content/docs/api.md index 42e2fc8..0a496d5 100644 --- a/packages/docs/content/docs/api.md +++ b/packages/docs/content/docs/api.md @@ -68,6 +68,48 @@ 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 +
    + + +
    +
  • +
+``` + +- **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, or a list of them, matched by @@ -119,7 +161,7 @@ order, and the first match wins: 3. The defaults of the moves left unbound. 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 +`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. @@ -140,7 +182,9 @@ the listener is attached to (`currentTarget`). A listener on `document` or - 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. + [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. @@ -157,17 +201,19 @@ 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.** 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. Unbound keys keep their browser behavior and remain available to your -handlers. Tab, Shift+Tab, Enter, Space and Escape are unbound by default. +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. @@ -186,7 +232,7 @@ 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. -If every item is skipped, entry, Home/End, list clamping or looping, and page +If every item is skipped, entry, Home/End, list clamping or looping, and page clamping currently fall back to the physical first or last item. Grid row moves and row ends do not use that fallback. Typeahead and roving initialization also exclude all skipped items. To prevent navigation when @@ -205,9 +251,9 @@ their caret, value and typing keys. Editable targets include: so navigating from them takes nothing away: a list of checkbox rows keeps its arrows. -A [focus key](#focus-keys) with Ctrl, Alt or Meta can run inside an editable +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 +AltGr as Ctrl+Alt, so a `ctrl+alt+` focus key may fire while typing characters such as € or @. When `isComposing` is true, no binding runs, including focus shortcuts. All @@ -240,7 +286,7 @@ work. 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 +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 @@ -293,7 +339,7 @@ keyRove(e, { items: '[role="menuitem"]' }); // override item lookup only | `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. | +| `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. | @@ -329,9 +375,10 @@ keyRove(e, { ``` `action` is `'next'`, `'prev'`, `'home'`, `'end'`, `'pageUp'` or `'pageDown'`; -`'nextRow'`, `'prevRow'`, `'homeRow'` or `'endRow'` in a grid; or `'focus'` -for a shortcut. `from` is the item focus left, or `null` on entry from outside -or a jump to a non-item. `to` is the element that received focus. +`'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 @@ -557,8 +604,8 @@ using the fallback order above. ## 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: +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'; @@ -637,6 +684,8 @@ On the root, read on every keypress: | `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` — @@ -743,6 +792,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 @@ -761,8 +812,8 @@ type Options = GroupOptions & { onMove?: (move: Move) => void }; ### GroupOptions, StrideAction All group settings are optional; see [options](#options) for fallbacks. -`StrideAction` includes every movement action except `focus`, whose binding -names a destination. +`StrideAction` covers movement through the item sequence. It excludes `exit`, +`enter` and `focus`. ```ts type GroupOptions = { @@ -772,13 +823,13 @@ type GroupOptions = { 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 diff --git a/packages/docs/content/docs/attributes-and-options.md b/packages/docs/content/docs/attributes-and-options.md index 271c115..c3725b9 100644 --- a/packages/docs/content/docs/attributes-and-options.md +++ b/packages/docs/content/docs/attributes-and-options.md @@ -39,7 +39,7 @@ keyRove(e, { loop: true }); // override looping only keyRove(e, { items: '[role="menuitem"]' }); // override item lookup only ``` -`keys` falls back per action. With `{ keys: { next: 'KeyJ' } }`, J moves to +`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. diff --git a/packages/docs/content/docs/examples/custom-keys.md b/packages/docs/content/docs/examples/custom-keys.md index acec368..3eb06d9 100644 --- a/packages/docs/content/docs/examples/custom-keys.md +++ b/packages/docs/content/docs/examples/custom-keys.md @@ -11,7 +11,7 @@ the navigation keys. Values are [`KeyboardEvent.code`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code) values, with optional [modifiers](#modifiers). -This toolbar uses Left/Right to move between buttons. Up/Down keep their +This toolbar uses ←/→ to move between buttons. ↑/↓ keep their browser behavior.
@@ -22,12 +22,12 @@ browser behavior.
``` -The handler stays `keyRove(e)`. Only bound keys are handled. Press Down in the +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 Left/Right navigation that follows text direction, use: +For ←/→ navigation that follows text direction, use: ```html
…
@@ -103,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 @@ -118,7 +118,7 @@ layout, so a binding chosen for QWERTY lands on the same physical key on AZERTY. 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 diff --git a/packages/docs/content/docs/examples/editable-targets.md b/packages/docs/content/docs/examples/editable-targets.md index 03e7060..53528e4 100644 --- a/packages/docs/content/docs/examples/editable-targets.md +++ b/packages/docs/content/docs/examples/editable-targets.md @@ -10,8 +10,8 @@ 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 between the demo's rows, then Tab into a control. Text fields keep their -editing behavior, _Font size_ keeps its slider keys, and Down on the checkbox +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. @@ -52,10 +52,10 @@ chained after it also receives the event. ## The one exception -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 +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. +including AltGr reported as Ctrl+Alt. [Typeahead](/docs/examples/typeahead) never captures typing inside a field. diff --git a/packages/docs/content/docs/examples/focus-keys.md b/packages/docs/content/docs/examples/focus-keys.md index 80a5211..e01d6d3 100644 --- a/packages/docs/content/docs/examples/focus-keys.md +++ b/packages/docs/content/docs/examples/focus-keys.md @@ -9,8 +9,8 @@ order: 19 Set `data-keyrove-focus-key` on an element to focus it with a shortcut from anywhere under the listener. -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. +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.
@@ -22,7 +22,7 @@ document 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 +`data-keyrove-focus-key="KeyE"` focuses an element with E outside editable fields. The move reports `action: 'focus'` to `onMove` and in the @@ -60,7 +60,7 @@ 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. -Focus keys use the bindings you choose: V for Move, O for Ellipse, I for +Focus keys use the bindings you choose: V for Move, O for Ellipse, I for Eyedropper. [Typeahead](/docs/examples/typeahead) instead matches labels. Each tool also has `aria-keyshortcuts`. The demo uses it to display the shortcut @@ -126,15 +126,15 @@ 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. 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 +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 Focus keys take precedence over explicit movement bindings and defaults. -For example, an element's `Home` focus key overrides the usual Home action. +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 diff --git a/packages/docs/content/docs/examples/grid.md b/packages/docs/content/docs/examples/grid.md index fc63180..fb3be57 100644 --- a/packages/docs/content/docs/examples/grid.md +++ b/packages/docs/content/docs/examples/grid.md @@ -6,8 +6,8 @@ group: Examples order: 14 --- -Set `data-keyrove-cols` above `1` to navigate a grid. Up/Down move one row in -the same column; Left/Right move one cell in DOM order. +Set `data-keyrove-cols` above `1` to navigate a grid. ↑/↓ move one row in +the same column; ←/→ move one cell in DOM order.
@@ -45,7 +45,7 @@ on screen. see [custom keys](/docs/examples/custom-keys#grids). At an edge, the key is still consumed and the log shows an amber no-op. -Holding Down at the bottom of the grid therefore does not scroll the page. +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 9865c00..05a28e6 100644 --- a/packages/docs/content/docs/examples/horizontal-lists.md +++ b/packages/docs/content/docs/examples/horizontal-lists.md @@ -7,8 +7,8 @@ order: 13 --- Set `data-keyrove-orientation="horizontal"` on a list's root to use -Left/Right. In left-to-right text, Right moves to the next item and Left to the -previous one. Up/Down keep their browser behavior. +←/→. In left-to-right text, → moves to the next item and ← to the +previous one. ↑/↓ keep their browser behavior.
@@ -18,18 +18,18 @@ document .addEventListener('keydown', (e) => keyRove(e)); ``` -Home/End still jump to the ends, PageUp/PageDown still move a page, and Tab +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 -Under `dir="rtl"`, the defaults reverse: Left is next and Right is previous. +Under `dir="rtl"`, the defaults reverse: ← is next and → is previous. This keeps navigation aligned with items laid out in right-to-left order.
-Compare the logs: Right reports `next` in the first bar and `prev` in the RTL +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 @@ -65,7 +65,7 @@ the cell arrows flip. ## Wrapping a strip -Add `data-keyrove-loop` to wrap next/previous at the ends. Under RTL, Left +Add `data-keyrove-loop` to wrap next/previous at the ends. Under RTL, ← wraps forward. See [looping lists](/docs/examples/looping-lists) for entry behavior and the moves that do not wrap. diff --git a/packages/docs/content/docs/examples/javascript-options.md b/packages/docs/content/docs/examples/javascript-options.md index d331d2d..e4c893f 100644 --- a/packages/docs/content/docs/examples/javascript-options.md +++ b/packages/docs/content/docs/examples/javascript-options.md @@ -28,8 +28,8 @@ document .addEventListener('keydown', (e) => keyRove(e, config) || typeahead(e)); ``` -Up/Down move between items and wrap at the ends. Typing finds an entry, and -Tab leaves the menu. The options configure navigation; the markup supplies +↑/↓ move between items and wrap at the ends. Typing finds an entry, and +Tab leaves the menu. The options configure navigation; the markup supplies roles, an initial tab stop and the `disabled` attribute on _Export as PDF_. ## What is coming from where @@ -42,7 +42,7 @@ replacement rules and the [API reference](/docs/api#options) for every field. ## One object, both handlers Pass the same `items`, `root`, `skip` and `rovingTabindex` settings to both -handlers. In the demo, typing P reaches _Post to Slack_ through the same +handlers. In the demo, typing P reaches _Post to Slack_ through the same selector the arrows use. Typeahead ignores movement settings such as keys, columns and looping. Use its diff --git a/packages/docs/content/docs/examples/listbox.md b/packages/docs/content/docs/examples/listbox.md index a3e7210..dd6ffec 100644 --- a/packages/docs/content/docs/examples/listbox.md +++ b/packages/docs/content/docs/examples/listbox.md @@ -10,7 +10,7 @@ This single-select listbox combines navigation, roving tabindex and typeahead. The widget supplies ARIA roles, selection and click handling. Tab into it and arrow around, type a first letter to -jump, then press Enter to pick. Space also selects +jump, then press Enter to pick. Space also selects when typeahead is inactive; wait 500 ms after typing to use it. Clicking picks too. Tab away and back, and focus returns to where you left it. @@ -86,9 +86,9 @@ The log distinguishes selection from focus movement. before the widget's own keys, so a letter jumps and a bound key never becomes typing. See [typeahead](/docs/examples/typeahead). - **Picking.** `pick` runs after navigation and typeahead. It uses - `matchesCombo` for exact Space/Enter matching, so Ctrl+Space remains + `matchesCombo` for exact Space/Enter matching, so Ctrl+Space remains unhandled. It returns `null` for other keys, allowing another handler to - follow it. Typeahead consumes Space when it extends a matching prefix; + follow it. Typeahead consumes Space when it extends a matching prefix; otherwise, `pick` handles it. - **The mouse.** `followFocus` updates the roving stop on `focusin`, including focus from clicks or code. The click handler then selects the option. diff --git a/packages/docs/content/docs/examples/looping-lists.md b/packages/docs/content/docs/examples/looping-lists.md index 8040afd..d9c21c3 100644 --- a/packages/docs/content/docs/examples/looping-lists.md +++ b/packages/docs/content/docs/examples/looping-lists.md @@ -50,7 +50,7 @@ This matches the optional arrow-key entry behavior in the [APG menu button](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/) pattern. -Home, End, and the page keys still require focus inside an item. +Home, End, and the page keys still require focus inside an item. ## Only next and prev wrap diff --git a/packages/docs/content/docs/examples/nested-roots.md b/packages/docs/content/docs/examples/nested-roots.md index c3a0589..ed0ee54 100644 --- a/packages/docs/content/docs/examples/nested-roots.md +++ b/packages/docs/content/docs/examples/nested-roots.md @@ -9,9 +9,9 @@ order: 18 Put `data-keyrove-root` on an inner group to give it its own keys, columns and page size. -In this menu, Up/Down move between actions. Up from _Reply_ enters the -reaction row, where Left/Right move between reactions. The demo's Escape -handler returns focus to the menu. +In this menu, ↑/↓ move between actions. ↑ from _Reply_ enters the +reaction row, where ←/→ move between reactions. Escape returns focus to +the menu.
@@ -64,8 +64,8 @@ attribute-selected item sequences separate, place them in sibling roots. ## Getting back out Inside a nested root, only that root's movement bindings apply. Unbound keys -do not fall through to the outer group. In the reaction row, Down is unhandled -(grey in the log); Left at the first reaction is consumed without moving +do not fall through to the outer group. In the reaction row, ↓ is unhandled +(grey in the log); ← at the first reaction is consumed without moving (amber). Provide a way to leave the inner group: @@ -75,22 +75,62 @@ Provide a way to leave the inner group: [roving tabindex](/docs/examples/roving-tabindex) on each group, Tab moves group to group and Shift+Tab back. No code. -2. **A key of your own.** Any code the roots have not bound is free. The demo - binds Escape on the reaction row and hands focus to - the item beside it: - - ```ts - reactions.addEventListener('keydown', (e) => { - if (e.code !== 'Escape') return; - - reactions.nextElementSibling.focus(); - }); +2. **An exit key.** The demo binds Escape on the + reaction row: + + ```html +
  • + + … +
  • ``` + Exit focuses the outer item containing the root, or the nearest eligible + item after it, then before it. Here, the reaction row has no containing + item, so Escape focuses _Reply_. Each root finds its destination from the + DOM, so several reaction rows can use the same binding. If there is no + destination, Escape remains unhandled. + 3. **A [focus key](/docs/examples/focus-keys) on an item of the outer group.** - `data-keyrove-focus-key` is heard as far as the listener reaches, nested - roots included, so one press lands on that item from anywhere inside the - inner group. No listener on the inner root, and no code. + `data-keyrove-focus-key` works from anywhere under the listener, nested + roots included. Use it to return to a fixed destination. + +## Getting in + +Outer arrow navigation can reach items inside nested roots, as in the reaction +row above. When an outer item contains its own group, bind an enter key on the +outer root to focus that group's items: + +```html +
      +
    • + Build failed +
      + + +
      +
    • +
    +``` + +Enter focuses the first nested root's navigable roving tab stop, or its first +navigable item. Escape returns to the containing item. Skipped and disabled +items are excluded. + +To remember the last focused button, set up +[roving tabindex](/docs/examples/roving-tabindex) in each group. Enter and exit +preserve the stop in the group being left. The markup above uses ordinary tab +stops, so Enter starts at _Retry_ each time. + +Neither action has a default binding. You can also use +`keys: { enter: 'Enter', exit: 'Escape' }`. When no destination is found, the +key remains available to the browser and your handlers. See the +[API reference](/docs/api#exit-and-enter) for target and focus rules. ## Nesting, or two roots side by side diff --git a/packages/docs/content/docs/examples/responsive-grid.md b/packages/docs/content/docs/examples/responsive-grid.md index 03fae8f..dca8218 100644 --- a/packages/docs/content/docs/examples/responsive-grid.md +++ b/packages/docs/content/docs/examples/responsive-grid.md @@ -13,7 +13,7 @@ copying breakpoints into JavaScript. This demo uses a [container query](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_queries). Drag the panel's corner or narrow the window, then try the arrows. With six -columns, Down moves from January to July; with two, it moves to March. +columns, ↓ moves from January to July; with two, it moves to March.
    diff --git a/packages/docs/content/docs/examples/roving-tabindex.md b/packages/docs/content/docs/examples/roving-tabindex.md index 2cda4c1..0f23b6a 100644 --- a/packages/docs/content/docs/examples/roving-tabindex.md +++ b/packages/docs/content/docs/examples/roving-tabindex.md @@ -5,7 +5,7 @@ group: Examples order: 16 --- -With the default bindings, Tab visits every item with `tabindex="0"`. Roving +With the default bindings, Tab visits every item with `tabindex="0"`. Roving tabindex gives the group one tab stop; the navigation keys move between items. - Put `data-keyrove-roving-tabindex` on every item, or pass @@ -16,7 +16,7 @@ tabindex gives the group one tab stop; the navigation keys move between items. - On a move from a roving item, keyrove sets the previous item to `-1` and the destination to `0`. -Arrow to an item, then Tab away and Shift+Tab back. Focus returns to that item. +Arrow to an item, then Tab away and Shift+Tab back. Focus returns to that item.
    @@ -53,13 +53,13 @@ A valid `initial` overrides the existing stop. Use it for first setup or an intentional selection change, and omit it during routine render updates. A null, skipped, disabled or non-roving item is ignored. -If no item has `tabindex="0"`, Tab cannot enter through the items. Keep one +If no item has `tabindex="0"`, Tab cannot enter through the items. Keep one stop whenever the group has a navigable roving item. ## Focus that keyrove did not move Attach `followFocus` to `focusin` to update the stop after a click, a call to -`element.focus()`, or Tab entering a control inside an item. It also handles +`element.focus()`, or Tab entering a control inside an item. It also handles entry from outside the group, where keyrove has no previous item to take the stop from: @@ -92,5 +92,5 @@ page's tab order. Use roving tabindex for a composite control such as a tab list. See the [ARIA keyboard practices](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/) for widget-specific guidance. With roving tabindex, -[Tab can leave a nested group](/docs/examples/nested-roots#getting-back-out) +[Tab can leave a nested group](/docs/examples/nested-roots#getting-back-out) without an extra exit handler. diff --git a/packages/docs/content/docs/examples/skipped-items.md b/packages/docs/content/docs/examples/skipped-items.md index 7f70043..3db496e 100644 --- a/packages/docs/content/docs/examples/skipped-items.md +++ b/packages/docs/content/docs/examples/skipped-items.md @@ -15,7 +15,7 @@ The headings keep `data-keyrove-item` and add `data-keyrove-skip`. Removing `data-keyrove-item` also excludes a heading from a list, but in a grid it shifts the positions of later cells. A skipped cell keeps its slot. -Skipping does not remove an item's native Tab stop; set its tabindex as +Skipping does not remove an item's native Tab stop; set its tabindex as needed. An explicit `focusKeys` mapping can still reach a skipped target; see [configuration differences](/docs/attributes-and-options#configuration-differences). @@ -38,7 +38,7 @@ keyrove excludes any element carrying `disabled` from its item sequence. In a grid, this shifts later cells. To retain a disabled cell's slot, use `aria-disabled="true"` with `data-keyrove-skip` instead. -## Home and End +## Home and End Home and End respect skipping too: they land on the first and last _navigable_ items, not on a leading heading or a diff --git a/packages/docs/content/docs/examples/tree-view.md b/packages/docs/content/docs/examples/tree-view.md index 09dce75..aa6059c 100644 --- a/packages/docs/content/docs/examples/tree-view.md +++ b/packages/docs/content/docs/examples/tree-view.md @@ -9,8 +9,8 @@ order: 15.5 Use keyrove to navigate a tree's visible rows. Your widget handles opening and closing branches and excludes hidden rows from navigation. -This sidebar uses attributes. Up/Down move between visible rows, Right opens a -folder, and Left closes it. Folders are buttons, so Enter, Space and clicks +This sidebar uses attributes. ↑/↓ move between visible rows, → opens a +folder, and ← closes it. Folders are buttons, so Enter, Space and clicks also toggle them.
    @@ -56,17 +56,17 @@ Hidden rows remain in the DOM. `setOpen` updates `data-keyrove-skip` after each toggle so navigation passes over rows inside hidden lists. See [skipped items](/docs/examples/skipped-items). -`fold` handles Left/Right only when they change a folder's state. On a page, +`fold` handles ←/→ only when they change a folder's state. On a page, or when the folder is already in the requested state, it returns `null` and leaves the key to the browser. -## Why ← and → are not keyrove's +## Why ← and → are not keyrove's Expanding a branch and moving to a parent require knowledge of the tree's structure. Add those actions in your widget's handler, as the listbox adds [selection](/docs/examples/listbox#the-pieces). -A vertical list leaves Left/Right unbound. `keyRove` returns `null` for them, +A vertical list leaves ←/→ unbound. `keyRove` returns `null` for them, so `keyRove(e) || fold(e)` passes them to your handler. ## A full tree view @@ -76,9 +76,9 @@ navigation from the [APG tree pattern](https://www.w3.org/WAI/ARIA/apg/patterns/ It uses [options](/docs/examples/javascript-options) to select rows and skip hidden descendants directly from the DOM. -- Up/Down move between visible rows; Home/End move to the first/last. -- Right opens a closed folder or enters an open folder's first row. -- Left closes an open folder or moves to the parent folder. +- ↑/↓ move between visible rows; Home/End move to the first/last. +- → opens a closed folder or enters an open folder's first row. +- ← closes an open folder or moves to the parent folder. - Typing finds a row by name. Clicking a folder toggles it.
    @@ -200,7 +200,7 @@ The log shows keyrove moves in green and tree actions in indigo. ## Variations -- **Enter.** The APG has Enter perform a row's default +- **Enter.** The APG has Enter perform a row's default action: open the file, or open or close the folder. In the full tree that is one more case in `branch`, on `matchesCombo(e, 'Enter')`; the sidebar's buttons have it already. diff --git a/packages/docs/content/docs/examples/typeahead.md b/packages/docs/content/docs/examples/typeahead.md index ce3d7ea..9c3f680 100644 --- a/packages/docs/content/docs/examples/typeahead.md +++ b/packages/docs/content/docs/examples/typeahead.md @@ -7,7 +7,7 @@ order: 20 --- Create a typeahead handler to focus items by typing their labels. In this -list, S focuses _Spanish_ and W immediately after it focuses _Swedish_. +list, S focuses _Spanish_ and W immediately after it focuses _Swedish_. After a 500 ms pause, the next character starts a new prefix.
    @@ -24,7 +24,7 @@ document Call `keyRove` first so navigation bindings take precedence. It returns `null` for an unhandled key, letting `typeahead` process it. For example, a `KeyJ` -navigation binding moves focus instead of adding J to the prefix. +navigation binding moves focus instead of adding J to the prefix. ## Why a factory @@ -118,9 +118,9 @@ after a successful focus move. Type "swez" quickly to see each result in the log: -- S and W are green: focus moves to _Spanish_, then _Swedish_. -- E is amber: "swe" still matches the focused item, so `to` is `null`. -- Z is grey: "swez" matches nothing, so the handler returns `null`. +- S and W are green: focus moves to _Spanish_, then _Swedish_. +- E is amber: "swe" still matches the focused item, so `to` is `null`. +- Z is grey: "swez" matches nothing, so the handler returns `null`. The unmatched character stays in the buffer. Pause for 500 ms to start again. diff --git a/packages/docs/content/docs/installation.md b/packages/docs/content/docs/installation.md index 501142d..04c15c1 100644 --- a/packages/docs/content/docs/installation.md +++ b/packages/docs/content/docs/installation.md @@ -43,10 +43,10 @@ list.addEventListener('keydown', (e) => keyRove(e)); ``` Give non-native items `tabindex="0"` so they can receive focus. With the default -bindings, Tab visits each item. For one tab stop per group, use +bindings, Tab visits each item. For one tab stop per group, use [roving tabindex](/docs/examples/roving-tabindex). -Lists use Up/Down by default. Set `data-keyrove-next-key` and +Lists use ↑/↓ by default. Set `data-keyrove-next-key` and `data-keyrove-prev-key` on the root to [change the keys](/docs/examples/custom-keys). Attribute names are also exported as [constants](/docs/api#constants). diff --git a/packages/docs/content/docs/introduction.md b/packages/docs/content/docs/introduction.md index e7adeee..68918f2 100644 --- a/packages/docs/content/docs/introduction.md +++ b/packages/docs/content/docs/introduction.md @@ -33,7 +33,7 @@ const menu = document.querySelector('#menu')!; menu.addEventListener('keydown', (e) => keyRove(e, { items: 'li' })); ``` -This list supports arrows, Home, End and page jumps. Tab still visits each +This list supports arrows, Home, End and page jumps. Tab still visits each item. You can [change the keys](#which-keys-move-focus) and configure the group with [attributes, options, or both](#where-a-group-is-described). @@ -53,21 +53,21 @@ items or adding a column count requires no navigation instance to update. ## What it handles -| Feature | Behavior | Example | -| -------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------- | -| Lists | Up/Down move one item in DOM order | [Basic list](/docs/examples/basic) | -| Grids | Up/Down move a row; Left/Right move a cell | [Grid](/docs/examples/grid) | -| First and last items | Home/End jump to list ends or grid row ends; Ctrl+Home/End jump to grid ends | [Grid](/docs/examples/grid) | -| Page jumps | PageUp/PageDown move 10 items or rows by default | [Page length](/docs/examples/basic#page-length) | -| Horizontal lists | Left/Right follow the text direction | [Horizontal lists](/docs/examples/horizontal-lists) | -| Looping | Next/previous wrap at list ends | [Looping lists](/docs/examples/looping-lists) | -| Roving tabindex | One tab stop follows focus within a group | [Roving tabindex](/docs/examples/roving-tabindex) | -| Skipped items | Pass over items marked with `data-keyrove-skip` or `disabled` | [Skipped items](/docs/examples/skipped-items) | -| Nested groups | Each group uses its own keys and settings | [Nested roots](/docs/examples/nested-roots) | -| Focus shortcuts | Focus an item or panel from anywhere under the listener | [Focus keys](/docs/examples/focus-keys) | -| Typeahead | Add `createTypeahead()` to focus items by typing their labels | [Typeahead](/docs/examples/typeahead) | -| Trees | Navigate visible rows; your handlers expand and collapse branches | [Tree view](/docs/examples/tree-view) | -| Moves from code | Call `rove(list, 'next')` from a button, gamepad or remote | [`rove`](/docs/api#rove-element-action-options) | +| Feature | Behavior | Example | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| Lists | ↑/↓ move one item in DOM order | [Basic list](/docs/examples/basic) | +| Grids | ↑/↓ move a row; ←/→ move a cell | [Grid](/docs/examples/grid) | +| First and last items | Home/End jump to list ends or grid row ends; Ctrl+Home/End jump to grid ends | [Grid](/docs/examples/grid) | +| Page jumps | PageUp/PageDown move 10 items or rows by default | [Page length](/docs/examples/basic#page-length) | +| Horizontal lists | ←/→ follow the text direction | [Horizontal lists](/docs/examples/horizontal-lists) | +| Looping | Next/previous wrap at list ends | [Looping lists](/docs/examples/looping-lists) | +| Roving tabindex | One tab stop follows focus within a group | [Roving tabindex](/docs/examples/roving-tabindex) | +| Skipped items | Pass over items marked with `data-keyrove-skip` or `disabled` | [Skipped items](/docs/examples/skipped-items) | +| Nested groups | Each group has its own keys, with optional enter and exit bindings | [Nested roots](/docs/examples/nested-roots) | +| Focus shortcuts | Focus an item or panel from anywhere under the listener | [Focus keys](/docs/examples/focus-keys) | +| Typeahead | Add `createTypeahead()` to focus items by typing their labels | [Typeahead](/docs/examples/typeahead) | +| Trees | Navigate visible rows; your handlers expand and collapse branches | [Tree view](/docs/examples/tree-view) | +| Moves from code | Call `rove(list, 'next')` from a button, gamepad or remote | [`rove`](/docs/api#rove-element-action-options) | Text fields, selects and editable content keep their editing keys. See [editable targets](/docs/examples/editable-targets) for the rules and exceptions. @@ -105,32 +105,32 @@ Lists use `ArrowDown` for next and `ArrowUp` for previous by default. Set ``` -Here, J and K move focus, and the arrows return to their browser defaults. -Each group can have different bindings. Home, End, PageUp and PageDown can also +Here, J and K move focus, and the arrows return to their browser defaults. +Each group can have different bindings. Home, End, PageUp and PageDown can also be rebound with their own key attributes. See [custom keys](/docs/examples/custom-keys) for examples and the [API reference](/docs/api#keys) for defaults, syntax and precedence. -## Tab still works +## Tab still works keyrove uses `element.focus()`, preserving the browser's focus styling, scrolling and focus announcements. An item also counts as focused when a link, button or other element inside it has focus. -With the default bindings, Tab, Shift+Tab, Enter, Space and Escape keep their +With the default bindings, Tab, Shift+Tab, Enter, Space and Escape keep their usual behavior. Items with `tabindex="0"` remain ordinary tab stops, reachable -with both Tab and the bound navigation keys. +with both Tab and the bound navigation keys. For a single tab stop per group, use -[roving tabindex](/docs/examples/roving-tabindex). Tab enters and leaves the +[roving tabindex](/docs/examples/roving-tabindex). Tab enters and leaves the group, while the bound keys move between its items. ## What it leaves to you - **Roles and ARIA.** Set the roles, labels and states your widget needs. keyrove does not write `role`, `aria-selected` or `aria-activedescendant`. -- **Selection and activation.** Decide what clicks, Enter and Space do, and +- **Selection and activation.** Decide what clicks, Enter and Space do, and add your own handlers. They are unbound by default. - **Initial tab stops.** Make items focusable in your markup. For a roving group, give one item `tabindex="0"` and the others `-1`, or call diff --git a/packages/docs/content/index.md b/packages/docs/content/index.md index b510aad..9db5dab 100644 --- a/packages/docs/content/index.md +++ b/packages/docs/content/index.md @@ -87,24 +87,24 @@ item list to update when your content changes. ### Any key, not just arrows Bind moves to any `KeyboardEvent.code` or combo, such as `mod+KeyJ`. -Horizontal lists use Left/Right arrows and follow the text direction. +Horizontal lists use ←/→ arrows and follow the text direction.
    ### Lists, grids and trees -Lists support arrows, Home, End, page jumps and optional looping. Set a column +Lists support arrows, Home, End, page jumps and optional looping. Set a column count for grid navigation. For trees, skip collapsed rows and add your own expand/collapse handlers.
    -### Tab is left alone +### Tab is left alone keyrove moves DOM focus and prevents the browser's default action only for keys -it handles. Tab, Enter and Space keep their defaults unless you bind them; +it handles. Tab, Enter and Space keep their defaults unless you bind them; text fields keep their editing keys.
    @@ -113,7 +113,7 @@ text fields keep their editing keys. ### Roving tabindex, when you want it [Roving tabindex](/docs/examples/roving-tabindex) gives a group one tab stop -that follows focus. Tab enters and leaves the group; the bound keys move within it. +that follows focus. Tab enters and leaves the group; the bound keys move within it.
    diff --git a/packages/docs/src/demos.ts b/packages/docs/src/demos.ts index 1b9fbc2..f4bd532 100644 --- a/packages/docs/src/demos.ts +++ b/packages/docs/src/demos.ts @@ -274,37 +274,6 @@ const createHistory = (demo: HTMLElement): Log | null => { }; }; -/** - * Escape, out of a nested group. - * - * Navigation stops at the nearest root, so no key pressed inside a nested group - * reaches the group around it — getting back out is the app's job. This is the - * smallest version of it: hand focus to the item beside the group. - */ -const wireGroupExit = (surface: HTMLElement) => { - const groups = surface.querySelectorAll( - `[${KEYROVE_ATTR_ROOT}]`, - ); - - for (const group of groups) { - group.addEventListener('keydown', (e) => { - if (!matchesCombo(e, 'Escape')) return; - - const exit = [ - group.nextElementSibling, - group.previousElementSibling, - ].find( - (el): el is HTMLElement => - el instanceof HTMLElement && - el.hasAttribute(KEYROVE_ATTR_ITEM) && - !el.hasAttribute(KEYROVE_ATTR_SKIP), - ); - - exit?.focus(); - }); - } -}; - /** * Picking, for the listbox demo. * @@ -639,7 +608,6 @@ export const mountDemos = () => { log.keydown(e, claimed); }); - wireGroupExit(surface); // The demo a page opens with starts focused, so the keys it documents work // on arrival rather than after a Tab or a click. Only the first one: focus diff --git a/packages/keyrove/README.md b/packages/keyrove/README.md index c7c1d60..78fd73e 100644 --- a/packages/keyrove/README.md +++ b/packages/keyrove/README.md @@ -177,6 +177,22 @@ default back to the browser, so a toolbar, which has no page moves, leaves
    ``` +Bind enter and exit keys to move between nested groups. Neither has a default: + +- `data-keyrove-exit-key` on the inner root, or `keys.exit`, focuses an eligible + outer item: the containing item, then the nearest after the root, then before. +- `data-keyrove-enter-key` on the outer root, or `keys.enter`, focuses the first + nested root's navigable roving tab stop, or its first navigable item. + +```html +
  • …
  • +``` + +Both exclude skipped and disabled targets. If no destination is found, the key +remains unhandled. See [nested roots](https://keyrove.pages.dev/docs/examples/nested-roots) +for setup and [exit and enter](https://keyrove.pages.dev/docs/api#exit-and-enter) +for focus and tab-stop behavior. + At the ends of a list the bound keys are consumed but focus stays put. Add `data-keyrove-loop` on the root and next on the last item wraps to the first, and vice versa. Grids keep their edges — they never wrap. @@ -356,6 +372,8 @@ while its attribute applies per item. See | `data-keyrove-end-row-key` | `keys.endRow` | root | `End` | Combo for the focused row's last cell. Grids only. | | `data-keyrove-page-up-key` | `keys.pageUp` | root | `PageUp` | Combo for the page jump back. | | `data-keyrove-page-down-key` | `keys.pageDown` | root | `PageDown` | Combo for the page jump forward. | +| `data-keyrove-exit-key` | `keys.exit` | root | — | Combo leaving this nested root for the group around it. | +| `data-keyrove-enter-key` | `keys.enter` | root | — | Combo entering the root nested in the focused item. | | `data-keyrove-focus-key` | `focusKeys` | any | — | Combo focusing this element, from anywhere under the listener, e.g. `ctrl+shift+KeyE`. As an option: combo → element or selector. | | `data-keyrove-loop` | `loop` | root | — | Next/prev wrap past the ends of a list. Grids never wrap. | | `data-keyrove-orientation` | `orientation` | root | — | `horizontal` maps a list's default keys to `ArrowRight`/`ArrowLeft`, RTL-aware. | @@ -396,8 +414,9 @@ or targets that cannot take focus, do not trigger it. `action` names the move: `'next'`, `'prev'`, `'home'`, `'end'`, `'pageUp'`, `'pageDown'`; the grid actions `'nextRow'`, `'prevRow'`, `'homeRow'`, `'endRow'`; -or `'focus'` for a shortcut. `from` is the previous item, or `null` when -entering from outside or jumping to a non-item. `to` is the destination. +`'exit'` and `'enter'` between nested groups; or `'focus'` for a shortcut. +`from` is the previous item, or `null` when no item was focused or the destination +is a non-item. `to` is the destination. `keyRove` returns `null` when it left the key untouched, and `{ action, from, to }` when it consumed it — with `to: null` for a consumed diff --git a/packages/keyrove/src/__tests__/bindings/bindings.test.ts b/packages/keyrove/src/__tests__/bindings/bindings.test.ts index bb5f3ec..aea813f 100644 --- a/packages/keyrove/src/__tests__/bindings/bindings.test.ts +++ b/packages/keyrove/src/__tests__/bindings/bindings.test.ts @@ -1,16 +1,22 @@ import { describe, it, expect, vi } from 'vitest'; import { buildBindings } from '../../bindings'; -import type { BuildBindingsArgs, Layout, StrideAction } from '../../types'; +import type { + BoundaryAction, + BuildBindingsArgs, + Layout, + StrideAction, +} from '../../types'; const LIST: Layout = { kind: 'list', cols: 1, horizontal: false, loop: false }; const GRID: Layout = { kind: 'grid', cols: 3, horizontal: true, loop: false }; -type Explicit = Partial>; +type Explicit = Partial>; // The root's `*-key` attributes as a plain object, looked up per move the way // `keyRove` reads them off the root. -const lookup = (explicit: Explicit) => (intent: StrideAction) => - explicit[intent]; +const lookup = + (explicit: Explicit) => (intent: StrideAction | BoundaryAction) => + explicit[intent]; type BuildOverrides = Partial> & { explicit?: Explicit; @@ -32,6 +38,9 @@ const build = ({ const combos = (bindings: ReturnType) => bindings.map(({ combo }) => combo); +const intents = (bindings: ReturnType) => + bindings.map(({ intent }) => intent); + describe('buildBindings', () => { describe('default tables (the documented keys tables)', () => { it('binds a vertical list', () => { @@ -290,9 +299,6 @@ describe('buildBindings', () => { }); describe('unbinding with none', () => { - const intents = (bindings: ReturnType) => - bindings.map(({ intent }) => intent); - it.each([ ['a list', LIST, ['next', 'prev', 'home', 'end', 'pageUp', 'pageDown']], [ @@ -389,14 +395,45 @@ describe('buildBindings', () => { expect(rtl).toHaveBeenCalledTimes(1); }); - it('looks up only the moves the layout has', () => { + it('looks up only the moves the layout has, and the boundary moves', () => { const explicit = vi.fn(lookup({})); buildBindings({ explicit, layout: LIST, rtl: () => false }); expect(explicit.mock.calls.map(([intent]) => intent).sort()).toEqual( - ['end', 'home', 'next', 'pageDown', 'pageUp', 'prev'].sort(), + [ + 'end', + 'enter', + 'exit', + 'home', + 'next', + 'pageDown', + 'pageUp', + 'prev', + ].sort(), ); }); }); + + describe('boundary moves', () => { + it('leaves exit and enter out of the table unless a root binds them', () => { + expect(intents(build())).not.toContain('exit'); + expect(intents(build())).not.toContain('enter'); + expect( + intents(build({ explicit: { exit: 'none', enter: ' NONE ' } })), + ).toEqual(intents(build())); + }); + + it('puts bound exit and enter among the explicit bindings, ahead of the defaults', () => { + const bindings = build({ + explicit: { next: 'KeyJ', exit: 'Escape', enter: 'Enter, ArrowRight' }, + }); + + expect(bindings.slice(0, 3)).toEqual([ + { combo: 'KeyJ', intent: 'next', enters: true }, + { combo: 'Escape', intent: 'exit', enters: false }, + { combo: 'Enter, ArrowRight', intent: 'enter', enters: false }, + ]); + }); + }); }); diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.boundary.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.boundary.test.ts new file mode 100644 index 0000000..8350438 --- /dev/null +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.boundary.test.ts @@ -0,0 +1,384 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { keyRove } from '../../keyRove'; +import type { Move, MoveResult, Options } from '../../types'; +import { activeId, pressKey, resetTestState } from './testUtils'; + +afterEach(resetTestState); + +/** + * Markup under one delegated listener, as a page wires it. Every result and + * every `onMove` is kept, with elements named by id. + */ +const render = (html: string, options: Options = {}) => { + const listener = document.createElement('div'); + listener.id = 'listener'; + listener.innerHTML = html; + document.body.appendChild(listener); + const results: (ReturnType | null)[] = []; + const moves: ReturnType[] = []; + listener.addEventListener('keydown', (e) => { + results.push( + named( + keyRove(e, { + ...options, + onMove: (move: Move) => moves.push(named(move)), + }), + ), + ); + }); + + return { listener, results, moves }; +}; + +const named = (result: MoveResult | null) => + result && { + action: result.action, + from: result.from?.id ?? null, + to: result.to?.id ?? null, + }; + +const byId = (id: string) => document.getElementById(id)!; + +const focus = (id: string) => byId(id).focus(); + +const tabindexes = (...ids: string[]) => + Object.fromEntries(ids.map((id) => [id, byId(id).getAttribute('tabindex')])); + +/** Presses a key on the focused element and says whether it was claimed. */ +const press = (code: string) => pressKey(code).defaultPrevented; + +// The nested demo's shape: a row of reactions that is a root of its own, a +// sibling of the outer list's items rather than one of them. +const REACTIONS = ` +
    + + +
    +
    Reply
    +
    Copy link
    +`; + +// A list whose items each hold a group of their own. +const MESSAGES = (attrs = '') => ` +
    +
    + + +
    +
    +
    +
    + + +
    +
    +`; + +describe('keyRove', () => { + describe('exit', () => { + it('leaves a nested root for the outer item after it', () => { + const { results, moves } = render(REACTIONS); + focus('r1'); + + expect(press('Escape')).toBe(true); + + expect(activeId()).toBe('reply'); + expect(results).toEqual([{ action: 'exit', from: 'r1', to: 'reply' }]); + expect(moves).toEqual(results); + }); + + it('lands on the outer item containing the root, before the one after it', () => { + render(MESSAGES('data-keyrove-exit-key="Escape"')); + focus('a1'); + + press('Escape'); + + expect(activeId()).toBe('m0'); + }); + + it('falls back to the nearest outer item before the root', () => { + render(` +
    o0
    +
    o1
    +
    + +
    + `); + focus('i0'); + + press('Escape'); + + expect(activeId()).toBe('o1'); + }); + + it('passes over skipped and disabled outer items, and the items of other nested roots', () => { + render(` +
    + +
    +
    skipped
    +
    disabled
    +
    + +
    +
    o0
    + `); + focus('i0'); + + press('Escape'); + + expect(activeId()).toBe('o0'); + }); + + it('lands in the nearest root around it, not the listener', () => { + render(` +
    l0
    +
    +
    a0
    +
    + +
    +
    a1
    +
    + `); + focus('i0'); + + press('Escape'); + + expect(activeId()).toBe('a1'); + }); + + it('exits from the root itself, with no item to report as from', () => { + const { results } = render(` +
    + +
    +
    Reply
    + `); + focus('row'); + + press('Escape'); + + expect(results).toEqual([{ action: 'exit', from: null, to: 'reply' }]); + }); + + it('moves the outer stop and leaves the inner one for the way back', () => { + render(` +
    o0
    +
    + + +
    +
    o1
    +
    o2
    + `); + focus('i1'); + + press('Escape'); + + expect(activeId()).toBe('o1'); + expect(tabindexes('o0', 'o1', 'o2', 'i0', 'i1')).toEqual({ + o0: '-1', + o1: '0', + o2: '-1', + i0: '-1', + i1: '0', + }); + }); + + it('works from the keys option', () => { + render(REACTIONS.replace('data-keyrove-exit-key="Escape"', ''), { + keys: { exit: 'Escape' }, + }); + focus('r0'); + + press('Escape'); + + expect(activeId()).toBe('reply'); + }); + + it('leaves the key alone where there is no group around', () => { + const { results } = render(REACTIONS, { keys: { exit: 'Escape' } }); + focus('copy'); + + expect(press('Escape')).toBe(false); + expect(results).toEqual([null]); + expect(activeId()).toBe('copy'); + }); + + it('leaves the key alone on a root that is an item of the group around it, focused itself', () => { + render(` +
    + +
    +
    o0
    + `); + focus('panel'); + + expect(press('Escape')).toBe(false); + + focus('p0'); + press('Escape'); + expect(activeId()).toBe('panel'); + }); + + it('is unbound by default', () => { + const { results } = render( + REACTIONS.replace('data-keyrove-exit-key="Escape"', ''), + ); + focus('r0'); + + expect(press('Escape')).toBe(false); + expect(results).toEqual([null]); + }); + + it('can be switched off with none', () => { + render(REACTIONS, { keys: { exit: 'none' } }); + focus('r0'); + + expect(press('Escape')).toBe(false); + }); + + it('leaves the key to a text field inside the group', () => { + render(` +
    +
    +
    +
    o0
    + `); + focus('field'); + + expect(press('Escape')).toBe(false); + expect(activeId()).toBe('field'); + }); + }); + + describe('enter', () => { + it('enters the group nested in the focused item, at its stop', () => { + const { listener, results, moves } = render(MESSAGES()); + listener.setAttribute('data-keyrove-enter-key', 'Enter'); + focus('m1'); + + expect(press('Enter')).toBe(true); + + expect(activeId()).toBe('b1'); + expect(results).toEqual([{ action: 'enter', from: 'm1', to: 'b1' }]); + expect(moves).toEqual(results); + }); + + it('keeps both stops where they are', () => { + const { listener } = render(MESSAGES()); + listener.setAttribute('data-keyrove-enter-key', 'Enter'); + focus('m0'); + + press('Enter'); + + expect(activeId()).toBe('a0'); + expect(tabindexes('m0', 'm1', 'a0', 'a1', 'b0', 'b1')).toEqual({ + m0: '0', + m1: '-1', + a0: '0', + a1: '-1', + b0: '-1', + b1: '0', + }); + }); + + it('enters at the first navigable item where the stop is on none', () => { + const { listener } = render(` +
    +
    +
    heading
    + + +
    +
    + `); + listener.setAttribute('data-keyrove-enter-key', 'Enter'); + focus('m0'); + + press('Enter'); + + expect(activeId()).toBe('i0'); + expect(tabindexes('h', 'i0', 'i1')).toEqual({ + h: '-1', + i0: '0', + i1: '-1', + }); + }); + + it('works from the keys option, and exit brings focus back', () => { + const { results } = render(MESSAGES(), { + keys: { enter: 'Enter', exit: 'Escape' }, + }); + focus('m1'); + + press('Enter'); + press('ArrowUp'); + press('Escape'); + press('Enter'); + + expect(results).toEqual([ + { action: 'enter', from: 'm1', to: 'b1' }, + { action: 'prev', from: 'b1', to: 'b0' }, + { action: 'exit', from: 'b0', to: 'm1' }, + { action: 'enter', from: 'm1', to: 'b0' }, + ]); + }); + + it('recognises the nested root by the root option', () => { + const markup = ` +
    +
    + +
    +
    + `; + render(markup, { keys: { enter: 'Enter' } }); + focus('m0'); + + expect(press('Enter')).toBe(false); + + document.body.innerHTML = ''; + render(markup, { root: '.group', keys: { enter: 'Enter' } }); + focus('m0'); + + expect(press('Enter')).toBe(true); + expect(activeId()).toBe('i0'); + }); + + it('leaves the key alone on an item with no group inside', () => { + const { results } = render(REACTIONS, { keys: { enter: 'Enter' } }); + focus('reply'); + + expect(press('Enter')).toBe(false); + expect(results).toEqual([null]); + }); + + it('leaves the key alone with no item focused, or nothing inside to land on', () => { + const { listener, results } = render(` +
    +
    + +
    +
    + `); + listener.setAttribute('data-keyrove-enter-key', 'Enter'); + listener.setAttribute('tabindex', '-1'); + + focus('m0'); + expect(press('Enter')).toBe(false); + focus('listener'); + expect(press('Enter')).toBe(false); + expect(results).toEqual([null, null]); + }); + + it('is unbound by default', () => { + render(MESSAGES()); + focus('m0'); + + expect(press('Enter')).toBe(false); + expect(activeId()).toBe('m0'); + }); + }); +}); diff --git a/packages/keyrove/src/attributes.ts b/packages/keyrove/src/attributes.ts index 46346e5..91a8e14 100644 --- a/packages/keyrove/src/attributes.ts +++ b/packages/keyrove/src/attributes.ts @@ -22,6 +22,8 @@ export const KEYROVE_ATTR_END_ROW_KEY = 'data-keyrove-end-row-key'; export const KEYROVE_ATTR_PAGE_UP_KEY = 'data-keyrove-page-up-key'; export const KEYROVE_ATTR_PAGE_DOWN_KEY = 'data-keyrove-page-down-key'; export const KEYROVE_ATTR_FOCUS_KEY = 'data-keyrove-focus-key'; +export const KEYROVE_ATTR_EXIT_KEY = 'data-keyrove-exit-key'; +export const KEYROVE_ATTR_ENTER_KEY = 'data-keyrove-enter-key'; export const KEYROVE_ATTR_PAGE_LENGTH = 'data-keyrove-page-length'; export const KEYROVE_ATTR_COLS = 'data-keyrove-cols'; export const KEYROVE_ATTR_ROVING_TABINDEX = 'data-keyrove-roving-tabindex'; diff --git a/packages/keyrove/src/bindings.ts b/packages/keyrove/src/bindings.ts index 1d9e0bb..5ff78e7 100644 --- a/packages/keyrove/src/bindings.ts +++ b/packages/keyrove/src/bindings.ts @@ -12,6 +12,7 @@ import { isComboSet } from './utils.js'; import type { Binding, + BoundaryAction, BuildBindingsArgs, KnownCode, Layout, @@ -30,6 +31,10 @@ type DefaultRow = [ // `next`/`prev` and their row forms. Every other stride moves only within one. const ENTERING = /^(next|prev)/; +// The moves across a nested root's boundary. They have no default key, so +// they are in the table only where a root binds them. +const BOUNDARY: BoundaryAction[] = ['exit', 'enter']; + // The value that binds a move to no key. It is no `KeyboardEvent.code`, so it // can never stand for a real key, and it reads as a boolean attribute's value // does: trimmed, in any case. @@ -100,6 +105,14 @@ export const buildBindings = ({ else if (!isNone(combo)) rebound.push({ combo, intent, enters }); } + for (const intent of BOUNDARY) { + const combo = explicit(intent); + + if (combo && !isNone(combo)) { + rebound.push({ combo, intent, enters: false }); + } + } + // An element's own key names one element, where a root's names a group and // a default names nothing in particular: the most specific declaration in // the table, so it sits first — it wins any collision, and two elements diff --git a/packages/keyrove/src/boundary.ts b/packages/keyrove/src/boundary.ts new file mode 100644 index 0000000..10e064d --- /dev/null +++ b/packages/keyrove/src/boundary.ts @@ -0,0 +1,132 @@ +/** + * The moves across a nested root's boundary. + * + * Inside a nested root only that root's bindings apply, so no stride reaches + * the group around it, and no stride means "go inside this item". `exit` and + * `enter` are those two moves. Each lands in a group other than the one the + * key was pressed in, so the roving stop that moves is the destination + * group's own, and the group focus left keeps its stop for the way back. + */ + +import { ownItems, stopHolder } from './group.js'; +import type { BoundaryAction, GroupConfig, IsRoot } from './types.js'; + +// `Node.DOCUMENT_POSITION_FOLLOWING`, spelled out so reading it needs no +// global `Node`. +const FOLLOWING = 4; + +/** + * Where a boundary move lands, and the item its group's roving stop is + * carried from — `null` where that group has no stop to carry. + */ +export type Crossing = { to: Element; stopFrom: Element | null }; + +/** + * The group a root sits in: the nearest root above it, else the listener's + * element, and never one past the listener's reach. `null` for a root that + * is the listener's element, or outside it: nothing around it is keyrove's. + */ +const outerRoot = ( + root: Element, + scope: Element, + isRoot: IsRoot, +): Element | null => { + if (root === scope || !scope.contains(root)) return null; + + for ( + let element = root.parentElement; + element && element !== scope; + element = element.parentElement + ) { + if (isRoot(element)) return element; + } + + return scope; +}; + +/** A group's own items that a move can land on: neither disabled nor skipped. */ +const landings = (root: Element, config: GroupConfig, isRoot: IsRoot) => + ownItems(root, config.readItems, isRoot).filter( + (item) => !item.hasAttribute('disabled') && !config.isSkipped(item), + ); + +/** + * Out of a nested root, to the group around it: the item of that group + * containing the root, where the root sits inside one, else the nearest of + * its items after the root in DOM order, else the nearest before it. Focus + * already on that item — a root that is itself an item of the group around it + * — has nowhere to go. + */ +const exit = ( + root: Element, + scope: Element, + config: GroupConfig, + isRoot: IsRoot, +): Crossing | null => { + const outer = outerRoot(root, scope, isRoot); + + if (!outer) return null; + + const items = landings(outer, config, isRoot); + const after = items.findIndex( + (item) => root.compareDocumentPosition(item) & FOLLOWING, + ); + const to = + items.find((item) => item.contains(root)) ?? + items[after < 0 ? items.length - 1 : after]; + + if (!to || to.ownerDocument.activeElement === to) return null; + + return { + to, + stopFrom: stopHolder(outer, config.readItems, isRoot, config.isRoving), + }; +}; + +/** + * Into the first root nested in `item`, outermost first: to the item holding + * that group's roving stop, where a move can land on it, so the group opens + * where the user left it; else to its first item a move can land on. + */ +const enter = ( + item: Element, + config: GroupConfig, + isRoot: IsRoot, +): Crossing | null => { + const inner = Array.from(item.querySelectorAll('*')).find(isRoot); + + if (!inner) return null; + + const items = landings(inner, config, isRoot); + const to = + items.find( + (each) => config.isRoving(each) && each.getAttribute('tabindex') === '0', + ) ?? items[0]; + + if (!to) return null; + + return { + to, + stopFrom: stopHolder(inner, config.readItems, isRoot, config.isRoving), + }; +}; + +/** + * Where a boundary move goes from `root`, the group the key was pressed in, + * or `null` where it has nowhere to go: `exit` from the listener's own group, + * or with no item around it; `enter` with no item focused, or none nested in + * it to land on. `scope` is the listener's element, past which no group is + * keyrove's. + */ +export const crossBoundary = ( + intent: BoundaryAction, + root: Element, + scope: Element, + focused: Element | null, + config: GroupConfig, + isRoot: IsRoot, +): Crossing | null => { + if (intent === 'exit') return exit(root, scope, config, isRoot); + + return focused ? enter(focused, config, isRoot) : null; +}; diff --git a/packages/keyrove/src/group.ts b/packages/keyrove/src/group.ts index b2c9dbf..d60e610 100644 --- a/packages/keyrove/src/group.ts +++ b/packages/keyrove/src/group.ts @@ -153,6 +153,24 @@ export const ownItems = ( return true; }); +/** + * The item holding a group's roving tab stop: the first of its own items that + * carries the stop and has `tabindex="0"`, disabled ones aside. `null` where + * the group has none. + */ +export const stopHolder = ( + root: Element, + readItems: ReadItems = attributeItems, + isRoot: IsRoot = attributeRoot, + isRoving: IsRoving = attributeRoving, +): Element | null => + ownItems(root, readItems, isRoot).find( + (item) => + isRoving(item) && + item.getAttribute('tabindex') === '0' && + !item.hasAttribute('disabled'), + ) ?? null; + /** * Gives `stop` the group's one `tabindex="0"` and every other of its roving * `items` `-1` — or all of them `-1` where there is no stop. Only attributes @@ -211,7 +229,8 @@ const carryStop = (from: Element, to: Element) => { * tab stop stay put, `onMove` stays quiet, and the result carries `to: null`. * Otherwise the roving tab stop follows when `isRoving` accepts the item being * left — by default, when it carries the attribute — `to` is focused, and - * `onMove` fires with the move that happened. + * `onMove` fires with the move that happened. A move into another group + * names the item its stop is carried from in `stopFrom` instead. * * A `to` that does not take focus — not focusable, inert, hidden — is the same * consumed no-op, with the tab stop put back where it was. @@ -222,6 +241,7 @@ export const moveFocus = ({ from, to, isRoving = attributeRoving, + stopFrom = from, onMove, }: MoveFocusArgs): ActionResult => { e?.preventDefault(); @@ -230,7 +250,10 @@ export const moveFocus = ({ // The stop moves before focus does: `tabindex="0"` is what makes a bare item // focusable in the first place. - const putBack = from && isRoving(from) ? carryStop(from, to) : undefined; + const putBack = + stopFrom && stopFrom !== to && isRoving(stopFrom) + ? carryStop(stopFrom, to) + : undefined; (to as HTMLElement).focus(); diff --git a/packages/keyrove/src/keyRove.ts b/packages/keyrove/src/keyRove.ts index 8fb0313..c7a5f00 100644 --- a/packages/keyrove/src/keyRove.ts +++ b/packages/keyrove/src/keyRove.ts @@ -1,6 +1,8 @@ import { buildBindings } from './bindings.js'; +import { crossBoundary } from './boundary.js'; import { readConfig, readFocusKeys, rootTest } from './config.js'; import { + attributeRoot, holdsFocus, listenerElement, moveFocus, @@ -105,6 +107,33 @@ export const keyRove = ( }); } + // A boundary move lands in the group next to this one — around it, or + // nested in the focused item — and it is that group's stop that moves. It + // claims its key only where there is somewhere to go: the keys it suits, + // Escape and Enter, are otherwise the page's. + if (binding.intent === 'exit' || binding.intent === 'enter') { + const crossing = crossBoundary( + binding.intent, + root, + scope, + focused, + config, + isRoot ?? attributeRoot, + ); + + if (!crossing) return null; + + return moveFocus({ + e, + action: binding.intent, + from: focused, + to: crossing.to, + isRoving: config.isRoving, + stopFrom: crossing.stopFrom, + onMove, + }); + } + // Most moves only act once focus is genuinely inside an item, whatever key // they are bound to: they move *within* a group, they are not a way into // one. The directional moves deliberately are — which is how a group is diff --git a/packages/keyrove/src/rove.ts b/packages/keyrove/src/rove.ts index 77325c2..b456c11 100644 --- a/packages/keyrove/src/rove.ts +++ b/packages/keyrove/src/rove.ts @@ -12,9 +12,9 @@ import { readConfig, rootTest } from './config.js'; import { attributeRoot, moveFocus, - ownItems, readGroup, resolveRoot, + stopHolder, } from './group.js'; import { resolveTarget } from './position.js'; import type { MoveResult, Options, StrideAction } from './types.js'; @@ -79,13 +79,12 @@ export const rove = ( const { items: elements, focused } = readGroup(root, config.readItems); const from = focused ?? - ownItems(root, config.readItems, isRoot ?? attributeRoot).find( - (item) => - config.isRoving(item) && - item.getAttribute('tabindex') === '0' && - elements.includes(item), - ) ?? - null; + stopHolder( + root, + config.readItems, + isRoot ?? attributeRoot, + config.isRoving, + ); const intent = from ? action : ENTRY[action]; if (!intent) return null; diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index c26e55d..0a21725 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -88,12 +88,21 @@ export type StrideAction = | 'pageDown'; /** - * Everything a keypress can resolve to: the strides, plus `focus` — an element - * named outright by its own `data-keyrove-focus-key`, item or not, reached from - * anywhere under the listener rather than from a position. It is the one move - * whose `*-key` attribute sits on its destination, and the one with no default. + * The moves across a nested root's boundary: `exit`, from inside a nested + * group to the group around it, and `enter`, from an item into the group + * nested inside it. Bound on a root like the strides, but with no default key: + * the keys they suit, Escape and Enter, already mean something to the page. */ -export type MoveAction = StrideAction | 'focus'; +export type BoundaryAction = 'exit' | 'enter'; + +/** + * Everything a keypress can resolve to: the strides, the boundary moves, plus + * `focus` — an element named outright by its own `data-keyrove-focus-key`, + * item or not, reached from anywhere under the listener rather than from a + * position. It is the one move whose `*-key` attribute sits on its + * destination. + */ +export type MoveAction = StrideAction | 'exit' | 'enter' | 'focus'; /** * The shape every handler returns for a consumed keypress, parameterised by @@ -155,9 +164,10 @@ export type GroupOptions = { * The combo each move answers to, or a comma-separated list of them: * `{ next: 'ArrowDown, KeyJ', prev: 'KeyK' }`. Read move by move, so a move * left out keeps its attribute and then its default key. `'none'` binds a - * move to no key, freeing its default. + * move to no key, freeing its default. `exit` and `enter` have no default, + * and move across a nested root's boundary: `{ exit: 'Escape' }`. */ - keys?: Partial>; + keys?: Partial>; /** * Elements reachable by a combo of their own: combo, or a comma-separated * list of them, → the element, or a selector resolved within the listener's @@ -291,9 +301,12 @@ export type Layout = { * move only within a group. A property of the move, not of the key it is bound * to. A focus row carries its target outright — the element that declared the * key — and always enters: it names a destination, not a step from a position. + * A boundary row never enters: it goes from the group focus is in to the one + * next to it, and decides for itself what it needs to be in. */ export type Binding = | { combo: string; intent: StrideAction; enters: boolean } + | { combo: string; intent: BoundaryAction; enters: false } | { combo: string; intent: 'focus'; enters: true; target: Element }; /** @@ -302,7 +315,7 @@ export type Binding = * its default key, and `none` where the move is bound to no key. */ export type ExplicitBinding = ( - intent: StrideAction, + intent: StrideAction | BoundaryAction, ) => string | null | undefined; /** A focus key as read off an element: its combo, and the element it focuses. */ @@ -329,8 +342,8 @@ export type IsRoving = (from: Element) => boolean; export type BuildBindingsArgs = { /** - * Asked only about the moves in the layout's default table, so a move the - * layout lacks is never looked up. + * Asked only about the moves in the layout's default table and the boundary + * moves, so a move the layout lacks is never looked up. */ explicit: ExplicitBinding; /** @@ -386,5 +399,11 @@ export type MoveFocusArgs = { * the roving-tabindex attribute. */ isRoving?: IsRoving; + /** + * The item the roving stop is carried from, where that is not `from`: a + * move across a nested root's boundary lands in another group, whose own + * stop moves while the group focus left keeps its. Nullish carries nothing. + */ + stopFrom?: Element | null; onMove?: (move: ActionResult & { to: Element }) => void; }; From 05fab609782b132a500bfc4b093c910120569255 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Tue, 22 Sep 2026 17:29:01 +0200 Subject: [PATCH 15/26] feat: implement attribute builders for root and item settings with type-checking --- packages/docs/content/docs/api.md | 73 +++++ .../content/docs/attributes-and-options.md | 31 ++ packages/docs/content/docs/installation.md | 89 +++++- .../attributeBuilders.test.ts | 276 ++++++++++++++++++ packages/keyrove/src/attributeBuilders.ts | 77 +++++ packages/keyrove/src/config.ts | 8 +- packages/keyrove/src/index.ts | 5 + packages/keyrove/src/keyAttribute.ts | 7 + packages/keyrove/src/types.ts | 40 +++ 9 files changed, 593 insertions(+), 13 deletions(-) create mode 100644 packages/keyrove/src/__tests__/attributeBuilders/attributeBuilders.test.ts create mode 100644 packages/keyrove/src/attributeBuilders.ts create mode 100644 packages/keyrove/src/keyAttribute.ts diff --git a/packages/docs/content/docs/api.md b/packages/docs/content/docs/api.md index 0a496d5..334a0fe 100644 --- a/packages/docs/content/docs/api.md +++ b/packages/docs/content/docs/api.md @@ -697,6 +697,75 @@ 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'`. Key codes remain +open 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?: KeyRoveCode; + 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 Every attribute name is exported as a constant, so markup built in JavaScript @@ -723,7 +792,11 @@ import type { MoveAction, MoveResult, InitRovingTabindexOptions, + ItemAttributeOptions, + ItemAttributes, Options, + RootAttributeOptions, + RootAttributes, RovingTabindexOptions, StrideAction, TypeaheadMove, diff --git a/packages/docs/content/docs/attributes-and-options.md b/packages/docs/content/docs/attributes-and-options.md index c3725b9..c02b0b4 100644 --- a/packages/docs/content/docs/attributes-and-options.md +++ b/packages/docs/content/docs/attributes-and-options.md @@ -74,6 +74,37 @@ The [API reference](/docs/api#options) lists every option and its fallback. 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. + **Use options when you cannot change the HTML.** Select items by existing roles or classes in a component library or CMS: diff --git a/packages/docs/content/docs/installation.md b/packages/docs/content/docs/installation.md index 04c15c1..be31653 100644 --- a/packages/docs/content/docs/installation.md +++ b/packages/docs/content/docs/installation.md @@ -57,11 +57,33 @@ and fallback rules. ## React -```tsx +Use [`rootAttributes` and `itemAttributes`](/docs/api#attribute-builders) for +typed settings in markup. They include the root and item markers, and turn +booleans into `"true"` or `"false"`. Hand-written attributes work too. + +```tsx title="Typed builders" +import { itemAttributes, keyRove, rootAttributes } from '@mixedrays/keyrove'; + +export const Menu = ({ items, loop = false }) => ( +
      + {items.map((item) => ( +
    • + {item.label} +
    • + ))} +
    +); +``` + +```tsx title="Hand-written attributes" import { keyRove } from '@mixedrays/keyrove'; -export const Menu = ({ items }) => ( -
      +export const Menu = ({ items, loop = false }) => ( +
        {items.map((item) => (
      • {item.label} @@ -76,13 +98,40 @@ passed as the handler directly. ## Vue -```vue +```vue title="Typed builders" + + + +``` + +```vue title="Hand-written attributes"