From 588e47f4e740a3f044fcca0e9268d5c7a5402f61 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Fri, 6 Nov 2026 14:26:44 +0100 Subject: [PATCH 01/13] fix: leave keys typed into a field inside a shadow root to the field A listener outside a shadow root sees a press from a web component's input retargeted to the shadow host, so the editable-target guard in keyRove and createTypeahead saw a plain element: arrows, Home/End and typed characters were consumed as moves and never reached the field. The guard now also checks the element the press began on, the first entry of the composed path, read off nativeEvent for React's synthetic events. Group resolution still uses the retargeted target. KeyRoveEvent gains two optional members, composedPath and nativeEvent. --- apps/docs/content/docs/api.md | 28 +++- .../content/docs/examples/editable-targets.md | 4 + packages/keyrove/browser/focus.test.ts | 33 ++++- .../createTypeahead/createTypeahead.test.ts | 38 ++++++ .../__tests__/createTypeahead/testUtils.ts | 5 +- .../keyRove/keyRove.editable.test.ts | 120 +++++++++++++++++- .../src/__tests__/keyRove/testUtils.ts | 2 +- .../keyrove/src/__tests__/types/types.test.ts | 13 ++ packages/keyrove/src/createTypeahead.ts | 10 +- packages/keyrove/src/keyRove.ts | 7 +- packages/keyrove/src/types.ts | 9 ++ packages/keyrove/src/utils.ts | 31 ++++- 12 files changed, 280 insertions(+), 20 deletions(-) diff --git a/apps/docs/content/docs/api.md b/apps/docs/content/docs/api.md index ced8e10..a501453 100644 --- a/apps/docs/content/docs/api.md +++ b/apps/docs/content/docs/api.md @@ -279,6 +279,12 @@ their caret, value and typing keys. Editable targets include: so navigating from them takes nothing away: a list of checkbox rows keeps its arrows. +The check starts from the element the press began on, so a field inside an +open shadow root counts, such as a web component's input under a listener +outside it. A closed shadow root hides its field, and the press reads as +coming from the shadow host. A component with a closed root has to keep its +keys itself, for example by stopping their propagation inside the root. + 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 @@ -917,16 +923,24 @@ type KeyRoveEvent = { isComposing?: boolean; defaultPrevented?: boolean; key?: string; + composedPath?: () => EventTarget[]; + nativeEvent?: { composedPath?: () => EventTarget[] }; }; ``` -The modifier flags, `isComposing`, `defaultPrevented` and `key` are optional so -a hand-built event object still qualifies. A missing flag reads as "not held", -a missing `isComposing` as "not composing" and a missing `defaultPrevented` as -not [consumed](#return-value). An object of your own that bridges events must -forward them, or every press matches as unmodified and unconsumed. Only -[typeahead](#createtypeahead-options) reads `key`; an event without it -navigates but never typeaheads. +The modifier flags, `isComposing`, `defaultPrevented`, `key`, `composedPath` +and `nativeEvent` are optional so a hand-built event object still qualifies. A +missing flag reads as "not held", a missing `isComposing` as "not composing" +and a missing `defaultPrevented` as not [consumed](#return-value). An object of +your own that bridges events must forward them, or every press matches as +unmodified and unconsumed. Only [typeahead](#createtypeahead-options) reads +`key`; an event without it navigates but never typeaheads. + +Only the [editable-target](#editable-targets) check reads the composed path, +to find the element the press began on. React's synthetic events have no +`composedPath` of their own, so the check reads `nativeEvent.composedPath()` +instead. Without either, it reads `target` alone, and a field inside a shadow +root reads as its host. ### KeyRoveCode diff --git a/apps/docs/content/docs/examples/editable-targets.md b/apps/docs/content/docs/examples/editable-targets.md index 7412df3..14eac19 100644 --- a/apps/docs/content/docs/examples/editable-targets.md +++ b/apps/docs/content/docs/examples/editable-targets.md @@ -40,6 +40,10 @@ The test is on the event's target and its ancestors: - an `input` of any type other than `button`, `checkbox`, `color`, `file`, `image`, `reset` and `submit`. +A field inside an open shadow root, such as a web component's input, counts +too: the test also starts from the element the press began on, before the +event was retargeted to the shadow host. A closed shadow root hides its field. + The input list is drawn by what the bound keys do natively. Arrows move a caret in a text field, step a `number`, slide a `range` and move between `radio` buttons; on a checkbox or a button they do nothing, so navigating from diff --git a/packages/keyrove/browser/focus.test.ts b/packages/keyrove/browser/focus.test.ts index 53e5549..b42d02d 100644 --- a/packages/keyrove/browser/focus.test.ts +++ b/packages/keyrove/browser/focus.test.ts @@ -13,7 +13,7 @@ import { keyRove, rove, } from '../src/index'; -import type { KeyRoveOptions, MoveResult } from '../src/index'; +import type { KeyRoveOptions, MoveResult, TypeaheadResult } from '../src/index'; import { activeId, byId, @@ -364,6 +364,37 @@ describe('inside a shadow root', () => { { action: 'homeRow', from: 'e', to: 'd' }, ]); }); + + it('leaves keys typed into a field in an item to the field', async () => { + // A web component's input in a light-DOM list: the listener on the list + // sees each press retargeted to the host. + mount(` +
+ +
+ +
+ `); + const shadow = byId('field').attachShadow({ mode: 'open' }); + shadow.innerHTML = ''; + const input = shadow.getElementById('inner') as HTMLInputElement; + const typeahead = createTypeahead(); + const results: (MoveResult | TypeaheadResult | null)[] = []; + byId('list').addEventListener('keydown', (e) => + results.push(keyRove(e) || typeahead(e)), + ); + input.focus(); + + await press('c'); + expect(input.value).toBe('c'); + + await press('{Home}'); + expect(input.selectionStart).toBe(0); + + await press('{ArrowDown}'); + expect(activeId()).toBe('inner'); + expect(results).toEqual([null, null, null]); + }); }); describe('a target that refuses focus', () => { diff --git a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts index e4952e0..35707f6 100644 --- a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts +++ b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts @@ -526,6 +526,44 @@ describe('createTypeahead', () => { pressKey('s', { from: shadow.activeElement! }); expect(shadow.activeElement?.id).toBe('a'); }); + + describe('in an item of a light-DOM list', () => { + // A web component's control in item `b`: a listener on the list sees + // the press retargeted to the host. + const renderShadowControl = (html: string) => { + const host = document.createElement('div'); + const shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = html; + const item = createItem('b', '', { tabindex: '-1' }); + item.appendChild(host); + const rendered = renderList([ + createItem('a', 'Apple'), + item, + createItem('c', 'Cherry'), + ]); + + return { ...rendered, inner: shadow.getElementById('inner')! }; + }; + + it('leaves typing in an input to the input', () => { + const { inner, results } = renderShadowControl(''); + inner.focus(); + + const event = pressKey('c', { from: inner, composed: true }); + + expect(results).toEqual([null]); + expect(event.defaultPrevented).toBe(false); + }); + + it('matches from a button', () => { + const { inner } = renderShadowControl(''); + inner.focus(); + + pressKey('c', { from: inner, composed: true }); + + expect(activeId()).toBe('c'); + }); + }); }); describe('labels', () => { diff --git a/packages/keyrove/src/__tests__/createTypeahead/testUtils.ts b/packages/keyrove/src/__tests__/createTypeahead/testUtils.ts index 1418655..d7b74ac 100644 --- a/packages/keyrove/src/__tests__/createTypeahead/testUtils.ts +++ b/packages/keyrove/src/__tests__/createTypeahead/testUtils.ts @@ -71,7 +71,10 @@ export const pressKey = ( code = '', from = document.activeElement as Element, ...modifiers - }: Pick & { + }: Pick< + KeyboardEventInit, + 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey' | 'composed' + > & { code?: string; from?: Element; } = {}, diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.editable.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.editable.test.ts index 52b38a9..9ef1090 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.editable.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.editable.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { keyRove } from '../../keyRove'; import { activeId, createItem, @@ -6,6 +7,7 @@ import { renderList, resetTestState, } from './testUtils'; +import type { RoveResult } from './testUtils'; afterEach(resetTestState); @@ -138,4 +140,120 @@ describe('keyRove', () => { expect(activeId()).toBe('b'); }); }); + + describe('a field inside a shadow root', () => { + // A web component's form control: its input sits in the host's open + // shadow root, so a listener on the list sees the press retargeted to the + // host, inside item `b`. + const renderShadowField = ( + html = '', + item = createItem('b', { tabindex: '-1' }), + ) => { + const host = document.createElement('div'); + host.id = 'host'; + const shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = html; + item.appendChild(host); + const results: RoveResult[] = []; + const list = renderList( + [createItem('a'), item, createItem('c', { focusKey: 'ctrl+KeyC' })], + { onResult: (result) => results.push(result) }, + ); + const inner = shadow.getElementById('inner')!; + const named = () => + results.map( + (result) => + result && { + action: result.action, + from: result.from?.id ?? null, + to: result.to?.id ?? null, + }, + ); + + return { shadow, host, inner, list, results, named }; + }; + + it.each([['ArrowDown'], ['Home']])( + 'leaves %s to an input in the shadow tree', + (code) => { + const { shadow, inner, results } = renderShadowField(); + inner.focus(); + + const event = pressKey(code, inner, { composed: true }); + + expect(results).toEqual([null]); + expect(event.defaultPrevented).toBe(false); + expect(shadow.activeElement).toBe(inner); + }, + ); + + it('fires a chorded focus key from the input, as from a light-DOM field', () => { + const { inner, named } = renderShadowField(); + inner.focus(); + + const event = pressKey('KeyC', inner, { composed: true, ctrlKey: true }); + + expect(activeId()).toBe('c'); + expect(event.defaultPrevented).toBe(true); + expect(named()).toEqual([{ action: 'focus', from: 'b', to: 'c' }]); + }); + + it('navigates from a button in the shadow tree', () => { + const { inner, named } = renderShadowField( + '', + ); + inner.focus(); + + pressKey('ArrowDown', inner, { composed: true }); + + expect(activeId()).toBe('c'); + expect(named()).toEqual([{ action: 'next', from: 'b', to: 'c' }]); + }); + + it('keeps a shadow tree inside a contenteditable region guarded', () => { + const region = createItem('b', { tabindex: '-1' }); + region.setAttribute('contenteditable', 'true'); + const { inner, results } = renderShadowField( + '', + region, + ); + inner.focus(); + + const event = pressKey('ArrowDown', inner, { composed: true }); + + expect(results).toEqual([null]); + expect(event.defaultPrevented).toBe(false); + }); + + it("guards a React-style event by its native event's path", () => { + const { shadow, host, inner, list } = renderShadowField(); + inner.focus(); + const preventDefault = vi.fn(); + + const result = keyRove({ + code: 'ArrowDown', + target: host, + currentTarget: list, + preventDefault, + nativeEvent: { composedPath: () => [inner, shadow, host, list] }, + }); + + expect(result).toBeNull(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it('reads the target alone on an event with no composed path', () => { + const { host, inner, list } = renderShadowField(); + inner.focus(); + + const result = keyRove({ + code: 'ArrowDown', + target: host, + currentTarget: list, + preventDefault: () => {}, + }); + + expect(result?.to?.id).toBe('c'); + }); + }); }); diff --git a/packages/keyrove/src/__tests__/keyRove/testUtils.ts b/packages/keyrove/src/__tests__/keyRove/testUtils.ts index 3710c27..a54a043 100644 --- a/packages/keyrove/src/__tests__/keyRove/testUtils.ts +++ b/packages/keyrove/src/__tests__/keyRove/testUtils.ts @@ -90,7 +90,7 @@ export const pressKey = ( from: Element = document.activeElement as Element, modifiers: Pick< KeyboardEventInit, - 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey' | 'isComposing' + 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey' | 'isComposing' | 'composed' > = {}, ) => { const event = new KeyboardEvent('keydown', { diff --git a/packages/keyrove/src/__tests__/types/types.test.ts b/packages/keyrove/src/__tests__/types/types.test.ts index 363f8d2..337684c 100644 --- a/packages/keyrove/src/__tests__/types/types.test.ts +++ b/packages/keyrove/src/__tests__/types/types.test.ts @@ -49,4 +49,17 @@ describe('public type names', () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toExtend(); }); + + it('accepts native events and React-style synthetic ones, path included', () => { + type SyntheticKeyboardEvent = { + code: string; + target: EventTarget; + currentTarget: Element; + preventDefault: () => void; + nativeEvent: KeyboardEvent; + }; + + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + }); }); diff --git a/packages/keyrove/src/createTypeahead.ts b/packages/keyrove/src/createTypeahead.ts index 6bd0395..cc8b496 100644 --- a/packages/keyrove/src/createTypeahead.ts +++ b/packages/keyrove/src/createTypeahead.ts @@ -10,7 +10,7 @@ import { KEYROVE_ATTR_TYPEAHEAD } from './attributes.js'; import { readGroups } from './group.js'; -import { hasCommandModifier, isEditableTarget } from './utils.js'; +import { hasCommandModifier, isEditablePress } from './utils.js'; import type { KeyRoveEvent, TypeaheadOptions, @@ -97,11 +97,11 @@ export const createTypeahead = ({ // is how capitals are typed). if (e.key?.length !== 1 || hasCommandModifier(e)) return null; - const eventTarget = e.target as Element | null; + // Read where the press began, as `keyRove` does, so a field inside a + // shadow root keeps its typing; the group is found from the host. + if (isEditablePress(e)) return null; - if (isEditableTarget(eventTarget)) return null; - - const group = groups.at(eventTarget, e.currentTarget); + const group = groups.at(e.target as Element | null, e.currentTarget); if (!group) return null; diff --git a/packages/keyrove/src/keyRove.ts b/packages/keyrove/src/keyRove.ts index 1e4442a..9410ec0 100644 --- a/packages/keyrove/src/keyRove.ts +++ b/packages/keyrove/src/keyRove.ts @@ -4,7 +4,7 @@ import { readConfig, readFocusKeys } from './config.js'; import { holdsFocus, readGroups } from './group.js'; import { listenerElement } from './nesting.js'; import { resolveTarget } from './position.js'; -import { hasCommandModifier, isEditableTarget, matchesCombo } from './utils.js'; +import { hasCommandModifier, isEditablePress, matchesCombo } from './utils.js'; import type { KeyRoveEvent, KeyRoveOptions, MoveResult } from './types.js'; // The attribute constants ship alongside the handler that reads them, so @@ -41,8 +41,11 @@ export const keyRove = ( // half-converted word. if (e.isComposing) return null; + // A press from a field inside a shadow root reaches a listener outside it + // retargeted to the host, so the guard reads where the press began; the + // group is still found from the host, in the listener's own tree. const eventTarget = e.target as Element | null; - const editable = isEditableTarget(eventTarget); + const editable = isEditablePress(e); // Typing. An editable target keeps every press that could be text or caret // movement, and nothing keyrove binds fires from one without a command diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index f4c650f..5afe246 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -79,6 +79,15 @@ export type KeyRoveEvent = { // deliberately stay on the physical `code`. Optional: an event without it // still navigates; it just never typeaheads. key?: string; + // The path the press travels (`Event.composedPath()`). Only the editable + // guard reads it, for the element the press began on: a listener outside a + // shadow root sees `target` retargeted to the host, never the field inside. + // Optional: absent, the guard reads `target` alone. + composedPath?: () => EventTarget[]; + // The native event behind a framework's synthetic one, as React passes it: + // React's events carry no `composedPath` of their own, so the guard reads + // it here instead. Optional like `composedPath`. + nativeEvent?: { composedPath?: () => EventTarget[] }; }; /** diff --git a/packages/keyrove/src/utils.ts b/packages/keyrove/src/utils.ts index 2e421e3..0e414c1 100644 --- a/packages/keyrove/src/utils.ts +++ b/packages/keyrove/src/utils.ts @@ -123,8 +123,8 @@ const INERT_INPUT_TYPES = new Set([ ]); /** Whether keys arriving from this target belong to it rather than to keyrove. */ -export const isEditableTarget = (target: Element | null) => { - const editable = target?.closest?.(EDITABLE_SELECTOR); +const isEditableTarget = (target: EventTarget | null) => { + const editable = (target as Element | null)?.closest?.(EDITABLE_SELECTOR); if (!editable) return false; @@ -135,6 +135,33 @@ export const isEditableTarget = (target: Element | null) => { return editable.getAttribute('contenteditable')?.toLowerCase() !== 'false'; }; +// The element the press began on. A listener outside a shadow root sees +// `target` retargeted to the shadow host, but the composed path still starts +// at the element inside. React's synthetic events have no path of their own, +// so it is read off the native event. An empty path — one read after +// dispatch — falls back to `target`. +const originOf = (e: KeyRoveEvent): EventTarget | null => + e.composedPath?.()[0] ?? e.nativeEvent?.composedPath?.()[0] ?? e.target; + +/** + * Whether the press began in an editable target, which keeps it rather than + * handing it to keyrove. + * + * Both ends of the press are checked: the element it began on, which may be + * a field in an open shadow root, and `target`, which a listener outside that + * root sees as the host. `closest` stops at a shadow root, so neither alone + * covers a shadow tree inside a light-DOM `contenteditable` region as well as + * a field inside a shadow tree. A closed shadow root hides its nodes from the + * composed path too, so a field inside one still reads as its host. + */ +export const isEditablePress = (e: KeyRoveEvent): boolean => { + if (isEditableTarget(e.target)) return true; + + const origin = originOf(e); + + return origin !== e.target && isEditableTarget(origin); +}; + /** * Sets `tabindex` to `0` / `-1` on `root`. * From 42c40fd93f853fd33a8f566bb74450cd3dd029b4 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Fri, 6 Nov 2026 15:54:57 +0100 Subject: [PATCH 02/13] feat: walk a move on past an item that refuses focus A move whose target refused focus (hidden, inert, display: none, visibility: hidden, inside a closed
) was a consumed no-op, so one such item in the middle of a group stopped the arrows from ever reaching the items past it. The move is now resolved again with the refused item passed over, until focus lands or nothing is left. keyRove, rove and typeahead walk on; focus keys and exit/enter, which name one destination, do not. The roving stop is put back on every item passed over, onMove fires once, and a move where nothing takes focus is the consumed no-op it was. A move whose first target takes focus costs the same as before. skip stays the recommended way to hide items: a run of 1,000 refusing items costs about 7 ms per press, against 0.3 ms when they are skipped. --- DEVELOPMENT.md | 2 +- GLOSSARY.md | 2 +- apps/docs/content/docs/api.md | 18 ++- .../content/docs/examples/skipped-items.md | 8 + apps/docs/content/docs/examples/tree-view.md | 6 +- apps/docs/content/docs/examples/typeahead.md | 5 +- packages/keyrove/README.md | 4 +- packages/keyrove/browser/focus.test.ts | 145 ++++++++++++++++++ .../createTypeahead/createTypeahead.test.ts | 29 ++++ .../keyrove/src/__tests__/group/group.test.ts | 36 +++++ .../__tests__/keyRove/keyRove.result.test.ts | 54 +++++++ .../keyrove/src/__tests__/rove/rove.test.ts | 28 +++- packages/keyrove/src/createTypeahead.ts | 16 +- packages/keyrove/src/group.ts | 79 ++++++---- packages/keyrove/src/keyRove.ts | 40 +++-- packages/keyrove/src/rove.ts | 33 ++-- packages/keyrove/src/types.ts | 16 +- 17 files changed, 445 insertions(+), 76 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f22f470..f03c6dd 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -62,7 +62,7 @@ covers that part of the contract in a real browser: a small suite under library's public entry and drives it with real key presses and clicks. It checks that Tab and Shift+Tab enter, leave and return to a roving group, that nested groups keep their own stops, that navigation works in a shadow root, -and that a hidden or inert target leaves focus and the stop alone. +and that a move passes over a hidden or inert target, leaving its stop alone. It runs on Vitest's browser mode with Playwright, in headless Chromium. Install the browser once: diff --git a/GLOSSARY.md b/GLOSSARY.md index 181c518..d6a9a5e 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -160,7 +160,7 @@ In keyrove, consuming a key never stops its propagation. _Avoid_: Handled key, captured key, swallowed key **Consumed no-op**: -A consumed key that produces no move, such as an action at the end of a list or toward a destination that cannot receive focus. +A consumed key that produces no move, such as an action at the end of a list or one where no destination can receive focus. _Avoid_: Failed move, ignored key **Untouched key**: diff --git a/apps/docs/content/docs/api.md b/apps/docs/content/docs/api.md index b89587a..2eec2d2 100644 --- a/apps/docs/content/docs/api.md +++ b/apps/docs/content/docs/api.md @@ -237,9 +237,14 @@ on where focus is: - **[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. +Give targets native focusability or a `tabindex`. If a move's target cannot +take focus, such as a hidden or inert element, the move passes over it and +tries the next one it would reach, the same way it passes over a skipped item. +The roving stop is put back on every item passed over, and `onMove` fires once, +for the item that took focus. When nothing beyond can take focus, the key is +consumed without moving. A [focus key](#focus-keys) and +[exit and enter](#exit-and-enter) name a single destination, so they are +consumed without moving if it cannot take focus. Unbound keys keep their browser behavior and remain available to your handlers. Tab, Shift+Tab, Enter, Space and Escape are unbound by default. @@ -400,7 +405,7 @@ settings accepted by [`createTypeahead`](#createtypeahead-options). 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, -and neither does a move whose target does not take focus. +and neither does a move where no target takes focus. ```ts keyRove(e, { @@ -422,7 +427,7 @@ focus left, or `null` when no item was focused or the destination is a non-item. This includes a key another handler already consumed. - `{ action, from, to }`: the key was consumed. `to` is the newly focused element, or `null` for a consumed no-op, where the group owns the key but - there is nowhere left to go, or the target did not take focus. + there is nowhere left to go, or no target could take focus. A non-null result means the key was consumed. Chain handlers with `||` to pass only unhandled keys to the next one: @@ -573,7 +578,8 @@ it. The handler follows the `keyRove` contract: `null` when the key was left untouched or [already consumed](#return-value), which leaves the buffer unchanged, and `{ action: 'typeahead', from, to }` when it was consumed, with -`to: null` when the match is already focused or cannot receive focus. +`to: null` when the match is already focused or no match can receive focus. A +match that cannot receive focus is passed over for the next one. `onMove` fires after focus has moved and only when it actually moved, so both handlers can feed the same follow-focus logic. The roving tab stop moves with the match, as for an arrow move. diff --git a/apps/docs/content/docs/examples/skipped-items.md b/apps/docs/content/docs/examples/skipped-items.md index b52b8ab..572ae66 100644 --- a/apps/docs/content/docs/examples/skipped-items.md +++ b/apps/docs/content/docs/examples/skipped-items.md @@ -21,6 +21,14 @@ Skipping does not remove an item's native Tab stop; set i needed. An explicit `focusKeys` mapping can still reach a skipped target; see [configuration differences](/docs/attributes-and-options#configuration-differences). +## Hidden items + +A hidden or inert item that is not skipped does not block the arrows: a move +tries to focus it and, when it refuses, passes over it to the next item it +would reach. Skipping hidden items is still recommended. A move then never +tries to focus them, which saves time on a long run such as a collapsed branch +of many rows, and typeahead and roving initialization pass over them too. + ## Disabled elements Anything carrying the `disabled` attribute is excluded automatically, with no diff --git a/apps/docs/content/docs/examples/tree-view.md b/apps/docs/content/docs/examples/tree-view.md index 4838153..4d8ff24 100644 --- a/apps/docs/content/docs/examples/tree-view.md +++ b/apps/docs/content/docs/examples/tree-view.md @@ -55,8 +55,10 @@ contains child items: when items nest, keyrove treats the first item in DOM order that contains focus as the current item. 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). +each toggle so navigation passes over rows inside hidden lists. A hidden row +that is not skipped does not block the arrows, since a move passes over a row +that refuses focus, but skipping also keeps typeahead and the roving stop off +hidden rows. See [skipped items](/docs/examples/skipped-items). `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 diff --git a/apps/docs/content/docs/examples/typeahead.md b/apps/docs/content/docs/examples/typeahead.md index f0d01a7..6ba779c 100644 --- a/apps/docs/content/docs/examples/typeahead.md +++ b/apps/docs/content/docs/examples/typeahead.md @@ -116,8 +116,9 @@ const typeahead = createTypeahead({ foldDiacritics: false }); The handler returns `null` when it left the key alone and `{ action: 'typeahead', from, to }` when it consumed it, with `to: null` when -the match is already focused or cannot receive focus. `onMove` fires only -after a successful focus move. +the match is already focused or no match can receive focus. A match that cannot +receive focus is passed over for the next one. `onMove` fires only after a +successful focus move. Type "swez" quickly to see each result in the readout: diff --git a/packages/keyrove/README.md b/packages/keyrove/README.md index 01a1ef1..eeee827 100644 --- a/packages/keyrove/README.md +++ b/packages/keyrove/README.md @@ -413,7 +413,9 @@ const result = keyRove(e, { ``` `onMove` runs only after focus moves successfully. Consumed keys at an edge, -or targets that cannot take focus, do not trigger it. +or moves where no target can take focus, do not trigger it. A move passes over +a target that cannot take focus, such as a hidden or inert item, and lands on +the next one that can. `action` names the move: `'next'`, `'prev'`, `'home'`, `'end'`, `'pageUp'`, `'pageDown'`; the grid actions `'nextRow'`, `'prevRow'`, `'homeRow'`, `'endRow'`; diff --git a/packages/keyrove/browser/focus.test.ts b/packages/keyrove/browser/focus.test.ts index 53e5549..db6532f 100644 --- a/packages/keyrove/browser/focus.test.ts +++ b/packages/keyrove/browser/focus.test.ts @@ -437,6 +437,151 @@ describe('a target that refuses focus', () => { }); }); +describe('a move past a target that refuses focus', () => { + /** A roving item, refusing focus by `attribute` where one is given. */ + const item = (id: string, attribute = '') => + ``; + + // Each is focusable by its attributes, and none is skipped: what refuses + // focus is the browser's own reading of the page. + const REFUSALS: Record string> = { + hidden: (id) => item(id, 'hidden'), + inert: (id) => item(id, 'inert'), + 'display: none': (id) => item(id, 'style="display: none"'), + 'visibility: hidden': (id) => item(id, 'style="visibility: hidden"'), + 'a closed
': (id) => + `
more${item(id)}
`, + }; + + for (const [name, refusing] of Object.entries(REFUSALS)) { + it(`walks on to the item beyond it: ${name}`, async () => { + mount(`
${item('a')}${refusing('b')}${item('c')}
`); + const onMove = vi.fn(); + const results = wire(byId('list'), { onMove }); + byId('a').focus(); + + await press('{ArrowDown}'); + expect(activeId()).toBe('c'); + + await press('{ArrowUp}'); + expect(activeId()).toBe('a'); + + expect(results.map(named)).toEqual([ + { action: 'next', from: 'a', to: 'c' }, + { action: 'prev', from: 'c', to: 'a' }, + ]); + expect(onMove.mock.calls.map(([move]) => named(move))).toEqual([ + { action: 'next', from: 'a', to: 'c' }, + { action: 'prev', from: 'c', to: 'a' }, + ]); + expect(tabindexes(byId('a'), byId('b'), byId('c'))).toEqual([ + '0', + '-1', + '-1', + ]); + }); + } + + it('lands Home, End and the page jumps on the nearest item that takes focus from their end', async () => { + mount(` +
+ ${item('a', 'hidden')}${item('b')}${item('c')}${item('d')}${item('e', 'inert')} +
+ `); + const results = wire(byId('list')); + byId('c').focus(); + + await press('{End}'); + await press('{Home}'); + await press('{PageDown}'); + await press('{PageUp}'); + + expect(results.map(named)).toEqual([ + { action: 'end', from: 'c', to: 'd' }, + { action: 'home', from: 'd', to: 'b' }, + { action: 'pageDown', from: 'b', to: 'd' }, + { action: 'pageUp', from: 'd', to: 'b' }, + ]); + }); + + it('keeps a row move in its column, a row further on', async () => { + mount(` +
+ ${['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'] + .map((id) => item(id, id === 'e' ? 'inert' : '')) + .join('')} +
+ `); + const results = wire(byId('grid')); + byId('b').focus(); + + await press('{ArrowDown}'); + await press('{ArrowUp}'); + + expect(results.map(named)).toEqual([ + { action: 'nextRow', from: 'b', to: 'h' }, + { action: 'prevRow', from: 'h', to: 'b' }, + ]); + }); + + it('enters the group at the first item that takes focus', async () => { + mount(` +
+ ${item('a', 'hidden')}${item('b')}${item('c')} +
+ `); + const results = wire(byId('list')); + byId('list').focus(); + + await press('{ArrowDown}'); + + expect(activeId()).toBe('b'); + expect(results.map(named)).toEqual([ + { action: 'next', from: null, to: 'b' }, + ]); + }); + + it('lands typeahead on the next match', async () => { + mount(` +
+ ${item('alpha')}${item('bravo', 'hidden')}${item('beta')} +
+ `); + const typeahead = createTypeahead(); + const results: unknown[] = []; + byId('list').addEventListener('keydown', (e) => + results.push(named(typeahead(e))), + ); + byId('alpha').focus(); + + await press('b'); + + expect(activeId()).toBe('beta'); + expect(results).toEqual([ + { action: 'typeahead', from: 'alpha', to: 'beta' }, + ]); + }); + + it('walks rove past it as the keys do', () => { + mount( + `
${item('a')}${item('b', 'hidden')}${item('c')}
`, + ); + byId('a').focus(); + + expect(named(rove(byId('list'), 'next'))).toEqual({ + action: 'next', + from: 'a', + to: 'c', + }); + expect(activeId()).toBe('c'); + expect(tabindexes(byId('a'), byId('b'), byId('c'))).toEqual([ + '-1', + '-1', + '0', + ]); + }); +}); + describe('layout read from the page', () => { const cells = (count: number) => Array.from( diff --git a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts index 0635d59..0e99da8 100644 --- a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts +++ b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts @@ -803,6 +803,35 @@ describe('createTypeahead', () => { }); }); + describe('a match that refuses focus, with another beyond it', () => { + it('lands on the next match and puts the passed-over stop back', () => { + const onMove = vi.fn(); + const items = [ + createItem('a', 'Drafts', { roving: true }), + createItem('b', 'Sent', { roving: true, tabindex: '-1' }), + createItem('c', 'Inbox', { roving: true, tabindex: '-1' }), + createItem('d', 'Spam', { roving: true, tabindex: '-1' }), + ]; + const { results } = renderList(items, { options: { onMove } }); + vi.spyOn(items[1], 'focus').mockImplementation(() => {}); + items[0].focus(); + + pressKey('s'); + + expect(activeId()).toBe('d'); + expect(results).toEqual([ + { action: 'typeahead', from: items[0], to: items[3] }, + ]); + expect(onMove).toHaveBeenCalledTimes(1); + expect(items.map((item) => item.getAttribute('tabindex'))).toEqual([ + '-1', + '-1', + '-1', + '0', + ]); + }); + }); + describe('across nested groups', () => { it("moves the matched item's own group stop and keeps the outer one", () => { const inner = document.createElement('div'); diff --git a/packages/keyrove/src/__tests__/group/group.test.ts b/packages/keyrove/src/__tests__/group/group.test.ts index a40d8d3..1066452 100644 --- a/packages/keyrove/src/__tests__/group/group.test.ts +++ b/packages/keyrove/src/__tests__/group/group.test.ts @@ -498,6 +498,42 @@ describe('readGroups', () => { expect(onMove).not.toHaveBeenCalled(); }); + it('walks on by retarget, passing over each item that refused', () => { + render(list); + byId('a').focus(); + vi.spyOn(byId('b'), 'focus').mockImplementation(() => {}); + const retarget = vi.fn((isNavigable: (item: Element) => boolean) => + ['b', 'c'].map(byId).find(isNavigable), + ); + + const result = readGroups({}) + .of(byId('root')) + .move({ action: 'next', from: byId('a'), to: byId('b'), retarget }); + + expect(result).toEqual({ + action: 'next', + from: byId('a'), + to: byId('c'), + }); + expect(retarget).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(byId('c')); + }); + + it('ends the walk where retarget hands back an item that already refused', () => { + render(list); + byId('a').focus(); + vi.spyOn(byId('c'), 'focus').mockImplementation(() => {}); + const retarget = vi.fn(() => byId('c')); + + const result = readGroups({}) + .of(byId('root')) + .move({ action: 'end', from: byId('a'), to: byId('c'), retarget }); + + expect(result).toEqual({ action: 'end', from: byId('a'), to: null }); + expect(retarget).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(byId('a')); + }); + it('carries no stop from an item that does not carry one, or into the group from outside', () => { render(list); byId('a').focus(); diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.result.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.result.test.ts index 9de1a1f..cc871a8 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.result.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.result.test.ts @@ -249,6 +249,60 @@ describe('keyRove', () => { ]); }); + it('walks on past every refusing item, firing onMove once, for the item that took focus', () => { + const onMove = vi.fn(); + const items = ['a', 'b', 'c', 'd'].map((id) => + createItem(id, { roving: true, tabindex: '-1' }), + ); + items[0].setAttribute('tabindex', '0'); + items[2].removeAttribute('tabindex'); + const results: RoveResult[] = []; + renderList(items, { + options: { onMove }, + onResult: (r) => results.push(r), + }); + // 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'); + + expect(activeId()).toBe('d'); + expect(results).toEqual([ + { action: 'next', from: byId('a'), to: byId('d') }, + ]); + expect(onMove).toHaveBeenCalledTimes(1); + expect(onMove).toHaveBeenCalledWith({ + action: 'next', + from: byId('a'), + to: byId('d'), + }); + expect(items.map((item) => item.getAttribute('tabindex'))).toEqual([ + '-1', + '-1', + null, + '0', + ]); + }); + + it('does not walk on from a focus key whose item refuses focus', () => { + const b = createItem('b'); + b.setAttribute(KEYROVE_ATTR_FOCUS_KEY, 'KeyP'); + const results: RoveResult[] = []; + renderList([createItem('a'), b, createItem('c')], { + onResult: (r) => results.push(r), + }); + vi.spyOn(b, 'focus').mockImplementation(() => {}); + byId('a').focus(); + + pressKey('KeyP'); + + expect(activeId()).toBe('a'); + expect(results).toEqual([{ action: 'focus', from: byId('a'), to: null }]); + }); + it('still moves to a bare item the roving stop makes focusable', () => { const onMove = vi.fn(); const b = bareItem('b'); diff --git a/packages/keyrove/src/__tests__/rove/rove.test.ts b/packages/keyrove/src/__tests__/rove/rove.test.ts index 40bc11a..84a83c9 100644 --- a/packages/keyrove/src/__tests__/rove/rove.test.ts +++ b/packages/keyrove/src/__tests__/rove/rove.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, afterEach } from 'vitest'; +import { describe, expect, it, afterEach, vi } from 'vitest'; import { keyRove, rove, @@ -199,6 +199,32 @@ describe('rove', () => { } }); + it('walks every move past items that refuse focus exactly as the key does', () => { + const ids = ['a', 'b', 'c', 'd', 'e', 'f']; + const children = ids.map((id) => item(id)); + // Inert or hidden in a browser, and not skipped: focusable by their + // attributes, and still refusing focus. + for (const refusing of [children[1], children[3], children[5]]) { + vi.spyOn(refusing, 'focus').mockImplementation(() => {}); + } + const { byKey, byRove } = twin(children, { + [KEYROVE_ATTR_PAGE_LENGTH]: '2', + }); + + expect(byRove('a', 'next').result).toEqual({ + action: 'next', + from: 'a', + to: 'c', + }); + for (const start of ['a', 'c', 'e']) { + for (const [action, key] of LIST_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(); diff --git a/packages/keyrove/src/createTypeahead.ts b/packages/keyrove/src/createTypeahead.ts index 6bd0395..a81027b 100644 --- a/packages/keyrove/src/createTypeahead.ts +++ b/packages/keyrove/src/createTypeahead.ts @@ -143,11 +143,16 @@ export const createTypeahead = ({ matchMode === 'cycle' && buffer.length === 1 && focused ? items.indexOf(focused) + 1 : 0; - const target = [...items.slice(start), ...items.slice(0, start)].find( - (item) => - group.isNavigable(item) && - fold(getLabel(item, label)).startsWith(buffer), - ); + const rotation = [...items.slice(start), ...items.slice(0, start)]; + // The first match the navigable test accepts: the group's own, and, where + // a match refuses focus, the same test passing over it, so the move lands + // on the next match in the rotation. + const match = (isNavigable: (item: Element) => boolean) => + rotation.find( + (item) => + isNavigable(item) && fold(getLabel(item, label)).startsWith(buffer), + ); + const target = match(group.isNavigable); // No match leaves the key untouched — the character still joined the // buffer, so a mistyped prefix goes quiet until the reset clears it. @@ -161,6 +166,7 @@ export const createTypeahead = ({ action: 'typeahead', from: focused, to: target, + retarget: match, onMove, }); }; diff --git a/packages/keyrove/src/group.ts b/packages/keyrove/src/group.ts index a2aa004..ae8c8b6 100644 --- a/packages/keyrove/src/group.ts +++ b/packages/keyrove/src/group.ts @@ -167,43 +167,59 @@ const carryStop = (from: Element, to: Element) => { * 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 it is - * carried from, which `stopSource` names once the move is known to go - * somewhere, `to` is focused, and `onMove` fires with the move that happened. + * carried from, which `stopSource` names for the destination, `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. + * A `to` that does not take focus — not focusable, inert, hidden — has its tab + * stop put back where it was, and the move walks on: `retarget` resolves it + * again with every refusing item passed over, by `isNavigable`, and the next + * destination is tried the same way. Each pass passes over one more item, so + * the walk ends. Where nothing beyond takes focus, or there is no `retarget`, + * the move is the same consumed no-op. A move whose first destination takes + * focus pays for none of it. */ const land = ( - { e, action, from, to, onMove }: MoveArgs, + { e, action, from, to, retarget, onMove }: MoveArgs, isRoving: IsRoving, - stopSource: () => Element | null, + isNavigable: (item: Element) => boolean, + stopSource: (to: Element) => Element | null, ): ActionResult => { e?.preventDefault(); - if (!to || to === from) return { action, from, to: null }; + let refused: Set | undefined; - // The stop moves before focus does: `tabindex="0"` is what makes a bare item - // focusable in the first place. - const stopFrom = stopSource(); - const putBack = - stopFrom && stopFrom !== to && isRoving(stopFrom) - ? carryStop(stopFrom, to) - : undefined; + // A `retarget` that hands back an item already refused would go round for + // ever: it ends the walk instead. + while (to && to !== from && !refused?.has(to)) { + // The stop moves before focus does: `tabindex="0"` is what makes a bare + // item focusable in the first place. + const stopFrom = stopSource(to); + const putBack = + stopFrom && stopFrom !== to && isRoving(stopFrom) + ? carryStop(stopFrom, to) + : undefined; - (to as HTMLElement).focus(); + (to as HTMLElement).focus(); + + // `focus()` fails silently, so whether focus landed on `to` or inside it + // is read off the tree. + if (holdsFocus(to)) { + const move = { action, from, to }; + onMove?.(move); + + return move; + } - // `focus()` fails silently, so whether focus landed on `to` or inside it is - // read off the tree. - if (!holdsFocus(to)) { putBack?.(); - return { action, from, to: null }; - } + if (!retarget) break; - const move = { action, from, to }; - onMove?.(move); + const passed = (refused ??= new Set()); + passed.add(to); + to = retarget((item) => !passed.has(item) && isNavigable(item)); + } - return move; + return { action, from, to: null }; }; /** @@ -302,13 +318,17 @@ class ResolvedGroup implements Group { } move(move: MoveArgs): ActionResult { - return land(move, this.readings.isRoving, () => - this.stopSource(move.from, move.to), + const { isRoving, isNavigable } = this.readings; + + return land(move, isRoving, isNavigable, (to) => + this.stopSource(move.from, to), ); } cross(move: MoveArgs): ActionResult { - return land(move, this.readings.isRoving, () => this.stop); + const { isRoving, isNavigable } = this.readings; + + return land(move, isRoving, isNavigable, () => this.stop); } /** @@ -317,11 +337,8 @@ class ResolvedGroup implements Group { * where the move goes from one group to another. `null` from outside, or * where the group moved into has no stop to carry. */ - private stopSource( - from: Element | null, - to: Element | null | undefined, - ): Element | null { - if (!from || !to) return from; + private stopSource(from: Element | null, to: Element): Element | null { + if (!from) return null; const { root } = this; const { isRoot } = this.readings; diff --git a/packages/keyrove/src/keyRove.ts b/packages/keyrove/src/keyRove.ts index 1e4442a..576d05c 100644 --- a/packages/keyrove/src/keyRove.ts +++ b/packages/keyrove/src/keyRove.ts @@ -131,17 +131,32 @@ export const keyRove = ( // entered from the keyboard — and so is a focus key, which is the point. if (!focused && !binding.enters) return null; - const target = - binding.intent === 'focus' - ? binding.target - : resolveTarget({ - intent: binding.intent, - elements, - fromIndex: focused ? elements.indexOf(focused) : -1, - layout: config.layout, - pageLength: config.pageLength, - isNavigable: group.isNavigable, - }); + // A focus key names its element outright: one destination, and no walk to + // carry on should it refuse focus. + if (binding.intent === 'focus') { + return group.move({ + e, + action: 'focus', + from: focused, + to: binding.target, + onMove, + }); + } + + const { intent } = binding; + const fromIndex = focused ? elements.indexOf(focused) : -1; + // The move's walk, by a navigable test: the group's own, and the same test + // passing over each item that refused focus when the group walks on. + const resolve = (isNavigable: (item: Element) => boolean) => + resolveTarget({ + intent, + elements, + fromIndex, + layout: config.layout, + pageLength: config.pageLength, + isNavigable, + }); + const target = resolve(group.isNavigable); // With neither a target nor a focused item, keyrove has nothing to move // from or to and the key is left with its browser default rather than @@ -152,9 +167,10 @@ export const keyRove = ( return group.move({ e, - action: binding.intent, + action: intent, from: focused, to: target, + retarget: resolve, onMove, }); }; diff --git a/packages/keyrove/src/rove.ts b/packages/keyrove/src/rove.ts index 1a275ce..0eb1a56 100644 --- a/packages/keyrove/src/rove.ts +++ b/packages/keyrove/src/rove.ts @@ -76,18 +76,29 @@ export const rove = ( 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, - isNavigable: group.isNavigable, - }); + // An entry is `home` or `end`, which go by the group's ends, not by an + // index. + const fromIndex = from ? elements.indexOf(from) : 0; + // The walk is resolved again, passing over each item that refused focus, + // as a key's is. + const resolve = (isNavigable: (item: Element) => boolean) => + resolveTarget({ + intent, + elements, + fromIndex, + layout: config.layout, + pageLength: config.pageLength, + isNavigable, + }); + const target = resolve(group.isNavigable); if (!target && !from) return null; - return group.move({ action, from, to: target, onMove: options.onMove }); + return group.move({ + action, + from, + to: target, + retarget: resolve, + onMove: options.onMove, + }); }; diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index ab3a68c..7704b59 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -549,9 +549,10 @@ export type GroupReader = { /** * What it takes to land a move: the event to claim, the action to report, the * item focus is leaving (`null` from outside the group), the one it lands on - * (nullish when there is nowhere to go), and who to tell. Generic in the action - * so each handler's result comes back exactly typed. Which item the roving - * stop is carried from is the group's to decide: see {@link Group.move}. + * (nullish when there is nowhere to go), how to walk on where that one refuses + * focus, and who to tell. Generic in the action so each handler's result comes + * back exactly typed. Which item the roving stop is carried from is the + * group's to decide: see {@link Group.move}. */ export type MoveArgs = { /** The keypress to claim. A move made from code has none. */ @@ -559,5 +560,14 @@ export type MoveArgs = { action: Action; from: Element | null; to: Element | null | undefined; + /** + * The move resolved again, by `isNavigable`: the group's navigable test, + * passing over every item that has refused focus so far. Asked each time a + * destination refuses, until one takes focus or none is left. Without it a + * refusal ends the move, as it does for a destination named outright. + */ + retarget?: ( + isNavigable: (item: Element) => boolean, + ) => Element | null | undefined; onMove?: (move: ActionResult & { to: Element }) => void; }; From eeab50a5d5f28cf1719f8346adcf4aa7e101926c Mon Sep 17 00:00:00 2001 From: mixedrays Date: Fri, 6 Nov 2026 16:10:56 +0100 Subject: [PATCH 03/13] chore(bench): wait for Chrome to exit before removing its profile pnpm bench:headless removed Chrome's profile while Chrome was still writing it after Browser.close. The removal threw ENOTEMPTY from runHarness's finally block and replaced the finished results, so the runner printed no report, wrote no --json file and exited 1. - Move Chrome handling into scripts/chrome.ts. withChrome runs a body in a fresh profile, waits up to 5 s for Chrome to exit, kills it with SIGKILL if it hasn't, then removes the profile. - Removing the profile or the scratch directory retries, and if it still fails it warns with the leftover path instead of throwing. A finished run keeps its report, and a cleanup error never hides a harness error. - Start cleanup as soon as the preview server is up, so a failed launch or DevTools connection also stops Chrome and the server. Pending DevTools calls now fail when the socket closes. - Test the cleanup with fake Chrome executables. --- apps/bench/README.md | 5 +- apps/bench/scripts/chrome.ts | 138 +++++++++++++++++++++++++ apps/bench/scripts/run.ts | 112 +++++++++----------- apps/bench/tests/chrome.test.ts | 174 ++++++++++++++++++++++++++++++++ 4 files changed, 362 insertions(+), 67 deletions(-) create mode 100644 apps/bench/scripts/chrome.ts create mode 100644 apps/bench/tests/chrome.test.ts diff --git a/apps/bench/README.md b/apps/bench/README.md index 60f77c2..c62ab2a 100644 --- a/apps/bench/README.md +++ b/apps/bench/README.md @@ -45,7 +45,10 @@ This is also how to measure Firefox or Safari: open the URL that `pnpm bench:headless` prints Markdown tables. Chrome or Chromium is found at `CHROME_PATH`, else at its usual install locations. The runner turns on -focus emulation, so the headless page behaves as a focused window. +focus emulation, so the headless page behaves as a focused window. Each run +gets a fresh Chrome profile in the temp directory, removed once Chrome has +exited; if a profile or scratch directory can't be removed, the runner +names it in a warning and still prints and saves the report. To update the recorded run, run with `--record`. Then replace the tables under [Results](#results) with the printed output. diff --git a/apps/bench/scripts/chrome.ts b/apps/bench/scripts/chrome.ts new file mode 100644 index 0000000..7484464 --- /dev/null +++ b/apps/bench/scripts/chrome.ts @@ -0,0 +1,138 @@ +/** + * Headless Chrome for the runner: finding it, running it in a fresh profile, + * and cleaning up after it. + * + * Chrome answers `Browser.close` before it exits, and keeps writing its + * profile until it does, so the profile is removed only once the process is + * gone. Cleanup never throws: a run that finished keeps its report, and an + * error from the run is never replaced by one from cleaning up. + */ + +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; + +/** Chrome or Chromium: at CHROME_PATH, else at its usual install locations. */ +export const findChrome = () => { + const candidates = [ + process.env.CHROME_PATH, + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + ]; + const found = candidates.find((path) => path && existsSync(path)); + if (found) return found; + + for (const name of ['google-chrome', 'google-chrome-stable', 'chromium']) { + const which = spawnSync('which', [name], { encoding: 'utf8' }); + if (which.status === 0) return which.stdout.trim(); + } + + throw new Error('Chrome not found. Set CHROME_PATH to its executable.'); +}; + +/** A running Chrome, as `withChrome` hands it to its body. */ +export type Chrome = { + child: ChildProcess; + /** Its DevTools WebSocket URL. */ + url: string; + /** Its `--user-data-dir`, removed once Chrome has exited. */ + profile: string; +}; + +type Launched = Chrome & { exited: Promise }; + +/** Starts Chrome and resolves once its DevTools are listening. */ +const launch = (chrome: string, profile: string) => + new Promise((ready, fail) => { + const child = spawn(chrome, [ + '--headless=new', + '--remote-debugging-port=0', + `--user-data-dir=${profile}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-extensions', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', + 'about:blank', + ]); + // Made at spawn time, so no exit goes unseen. + const exited = new Promise((done) => { + child.once('exit', () => done()); + child.once('error', () => done()); + }); + let stderr = ''; + child.stderr!.on('data', (chunk) => { + stderr += chunk; + const match = stderr.match(/DevTools listening on (ws:\/\/\S+)/); + if (match) ready({ child, exited, url: match[1], profile }); + }); + child.on('exit', (code) => + fail(new Error(`Chrome exited (${code}) before DevTools started.`)), + ); + child.on('error', fail); + }); + +/** Whether Chrome exits within `timeout` ms. */ +const exitsWithin = (chrome: Launched, timeout: number) => + Promise.race([ + chrome.exited.then(() => true), + // Unreferenced, so a pending wait doesn't keep Node running. + delay(timeout, false, { ref: false }), + ]); + +/** Waits for Chrome to exit, and kills it if it doesn't in time. */ +const stop = async (chrome: Launched, timeout: number) => { + if (await exitsWithin(chrome, timeout)) return; + chrome.child.kill('SIGKILL'); + await exitsWithin(chrome, timeout); +}; + +export type Warn = (message: string) => void; + +const warnOnStderr: Warn = (message) => console.error(`Warning: ${message}`); + +/** Removes `dir`, or warns that it is left behind. Never throws. */ +export const removeDir = async (dir: string, warn: Warn = warnOnStderr) => { + try { + // Retries what a process still letting go of a file causes: ENOTEMPTY, + // EBUSY and EPERM. + await rm(dir, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100, + }); + } catch (error) { + warn(`Could not remove ${dir}: ${(error as Error).message}`); + } +}; + +/** + * Runs `body` with a headless Chrome in a fresh profile. Afterwards, waits up + * to `timeout` ms for Chrome to exit (the body asks it to, with + * `Browser.close`), kills it if it hasn't, then removes the profile. If the + * profile can't be removed, `warn` names it and the body's result stands. + */ +export const withChrome = async ( + path: string, + body: (chrome: Chrome) => Promise, + { + timeout = 5000, + warn = warnOnStderr, + }: { timeout?: number; warn?: Warn } = {}, +): Promise => { + const profile = await mkdtemp(join(tmpdir(), 'keyrove-bench-chrome-')); + let chrome: Launched | undefined; + try { + chrome = await launch(path, profile); + return await body(chrome); + } finally { + if (chrome) await stop(chrome, timeout); + await removeDir(profile, warn); + } +}; diff --git a/apps/bench/scripts/run.ts b/apps/bench/scripts/run.ts index 1a8b74c..ac7b5e6 100644 --- a/apps/bench/scripts/run.ts +++ b/apps/bench/scripts/run.ts @@ -16,12 +16,10 @@ * `performance.now()` its finer resolution. Chrome drives the page over the * DevTools protocol with focus emulation on, so a headless window behaves as * a focused one. Chrome is found at CHROME_PATH, else at its usual install - * locations. + * locations (see chrome.ts). */ -import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, writeFile } from 'node:fs/promises'; import { arch, cpus, platform, release, tmpdir, totalmem } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -32,6 +30,7 @@ import { build, preview } from 'vite'; import { toMarkdown } from '../src/report.ts'; import { describeLibrary, timingLine } from '../src/results.ts'; import type { BenchReport, BenchResults } from '../src/types.ts'; +import { findChrome, removeDir, withChrome } from './chrome.ts'; import { buildLibrary, prepareSource, reportName } from './library.ts'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -70,50 +69,6 @@ const checkArgs = () => { } }; -const findChrome = () => { - const candidates = [ - process.env.CHROME_PATH, - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Chromium.app/Contents/MacOS/Chromium', - 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', - ]; - const found = candidates.find((path) => path && existsSync(path)); - if (found) return found; - - for (const name of ['google-chrome', 'google-chrome-stable', 'chromium']) { - const which = spawnSync('which', [name], { encoding: 'utf8' }); - if (which.status === 0) return which.stdout.trim(); - } - - throw new Error('Chrome not found. Set CHROME_PATH to its executable.'); -}; - -/** Starts Chrome and resolves with its DevTools WebSocket URL. */ -const launch = (chrome: string, profile: string) => - new Promise<{ child: ChildProcess; url: string }>((ready, fail) => { - const child = spawn(chrome, [ - '--headless=new', - '--remote-debugging-port=0', - `--user-data-dir=${profile}`, - '--no-first-run', - '--no-default-browser-check', - '--disable-extensions', - '--disable-background-timer-throttling', - '--disable-renderer-backgrounding', - '--disable-backgrounding-occluded-windows', - 'about:blank', - ]); - let stderr = ''; - child.stderr!.on('data', (chunk) => { - stderr += chunk; - const match = stderr.match(/DevTools listening on (ws:\/\/\S+)/); - if (match) ready({ child, url: match[1] }); - }); - child.on('exit', (code) => - fail(new Error(`Chrome exited (${code}) before DevTools started.`)), - ); - }); - type Message = { id?: number; method?: string; @@ -138,6 +93,13 @@ const connect = (url: string) => let id = 0; socket.onerror = () => fail(new Error(`Cannot connect to ${url}`)); + // A call Chrome can no longer answer fails rather than waiting forever. + socket.onclose = () => { + for (const call of pending.values()) { + call.fail(new Error('The DevTools connection closed.')); + } + pending.clear(); + }; socket.onmessage = ({ data }) => { const message: Message = JSON.parse(String(data)); const call = message.id === undefined ? null : pending.get(message.id); @@ -172,6 +134,12 @@ const status = (text: string) => { if (process.stderr.isTTY) process.stderr.write(`\r\x1b[2K${text}`); }; +/** A line on stderr that doesn't stop the run, below any status line. */ +const warn = (message: string) => { + status(''); + console.error(`Warning: ${message}`); +}; + /** Builds the harness page against `library`, into `outDir`. */ const buildHarness = (library: string, outDir: string) => build({ @@ -187,20 +155,8 @@ const buildHarness = (library: string, outDir: string) => }, }); -/** Serves a built harness and runs it in a fresh headless Chrome. */ -const runHarness = async (chrome: string, harness: string, label: string) => { - // The bench's own config, for its headers, serving the scratch build. - const server = await preview({ - root, - logLevel: 'warn', - build: { outDir: harness }, - preview: { host: '127.0.0.1', port: 0, open: false }, - }); - const origin = server.resolvedUrls?.local[0]; - if (!origin) throw new Error('The preview server did not report its URL.'); - - const profile = await mkdtemp(join(tmpdir(), 'keyrove-bench-chrome-')); - const { child, url } = await launch(chrome, profile); +/** Runs the harness at `origin` in the Chrome whose DevTools are at `url`. */ +const runPage = async (url: string, origin: string, label: string) => { const cdp = await connect(url); try { @@ -258,11 +214,35 @@ const runHarness = async (chrome: string, harness: string, label: string) => { browser: `${version.product} (headless), V8 ${version.jsVersion}`, }; } finally { + // Chrome answers, then exits on its own; withChrome waits for that. await cdp.send('Browser.close').catch(() => {}); cdp.close(); - child.kill(); - await server.close(); - await rm(profile, { recursive: true, force: true }); + } +}; + +/** Serves a built harness and runs it in a fresh headless Chrome. */ +const runHarness = async (chrome: string, harness: string, label: string) => { + // The bench's own config, for its headers, serving the scratch build. + const server = await preview({ + root, + logLevel: 'warn', + build: { outDir: harness }, + preview: { host: '127.0.0.1', port: 0, open: false }, + }); + + try { + const origin = server.resolvedUrls?.local[0]; + if (!origin) throw new Error('The preview server did not report its URL.'); + + return await withChrome(chrome, ({ url }) => runPage(url, origin, label), { + warn, + }); + } finally { + await server + .close() + .catch((error: Error) => + warn(`Could not stop the preview server: ${error.message}`), + ); } }; @@ -301,7 +281,7 @@ const benchmark = async (chrome: string, ref: string | undefined) => { return { label, report }; } finally { - await rm(scratch, { recursive: true, force: true }); + await removeDir(scratch, warn); } }; diff --git a/apps/bench/tests/chrome.test.ts b/apps/bench/tests/chrome.test.ts new file mode 100644 index 0000000..d7b6526 --- /dev/null +++ b/apps/bench/tests/chrome.test.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test, type TestContext } from 'node:test'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { withChrome, type Chrome } from '../scripts/chrome.ts'; + +/** + * An executable that starts like Chrome, announcing DevTools on stderr unless + * told not to, then runs `behavior` with `profile` set to its + * `--user-data-dir`. It notes that path in a `profile` file next to itself. + */ +const fakeChrome = async ( + t: TestContext, + behavior: string, + { announce = true } = {}, +) => { + const dir = await mkdtemp(join(tmpdir(), 'keyrove-fake-chrome-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const path = join(dir, 'chrome'); + await writeFile( + path, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const profile = process.argv + .find((arg) => arg.startsWith('--user-data-dir=')) + .slice('--user-data-dir='.length); +fs.writeFileSync(path.join(__dirname, 'profile'), profile); +${announce ? "process.stderr.write('DevTools listening on ws://127.0.0.1:1/devtools/browser/fake\\n');" : ''} +${behavior} +`, + ); + await chmod(path, 0o755); + return path; +}; + +/** + * Keeps writing the profile for a while, as Chrome does once asked to close, + * then exits. Like Chrome mid-shutdown, it doesn't stop for SIGTERM. + */ +const SHUTS_DOWN_SLOWLY = ` +process.on('SIGTERM', () => {}); +const until = Date.now() + 300; +const write = () => { + fs.mkdirSync(path.join(profile, 'Default'), { recursive: true }); + fs.writeFileSync(path.join(profile, 'Default', 'Preferences'), '{}'); + if (Date.now() < until) setTimeout(write, 5); + else process.exit(0); +}; +write(); +`; + +test('withChrome removes the profile after Chrome has finished writing it', async (t) => { + const chrome = await fakeChrome(t, SHUTS_DOWN_SLOWLY); + let profile = ''; + + await withChrome(chrome, async (browser) => { + profile = browser.profile; + }); + // A Chrome still running would have written the profile again by now. + await delay(100); + + assert.ok(profile); + assert.equal(existsSync(profile), false); +}); + +/** Ignores SIGTERM and never exits on its own. */ +const HANGS = ` +process.on('SIGTERM', () => {}); +fs.mkdirSync(path.join(profile, 'Default'), { recursive: true }); +setInterval(() => {}, 1000); +`; + +test('withChrome kills a Chrome that does not exit in time, and removes its profile', async (t) => { + const chrome = await fakeChrome(t, HANGS); + let browser: Chrome | undefined; + + const started = Date.now(); + await withChrome( + chrome, + async (running) => { + browser = running; + }, + { timeout: 200 }, + ); + + assert.ok(Date.now() - started < 2000); + assert.equal(browser?.child.signalCode, 'SIGKILL'); + assert.equal(existsSync(browser!.profile), false); +}); + +/** Leaves a profile that can't be removed: a file in a read-only folder. */ +const LEAVES_A_LOCKED_PROFILE = ` +fs.mkdirSync(path.join(profile, 'Default'), { recursive: true }); +fs.writeFileSync(path.join(profile, 'Default', 'Preferences'), '{}'); +fs.chmodSync(path.join(profile, 'Default'), 0o555); +setTimeout(() => process.exit(0), 50); +`; + +/** Lets the test remove a profile the fake Chrome locked. */ +const unlockAfter = (t: TestContext, profile: () => string) => + t.after(async () => { + if (!existsSync(profile())) return; + await chmod(join(profile(), 'Default'), 0o755); + await rm(profile(), { recursive: true, force: true }); + }); + +test('withChrome returns what its body did when the profile cannot be removed, and warns', async (t) => { + const chrome = await fakeChrome(t, LEAVES_A_LOCKED_PROFILE); + let profile = ''; + unlockAfter(t, () => profile); + const warnings: string[] = []; + + const result = await withChrome( + chrome, + async (browser) => { + profile = browser.profile; + return 'report'; + }, + { warn: (message) => warnings.push(message) }, + ); + + assert.equal(result, 'report'); + assert.equal(warnings.length, 1); + assert.ok(warnings[0].includes(profile), warnings[0]); +}); + +test('withChrome rejects with what its body threw, not with a cleanup failure', async (t) => { + const chrome = await fakeChrome(t, LEAVES_A_LOCKED_PROFILE); + let profile = ''; + unlockAfter(t, () => profile); + const warnings: string[] = []; + + await assert.rejects( + withChrome( + chrome, + async (browser) => { + profile = browser.profile; + throw new Error('runBench failed'); + }, + { warn: (message) => warnings.push(message) }, + ), + { message: 'runBench failed' }, + ); + assert.equal(warnings.length, 1); + assert.ok(warnings[0].includes(profile), warnings[0]); +}); + +test('withChrome removes the profile when Chrome exits before DevTools starts', async (t) => { + const chrome = await fakeChrome(t, 'process.exit(1);', { announce: false }); + + await assert.rejects( + withChrome(chrome, async () => {}), + { + message: 'Chrome exited (1) before DevTools started.', + }, + ); + const profile = await readFile(join(dirname(chrome), 'profile'), 'utf8'); + assert.equal(existsSync(profile), false); +}); + +test('withChrome rejects when Chrome cannot be started', async (t) => { + const chrome = await fakeChrome(t, ''); + await chmod(chrome, 0o644); + + await assert.rejects( + withChrome(chrome, async () => {}), + { code: 'EACCES' }, + ); +}); From 7343b39eb85cff5dcd84333a4d3dd9ae2a4c3b4d Mon Sep 17 00:00:00 2001 From: mixedrays Date: Fri, 6 Nov 2026 16:23:49 +0100 Subject: [PATCH 04/13] fix: let CommonJS code require the package on Node versions that load ES modules The exports map offered only the types and import conditions, so require('@mixedrays/keyrove') failed with ERR_PACKAGE_PATH_NOT_EXPORTED before Node could load the file, even on Node versions that load ES modules through require(). A default condition points require, and any resolver with its own conditions, at the same ESM build. No CommonJS build is added. On Node below 20.19 / 22.12, require now fails with ERR_REQUIRE_ESM, which names the real cause. The installation page notes that the package can be required on Node >= 20.19 or >= 22.12. --- apps/docs/content/docs/installation.md | 1 + packages/keyrove/package.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/docs/installation.md b/apps/docs/content/docs/installation.md index f38c1e3..900f4b3 100644 --- a/apps/docs/content/docs/installation.md +++ b/apps/docs/content/docs/installation.md @@ -23,6 +23,7 @@ yarn add @mixedrays/keyrove ``` The package is ESM-only, ships its own types, and has no runtime dependencies. +On Node ≥ 20.19 or ≥ 22.12 it can also be loaded with `require()`. ## Vanilla diff --git a/packages/keyrove/package.json b/packages/keyrove/package.json index 57c0709..42802b3 100644 --- a/packages/keyrove/package.json +++ b/packages/keyrove/package.json @@ -36,7 +36,8 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "import": "./dist/index.js", + "default": "./dist/index.js" }, "./package.json": "./package.json" }, From c066bbc14f8c5a7cc650b84405c1f6cd825387e5 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Fri, 6 Nov 2026 17:59:35 +0100 Subject: [PATCH 05/13] perf: walk on past refusing items in one pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A move whose destination refused focus resolved the whole move again from the focused item, so a run of N items that refuse focus cost O(N²) navigable tests: about 7.3–7.7 ms per press for 1,000 hidden rows on an M1 Pro. The move now takes each next destination from the same walk. walkTargets in position.ts hands out every destination in the order a stride tries them, its own scan and then its clamp or wrap, testing each item only as it reaches it. keyRove, rove and typeahead take the first destination from it and carry it on past each refusal by the stricter test land asks with. That test only grows stricter, so the walk lands where resolving again would, for every stride. 1,000 hidden rows now cost about 0.65 ms, and typeahead reads each label once however many matches refuse. The benchmark gains a "refused focus" section, and its recorded run and README are re-recorded with it. --- apps/bench/README.md | 208 +++-- apps/bench/results/recorded.json | 864 +++++++++++------- apps/bench/src/keydown.ts | 127 ++- apps/bench/src/plan.ts | 21 +- apps/bench/src/report.ts | 4 +- apps/bench/src/results.ts | 8 + apps/bench/src/types.ts | 23 + apps/bench/src/view.ts | 5 +- apps/bench/tests/results.test.ts | 19 +- .../createTypeahead/createTypeahead.test.ts | 26 + .../src/__tests__/position/position.test.ts | 102 ++- packages/keyrove/src/createTypeahead.ts | 28 +- packages/keyrove/src/group.ts | 43 +- packages/keyrove/src/keyRove.ts | 32 +- packages/keyrove/src/position.ts | 120 +-- packages/keyrove/src/rove.ts | 31 +- packages/keyrove/src/types.ts | 10 +- 17 files changed, 1109 insertions(+), 562 deletions(-) diff --git a/apps/bench/README.md b/apps/bench/README.md index 60f77c2..9a886e9 100644 --- a/apps/bench/README.md +++ b/apps/bench/README.md @@ -2,8 +2,8 @@ Measures what one `keyRove` call costs in a real browser, on pages of increasing size, with the listener on the group or on the document, for -unbound and bound keys, with and without focus keys, and with fixed or -`auto` columns. +unbound and bound keys, with and without focus keys, with fixed or `auto` +columns, and for a move past a run of items that refuse focus. It runs two ways: in a page you open in any browser, or in headless Chrome from the command line. Both time the same code on the same page. @@ -78,9 +78,10 @@ keyrove's own build uses, so two reports differ only in the library's source. Some cases time features older versions lack: the `focusKeys` option -arrived in 2.4.0 and `cols="auto"` in 2.5.0. Before running, the harness -tries each feature on the library, and a case whose feature is missing is -recorded as not run rather than timed. It would still run, but it would time +arrived in 2.4.0, `cols="auto"` in 2.5.0, and a move past an item that +refuses focus after 2.6.1. Before running, the harness tries each feature on +the library, and a case whose feature is missing is recorded as not run +rather than timed. It would still run, but it would time something other than its label. In a comparison those cases read n/a. In the UI, the comparison starts with a summary of each report against the @@ -101,7 +102,7 @@ to see reports saved since. | Path | What it is | | --------------------------- | ------------------------------------------------------------------------------------------ | | `src/keydown.ts` | The benchmark: probes the library's features, builds each fixture, and times the cases. | -| `src/plan.ts` | What a run covers: page sizes, focus-key counts, grids, default timing. | +| `src/plan.ts` | What a run covers: page sizes, focus-key counts, grids, refusal runs, default timing. | | `harness.html` | The page the cases run in. It holds the fixture and almost nothing else. | | `index.html`, `src/main.ts` | The UI. Each run loads a fresh harness in a frame, so none of the UI's markup is measured. | | `src/view.ts` | The UI's results, one report or a comparison, as HTML. | @@ -136,12 +137,22 @@ attributes evenly over the L page, each a distinct combo that no case presses. Grid fixtures lay each M group out as a four-column CSS grid with `data-keyrove-cols` set to `4` or `auto`. +Refused-focus fixtures are one group each: a visible item, a run of 1 to +1,000 items hidden with `display: none` or `visibility: hidden`, and another +visible item. They are named by the run. In the `roving` one every item +carries `data-keyrove-roving-tabindex`, so the tab stop is carried onto each +hidden item and put back. In the `skipped` one the run carries +`data-keyrove-skip`. + Every press starts in the first group: - **unbound:** `KeyQ`, which no binding matches. - **bound, no move:** `End` on the last item, a consumed no-op. - **bound, moves focus:** `ArrowDown`/`ArrowUp` between the first two items, or `ArrowRight`/`ArrowLeft` in a grid, calling `focus()` every time. +- **moves past the run:** `ArrowDown`/`ArrowUp` between the two visible items + of a refused-focus page, under a local listener. Each press tries `focus()` + on every hidden item on the way, unless the run is skipped. ## Method @@ -156,8 +167,7 @@ Every press starts in the first group: - The cases run in `harness.html`, which holds the fixture and almost nothing else. The headless runner loads it directly, and the UI loads a fresh copy in a frame for each run, so neither counts or scans any UI - markup. The harness has five fewer elements than the page the recorded run - used, so a new run's element counts are that much lower. + markup. - The page is cross-origin isolated, which gives `performance.now()` its finer resolution. - Each case records what one call returned: `unhandled`, `consumed no-op` or @@ -168,80 +178,88 @@ Every press starts in the first group: ## Results -Recorded on 2026-09-22 in headless Chrome, by what is now -`pnpm bench:headless`. The same run is in `results/recorded.json`. +Recorded on 2026-11-06 in headless Chrome by `pnpm bench:headless -- --record`. +The same run is in `results/recorded.json`. ### Environment -- Browser: Chrome/153.0.8010.53 (headless), V8 15.3.76.13 +- Library: 2.6.0 (working tree at d21fa4f, with uncommitted changes) +- Browser: Chrome/156.0.8078.80 (headless), V8 15.6.75.27 - OS: darwin 25.2.0 arm64 - CPU: Apple M1 Pro × 10 - Memory: 16 GB - Node: v25.2.1 - Cross-origin isolated: true - Timing: 100 ms warmup, then 15 batches of ≥ 10 ms per case -- Date: 2026-09-22 +- Date: 2026-11-06 ### Fixtures -| Fixture | Groups | Items | Focus keys | Grid | Elements | Build (ms) | -| ------------------ | -----: | ----: | ---------: | ---- | -------: | ---------: | -| S | 10 | 200 | 0 | - | 624 | 1.59 | -| M | 100 | 2000 | 0 | - | 6114 | 11.82 | -| L | 1000 | 20000 | 0 | - | 61014 | 139.89 | -| L, 0 focus keys | 1000 | 20000 | 0 | - | 61014 | 172.39 | -| L, 10 focus keys | 1000 | 20000 | 10 | - | 61014 | 117.89 | -| L, 100 focus keys | 1000 | 20000 | 100 | - | 61014 | 144.97 | -| L, 1000 focus keys | 1000 | 20000 | 1000 | - | 61014 | 109.96 | -| M, cols="4" | 100 | 2000 | 0 | 4 | 6114 | 15.01 | -| M, cols="auto" | 100 | 2000 | 0 | auto | 6114 | 14.97 | +| Fixture | Groups | Items | Focus keys | Grid | Elements | Build (ms) | +| ----------------------------- | -----: | ----: | ---------: | ---- | -------: | ---------: | +| S | 10 | 200 | 0 | - | 618 | 1.43 | +| M | 100 | 2000 | 0 | - | 6108 | 11.09 | +| L | 1000 | 20000 | 0 | - | 61008 | 123.15 | +| L, 0 focus keys | 1000 | 20000 | 0 | - | 61008 | 127.74 | +| L, 10 focus keys | 1000 | 20000 | 10 | - | 61008 | 102.69 | +| L, 100 focus keys | 1000 | 20000 | 100 | - | 61008 | 126.12 | +| L, 1000 focus keys | 1000 | 20000 | 1000 | - | 61008 | 103.35 | +| M, cols="4" | 100 | 2000 | 0 | 4 | 6108 | 11.41 | +| M, cols="auto" | 100 | 2000 | 0 | auto | 6108 | 10.85 | +| 1 × display: none | 1 | 3 | 0 | - | 18 | 0.25 | +| 10 × display: none | 1 | 12 | 0 | - | 45 | 0.17 | +| 100 × display: none | 1 | 102 | 0 | - | 315 | 0.30 | +| 1000 × display: none | 1 | 1002 | 0 | - | 3015 | 1.62 | +| 1000 × visibility: hidden | 1 | 1002 | 0 | - | 3015 | 5.68 | +| 1000 × display: none, roving | 1 | 1002 | 0 | - | 3015 | 1.62 | +| 1000 × display: none, skipped | 1 | 1002 | 0 | - | 3015 | 1.46 | ### baseline -| Fixture | Case | Result | Median (µs) | p10–p90 (µs) | Batch | -| ------- | ------------------------ | ------ | ----------: | -----------: | ------: | -| S | harness only, no handler | - | 0.00 | 0.00–0.01 | 2097152 | +| Fixture | Case | Result | Median (µs) | p10–p90 (µs) | Batch | +| ------- | ------------------------ | ------ | ----------: | -----------: | -------: | +| S | harness only, no handler | - | 0.00 | 0.00–0.00 | 16777216 | ### listener | Fixture | Case | Result | Median (µs) | p10–p90 (µs) | Batch | | ------- | ---------------------------- | -------------- | ----------: | -----------: | ----: | -| S | local, unbound | unhandled | 2.68 | 2.64–2.93 | 4096 | -| S | local, bound, no move | consumed no-op | 9.43 | 8.60–10.01 | 1024 | -| S | local, bound, moves focus | moved | 24.70 | 23.30–27.14 | 512 | -| S | document, unbound | unhandled | 2.69 | 2.63–3.00 | 4096 | -| S | document, bound, no move | consumed no-op | 9.85 | 9.45–10.28 | 2048 | -| S | document, bound, moves focus | moved | 23.31 | 22.33–25.07 | 512 | -| M | local, unbound | unhandled | 2.56 | 2.55–2.69 | 4096 | -| M | local, bound, no move | consumed no-op | 9.18 | 8.50–9.70 | 2048 | -| M | local, bound, moves focus | moved | 22.57 | 22.17–27.47 | 512 | -| M | document, unbound | unhandled | 2.57 | 2.55–2.63 | 4096 | -| M | document, bound, no move | consumed no-op | 8.73 | 8.37–10.59 | 1024 | -| M | document, bound, moves focus | moved | 22.49 | 22.22–26.24 | 512 | -| L | local, unbound | unhandled | 2.59 | 2.57–2.62 | 4096 | -| L | local, bound, no move | consumed no-op | 9.09 | 9.01–9.83 | 2048 | -| L | local, bound, moves focus | moved | 22.16 | 21.82–22.90 | 512 | -| L | document, unbound | unhandled | 2.57 | 2.55–2.65 | 4096 | -| L | document, bound, no move | consumed no-op | 8.77 | 8.50–10.00 | 2048 | -| L | document, bound, moves focus | moved | 24.50 | 23.91–25.21 | 512 | +| S | local, unbound | unhandled | 2.55 | 2.53–2.60 | 4096 | +| S | local, bound, no move | consumed no-op | 9.57 | 8.76–10.22 | 1024 | +| S | local, bound, moves focus | moved | 22.94 | 22.60–24.81 | 512 | +| S | document, unbound | unhandled | 2.52 | 2.50–2.70 | 4096 | +| S | document, bound, no move | consumed no-op | 8.98 | 8.70–10.26 | 1024 | +| S | document, bound, moves focus | moved | 23.37 | 22.86–25.44 | 512 | +| M | local, unbound | unhandled | 2.62 | 2.47–2.69 | 4096 | +| M | local, bound, no move | consumed no-op | 9.05 | 8.75–10.15 | 2048 | +| M | local, bound, moves focus | moved | 23.36 | 22.78–25.97 | 512 | +| M | document, unbound | unhandled | 2.49 | 2.47–2.51 | 4096 | +| M | document, bound, no move | consumed no-op | 9.37 | 8.89–10.31 | 2048 | +| M | document, bound, moves focus | moved | 23.10 | 22.71–26.89 | 512 | +| L | local, unbound | unhandled | 2.49 | 2.48–2.50 | 4096 | +| L | local, bound, no move | consumed no-op | 9.62 | 8.87–11.01 | 2048 | +| L | local, bound, moves focus | moved | 22.66 | 22.42–22.89 | 512 | +| L | document, unbound | unhandled | 2.46 | 2.45–2.48 | 4096 | +| L | document, bound, no move | consumed no-op | 9.00 | 8.74–10.11 | 2048 | +| L | document, bound, moves focus | moved | 22.76 | 22.48–23.03 | 512 | ### focus keys | Fixture | Case | Result | Median (µs) | p10–p90 (µs) | Batch | | ------------------ | ------------------------------- | --------- | ----------: | ------------: | ----: | -| L, 0 focus keys | 0 × attribute scan | unhandled | 2.65 | 2.60–2.76 | 4096 | -| L, 0 focus keys | 0 × focusKeys map, elements | unhandled | 2.42 | 2.35–2.50 | 8192 | -| L, 0 focus keys | 0 × focusKeys map, selectors | unhandled | 2.36 | 2.33–2.40 | 8192 | -| L, 10 focus keys | 10 × attribute scan | unhandled | 18.32 | 17.74–18.87 | 1024 | -| L, 10 focus keys | 10 × focusKeys map, elements | unhandled | 4.41 | 4.34–4.47 | 4096 | -| L, 10 focus keys | 10 × focusKeys map, selectors | unhandled | 4.97 | 4.92–5.06 | 2048 | -| L, 100 focus keys | 100 × attribute scan | unhandled | 68.46 | 67.71–68.81 | 256 | -| L, 100 focus keys | 100 × focusKeys map, elements | unhandled | 41.27 | 40.41–42.68 | 256 | -| L, 100 focus keys | 100 × focusKeys map, selectors | unhandled | 49.73 | 49.41–50.10 | 256 | -| L, 1000 focus keys | 1000 × attribute scan | unhandled | 575.78 | 560.00–599.22 | 32 | -| L, 1000 focus keys | 1000 × focusKeys map, elements | unhandled | 420.31 | 412.50–429.37 | 32 | -| L, 1000 focus keys | 1000 × focusKeys map, selectors | unhandled | 855.00 | 795.31–905.94 | 16 | -| L, 1000 focus keys | 1000 on page, local listener | unhandled | 3.43 | 3.24–4.42 | 4096 | +| L, 0 focus keys | 0 × attribute scan | unhandled | 2.49 | 2.48–2.50 | 4096 | +| L, 0 focus keys | 0 × focusKeys map, elements | unhandled | 2.25 | 2.24–2.26 | 8192 | +| L, 0 focus keys | 0 × focusKeys map, selectors | unhandled | 2.26 | 2.25–2.26 | 8192 | +| L, 10 focus keys | 10 × attribute scan | unhandled | 17.63 | 17.11–17.93 | 1024 | +| L, 10 focus keys | 10 × focusKeys map, elements | unhandled | 4.54 | 4.54–4.57 | 4096 | +| L, 10 focus keys | 10 × focusKeys map, selectors | unhandled | 5.13 | 5.10–5.18 | 2048 | +| L, 100 focus keys | 100 × attribute scan | unhandled | 69.36 | 69.04–69.75 | 256 | +| L, 100 focus keys | 100 × focusKeys map, elements | unhandled | 42.85 | 42.66–43.07 | 256 | +| L, 100 focus keys | 100 × focusKeys map, selectors | unhandled | 51.07 | 50.96–51.33 | 256 | +| L, 1000 focus keys | 1000 × attribute scan | unhandled | 608.75 | 596.25–624.38 | 16 | +| L, 1000 focus keys | 1000 × focusKeys map, elements | unhandled | 467.19 | 452.03–485.62 | 32 | +| L, 1000 focus keys | 1000 × focusKeys map, selectors | unhandled | 751.25 | 729.69–905.94 | 16 | +| L, 1000 focus keys | 1000 on page, local listener | unhandled | 3.98 | 3.87–4.31 | 4096 | ### diagnostics @@ -249,53 +267,73 @@ Recorded on 2026-09-22 in headless Chrome, by what is now | ------------------ | ----------------------------------------- | ------ | ----------: | ------------: | ------: | | L, 0 focus keys | 0 × querySelectorAll over the document | - | 0.09 | 0.08–0.13 | 131072 | | L, 0 focus keys | 0 × matchesCombo, none matching | - | 0.01 | 0.01–0.01 | 2097152 | -| L, 10 focus keys | 10 × querySelectorAll over the document | - | 9.74 | 9.44–10.12 | 1024 | -| L, 10 focus keys | 10 × matchesCombo, none matching | - | 1.09 | 1.08–1.11 | 16384 | -| L, 100 focus keys | 100 × querySelectorAll over the document | - | 24.15 | 23.53–25.67 | 512 | -| L, 100 focus keys | 100 × matchesCombo, none matching | - | 24.44 | 24.03–25.01 | 512 | -| L, 1000 focus keys | 1000 × querySelectorAll over the document | - | 141.60 | 122.81–309.26 | 128 | -| L, 1000 focus keys | 1000 × matchesCombo, none matching | - | 287.81 | 278.12–305.08 | 64 | +| L, 10 focus keys | 10 × querySelectorAll over the document | - | 8.99 | 8.88–9.07 | 2048 | +| L, 10 focus keys | 10 × matchesCombo, none matching | - | 1.03 | 1.03–1.06 | 16384 | +| L, 100 focus keys | 100 × querySelectorAll over the document | - | 22.42 | 22.32–25.62 | 512 | +| L, 100 focus keys | 100 × matchesCombo, none matching | - | 25.10 | 23.95–25.78 | 512 | +| L, 1000 focus keys | 1000 × querySelectorAll over the document | - | 113.67 | 109.80–117.54 | 128 | +| L, 1000 focus keys | 1000 × matchesCombo, none matching | - | 229.45 | 224.77–232.11 | 64 | ### columns | Fixture | Case | Result | Median (µs) | p10–p90 (µs) | Batch | | -------------- | ------------------------------- | --------- | ----------: | -----------: | ----: | -| M, cols="4" | cols="4", unbound | unhandled | 5.03 | 4.95–5.24 | 2048 | -| M, cols="4" | cols="4", bound, moves focus | moved | 26.89 | 26.68–27.72 | 512 | -| M, cols="auto" | cols="auto", unbound | unhandled | 6.75 | 6.56–7.25 | 2048 | -| M, cols="auto" | cols="auto", bound, moves focus | moved | 29.86 | 28.81–32.46 | 512 | +| M, cols="4" | cols="4", unbound | unhandled | 4.21 | 4.15–4.37 | 4096 | +| M, cols="4" | cols="4", bound, moves focus | moved | 27.55 | 26.90–28.01 | 512 | +| M, cols="auto" | cols="auto", unbound | unhandled | 5.36 | 5.32–5.63 | 2048 | +| M, cols="auto" | cols="auto", bound, moves focus | moved | 30.02 | 29.13–35.41 | 512 | + +### refused focus + +| Fixture | Case | Result | Median (µs) | p10–p90 (µs) | Batch | +| ----------------------------- | ------------------------- | ------ | ----------: | --------------: | ----: | +| 1 × display: none | local, moves past the run | moved | 23.04 | 22.02–26.06 | 512 | +| 10 × display: none | local, moves past the run | moved | 29.35 | 27.81–31.85 | 512 | +| 100 × display: none | local, moves past the run | moved | 85.94 | 83.87–98.01 | 128 | +| 1000 × display: none | local, moves past the run | moved | 659.06 | 610.62–694.06 | 32 | +| 1000 × visibility: hidden | local, moves past the run | moved | 618.12 | 600.94–718.13 | 16 | +| 1000 × display: none, roving | local, moves past the run | moved | 1439.37 | 1360.00–1623.12 | 8 | +| 1000 × display: none, skipped | local, moves past the run | moved | 335.31 | 327.34–379.06 | 32 | ## Findings 1. **Page size and listener placement do not change the cost when there are no focus keys.** From 200 to 20,000 items, and with the listener on the - group or on the document, an unbound key costs about 2.6 µs. A bound key + group or on the document, an unbound key costs about 2.5 µs. A bound key with nowhere to go costs about 9 µs, and a move, including the browser's - `focus()`, costs 22–25 µs. A move reads only the target's group. Chrome + `focus()`, costs about 23 µs. A move reads only the target's group. Chrome answers the document query for focus keys in about 0.1 µs when nothing matches, even on 61,000 elements. -2. **Focus keys in the listener's reach are the one cost that grows.** It - grows linearly with their number and is paid on every keydown the listener - handles, unbound keys included. Under a document listener, attribute - scanning costs 18 µs for 10 focus keys, 68 µs for 100 and 0.58 ms for - 1,000. A local listener stays at 3.4 µs with 1,000 focus keys elsewhere on - the page. A `focusKeys` map of elements is cheaper (0.42 ms at 1,000). A - map of selectors is the most expensive (0.86 ms), because each selector - is queried on every press. +2. **Focus keys in the listener's reach are the one cost that grows with the + page.** It grows linearly with their number and is paid on every keydown + the listener handles, unbound keys included. Under a document listener, + attribute scanning costs 18 µs for 10 focus keys, 69 µs for 100 and + 0.61 ms for 1,000. A local listener stays at 4 µs with 1,000 focus keys + elsewhere on the page. A `focusKeys` map of elements is cheaper (0.47 ms + at 1,000). A map of selectors is the most expensive (0.75 ms), because + each selector is queried on every press. 3. **At 1,000 focus keys, matching the press is the largest share.** Testing - the press against every combo with `matchesCombo` takes 288 µs, about half - of the 0.58 ms total, and the document query takes 142 µs. The rest is - reading and filtering each target and building the binding table. + the press against every combo with `matchesCombo` takes 229 µs, over a + third of the 0.61 ms total, and the document query takes 114 µs. The rest + is reading and filtering each target and building the binding table. 4. **Columns and direction cost a few microseconds.** `cols="auto"` adds - 1.7–3 µs over a fixed count. A grid's unbound key costs about 2.4 µs more - than a list's, because its default next/prev keys read the computed + 1.2–2.5 µs over a fixed count. A grid's unbound key costs about 1.6 µs + more than a list's, because its default next/prev keys read the computed direction when no `dir` attribute is set. +5. **A run of items that refuse focus costs one pass over it, and `skip` is + still the fast path.** A move past 1,000 hidden items that are not skipped + tries `focus()` on each and costs 0.66 ms with `display: none` and + 0.62 ms with `visibility: hidden`. It costs 1.4 ms when each item carries + a roving stop, which is carried onto it and put back. From 100 to 1,000 + items the cost grows 7.7 times. The same 1,000 items marked skipped cost + 0.34 ms, since no move tries to focus them. ## Conclusion No change is warranted. The costliest realistic case measured, 100 focus keys under a document listener, stays under 0.1 ms per keydown. Only the -synthetic 1,000 focus keys reach 0.4–0.9 ms. That is under a 16 ms frame, +synthetic 1,000 focus keys reach 0.5–0.8 ms, and a move past 1,000 hidden +items that are not skipped 0.6–1.4 ms. That is under a 16 ms frame, and would stay under 10 ms on a CPU ten times slower. Caching items or bindings would give up reading the current DOM on every press, and nothing measured here pays for it: page size and listener placement make no @@ -304,8 +342,8 @@ difference. If pages with hundreds of focus keys under one listener become a real use case, the measured follow-up is to memoize each combo string's parsed form inside `matchesCombo`. Parsing is a pure function of the string, so the -cache needs no DOM invalidation. It targets the largest share measured, 288 -of 576 µs at 1,000 focus keys. Run this benchmark before and after such a +cache needs no DOM invalidation. It targets the largest share measured, 229 +of 609 µs at 1,000 focus keys. Run this benchmark before and after such a change. ## Limitations diff --git a/apps/bench/results/recorded.json b/apps/bench/results/recorded.json index 23e7255..da8f3bd 100644 --- a/apps/bench/results/recorded.json +++ b/apps/bench/results/recorded.json @@ -1,15 +1,22 @@ { "environment": { - "Browser": "Chrome/153.0.8010.53 (headless), V8 15.3.76.13", + "Library": "2.6.0 (working tree at d21fa4f, with uncommitted changes)", + "Browser": "Chrome/156.0.8078.80 (headless), V8 15.6.75.27", "OS": "darwin 25.2.0 arm64", "CPU": "Apple M1 Pro × 10", "Memory": "16 GB", "Node": "v25.2.1", "Cross-origin isolated": true, "Timing": "100 ms warmup, then 15 batches of ≥ 10 ms per case", - "Date": "2026-09-22" + "Date": "2026-11-06" + }, + "library": { + "version": "2.6.0", + "commit": "d21fa4f", + "dirty": true }, "results": { + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.0.0 Safari/537.36", "hardwareConcurrency": 10, "crossOriginIsolated": true, "options": { @@ -17,66 +24,120 @@ "minBatchMs": 10, "warmupMs": 100 }, + "supports": { + "focusKeysMap": true, + "autoColumns": true, + "refusedFocus": true + }, "fixtures": [ { "name": "S", "groups": 10, - "buildMs": 1.59, - "elements": 624 + "buildMs": 1.425000011920929, + "elements": 618 }, { "name": "M", "groups": 100, - "buildMs": 11.82, - "elements": 6114 + "buildMs": 11.089999973773956, + "elements": 6108 }, { "name": "L", "groups": 1000, - "buildMs": 139.89, - "elements": 61014 + "buildMs": 123.14999997615814, + "elements": 61008 }, { "name": "L, 0 focus keys", "groups": 1000, "focusKeys": 0, - "buildMs": 172.39, - "elements": 61014 + "buildMs": 127.73500001430511, + "elements": 61008 }, { "name": "L, 10 focus keys", "groups": 1000, "focusKeys": 10, - "buildMs": 117.89, - "elements": 61014 + "buildMs": 102.68500000238419, + "elements": 61008 }, { "name": "L, 100 focus keys", "groups": 1000, "focusKeys": 100, - "buildMs": 144.97, - "elements": 61014 + "buildMs": 126.12000000476837, + "elements": 61008 }, { "name": "L, 1000 focus keys", "groups": 1000, "focusKeys": 1000, - "buildMs": 109.96, - "elements": 61014 + "buildMs": 103.35000002384186, + "elements": 61008 }, { "name": "M, cols=\"4\"", "groups": 100, "grid": "4", - "buildMs": 15.01, - "elements": 6114 + "buildMs": 11.409999966621399, + "elements": 6108 }, { "name": "M, cols=\"auto\"", "groups": 100, "grid": "auto", - "buildMs": 14.97, - "elements": 6114 + "buildMs": 10.854999959468842, + "elements": 6108 + }, + { + "name": "1 × display: none", + "groups": 1, + "items": 3, + "buildMs": 0.25, + "elements": 18 + }, + { + "name": "10 × display: none", + "groups": 1, + "items": 12, + "buildMs": 0.17000001668930054, + "elements": 45 + }, + { + "name": "100 × display: none", + "groups": 1, + "items": 102, + "buildMs": 0.3049999475479126, + "elements": 315 + }, + { + "name": "1000 × display: none", + "groups": 1, + "items": 1002, + "buildMs": 1.6150000095367432, + "elements": 3015 + }, + { + "name": "1000 × visibility: hidden", + "groups": 1, + "items": 1002, + "buildMs": 5.675000011920929, + "elements": 3015 + }, + { + "name": "1000 × display: none, roving", + "groups": 1, + "items": 1002, + "buildMs": 1.6200000047683716, + "elements": 3015 + }, + { + "name": "1000 × display: none, skipped", + "groups": 1, + "items": 1002, + "buildMs": 1.4550000429153442, + "elements": 3015 } ], "cases": [ @@ -86,14 +147,14 @@ "fixture": { "name": "S", "groups": 10, - "buildMs": 1.59, - "elements": 624 + "buildMs": 1.425000011920929, + "elements": 618 }, "result": "-", - "median": 0, - "p10": 0, - "p90": 0.01, - "batch": 2097152 + "batch": 16777216, + "median": 0.00045627355760302635, + "p10": 0.0004500150652120283, + "p90": 0.0005593895906486068 }, { "section": "listener", @@ -101,14 +162,14 @@ "fixture": { "name": "S", "groups": 10, - "buildMs": 1.59, - "elements": 624 + "buildMs": 1.425000011920929, + "elements": 618 }, "result": "unhandled", - "median": 2.68, - "p10": 2.64, - "p90": 2.93, - "batch": 4096 + "batch": 4096, + "median": 2.54638672049623, + "p10": 2.5256347726099193, + "p90": 2.6013183523900807 }, { "section": "listener", @@ -116,14 +177,14 @@ "fixture": { "name": "S", "groups": 10, - "buildMs": 1.59, - "elements": 624 + "buildMs": 1.425000011920929, + "elements": 618 }, "result": "consumed no-op", - "median": 9.43, - "p10": 8.6, - "p90": 10.01, - "batch": 1024 + "batch": 1024, + "median": 9.565429645590484, + "p10": 8.764648460783064, + "p90": 10.224609344732016 }, { "section": "listener", @@ -131,14 +192,14 @@ "fixture": { "name": "S", "groups": 10, - "buildMs": 1.59, - "elements": 624 + "buildMs": 1.425000011920929, + "elements": 618 }, "result": "moved", - "median": 24.7, - "p10": 23.3, - "p90": 27.14, - "batch": 512 + "batch": 512, + "median": 22.939453134313226, + "p10": 22.59765623603016, + "p90": 24.81445309240371 }, { "section": "listener", @@ -146,14 +207,14 @@ "fixture": { "name": "S", "groups": 10, - "buildMs": 1.59, - "elements": 624 + "buildMs": 1.425000011920929, + "elements": 618 }, "result": "unhandled", - "median": 2.69, - "p10": 2.63, - "p90": 3, - "batch": 4096 + "batch": 4096, + "median": 2.5183105462929234, + "p10": 2.495117179933004, + "p90": 2.6965332071995363 }, { "section": "listener", @@ -161,14 +222,14 @@ "fixture": { "name": "S", "groups": 10, - "buildMs": 1.59, - "elements": 624 + "buildMs": 1.425000011920929, + "elements": 618 }, "result": "consumed no-op", - "median": 9.85, - "p10": 9.45, - "p90": 10.28, - "batch": 2048 + "batch": 1024, + "median": 8.984374988358468, + "p10": 8.696289034560323, + "p90": 10.258789057843387 }, { "section": "listener", @@ -176,14 +237,14 @@ "fixture": { "name": "S", "groups": 10, - "buildMs": 1.59, - "elements": 624 + "buildMs": 1.425000011920929, + "elements": 618 }, "result": "moved", - "median": 23.31, - "p10": 22.33, - "p90": 25.07, - "batch": 512 + "batch": 512, + "median": 23.36914069019258, + "p10": 22.86132809240371, + "p90": 25.43945307843387 }, { "section": "listener", @@ -191,14 +252,14 @@ "fixture": { "name": "M", "groups": 100, - "buildMs": 11.82, - "elements": 6114 + "buildMs": 11.089999973773956, + "elements": 6108 }, "result": "unhandled", - "median": 2.56, - "p10": 2.55, - "p90": 2.69, - "batch": 4096 + "batch": 4096, + "median": 2.6196288963546976, + "p10": 2.474365246598609, + "p90": 2.6928710867650807 }, { "section": "listener", @@ -206,14 +267,14 @@ "fixture": { "name": "M", "groups": 100, - "buildMs": 11.82, - "elements": 6114 + "buildMs": 11.089999973773956, + "elements": 6108 }, "result": "consumed no-op", - "median": 9.18, - "p10": 8.5, - "p90": 9.7, - "batch": 2048 + "batch": 2048, + "median": 9.045410173712298, + "p10": 8.745117171201855, + "p90": 10.148925764951855 }, { "section": "listener", @@ -221,14 +282,14 @@ "fixture": { "name": "M", "groups": 100, - "buildMs": 11.82, - "elements": 6114 + "buildMs": 11.089999973773956, + "elements": 6108 }, "result": "moved", - "median": 22.57, - "p10": 22.17, - "p90": 27.47, - "batch": 512 + "batch": 512, + "median": 23.359375074505806, + "p10": 22.783203166909516, + "p90": 25.96679690759629 }, { "section": "listener", @@ -236,14 +297,14 @@ "fixture": { "name": "M", "groups": 100, - "buildMs": 11.82, - "elements": 6114 + "buildMs": 11.089999973773956, + "elements": 6108 }, "result": "unhandled", - "median": 2.57, - "p10": 2.55, - "p90": 2.63, - "batch": 4096 + "batch": 4096, + "median": 2.4877929681679234, + "p10": 2.4682617076905444, + "p90": 2.508544916054234 }, { "section": "listener", @@ -251,14 +312,14 @@ "fixture": { "name": "M", "groups": 100, - "buildMs": 11.82, - "elements": 6114 + "buildMs": 11.089999973773956, + "elements": 6108 }, "result": "consumed no-op", - "median": 8.73, - "p10": 8.37, - "p90": 10.59, - "batch": 1024 + "batch": 2048, + "median": 9.367675782414153, + "p10": 8.891601552022621, + "p90": 10.31494140625 }, { "section": "listener", @@ -266,14 +327,14 @@ "fixture": { "name": "M", "groups": 100, - "buildMs": 11.82, - "elements": 6114 + "buildMs": 11.089999973773956, + "elements": 6108 }, "result": "moved", - "median": 22.49, - "p10": 22.22, - "p90": 26.24, - "batch": 512 + "batch": 512, + "median": 23.095703101716936, + "p10": 22.714843740686774, + "p90": 26.894531212747097 }, { "section": "listener", @@ -281,14 +342,14 @@ "fixture": { "name": "L", "groups": 1000, - "buildMs": 139.89, - "elements": 61014 + "buildMs": 123.14999997615814, + "elements": 61008 }, "result": "unhandled", - "median": 2.59, - "p10": 2.57, - "p90": 2.62, - "batch": 4096 + "batch": 4096, + "median": 2.4926757760113105, + "p10": 2.476806635968387, + "p90": 2.503662108210847 }, { "section": "listener", @@ -296,14 +357,14 @@ "fixture": { "name": "L", "groups": 1000, - "buildMs": 139.89, - "elements": 61014 + "buildMs": 123.14999997615814, + "elements": 61008 }, "result": "consumed no-op", - "median": 9.09, - "p10": 9.01, - "p90": 9.83, - "batch": 2048 + "batch": 2048, + "median": 9.621582023100927, + "p10": 8.867187512805685, + "p90": 11.005859385477379 }, { "section": "listener", @@ -311,14 +372,14 @@ "fixture": { "name": "L", "groups": 1000, - "buildMs": 139.89, - "elements": 61014 + "buildMs": 123.14999997615814, + "elements": 61008 }, "result": "moved", - "median": 22.16, - "p10": 21.82, - "p90": 22.9, - "batch": 512 + "batch": 512, + "median": 22.65625004656613, + "p10": 22.421875037252903, + "p90": 22.890624939464033 }, { "section": "listener", @@ -326,14 +387,14 @@ "fixture": { "name": "L", "groups": 1000, - "buildMs": 139.89, - "elements": 61014 + "buildMs": 123.14999997615814, + "elements": 61008 }, "result": "unhandled", - "median": 2.57, - "p10": 2.55, - "p90": 2.65, - "batch": 4096 + "batch": 4096, + "median": 2.4609374959254637, + "p10": 2.4487304617650807, + "p90": 2.4792480398900807 }, { "section": "listener", @@ -341,14 +402,14 @@ "fixture": { "name": "L", "groups": 1000, - "buildMs": 139.89, - "elements": 61014 + "buildMs": 123.14999997615814, + "elements": 61008 }, "result": "consumed no-op", - "median": 8.77, - "p10": 8.5, - "p90": 10, - "batch": 2048 + "batch": 2048, + "median": 8.996582037070766, + "p10": 8.740234392462298, + "p90": 10.114746080944315 }, { "section": "listener", @@ -356,14 +417,14 @@ "fixture": { "name": "L", "groups": 1000, - "buildMs": 139.89, - "elements": 61014 + "buildMs": 123.14999997615814, + "elements": 61008 }, "result": "moved", - "median": 24.5, - "p10": 23.91, - "p90": 25.21, - "batch": 512 + "batch": 512, + "median": 22.763671935535967, + "p10": 22.48046873137355, + "p90": 23.027343675494194 }, { "section": "focus keys", @@ -372,14 +433,14 @@ "name": "L, 0 focus keys", "groups": 1000, "focusKeys": 0, - "buildMs": 172.39, - "elements": 61014 + "buildMs": 127.73500001430511, + "elements": 61008 }, "result": "unhandled", - "median": 2.65, - "p10": 2.6, - "p90": 2.76, - "batch": 4096 + "batch": 4096, + "median": 2.48535156424623, + "p10": 2.4816894438117743, + "p90": 2.4987793003674597 }, { "section": "focus keys", @@ -388,14 +449,14 @@ "name": "L, 0 focus keys", "groups": 1000, "focusKeys": 0, - "buildMs": 172.39, - "elements": 61014 + "buildMs": 127.73500001430511, + "elements": 61008 }, "result": "unhandled", - "median": 2.42, - "p10": 2.35, - "p90": 2.5, - "batch": 8192 + "batch": 8192, + "median": 2.2503662112285383, + "p10": 2.2430419921875, + "p90": 2.259521483210847 }, { "section": "focus keys", @@ -404,14 +465,46 @@ "name": "L, 0 focus keys", "groups": 1000, "focusKeys": 0, - "buildMs": 172.39, - "elements": 61014 + "buildMs": 127.73500001430511, + "elements": 61008 }, "result": "unhandled", - "median": 2.36, - "p10": 2.33, - "p90": 2.4, - "batch": 8192 + "batch": 8192, + "median": 2.255249026347883, + "p10": 2.2473144563264214, + "p90": 2.260742192447651 + }, + { + "section": "diagnostics", + "label": "0 × querySelectorAll over the document", + "fixture": { + "name": "L, 0 focus keys", + "groups": 1000, + "focusKeys": 0, + "buildMs": 127.73500001430511, + "elements": 61008 + }, + "result": "-", + "batch": 131072, + "median": 0.08541107172277407, + "p10": 0.0762939453125, + "p90": 0.13320922835191595 + }, + { + "section": "diagnostics", + "label": "0 × matchesCombo, none matching", + "fixture": { + "name": "L, 0 focus keys", + "groups": 1000, + "focusKeys": 0, + "buildMs": 127.73500001430511, + "elements": 61008 + }, + "result": "-", + "batch": 2097152, + "median": 0.005037784575279147, + "p10": 0.005023479474175474, + "p90": 0.00511407850467549 }, { "section": "focus keys", @@ -420,14 +513,14 @@ "name": "L, 10 focus keys", "groups": 1000, "focusKeys": 10, - "buildMs": 117.89, - "elements": 61014 + "buildMs": 102.68500000238419, + "elements": 61008 }, "result": "unhandled", - "median": 18.32, - "p10": 17.74, - "p90": 18.87, - "batch": 1024 + "batch": 1024, + "median": 17.63183594448492, + "p10": 17.10937503958121, + "p90": 17.934570321813226 }, { "section": "focus keys", @@ -436,14 +529,14 @@ "name": "L, 10 focus keys", "groups": 1000, "focusKeys": 10, - "buildMs": 117.89, - "elements": 61014 + "buildMs": 102.68500000238419, + "elements": 61008 }, "result": "unhandled", - "median": 4.41, - "p10": 4.34, - "p90": 4.47, - "batch": 4096 + "batch": 4096, + "median": 4.542236332781613, + "p10": 4.536132808425464, + "p90": 4.566650386550464 }, { "section": "focus keys", @@ -452,14 +545,46 @@ "name": "L, 10 focus keys", "groups": 1000, "focusKeys": 10, - "buildMs": 117.89, - "elements": 61014 + "buildMs": 102.68500000238419, + "elements": 61008 }, "result": "unhandled", - "median": 4.97, - "p10": 4.92, - "p90": 5.06, - "batch": 2048 + "batch": 2048, + "median": 5.131835961947218, + "p10": 5.095214844914153, + "p90": 5.178222636459395 + }, + { + "section": "diagnostics", + "label": "10 × querySelectorAll over the document", + "fixture": { + "name": "L, 10 focus keys", + "groups": 1000, + "focusKeys": 10, + "buildMs": 102.68500000238419, + "elements": 61008 + }, + "result": "-", + "batch": 2048, + "median": 8.989257796201855, + "p10": 8.881835936335847, + "p90": 9.072265616850927 + }, + { + "section": "diagnostics", + "label": "10 × matchesCombo, none matching", + "fixture": { + "name": "L, 10 focus keys", + "groups": 1000, + "focusKeys": 10, + "buildMs": 102.68500000238419, + "elements": 61008 + }, + "result": "-", + "batch": 16384, + "median": 1.0278320296492893, + "p10": 1.0263061521982308, + "p90": 1.0583496077742893 }, { "section": "focus keys", @@ -468,14 +593,14 @@ "name": "L, 100 focus keys", "groups": 1000, "focusKeys": 100, - "buildMs": 144.97, - "elements": 61014 + "buildMs": 126.12000000476837, + "elements": 61008 }, "result": "unhandled", - "median": 68.46, - "p10": 67.71, - "p90": 68.81, - "batch": 256 + "batch": 256, + "median": 69.35546873137355, + "p10": 69.04296856373549, + "p90": 69.7460938245058 }, { "section": "focus keys", @@ -484,14 +609,14 @@ "name": "L, 100 focus keys", "groups": 1000, "focusKeys": 100, - "buildMs": 144.97, - "elements": 61014 + "buildMs": 126.12000000476837, + "elements": 61008 }, "result": "unhandled", - "median": 41.27, - "p10": 40.41, - "p90": 42.68, - "batch": 256 + "batch": 256, + "median": 42.851562378928065, + "p10": 42.65625006519258, + "p90": 43.066406389698386 }, { "section": "focus keys", @@ -500,14 +625,46 @@ "name": "L, 100 focus keys", "groups": 1000, "focusKeys": 100, - "buildMs": 144.97, - "elements": 61014 + "buildMs": 126.12000000476837, + "elements": 61008 }, "result": "unhandled", - "median": 49.73, - "p10": 49.41, - "p90": 50.1, - "batch": 256 + "batch": 256, + "median": 51.07421870343387, + "p10": 50.95703131519258, + "p90": 51.328124944120646 + }, + { + "section": "diagnostics", + "label": "100 × querySelectorAll over the document", + "fixture": { + "name": "L, 100 focus keys", + "groups": 1000, + "focusKeys": 100, + "buildMs": 126.12000000476837, + "elements": 61008 + }, + "result": "-", + "batch": 512, + "median": 22.421875037252903, + "p10": 22.32421876396984, + "p90": 25.61523439362645 + }, + { + "section": "diagnostics", + "label": "100 × matchesCombo, none matching", + "fixture": { + "name": "L, 100 focus keys", + "groups": 1000, + "focusKeys": 100, + "buildMs": 126.12000000476837, + "elements": 61008 + }, + "result": "-", + "batch": 512, + "median": 25.09765629656613, + "p10": 23.94531248137355, + "p90": 25.781249976716936 }, { "section": "focus keys", @@ -516,14 +673,14 @@ "name": "L, 1000 focus keys", "groups": 1000, "focusKeys": 1000, - "buildMs": 109.96, - "elements": 61014 + "buildMs": 103.35000002384186, + "elements": 61008 }, "result": "unhandled", - "median": 575.78, - "p10": 560, - "p90": 599.22, - "batch": 32 + "batch": 16, + "median": 608.7500005960464, + "p10": 596.2499976158142, + "p90": 624.3750005960464 }, { "section": "focus keys", @@ -532,14 +689,14 @@ "name": "L, 1000 focus keys", "groups": 1000, "focusKeys": 1000, - "buildMs": 109.96, - "elements": 61014 + "buildMs": 103.35000002384186, + "elements": 61008 }, "result": "unhandled", - "median": 420.31, - "p10": 412.5, - "p90": 429.37, - "batch": 32 + "batch": 32, + "median": 467.18749962747097, + "p10": 452.03124918043613, + "p90": 485.6249988079071 }, { "section": "focus keys", @@ -548,14 +705,14 @@ "name": "L, 1000 focus keys", "groups": 1000, "focusKeys": 1000, - "buildMs": 109.96, - "elements": 61014 + "buildMs": 103.35000002384186, + "elements": 61008 }, "result": "unhandled", - "median": 855, - "p10": 795.31, - "p90": 905.94, - "batch": 16 + "batch": 16, + "median": 751.2499988079071, + "p10": 729.6875007450581, + "p90": 905.9375002980232 }, { "section": "focus keys", @@ -564,110 +721,14 @@ "name": "L, 1000 focus keys", "groups": 1000, "focusKeys": 1000, - "buildMs": 109.96, - "elements": 61014 + "buildMs": 103.35000002384186, + "elements": 61008 }, "result": "unhandled", - "median": 3.43, - "p10": 3.24, - "p90": 4.42, - "batch": 4096 - }, - { - "section": "diagnostics", - "label": "0 × querySelectorAll over the document", - "fixture": { - "name": "L, 0 focus keys", - "groups": 1000, - "focusKeys": 0, - "buildMs": 172.39, - "elements": 61014 - }, - "result": "-", - "median": 0.09, - "p10": 0.08, - "p90": 0.13, - "batch": 131072 - }, - { - "section": "diagnostics", - "label": "0 × matchesCombo, none matching", - "fixture": { - "name": "L, 0 focus keys", - "groups": 1000, - "focusKeys": 0, - "buildMs": 172.39, - "elements": 61014 - }, - "result": "-", - "median": 0.01, - "p10": 0.01, - "p90": 0.01, - "batch": 2097152 - }, - { - "section": "diagnostics", - "label": "10 × querySelectorAll over the document", - "fixture": { - "name": "L, 10 focus keys", - "groups": 1000, - "focusKeys": 10, - "buildMs": 117.89, - "elements": 61014 - }, - "result": "-", - "median": 9.74, - "p10": 9.44, - "p90": 10.12, - "batch": 1024 - }, - { - "section": "diagnostics", - "label": "10 × matchesCombo, none matching", - "fixture": { - "name": "L, 10 focus keys", - "groups": 1000, - "focusKeys": 10, - "buildMs": 117.89, - "elements": 61014 - }, - "result": "-", - "median": 1.09, - "p10": 1.08, - "p90": 1.11, - "batch": 16384 - }, - { - "section": "diagnostics", - "label": "100 × querySelectorAll over the document", - "fixture": { - "name": "L, 100 focus keys", - "groups": 1000, - "focusKeys": 100, - "buildMs": 144.97, - "elements": 61014 - }, - "result": "-", - "median": 24.15, - "p10": 23.53, - "p90": 25.67, - "batch": 512 - }, - { - "section": "diagnostics", - "label": "100 × matchesCombo, none matching", - "fixture": { - "name": "L, 100 focus keys", - "groups": 1000, - "focusKeys": 100, - "buildMs": 144.97, - "elements": 61014 - }, - "result": "-", - "median": 24.44, - "p10": 24.03, - "p90": 25.01, - "batch": 512 + "batch": 4096, + "median": 3.98437499825377, + "p10": 3.868408195558004, + "p90": 4.312744131311774 }, { "section": "diagnostics", @@ -676,14 +737,14 @@ "name": "L, 1000 focus keys", "groups": 1000, "focusKeys": 1000, - "buildMs": 109.96, - "elements": 61014 + "buildMs": 103.35000002384186, + "elements": 61008 }, "result": "-", - "median": 141.6, - "p10": 122.81, - "p90": 309.26, - "batch": 128 + "batch": 128, + "median": 113.67187509313226, + "p10": 109.80468709021807, + "p90": 117.53906263038516 }, { "section": "diagnostics", @@ -692,14 +753,14 @@ "name": "L, 1000 focus keys", "groups": 1000, "focusKeys": 1000, - "buildMs": 109.96, - "elements": 61014 + "buildMs": 103.35000002384186, + "elements": 61008 }, "result": "-", - "median": 287.81, - "p10": 278.12, - "p90": 305.08, - "batch": 64 + "batch": 64, + "median": 229.4531250372529, + "p10": 224.7656248509884, + "p90": 232.10937436670065 }, { "section": "columns", @@ -708,14 +769,14 @@ "name": "M, cols=\"4\"", "groups": 100, "grid": "4", - "buildMs": 15.01, - "elements": 6114 + "buildMs": 11.409999966621399, + "elements": 6108 }, "result": "unhandled", - "median": 5.03, - "p10": 4.95, - "p90": 5.24, - "batch": 2048 + "batch": 4096, + "median": 4.21142578125, + "p10": 4.14672851911746, + "p90": 4.36767577775754 }, { "section": "columns", @@ -724,14 +785,14 @@ "name": "M, cols=\"4\"", "groups": 100, "grid": "4", - "buildMs": 15.01, - "elements": 6114 + "buildMs": 11.409999966621399, + "elements": 6108 }, "result": "moved", - "median": 26.89, - "p10": 26.68, - "p90": 27.72, - "batch": 512 + "batch": 512, + "median": 27.548828162252903, + "p10": 26.90429682843387, + "p90": 28.00781256519258 }, { "section": "columns", @@ -740,14 +801,14 @@ "name": "M, cols=\"auto\"", "groups": 100, "grid": "auto", - "buildMs": 14.97, - "elements": 6114 + "buildMs": 10.854999959468842, + "elements": 6108 }, "result": "unhandled", - "median": 6.75, - "p10": 6.56, - "p90": 7.25, - "batch": 2048 + "batch": 2048, + "median": 5.363769509131089, + "p10": 5.324707017280161, + "p90": 5.634765635477379 }, { "section": "columns", @@ -756,15 +817,128 @@ "name": "M, cols=\"auto\"", "groups": 100, "grid": "auto", - "buildMs": 14.97, - "elements": 6114 + "buildMs": 10.854999959468842, + "elements": 6108 + }, + "result": "moved", + "batch": 512, + "median": 30.019531259313226, + "p10": 29.130859416909516, + "p90": 35.410156240686774 + }, + { + "section": "refused focus", + "label": "local, moves past the run", + "fixture": { + "name": "1 × display: none", + "groups": 1, + "items": 3, + "buildMs": 0.25, + "elements": 18 + }, + "result": "moved", + "batch": 512, + "median": 23.03710940759629, + "p10": 22.02148432843387, + "p90": 26.064453180879354 + }, + { + "section": "refused focus", + "label": "local, moves past the run", + "fixture": { + "name": "10 × display: none", + "groups": 1, + "items": 12, + "buildMs": 0.17000001668930054, + "elements": 45 + }, + "result": "moved", + "batch": 512, + "median": 29.34570307843387, + "p10": 27.81250001862645, + "p90": 31.84570313896984 + }, + { + "section": "refused focus", + "label": "local, moves past the run", + "fixture": { + "name": "100 × display: none", + "groups": 1, + "items": 102, + "buildMs": 0.3049999475479126, + "elements": 315 }, "result": "moved", - "median": 29.86, - "p10": 28.81, - "p90": 32.46, - "batch": 512 + "batch": 128, + "median": 85.9375, + "p10": 83.86718761175871, + "p90": 98.00781263038516 + }, + { + "section": "refused focus", + "label": "local, moves past the run", + "fixture": { + "name": "1000 × display: none", + "groups": 1, + "items": 1002, + "buildMs": 1.6150000095367432, + "elements": 3015 + }, + "result": "moved", + "batch": 32, + "median": 659.0625010430813, + "p10": 610.6249988079071, + "p90": 694.0625011920929 + }, + { + "section": "refused focus", + "label": "local, moves past the run", + "fixture": { + "name": "1000 × visibility: hidden", + "groups": 1, + "items": 1002, + "buildMs": 5.675000011920929, + "elements": 3015 + }, + "result": "moved", + "batch": 16, + "median": 618.1249991059303, + "p10": 600.9375005960464, + "p90": 718.1250005960464 + }, + { + "section": "refused focus", + "label": "local, moves past the run", + "fixture": { + "name": "1000 × display: none, roving", + "groups": 1, + "items": 1002, + "buildMs": 1.6200000047683716, + "elements": 3015 + }, + "result": "moved", + "batch": 8, + "median": 1439.3749982118607, + "p10": 1359.9999994039536, + "p90": 1623.1249943375587 + }, + { + "section": "refused focus", + "label": "local, moves past the run", + "fixture": { + "name": "1000 × display: none, skipped", + "groups": 1, + "items": 1002, + "buildMs": 1.4550000429153442, + "elements": 3015 + }, + "result": "moved", + "batch": 32, + "median": 335.31250059604645, + "p10": 327.3437488824129, + "p90": 379.0624998509884 } - ] + ], + "skipped": [] } } diff --git a/apps/bench/src/keydown.ts b/apps/bench/src/keydown.ts index ee4ba85..d07a265 100644 --- a/apps/bench/src/keydown.ts +++ b/apps/bench/src/keydown.ts @@ -28,6 +28,7 @@ import { FOCUS_KEY_COUNTS, GRIDS, ITEMS_PER_GROUP, + REFUSALS, SIZES, } from './plan.ts'; import type { @@ -35,6 +36,7 @@ import type { BenchOptions, Fixture, Outcome, + RefusalSpec, RunBench, SkippedCase, Supports, @@ -62,6 +64,13 @@ const event = ( type PageSpec = { groups: number; focusKeys?: number; grid?: string }; +/** What a fixture is built from: everything it reports but its measurements. */ +type FixtureSpec = Omit; + +/** Item `n`: a button holding two spans, three elements in all. */ +const itemMarkup = (id: string, n: number, attributes = '') => + ``; + /** * A page of `groups` roots with 20 button items each, every item holding two * spans: three elements per item. `focusKeys` items spread evenly over the @@ -69,8 +78,7 @@ type PageSpec = { groups: number; focusKeys?: number; grid?: string }; * presses. `grid` lays each group out as a four-column CSS grid, with `cols` * set to `4` or `auto`. */ -const buildPage = ({ groups, focusKeys = 0, grid }: PageSpec) => { - const start = performance.now(); +const pageMarkup = ({ groups, focusKeys = 0, grid }: PageSpec) => { const total = groups * ITEMS_PER_GROUP; const every = focusKeys ? Math.floor(total / focusKeys) : 0; const parts: string[] = []; @@ -87,14 +95,50 @@ const buildPage = ({ groups, focusKeys = 0, grid }: PageSpec) => { every && n % every === 0 && n / every < focusKeys ? ` data-keyrove-focus-key="ctrl+alt+F${n / every + 1}"` : ''; - parts.push( - ``, - ); + parts.push(itemMarkup(`g${g}i${i}`, i, key)); } parts.push(''); } - host().innerHTML = parts.join(''); + return parts.join(''); +}; + +/** + * One group: a visible item, `count` items hidden by `hidden`, and a visible + * item. With `roving` every item carries a roving tab stop, held by the + * first; with `skip` the hidden run is marked skipped. + */ +const refusalMarkup = ({ count, hidden, roving, skip }: RefusalSpec) => { + const stop = (holds: boolean) => + roving ? ` data-keyrove-roving-tabindex tabindex="${holds ? 0 : -1}"` : ''; + const style = hidden.replace(' ', ''); + const parts = [ + '
', + itemMarkup('first', 0, stop(true)), + ]; + + for (let i = 0; i < count; i++) { + parts.push( + itemMarkup( + `hidden${i}`, + i + 1, + `${stop(false)} style="${style}"${skip ? ' data-keyrove-skip' : ''}`, + ), + ); + } + parts.push(itemMarkup('last', count + 1, stop(false)), '
'); + + return parts.join(''); +}; + +/** The name a refused-focus page goes by. */ +const refusalName = ({ count, hidden, roving, skip }: RefusalSpec) => + `${count} × ${hidden}${roving ? ', roving' : ''}${skip ? ', skipped' : ''}`; + +/** Builds `markup` into the fixture host and lays it out, timing both. */ +const build = (markup: () => string) => { + const start = performance.now(); + host().innerHTML = markup(); // Layout now, so the first timed call does not pay for it. void host().offsetHeight; @@ -104,7 +148,7 @@ const buildPage = ({ groups, focusKeys = 0, grid }: PageSpec) => { }; }; -/** The same focus keys as `buildPage` declares, as a `focusKeys` map. */ +/** The same focus keys as `pageMarkup` declares, as a `focusKeys` map. */ const focusKeyMap = (count: number, values: 'elements' | 'selectors') => { const map: Record = {}; const targets = Array.from( @@ -226,12 +270,25 @@ const probe = (): Supports => { return keyRove(event('ArrowDown', items[0], root))?.to === items[2]; }); + // A hidden item between two others: ArrowDown reaches the third only if + // the move walks on past the item that refuses focus. + const refusedFocus = tryIt(() => { + host().innerHTML = + '
'; + const root = host().firstElementChild!; + const items = Array.from(root.querySelectorAll('button')); + items[0].focus(); + return keyRove(event('ArrowDown', items[0], root))?.to === items[2]; + }); + host().innerHTML = ''; - return { focusKeysMap, autoColumns }; + return { focusKeysMap, autoColumns, refusedFocus }; }; const NO_FOCUS_KEYS_MAP = 'this version has no focusKeys option'; const NO_AUTO_COLUMNS = 'this version cannot read columns from the layout'; +const NO_REFUSED_FOCUS = + 'this version stops at an item that refuses focus instead of moving past it'; export const runBench: RunBench = async ({ samples = DEFAULT_OPTIONS.samples, @@ -290,11 +347,15 @@ export const runBench: RunBench = async ({ await yieldToBrowser(); }; - const page = async (name: string, spec: PageSpec) => { + const page = async ( + name: string, + spec: Spec, + markup: (spec: Spec) => string, + ) => { signal?.throwIfAborted(); host().innerHTML = ''; await yieldToBrowser(); - const built = buildPage(spec); + const built = build(() => markup(spec)); const fixture: Fixture = { name, ...spec, ...built }; fixtures.push(fixture); onProgress?.({ @@ -310,7 +371,7 @@ export const runBench: RunBench = async ({ try { // A: listener placement, by key and page size, with no focus keys. for (const [name, groups] of SIZES) { - const { fixture, group } = await page(name, { groups }); + const { fixture, group } = await page(name, { groups }, pageMarkup); // The floor, once: the harness building the event and calling nothing. if (name === 'S') { @@ -346,10 +407,11 @@ export const runBench: RunBench = async ({ // B: focus-key lookup on the large page, document listener, unbound key. for (const count of FOCUS_KEY_COUNTS) { - const { fixture, group } = await page(`L, ${count} focus keys`, { - groups: 1000, - focusKeys: count, - }); + const { fixture, group } = await page( + `L, ${count} focus keys`, + { groups: 1000, focusKeys: count }, + pageMarkup, + ); const first = group.firstElementChild as HTMLElement; const unbound = () => 'KeyQ'; @@ -432,7 +494,11 @@ export const runBench: RunBench = async ({ continue; } - const { fixture, group } = await page(name, { groups: 100, grid }); + const { fixture, group } = await page( + name, + { groups: 100, grid }, + pageMarkup, + ); for (const [key, start, code] of keys(group, { prev: 'ArrowLeft', @@ -449,6 +515,35 @@ export const runBench: RunBench = async ({ ); } } + + // D: a move past a run of items that refuse focus, local listener. Each + // press goes from one visible item to the other, past the whole run. + for (const refusal of REFUSALS) { + const name = refusalName(refusal); + const label = 'local, moves past the run'; + + // Skipped items are never tried, in any version. + if (!refusal.skip && !supports.refusedFocus) { + skip({ + section: 'refused focus', + label, + fixture: name, + reason: NO_REFUSED_FOCUS, + }); + continue; + } + + const { fixture, group } = await page( + name, + { groups: 1, items: refusal.count + 2 }, + () => refusalMarkup(refusal), + ); + const first = group.firstElementChild as HTMLElement; + + await run('refused focus', label, fixture, first, group, () => + document.activeElement === first ? 'ArrowDown' : 'ArrowUp', + ); + } } finally { host().innerHTML = ''; } diff --git a/apps/bench/src/plan.ts b/apps/bench/src/plan.ts index a565185..73e32db 100644 --- a/apps/bench/src/plan.ts +++ b/apps/bench/src/plan.ts @@ -1,4 +1,4 @@ -import type { BenchOptions } from './types.ts'; +import type { BenchOptions, RefusalSpec } from './types.ts'; /** * What a run covers: the pages it builds and the settings it times with. @@ -18,6 +18,21 @@ export const FOCUS_KEY_COUNTS = [0, 10, 100, 1000]; export const GRIDS = ['4', 'auto']; +/** + * The refused-focus pages: one group holding a run of items that refuse focus + * between the two the press moves between, hidden one way or another. The + * last is the same run marked skipped, which is never tried. + */ +export const REFUSALS: RefusalSpec[] = [ + { count: 1, hidden: 'display: none' }, + { count: 10, hidden: 'display: none' }, + { count: 100, hidden: 'display: none' }, + { count: 1000, hidden: 'display: none' }, + { count: 1000, hidden: 'visibility: hidden' }, + { count: 1000, hidden: 'display: none', roving: true }, + { count: 1000, hidden: 'display: none', skip: true }, +]; + export const DEFAULT_OPTIONS: BenchOptions = { samples: 15, minBatchMs: 10, @@ -39,4 +54,6 @@ export const CASE_COUNT = // diagnostics: two parts of the lookup, per count. FOCUS_KEY_COUNTS.length * 2 + // columns: the unbound key and a move, per grid. - GRIDS.length * 2; + GRIDS.length * 2 + + // refused focus: a move past the run, per page. + REFUSALS.length; diff --git a/apps/bench/src/report.ts b/apps/bench/src/report.ts index 26d4a44..6b802ff 100644 --- a/apps/bench/src/report.ts +++ b/apps/bench/src/report.ts @@ -1,4 +1,3 @@ -import { ITEMS_PER_GROUP } from './plan.ts'; import { caseRows, compareCases, @@ -7,6 +6,7 @@ import { formatChange, formatTime, indexCases, + itemCount, sectionsOf, skipReason, summarize, @@ -37,7 +37,7 @@ export const toMarkdown = ( '| ------- | -----: | ----: | ---------: | ---- | -------: | ---------: |', ...results.fixtures.map( (fixture) => - `| ${fixture.name} | ${fixture.groups} | ${fixture.groups * ITEMS_PER_GROUP} | ${fixture.focusKeys ?? 0} | ${fixture.grid ?? '-'} | ${fixture.elements} | ${fixture.buildMs.toFixed(2)} |`, + `| ${fixture.name} | ${fixture.groups} | ${itemCount(fixture)} | ${fixture.focusKeys ?? 0} | ${fixture.grid ?? '-'} | ${fixture.elements} | ${fixture.buildMs.toFixed(2)} |`, ), ]; diff --git a/apps/bench/src/results.ts b/apps/bench/src/results.ts index 94b65e2..002477a 100644 --- a/apps/bench/src/results.ts +++ b/apps/bench/src/results.ts @@ -3,17 +3,23 @@ * here touches the DOM, so the same code can serve a page built elsewhere. */ +import { ITEMS_PER_GROUP } from './plan.ts'; import type { BenchCase, BenchOptions, BenchReport, BenchResults, + Fixture, LibraryInfo, } from './types.ts'; /** A 60 Hz frame, in microseconds: the budget a keydown shares. */ const FRAME_US = 1_000_000 / 60; +/** How many items a fixture's page holds. */ +export const itemCount = (fixture: Fixture) => + fixture.items ?? fixture.groups * ITEMS_PER_GROUP; + /** Section names in the order the run recorded them. */ export const sectionsOf = (results: BenchResults) => [ ...new Set(results.cases.map((each) => each.section)), @@ -44,6 +50,8 @@ export const SECTION_NOTES: Record = { diagnostics: 'The two parts of the focus-key lookup, each timed on its own.', columns: 'Arrow keys in four-column grids, with the column count fixed at 4 or read from the layout.', + 'refused focus': + 'Arrow keys between two items with a run of 1 to 1,000 items between them that refuse focus, so the move walks on past each, and the same run marked skipped.', }; /** diff --git a/apps/bench/src/types.ts b/apps/bench/src/types.ts index 3172f94..f6a1571 100644 --- a/apps/bench/src/types.ts +++ b/apps/bench/src/types.ts @@ -21,11 +21,29 @@ export type Fixture = { focusKeys?: number; /** Set on the grid pages only: the `cols` value each group carries. */ grid?: string; + /** + * Set where the groups do not hold 20 items each: the refused-focus pages, + * whose one group holds its run of refusing items and the two around it. + */ + items?: number; buildMs: number; /** Every element in the document once the page was built. */ elements: number; }; +/** + * A refused-focus page: `count` items between the two the press moves + * between, each hidden by `hidden`. `roving` gives every item a roving tab + * stop, carried onto each refusing item and put back. `skip` marks the run + * skipped, so no move tries it. + */ +export type RefusalSpec = { + count: number; + hidden: 'display: none' | 'visibility: hidden'; + roving?: boolean; + skip?: boolean; +}; + /** What one call returned: proof that a case measures what its label says. */ export type Outcome = 'unhandled' | 'consumed no-op' | 'moved' | '-'; @@ -60,6 +78,11 @@ export type Supports = { focusKeysMap: boolean; /** `cols="auto"`, columns read from the layout (2.5.0 on). */ autoColumns: boolean; + /** + * A move walks on past an item that refuses focus (after 2.6.1). Absent + * from a run recorded before the field existed. + */ + refusedFocus?: boolean; }; export type BenchResults = { diff --git a/apps/bench/src/view.ts b/apps/bench/src/view.ts index 5a747d4..52a45a9 100644 --- a/apps/bench/src/view.ts +++ b/apps/bench/src/view.ts @@ -5,7 +5,7 @@ */ import { icon } from './icons.ts'; -import { DEFAULT_OPTIONS, ITEMS_PER_GROUP } from './plan.ts'; +import { DEFAULT_OPTIONS } from './plan.ts'; import { browserMajor, caseRows, @@ -19,6 +19,7 @@ import { formatTimeShort, HEADLINES, indexCases, + itemCount, SECTION_NOTES, sectionsOf, skipReason, @@ -233,7 +234,7 @@ const renderFixtures = (results: BenchResults) => { (fixture) => ` ${escapeHtml(fixture.name)} ${formatCount(fixture.groups)} - ${formatCount(fixture.groups * ITEMS_PER_GROUP)} + ${formatCount(itemCount(fixture))} ${formatCount(fixture.focusKeys ?? 0)} ${escapeHtml(fixture.grid ?? '–')} ${formatCount(fixture.elements)} diff --git a/apps/bench/tests/results.test.ts b/apps/bench/tests/results.test.ts index 61fdbbc..1bf54f3 100644 --- a/apps/bench/tests/results.test.ts +++ b/apps/bench/tests/results.test.ts @@ -67,26 +67,33 @@ test('sections come in the order the run recorded them', () => { 'focus keys', 'diagnostics', 'columns', + 'refused focus', ]); }); test('the Markdown report prints the README table rows', () => { const markdown = toMarkdown(recorded); - assert.ok(markdown.startsWith('## Environment\n\n- Browser: ')); + assert.ok(markdown.startsWith('## Environment\n\n- Library: ')); assert.ok( - markdown.includes('| S | 10 | 200 | 0 | - | 624 | 1.59 |'), + markdown.includes('| S | 10 | 200 | 0 | - | 618 | 1.43 |'), 'fixture row', ); assert.ok( markdown.includes( - '| M, cols="auto" | 100 | 2000 | 0 | auto | 6114 | 14.97 |', + '| M, cols="auto" | 100 | 2000 | 0 | auto | 6108 | 10.85 |', ), 'grid fixture row', ); assert.ok( markdown.includes( - '| S | local, unbound | unhandled | 2.68 | 2.64–2.93 | 4096 |', + '| 1000 × display: none | 1 | 1002 | 0 | - | 3015 | 1.62 |', + ), + 'refused-focus fixture row', + ); + assert.ok( + markdown.includes( + '| S | local, unbound | unhandled | 2.55 | 2.53–2.60 | 4096 |', ), 'case row', ); @@ -303,11 +310,11 @@ test('the Markdown report can be in milliseconds', () => { assert.ok(markdown.includes('| Median (ms) | p10–p90 (ms) |')); assert.ok( markdown.includes( - '| S | local, unbound | unhandled | 0.00268 | 0.00264–0.00293 | 4096 |', + '| S | local, unbound | unhandled | 0.00255 | 0.00253–0.00260 | 4096 |', ), ); // Build times stay in milliseconds whatever the unit. - assert.ok(markdown.includes('| S | 10 | 200 | 0 | - | 624 | 1.59 |')); + assert.ok(markdown.includes('| S | 10 | 200 | 0 | - | 618 | 1.43 |')); }); test('frame shares show as many places as they need', () => { diff --git a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts index 091c4f8..6e54813 100644 --- a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts +++ b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts @@ -870,6 +870,32 @@ describe('createTypeahead', () => { }); }); + describe('a run of matches that refuse focus', () => { + it('reads each label before them once, however many refuse', () => { + const label = vi.fn((item: Element) => item.textContent!); + const others = ['Archive', 'Drafts', 'Inbox'].map((name, i) => + createItem(`o${i}`, name), + ); + const refusing = [0, 1, 2, 3].map((i) => createItem(`r${i}`, 'Spam')); + const items = [...others, ...refusing, createItem('sent', 'Sent')]; + renderList(items, { options: { label } }); + for (const item of refusing) { + vi.spyOn(item, 'focus').mockImplementation(() => {}); + } + others[0].focus(); + + pressKey('s'); + + expect(activeId()).toBe('sent'); + for (const item of others) { + expect( + label.mock.calls.filter(([read]) => read === item), + item.id, + ).toHaveLength(1); + } + }); + }); + describe('across nested groups', () => { it("moves the matched item's own group stop and keeps the outer one", () => { const inner = document.createElement('div'); diff --git a/packages/keyrove/src/__tests__/position/position.test.ts b/packages/keyrove/src/__tests__/position/position.test.ts index de50763..b120ef8 100644 --- a/packages/keyrove/src/__tests__/position/position.test.ts +++ b/packages/keyrove/src/__tests__/position/position.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { resolveTarget } from '../../position'; +import { resolveTarget, walkTargets } from '../../position'; import type { Layout, ResolveTargetArgs } from '../../types'; import { buildElements, idOf, isNavigable } from './testUtils'; @@ -321,3 +321,103 @@ describe('resolveTarget', () => { }); }); }); + +describe('walkTargets', () => { + /** + * Walks to the end with every destination refusing focus, as `land` walks + * a run of items that refuse it: the test turns down each item the walk has + * handed out. Counts the navigable tests each item gets. + */ + const walkRefusingAll = (args: ResolveArgs) => { + const elements = args.elements ?? buildElements(4); + const tests = new Map(); + const refused = new Set(); + const walk = walkTargets({ + layout: LIST, + pageLength: 10, + ...args, + elements, + isNavigable: (item) => { + tests.set(item, (tests.get(item) ?? 0) + 1); + return !refused.has(item); + }, + }); + + for (const item of walk) refused.add(item); + + return { elements, tests, refused }; + }; + + it.each<[string, ResolveArgs]>([ + ['next, clamping', { intent: 'next', fromIndex: 10 }], + ['next, looping', { intent: 'next', fromIndex: 10, layout: LOOP }], + ['pageDown', { intent: 'pageDown', fromIndex: 10, pageLength: 5 }], + ['end', { intent: 'end', fromIndex: 10 }], + ])('tests each item at most twice over a run of refusals: %s', (_, args) => { + const { elements, tests, refused } = walkRefusingAll({ + elements: buildElements(50), + ...args, + }); + + // The walk reached every item, and tested none more than twice. + expect(refused.size).toBe(50); + expect( + Math.max(...elements.map((item) => tests.get(item) ?? 0)), + ).toBeLessThanOrEqual(2); + }); + + it('hands out, one by one, what resolving again past each one would', () => { + const elements = buildElements(12, [2, 7]); + const layouts = { LIST, LOOP, GRID }; + const intents = [ + 'next', + 'prev', + 'nextRow', + 'prevRow', + 'home', + 'end', + 'homeRow', + 'endRow', + 'pageUp', + 'pageDown', + ] as const; + + for (const [name, layout] of Object.entries(layouts)) { + for (const intent of intents) { + if (layout.kind === 'list' && intent.endsWith('Row')) continue; + + for (let fromIndex = -1; fromIndex < elements.length; fromIndex++) { + const args = { intent, fromIndex, elements, layout, pageLength: 2 }; + // One destination at a time, each resolved afresh past every one + // before it, until there is none, or it is the one moved from. + const resolved: (Element | null | undefined)[] = []; + for (;;) { + const to = resolveTarget({ + ...args, + isNavigable: (item) => + isNavigable(item) && !resolved.includes(item), + }); + resolved.push(to); + if (!to || to === elements[fromIndex]) break; + } + + const walked: (Element | null | undefined)[] = []; + const walk = walkTargets({ + ...args, + isNavigable: (item) => isNavigable(item) && !walked.includes(item), + }); + for (;;) { + const to = walk.next().value; + walked.push(to); + if (!to || to === elements[fromIndex]) break; + } + + expect( + walked.map(idOf), + `${name} ${intent} from ${fromIndex}`, + ).toEqual(resolved.map(idOf)); + } + } + } + }); +}); diff --git a/packages/keyrove/src/createTypeahead.ts b/packages/keyrove/src/createTypeahead.ts index b3d0085..3d4e0e1 100644 --- a/packages/keyrove/src/createTypeahead.ts +++ b/packages/keyrove/src/createTypeahead.ts @@ -9,7 +9,7 @@ */ import { KEYROVE_ATTR_TYPEAHEAD } from './attributes.js'; -import { readGroups } from './group.js'; +import { readGroups, walkOn } from './group.js'; import { hasCommandModifier, isEditablePress } from './utils.js'; import type { KeyRoveEvent, @@ -144,15 +144,21 @@ export const createTypeahead = ({ ? items.indexOf(focused) + 1 : 0; const rotation = [...items.slice(start), ...items.slice(0, start)]; - // The first match the navigable test accepts: the group's own, and, where - // a match refuses focus, the same test passing over it, so the move lands - // on the next match in the rotation. - const match = (isNavigable: (item: Element) => boolean) => - rotation.find( - (item) => - isNavigable(item) && fold(getLabel(item, label)).startsWith(buffer), - ); - const target = match(group.isNavigable); + // The first match the navigable test accepts, and, where a match refuses + // focus, the next one in the rotation: one pass over it, so each label is + // read once however many matches refuse. + const { to: target, retarget } = walkOn(function* ( + isNavigable, + ): Generator { + for (const item of rotation) { + if ( + isNavigable(item) && + fold(getLabel(item, label)).startsWith(buffer) + ) { + yield item; + } + } + }, group.isNavigable); // No match leaves the key untouched — the character still joined the // buffer, so a mistyped prefix goes quiet until the reset clears it. @@ -166,7 +172,7 @@ export const createTypeahead = ({ action: 'typeahead', from: focused, to: target, - retarget: match, + retarget, onMove, }); }; diff --git a/packages/keyrove/src/group.ts b/packages/keyrove/src/group.ts index ae8c8b6..fd0ae37 100644 --- a/packages/keyrove/src/group.ts +++ b/packages/keyrove/src/group.ts @@ -157,6 +157,37 @@ const carryStop = (from: Element, to: Element) => { }; }; +/** + * A move's destination and the rest of its walk, as {@link MoveArgs} takes + * them: the first item `walk` hands out by `isNavigable`, the group's own + * test, and a `retarget` that carries the same walk on rather than starting + * it again. + * + * `land` asks `retarget` with a test that turns down every item refused so + * far: one that only grows stricter, and so turns down every item the walk + * has passed too. The walk goes on by it from where it stopped, and lands + * where starting again would, at one pass over the items rather than one per + * refusal. + */ +export const walkOn = ( + walk: ( + isNavigable: (item: Element) => boolean, + ) => Iterator, + isNavigable: (item: Element) => boolean, +) => { + let test = isNavigable; + const targets = walk((item) => test(item)); + + return { + to: targets.next().value, + retarget: (stricter: (item: Element) => boolean) => { + test = stricter; + + return targets.next().value; + }, + }; +}; + /** * Claims the key and lands focus on `to`, reporting the move. * @@ -171,12 +202,12 @@ const carryStop = (from: Element, to: Element) => { * and `onMove` fires with the move that happened. * * A `to` that does not take focus — not focusable, inert, hidden — has its tab - * stop put back where it was, and the move walks on: `retarget` resolves it - * again with every refusing item passed over, by `isNavigable`, and the next - * destination is tried the same way. Each pass passes over one more item, so - * the walk ends. Where nothing beyond takes focus, or there is no `retarget`, - * the move is the same consumed no-op. A move whose first destination takes - * focus pays for none of it. + * stop put back where it was, and the move walks on: `retarget` hands out the + * next destination by `isNavigable` with every refusing item passed over, and + * it is tried the same way. Each pass passes over one more item, so the walk + * ends. Where nothing beyond takes focus, or there is no `retarget`, the move + * is the same consumed no-op. A move whose first destination takes focus pays + * for none of it. */ const land = ( { e, action, from, to, retarget, onMove }: MoveArgs, diff --git a/packages/keyrove/src/keyRove.ts b/packages/keyrove/src/keyRove.ts index d4540f7..7158bfa 100644 --- a/packages/keyrove/src/keyRove.ts +++ b/packages/keyrove/src/keyRove.ts @@ -1,9 +1,9 @@ import { buildBindings } from './bindings.js'; import { crossBoundary } from './boundary.js'; import { readConfig, readFocusKeys } from './config.js'; -import { holdsFocus, readGroups } from './group.js'; +import { holdsFocus, readGroups, walkOn } from './group.js'; import { listenerElement } from './nesting.js'; -import { resolveTarget } from './position.js'; +import { walkTargets } from './position.js'; import { hasCommandModifier, isEditablePress, matchesCombo } from './utils.js'; import type { KeyRoveEvent, KeyRoveOptions, MoveResult } from './types.js'; @@ -148,18 +148,20 @@ export const keyRove = ( const { intent } = binding; const fromIndex = focused ? elements.indexOf(focused) : -1; - // The move's walk, by a navigable test: the group's own, and the same test - // passing over each item that refused focus when the group walks on. - const resolve = (isNavigable: (item: Element) => boolean) => - resolveTarget({ - intent, - elements, - fromIndex, - layout: config.layout, - pageLength: config.pageLength, - isNavigable, - }); - const target = resolve(group.isNavigable); + // The move's walk: its destination by the group's navigable test, and the + // rest of it to carry on past each item that refuses focus. + const { to: target, retarget } = walkOn( + (isNavigable) => + walkTargets({ + intent, + elements, + fromIndex, + layout: config.layout, + pageLength: config.pageLength, + isNavigable, + }), + group.isNavigable, + ); // With neither a target nor a focused item, keyrove has nothing to move // from or to and the key is left with its browser default rather than @@ -173,7 +175,7 @@ export const keyRove = ( action: intent, from: focused, to: target, - retarget: resolve, + retarget, onMove, }); }; diff --git a/packages/keyrove/src/position.ts b/packages/keyrove/src/position.ts index 794ee2d..15e1a93 100644 --- a/packages/keyrove/src/position.ts +++ b/packages/keyrove/src/position.ts @@ -12,71 +12,83 @@ import type { ResolveTargetArgs } from './types.js'; /** - * Binds the group's navigable test into the walk every move is made of: the - * first element `isNavigable` accepts, from index `i` in steps of `step`; - * `undefined` once the walk leaves either end. The moves differ by where they - * start, by what they stride, and by the fallback when the walk comes back - * empty — never by which items they can land on, so that test is bound once - * per resolution. + * The walk every move is made of: each element `isNavigable` accepts, from + * index `i` in steps of `step`, until the walk leaves either end. An item is + * tested only as the walk reaches it, so a test that turns down more items as + * the walk goes on is read afresh at every step. */ -const scanner = - (isNavigable: ResolveTargetArgs['isNavigable']) => - (elements: Element[], i: number, step: number): Element | undefined => { - for (; i >= 0 && i < elements.length; i += step) { - if (isNavigable(elements[i])) { - return elements[i]; - } - } - }; +function* scan( + elements: Element[], + i: number, + step: number, + isNavigable: ResolveTargetArgs['isNavigable'], +): Generator { + for (; i >= 0 && i < elements.length; i += step) { + if (isNavigable(elements[i])) yield elements[i]; + } +} /** - * Resolves the element an intent lands on, or `null`/`undefined` when there - * is nowhere to go (a grid edge, an empty group). + * Every element an intent can land on, in the order the move tries them: the + * stride's own walk first, then its fallback once that walk comes back empty + * — the clamp or the wrap past a list's end, the clamp after a page jump. + * The moves differ by where they start, by what they stride, and by that + * fallback — never by which items they can land on, which is `isNavigable`'s + * to say. + * + * The first element is the move's destination. The rest are where it goes + * instead when that one turns out not to take focus: a test that turns down + * every item refused so far turns down every item the walk has passed too, so + * carrying the walk on lands where resolving the move again would, at one + * pass over the items rather than one pass per refusal. Once done it returns + * what the move resolves to when there is nowhere to go: `null` for a move + * that stops at an edge (a grid's, or a row's), `undefined` for one that goes + * by the group's ends and found none. * - * Entry is handled here: with `fromIndex: -1` a directional intent resolves - * to the first navigable item — the last, for `prev` on a looping list. The + * Entry is handled here: with `fromIndex: -1` a directional intent starts at + * the first navigable item — the last, for `prev` on a looping list. The * caller gates the bindings that do not enter before ever asking. */ -export const resolveTarget = ({ +export function* walkTargets({ intent, elements, fromIndex, layout: { kind, cols, loop }, pageLength, isNavigable, -}: ResolveTargetArgs): Element | null | undefined => { - const scan = scanner(isNavigable); +}: ResolveTargetArgs): Generator { const lastIndex = elements.length - 1; - // The group's ends: its first and last navigable items. When every item is - // skipped there are none, and a move that goes by the ends lands nowhere — - // a skipped item is never a destination, whichever move is asking. - const first = () => scan(elements, 0, 1); - const last = () => scan(elements, lastIndex, -1); + const walk = (i: number, step: number, sequence = elements) => + scan(sequence, i, step, isNavigable); + // The group's ends: its first and last navigable items, and on from there. + // When every item is skipped there are none, and a move that goes by the + // ends lands nowhere — a skipped item is never a destination, whichever + // move is asking. + const first = () => walk(0, 1); + const last = () => walk(lastIndex, -1); - if (fromIndex < 0) return intent === 'prev' && loop ? last() : first(); + if (fromIndex < 0) return yield* intent === 'prev' && loop ? last() : first(); switch (intent) { // Past its end a list clamps, or wraps when it loops; a grid stops at its // edge and never wraps, per the APG grid pattern. case 'next': - return ( - scan(elements, fromIndex + 1, 1) || - (kind === 'grid' ? null : loop ? first() : last()) - ); + yield* walk(fromIndex + 1, 1); + return kind === 'grid' ? null : yield* loop ? first() : last(); case 'prev': - return ( - scan(elements, fromIndex - 1, -1) || - (kind === 'grid' ? null : loop ? last() : first()) - ); + yield* walk(fromIndex - 1, -1); + return kind === 'grid' ? null : yield* loop ? last() : first(); // A row move keeps its column, stepping a further row over a skipped cell. case 'nextRow': - return scan(elements, fromIndex + cols, cols) || null; + yield* walk(fromIndex + cols, cols); + return null; case 'prevRow': - return scan(elements, fromIndex - cols, -cols) || null; + yield* walk(fromIndex - cols, -cols); + return null; case 'home': - return first(); + return yield* first(); case 'end': - return last(); + return yield* last(); case 'homeRow': case 'endRow': { // A row end goes by its own row alone: a row of nothing but skipped @@ -84,11 +96,10 @@ export const resolveTarget = ({ const rowStart = fromIndex - (fromIndex % cols); const row = elements.slice(rowStart, rowStart + cols); - return ( - (intent === 'homeRow' - ? scan(row, 0, 1) - : scan(row, row.length - 1, -1)) || null - ); + yield* intent === 'homeRow' + ? walk(0, 1, row) + : walk(row.length - 1, -1, row); + return null; } // A page jump is a request to travel as far as possible: overshooting an // end, or finding only skipped items from where it lands onward, clamps to @@ -98,12 +109,17 @@ export const resolveTarget = ({ const direction = intent === 'pageUp' ? -1 : 1; const landing = fromIndex + pageLength * cols * direction; - return ( - (landing >= 0 && - landing <= lastIndex && - scan(elements, landing, direction)) || - (direction < 0 ? first() : last()) - ); + if (landing >= 0 && landing <= lastIndex) yield* walk(landing, direction); + return yield* direction < 0 ? first() : last(); } } -}; +} + +/** + * Resolves the element an intent lands on, or `null`/`undefined` when there + * is nowhere to go (a grid edge, an empty group): the first destination of + * its walk, {@link walkTargets}. + */ +export const resolveTarget = ( + args: ResolveTargetArgs, +): Element | null | undefined => walkTargets(args).next().value; diff --git a/packages/keyrove/src/rove.ts b/packages/keyrove/src/rove.ts index 0eb1a56..03bf22f 100644 --- a/packages/keyrove/src/rove.ts +++ b/packages/keyrove/src/rove.ts @@ -9,8 +9,8 @@ */ import { readConfig } from './config.js'; -import { readGroups } from './group.js'; -import { resolveTarget } from './position.js'; +import { readGroups, walkOn } from './group.js'; +import { walkTargets } from './position.js'; import type { KeyRoveOptions, MoveResult, StrideAction } from './types.js'; // Where each move enters a group it has no position in: a forward move at the @@ -79,18 +79,19 @@ export const rove = ( // An entry is `home` or `end`, which go by the group's ends, not by an // index. const fromIndex = from ? elements.indexOf(from) : 0; - // The walk is resolved again, passing over each item that refused focus, - // as a key's is. - const resolve = (isNavigable: (item: Element) => boolean) => - resolveTarget({ - intent, - elements, - fromIndex, - layout: config.layout, - pageLength: config.pageLength, - isNavigable, - }); - const target = resolve(group.isNavigable); + // The walk carries on past each item that refuses focus, as a key's does. + const { to: target, retarget } = walkOn( + (isNavigable) => + walkTargets({ + intent, + elements, + fromIndex, + layout: config.layout, + pageLength: config.pageLength, + isNavigable, + }), + group.isNavigable, + ); if (!target && !from) return null; @@ -98,7 +99,7 @@ export const rove = ( action, from, to: target, - retarget: resolve, + retarget, onMove: options.onMove, }); }; diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index 424807c..5704037 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -570,10 +570,12 @@ export type MoveArgs = { from: Element | null; to: Element | null | undefined; /** - * The move resolved again, by `isNavigable`: the group's navigable test, - * passing over every item that has refused focus so far. Asked each time a - * destination refuses, until one takes focus or none is left. Without it a - * refusal ends the move, as it does for a destination named outright. + * The next destination of the move's walk, by `isNavigable`: the group's + * navigable test, passing over every item that has refused focus so far. + * Asked each time a destination refuses, until one takes focus or none is + * left. The test only grows stricter, so the walk can go on from where it + * stopped rather than start again. Without it a refusal ends the move, as + * it does for a destination named outright. */ retarget?: ( isNavigable: (item: Element) => boolean, From eb083940e6cc458b5ad1d86d9893ca6aebfc4bde Mon Sep 17 00:00:00 2001 From: mixedrays Date: Fri, 6 Nov 2026 19:26:37 +0100 Subject: [PATCH 06/13] fix: treat AltGr presses as typing, not commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows browsers report AltGr as Ctrl+Alt, so a press that types "@" or "€" read as a command: a ctrl+alt+ focus key fired from inside a field, and typeahead dropped characters typed with AltGr. hasCommandModifier now treats a press with getModifierState('AltGraph') held as typing, in both the editable-target guard and typeahead. Combo matching is unchanged, so outside a field a ctrl+alt+ focus key still fires on the AltGr press. KeyRoveEvent gains an optional getModifierState, asked only about 'AltGraph' so React's narrower ModifierKey parameter fits too. An event without it behaves as before. The docs drop the AltGr caveat. --- GLOSSARY.md | 2 +- apps/docs/content/docs/api.md | 32 +++++--- .../content/docs/examples/editable-targets.md | 5 +- apps/docs/content/docs/examples/focus-keys.md | 8 +- apps/docs/content/docs/examples/typeahead.md | 4 +- packages/keyrove/README.md | 5 +- .../createTypeahead/createTypeahead.test.ts | 28 +++++++ .../__tests__/createTypeahead/testUtils.ts | 7 +- .../keyRove/keyRove.focusKey.test.ts | 81 +++++++++++++++++++ .../src/__tests__/keyRove/testUtils.ts | 8 +- .../keyrove/src/__tests__/types/types.test.ts | 3 + packages/keyrove/src/createTypeahead.ts | 9 ++- packages/keyrove/src/types.ts | 6 ++ packages/keyrove/src/utils.ts | 4 +- 14 files changed, 174 insertions(+), 28 deletions(-) diff --git a/GLOSSARY.md b/GLOSSARY.md index d6a9a5e..9c3d6b5 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -148,7 +148,7 @@ A combo assigned to one destination element, item or not, rather than to a strid _Avoid_: Focus shortcut, access key, jump key **Editable target**: -An event target inside a text field, select, editable content, or another input whose native behavior uses the keys keyrove binds. From an editable target, only focus keys pressed with Control, Alt, or Meta act. +An event target inside a text field, select, editable content, or another input whose native behavior uses the keys keyrove binds. From an editable target, only focus keys pressed with Control, Alt, or Meta act, unless AltGr is held. In keyrove, buttons, checkboxes, and similar inputs are not editable targets, so navigation works from them. _Avoid_: Input, form field, text input diff --git a/apps/docs/content/docs/api.md b/apps/docs/content/docs/api.md index 2a5904c..a5d2426 100644 --- a/apps/docs/content/docs/api.md +++ b/apps/docs/content/docs/api.md @@ -293,9 +293,9 @@ coming from the shadow host. A component with a closed root has to keep its keys itself, for example by stopping their propagation inside the root. A [focus key](#focus-keys) with Ctrl, Alt or Meta can run inside an editable -field. These shortcuts can still conflict with text input: Windows can report -AltGr as Ctrl+Alt, so a `ctrl+alt+` focus key may fire while typing characters -such as € or @. +field. A press with AltGr held counts as typing, so it stays in the field even +though Windows reports it as Ctrl+Alt: typing € or @ never fires a `ctrl+alt+` +focus key. Outside a field, such a focus key still fires on the AltGr press. When `isComposing` is true, no binding runs, including focus shortcuts. All keys remain available to the input method. @@ -569,7 +569,9 @@ typing: - a single character, with none of Ctrl, Alt or Meta held - (Shift is allowed; it is how capitals are typed); + (Shift is allowed; it is how capitals are typed, and + so is AltGr, even where Windows reports it as + Ctrl+Alt); - not inside an [editable target](#editable-targets); - a space only once the buffer holds a character, so the spacebar keeps scrolling and clicking. @@ -929,6 +931,7 @@ type KeyRoveEvent = { altKey?: boolean; shiftKey?: boolean; metaKey?: boolean; + getModifierState?: (key: 'AltGraph') => boolean; isComposing?: boolean; defaultPrevented?: boolean; key?: string; @@ -937,13 +940,20 @@ type KeyRoveEvent = { }; ``` -The modifier flags, `isComposing`, `defaultPrevented`, `key`, `composedPath` -and `nativeEvent` are optional so a hand-built event object still qualifies. A -missing flag reads as "not held", a missing `isComposing` as "not composing" -and a missing `defaultPrevented` as not [consumed](#return-value). An object of -your own that bridges events must forward them, or every press matches as -unmodified and unconsumed. Only [typeahead](#createtypeahead-options) reads -`key`; an event without it navigates but never typeaheads. +The modifier flags, `getModifierState`, `isComposing`, `defaultPrevented`, +`key`, `composedPath` and `nativeEvent` are optional so a hand-built event +object still qualifies. A missing flag reads as "not held", a missing +`isComposing` as "not composing" and a missing `defaultPrevented` as not +[consumed](#return-value). An object of your own that bridges events must +forward them, or every press matches as unmodified and unconsumed. Only +[typeahead](#createtypeahead-options) reads `key`; an event without it +navigates but never typeaheads. + +`getModifierState` is asked only about `'AltGraph'`, to tell a press that +types a character with AltGr from a Ctrl+Alt command: such a press is +typing, both in an [editable target](#editable-targets) and for typeahead. +Without it, Ctrl+Alt always reads as a command. Combo matching does not read +it. Only the [editable-target](#editable-targets) check reads the composed path, to find the element the press began on. React's synthetic events have no diff --git a/apps/docs/content/docs/examples/editable-targets.md b/apps/docs/content/docs/examples/editable-targets.md index 14eac19..6999350 100644 --- a/apps/docs/content/docs/examples/editable-targets.md +++ b/apps/docs/content/docs/examples/editable-targets.md @@ -62,8 +62,9 @@ chained after it also receives the event. 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. +for typing. A press with AltGr held counts as typing too, even where Windows +reports it as Ctrl+Alt. Choose shortcuts carefully: Alt+letter can still type +accented characters on macOS. [Typeahead](/docs/examples/typeahead) never captures typing inside a field. diff --git a/apps/docs/content/docs/examples/focus-keys.md b/apps/docs/content/docs/examples/focus-keys.md index e565455..542eef3 100644 --- a/apps/docs/content/docs/examples/focus-keys.md +++ b/apps/docs/content/docs/examples/focus-keys.md @@ -130,10 +130,10 @@ 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 -can therefore fire during text entry. No focus key runs while `isComposing` -is true. +For example, Ctrl+B can mean bold, and Alt+letter can type accented characters on +macOS. A press with AltGr held counts as typing, even where Windows reports it +as Ctrl+Alt, so a `ctrl+alt+` focus shortcut does not fire while typing € or @ in +a field. No focus key runs while `isComposing` is true. ## Precedence and ties diff --git a/apps/docs/content/docs/examples/typeahead.md b/apps/docs/content/docs/examples/typeahead.md index 6ba779c..2b7b2e4 100644 --- a/apps/docs/content/docs/examples/typeahead.md +++ b/apps/docs/content/docs/examples/typeahead.md @@ -70,7 +70,9 @@ else: - a single character, with none of Ctrl, Alt or Meta held. Shift is allowed; it is how capitals are typed, and - matching ignores case anyway; + matching ignores case anyway. AltGr is allowed too, + even where Windows reports it as Ctrl+Alt, + so "ł" on a Polish layout reaches _Łódź_; - not inside an [editable target](/docs/examples/editable-targets), so a field inside an item keeps its letters; - a space only once the buffer holds a character, so the spacebar keeps diff --git a/packages/keyrove/README.md b/packages/keyrove/README.md index eeee827..d10d576 100644 --- a/packages/keyrove/README.md +++ b/packages/keyrove/README.md @@ -207,7 +207,7 @@ for the full list and focus-shortcut exception. 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. +during input-method composition or while AltGr is held. ```html
@@ -311,7 +311,8 @@ 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. +Typing inside editable fields and Ctrl/Alt/Meta combinations are ignored; +a press with AltGr held counts as typing. Space joins the buffer only after another character. An unmatched character stays in the buffer but leaves its browser behavior unchanged. diff --git a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts index 091c4f8..a1653ff 100644 --- a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts +++ b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts @@ -608,6 +608,34 @@ describe('createTypeahead', () => { }); }); + describe('a character typed with AltGr', () => { + // Windows reports AltGr as Ctrl+Alt, with the AltGraph modifier state set + // too: on a Polish programmer layout, AltGr+L types "ł" and AltGr+O "ó". + const ALTGR = { ctrlKey: true, altKey: true, modifierAltGraph: true }; + + it('joins the buffer like any typed character', () => { + const { results } = renderList( + [ + createItem('a', 'Kraków'), + createItem('b', 'Lublin'), + createItem('c', 'Łeba'), + createItem('d', 'Łódź'), + ], + { chainKeyRove: true }, + ); + document.getElementById('a')!.focus(); + + const first = pressKey('ł', { code: 'KeyL', ...ALTGR }); + expect(activeId()).toBe('c'); + expect(first.defaultPrevented).toBe(true); + + // the buffer is "łó", which only Łódź starts with + pressKey('ó', { code: 'KeyO', ...ALTGR }); + expect(activeId()).toBe('d'); + expect(results.map((result) => result?.to?.id)).toEqual(['c', 'd']); + }); + }); + describe('keys that never typeahead', () => { it('ignores presses with Ctrl, Alt, or Meta held', () => { const { results } = renderList(mailbox()); diff --git a/packages/keyrove/src/__tests__/createTypeahead/testUtils.ts b/packages/keyrove/src/__tests__/createTypeahead/testUtils.ts index d7b74ac..1815e9f 100644 --- a/packages/keyrove/src/__tests__/createTypeahead/testUtils.ts +++ b/packages/keyrove/src/__tests__/createTypeahead/testUtils.ts @@ -73,7 +73,12 @@ export const pressKey = ( ...modifiers }: Pick< KeyboardEventInit, - 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey' | 'composed' + | 'ctrlKey' + | 'altKey' + | 'shiftKey' + | 'metaKey' + | 'modifierAltGraph' + | 'composed' > & { code?: string; from?: Element; diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts index 2e72dc9..13174c0 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts @@ -548,6 +548,87 @@ describe('keyRove', () => { expect(shifted.defaultPrevented).toBe(false); }); + describe('with AltGr held', () => { + // Windows reports AltGr as Ctrl+Alt, with the AltGraph modifier state + // set too: AltGr+Q types "@" on a German layout. + const ALTGR = { ctrlKey: true, altKey: true, modifierAltGraph: true }; + + const renderFieldAndPanel = () => { + const results: RoveResult[] = []; + + document.body.innerHTML = ` +
+ +
+ +
`; + + const node = byId('list'); + const listen = (e: Event) => { + results.push(keyRove(e as KeyboardEvent)); + }; + node.addEventListener('keydown', listen); + detach = () => node.removeEventListener('keydown', listen); + + return results; + }; + + it('leaves the press to the field: it types a character there', () => { + const results = renderFieldAndPanel(); + byId('field').focus(); + + const event = pressKey('KeyQ', undefined, ALTGR); + + expect(results).toEqual([null]); + expect(event.defaultPrevented).toBe(false); + expect(activeId()).toBe('field'); + }); + + it('still fires the focus key from the field on Ctrl+Alt without AltGr', () => { + const results = renderFieldAndPanel(); + byId('field').focus(); + + const event = pressKey('KeyQ', undefined, { + ctrlKey: true, + altKey: true, + }); + + expect(results.map((result) => result?.to?.id)).toEqual(['panel']); + expect(event.defaultPrevented).toBe(true); + expect(activeId()).toBe('panel'); + }); + + it('still fires the focus key outside a field, where no text is lost', () => { + const results = renderFieldAndPanel(); + byId('x').focus(); + + const event = pressKey('KeyQ', undefined, ALTGR); + + expect(results.map((result) => result?.to?.id)).toEqual(['panel']); + expect(event.defaultPrevented).toBe(true); + expect(activeId()).toBe('panel'); + }); + + it('reads Ctrl+Alt as a command on an event with no modifier state', () => { + renderFieldAndPanel(); + const field = byId('field'); + field.focus(); + const preventDefault = vi.fn(); + + const result = keyRove({ + code: 'KeyQ', + ctrlKey: true, + altKey: true, + target: field, + currentTarget: byId('list'), + preventDefault, + }); + + expect(result?.to?.id).toBe('panel'); + expect(preventDefault).toHaveBeenCalled(); + }); + }); + it('keeps a chorded move out of the field — only a focus key reaches out', () => { const host = createItem('host'); const field = document.createElement('textarea'); diff --git a/packages/keyrove/src/__tests__/keyRove/testUtils.ts b/packages/keyrove/src/__tests__/keyRove/testUtils.ts index a54a043..e3cdb9a 100644 --- a/packages/keyrove/src/__tests__/keyRove/testUtils.ts +++ b/packages/keyrove/src/__tests__/keyRove/testUtils.ts @@ -90,7 +90,13 @@ export const pressKey = ( from: Element = document.activeElement as Element, modifiers: Pick< KeyboardEventInit, - 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey' | 'isComposing' | 'composed' + | 'ctrlKey' + | 'altKey' + | 'shiftKey' + | 'metaKey' + | 'modifierAltGraph' + | 'isComposing' + | 'composed' > = {}, ) => { const event = new KeyboardEvent('keydown', { diff --git a/packages/keyrove/src/__tests__/types/types.test.ts b/packages/keyrove/src/__tests__/types/types.test.ts index 337684c..829da95 100644 --- a/packages/keyrove/src/__tests__/types/types.test.ts +++ b/packages/keyrove/src/__tests__/types/types.test.ts @@ -57,6 +57,9 @@ describe('public type names', () => { currentTarget: Element; preventDefault: () => void; nativeEvent: KeyboardEvent; + // React's parameter is a union of modifier names, narrower than the + // DOM's `string`. + getModifierState(key: 'Alt' | 'AltGraph' | 'Control' | 'Shift'): boolean; }; expectTypeOf().toExtend(); diff --git a/packages/keyrove/src/createTypeahead.ts b/packages/keyrove/src/createTypeahead.ts index b3d0085..dc9b4f7 100644 --- a/packages/keyrove/src/createTypeahead.ts +++ b/packages/keyrove/src/createTypeahead.ts @@ -46,9 +46,9 @@ const foldMarks = (text: string) => * `label` option, falling back to the `data-keyrove-typeahead` attribute and * then to trimmed `textContent` — starts with it, ignoring case and, by * default, accents and other combining marks. Typing inside editable elements - * is never captured, modified presses (Ctrl/Alt/Meta) are left to their - * shortcuts, and a press an earlier handler already claimed with - * `preventDefault()` is left alone, buffer included. In `cycle` mode a single character moves to the next match after + * is never captured, modified presses (Ctrl/Alt/Meta, unless AltGr is held) + * are left to their shortcuts, and a press an earlier handler already claimed + * with `preventDefault()` is left alone, buffer included. 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. * @@ -94,7 +94,8 @@ export const createTypeahead = ({ // printable keys. Navigation and function keys ("ArrowDown", "F6") are // longer names, and an event without `key` cannot typeahead at all. A // command modifier makes a press a shortcut, not typing (Shift stays: it - // is how capitals are typed). + // is how capitals are typed; so does AltGr, which Windows reports as + // Ctrl+Alt). if (e.key?.length !== 1 || hasCommandModifier(e)) return null; // Read where the press began, as `keyRove` does, so a field inside a diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index 424807c..c48ec57 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -67,6 +67,12 @@ export type KeyRoveEvent = { altKey?: boolean; shiftKey?: boolean; metaKey?: boolean; + // Whether AltGr is held (`KeyboardEvent.getModifierState`). Windows reports + // AltGr as Ctrl+Alt, so this is how a press that types "@" or "€" tells + // itself apart from a Ctrl+Alt command. Only `'AltGraph'` is asked, which + // lets React's narrower `ModifierKey` parameter fit too. Optional: absent, + // Ctrl+Alt reads as a command. + getModifierState?: (key: 'AltGraph') => boolean; // Whether an input method is mid-composition (`KeyboardEvent.isComposing`). // Optional like the flags: absent reads as "not composing". isComposing?: boolean; diff --git a/packages/keyrove/src/utils.ts b/packages/keyrove/src/utils.ts index 0e414c1..bd96109 100644 --- a/packages/keyrove/src/utils.ts +++ b/packages/keyrove/src/utils.ts @@ -97,9 +97,11 @@ export const isComboSet = (value: string | null | undefined): value is string => /** * 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. + * Nor does a press with AltGr held: Windows reports AltGr as Ctrl+Alt, yet + * the press types a character, such as "@" or "€" on a German layout. */ export const hasCommandModifier = (e: KeyRoveEvent): boolean => - !!(e.ctrlKey || e.altKey || e.metaKey); + !e.getModifierState?.('AltGraph') && !!(e.ctrlKey || e.altKey || e.metaKey); // An editable target owns the keys keyrove binds: arrows and Home/End move // the caret there, and printable keys type. `closest` rather than `matches`, From 31860929d6b7d8c4b4816794e7fb5561398efb0f Mon Sep 17 00:00:00 2001 From: mixedrays Date: Fri, 6 Nov 2026 20:20:07 +0100 Subject: [PATCH 07/13] feat: count the first line's items for cols auto on wrapping layouts `cols: 'auto'` counted only CSS grid tracks, so a flex-wrap or inline-block gallery fell back to a one-column list and needed hand-written glue to copy its column count into the attribute. Where the root has no more than one grid track, `auto` now counts the items on the first line: from the first item of the sequence, every item that starts above the first item's bottom edge, on each keypress. Items of different heights centred on one line count as that line. Only the first line is read, plus one item. An item with no box ends the count, so without layout (jsdom) the group stays a list. A grid with more than one track returns before any item is read, and a numeric count never reads layout. readConfig takes the group (its root and its lazily read items) in place of the root. This widens what `auto` means on a non-grid root: a single-line flex row becomes a one-row grid, so its next/prev keys move to ArrowLeft/Right. A toolbar should use data-keyrove-orientation instead. --- DEVELOPMENT.md | 3 +- GLOSSARY.md | 4 +- apps/docs/content/docs/api.md | 9 +- .../content/docs/attributes-and-options.md | 5 +- .../docs/examples/javascript-options.md | 5 +- .../content/docs/examples/responsive-grid.md | 67 +++++-- packages/keyrove/README.md | 2 +- packages/keyrove/browser/wrapping.test.ts | 175 ++++++++++++++++++ .../__tests__/keyRove/keyRove.grid.test.ts | 15 ++ packages/keyrove/src/config.ts | 87 +++++++-- packages/keyrove/src/keyRove.ts | 2 +- packages/keyrove/src/rove.ts | 2 +- packages/keyrove/src/types.ts | 3 +- 13 files changed, 339 insertions(+), 40 deletions(-) create mode 100644 packages/keyrove/browser/wrapping.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f03c6dd..3908de3 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -62,7 +62,8 @@ covers that part of the contract in a real browser: a small suite under library's public entry and drives it with real key presses and clicks. It checks that Tab and Shift+Tab enter, leave and return to a roving group, that nested groups keep their own stops, that navigation works in a shadow root, -and that a move passes over a hidden or inert target, leaving its stop alone. +that a move passes over a hidden or inert target, leaving its stop alone, and +that `cols: 'auto'` counts the columns of a grid or a wrapping layout. It runs on Vitest's browser mode with Playwright, in headless Chromium. Install the browser once: diff --git a/GLOSSARY.md b/GLOSSARY.md index 9c3d6b5..62f1af2 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -61,11 +61,11 @@ _Avoid_: Vertical group, one-dimensional group **Grid**: A group with a column count above one, whose item sequence fills consecutive rows of that many items. -In keyrove, "grid" means a column count above one, which a CSS grid can supply; neither the ARIA `grid` role nor on-screen geometry makes a group a grid. +In keyrove, "grid" means a column count above one, which a CSS grid or a wrapping layout can supply; neither the ARIA `grid` role nor on-screen geometry makes a group a grid on its own. _Avoid_: Matrix, table, spatial navigation **Column count**: -The number of items per row of a group's item sequence, either fixed or counted from the root's CSS grid columns. A column count of one makes a list. +The number of items per row of a group's item sequence, either fixed or counted on screen, from the root's CSS grid columns or from the items on its first line. A column count of one makes a list. _Avoid_: Row length, width **Orientation**: diff --git a/apps/docs/content/docs/api.md b/apps/docs/content/docs/api.md index a5d2426..1226232 100644 --- a/apps/docs/content/docs/api.md +++ b/apps/docs/content/docs/api.md @@ -195,7 +195,8 @@ listener's element. A listener on a shadow root falls back to the shadow root itself. It has no attributes, so configure that group with [options](/docs/attributes-and-options), or wrap the items in an element carrying `data-keyrove-root`. Reading -direction and `cols: 'auto'` are read from the host element. +direction and the grid tracks `cols: 'auto'` counts are read from the host +element. - The root's attributes configure the group. They are read on every keypress, so changing one takes effect at once; see @@ -380,7 +381,7 @@ keyRove(e, { items: '[role="menuitem"]' }); // override item lookup only | ---------------- | ------------------------------------------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `items` | `string \| (root: Element) => Element[]` | `data-keyrove-item` | The group's items: a selector run inside the root, or a function returning them. Elements carrying `disabled` or matching `:disabled` are never navigable. | | `root` | `string` | `data-keyrove-root` | Selector a [root](#roots) answers to, matched at or above the event's target. | -| `cols` | `number \| 'auto'` | `data-keyrove-cols` | Columns. Above 1 the group is a grid. `'auto'` counts the root's CSS grid tracks on every keypress. | +| `cols` | `number \| 'auto'` | `data-keyrove-cols` | Columns. Above 1 the group is a grid. `'auto'` counts the root's CSS grid tracks on every keypress, or else the items on a wrapping layout's first line. | | `loop` | `boolean` | `data-keyrove-loop` | Whether next/prev wrap at the ends. Lists only. | | `orientation` | `'horizontal' \| 'vertical'` | `data-keyrove-orientation` | `'horizontal'` re-points a list's default arrows; see [RTL](#horizontal-groups-and-rtl). | | `pageLength` | `number` | `data-keyrove-page-length` | Rows per page jump — items, in a list. | @@ -778,7 +779,7 @@ 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. `auto` counts the tracks of the root's CSS grid on every keypress; see [responsive grid](/docs/examples/responsive-grid). | +| `data-keyrove-cols` | `1` | Column count; above 1 the group navigates as a grid. Values below 1 fall back to the default. `auto` counts grid tracks, or else first-line items, per 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). | @@ -1051,7 +1052,7 @@ All group settings are optional; see [options](#options) for fallbacks. type GroupOptions = { items?: string | ((root: Element) => Element[]); root?: string; - cols?: number | 'auto'; + cols?: number | 'auto'; // 'auto': the grid's tracks, else the first line's items loop?: boolean; orientation?: 'horizontal' | 'vertical'; pageLength?: number; diff --git a/apps/docs/content/docs/attributes-and-options.md b/apps/docs/content/docs/attributes-and-options.md index d043fbb..026e865 100644 --- a/apps/docs/content/docs/attributes-and-options.md +++ b/apps/docs/content/docs/attributes-and-options.md @@ -145,8 +145,9 @@ el.addEventListener('keydown', (e) => ); ``` -For a CSS grid, `data-keyrove-cols="auto"` or `{ cols: 'auto' }` reads the -column count from the layout; see [responsive grid](/docs/examples/responsive-grid). +For a CSS grid or a wrapping layout, `data-keyrove-cols="auto"` or +`{ cols: 'auto' }` reads the column count from the layout; see +[responsive grid](/docs/examples/responsive-grid). ## One object, both handlers diff --git a/apps/docs/content/docs/examples/javascript-options.md b/apps/docs/content/docs/examples/javascript-options.md index 485fdd1..4758f64 100644 --- a/apps/docs/content/docs/examples/javascript-options.md +++ b/apps/docs/content/docs/examples/javascript-options.md @@ -75,5 +75,6 @@ keyRove(e, { items: '.cell', skip: '.cell-empty', cols: 4 }); Use options when you cannot edit the markup or need a computed setting: `keyRove(e, { items: '.cell', cols: columnsNow() })` reads the column count on -each call. For CSS grids, `cols: 'auto'` or `data-keyrove-cols="auto"` reads it -from the layout; see [responsive grid](/docs/examples/responsive-grid). +each call. For CSS grids and wrapping layouts, `cols: 'auto'` or +`data-keyrove-cols="auto"` reads it from the layout; see +[responsive grid](/docs/examples/responsive-grid). diff --git a/apps/docs/content/docs/examples/responsive-grid.md b/apps/docs/content/docs/examples/responsive-grid.md index 9cf809e..eef78ff 100644 --- a/apps/docs/content/docs/examples/responsive-grid.md +++ b/apps/docs/content/docs/examples/responsive-grid.md @@ -40,8 +40,9 @@ attribute is not needed. ## Counted on every keypress -`auto` reads the root's computed `grid-template-columns` during each call. -No resize listener or observer is needed to keep the count current. +`auto` reads the root's computed `grid-template-columns` during each call, or, +where that has no tracks to count, the positions of the items on the first +line. No resize listener or observer is needed to keep the count current. ## The stylesheet owns the breakpoints @@ -64,19 +65,59 @@ way. ## What auto counts -- The root must be the grid container, with one item per cell in DOM order. - Each item must span one track. Spanning items, subgrids and row wrappers do - not match this navigation model, including when `items` selects descendants. +- On a CSS grid, the root must be the grid container, with one item per cell + in DOM order. Each item must span one track. Spanning items, subgrids and + row wrappers do not match this navigation model, including when `items` + selects descendants. - Named lines, such as `[full-start]`, are not counted as columns. -- If the computed value does not resolve to a list of pixel track sizes, the - count falls back to one and the group navigates as a list. This includes a - flex container or a grid that is not laid out. - -## Layouts that are not grids +- If the computed value does not resolve to a list of pixel track sizes — a + flex container, a block of inline-block items, or a grid that is not laid + out — `auto` counts the items on the first line instead; see + [wrapping layouts](#wrapping-layouts). Where nothing is laid out at all, as + in jsdom, that count is one and the group navigates as a list. + +## Wrapping layouts + +A `flex-wrap` row, or inline-block items in a block container, has no track +list, so `auto` counts the items laid out on the first line: from the first +item, every item that starts above the first item's bottom edge. Items of +different heights centred on one line (`align-items: center`) count as that +line. Only the first line is measured, plus one item, so the cost grows with +the column count rather than with the group. + +```html +
+ + … +
+``` -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`: +The count follows the layout. A card gallery whose cards wrap by width folds +by however many fit, without a stylesheet stating the number. Some outcomes +follow from it: + +- A single-line flex row is a one-row grid of all its items, so its next and + previous keys are ← and →, and + ↑ and ↓ are row moves with + nowhere to go. For a toolbar, use `data-keyrove-orientation="horizontal"` + rather than `auto`. +- A `flex-direction: column` root stacks its items, so its first line holds + one and it stays a list. +- The item sequence is document order. `flex-direction: row-reverse`, + `wrap-reverse` and `order` change only what is on screen, so the fold no + longer matches it. +- An item with no box, such as one with `display: none`, ends the count: it + takes no place on screen but keeps one in the sequence. Leave hidden items + out of a wrapping group's items, with `items` or by removing the item + attribute. +- The first line is counted from the first item, skipped items included, as + skipped items keep their place in the fold. + +## When the first line is not representative + +Where the first line holds a different number of items from the rest, such as +a wide featured card at the start, count the columns yourself and copy the +count in right before the call. Here the stylesheet publishes it as `--cols`: ```ts months.addEventListener('keydown', (e) => { diff --git a/packages/keyrove/README.md b/packages/keyrove/README.md index d10d576..1d098b9 100644 --- a/packages/keyrove/README.md +++ b/packages/keyrove/README.md @@ -363,7 +363,7 @@ while its attribute applies per item. See | `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. `auto` counts the root's CSS grid tracks on every keypress. | +| `data-keyrove-cols` | `cols` | root | `1` | Column count; above 1 the group navigates as a grid. `auto` counts CSS grid tracks, else the first line's items, per 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/browser/wrapping.test.ts b/packages/keyrove/browser/wrapping.test.ts new file mode 100644 index 0000000..3883262 --- /dev/null +++ b/packages/keyrove/browser/wrapping.test.ts @@ -0,0 +1,175 @@ +/** + * `cols: 'auto'` on layouts that wrap without grid tracks — flex-wrap rows and + * inline-block items — where the column count is the items on the first line. + * It needs real layout, which jsdom has none of. Run with `pnpm test:browser`. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { keyRove, rove } from '../src/index'; +import type { KeyRoveOptions, MoveResult } from '../src/index'; +import { activeId, byId, mount, named, press, unmount } from './testUtils'; + +afterEach(unmount); + +/** Listens for keys on `root`, keeping every keydown's result. */ +const listen = (root: Element, options?: KeyRoveOptions) => { + const results: (MoveResult | null)[] = []; + root.addEventListener('keydown', (e) => + results.push(keyRove(e as KeyboardEvent, options)), + ); + + return results; +}; + +/** `count` items `i0`, `i1`, …, each with the given inline style. */ +const items = (count: number, style = '') => + Array.from( + { length: count }, + (_, i) => + ``, + ).join(''); + +// Nine 90px items in a 300px row: three to a line. +const FLEX_WRAP = ` +
+ ${items(9, 'width: 90px; margin: 0; padding: 4px')} +
+`; + +describe("cols: 'auto' on a wrapping layout", () => { + it('folds a flex-wrap row by the items on its first line', async () => { + mount(FLEX_WRAP); + const results = listen(byId('root')); + byId('i0').focus(); + + await press('{ArrowRight}'); + expect(activeId()).toBe('i1'); + + await press('{ArrowLeft}'); + await press('{ArrowDown}'); + expect(activeId()).toBe('i3'); + expect(results.map(named)).toEqual([ + { action: 'next', from: 'i0', to: 'i1' }, + { action: 'prev', from: 'i1', to: 'i0' }, + { action: 'nextRow', from: 'i0', to: 'i3' }, + ]); + }); + + it('counts again on the next press after the layout reflows', async () => { + mount(FLEX_WRAP); + listen(byId('root')); + byId('i0').focus(); + + await press('{ArrowDown}'); + expect(activeId()).toBe('i3'); + + // Two to a line: i3 is now in the second row's second column. + byId('root').style.width = '200px'; + await press('{ArrowDown}'); + expect(activeId()).toBe('i5'); + }); + + it('counts items of mixed heights centred on one line as that line', async () => { + mount(` +
+ ${items(9, 'width: 90px; margin: 0; height: 30px')} +
+ `); + // The first item is the shortest on its line, and the next one the + // tallest: both start above the first item's bottom only because they + // share its line. + byId('i0').style.height = '20px'; + byId('i1').style.height = '60px'; + listen(byId('root')); + byId('i0').focus(); + + await press('{ArrowDown}'); + + expect(activeId()).toBe('i3'); + }); + + it('folds inline-block items in a block root the same way', async () => { + mount(` +
+ ${items(9, 'display: inline-block; width: 90px; margin: 0')} +
+ `); + listen(byId('root')); + byId('i0').focus(); + + await press('{ArrowDown}'); + expect(activeId()).toBe('i3'); + + await press('{ArrowRight}'); + expect(activeId()).toBe('i4'); + }); + + it('makes a single-line flex row a one-row grid of all its items', async () => { + mount(` +
${items(4)}
+ `); + const results = listen(byId('root'), { cols: 'auto' }); + byId('i0').focus(); + + await press('{ArrowRight}'); + expect(activeId()).toBe('i1'); + + // A grid's row move, with no row below to go to: a consumed no-op. + await press('{ArrowDown}'); + expect(activeId()).toBe('i1'); + + await press('{End}'); + expect(activeId()).toBe('i3'); + expect(results.map(named)).toEqual([ + { action: 'next', from: 'i0', to: 'i1' }, + { action: 'nextRow', from: 'i1', to: null }, + { action: 'endRow', from: 'i1', to: 'i3' }, + ]); + }); + + it('leaves a flex column a list', async () => { + mount(` +
${items(4)}
+ `); + const results = listen(byId('root')); + byId('i0').focus(); + + await press('{ArrowDown}'); + expect(activeId()).toBe('i1'); + + await press('{ArrowRight}'); + expect(activeId()).toBe('i1'); + expect(results.map(named)).toEqual([ + { action: 'next', from: 'i0', to: 'i1' }, + null, + ]); + }); + + it('ends the first line at an item with no box', async () => { + mount(FLEX_WRAP); + // Hidden, i1 takes no slot on screen; the line is counted up to it. + byId('i1').style.display = 'none'; + const results = listen(byId('root')); + byId('i0').focus(); + + await press('{ArrowRight}'); + + expect(results.map(named)).toEqual([null]); + expect(activeId()).toBe('i0'); + }); + + it('folds a move made from code the same way', () => { + mount(FLEX_WRAP); + byId('i1').focus(); + + expect(named(rove(byId('root'), 'nextRow'))).toEqual({ + action: 'nextRow', + from: 'i1', + to: 'i4', + }); + expect(activeId()).toBe('i4'); + }); +}); diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.grid.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.grid.test.ts index 3caf063..b592a8c 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.grid.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.grid.test.ts @@ -381,6 +381,21 @@ describe('keyRove', () => { expect(activeId()).toBe('2'); }); + + it('reads no layout for a numeric count', () => { + const style = vi.spyOn(CSSStyleDeclaration.prototype, 'getPropertyValue'); + const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect'); + const rects = vi.spyOn(Element.prototype, 'getClientRects'); + renderGrid(9, 3); + document.getElementById('0')!.focus(); + + pressKey('ArrowDown'); + + expect(activeId()).toBe('3'); + expect(style).not.toHaveBeenCalledWith('grid-template-columns'); + expect(rect).not.toHaveBeenCalled(); + expect(rects).not.toHaveBeenCalled(); + }); }); describe('grid key bindings', () => { diff --git a/packages/keyrove/src/config.ts b/packages/keyrove/src/config.ts index 9105b39..d4765e8 100644 --- a/packages/keyrove/src/config.ts +++ b/packages/keyrove/src/config.ts @@ -38,6 +38,16 @@ import type { Layout, } from './types.js'; +/** + * What the settings are read off: the group's root, and its item sequence — + * asked for only where `auto` counts the columns on the first line, so it is + * best handed over as a getter that reads the items on first ask. + */ +type ConfigGroup = { + readonly root: Element; + readonly items: readonly Element[]; +}; + // `Node.DOCUMENT_FRAGMENT_NODE`, spelled out so reading it needs no global // `Node`. const DOCUMENT_FRAGMENT_NODE = 11; @@ -108,20 +118,72 @@ const countTracks = (root: Element): number => { return tracks.every((track) => TRACK.test(track)) ? tracks.length : 1; }; +/** + * The columns a wrapping layout lays out, where it has no tracks to count — + * a `flex-wrap` row, inline-block items: the items on the first line. The walk + * goes from the first item and stops at the first one starting below the + * first item's box, so items of different heights centred on one line still + * count as that line, and only the first line is read, plus one item. + * + * It counts the item sequence, which keeps a skipped item in its slot, as the + * fold does, and reads each item where it sits, in a shadow root as anywhere. + * An item with no box — `display: none`, or nothing laid out at all, as in + * jsdom — takes no slot on a line, so the count ends there: a first item with + * no box leaves the group a list. + */ +const countFirstLine = (items: readonly Element[]): number => { + const hasBox = (item: Element) => item.getClientRects().length > 0; + const first = items[0]; + + if (!first || !hasBox(first)) return 1; + + const { bottom, height } = first.getBoundingClientRect(); + + if (height === 0) return 1; + + let columns = 1; + + while ( + columns < items.length && + hasBox(items[columns]) && + items[columns].getBoundingClientRect().top < bottom + ) { + columns++; + } + + return columns; +}; + +/** + * The columns on screen, for `auto`: a CSS grid's tracks, where there is more + * than one, else the items on the first line. The items are asked for only + * here, so a numeric count never reads them, nor any layout. + */ +const countColumns = (group: ConfigGroup): number => { + const tracks = countTracks(group.root); + + return tracks > 1 ? tracks : countFirstLine(group.items); +}; + /** * 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 + * attribute, counts the columns 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 readColumns = ( + group: ConfigGroup, + cols: GroupOptions['cols'], +): number => { + if (cols === 'auto') return countColumns(group); const option = count(cols); if (option) return option; + const { root } = group; + if (readAttribute(root, KEYROVE_ATTR_COLS)?.trim().toLowerCase() === 'auto') { - return countTracks(root); + return countColumns(group); } return parseAttributeInt(root, KEYROVE_ATTR_COLS, 1); @@ -133,10 +195,11 @@ const readColumns = (root: Element, cols: GroupOptions['cols']): number => { * sideways by nature — and never wraps, per the APG grid pattern. */ const readLayout = ( - root: Element, + group: ConfigGroup, { cols, orientation, loop }: GroupOptions, ): Layout => { - const columns = readColumns(root, cols); + const { root } = group; + const columns = readColumns(group, cols); if (columns > 1) { return { kind: 'grid', cols: columns, horizontal: true, loop: false }; @@ -209,20 +272,20 @@ export const readFocusKeys = ( }; /** - * Every setting one move needs, read once for the root it resolved in. The + * Every setting one move needs, read once for the group 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, + group: ConfigGroup, options: GroupOptions, ): GroupConfig => ({ - layout: readLayout(root, options), - explicit: readExplicitBinding(root, options), + layout: readLayout(group, options), + explicit: readExplicitBinding(group.root, options), // Resolved on demand: read only when an unbound `next`/`prev` default on a // horizontal axis could flip, never otherwise. - rtl: () => isRtl(root), + rtl: () => isRtl(group.root), pageLength: count(options.pageLength) ?? - parseAttributeInt(root, KEYROVE_ATTR_PAGE_LENGTH, 10), + parseAttributeInt(group.root, KEYROVE_ATTR_PAGE_LENGTH, 10), }); diff --git a/packages/keyrove/src/keyRove.ts b/packages/keyrove/src/keyRove.ts index 7158bfa..8a9c76a 100644 --- a/packages/keyrove/src/keyRove.ts +++ b/packages/keyrove/src/keyRove.ts @@ -64,7 +64,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) ?? pressed.root; - const config = readConfig(pressed.root, options); + const config = readConfig(pressed, options); const { onMove } = options; // First match wins: one keypress resolves to at most one action, and the diff --git a/packages/keyrove/src/rove.ts b/packages/keyrove/src/rove.ts index 03bf22f..91bc662 100644 --- a/packages/keyrove/src/rove.ts +++ b/packages/keyrove/src/rove.ts @@ -66,7 +66,7 @@ export const rove = ( if (!group) return null; - const config = readConfig(group.root, options); + const config = readConfig(group, options); if (config.layout.kind === 'list' && action.endsWith('Row')) return null; diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index c85a356..34bb53c 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -182,7 +182,8 @@ export type GroupOptions = { root?: string; /** * 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. + * root's CSS grid on every keypress, or, where it has none, the items on + * the first line of a wrapping layout. Defaults to the cols attribute. */ cols?: number | 'auto'; /** From bc994964322ff70497a48d17f2a77eaf88c911cb Mon Sep 17 00:00:00 2001 From: mixedrays Date: Sat, 7 Nov 2026 14:51:05 +0100 Subject: [PATCH 08/13] perf: read the layout only for a press that could move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With cols auto, readConfig read the layout on every keydown, before the key was matched. On a flex root an unbound key paid for reading the items and two rect calls per first-line item, and laid the page out when it had changed since the last read. readConfig now reads the layout on first ask. findBinding asks for it only when the press's code is a default key of some layout, or the press matches a combo some move is bound to. Any other press can match nothing but a focus key, and focus keys head every table, so it is resolved among them without the layout. Default keys are tested by code alone, a bound combo is parsed only where its text holds the press's code, and each move's attribute name is spelled once. countFirstLine reads one getClientRects() per item, its first box, in place of a getClientRects() and a getBoundingClientRect(). An unbound key now costs about 1.2 µs on every root, down from 2.5 µs on a list, 5.6 µs on a grid with auto and 10-25 µs on a flex root with auto, and none lays out a changed page. --- apps/docs/content/docs/api.md | 2 +- .../content/docs/examples/responsive-grid.md | 4 + .../src/__tests__/bindings/bindings.test.ts | 152 +++++++++++++++++- .../__tests__/keyRove/keyRove.grid.test.ts | 61 +++++++ packages/keyrove/src/bindings.ts | 89 ++++++++-- packages/keyrove/src/config.ts | 64 +++++--- packages/keyrove/src/keyAttribute.ts | 20 ++- packages/keyrove/src/keyRove.ts | 13 +- packages/keyrove/src/types.ts | 11 +- 9 files changed, 364 insertions(+), 52 deletions(-) diff --git a/apps/docs/content/docs/api.md b/apps/docs/content/docs/api.md index 1226232..b912d8c 100644 --- a/apps/docs/content/docs/api.md +++ b/apps/docs/content/docs/api.md @@ -381,7 +381,7 @@ keyRove(e, { items: '[role="menuitem"]' }); // override item lookup only | ---------------- | ------------------------------------------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `items` | `string \| (root: Element) => Element[]` | `data-keyrove-item` | The group's items: a selector run inside the root, or a function returning them. Elements carrying `disabled` or matching `:disabled` are never navigable. | | `root` | `string` | `data-keyrove-root` | Selector a [root](#roots) answers to, matched at or above the event's target. | -| `cols` | `number \| 'auto'` | `data-keyrove-cols` | Columns. Above 1 the group is a grid. `'auto'` counts the root's CSS grid tracks on every keypress, or else the items on a wrapping layout's first line. | +| `cols` | `number \| 'auto'` | `data-keyrove-cols` | Columns. Above 1 the group is a grid. `'auto'` counts the root's CSS grid tracks on every keypress that could move, or else the items on a wrapping layout's first line. | | `loop` | `boolean` | `data-keyrove-loop` | Whether next/prev wrap at the ends. Lists only. | | `orientation` | `'horizontal' \| 'vertical'` | `data-keyrove-orientation` | `'horizontal'` re-points a list's default arrows; see [RTL](#horizontal-groups-and-rtl). | | `pageLength` | `number` | `data-keyrove-page-length` | Rows per page jump — items, in a list. | diff --git a/apps/docs/content/docs/examples/responsive-grid.md b/apps/docs/content/docs/examples/responsive-grid.md index eef78ff..e9563de 100644 --- a/apps/docs/content/docs/examples/responsive-grid.md +++ b/apps/docs/content/docs/examples/responsive-grid.md @@ -44,6 +44,10 @@ attribute is not needed. where that has no tracks to count, the positions of the items on the first line. No resize listener or observer is needed to keep the count current. +Only a press that could move reads the layout: an arrow, Home/End, a page key, +or a key a move is bound to. Any other key, and a focus key, reads none, so +typing into typeahead never asks the browser for the layout. + ## The stylesheet owns the breakpoints The panel declares itself a container with `container-type: inline-size`; the diff --git a/packages/keyrove/src/__tests__/bindings/bindings.test.ts b/packages/keyrove/src/__tests__/bindings/bindings.test.ts index aea813f..2cd71ee 100644 --- a/packages/keyrove/src/__tests__/bindings/bindings.test.ts +++ b/packages/keyrove/src/__tests__/bindings/bindings.test.ts @@ -1,8 +1,11 @@ import { describe, it, expect, vi } from 'vitest'; -import { buildBindings } from '../../bindings'; +import { buildBindings, findBinding } from '../../bindings'; +import { matchesCombo } from '../../utils'; import type { BoundaryAction, BuildBindingsArgs, + FocusKey, + KeyRoveEvent, Layout, StrideAction, } from '../../types'; @@ -437,3 +440,150 @@ describe('buildBindings', () => { }); }); }); + +describe('findBinding', () => { + const press = ( + code: string, + modifiers: Pick = {}, + ): KeyRoveEvent => ({ + code, + target: null, + currentTarget: null, + preventDefault: () => {}, + ...modifiers, + }); + + type FindOverrides = { + explicit?: Explicit; + focus?: FocusKey[]; + layout?: Layout; + rtl?: boolean; + }; + + const find = ( + e: KeyRoveEvent, + { + explicit = {}, + focus = [], + layout = LIST, + rtl = false, + }: FindOverrides = {}, + ) => { + const readLayout = vi.fn(() => layout); + const binding = findBinding(e, { + explicit: lookup(explicit), + focus, + layout: readLayout, + rtl: () => rtl, + }); + + return { binding, readLayout }; + }; + + it('leaves the layout unread for a press no move could claim', () => { + const { binding, readLayout } = find(press('KeyQ')); + + expect(binding).toBeUndefined(); + expect(readLayout).not.toHaveBeenCalled(); + }); + + it('resolves a focus key without the layout', () => { + const target = document.createElement('button'); + const { binding, readLayout } = find(press('KeyF', { altKey: true }), { + focus: [{ combo: 'alt+KeyF', target }], + }); + + expect(binding).toEqual({ + combo: 'alt+KeyF', + intent: 'focus', + enters: true, + target, + }); + expect(readLayout).not.toHaveBeenCalled(); + }); + + it.each([ + ['ArrowUp', {}], + ['ArrowDown', {}], + ['ArrowLeft', {}], + ['ArrowRight', {}], + ['Home', {}], + ['End', {}], + ['Home', { ctrlKey: true }], + ['End', { ctrlKey: true }], + ['PageUp', {}], + ['PageDown', {}], + ] as const)( + 'reads the layout for %s %o, a default key in some layout', + (code, modifiers) => { + const { readLayout } = find(press(code, modifiers)); + + expect(readLayout).toHaveBeenCalledOnce(); + }, + ); + + it('reads the layout for a key bound to a move the layout may lack', () => { + const explicit = { nextRow: 'KeyJ' }; + + const inList = find(press('KeyJ'), { explicit }); + expect(inList.readLayout).toHaveBeenCalledOnce(); + expect(inList.binding).toBeUndefined(); + + const inGrid = find(press('KeyJ'), { explicit, layout: GRID }); + expect(inGrid.binding?.intent).toBe('nextRow'); + }); + + it('reads the layout for a bound boundary move', () => { + const { binding, readLayout } = find(press('Escape'), { + explicit: { exit: 'Escape' }, + }); + + expect(binding?.intent).toBe('exit'); + expect(readLayout).toHaveBeenCalledOnce(); + }); + + it('resolves every press as the first match of the full table', () => { + const target = document.createElement('button'); + const setups: FindOverrides[] = [ + {}, + { layout: GRID }, + { layout: { ...LIST, horizontal: true }, rtl: true }, + { explicit: { next: 'KeyJ', prev: 'none', nextRow: 'KeyN' } }, + { explicit: { home: 'ctrl+Home', exit: 'Escape' }, layout: GRID }, + { focus: [{ combo: 'ArrowDown', target }] }, + { focus: [{ combo: 'KeyQ, alt+KeyF', target }], layout: GRID }, + ]; + const presses = [ + press('KeyQ'), + press('KeyJ'), + press('KeyN'), + press('Escape'), + press('KeyF', { altKey: true }), + press('ArrowUp'), + press('ArrowDown'), + press('ArrowLeft'), + press('ArrowRight'), + press('ArrowDown', { shiftKey: true }), + press('Home'), + press('Home', { ctrlKey: true }), + press('End', { ctrlKey: true }), + press('PageDown'), + ]; + + for (const setup of setups) { + const { explicit = {}, focus = [], layout = LIST, rtl = false } = setup; + const table = buildBindings({ + explicit: lookup(explicit), + focus, + layout, + rtl: () => rtl, + }); + + for (const e of presses) { + expect(find(e, setup).binding).toEqual( + table.find(({ combo }) => matchesCombo(e, combo)), + ); + } + } + }); +}); diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.grid.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.grid.test.ts index b592a8c..86ac6dc 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.grid.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.grid.test.ts @@ -382,6 +382,67 @@ describe('keyRove', () => { expect(activeId()).toBe('2'); }); + it('reads no layout for a key no move is bound to', () => { + const style = resolveColumns('100px 100px 100px'); + const rects = vi.spyOn(Element.prototype, 'getClientRects'); + renderAuto(); + document.getElementById('0')!.focus(); + + const press = pressKey('KeyQ'); + + expect(press.defaultPrevented).toBe(false); + expect(style).not.toHaveBeenCalled(); + expect(rects).not.toHaveBeenCalled(); + }); + + it('reads no layout for a focus key', () => { + const style = resolveColumns('100px 100px 100px'); + const rects = vi.spyOn(Element.prototype, 'getClientRects'); + const root = renderAuto(); + root.children[5].setAttribute('data-keyrove-focus-key', 'alt+KeyF'); + document.getElementById('0')!.focus(); + + pressKey('KeyF', undefined, { altKey: true }); + + expect(activeId()).toBe('5'); + expect(style).not.toHaveBeenCalled(); + expect(rects).not.toHaveBeenCalled(); + }); + + it('reads the layout for a row move bound to another key', () => { + resolveColumns('100px 100px 100px'); + renderAuto({ containerAttrs: { [KEYROVE_ATTR_NEXT_ROW_KEY]: 'KeyJ' } }); + document.getElementById('0')!.focus(); + + pressKey('KeyJ'); + + expect(activeId()).toBe('3'); + }); + + it('counts the first line with one getClientRects call per item', () => { + // No grid tracks, and three items a line: each item's first box sits + // on the row its index puts it in. + resolveColumns('none'); + const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect'); + const rects = vi + .spyOn(Element.prototype, 'getClientRects') + .mockImplementation(function (this: Element) { + const top = Math.floor(Number(this.id) / 3) * 20; + return [ + { top, bottom: top + 20, height: 20 }, + ] as unknown as DOMRectList; + }); + renderAuto(); + document.getElementById('0')!.focus(); + + pressKey('ArrowDown'); + + expect(activeId()).toBe('3'); + expect(rect).not.toHaveBeenCalled(); + // The first line's three items, and the one starting the next line. + expect(rects).toHaveBeenCalledTimes(4); + }); + it('reads no layout for a numeric count', () => { const style = vi.spyOn(CSSStyleDeclaration.prototype, 'getPropertyValue'); const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect'); diff --git a/packages/keyrove/src/bindings.ts b/packages/keyrove/src/bindings.ts index 5ff78e7..2b01711 100644 --- a/packages/keyrove/src/bindings.ts +++ b/packages/keyrove/src/bindings.ts @@ -9,11 +9,15 @@ * precedence of a keypress is one ordered list. */ -import { isComboSet } from './utils.js'; +import { isComboSet, matchesCombo } from './utils.js'; import type { Binding, BoundaryAction, BuildBindingsArgs, + ExplicitBinding, + FindBindingArgs, + FocusKey, + KeyRoveEvent, KnownCode, Layout, StrideAction, @@ -72,6 +76,56 @@ const defaultTable = ( ['pageDown', 'PageDown'], ]; +// Every row a default table holds, in every layout and direction: a grid both +// ways round, which binds every arrow, and a list, which binds bare Home/End. +const EVERY_ROW = ( + [ + { kind: 'grid', cols: 2, horizontal: true, loop: false }, + { kind: 'list', cols: 1, horizontal: false, loop: false }, + ] satisfies Layout[] +).flatMap((layout) => + [false, true].flatMap((flip) => defaultTable(layout, flip)), +); + +// What a press must match before any layout could make it a move: a key some +// default table binds, with or without its modifiers, or the combo a root +// binds any move to. +const DEFAULT_CODES = new Set( + EVERY_ROW.map(([, combo]) => combo.split('+').pop()!), +); +const MOVES = [...new Set(EVERY_ROW.map(([intent]) => intent)), ...BOUNDARY]; + +/** + * Whether a press could be a move in some layout. Every table's moves are + * among the defaults and bindings tried here, so a press matching none of + * them can be claimed by nothing but a focus key, whatever the layout. The + * test may say yes where the table then matches nothing, never the other way + * round: a default key counts by its code alone, and a bound combo is parsed + * only where its text holds the press's code. + */ +const couldMove = (e: KeyRoveEvent, explicit: ExplicitBinding) => + DEFAULT_CODES.has(e.code) || + MOVES.some((intent) => { + const combo = explicit(intent); + + return !!combo && combo.includes(e.code) && matchesCombo(e, combo); + }); + +/** + * The focus keys as bindings, 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 focusBindings = (focus: readonly FocusKey[]): Binding[] => + focus + .filter(({ combo }) => isComboSet(combo) && !isNone(combo)) + .map(({ combo, target }) => ({ + combo, + intent: 'focus', + enters: true, + target, + })); + /** * Builds the ordered binding table for a group. The first entry that matches * a keypress claims it, so order *is* precedence: the focus keys first, then @@ -116,17 +170,26 @@ 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 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 }) => isComboSet(combo) && !isNone(combo)) - .map(({ combo, target }) => ({ - combo, - intent: 'focus', - enters: true, - target, - })); + // naming one combo resolve to the first in DOM order. + return [...focusBindings(focus), ...rebound, ...defaults]; +}; + +/** + * The binding a press resolves to: the first entry of the group's table that + * matches it. The layout, which `auto` reads off the page, is asked for only + * when the press could be a move in some layout. Any other press can match + * nothing but a focus key, and those head every table, so it is resolved + * among them without the rest of the table. + */ +export const findBinding = ( + e: KeyRoveEvent, + { explicit, focus = [], layout, rtl }: FindBindingArgs, +): Binding | undefined => { + const matches = ({ combo }: Binding) => matchesCombo(e, combo); + + if (!couldMove(e, explicit)) return focusBindings(focus).find(matches); - return [...named, ...rebound, ...defaults]; + return buildBindings({ explicit, focus, layout: layout(), rtl }).find( + matches, + ); }; diff --git a/packages/keyrove/src/config.ts b/packages/keyrove/src/config.ts index d4765e8..d0275f5 100644 --- a/packages/keyrove/src/config.ts +++ b/packages/keyrove/src/config.ts @@ -123,7 +123,9 @@ const countTracks = (root: Element): number => { * a `flex-wrap` row, inline-block items: the items on the first line. The walk * goes from the first item and stops at the first one starting below the * first item's box, so items of different heights centred on one line still - * count as that line, and only the first line is read, plus one item. + * count as that line, and only the first line is read, plus one item. Each + * item is read once, by its first box: one call per item, where the + * bounding rect would be a second. * * It counts the item sequence, which keeps a skipped item in its slot, as the * fold does, and reads each item where it sits, in a shadow root as anywhere. @@ -132,21 +134,18 @@ const countTracks = (root: Element): number => { * no box leaves the group a list. */ const countFirstLine = (items: readonly Element[]): number => { - const hasBox = (item: Element) => item.getClientRects().length > 0; - const first = items[0]; + // Where an item starts on screen: its first box, or none without a box. + const firstBox = (item: Element): DOMRect | undefined => + item.getClientRects()[0]; + const first = items[0] && firstBox(items[0]); - if (!first || !hasBox(first)) return 1; - - const { bottom, height } = first.getBoundingClientRect(); - - if (height === 0) return 1; + if (!first || first.height === 0) return 1; let columns = 1; while ( columns < items.length && - hasBox(items[columns]) && - items[columns].getBoundingClientRect().top < bottom + (firstBox(items[columns])?.top ?? Infinity) < first.bottom ) { columns++; } @@ -167,8 +166,8 @@ const countColumns = (group: ConfigGroup): number => { /** * The group's column count. `auto`, from either source and in any case in the - * attribute, counts the columns on screen on every keypress; a number is taken - * as it stands, where it is usable. + * attribute, counts the columns on screen each time the layout is read; a + * number is taken as it stands, where it is usable. */ const readColumns = ( group: ConfigGroup, @@ -231,10 +230,15 @@ const readLayout = ( */ const readExplicitBinding = (root: Element, { keys }: GroupOptions): ExplicitBinding => - (intent) => - [keys?.[intent], readAttribute(root, keyAttribute(intent))].find( - isComboSet, - ); + (intent) => { + const option = keys?.[intent]; + + if (isComboSet(option)) return option; + + const attribute = readAttribute(root, keyAttribute(intent)); + + return isComboSet(attribute) ? attribute : undefined; + }; /** * The focus keys in reach of a keypress: the `focusKeys` map where one is @@ -279,13 +283,21 @@ export const readFocusKeys = ( export const readConfig = ( group: ConfigGroup, options: GroupOptions, -): GroupConfig => ({ - layout: readLayout(group, options), - explicit: readExplicitBinding(group.root, options), - // Resolved on demand: read only when an unbound `next`/`prev` default on a - // horizontal axis could flip, never otherwise. - rtl: () => isRtl(group.root), - pageLength: - count(options.pageLength) ?? - parseAttributeInt(group.root, KEYROVE_ATTR_PAGE_LENGTH, 10), -}); +): GroupConfig => { + let layout: Layout | undefined; + + return { + // Read on first ask, and kept: `auto` reads the page's layout, which a + // press no move could claim never needs. + get layout() { + return (layout ??= readLayout(group, options)); + }, + explicit: readExplicitBinding(group.root, options), + // Resolved on demand: read only when an unbound `next`/`prev` default on a + // horizontal axis could flip, never otherwise. + rtl: () => isRtl(group.root), + pageLength: + count(options.pageLength) ?? + parseAttributeInt(group.root, KEYROVE_ATTR_PAGE_LENGTH, 10), + }; +}; diff --git a/packages/keyrove/src/keyAttribute.ts b/packages/keyrove/src/keyAttribute.ts index 50f1a33..44dcc55 100644 --- a/packages/keyrove/src/keyAttribute.ts +++ b/packages/keyrove/src/keyAttribute.ts @@ -1,7 +1,19 @@ import type { GroupOptions, KeyAttributeName } from './types.js'; +type Move = keyof NonNullable; + +// Spelled once per move: a keypress reads every move's attribute. +const spelled = new Map(); + /** The same move-to-attribute spelling for readers and builders. */ -export const keyAttribute = ( - intent: keyof NonNullable, -): KeyAttributeName => - `data-keyrove-${intent.replace(/[A-Z]/g, '-$&').toLowerCase()}-key` as KeyAttributeName; +export const keyAttribute = (intent: Move): KeyAttributeName => { + let name = spelled.get(intent); + + if (!name) { + name = + `data-keyrove-${intent.replace(/[A-Z]/g, '-$&').toLowerCase()}-key` as KeyAttributeName; + spelled.set(intent, name); + } + + return name; +}; diff --git a/packages/keyrove/src/keyRove.ts b/packages/keyrove/src/keyRove.ts index 8a9c76a..1ce3e90 100644 --- a/packages/keyrove/src/keyRove.ts +++ b/packages/keyrove/src/keyRove.ts @@ -1,10 +1,10 @@ -import { buildBindings } from './bindings.js'; +import { findBinding } from './bindings.js'; import { crossBoundary } from './boundary.js'; import { readConfig, readFocusKeys } from './config.js'; import { holdsFocus, readGroups, walkOn } from './group.js'; import { listenerElement } from './nesting.js'; import { walkTargets } from './position.js'; -import { hasCommandModifier, isEditablePress, matchesCombo } from './utils.js'; +import { hasCommandModifier, isEditablePress } from './utils.js'; import type { KeyRoveEvent, KeyRoveOptions, MoveResult } from './types.js'; // The attribute constants ship alongside the handler that reads them, so @@ -69,13 +69,14 @@ export const keyRove = ( // First match wins: one keypress resolves to at most one action, and the // table's order is the precedence — an element's own key over the explicit - // bindings over the defaults. - const binding = buildBindings({ + // bindings over the defaults. The layout is read only for a press that + // could be a move, so a key nothing binds never reads the page's layout. + const binding = findBinding(e, { explicit: config.explicit, focus: readFocusKeys(scope, options), - layout: config.layout, + layout: () => config.layout, rtl: config.rtl, - }).find(({ combo }) => matchesCombo(e, combo)); + }); if (!binding) return null; diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index 34bb53c..22547c8 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -274,7 +274,8 @@ export type ItemAttributes = { * group's own readings: see {@link GroupReader}. */ export type GroupConfig = { - layout: Layout; + /** Read on first ask: with `auto`, it reads the page's layout. */ + readonly layout: Layout; explicit: ExplicitBinding; rtl: () => boolean; pageLength: number; @@ -435,6 +436,14 @@ export type BuildBindingsArgs = { rtl: () => boolean; }; +export type FindBindingArgs = Omit & { + /** + * The group's layout, asked for only when the press could be a move in some + * layout: with `auto`, reading it reads the page's layout. + */ + layout: () => Layout; +}; + export type ResolveTargetArgs = { intent: StrideAction; elements: Element[]; From b3a817e45cf827ddaa6aee61339b426b1058ad4b Mon Sep 17 00:00:00 2001 From: mixedrays Date: Sat, 7 Nov 2026 14:51:05 +0100 Subject: [PATCH 09/13] chore: time cols auto on flex layouts and with a changed layout in the benchmark The columns section timed only a four-column CSS grid, whose tracks auto counts without reading any item, so the first-line count went unmeasured. It now also lays groups out as a flex-wrap row of four, a flex row of all 20 and a flex column, with a fixed cols and with auto. Each page times the unbound key and a move with the layout laid out and with it changed just before each press, and one layout of the page with no handler. A probe skips the flex moves on versions that read a layout with no grid tracks as a list. Re-recorded, with the README's results and findings, and the bench tests' copied rows, updated to the new run. --- apps/bench/README.md | 258 +++++--- apps/bench/results/recorded.json | 1012 +++++++++++++++++++++++------- apps/bench/src/keydown.ts | 187 +++++- apps/bench/src/plan.ts | 31 +- apps/bench/src/results.ts | 2 +- apps/bench/src/types.ts | 12 + apps/bench/tests/results.test.ts | 12 +- 7 files changed, 1134 insertions(+), 380 deletions(-) diff --git a/apps/bench/README.md b/apps/bench/README.md index 38fbfc3..9c18ba7 100644 --- a/apps/bench/README.md +++ b/apps/bench/README.md @@ -3,7 +3,8 @@ Measures what one `keyRove` call costs in a real browser, on pages of increasing size, with the listener on the group or on the document, for unbound and bound keys, with and without focus keys, with fixed or `auto` -columns, and for a move past a run of items that refuse focus. +columns in grid and flex layouts, and for a move past a run of items that +refuse focus. It runs two ways: in a page you open in any browser, or in headless Chrome from the command line. Both time the same code on the same page. @@ -82,7 +83,7 @@ source. Some cases time features older versions lack: the `focusKeys` option arrived in 2.4.0, `cols="auto"` in 2.5.0, and a move past an item that -refuses focus after 2.6.1. Before running, the harness tries each feature on +refuses focus and `cols="auto"` on a flex layout after 2.6.1. Before running, the harness tries each feature on the library, and a case whose feature is missing is recorded as not run rather than timed. It would still run, but it would time something other than its label. In a comparison those cases read n/a. @@ -105,7 +106,7 @@ to see reports saved since. | Path | What it is | | --------------------------- | ------------------------------------------------------------------------------------------ | | `src/keydown.ts` | The benchmark: probes the library's features, builds each fixture, and times the cases. | -| `src/plan.ts` | What a run covers: page sizes, focus-key counts, grids, refusal runs, default timing. | +| `src/plan.ts` | What a run covers: page sizes, focus-key counts, column layouts, refusal runs, timing. | | `harness.html` | The page the cases run in. It holds the fixture and almost nothing else. | | `index.html`, `src/main.ts` | The UI. Each run loads a fresh harness in a frame, so none of the UI's markup is measured. | | `src/view.ts` | The UI's results, one report or a comparison, as HTML. | @@ -137,8 +138,19 @@ per item: Focus-key fixtures spread 10, 100 or 1,000 `data-keyrove-focus-key` attributes evenly over the L page, each a distinct combo that no case -presses. Grid fixtures lay each M group out as a four-column CSS grid with -`data-keyrove-cols` set to `4` or `auto`. +presses. + +Column fixtures lay each M group out one way and set `data-keyrove-cols`: + +| Layout | Each group | `cols` | What `auto` reads | +| ----------- | -------------------------------- | ----------- | -------------------------------- | +| grid | a four-column CSS grid | `4`, `auto` | the grid's tracks | +| flex-wrap | a wrapping flex row, 4 to a line | `4`, `auto` | the first line's items: 4, and 1 | +| flex row | one flex row of all 20 | `auto` | the first line's items: all 20 | +| flex column | a flex column, 1 to a line | `1`, `auto` | the first line's items: 1, and 1 | + +The grids keep their earlier names, `M, cols="4"` and `M, cols="auto"`; the +flex fixtures are named by layout, such as `M, flex-wrap, cols="auto"`. Refused-focus fixtures are one group each: a visible item, a run of 1 to 1,000 items hidden with `display: none` or `visibility: hidden`, and another @@ -152,7 +164,14 @@ Every press starts in the first group: - **unbound:** `KeyQ`, which no binding matches. - **bound, no move:** `End` on the last item, a consumed no-op. - **bound, moves focus:** `ArrowDown`/`ArrowUp` between the first two items, - or `ArrowRight`/`ArrowLeft` in a grid, calling `focus()` every time. + or `ArrowRight`/`ArrowLeft` in a grid, a flex-wrap or a flex row, calling + `focus()` every time. +- **layout dirtied:** on a column fixture, the unbound key and the move again, + with the fixture's padding changed just before each press. A read of the + layout in the handler then lays the page out first, as it would after + another listener wrote to the DOM earlier in the same task. Each column + fixture also times that change and one layout read with no handler at all: + what the layout costs on that page. - **moves past the run:** `ArrowDown`/`ArrowUp` between the two visible items of a refused-focus page, under a local listener. Each press tries `focus()` on every hidden item on the way, unless the run is skipped. @@ -181,12 +200,12 @@ Every press starts in the first group: ## Results -Recorded on 2026-11-06 in headless Chrome by `pnpm bench:headless -- --record`. +Recorded on 2026-11-07 in headless Chrome by `pnpm bench:headless -- --record`. The same run is in `results/recorded.json`. ### Environment -- Library: 2.6.0 (working tree at d21fa4f, with uncommitted changes) +- Library: 2.6.0 (working tree at 3186092, with uncommitted changes) - Browser: Chrome/156.0.8078.80 (headless), V8 15.6.75.27 - OS: darwin 25.2.0 arm64 - CPU: Apple M1 Pro × 10 @@ -194,28 +213,33 @@ The same run is in `results/recorded.json`. - Node: v25.2.1 - Cross-origin isolated: true - Timing: 100 ms warmup, then 15 batches of ≥ 10 ms per case -- Date: 2026-11-06 +- Date: 2026-11-07 ### Fixtures | Fixture | Groups | Items | Focus keys | Grid | Elements | Build (ms) | | ----------------------------- | -----: | ----: | ---------: | ---- | -------: | ---------: | -| S | 10 | 200 | 0 | - | 618 | 1.43 | -| M | 100 | 2000 | 0 | - | 6108 | 11.09 | -| L | 1000 | 20000 | 0 | - | 61008 | 123.15 | -| L, 0 focus keys | 1000 | 20000 | 0 | - | 61008 | 127.74 | -| L, 10 focus keys | 1000 | 20000 | 10 | - | 61008 | 102.69 | -| L, 100 focus keys | 1000 | 20000 | 100 | - | 61008 | 126.12 | -| L, 1000 focus keys | 1000 | 20000 | 1000 | - | 61008 | 103.35 | -| M, cols="4" | 100 | 2000 | 0 | 4 | 6108 | 11.41 | -| M, cols="auto" | 100 | 2000 | 0 | auto | 6108 | 10.85 | -| 1 × display: none | 1 | 3 | 0 | - | 18 | 0.25 | -| 10 × display: none | 1 | 12 | 0 | - | 45 | 0.17 | -| 100 × display: none | 1 | 102 | 0 | - | 315 | 0.30 | -| 1000 × display: none | 1 | 1002 | 0 | - | 3015 | 1.62 | -| 1000 × visibility: hidden | 1 | 1002 | 0 | - | 3015 | 5.68 | -| 1000 × display: none, roving | 1 | 1002 | 0 | - | 3015 | 1.62 | -| 1000 × display: none, skipped | 1 | 1002 | 0 | - | 3015 | 1.46 | +| S | 10 | 200 | 0 | - | 618 | 1.63 | +| M | 100 | 2000 | 0 | - | 6108 | 11.45 | +| L | 1000 | 20000 | 0 | - | 61008 | 128.90 | +| L, 0 focus keys | 1000 | 20000 | 0 | - | 61008 | 136.04 | +| L, 10 focus keys | 1000 | 20000 | 10 | - | 61008 | 107.09 | +| L, 100 focus keys | 1000 | 20000 | 100 | - | 61008 | 103.56 | +| L, 1000 focus keys | 1000 | 20000 | 1000 | - | 61008 | 130.81 | +| M, cols="4" | 100 | 2000 | 0 | 4 | 6108 | 13.63 | +| M, cols="auto" | 100 | 2000 | 0 | auto | 6108 | 11.34 | +| M, flex-wrap, cols="4" | 100 | 2000 | 0 | 4 | 6108 | 12.80 | +| M, flex-wrap, cols="auto" | 100 | 2000 | 0 | auto | 6108 | 12.28 | +| M, flex row, cols="auto" | 100 | 2000 | 0 | auto | 6108 | 11.91 | +| M, flex column, cols="1" | 100 | 2000 | 0 | 1 | 6108 | 9.30 | +| M, flex column, cols="auto" | 100 | 2000 | 0 | auto | 6108 | 9.37 | +| 1 × display: none | 1 | 3 | 0 | - | 18 | 0.32 | +| 10 × display: none | 1 | 12 | 0 | - | 45 | 0.21 | +| 100 × display: none | 1 | 102 | 0 | - | 315 | 0.37 | +| 1000 × display: none | 1 | 1002 | 0 | - | 3015 | 1.69 | +| 1000 × visibility: hidden | 1 | 1002 | 0 | - | 3015 | 5.61 | +| 1000 × display: none, roving | 1 | 1002 | 0 | - | 3015 | 3.14 | +| 1000 × display: none, skipped | 1 | 1002 | 0 | - | 3015 | 1.50 | ### baseline @@ -227,126 +251,170 @@ The same run is in `results/recorded.json`. | Fixture | Case | Result | Median (µs) | p10–p90 (µs) | Batch | | ------- | ---------------------------- | -------------- | ----------: | -----------: | ----: | -| S | local, unbound | unhandled | 2.55 | 2.53–2.60 | 4096 | -| S | local, bound, no move | consumed no-op | 9.57 | 8.76–10.22 | 1024 | -| S | local, bound, moves focus | moved | 22.94 | 22.60–24.81 | 512 | -| S | document, unbound | unhandled | 2.52 | 2.50–2.70 | 4096 | -| S | document, bound, no move | consumed no-op | 8.98 | 8.70–10.26 | 1024 | -| S | document, bound, moves focus | moved | 23.37 | 22.86–25.44 | 512 | -| M | local, unbound | unhandled | 2.62 | 2.47–2.69 | 4096 | -| M | local, bound, no move | consumed no-op | 9.05 | 8.75–10.15 | 2048 | -| M | local, bound, moves focus | moved | 23.36 | 22.78–25.97 | 512 | -| M | document, unbound | unhandled | 2.49 | 2.47–2.51 | 4096 | -| M | document, bound, no move | consumed no-op | 9.37 | 8.89–10.31 | 2048 | -| M | document, bound, moves focus | moved | 23.10 | 22.71–26.89 | 512 | -| L | local, unbound | unhandled | 2.49 | 2.48–2.50 | 4096 | -| L | local, bound, no move | consumed no-op | 9.62 | 8.87–11.01 | 2048 | -| L | local, bound, moves focus | moved | 22.66 | 22.42–22.89 | 512 | -| L | document, unbound | unhandled | 2.46 | 2.45–2.48 | 4096 | -| L | document, bound, no move | consumed no-op | 9.00 | 8.74–10.11 | 2048 | -| L | document, bound, moves focus | moved | 22.76 | 22.48–23.03 | 512 | +| S | local, unbound | unhandled | 1.28 | 1.19–1.50 | 8192 | +| S | local, bound, no move | consumed no-op | 8.53 | 7.95–9.17 | 2048 | +| S | local, bound, moves focus | moved | 22.51 | 21.83–24.92 | 512 | +| S | document, unbound | unhandled | 1.21 | 1.17–1.44 | 8192 | +| S | document, bound, no move | consumed no-op | 8.63 | 8.10–9.20 | 2048 | +| S | document, bound, moves focus | moved | 22.24 | 21.56–25.40 | 512 | +| M | local, unbound | unhandled | 1.25 | 1.13–1.30 | 16384 | +| M | local, bound, no move | consumed no-op | 8.30 | 7.87–9.13 | 2048 | +| M | local, bound, moves focus | moved | 21.62 | 20.07–24.27 | 512 | +| M | document, unbound | unhandled | 1.15 | 1.14–1.45 | 8192 | +| M | document, bound, no move | consumed no-op | 8.24 | 7.79–9.87 | 2048 | +| M | document, bound, moves focus | moved | 21.80 | 21.29–23.47 | 512 | +| L | local, unbound | unhandled | 1.15 | 1.13–1.38 | 8192 | +| L | local, bound, no move | consumed no-op | 8.27 | 8.08–9.52 | 2048 | +| L | local, bound, moves focus | moved | 22.40 | 21.91–22.81 | 512 | +| L | document, unbound | unhandled | 1.14 | 1.12–1.37 | 8192 | +| L | document, bound, no move | consumed no-op | 8.47 | 7.84–9.52 | 2048 | +| L | document, bound, moves focus | moved | 21.76 | 21.45–23.32 | 512 | ### focus keys | Fixture | Case | Result | Median (µs) | p10–p90 (µs) | Batch | | ------------------ | ------------------------------- | --------- | ----------: | ------------: | ----: | -| L, 0 focus keys | 0 × attribute scan | unhandled | 2.49 | 2.48–2.50 | 4096 | -| L, 0 focus keys | 0 × focusKeys map, elements | unhandled | 2.25 | 2.24–2.26 | 8192 | -| L, 0 focus keys | 0 × focusKeys map, selectors | unhandled | 2.26 | 2.25–2.26 | 8192 | -| L, 10 focus keys | 10 × attribute scan | unhandled | 17.63 | 17.11–17.93 | 1024 | -| L, 10 focus keys | 10 × focusKeys map, elements | unhandled | 4.54 | 4.54–4.57 | 4096 | -| L, 10 focus keys | 10 × focusKeys map, selectors | unhandled | 5.13 | 5.10–5.18 | 2048 | -| L, 100 focus keys | 100 × attribute scan | unhandled | 69.36 | 69.04–69.75 | 256 | -| L, 100 focus keys | 100 × focusKeys map, elements | unhandled | 42.85 | 42.66–43.07 | 256 | -| L, 100 focus keys | 100 × focusKeys map, selectors | unhandled | 51.07 | 50.96–51.33 | 256 | -| L, 1000 focus keys | 1000 × attribute scan | unhandled | 608.75 | 596.25–624.38 | 16 | -| L, 1000 focus keys | 1000 × focusKeys map, elements | unhandled | 467.19 | 452.03–485.62 | 32 | -| L, 1000 focus keys | 1000 × focusKeys map, selectors | unhandled | 751.25 | 729.69–905.94 | 16 | -| L, 1000 focus keys | 1000 on page, local listener | unhandled | 3.98 | 3.87–4.31 | 4096 | +| L, 0 focus keys | 0 × attribute scan | unhandled | 1.15 | 1.10–1.35 | 8192 | +| L, 0 focus keys | 0 × focusKeys map, elements | unhandled | 0.95 | 0.92–1.00 | 16384 | +| L, 0 focus keys | 0 × focusKeys map, selectors | unhandled | 0.95 | 0.91–1.03 | 16384 | +| L, 10 focus keys | 10 × attribute scan | unhandled | 16.42 | 15.87–17.01 | 1024 | +| L, 10 focus keys | 10 × focusKeys map, elements | unhandled | 3.27 | 3.17–3.35 | 4096 | +| L, 10 focus keys | 10 × focusKeys map, selectors | unhandled | 3.86 | 3.77–3.97 | 4096 | +| L, 100 focus keys | 100 × attribute scan | unhandled | 69.06 | 68.26–70.62 | 256 | +| L, 100 focus keys | 100 × focusKeys map, elements | unhandled | 42.11 | 41.37–43.55 | 256 | +| L, 100 focus keys | 100 × focusKeys map, selectors | unhandled | 50.16 | 49.63–50.92 | 256 | +| L, 1000 focus keys | 1000 × attribute scan | unhandled | 657.81 | 605.94–737.50 | 16 | +| L, 1000 focus keys | 1000 × focusKeys map, elements | unhandled | 460.47 | 436.88–470.16 | 32 | +| L, 1000 focus keys | 1000 × focusKeys map, selectors | unhandled | 742.50 | 725.31–795.00 | 16 | +| L, 1000 focus keys | 1000 on page, local listener | unhandled | 2.53 | 2.46–3.63 | 2048 | ### diagnostics | Fixture | Case | Result | Median (µs) | p10–p90 (µs) | Batch | | ------------------ | ----------------------------------------- | ------ | ----------: | ------------: | ------: | -| L, 0 focus keys | 0 × querySelectorAll over the document | - | 0.09 | 0.08–0.13 | 131072 | +| L, 0 focus keys | 0 × querySelectorAll over the document | - | 0.08 | 0.08–0.11 | 131072 | | L, 0 focus keys | 0 × matchesCombo, none matching | - | 0.01 | 0.01–0.01 | 2097152 | -| L, 10 focus keys | 10 × querySelectorAll over the document | - | 8.99 | 8.88–9.07 | 2048 | -| L, 10 focus keys | 10 × matchesCombo, none matching | - | 1.03 | 1.03–1.06 | 16384 | -| L, 100 focus keys | 100 × querySelectorAll over the document | - | 22.42 | 22.32–25.62 | 512 | -| L, 100 focus keys | 100 × matchesCombo, none matching | - | 25.10 | 23.95–25.78 | 512 | -| L, 1000 focus keys | 1000 × querySelectorAll over the document | - | 113.67 | 109.80–117.54 | 128 | -| L, 1000 focus keys | 1000 × matchesCombo, none matching | - | 229.45 | 224.77–232.11 | 64 | +| L, 10 focus keys | 10 × querySelectorAll over the document | - | 9.15 | 8.96–9.34 | 2048 | +| L, 10 focus keys | 10 × matchesCombo, none matching | - | 1.05 | 1.04–1.06 | 16384 | +| L, 100 focus keys | 100 × querySelectorAll over the document | - | 24.03 | 23.00–24.90 | 512 | +| L, 100 focus keys | 100 × matchesCombo, none matching | - | 23.71 | 22.99–24.01 | 512 | +| L, 1000 focus keys | 1000 × querySelectorAll over the document | - | 110.16 | 107.58–114.96 | 128 | +| L, 1000 focus keys | 1000 × matchesCombo, none matching | - | 229.30 | 226.72–236.56 | 64 | ### columns -| Fixture | Case | Result | Median (µs) | p10–p90 (µs) | Batch | -| -------------- | ------------------------------- | --------- | ----------: | -----------: | ----: | -| M, cols="4" | cols="4", unbound | unhandled | 4.21 | 4.15–4.37 | 4096 | -| M, cols="4" | cols="4", bound, moves focus | moved | 27.55 | 26.90–28.01 | 512 | -| M, cols="auto" | cols="auto", unbound | unhandled | 5.36 | 5.32–5.63 | 2048 | -| M, cols="auto" | cols="auto", bound, moves focus | moved | 30.02 | 29.13–35.41 | 512 | +| Fixture | Case | Result | Median (µs) | p10–p90 (µs) | Batch | +| --------------------------- | ----------------------------------------------- | --------- | ----------: | --------------: | ----: | +| M, cols="4" | cols="4", unbound | unhandled | 1.19 | 1.17–1.43 | 8192 | +| M, cols="4" | cols="4", unbound, layout dirtied | unhandled | 1.37 | 1.35–1.61 | 8192 | +| M, cols="4" | cols="4", bound, moves focus | moved | 26.50 | 25.49–27.51 | 512 | +| M, cols="4" | cols="4", bound, moves focus, layout dirtied | moved | 1084.69 | 1040.31–1342.81 | 16 | +| M, cols="4" | layout dirtied, then read, no handler | - | 973.12 | 947.19–1236.56 | 16 | +| M, cols="auto" | cols="auto", unbound | unhandled | 1.26 | 1.15–1.32 | 16384 | +| M, cols="auto" | cols="auto", unbound, layout dirtied | unhandled | 1.33 | 1.31–1.54 | 8192 | +| M, cols="auto" | cols="auto", bound, moves focus | moved | 28.54 | 28.13–29.22 | 512 | +| M, cols="auto" | cols="auto", bound, moves focus, layout dirtied | moved | 1033.12 | 1003.13–1302.19 | 16 | +| M, cols="auto" | layout dirtied, then read, no handler | - | 994.69 | 939.06–1248.44 | 16 | +| M, flex-wrap, cols="4" | cols="4", unbound | unhandled | 1.17 | 1.14–1.39 | 8192 | +| M, flex-wrap, cols="4" | cols="4", unbound, layout dirtied | unhandled | 1.36 | 1.33–1.57 | 8192 | +| M, flex-wrap, cols="4" | cols="4", bound, moves focus | moved | 26.41 | 25.52–27.51 | 512 | +| M, flex-wrap, cols="4" | cols="4", bound, moves focus, layout dirtied | moved | 50.37 | 49.75–52.09 | 256 | +| M, flex-wrap, cols="4" | layout dirtied, then read, no handler | - | 20.05 | 19.62–20.47 | 512 | +| M, flex-wrap, cols="auto" | cols="auto", unbound | unhandled | 1.18 | 1.15–1.40 | 8192 | +| M, flex-wrap, cols="auto" | cols="auto", unbound, layout dirtied | unhandled | 1.35 | 1.32–1.60 | 8192 | +| M, flex-wrap, cols="auto" | cols="auto", bound, moves focus | moved | 30.18 | 29.58–35.42 | 512 | +| M, flex-wrap, cols="auto" | cols="auto", bound, moves focus, layout dirtied | moved | 55.49 | 54.96–56.27 | 256 | +| M, flex-wrap, cols="auto" | layout dirtied, then read, no handler | - | 19.59 | 19.56–19.80 | 512 | +| M, flex row, cols="auto" | cols="auto", unbound | unhandled | 1.16 | 1.13–1.38 | 8192 | +| M, flex row, cols="auto" | cols="auto", unbound, layout dirtied | unhandled | 1.33 | 1.30–1.56 | 8192 | +| M, flex row, cols="auto" | cols="auto", bound, moves focus | moved | 36.44 | 35.97–42.33 | 512 | +| M, flex row, cols="auto" | cols="auto", bound, moves focus, layout dirtied | moved | 823.75 | 796.25–954.37 | 16 | +| M, flex row, cols="auto" | layout dirtied, then read, no handler | - | 755.94 | 736.56–899.37 | 16 | +| M, flex column, cols="1" | cols="1", unbound | unhandled | 1.15 | 1.14–1.38 | 8192 | +| M, flex column, cols="1" | cols="1", unbound, layout dirtied | unhandled | 1.32 | 1.30–1.55 | 8192 | +| M, flex column, cols="1" | cols="1", bound, moves focus | moved | 24.41 | 24.01–25.75 | 512 | +| M, flex column, cols="1" | cols="1", bound, moves focus, layout dirtied | moved | 3570.00 | 3202.50–4273.75 | 4 | +| M, flex column, cols="1" | layout dirtied, then read, no handler | - | 3490.00 | 3087.50–4225.00 | 4 | +| M, flex column, cols="auto" | cols="auto", unbound | unhandled | 1.17 | 1.14–1.40 | 8192 | +| M, flex column, cols="auto" | cols="auto", unbound, layout dirtied | unhandled | 1.31 | 1.31–1.54 | 8192 | +| M, flex column, cols="auto" | cols="auto", bound, moves focus | moved | 27.28 | 26.92–28.05 | 512 | +| M, flex column, cols="auto" | cols="auto", bound, moves focus, layout dirtied | moved | 3450.00 | 3200.00–4547.50 | 4 | +| M, flex column, cols="auto" | layout dirtied, then read, no handler | - | 3343.75 | 3108.75–4696.25 | 4 | ### refused focus | Fixture | Case | Result | Median (µs) | p10–p90 (µs) | Batch | | ----------------------------- | ------------------------- | ------ | ----------: | --------------: | ----: | -| 1 × display: none | local, moves past the run | moved | 23.04 | 22.02–26.06 | 512 | -| 10 × display: none | local, moves past the run | moved | 29.35 | 27.81–31.85 | 512 | -| 100 × display: none | local, moves past the run | moved | 85.94 | 83.87–98.01 | 128 | -| 1000 × display: none | local, moves past the run | moved | 659.06 | 610.62–694.06 | 32 | -| 1000 × visibility: hidden | local, moves past the run | moved | 618.12 | 600.94–718.13 | 16 | -| 1000 × display: none, roving | local, moves past the run | moved | 1439.37 | 1360.00–1623.12 | 8 | -| 1000 × display: none, skipped | local, moves past the run | moved | 335.31 | 327.34–379.06 | 32 | +| 1 × display: none | local, moves past the run | moved | 21.85 | 21.35–23.19 | 512 | +| 10 × display: none | local, moves past the run | moved | 27.47 | 27.23–30.32 | 512 | +| 100 × display: none | local, moves past the run | moved | 85.59 | 82.58–98.48 | 128 | +| 1000 × display: none | local, moves past the run | moved | 629.84 | 612.81–688.12 | 32 | +| 1000 × visibility: hidden | local, moves past the run | moved | 620.16 | 608.44–697.97 | 32 | +| 1000 × display: none, roving | local, moves past the run | moved | 1408.12 | 1350.63–1643.75 | 8 | +| 1000 × display: none, skipped | local, moves past the run | moved | 331.72 | 323.75–368.13 | 32 | ## Findings 1. **Page size and listener placement do not change the cost when there are no focus keys.** From 200 to 20,000 items, and with the listener on the - group or on the document, an unbound key costs about 2.5 µs. A bound key - with nowhere to go costs about 9 µs, and a move, including the browser's - `focus()`, costs about 23 µs. A move reads only the target's group. Chrome + group or on the document, an unbound key costs about 1.2 µs. A bound key + with nowhere to go costs about 8.5 µs, and a move, including the browser's + `focus()`, costs about 22 µs. A move reads only the target's group. Chrome answers the document query for focus keys in about 0.1 µs when nothing matches, even on 61,000 elements. 2. **Focus keys in the listener's reach are the one cost that grows with the page.** It grows linearly with their number and is paid on every keydown the listener handles, unbound keys included. Under a document listener, - attribute scanning costs 18 µs for 10 focus keys, 69 µs for 100 and - 0.61 ms for 1,000. A local listener stays at 4 µs with 1,000 focus keys - elsewhere on the page. A `focusKeys` map of elements is cheaper (0.47 ms - at 1,000). A map of selectors is the most expensive (0.75 ms), because + attribute scanning costs 16 µs for 10 focus keys, 69 µs for 100 and + 0.66 ms for 1,000. A local listener stays at 2.5 µs with 1,000 focus keys + elsewhere on the page. A `focusKeys` map of elements is cheaper (0.46 ms + at 1,000). A map of selectors is the most expensive (0.74 ms), because each selector is queried on every press. 3. **At 1,000 focus keys, matching the press is the largest share.** Testing the press against every combo with `matchesCombo` takes 229 µs, over a - third of the 0.61 ms total, and the document query takes 114 µs. The rest - is reading and filtering each target and building the binding table. -4. **Columns and direction cost a few microseconds.** `cols="auto"` adds - 1.2–2.5 µs over a fixed count. A grid's unbound key costs about 1.6 µs - more than a list's, because its default next/prev keys read the computed - direction when no `dir` attribute is set. -5. **A run of items that refuse focus costs one pass over it, and `skip` is + third of the 0.66 ms total, and the document query takes 110 µs. The rest + is reading and filtering each target. +4. **Columns cost nothing on a key no move is bound to, and a few + microseconds on a move.** An unbound key reads no layout, so it costs + about 1.2 µs whatever the columns, `auto` included. A move with + `cols="auto"` costs 2 µs more than with a fixed count in a grid, 3–4 µs + more in a flex-wrap row of four or a flex column, and 10 µs more in a flex + row of 20, where it reads the box of every item on the line. +5. **A layout that has changed since the last read is laid out by moves + alone, whatever the column count.** With the layout dirtied before each + press, an unbound key still costs about 1.3 µs on every column page. A + move costs one layout of the page, from 20 µs (flex-wrap rows of a fixed + width) to 3.5 ms (a flex column), with a fixed count as with `auto`: the + browser's `focus()` lays the page out either way. +6. **A run of items that refuse focus costs one pass over it, and `skip` is still the fast path.** A move past 1,000 hidden items that are not skipped - tries `focus()` on each and costs 0.66 ms with `display: none` and + tries `focus()` on each and costs 0.63 ms with `display: none` and 0.62 ms with `visibility: hidden`. It costs 1.4 ms when each item carries a roving stop, which is carried onto it and put back. From 100 to 1,000 - items the cost grows 7.7 times. The same 1,000 items marked skipped cost - 0.34 ms, since no move tries to focus them. + items the cost grows 7.4 times. The same 1,000 items marked skipped cost + 0.33 ms, since no move tries to focus them. ## Conclusion No change is warranted. The costliest realistic case measured, 100 focus keys under a document listener, stays under 0.1 ms per keydown. Only the -synthetic 1,000 focus keys reach 0.5–0.8 ms, and a move past 1,000 hidden +synthetic 1,000 focus keys reach 0.5–0.7 ms, and a move past 1,000 hidden items that are not skipped 0.6–1.4 ms. That is under a 16 ms frame, and would stay under 10 ms on a CPU ten times slower. Caching items or bindings would give up reading the current DOM on every press, and nothing measured here pays for it: page size and listener placement make no difference. +`cols="auto"` reads the layout only for a press that could move. A move lays +the page out in `focus()` anyway, so `auto` adds no layout of its own to it. +The one press where `auto` lays out a changed page that a fixed count would +not is a bound key with nowhere to go, such as End on the last item. + If pages with hundreds of focus keys under one listener become a real use case, the measured follow-up is to memoize each combo string's parsed form inside `matchesCombo`. Parsing is a pure function of the string, so the cache needs no DOM invalidation. It targets the largest share measured, 229 -of 609 µs at 1,000 focus keys. Run this benchmark before and after such a +of 658 µs at 1,000 focus keys. Run this benchmark before and after such a change. ## Limitations diff --git a/apps/bench/results/recorded.json b/apps/bench/results/recorded.json index da8f3bd..decbb79 100644 --- a/apps/bench/results/recorded.json +++ b/apps/bench/results/recorded.json @@ -1,6 +1,6 @@ { "environment": { - "Library": "2.6.0 (working tree at d21fa4f, with uncommitted changes)", + "Library": "2.6.0 (working tree at 3186092, with uncommitted changes)", "Browser": "Chrome/156.0.8078.80 (headless), V8 15.6.75.27", "OS": "darwin 25.2.0 arm64", "CPU": "Apple M1 Pro × 10", @@ -8,11 +8,11 @@ "Node": "v25.2.1", "Cross-origin isolated": true, "Timing": "100 ms warmup, then 15 batches of ≥ 10 ms per case", - "Date": "2026-11-06" + "Date": "2026-11-07" }, "library": { "version": "2.6.0", - "commit": "d21fa4f", + "commit": "3186092", "dirty": true }, "results": { @@ -27,116 +27,152 @@ "supports": { "focusKeysMap": true, "autoColumns": true, - "refusedFocus": true + "refusedFocus": true, + "wrapColumns": true }, "fixtures": [ { "name": "S", "groups": 10, - "buildMs": 1.425000011920929, + "buildMs": 1.6349999904632568, "elements": 618 }, { "name": "M", "groups": 100, - "buildMs": 11.089999973773956, + "buildMs": 11.450000047683716, "elements": 6108 }, { "name": "L", "groups": 1000, - "buildMs": 123.14999997615814, + "buildMs": 128.9000000357628, "elements": 61008 }, { "name": "L, 0 focus keys", "groups": 1000, "focusKeys": 0, - "buildMs": 127.73500001430511, + "buildMs": 136.04499995708466, "elements": 61008 }, { "name": "L, 10 focus keys", "groups": 1000, "focusKeys": 10, - "buildMs": 102.68500000238419, + "buildMs": 107.08500003814697, "elements": 61008 }, { "name": "L, 100 focus keys", "groups": 1000, "focusKeys": 100, - "buildMs": 126.12000000476837, + "buildMs": 103.56499999761581, "elements": 61008 }, { "name": "L, 1000 focus keys", "groups": 1000, "focusKeys": 1000, - "buildMs": 103.35000002384186, + "buildMs": 130.8149999976158, "elements": 61008 }, { "name": "M, cols=\"4\"", "groups": 100, "grid": "4", - "buildMs": 11.409999966621399, + "buildMs": 13.634999990463257, "elements": 6108 }, { "name": "M, cols=\"auto\"", "groups": 100, "grid": "auto", - "buildMs": 10.854999959468842, + "buildMs": 11.335000038146973, + "elements": 6108 + }, + { + "name": "M, flex-wrap, cols=\"4\"", + "groups": 100, + "grid": "4", + "buildMs": 12.800000011920929, + "elements": 6108 + }, + { + "name": "M, flex-wrap, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 12.27999997138977, + "elements": 6108 + }, + { + "name": "M, flex row, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 11.909999966621399, + "elements": 6108 + }, + { + "name": "M, flex column, cols=\"1\"", + "groups": 100, + "grid": "1", + "buildMs": 9.300000011920929, + "elements": 6108 + }, + { + "name": "M, flex column, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 9.365000009536743, "elements": 6108 }, { "name": "1 × display: none", "groups": 1, "items": 3, - "buildMs": 0.25, + "buildMs": 0.3199999928474426, "elements": 18 }, { "name": "10 × display: none", "groups": 1, "items": 12, - "buildMs": 0.17000001668930054, + "buildMs": 0.20999997854232788, "elements": 45 }, { "name": "100 × display: none", "groups": 1, "items": 102, - "buildMs": 0.3049999475479126, + "buildMs": 0.36500000953674316, "elements": 315 }, { "name": "1000 × display: none", "groups": 1, "items": 1002, - "buildMs": 1.6150000095367432, + "buildMs": 1.6949999928474426, "elements": 3015 }, { "name": "1000 × visibility: hidden", "groups": 1, "items": 1002, - "buildMs": 5.675000011920929, + "buildMs": 5.610000014305115, "elements": 3015 }, { "name": "1000 × display: none, roving", "groups": 1, "items": 1002, - "buildMs": 1.6200000047683716, + "buildMs": 3.1399999856948853, "elements": 3015 }, { "name": "1000 × display: none, skipped", "groups": 1, "items": 1002, - "buildMs": 1.4550000429153442, + "buildMs": 1.5049999952316284, "elements": 3015 } ], @@ -147,14 +183,14 @@ "fixture": { "name": "S", "groups": 10, - "buildMs": 1.425000011920929, + "buildMs": 1.6349999904632568, "elements": 618 }, "result": "-", "batch": 16777216, - "median": 0.00045627355760302635, - "p10": 0.0004500150652120283, - "p90": 0.0005593895906486068 + "median": 0.0004595518099392848, + "p10": 0.0004532933246537141, + "p90": 0.0005415081965054469 }, { "section": "listener", @@ -162,14 +198,14 @@ "fixture": { "name": "S", "groups": 10, - "buildMs": 1.425000011920929, + "buildMs": 1.6349999904632568, "elements": 618 }, "result": "unhandled", - "batch": 4096, - "median": 2.54638672049623, - "p10": 2.5256347726099193, - "p90": 2.6013183523900807 + "batch": 8192, + "median": 1.2829589832108468, + "p10": 1.1895751958945766, + "p90": 1.4984130903030746 }, { "section": "listener", @@ -177,14 +213,14 @@ "fixture": { "name": "S", "groups": 10, - "buildMs": 1.425000011920929, + "buildMs": 1.6349999904632568, "elements": 618 }, "result": "consumed no-op", - "batch": 1024, - "median": 9.565429645590484, - "p10": 8.764648460783064, - "p90": 10.224609344732016 + "batch": 2048, + "median": 8.530273422366008, + "p10": 7.949218765133992, + "p90": 9.167480486212298 }, { "section": "listener", @@ -192,14 +228,14 @@ "fixture": { "name": "S", "groups": 10, - "buildMs": 1.425000011920929, + "buildMs": 1.6349999904632568, "elements": 618 }, "result": "moved", "batch": 512, - "median": 22.939453134313226, - "p10": 22.59765623603016, - "p90": 24.81445309240371 + "median": 22.50976557843387, + "p10": 21.826171781867743, + "p90": 24.92187498137355 }, { "section": "listener", @@ -207,14 +243,14 @@ "fixture": { "name": "S", "groups": 10, - "buildMs": 1.425000011920929, + "buildMs": 1.6349999904632568, "elements": 618 }, "result": "unhandled", - "batch": 4096, - "median": 2.5183105462929234, - "p10": 2.495117179933004, - "p90": 2.6965332071995363 + "batch": 8192, + "median": 1.209106449095998, + "p10": 1.1694335989886895, + "p90": 1.4447021530941129 }, { "section": "listener", @@ -222,14 +258,14 @@ "fixture": { "name": "S", "groups": 10, - "buildMs": 1.425000011920929, + "buildMs": 1.6349999904632568, "elements": 618 }, "result": "consumed no-op", - "batch": 1024, - "median": 8.984374988358468, - "p10": 8.696289034560323, - "p90": 10.258789057843387 + "batch": 2048, + "median": 8.625488262623549, + "p10": 8.098144520772621, + "p90": 9.199218737194315 }, { "section": "listener", @@ -237,14 +273,14 @@ "fixture": { "name": "S", "groups": 10, - "buildMs": 1.425000011920929, + "buildMs": 1.6349999904632568, "elements": 618 }, "result": "moved", "batch": 512, - "median": 23.36914069019258, - "p10": 22.86132809240371, - "p90": 25.43945307843387 + "median": 22.23632810637355, + "p10": 21.562499925494194, + "p90": 25.400390615686774 }, { "section": "listener", @@ -252,14 +288,14 @@ "fixture": { "name": "M", "groups": 100, - "buildMs": 11.089999973773956, + "buildMs": 11.450000047683716, "elements": 6108 }, "result": "unhandled", - "batch": 4096, - "median": 2.6196288963546976, - "p10": 2.474365246598609, - "p90": 2.6928710867650807 + "batch": 16384, + "median": 1.2527465805760585, + "p10": 1.130065917095635, + "p90": 1.2951660173712298 }, { "section": "listener", @@ -267,14 +303,14 @@ "fixture": { "name": "M", "groups": 100, - "buildMs": 11.089999973773956, + "buildMs": 11.450000047683716, "elements": 6108 }, "result": "consumed no-op", "batch": 2048, - "median": 9.045410173712298, - "p10": 8.745117171201855, - "p90": 10.148925764951855 + "median": 8.303222653921694, + "p10": 7.866210944484919, + "p90": 9.130859369179234 }, { "section": "listener", @@ -282,14 +318,14 @@ "fixture": { "name": "M", "groups": 100, - "buildMs": 11.089999973773956, + "buildMs": 11.450000047683716, "elements": 6108 }, "result": "moved", "batch": 512, - "median": 23.359375074505806, - "p10": 22.783203166909516, - "p90": 25.96679690759629 + "median": 21.62109373603016, + "p10": 20.06835932843387, + "p90": 24.267578148283064 }, { "section": "listener", @@ -297,14 +333,14 @@ "fixture": { "name": "M", "groups": 100, - "buildMs": 11.089999973773956, + "buildMs": 11.450000047683716, "elements": 6108 }, "result": "unhandled", - "batch": 4096, - "median": 2.4877929681679234, - "p10": 2.4682617076905444, - "p90": 2.508544916054234 + "batch": 8192, + "median": 1.1492919875308871, + "p10": 1.1431884777266532, + "p90": 1.453247074095998 }, { "section": "listener", @@ -312,14 +348,14 @@ "fixture": { "name": "M", "groups": 100, - "buildMs": 11.089999973773956, + "buildMs": 11.450000047683716, "elements": 6108 }, "result": "consumed no-op", "batch": 2048, - "median": 9.367675782414153, - "p10": 8.891601552022621, - "p90": 10.31494140625 + "median": 8.244628901593387, + "p10": 7.7929687686264515, + "p90": 9.865722648100927 }, { "section": "listener", @@ -327,14 +363,14 @@ "fixture": { "name": "M", "groups": 100, - "buildMs": 11.089999973773956, + "buildMs": 11.450000047683716, "elements": 6108 }, "result": "moved", "batch": 512, - "median": 23.095703101716936, - "p10": 22.714843740686774, - "p90": 26.894531212747097 + "median": 21.79687505122274, + "p10": 21.28906245343387, + "p90": 23.466796847060323 }, { "section": "listener", @@ -342,14 +378,14 @@ "fixture": { "name": "L", "groups": 1000, - "buildMs": 123.14999997615814, + "buildMs": 128.9000000357628, "elements": 61008 }, "result": "unhandled", - "batch": 4096, - "median": 2.4926757760113105, - "p10": 2.476806635968387, - "p90": 2.503662108210847 + "batch": 8192, + "median": 1.1541748026502319, + "p10": 1.1315918018226512, + "p90": 1.379394525429234 }, { "section": "listener", @@ -357,14 +393,14 @@ "fixture": { "name": "L", "groups": 1000, - "buildMs": 123.14999997615814, + "buildMs": 128.9000000357628, "elements": 61008 }, "result": "consumed no-op", "batch": 2048, - "median": 9.621582023100927, - "p10": 8.867187512805685, - "p90": 11.005859385477379 + "median": 8.271484373835847, + "p10": 8.081054693320766, + "p90": 9.523925778921694 }, { "section": "listener", @@ -372,14 +408,14 @@ "fixture": { "name": "L", "groups": 1000, - "buildMs": 123.14999997615814, + "buildMs": 128.9000000357628, "elements": 61008 }, "result": "moved", "batch": 512, - "median": 22.65625004656613, - "p10": 22.421875037252903, - "p90": 22.890624939464033 + "median": 22.402343805879354, + "p10": 21.914062439464033, + "p90": 22.81250001396984 }, { "section": "listener", @@ -387,14 +423,14 @@ "fixture": { "name": "L", "groups": 1000, - "buildMs": 123.14999997615814, + "buildMs": 128.9000000357628, "elements": 61008 }, "result": "unhandled", - "batch": 4096, - "median": 2.4609374959254637, - "p10": 2.4487304617650807, - "p90": 2.4792480398900807 + "batch": 8192, + "median": 1.1395263645681553, + "p10": 1.1151123107993044, + "p90": 1.3745117175858468 }, { "section": "listener", @@ -402,14 +438,14 @@ "fixture": { "name": "L", "groups": 1000, - "buildMs": 123.14999997615814, + "buildMs": 128.9000000357628, "elements": 61008 }, "result": "consumed no-op", "batch": 2048, - "median": 8.996582037070766, - "p10": 8.740234392462298, - "p90": 10.114746080944315 + "median": 8.474121103063226, + "p10": 7.841796876164153, + "p90": 9.519042971078306 }, { "section": "listener", @@ -417,14 +453,14 @@ "fixture": { "name": "L", "groups": 1000, - "buildMs": 123.14999997615814, + "buildMs": 128.9000000357628, "elements": 61008 }, "result": "moved", "batch": 512, - "median": 22.763671935535967, - "p10": 22.48046873137355, - "p90": 23.027343675494194 + "median": 21.757812472060323, + "p10": 21.44531242083758, + "p90": 23.320312495343387 }, { "section": "focus keys", @@ -433,14 +469,14 @@ "name": "L, 0 focus keys", "groups": 1000, "focusKeys": 0, - "buildMs": 127.73500001430511, + "buildMs": 136.04499995708466, "elements": 61008 }, "result": "unhandled", - "batch": 4096, - "median": 2.48535156424623, - "p10": 2.4816894438117743, - "p90": 2.4987793003674597 + "batch": 8192, + "median": 1.1462402399047278, + "p10": 1.0998535144608468, + "p90": 1.3452148414216936 }, { "section": "focus keys", @@ -449,14 +485,14 @@ "name": "L, 0 focus keys", "groups": 1000, "focusKeys": 0, - "buildMs": 127.73500001430511, + "buildMs": 136.04499995708466, "elements": 61008 }, "result": "unhandled", - "batch": 8192, - "median": 2.2503662112285383, - "p10": 2.2430419921875, - "p90": 2.259521483210847 + "batch": 16384, + "median": 0.9490966804150958, + "p10": 0.9197998042509425, + "p90": 1.0040283195849042 }, { "section": "focus keys", @@ -465,14 +501,14 @@ "name": "L, 0 focus keys", "groups": 1000, "focusKeys": 0, - "buildMs": 127.73500001430511, + "buildMs": 136.04499995708466, "elements": 61008 }, "result": "unhandled", - "batch": 8192, - "median": 2.255249026347883, - "p10": 2.2473144563264214, - "p90": 2.260742192447651 + "batch": 16384, + "median": 0.950622561504133, + "p10": 0.9094238303077873, + "p90": 1.027221678668866 }, { "section": "diagnostics", @@ -481,14 +517,14 @@ "name": "L, 0 focus keys", "groups": 1000, "focusKeys": 0, - "buildMs": 127.73500001430511, + "buildMs": 136.04499995708466, "elements": 61008 }, "result": "-", "batch": 131072, - "median": 0.08541107172277407, - "p10": 0.0762939453125, - "p90": 0.13320922835191595 + "median": 0.0831222532724496, + "p10": 0.08110046428555506, + "p90": 0.11058807376684854 }, { "section": "diagnostics", @@ -497,14 +533,14 @@ "name": "L, 0 focus keys", "groups": 1000, "focusKeys": 0, - "buildMs": 127.73500001430511, + "buildMs": 136.04499995708466, "elements": 61008 }, "result": "-", "batch": 2097152, - "median": 0.005037784575279147, - "p10": 0.005023479474175474, - "p90": 0.00511407850467549 + "median": 0.005125999450683594, + "p10": 0.005056858071839088, + "p90": 0.00521421432608804 }, { "section": "focus keys", @@ -513,14 +549,14 @@ "name": "L, 10 focus keys", "groups": 1000, "focusKeys": 10, - "buildMs": 102.68500000238419, + "buildMs": 107.08500003814697, "elements": 61008 }, "result": "unhandled", "batch": 1024, - "median": 17.63183594448492, - "p10": 17.10937503958121, - "p90": 17.934570321813226 + "median": 16.416015627328306, + "p10": 15.874023432843387, + "p90": 17.006835900247097 }, { "section": "focus keys", @@ -529,14 +565,14 @@ "name": "L, 10 focus keys", "groups": 1000, "focusKeys": 10, - "buildMs": 102.68500000238419, + "buildMs": 107.08500003814697, "elements": 61008 }, "result": "unhandled", "batch": 4096, - "median": 4.542236332781613, - "p10": 4.536132808425464, - "p90": 4.566650386550464 + "median": 3.2739257731009275, + "p10": 3.167724615195766, + "p90": 3.3520507713546976 }, { "section": "focus keys", @@ -545,14 +581,14 @@ "name": "L, 10 focus keys", "groups": 1000, "focusKeys": 10, - "buildMs": 102.68500000238419, + "buildMs": 107.08500003814697, "elements": 61008 }, "result": "unhandled", - "batch": 2048, - "median": 5.131835961947218, - "p10": 5.095214844914153, - "p90": 5.178222636459395 + "batch": 4096, + "median": 3.8598632818320766, + "p10": 3.7683105474570766, + "p90": 3.9709472621325403 }, { "section": "diagnostics", @@ -561,14 +597,14 @@ "name": "L, 10 focus keys", "groups": 1000, "focusKeys": 10, - "buildMs": 102.68500000238419, + "buildMs": 107.08500003814697, "elements": 61008 }, "result": "-", "batch": 2048, - "median": 8.989257796201855, - "p10": 8.881835936335847, - "p90": 9.072265616850927 + "median": 9.14794922573492, + "p10": 8.959960920037702, + "p90": 9.343261714093387 }, { "section": "diagnostics", @@ -577,14 +613,14 @@ "name": "L, 10 focus keys", "groups": 1000, "focusKeys": 10, - "buildMs": 102.68500000238419, + "buildMs": 107.08500003814697, "elements": 61008 }, "result": "-", "batch": 16384, - "median": 1.0278320296492893, - "p10": 1.0263061521982308, - "p90": 1.0583496077742893 + "median": 1.0464477527420968, + "p10": 1.0360717787989415, + "p90": 1.0559082038525958 }, { "section": "focus keys", @@ -593,14 +629,14 @@ "name": "L, 100 focus keys", "groups": 1000, "focusKeys": 100, - "buildMs": 126.12000000476837, + "buildMs": 103.56499999761581, "elements": 61008 }, "result": "unhandled", "batch": 256, - "median": 69.35546873137355, - "p10": 69.04296856373549, - "p90": 69.7460938245058 + "median": 69.06250002793968, + "p10": 68.26171884313226, + "p90": 70.62499993480742 }, { "section": "focus keys", @@ -609,14 +645,14 @@ "name": "L, 100 focus keys", "groups": 1000, "focusKeys": 100, - "buildMs": 126.12000000476837, + "buildMs": 103.56499999761581, "elements": 61008 }, "result": "unhandled", "batch": 256, - "median": 42.851562378928065, - "p10": 42.65625006519258, - "p90": 43.066406389698386 + "median": 42.109375121071935, + "p10": 41.36718739755452, + "p90": 43.55468740686774 }, { "section": "focus keys", @@ -625,14 +661,14 @@ "name": "L, 100 focus keys", "groups": 1000, "focusKeys": 100, - "buildMs": 126.12000000476837, + "buildMs": 103.56499999761581, "elements": 61008 }, "result": "unhandled", "batch": 256, - "median": 51.07421870343387, - "p10": 50.95703131519258, - "p90": 51.328124944120646 + "median": 50.15624989755452, + "p10": 49.62890618480742, + "p90": 50.91796861961484 }, { "section": "diagnostics", @@ -641,14 +677,14 @@ "name": "L, 100 focus keys", "groups": 1000, "focusKeys": 100, - "buildMs": 126.12000000476837, + "buildMs": 103.56499999761581, "elements": 61008 }, "result": "-", "batch": 512, - "median": 22.421875037252903, - "p10": 22.32421876396984, - "p90": 25.61523439362645 + "median": 24.03320313896984, + "p10": 22.998046944849193, + "p90": 24.90234375 }, { "section": "diagnostics", @@ -657,14 +693,14 @@ "name": "L, 100 focus keys", "groups": 1000, "focusKeys": 100, - "buildMs": 126.12000000476837, + "buildMs": 103.56499999761581, "elements": 61008 }, "result": "-", "batch": 512, - "median": 25.09765629656613, - "p10": 23.94531248137355, - "p90": 25.781249976716936 + "median": 23.710937472060323, + "p10": 22.988281212747097, + "p90": 24.01367190759629 }, { "section": "focus keys", @@ -673,14 +709,14 @@ "name": "L, 1000 focus keys", "groups": 1000, "focusKeys": 1000, - "buildMs": 103.35000002384186, + "buildMs": 130.8149999976158, "elements": 61008 }, "result": "unhandled", "batch": 16, - "median": 608.7500005960464, - "p10": 596.2499976158142, - "p90": 624.3750005960464 + "median": 657.8125022351742, + "p10": 605.9374995529652, + "p90": 737.5000007450581 }, { "section": "focus keys", @@ -689,14 +725,14 @@ "name": "L, 1000 focus keys", "groups": 1000, "focusKeys": 1000, - "buildMs": 103.35000002384186, + "buildMs": 130.8149999976158, "elements": 61008 }, "result": "unhandled", "batch": 32, - "median": 467.18749962747097, - "p10": 452.03124918043613, - "p90": 485.6249988079071 + "median": 460.46875044703484, + "p10": 436.87500059604645, + "p90": 470.15625052154064 }, { "section": "focus keys", @@ -705,14 +741,14 @@ "name": "L, 1000 focus keys", "groups": 1000, "focusKeys": 1000, - "buildMs": 103.35000002384186, + "buildMs": 130.8149999976158, "elements": 61008 }, "result": "unhandled", "batch": 16, - "median": 751.2499988079071, - "p10": 729.6875007450581, - "p90": 905.9375002980232 + "median": 742.4999997019768, + "p10": 725.3125011920929, + "p90": 795.0000017881393 }, { "section": "focus keys", @@ -721,14 +757,14 @@ "name": "L, 1000 focus keys", "groups": 1000, "focusKeys": 1000, - "buildMs": 103.35000002384186, + "buildMs": 130.8149999976158, "elements": 61008 }, "result": "unhandled", - "batch": 4096, - "median": 3.98437499825377, - "p10": 3.868408195558004, - "p90": 4.312744131311774 + "batch": 2048, + "median": 2.5292968784924597, + "p10": 2.463378885295242, + "p90": 3.630371094914153 }, { "section": "diagnostics", @@ -737,14 +773,14 @@ "name": "L, 1000 focus keys", "groups": 1000, "focusKeys": 1000, - "buildMs": 103.35000002384186, + "buildMs": 130.8149999976158, "elements": 61008 }, "result": "-", "batch": 128, - "median": 113.67187509313226, - "p10": 109.80468709021807, - "p90": 117.53906263038516 + "median": 110.15625018626451, + "p10": 107.57812531664968, + "p90": 114.96093729510903 }, { "section": "diagnostics", @@ -753,14 +789,14 @@ "name": "L, 1000 focus keys", "groups": 1000, "focusKeys": 1000, - "buildMs": 103.35000002384186, + "buildMs": 130.8149999976158, "elements": 61008 }, "result": "-", "batch": 64, - "median": 229.4531250372529, - "p10": 224.7656248509884, - "p90": 232.10937436670065 + "median": 229.29687425494194, + "p10": 226.7187498509884, + "p90": 236.56249977648258 }, { "section": "columns", @@ -769,14 +805,30 @@ "name": "M, cols=\"4\"", "groups": 100, "grid": "4", - "buildMs": 11.409999966621399, + "buildMs": 13.634999990463257, "elements": 6108 }, "result": "unhandled", - "batch": 4096, - "median": 4.21142578125, - "p10": 4.14672851911746, - "p90": 4.36767577775754 + "batch": 8192, + "median": 1.1938476527575403, + "p10": 1.1737060558516532, + "p90": 1.4306640659924597 + }, + { + "section": "columns", + "label": "cols=\"4\", unbound, layout dirtied", + "fixture": { + "name": "M, cols=\"4\"", + "groups": 100, + "grid": "4", + "buildMs": 13.634999990463257, + "elements": 6108 + }, + "result": "unhandled", + "batch": 8192, + "median": 1.3684082077816129, + "p10": 1.3537597624235786, + "p90": 1.6113281235448085 }, { "section": "columns", @@ -785,14 +837,46 @@ "name": "M, cols=\"4\"", "groups": 100, "grid": "4", - "buildMs": 11.409999966621399, + "buildMs": 13.634999990463257, "elements": 6108 }, "result": "moved", "batch": 512, - "median": 27.548828162252903, - "p10": 26.90429682843387, - "p90": 28.00781256519258 + "median": 26.50390623603016, + "p10": 25.488281273283064, + "p90": 27.509765699505806 + }, + { + "section": "columns", + "label": "cols=\"4\", bound, moves focus, layout dirtied", + "fixture": { + "name": "M, cols=\"4\"", + "groups": 100, + "grid": "4", + "buildMs": 13.634999990463257, + "elements": 6108 + }, + "result": "moved", + "batch": 16, + "median": 1084.6874974668026, + "p10": 1040.312498807907, + "p90": 1342.8125008940697 + }, + { + "section": "columns", + "label": "layout dirtied, then read, no handler", + "fixture": { + "name": "M, cols=\"4\"", + "groups": 100, + "grid": "4", + "buildMs": 13.634999990463257, + "elements": 6108 + }, + "result": "-", + "batch": 16, + "median": 973.1249995529652, + "p10": 947.187501937151, + "p90": 1236.5624979138374 }, { "section": "columns", @@ -801,14 +885,30 @@ "name": "M, cols=\"auto\"", "groups": 100, "grid": "auto", - "buildMs": 10.854999959468842, + "buildMs": 11.335000038146973, "elements": 6108 }, "result": "unhandled", - "batch": 2048, - "median": 5.363769509131089, - "p10": 5.324707017280161, - "p90": 5.634765635477379 + "batch": 16384, + "median": 1.2576293956954032, + "p10": 1.1480712892080192, + "p90": 1.3220214859757107 + }, + { + "section": "columns", + "label": "cols=\"auto\", unbound, layout dirtied", + "fixture": { + "name": "M, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 11.335000038146973, + "elements": 6108 + }, + "result": "unhandled", + "batch": 8192, + "median": 1.3250732445158064, + "p10": 1.308593746216502, + "p90": 1.5441894574905746 }, { "section": "columns", @@ -817,14 +917,446 @@ "name": "M, cols=\"auto\"", "groups": 100, "grid": "auto", - "buildMs": 10.854999959468842, + "buildMs": 11.335000038146973, "elements": 6108 }, "result": "moved", "batch": 512, - "median": 30.019531259313226, - "p10": 29.130859416909516, - "p90": 35.410156240686774 + "median": 28.54492177721113, + "p10": 28.134765685535967, + "p90": 29.218749958090484 + }, + { + "section": "columns", + "label": "cols=\"auto\", bound, moves focus, layout dirtied", + "fixture": { + "name": "M, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 11.335000038146973, + "elements": 6108 + }, + "result": "moved", + "batch": 16, + "median": 1033.1249982118607, + "p10": 1003.1250007450581, + "p90": 1302.1874986588955 + }, + { + "section": "columns", + "label": "layout dirtied, then read, no handler", + "fixture": { + "name": "M, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 11.335000038146973, + "elements": 6108 + }, + "result": "-", + "batch": 16, + "median": 994.6875013411045, + "p10": 939.0625022351742, + "p90": 1248.4374977648258 + }, + { + "section": "columns", + "label": "cols=\"4\", unbound", + "fixture": { + "name": "M, flex-wrap, cols=\"4\"", + "groups": 100, + "grid": "4", + "buildMs": 12.800000011920929, + "elements": 6108 + }, + "result": "unhandled", + "batch": 8192, + "median": 1.1663818295346573, + "p10": 1.1425781267462298, + "p90": 1.3934326198068447 + }, + { + "section": "columns", + "label": "cols=\"4\", unbound, layout dirtied", + "fixture": { + "name": "M, flex-wrap, cols=\"4\"", + "groups": 100, + "grid": "4", + "buildMs": 12.800000011920929, + "elements": 6108 + }, + "result": "unhandled", + "batch": 8192, + "median": 1.3562011736212298, + "p10": 1.3262939464766532, + "p90": 1.5661621146136895 + }, + { + "section": "columns", + "label": "cols=\"4\", bound, moves focus", + "fixture": { + "name": "M, flex-wrap, cols=\"4\"", + "groups": 100, + "grid": "4", + "buildMs": 12.800000011920929, + "elements": 6108 + }, + "result": "moved", + "batch": 512, + "median": 26.40625007916242, + "p10": 25.517578120343387, + "p90": 27.509765583090484 + }, + { + "section": "columns", + "label": "cols=\"4\", bound, moves focus, layout dirtied", + "fixture": { + "name": "M, flex-wrap, cols=\"4\"", + "groups": 100, + "grid": "4", + "buildMs": 12.800000011920929, + "elements": 6108 + }, + "result": "moved", + "batch": 256, + "median": 50.37109390832484, + "p10": 49.746093805879354, + "p90": 52.08984389901161 + }, + { + "section": "columns", + "label": "layout dirtied, then read, no handler", + "fixture": { + "name": "M, flex-wrap, cols=\"4\"", + "groups": 100, + "grid": "4", + "buildMs": 12.800000011920929, + "elements": 6108 + }, + "result": "-", + "batch": 512, + "median": 20.048828097060323, + "p10": 19.61914054118097, + "p90": 20.46874992083758 + }, + { + "section": "columns", + "label": "cols=\"auto\", unbound", + "fixture": { + "name": "M, flex-wrap, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 12.27999997138977, + "elements": 6108 + }, + "result": "unhandled", + "batch": 8192, + "median": 1.1804199239122681, + "p10": 1.152954100689385, + "p90": 1.4013671825523488 + }, + { + "section": "columns", + "label": "cols=\"auto\", unbound, layout dirtied", + "fixture": { + "name": "M, flex-wrap, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 12.27999997138977, + "elements": 6108 + }, + "result": "unhandled", + "batch": 8192, + "median": 1.3500976565410383, + "p10": 1.3165283235139214, + "p90": 1.5960693344823085 + }, + { + "section": "columns", + "label": "cols=\"auto\", bound, moves focus", + "fixture": { + "name": "M, flex-wrap, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 12.27999997138977, + "elements": 6108 + }, + "result": "moved", + "batch": 512, + "median": 30.175781343132257, + "p10": 29.580078087747097, + "p90": 35.41992185637355 + }, + { + "section": "columns", + "label": "cols=\"auto\", bound, moves focus, layout dirtied", + "fixture": { + "name": "M, flex-wrap, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 12.27999997138977, + "elements": 6108 + }, + "result": "moved", + "batch": 256, + "median": 55.48828118480742, + "p10": 54.96093747206032, + "p90": 56.269531371071935 + }, + { + "section": "columns", + "label": "layout dirtied, then read, no handler", + "fixture": { + "name": "M, flex-wrap, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 12.27999997138977, + "elements": 6108 + }, + "result": "-", + "batch": 512, + "median": 19.589843810535967, + "p10": 19.560546847060323, + "p90": 19.804687472060323 + }, + { + "section": "columns", + "label": "cols=\"auto\", unbound", + "fixture": { + "name": "M, flex row, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 11.909999966621399, + "elements": 6108 + }, + "result": "unhandled", + "batch": 8192, + "median": 1.1633300746325403, + "p10": 1.1346435567247681, + "p90": 1.3775634797639214 + }, + { + "section": "columns", + "label": "cols=\"auto\", unbound, layout dirtied", + "fixture": { + "name": "M, flex row, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 11.909999966621399, + "elements": 6108 + }, + "result": "unhandled", + "batch": 8192, + "median": 1.3336181655176915, + "p10": 1.303100580116734, + "p90": 1.5582275373162702 + }, + { + "section": "columns", + "label": "cols=\"auto\", bound, moves focus", + "fixture": { + "name": "M, flex row, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 11.909999966621399, + "elements": 6108 + }, + "result": "moved", + "batch": 512, + "median": 36.43554693553597, + "p10": 35.966796800494194, + "p90": 42.333984398283064 + }, + { + "section": "columns", + "label": "cols=\"auto\", bound, moves focus, layout dirtied", + "fixture": { + "name": "M, flex row, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 11.909999966621399, + "elements": 6108 + }, + "result": "moved", + "batch": 16, + "median": 823.7500004470348, + "p10": 796.2500005960464, + "p90": 954.3749988079071 + }, + { + "section": "columns", + "label": "layout dirtied, then read, no handler", + "fixture": { + "name": "M, flex row, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 11.909999966621399, + "elements": 6108 + }, + "result": "-", + "batch": 16, + "median": 755.937498062849, + "p10": 736.5625016391277, + "p90": 899.3749991059303 + }, + { + "section": "columns", + "label": "cols=\"1\", unbound", + "fixture": { + "name": "M, flex column, cols=\"1\"", + "groups": 100, + "grid": "1", + "buildMs": 9.300000011920929, + "elements": 6108 + }, + "result": "unhandled", + "batch": 8192, + "median": 1.1541748026502319, + "p10": 1.1395263718441129, + "p90": 1.3751220758422278 + }, + { + "section": "columns", + "label": "cols=\"1\", unbound, layout dirtied", + "fixture": { + "name": "M, flex column, cols=\"1\"", + "groups": 100, + "grid": "1", + "buildMs": 9.300000011920929, + "elements": 6108 + }, + "result": "unhandled", + "batch": 8192, + "median": 1.3165283162379637, + "p10": 1.2951660100952722, + "p90": 1.5496826235903427 + }, + { + "section": "columns", + "label": "cols=\"1\", bound, moves focus", + "fixture": { + "name": "M, flex column, cols=\"1\"", + "groups": 100, + "grid": "1", + "buildMs": 9.300000011920929, + "elements": 6108 + }, + "result": "moved", + "batch": 512, + "median": 24.4140625, + "p10": 24.01367190759629, + "p90": 25.751953129656613 + }, + { + "section": "columns", + "label": "cols=\"1\", bound, moves focus, layout dirtied", + "fixture": { + "name": "M, flex column, cols=\"1\"", + "groups": 100, + "grid": "1", + "buildMs": 9.300000011920929, + "elements": 6108 + }, + "result": "moved", + "batch": 4, + "median": 3570.000007748604, + "p10": 3202.5000005960464, + "p90": 4273.749992251396 + }, + { + "section": "columns", + "label": "layout dirtied, then read, no handler", + "fixture": { + "name": "M, flex column, cols=\"1\"", + "groups": 100, + "grid": "1", + "buildMs": 9.300000011920929, + "elements": 6108 + }, + "result": "-", + "batch": 4, + "median": 3489.999994635582, + "p10": 3087.5000059604645, + "p90": 4224.9999940395355 + }, + { + "section": "columns", + "label": "cols=\"auto\", unbound", + "fixture": { + "name": "M, flex column, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 9.365000009536743, + "elements": 6108 + }, + "result": "unhandled", + "batch": 8192, + "median": 1.1676025387714617, + "p10": 1.1370849606464617, + "p90": 1.4031982427695766 + }, + { + "section": "columns", + "label": "cols=\"auto\", unbound, layout dirtied", + "fixture": { + "name": "M, flex column, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 9.365000009536743, + "elements": 6108 + }, + "result": "unhandled", + "batch": 8192, + "median": 1.3128662103554234, + "p10": 1.3079834025120363, + "p90": 1.5386962841148488 + }, + { + "section": "columns", + "label": "cols=\"auto\", bound, moves focus", + "fixture": { + "name": "M, flex column, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 9.365000009536743, + "elements": 6108 + }, + "result": "moved", + "batch": 512, + "median": 27.27539069019258, + "p10": 26.92382805980742, + "p90": 28.046875027939677 + }, + { + "section": "columns", + "label": "cols=\"auto\", bound, moves focus, layout dirtied", + "fixture": { + "name": "M, flex column, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 9.365000009536743, + "elements": 6108 + }, + "result": "moved", + "batch": 4, + "median": 3449.999988079071, + "p10": 3200.0000029802322, + "p90": 4547.499999403954 + }, + { + "section": "columns", + "label": "layout dirtied, then read, no handler", + "fixture": { + "name": "M, flex column, cols=\"auto\"", + "groups": 100, + "grid": "auto", + "buildMs": 9.365000009536743, + "elements": 6108 + }, + "result": "-", + "batch": 4, + "median": 3343.75, + "p10": 3108.7500005960464, + "p90": 4696.24999165535 }, { "section": "refused focus", @@ -833,14 +1365,14 @@ "name": "1 × display: none", "groups": 1, "items": 3, - "buildMs": 0.25, + "buildMs": 0.3199999928474426, "elements": 18 }, "result": "moved", "batch": 512, - "median": 23.03710940759629, - "p10": 22.02148432843387, - "p90": 26.064453180879354 + "median": 21.845703129656613, + "p10": 21.34765626396984, + "p90": 23.193359375 }, { "section": "refused focus", @@ -849,14 +1381,14 @@ "name": "10 × display: none", "groups": 1, "items": 12, - "buildMs": 0.17000001668930054, + "buildMs": 0.20999997854232788, "elements": 45 }, "result": "moved", "batch": 512, - "median": 29.34570307843387, - "p10": 27.81250001862645, - "p90": 31.84570313896984 + "median": 27.470703120343387, + "p10": 27.226562495343387, + "p90": 30.32226557843387 }, { "section": "refused focus", @@ -865,14 +1397,14 @@ "name": "100 × display: none", "groups": 1, "items": 102, - "buildMs": 0.3049999475479126, + "buildMs": 0.36500000953674316, "elements": 315 }, "result": "moved", "batch": 128, - "median": 85.9375, - "p10": 83.86718761175871, - "p90": 98.00781263038516 + "median": 85.58593736961484, + "p10": 82.57812494412065, + "p90": 98.47656264901161 }, { "section": "refused focus", @@ -881,14 +1413,14 @@ "name": "1000 × display: none", "groups": 1, "items": 1002, - "buildMs": 1.6150000095367432, + "buildMs": 1.6949999928474426, "elements": 3015 }, "result": "moved", "batch": 32, - "median": 659.0625010430813, - "p10": 610.6249988079071, - "p90": 694.0625011920929 + "median": 629.8437509685755, + "p10": 612.8125004470348, + "p90": 688.1249994039536 }, { "section": "refused focus", @@ -897,14 +1429,14 @@ "name": "1000 × visibility: hidden", "groups": 1, "items": 1002, - "buildMs": 5.675000011920929, + "buildMs": 5.610000014305115, "elements": 3015 }, "result": "moved", - "batch": 16, - "median": 618.1249991059303, - "p10": 600.9375005960464, - "p90": 718.1250005960464 + "batch": 32, + "median": 620.1562490314245, + "p10": 608.4374990314245, + "p90": 697.9687511920929 }, { "section": "refused focus", @@ -913,14 +1445,14 @@ "name": "1000 × display: none, roving", "groups": 1, "items": 1002, - "buildMs": 1.6200000047683716, + "buildMs": 3.1399999856948853, "elements": 3015 }, "result": "moved", "batch": 8, - "median": 1439.3749982118607, - "p10": 1359.9999994039536, - "p90": 1623.1249943375587 + "median": 1408.1249982118607, + "p10": 1350.6250008940697, + "p90": 1643.7499970197678 }, { "section": "refused focus", @@ -929,14 +1461,14 @@ "name": "1000 × display: none, skipped", "groups": 1, "items": 1002, - "buildMs": 1.4550000429153442, + "buildMs": 1.5049999952316284, "elements": 3015 }, "result": "moved", "batch": 32, - "median": 335.31250059604645, - "p10": 327.3437488824129, - "p90": 379.0624998509884 + "median": 331.7187484353781, + "p10": 323.75000044703484, + "p90": 368.1250009685755 } ], "skipped": [] diff --git a/apps/bench/src/keydown.ts b/apps/bench/src/keydown.ts index d07a265..0318b81 100644 --- a/apps/bench/src/keydown.ts +++ b/apps/bench/src/keydown.ts @@ -24,16 +24,20 @@ import type { import { CASE_COUNT, + COLUMN_KEYS, + COLUMNS, DEFAULT_OPTIONS, FOCUS_KEY_COUNTS, - GRIDS, ITEMS_PER_GROUP, + LAYOUT_STATES, REFUSALS, SIZES, } from './plan.ts'; import type { BenchCase, BenchOptions, + ColumnLayout, + ColumnSpec, Fixture, Outcome, RefusalSpec, @@ -62,7 +66,12 @@ const event = ( preventDefault: noop, }); -type PageSpec = { groups: number; focusKeys?: number; grid?: string }; +type PageSpec = { + groups: number; + focusKeys?: number; + grid?: string; + layout?: ColumnLayout; +}; /** What a fixture is built from: everything it reports but its measurements. */ type FixtureSpec = Omit; @@ -71,31 +80,53 @@ type FixtureSpec = Omit; const itemMarkup = (id: string, n: number, attributes = '') => ``; +/** + * Each column layout's styles: the root's, and each item's where it needs + * one. `flex-wrap` holds four items a line, `flex row` all 20 on one line, + * and `flex column` one a line. + */ +const LAYOUT_STYLES: Record = { + grid: { root: 'display:grid;grid-template-columns:repeat(4,4rem)' }, + 'flex-wrap': { + root: 'display:flex;flex-wrap:wrap;width:16rem', + item: 'flex:0 0 25%;box-sizing:border-box', + }, + 'flex row': { root: 'display:flex' }, + 'flex column': { root: 'display:flex;flex-direction:column' }, +}; + /** * A page of `groups` roots with 20 button items each, every item holding two * spans: three elements per item. `focusKeys` items spread evenly over the * page carry a focus key of their own, each a distinct combo that no case - * presses. `grid` lays each group out as a four-column CSS grid, with `cols` - * set to `4` or `auto`. + * presses. `grid` sets each group's `cols`, and `layout` lays it out, as a + * four-column CSS grid unless it names a flex layout. */ -const pageMarkup = ({ groups, focusKeys = 0, grid }: PageSpec) => { +const pageMarkup = ({ + groups, + focusKeys = 0, + grid, + layout = 'grid', +}: PageSpec) => { const total = groups * ITEMS_PER_GROUP; const every = focusKeys ? Math.floor(total / focusKeys) : 0; + const styles = LAYOUT_STYLES[layout]; + const itemStyle = grid && styles.item ? ` style="${styles.item}"` : ''; const parts: string[] = []; let n = 0; for (let g = 0; g < groups; g++) { - const layout = grid - ? ` data-keyrove-cols="${grid}" style="display:grid;grid-template-columns:repeat(4,4rem)"` + const columns = grid + ? ` data-keyrove-cols="${grid}" style="${styles.root}"` : ''; - parts.push(`
`); + parts.push(`
`); for (let i = 0; i < ITEMS_PER_GROUP; i++, n++) { const key = every && n % every === 0 && n / every < focusKeys ? ` data-keyrove-focus-key="ctrl+alt+F${n / every + 1}"` : ''; - parts.push(itemMarkup(`g${g}i${i}`, i, key)); + parts.push(itemMarkup(`g${g}i${i}`, i, `${key}${itemStyle}`)); } parts.push('
'); } @@ -135,6 +166,22 @@ const refusalMarkup = ({ count, hidden, roving, skip }: RefusalSpec) => { const refusalName = ({ count, hidden, roving, skip }: RefusalSpec) => `${count} × ${hidden}${roving ? ', roving' : ''}${skip ? ', skipped' : ''}`; +/** The name a column page goes by. The grids keep their earlier names. */ +const columnName = ({ layout, cols }: ColumnSpec) => + layout === 'grid' ? `M, cols="${cols}"` : `M, ${layout}, cols="${cols}"`; + +let dirtied = false; + +/** + * Changes the fixture's padding, so the next read of the layout lays the page + * out again: what a page pays when something wrote to the DOM earlier in the + * same task, another keydown listener say. + */ +const dirtyLayout = () => { + dirtied = !dirtied; + host().style.paddingLeft = dirtied ? '1px' : ''; +}; + /** Builds `markup` into the fixture host and lays it out, timing both. */ const build = (markup: () => string) => { const start = performance.now(); @@ -270,6 +317,16 @@ const probe = (): Supports => { return keyRove(event('ArrowDown', items[0], root))?.to === items[2]; }); + // Two items a line in a flex-wrap row, which has no grid tracks: ArrowDown + // lands a row down only if `auto` counts the items on the first line. + const wrapColumns = tryIt(() => { + host().innerHTML = `
${''.repeat(4)}
`; + const root = host().firstElementChild!; + const items = Array.from(root.querySelectorAll('button')); + items[0].focus(); + return keyRove(event('ArrowDown', items[0], root))?.to === items[2]; + }); + // A hidden item between two others: ArrowDown reaches the third only if // the move walks on past the item that refuses focus. const refusedFocus = tryIt(() => { @@ -282,11 +339,13 @@ const probe = (): Supports => { }); host().innerHTML = ''; - return { focusKeysMap, autoColumns, refusedFocus }; + return { focusKeysMap, autoColumns, refusedFocus, wrapColumns }; }; const NO_FOCUS_KEYS_MAP = 'this version has no focusKeys option'; const NO_AUTO_COLUMNS = 'this version cannot read columns from the layout'; +const NO_WRAP_COLUMNS = + 'this version reads a layout with no grid tracks as a list, so the key does not move'; const NO_REFUSED_FOCUS = 'this version stops at an item that refuses focus instead of moving past it'; @@ -326,7 +385,10 @@ export const runBench: RunBench = async ({ }); }; - /** Times one press from `start`, with the listener on `currentTarget`. */ + /** + * Times one press from `start`, with the listener on `currentTarget`. + * `before` runs ahead of every press, inside the timing. + */ const run = async ( section: string, label: string, @@ -335,11 +397,17 @@ export const runBench: RunBench = async ({ currentTarget: EventTarget, code: () => string, config?: KeyRoveOptions, + before?: () => void, ) => { signal?.throwIfAborted(); start.focus(); - const call = () => - keyRove(event(code(), document.activeElement, currentTarget), config); + const call = () => { + before?.(); + return keyRove( + event(code(), document.activeElement, currentTarget), + config, + ); + }; const result = outcome(call()); start.focus(); const timing = measure(call, options); @@ -479,14 +547,23 @@ export const runBench: RunBench = async ({ await yieldToBrowser(); } - // C: a fixed column count against `auto`, which reads the layout. - for (const grid of GRIDS) { - const name = `M, cols="${grid}"`; - if (grid === 'auto' && !supports.autoColumns) { - for (const key of ['unbound', 'bound, moves focus']) { + // C: a fixed column count against `auto`, which reads the layout, in a + // grid and in flex layouts, on a laid-out page and with the layout + // dirtied before each press. + for (const column of COLUMNS) { + const { layout, cols } = column; + const name = columnName(column); + const auto = cols === 'auto'; + const labels = COLUMN_KEYS.flatMap((key) => + LAYOUT_STATES.map((state) => `cols="${cols}", ${key}${state}`), + ); + const forced = 'layout dirtied, then read, no handler'; + + if (auto && !supports.autoColumns) { + for (const label of [...labels, forced]) { skip({ section: 'columns', - label: `cols="${grid}", ${key}`, + label, fixture: name, reason: NO_AUTO_COLUMNS, }); @@ -496,24 +573,66 @@ export const runBench: RunBench = async ({ const { fixture, group } = await page( name, - { groups: 100, grid }, - pageMarkup, + { groups: 100, grid: cols }, + (spec) => pageMarkup({ ...spec, layout }), ); - - for (const [key, start, code] of keys(group, { - prev: 'ArrowLeft', - next: 'ArrowRight', - })) { + const moves = + layout === 'flex column' + ? { prev: 'ArrowUp', next: 'ArrowDown' } + : { prev: 'ArrowLeft', next: 'ArrowRight' }; + // Without first-line counting, a flex row with `auto` is a list, where + // ArrowRight is unbound. + const movesHere = + !auto || + layout === 'grid' || + layout === 'flex column' || + supports.wrapColumns; + + for (const [key, start, code] of keys(group, moves)) { if (key === 'bound, no move') continue; - await run( - 'columns', - `cols="${grid}", ${key}`, - fixture, - start, - group, - code, - ); + + for (const state of LAYOUT_STATES) { + const label = `cols="${cols}", ${key}${state}`; + + if (key !== 'unbound' && !movesHere) { + skip({ + section: 'columns', + label, + fixture: name, + reason: NO_WRAP_COLUMNS, + }); + continue; + } + + await run( + 'columns', + label, + fixture, + start, + group, + code, + undefined, + state ? dirtyLayout : undefined, + ); + host().style.paddingLeft = ''; + } } + + // What the dirtied layout costs to read on this page, with no handler: + // the most a layout read in the handler can add to a press. + signal?.throwIfAborted(); + record({ + section: 'columns', + label: forced, + fixture, + result: '-', + ...measure(() => { + dirtyLayout(); + sink = host().offsetHeight; + }, options), + }); + host().style.paddingLeft = ''; + await yieldToBrowser(); } // D: a move past a run of items that refuse focus, local listener. Each diff --git a/apps/bench/src/plan.ts b/apps/bench/src/plan.ts index 73e32db..c23e708 100644 --- a/apps/bench/src/plan.ts +++ b/apps/bench/src/plan.ts @@ -1,4 +1,4 @@ -import type { BenchOptions, RefusalSpec } from './types.ts'; +import type { BenchOptions, ColumnSpec, RefusalSpec } from './types.ts'; /** * What a run covers: the pages it builds and the settings it times with. @@ -16,7 +16,29 @@ export const SIZES: [name: string, groups: number][] = [ export const FOCUS_KEY_COUNTS = [0, 10, 100, 1000]; -export const GRIDS = ['4', 'auto']; +/** + * The column pages: each M group laid out one way, with its column count + * fixed or read from the layout with `auto`. `grid` is a four-column CSS + * grid, whose tracks `auto` counts. The flex layouts have no tracks, so + * `auto` counts the items on their first line: four in `flex-wrap`, all 20 + * in `flex row`, one in `flex column`. + */ +export const COLUMNS: ColumnSpec[] = [ + { layout: 'grid', cols: '4' }, + { layout: 'grid', cols: 'auto' }, + { layout: 'flex-wrap', cols: '4' }, + { layout: 'flex-wrap', cols: 'auto' }, + { layout: 'flex row', cols: 'auto' }, + { layout: 'flex column', cols: '1' }, + { layout: 'flex column', cols: 'auto' }, +]; + +/** + * What each column page is timed with: an unbound key and a move, each on a + * laid-out page and with the layout dirtied just before the press. + */ +export const COLUMN_KEYS = ['unbound', 'bound, moves focus'] as const; +export const LAYOUT_STATES = ['', ', layout dirtied'] as const; /** * The refused-focus pages: one group holding a run of items that refuse focus @@ -53,7 +75,8 @@ export const CASE_COUNT = 1 + // diagnostics: two parts of the lookup, per count. FOCUS_KEY_COUNTS.length * 2 + - // columns: the unbound key and a move, per grid. - GRIDS.length * 2 + + // columns: each key on a clean and a dirtied layout, and the dirtied + // layout read with no handler, per page. + COLUMNS.length * (COLUMN_KEYS.length * LAYOUT_STATES.length + 1) + // refused focus: a move past the run, per page. REFUSALS.length; diff --git a/apps/bench/src/results.ts b/apps/bench/src/results.ts index 002477a..9965bea 100644 --- a/apps/bench/src/results.ts +++ b/apps/bench/src/results.ts @@ -49,7 +49,7 @@ export const SECTION_NOTES: Record = { 'An unbound key under a document listener on the L page, with 0 to 1,000 focus keys declared as attributes or passed as a map.', diagnostics: 'The two parts of the focus-key lookup, each timed on its own.', columns: - 'Arrow keys in four-column grids, with the column count fixed at 4 or read from the layout.', + 'An unbound key and a move in grid and flex layouts, with the column count fixed or read from the layout, on a laid-out page and with the layout dirtied just before each press.', 'refused focus': 'Arrow keys between two items with a run of 1 to 1,000 items between them that refuse focus, so the move walks on past each, and the same run marked skipped.', }; diff --git a/apps/bench/src/types.ts b/apps/bench/src/types.ts index f6a1571..3e01463 100644 --- a/apps/bench/src/types.ts +++ b/apps/bench/src/types.ts @@ -31,6 +31,12 @@ export type Fixture = { elements: number; }; +/** How a column page lays out each group. */ +export type ColumnLayout = 'grid' | 'flex-wrap' | 'flex row' | 'flex column'; + +/** A column page: each group laid out by `layout`, carrying `cols`. */ +export type ColumnSpec = { layout: ColumnLayout; cols: string }; + /** * A refused-focus page: `count` items between the two the press moves * between, each hidden by `hidden`. `roving` gives every item a roving tab @@ -83,6 +89,12 @@ export type Supports = { * from a run recorded before the field existed. */ refusedFocus?: boolean; + /** + * `cols="auto"` counts the items on the first line of a layout with no + * grid tracks, such as `flex-wrap` (after 2.6.1). Absent from a run + * recorded before the field existed. + */ + wrapColumns?: boolean; }; export type BenchResults = { diff --git a/apps/bench/tests/results.test.ts b/apps/bench/tests/results.test.ts index 1bf54f3..31d0f4f 100644 --- a/apps/bench/tests/results.test.ts +++ b/apps/bench/tests/results.test.ts @@ -76,24 +76,24 @@ test('the Markdown report prints the README table rows', () => { assert.ok(markdown.startsWith('## Environment\n\n- Library: ')); assert.ok( - markdown.includes('| S | 10 | 200 | 0 | - | 618 | 1.43 |'), + markdown.includes('| S | 10 | 200 | 0 | - | 618 | 1.63 |'), 'fixture row', ); assert.ok( markdown.includes( - '| M, cols="auto" | 100 | 2000 | 0 | auto | 6108 | 10.85 |', + '| M, cols="auto" | 100 | 2000 | 0 | auto | 6108 | 11.34 |', ), 'grid fixture row', ); assert.ok( markdown.includes( - '| 1000 × display: none | 1 | 1002 | 0 | - | 3015 | 1.62 |', + '| 1000 × display: none | 1 | 1002 | 0 | - | 3015 | 1.69 |', ), 'refused-focus fixture row', ); assert.ok( markdown.includes( - '| S | local, unbound | unhandled | 2.55 | 2.53–2.60 | 4096 |', + '| S | local, unbound | unhandled | 1.28 | 1.19–1.50 | 8192 |', ), 'case row', ); @@ -310,11 +310,11 @@ test('the Markdown report can be in milliseconds', () => { assert.ok(markdown.includes('| Median (ms) | p10–p90 (ms) |')); assert.ok( markdown.includes( - '| S | local, unbound | unhandled | 0.00255 | 0.00253–0.00260 | 4096 |', + '| S | local, unbound | unhandled | 0.00128 | 0.00119–0.00150 | 8192 |', ), ); // Build times stay in milliseconds whatever the unit. - assert.ok(markdown.includes('| S | 10 | 200 | 0 | - | 618 | 1.43 |')); + assert.ok(markdown.includes('| S | 10 | 200 | 0 | - | 618 | 1.63 |')); }); test('frame shares show as many places as they need', () => { From dc70b52d8ef5355d806c8bfd99cbd35ea94735aa Mon Sep 17 00:00:00 2001 From: mixedrays Date: Sun, 8 Nov 2026 16:31:29 +0100 Subject: [PATCH 10/13] refactor: name the shared group settings and boundary moves once - Build TypeaheadOptions on RovingTabindexOptions, and read groups by it, in place of three copies of the same Pick of GroupOptions. GroupReadingOptions, identical to RovingTabindexOptions, is gone. - Type MoveAction and the keys option with BoundaryAction instead of spelling out 'exit' | 'enter'. - Export the KEYROVE_ATTR_* constants from index.ts directly rather than through keyRove.ts, and name the keyRove and createTypeahead exports, so the public surface reads at a glance in one file. - Move resolveTarget, used only by the position tests, into them. - Correct the ExplicitBinding comment: since 2.4.0 the keys option is read before the root's *-key attribute. The public surface is unchanged. --- .../createTypeahead.options.test.ts | 3 +- .../createTypeahead/createTypeahead.test.ts | 2 +- .../__tests__/createTypeahead/testUtils.ts | 4 +- .../keyRove/keyRove.focusKey.test.ts | 4 +- .../__tests__/keyRove/keyRove.grid.test.ts | 2 +- .../__tests__/keyRove/keyRove.homeEnd.test.ts | 2 +- .../__tests__/keyRove/keyRove.keys.test.ts | 2 +- .../__tests__/keyRove/keyRove.loop.test.ts | 2 +- .../keyRove/keyRove.navigation.test.ts | 3 +- .../__tests__/keyRove/keyRove.options.test.ts | 4 +- .../keyRove/keyRove.orientation.test.ts | 5 ++- .../__tests__/keyRove/keyRove.paging.test.ts | 2 +- .../__tests__/keyRove/keyRove.result.test.ts | 4 +- .../keyRove/keyRove.rovingTabindex.test.ts | 3 +- .../src/__tests__/keyRove/testUtils.ts | 4 +- .../src/__tests__/position/position.test.ts | 9 +++- .../keyrove/src/__tests__/types/types.test.ts | 23 ++++++++++ packages/keyrove/src/attributes.ts | 6 +-- packages/keyrove/src/group.ts | 12 ++--- packages/keyrove/src/index.ts | 6 ++- packages/keyrove/src/keyRove.ts | 4 -- packages/keyrove/src/position.ts | 9 ---- packages/keyrove/src/types.ts | 44 ++++++++----------- 23 files changed, 89 insertions(+), 70 deletions(-) diff --git a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.options.test.ts b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.options.test.ts index 1c91ab4..82b0d62 100644 --- a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.options.test.ts +++ b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.options.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { createTypeahead } from '../../createTypeahead'; -import { keyRove, KEYROVE_ATTR_TYPEAHEAD } from '../../keyRove'; +import { KEYROVE_ATTR_TYPEAHEAD } from '../../attributes'; +import { keyRove } from '../../keyRove'; import { activeId, pressKey, resetTestState } from './testUtils'; import type { TypeaheadOptions } from '../../index'; diff --git a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts index f9a50c2..22a65c9 100644 --- a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts +++ b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts @@ -5,7 +5,7 @@ import { KEYROVE_ATTR_ITEM, KEYROVE_ATTR_NEXT_KEY, KEYROVE_ATTR_ROOT, -} from '../../keyRove'; +} from '../../attributes'; import { activeId, createItem, diff --git a/packages/keyrove/src/__tests__/createTypeahead/testUtils.ts b/packages/keyrove/src/__tests__/createTypeahead/testUtils.ts index 1815e9f..ff03761 100644 --- a/packages/keyrove/src/__tests__/createTypeahead/testUtils.ts +++ b/packages/keyrove/src/__tests__/createTypeahead/testUtils.ts @@ -1,12 +1,12 @@ import { vi } from 'vitest'; import { createTypeahead } from '../../createTypeahead'; import { - keyRove, KEYROVE_ATTR_ITEM, KEYROVE_ATTR_SKIP, KEYROVE_ATTR_ROVING_TABINDEX, KEYROVE_ATTR_TYPEAHEAD, -} from '../../keyRove'; +} from '../../attributes'; +import { keyRove } from '../../keyRove'; export type ItemSpec = { skip?: boolean; diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts index 13174c0..24bbc6a 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { - keyRove, KEYROVE_ATTR_FOCUS_KEY, KEYROVE_ATTR_ITEM, KEYROVE_ATTR_NEXT_KEY, KEYROVE_ATTR_ROOT, KEYROVE_ATTR_ROVING_TABINDEX, KEYROVE_ATTR_SKIP, -} from '../../keyRove'; +} from '../../attributes'; +import { keyRove } from '../../keyRove'; import { activeId, createItem, diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.grid.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.grid.test.ts index 86ac6dc..6b545a7 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.grid.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.grid.test.ts @@ -5,7 +5,7 @@ import { KEYROVE_ATTR_NEXT_ROW_KEY, KEYROVE_ATTR_PREV_KEY, KEYROVE_ATTR_PREV_ROW_KEY, -} from '../../keyRove'; +} from '../../attributes'; import { activeId, createItem, diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.homeEnd.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.homeEnd.test.ts index 34bceb9..a2bde8f 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.homeEnd.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.homeEnd.test.ts @@ -4,7 +4,7 @@ import { KEYROVE_ATTR_HOME_KEY, KEYROVE_ATTR_HOME_ROW_KEY, KEYROVE_ATTR_PAGE_LENGTH, -} from '../../keyRove'; +} from '../../attributes'; import { activeId, createItem, diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.keys.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.keys.test.ts index 8eab525..cb464b7 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.keys.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.keys.test.ts @@ -12,7 +12,7 @@ import { KEYROVE_ATTR_PAGE_UP_KEY, KEYROVE_ATTR_PREV_KEY, KEYROVE_ATTR_PREV_ROW_KEY, -} from '../../keyRove'; +} from '../../attributes'; import type { StrideAction } from '../../types'; import { activeId, diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.loop.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.loop.test.ts index ffd6eb1..7214506 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.loop.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.loop.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; -import { KEYROVE_ATTR_LOOP } from '../../keyRove'; +import { KEYROVE_ATTR_LOOP } from '../../attributes'; import { activeId, createItem, diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts index fa1d0d3..c53acc9 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { keyRove, KEYROVE_ATTR_ROOT } from '../../keyRove'; +import { KEYROVE_ATTR_ROOT } from '../../attributes'; +import { keyRove } from '../../keyRove'; import { activeId, createItem, diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.options.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.options.test.ts index 1e38370..d128e5b 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.options.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.options.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { - keyRove, KEYROVE_ATTR_COLS, KEYROVE_ATTR_NEXT_KEY, KEYROVE_ATTR_SKIP, -} from '../../keyRove'; +} from '../../attributes'; +import { keyRove } from '../../keyRove'; import { activeId, createItem, diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.orientation.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.orientation.test.ts index c090024..0377242 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.orientation.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.orientation.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { KEYROVE_ATTR_NEXT_KEY, KEYROVE_ATTR_ORIENTATION } from '../../keyRove'; +import { + KEYROVE_ATTR_NEXT_KEY, + KEYROVE_ATTR_ORIENTATION, +} from '../../attributes'; import { activeId, createItem, diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.paging.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.paging.test.ts index 6fcb0a6..80b5bc8 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.paging.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.paging.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { KEYROVE_ATTR_PAGE_LENGTH } from '../../keyRove'; +import { KEYROVE_ATTR_PAGE_LENGTH } from '../../attributes'; import { activeId, createItem, diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.result.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.result.test.ts index cc871a8..d6dc8e0 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.result.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.result.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { - keyRove, KEYROVE_ATTR_FOCUS_KEY, KEYROVE_ATTR_NEXT_KEY, KEYROVE_ATTR_ROVING_TABINDEX, -} from '../../keyRove'; +} from '../../attributes'; +import { keyRove } from '../../keyRove'; import { activeId, createItem, diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.rovingTabindex.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.rovingTabindex.test.ts index 6fa6c74..5267a45 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.rovingTabindex.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.rovingTabindex.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { followFocus } from '../../followFocus'; -import { keyRove, KEYROVE_ATTR_ROVING_TABINDEX } from '../../keyRove'; +import { KEYROVE_ATTR_ROVING_TABINDEX } from '../../attributes'; +import { keyRove } from '../../keyRove'; import type { Options } from '../../types'; import { activeId, diff --git a/packages/keyrove/src/__tests__/keyRove/testUtils.ts b/packages/keyrove/src/__tests__/keyRove/testUtils.ts index e3cdb9a..76129d0 100644 --- a/packages/keyrove/src/__tests__/keyRove/testUtils.ts +++ b/packages/keyrove/src/__tests__/keyRove/testUtils.ts @@ -1,12 +1,12 @@ import { vi } from 'vitest'; import { - keyRove, KEYROVE_ATTR_ITEM, KEYROVE_ATTR_SKIP, KEYROVE_ATTR_COLS, KEYROVE_ATTR_FOCUS_KEY, KEYROVE_ATTR_ROVING_TABINDEX, -} from '../../keyRove'; +} from '../../attributes'; +import { keyRove } from '../../keyRove'; export type RoveResult = ReturnType; diff --git a/packages/keyrove/src/__tests__/position/position.test.ts b/packages/keyrove/src/__tests__/position/position.test.ts index b120ef8..ecbe930 100644 --- a/packages/keyrove/src/__tests__/position/position.test.ts +++ b/packages/keyrove/src/__tests__/position/position.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { resolveTarget, walkTargets } from '../../position'; +import { walkTargets } from '../../position'; import type { Layout, ResolveTargetArgs } from '../../types'; import { buildElements, idOf, isNavigable } from './testUtils'; @@ -7,6 +7,13 @@ const LIST: Layout = { kind: 'list', cols: 1, horizontal: false, loop: false }; const LOOP: Layout = { ...LIST, loop: true }; const GRID: Layout = { kind: 'grid', cols: 3, horizontal: true, loop: false }; +/** + * The element an intent lands on, or `null`/`undefined` when there is nowhere + * to go (a grid edge, an empty group): the first destination of its walk. + */ +const resolveTarget = (args: ResolveTargetArgs): Element | null | undefined => + walkTargets(args).next().value; + type ResolveArgs = Pick & Partial; diff --git a/packages/keyrove/src/__tests__/types/types.test.ts b/packages/keyrove/src/__tests__/types/types.test.ts index 829da95..5bdd44b 100644 --- a/packages/keyrove/src/__tests__/types/types.test.ts +++ b/packages/keyrove/src/__tests__/types/types.test.ts @@ -1,4 +1,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest'; +import * as attributes from '../../attributes'; +import * as entry from '../../index'; import { itemAttributes, keyRove, @@ -13,6 +15,8 @@ import type { KeyRoveEvent, KeyRoveOptions, Options, + RovingTabindexOptions, + TypeaheadOptions, } from '../../index'; // Checked by the typecheck; the runtime assertions only keep vitest honest. @@ -65,4 +69,23 @@ describe('public type names', () => { expectTypeOf().toExtend(); expectTypeOf().toExtend(); }); + + it('builds the typeahead options on the roving tabindex options', () => { + expectTypeOf().toEqualTypeOf< + Pick + >(); + expectTypeOf().toExtend(); + expectTypeOf< + Pick + >().toEqualTypeOf(); + }); +}); + +describe('public constants', () => { + it('exports every attribute constant from the package entry', () => { + expect( + Object.keys(entry).filter((name) => name.startsWith('KEYROVE_ATTR_')), + ).toEqual(expect.arrayContaining(Object.keys(attributes))); + expect(Object.keys(attributes)).toHaveLength(22); + }); }); diff --git a/packages/keyrove/src/attributes.ts b/packages/keyrove/src/attributes.ts index 91a8e14..16e42ef 100644 --- a/packages/keyrove/src/attributes.ts +++ b/packages/keyrove/src/attributes.ts @@ -4,9 +4,9 @@ * Flat constants rather than a map keyed by role, for the bundle: a bundler * drops the constants a build never reads, and a minifier renames a constant * but never an object key, so a map would ship every name and every key to - * every consumer. These are public surface — `keyRove.ts` re-exports them — - * and have a module of their own so the group layer and the typeahead handler - * can read them without importing the handler. + * every consumer. These are public surface — `index.ts` exports them — and + * have a module of their own so the group layer and the typeahead handler can + * read them without importing the handler. */ export const KEYROVE_ATTR_ITEM = 'data-keyrove-item'; export const KEYROVE_ATTR_SKIP = 'data-keyrove-skip'; diff --git a/packages/keyrove/src/group.ts b/packages/keyrove/src/group.ts index fd0ae37..e548a2a 100644 --- a/packages/keyrove/src/group.ts +++ b/packages/keyrove/src/group.ts @@ -23,12 +23,12 @@ import type { ActionResult, Group, GroupReader, - GroupReadingOptions, IsRoot, IsRoving, IsSkipped, MoveArgs, ReadItems, + RovingTabindexOptions, } from './types.js'; // The attribute source's answers to the four questions this layer asks about @@ -57,11 +57,11 @@ export const attributeSkip: IsSkipped = (element) => * What marks a root: a `root` selector where one is named, else the attribute. * A shadow root handed to `rove` is asked too, and matches no selector. */ -const rootTest = ({ root }: GroupReadingOptions): IsRoot => +const rootTest = ({ root }: RovingTabindexOptions): IsRoot => root ? (element) => !!element.matches?.(root) : attributeRoot; /** How the items are found: a selector run in the root, a reading of your own, or the attribute. */ -const itemsReader = ({ items }: GroupReadingOptions): ReadItems => { +const itemsReader = ({ items }: RovingTabindexOptions): ReadItems => { if (!items) return attributeItems; if (typeof items === 'string') { @@ -72,7 +72,7 @@ const itemsReader = ({ items }: GroupReadingOptions): ReadItems => { }; /** Which items a move passes over: a selector or a test of your own, else the attribute. */ -const skipTest = ({ skip }: GroupReadingOptions): IsSkipped => { +const skipTest = ({ skip }: RovingTabindexOptions): IsSkipped => { if (!skip) return attributeSkip; return typeof skip === 'string' ? (element) => element.matches(skip) : skip; @@ -83,7 +83,7 @@ const skipTest = ({ skip }: GroupReadingOptions): IsSkipped => { * for the group where options name it, else the attribute's reading of that * item. */ -const rovingTest = ({ rovingTabindex }: GroupReadingOptions): IsRoving => +const rovingTest = ({ rovingTabindex }: RovingTabindexOptions): IsRoving => rovingTabindex === undefined ? attributeRoving : () => rovingTabindex; /** @@ -425,7 +425,7 @@ const attributeReader = createReader( * resolved here once, options first and the attributes where options say * nothing, and every group read through the result answers by them. */ -export const readGroups = (options: GroupReadingOptions): GroupReader => +export const readGroups = (options: RovingTabindexOptions): GroupReader => !options.root && !options.items && !options.skip && diff --git a/packages/keyrove/src/index.ts b/packages/keyrove/src/index.ts index 805e709..7bd7062 100644 --- a/packages/keyrove/src/index.ts +++ b/packages/keyrove/src/index.ts @@ -1,10 +1,12 @@ -export * from './keyRove.js'; -export * from './createTypeahead.js'; +export { keyRove } from './keyRove.js'; +export { createTypeahead } from './createTypeahead.js'; export { rootAttributes, itemAttributes } from './attributeBuilders.js'; export { followFocus } from './followFocus.js'; export { initRovingTabindex } from './initRovingTabindex.js'; export { rove } from './rove.js'; export { matchesCombo, toggleTabIndex } from './utils.js'; +// Every `KEYROVE_ATTR_*` constant: the module holds nothing else. +export * from './attributes.js'; // Named rather than `export *`, so the internal types in `types.ts` stay // internal and the public surface is visible at a glance. export type { diff --git a/packages/keyrove/src/keyRove.ts b/packages/keyrove/src/keyRove.ts index 1ce3e90..62a0588 100644 --- a/packages/keyrove/src/keyRove.ts +++ b/packages/keyrove/src/keyRove.ts @@ -7,10 +7,6 @@ import { walkTargets } from './position.js'; import { hasCommandModifier, isEditablePress } from './utils.js'; import type { KeyRoveEvent, KeyRoveOptions, MoveResult } from './types.js'; -// The attribute constants ship alongside the handler that reads them, so -// consumers can spread them into markup; see `attributes.ts`. -export * from './attributes.js'; - /** * Handles keyboard navigation within the provided event's current target. * @param e - The keydown event, native or framework-synthetic. diff --git a/packages/keyrove/src/position.ts b/packages/keyrove/src/position.ts index 15e1a93..d8e98f3 100644 --- a/packages/keyrove/src/position.ts +++ b/packages/keyrove/src/position.ts @@ -114,12 +114,3 @@ export function* walkTargets({ } } } - -/** - * Resolves the element an intent lands on, or `null`/`undefined` when there - * is nowhere to go (a grid edge, an empty group): the first destination of - * its walk, {@link walkTargets}. - */ -export const resolveTarget = ( - args: ResolveTargetArgs, -): Element | null | undefined => walkTargets(args).next().value; diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index 22547c8..7438183 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -134,7 +134,7 @@ export type BoundaryAction = 'exit' | 'enter'; * position. It is the one move whose `*-key` attribute sits on its * destination. */ -export type MoveAction = StrideAction | 'exit' | 'enter' | 'focus'; +export type MoveAction = StrideAction | BoundaryAction | 'focus'; /** * The shape every handler returns for a consumed keypress, parameterised by @@ -201,7 +201,7 @@ export type GroupOptions = { * 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: a {@link KeyCombo} → the * element, or a selector resolved within the listener's reach. Replaces the @@ -226,6 +226,18 @@ export type KeyRoveOptions = GroupOptions & { /** {@link KeyRoveOptions} under its earlier name, kept for existing imports. */ export type Options = KeyRoveOptions; +/** + * 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. The + * group layer reads its groups by these fields alone, and the typeahead + * options build on them. + */ +export type RovingTabindexOptions = Pick< + GroupOptions, + 'items' | 'root' | 'skip' | 'rovingTabindex' +>; + /** Group settings that can be written as attributes on a root. */ export type RootAttributeOptions = Pick< GroupOptions, @@ -290,10 +302,7 @@ export type GroupConfig = { * takes what it needs. The rest of a group's settings are about moves, which * typeahead does not make: it has one way to reach an item, its label. */ -export type TypeaheadOptions = Pick< - GroupOptions, - 'items' | 'root' | 'skip' | 'rovingTabindex' -> & { +export type TypeaheadOptions = RovingTabindexOptions & { /** * The text an item is matched by. Falling back, where it returns nothing, to * the typeahead attribute and then the item's own text — the same chain an @@ -331,16 +340,6 @@ export type TypeaheadResult = ActionResult<'typeahead'>; /** The argument a typeahead `onMove` receives: a move that actually happened. */ export type TypeaheadMove = TypeaheadResult & { to: Element }; -/** - * 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< - GroupOptions, - 'items' | 'root' | 'skip' | 'rovingTabindex' ->; - /** * What `initRovingTabindex` takes: the group settings, and the item the stop * should go to. @@ -388,9 +387,10 @@ export type Binding = | { combo: string; intent: 'focus'; enters: true; target: Element }; /** - * 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, and `none` where the move is bound to no key. + * Looks up the combo explicitly bound to a move: the `keys` option where it + * names the move, else the root's `*-key` attribute. Nullish where neither + * sets it and the move keeps its default key, and `none` where the move is + * bound to no key. */ export type ExplicitBinding = ( intent: StrideAction | BoundaryAction, @@ -465,12 +465,6 @@ export type ToggleTabIndexArgs = { isActive: boolean; }; -/** The group settings the group layer reads: the four readings' sources. */ -export type GroupReadingOptions = Pick< - GroupOptions, - 'items' | 'root' | 'skip' | 'rovingTabindex' ->; - /** * One group, resolved for one call: the items its root governs and every * question the handlers ask about them, with the way a move lands in it. From aa881068c2b36b987081c8adaf546e70227a7b0f Mon Sep 17 00:00:00 2001 From: mixedrays Date: Sun, 8 Nov 2026 16:31:29 +0100 Subject: [PATCH 11/13] feat: export the AnyMove and BoundaryAction types AnyMove, Move | TypeaheadMove, types an onMove in one config object that is passed to both keyRove and createTypeahead. Typed with Move alone, such an object fails to type-check against createTypeahead, whose moves have the action 'typeahead'. BoundaryAction, 'exit' | 'enter', lets a keys map for the boundary moves be typed, as StrideAction already does for the strides. The README's and the API reference's shared-config examples type onMove with AnyMove, and the docs site's readout takes it. --- apps/docs/content/docs/api.md | 54 ++++++++++++------- apps/docs/src/readout.ts | 6 +-- packages/keyrove/README.md | 12 ++++- .../keyrove/src/__tests__/types/types.test.ts | 44 +++++++++++++++ packages/keyrove/src/index.ts | 2 + packages/keyrove/src/types.ts | 7 +++ 6 files changed, 102 insertions(+), 23 deletions(-) diff --git a/apps/docs/content/docs/api.md b/apps/docs/content/docs/api.md index b912d8c..b770bb7 100644 --- a/apps/docs/content/docs/api.md +++ b/apps/docs/content/docs/api.md @@ -377,18 +377,18 @@ keyRove(e, { loop: true }); // override looping only keyRove(e, { items: '[role="menuitem"]' }); // override item lookup only ``` -| Option | Type | Falls back to | Meaning | -| ---------------- | ------------------------------------------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `items` | `string \| (root: Element) => Element[]` | `data-keyrove-item` | The group's items: a selector run inside the root, or a function returning them. Elements carrying `disabled` or matching `:disabled` are never navigable. | -| `root` | `string` | `data-keyrove-root` | Selector a [root](#roots) answers to, matched at or above the event's target. | -| `cols` | `number \| 'auto'` | `data-keyrove-cols` | Columns. Above 1 the group is a grid. `'auto'` counts the root's CSS grid tracks on every keypress that could move, or else the items on a wrapping layout's first line. | -| `loop` | `boolean` | `data-keyrove-loop` | Whether next/prev wrap at the ends. Lists only. | -| `orientation` | `'horizontal' \| 'vertical'` | `data-keyrove-orientation` | `'horizontal'` re-points a list's default arrows; see [RTL](#horizontal-groups-and-rtl). | -| `pageLength` | `number` | `data-keyrove-page-length` | Rows per page jump — items, in a list. | -| `keys` | `Partial>` | 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` | `Record` | `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` | `string \| (element: Element) => boolean` | `data-keyrove-skip` | Which items a move passes over: a selector, or a predicate. | -| `rovingTabindex` | `boolean` | `data-keyrove-roving-tabindex` | Whether the group carries one tab stop. One boolean for the group, where the attribute is read per item. | +| Option | Type | Falls back to | Meaning | +| ---------------- | --------------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `items` | `string \| (root: Element) => Element[]` | `data-keyrove-item` | The group's items: a selector run inside the root, or a function returning them. Elements carrying `disabled` or matching `:disabled` are never navigable. | +| `root` | `string` | `data-keyrove-root` | Selector a [root](#roots) answers to, matched at or above the event's target. | +| `cols` | `number \| 'auto'` | `data-keyrove-cols` | Columns. Above 1 the group is a grid. `'auto'` counts the root's CSS grid tracks on every keypress that could move, or else the items on a wrapping layout's first line. | +| `loop` | `boolean` | `data-keyrove-loop` | Whether next/prev wrap at the ends. Lists only. | +| `orientation` | `'horizontal' \| 'vertical'` | `data-keyrove-orientation` | `'horizontal'` re-points a list's default arrows; see [RTL](#horizontal-groups-and-rtl). | +| `pageLength` | `number` | `data-keyrove-page-length` | Rows per page jump — items, in a list. | +| `keys` | `Partial>` | 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` | `Record` | `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` | `string \| (element: Element) => boolean` | `data-keyrove-skip` | Which items a move passes over: a selector, or a predicate. | +| `rovingTabindex` | `boolean` | `data-keyrove-roving-tabindex` | Whether the group carries one tab stop. One boolean for the group, where the attribute is read per item. | `keys` falls back per action. `{ keys: { next: 'KeyJ' } }` changes only next; other actions retain their attribute or default bindings. Empty, blank and @@ -542,10 +542,18 @@ buffer lives in the handler, which keeps `keyRove` itself stateless. See | `onMove` | `(move: TypeaheadMove) => void` | — | Fired after focus has moved, and only then; see [`keyRove`'s option](#options-onmove). | It shares `items`, `root`, `skip` and `rovingTabindex` with `keyRove`, using -the same fallback rules. Pass one configuration to both handlers: +the same fallback rules. Pass one configuration to both handlers. An `onMove` +in it receives either handler's move, so type its argument +[`AnyMove`](#typeaheadoptions-typeaheadresult-typeaheadmove): ```ts -const config = { items: '[role="menuitem"]', loop: true }; +import { keyRove, createTypeahead, type AnyMove } from '@mixedrays/keyrove'; + +const config = { + items: '[role="menuitem"]', + loop: true, + onMove: ({ action, to }: AnyMove) => console.log(action, to), +}; const typeahead = createTypeahead(config); el.addEventListener('keydown', (e) => keyRove(e, config) || typeahead(e)); @@ -895,6 +903,8 @@ item.tabIndex = 0; ```ts import type { + AnyMove, + BoundaryAction, GroupOptions, KeyCombo, KeyRoveCode, @@ -1046,7 +1056,8 @@ type Options = KeyRoveOptions; All group settings are optional; see [options](#options) for fallbacks. `StrideAction` covers movement through the item sequence. It excludes `exit`, -`enter` and `focus`. +`enter` and `focus`. `BoundaryAction` names `exit` and `enter`, the moves +[across a nested root's boundary](#exit-and-enter), for typing a `keys` map. ```ts type GroupOptions = { @@ -1056,19 +1067,23 @@ 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; + +type BoundaryAction = 'exit' | 'enter'; ``` ### TypeaheadOptions, TypeaheadResult, TypeaheadMove What [`createTypeahead`](#createtypeahead-options) takes and its handler -returns. The result has `MoveResult`'s shape with its own action. +returns. The result has `MoveResult`'s shape with its own action. `AnyMove` +is either handler's move: type an `onMove` with it when one object is passed +to both `keyRove` and `createTypeahead`. ```ts type TypeaheadOptions = Pick< @@ -1090,6 +1105,9 @@ type TypeaheadResult = { // what onMove receives: a move that actually happened type TypeaheadMove = TypeaheadResult & { to: Element }; + +// what an onMove shared by both handlers receives +type AnyMove = Move | TypeaheadMove; ``` ### RovingTabindexOptions, InitRovingTabindexOptions diff --git a/apps/docs/src/readout.ts b/apps/docs/src/readout.ts index 39bca6a..5142682 100644 --- a/apps/docs/src/readout.ts +++ b/apps/docs/src/readout.ts @@ -1,4 +1,4 @@ -import type { MoveResult } from '@mixedrays/keyrove'; +import type { AnyMove, MoveResult } from '@mixedrays/keyrove'; /** * The readout: the line in a live demo's header saying what the last key did. @@ -30,8 +30,8 @@ export const READOUT_HTML = ` void; + /** A move that happened, as either handler's `onMove` hands it over. */ + move: (move: AnyMove) => void; /** * The widget's own doing, which keyrove has no part in: the listbox's pick, * the tree opening a folder or stepping out of one. `key` is empty when the diff --git a/packages/keyrove/README.md b/packages/keyrove/README.md index 1d098b9..c60da0c 100644 --- a/packages/keyrove/README.md +++ b/packages/keyrove/README.md @@ -322,10 +322,18 @@ 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: +handlers use the same group rules. An `onMove` in the shared object receives +either handler's move, so type its argument `AnyMove`: ```ts -const config = { items: '[role="menuitem"]', loop: true, rovingTabindex: true }; +import { keyRove, createTypeahead, type AnyMove } from '@mixedrays/keyrove'; + +const config = { + items: '[role="menuitem"]', + loop: true, + rovingTabindex: true, + onMove: ({ action, to }: AnyMove) => console.log(action, to), +}; const typeahead = createTypeahead(config); menu.addEventListener('keydown', (e) => keyRove(e, config) || typeahead(e)); diff --git a/packages/keyrove/src/__tests__/types/types.test.ts b/packages/keyrove/src/__tests__/types/types.test.ts index 5bdd44b..487768c 100644 --- a/packages/keyrove/src/__tests__/types/types.test.ts +++ b/packages/keyrove/src/__tests__/types/types.test.ts @@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest'; import * as attributes from '../../attributes'; import * as entry from '../../index'; import { + createTypeahead, itemAttributes, keyRove, matchesCombo, @@ -9,13 +10,19 @@ import { rove, } from '../../index'; import type { + AnyMove, + BoundaryAction, GroupOptions, KeyCombo, KeyRoveCode, KeyRoveEvent, KeyRoveOptions, + Move, + MoveAction, Options, RovingTabindexOptions, + StrideAction, + TypeaheadMove, TypeaheadOptions, } from '../../index'; @@ -70,6 +77,43 @@ describe('public type names', () => { expectTypeOf().toExtend(); }); + it('types one onMove for keyRove, rove and createTypeahead with AnyMove', () => { + expectTypeOf().toEqualTypeOf(); + + const moves: AnyMove[] = []; + const config = { + items: '[role="menuitem"]', + loop: true, + onMove: (move: AnyMove) => moves.push(move), + }; + + // Both calls type-check with the one object; neither runs a move here. + const typeahead = createTypeahead(config); + keyRove( + { code: 'KeyA', target: null, currentTarget: null, preventDefault() {} }, + config, + ); + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + expect(typeof typeahead).toBe('function'); + expect(moves).toEqual([]); + }); + + it('names the boundary moves BoundaryAction, for typing a keys map', () => { + expectTypeOf().toEqualTypeOf<'exit' | 'enter'>(); + expectTypeOf().toEqualTypeOf< + StrideAction | BoundaryAction | 'focus' + >(); + expectTypeOf>().toEqualTypeOf< + StrideAction | BoundaryAction + >(); + + const keys: Partial> = { exit: 'Escape' }; + const options: KeyRoveOptions = { keys }; + + expect(options.keys?.exit).toBe('Escape'); + }); + it('builds the typeahead options on the roving tabindex options', () => { expectTypeOf().toEqualTypeOf< Pick diff --git a/packages/keyrove/src/index.ts b/packages/keyrove/src/index.ts index 7bd7062..d5ffdd1 100644 --- a/packages/keyrove/src/index.ts +++ b/packages/keyrove/src/index.ts @@ -10,6 +10,8 @@ export * from './attributes.js'; // Named rather than `export *`, so the internal types in `types.ts` stay // internal and the public surface is visible at a glance. export type { + AnyMove, + BoundaryAction, GroupOptions, InitRovingTabindexOptions, ItemAttributeOptions, diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index 7438183..da83da0 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -340,6 +340,13 @@ export type TypeaheadResult = ActionResult<'typeahead'>; /** The argument a typeahead `onMove` receives: a move that actually happened. */ export type TypeaheadMove = TypeaheadResult & { to: Element }; +/** + * Either handler's move: what an `onMove` receives when one config object is + * handed to both `keyRove` and `createTypeahead`. Typed {@link Move} alone, + * such an `onMove` fits `keyRove` but not the typeahead handler. + */ +export type AnyMove = Move | TypeaheadMove; + /** * What `initRovingTabindex` takes: the group settings, and the item the stop * should go to. From 8fd905696404df8b00e4f5e35ce9ba0d104bfff9 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Sun, 8 Nov 2026 16:31:29 +0100 Subject: [PATCH 12/13] feat: deprecate toggleTabIndex and the Options type alias toggleTabIndex is a single setAttribute call. Its parameter is named root though callers pass an item, and its spelling differs from initRovingTabindex and RovingTabindexOptions. For a roving group, use initRovingTabindex to keep its one tab stop and followFocus to move it with focus; for a stop managed by hand, set element.tabIndex. The library and the tree demo no longer call it. Options, the earlier name of KeyRoveOptions, now carries @deprecated, so editors point to KeyRoveOptions. Both keep working and will be removed in the next major version. --- apps/docs/content/docs/api.md | 24 ++++++++++++-------- apps/docs/content/docs/examples/tree-view.md | 11 +++------ apps/docs/src/demos.ts | 5 ++-- packages/keyrove/README.md | 9 ++++---- packages/keyrove/src/group.ts | 12 +++++----- packages/keyrove/src/types.ts | 7 +++++- packages/keyrove/src/utils.ts | 5 ++++ 7 files changed, 41 insertions(+), 32 deletions(-) diff --git a/apps/docs/content/docs/api.md b/apps/docs/content/docs/api.md index b770bb7..2589c43 100644 --- a/apps/docs/content/docs/api.md +++ b/apps/docs/content/docs/api.md @@ -16,7 +16,7 @@ order: 4 | [`matchesCombo(event, combo)`](#matchescombo-event-combo) | Tests a key combination in your own handlers. | | [`initRovingTabindex(root, options?)`](#initrovingtabindex-root-options) | Initializes or repairs a group's roving tab stop. | | [`followFocus(event, options?)`](#followfocus-event-options) | Updates the roving tab stop on `focusin`. | -| [`toggleTabIndex({ root, isActive })`](#toggletabindex-root-isactive) | Sets one element's `tabindex` to `0` or `-1`. | +| [`toggleTabIndex({ root, isActive })`](#toggletabindex-root-isactive) | Deprecated. Sets one element's `tabindex`. | | [`data-keyrove-*`](#attributes) | Configures items and roots in markup. | | [`KEYROVE_ATTR_*`](#constants) | Constants for attribute names. | | [Types](#types) | Event, configuration and result types. | @@ -744,6 +744,11 @@ the helpers wired together. ## toggleTabIndex({ root, isActive }) +> **Deprecated.** It will be removed in the next major version. For a roving +> group, [`initRovingTabindex`](#initrovingtabindex-root-options) keeps its +> one tab stop and [`followFocus`](#followfocus-event-options) moves it with +> focus. For a stop you manage yourself, set `element.tabIndex`. + Sets `tabindex` to `0` or `-1` on a single element. ```ts @@ -759,12 +764,9 @@ toggleTabIndex({ root: firstItem, isActive: true }); Returns nothing. -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. +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 @@ -916,7 +918,7 @@ import type { InitRovingTabindexOptions, ItemAttributeOptions, ItemAttributes, - Options, // the earlier name of KeyRoveOptions + Options, // deprecated: the earlier name of KeyRoveOptions RootAttributeOptions, RootAttributes, RovingTabindexOptions, @@ -1043,12 +1045,14 @@ type Move = MoveResult & { to: Element }; ### KeyRoveOptions What [`keyRove`](#options) and [`rove`](#rove-element-action-options) take: -the group settings and `onMove`. `Options` is its earlier name and remains -exported as the same type. +the group settings and `onMove`. `Options` is its earlier name, still +exported as the same type. It is deprecated and will be removed in the next +major version. ```ts type KeyRoveOptions = GroupOptions & { onMove?: (move: Move) => void }; +/** @deprecated Use KeyRoveOptions. */ type Options = KeyRoveOptions; ``` diff --git a/apps/docs/content/docs/examples/tree-view.md b/apps/docs/content/docs/examples/tree-view.md index 4d8ff24..9c8c62b 100644 --- a/apps/docs/content/docs/examples/tree-view.md +++ b/apps/docs/content/docs/examples/tree-view.md @@ -88,12 +88,7 @@ hidden descendants directly from the DOM.
```ts -import { - createTypeahead, - keyRove, - matchesCombo, - toggleTabIndex, -} from '@mixedrays/keyrove'; +import { createTypeahead, keyRove, matchesCombo } from '@mixedrays/keyrove'; const tree = document.querySelector('#files'); @@ -117,8 +112,8 @@ const toggle = (item, open) => { }; const moveTo = (from, to) => { - toggleTabIndex({ root: from, isActive: false }); - toggleTabIndex({ root: to, isActive: true }); + from.tabIndex = -1; + to.tabIndex = 0; to.focus(); }; diff --git a/apps/docs/src/demos.ts b/apps/docs/src/demos.ts index ca4074e..345d82d 100644 --- a/apps/docs/src/demos.ts +++ b/apps/docs/src/demos.ts @@ -8,7 +8,6 @@ import { followFocus, keyRove, matchesCombo, - toggleTabIndex, } from '@mixedrays/keyrove'; import { demoDefinition, type DemoExtra } from '../build/demo-definitions.ts'; @@ -173,8 +172,8 @@ const wireTree = ( }; const moveTo = (from: Element | null, to: Element) => { - toggleTabIndex({ root: from, isActive: false }); - toggleTabIndex({ root: to, isActive: true }); + from?.setAttribute('tabindex', '-1'); + to.setAttribute('tabindex', '0'); (to as HTMLElement).focus(); }; diff --git a/packages/keyrove/README.md b/packages/keyrove/README.md index c60da0c..bb21578 100644 --- a/packages/keyrove/README.md +++ b/packages/keyrove/README.md @@ -446,10 +446,11 @@ An event an earlier handler already consumed with `preventDefault()` returns focus once per press. keyrove never stops propagation. Check `e.defaultPrevented` in your own handler if it should skip consumed keys too. -`toggleTabIndex({ root, isActive })` is exported for cases where you manage one -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. +For a whole roving group, `initRovingTabindex(root, options?)` keeps exactly +one stop, and `followFocus(event, options?)` moves it with focus keyRove did +not move. For one element's tab stop you manage yourself, set +`element.tabIndex`. `toggleTabIndex({ root, isActive })` is deprecated and +will be removed in the next major version. ## Moves without a keypress diff --git a/packages/keyrove/src/group.ts b/packages/keyrove/src/group.ts index e548a2a..a56b88e 100644 --- a/packages/keyrove/src/group.ts +++ b/packages/keyrove/src/group.ts @@ -18,7 +18,7 @@ import { KEYROVE_ATTR_SKIP, } from './attributes.js'; import { innerRoot, outerRoot, ownerRoot, rootAt } from './nesting.js'; -import { hasEnabledAttribute, toggleTabIndex } from './utils.js'; +import { hasEnabledAttribute } from './utils.js'; import type { ActionResult, Group, @@ -129,10 +129,10 @@ export const isDisabled = (element: Element) => */ const placeStop = (items: Element[], stop: Element | null) => { for (const item of items) { - const isActive = item === stop; + const tabindex = item === stop ? '0' : '-1'; - if (item.getAttribute('tabindex') !== (isActive ? '0' : '-1')) { - toggleTabIndex({ root: item, isActive }); + if (item.getAttribute('tabindex') !== tabindex) { + item.setAttribute('tabindex', tabindex); } } }; @@ -146,8 +146,8 @@ const carryStop = (from: Element, to: Element) => { (element) => [element, element.getAttribute('tabindex')] as const, ); - toggleTabIndex({ root: from, isActive: false }); - toggleTabIndex({ root: to, isActive: true }); + from.setAttribute('tabindex', '-1'); + to.setAttribute('tabindex', '0'); return () => { for (const [element, value] of before) { diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index da83da0..564a346 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -223,7 +223,12 @@ export type KeyRoveOptions = GroupOptions & { onMove?: (move: Move) => void; }; -/** {@link KeyRoveOptions} under its earlier name, kept for existing imports. */ +/** + * {@link KeyRoveOptions} under its earlier name, kept for existing imports. + * + * @deprecated Use {@link KeyRoveOptions}. `Options` will be removed in the + * next major version. + */ export type Options = KeyRoveOptions; /** diff --git a/packages/keyrove/src/utils.ts b/packages/keyrove/src/utils.ts index bd96109..2e7cded 100644 --- a/packages/keyrove/src/utils.ts +++ b/packages/keyrove/src/utils.ts @@ -169,6 +169,11 @@ export const isEditablePress = (e: KeyRoveEvent): boolean => { * * Descendant `tabindex` values are deliberately left alone — they belong to the * consumer. Roving tabindex only needs the item itself to carry the tab stop. + * + * @deprecated For a roving group, `initRovingTabindex` keeps its one tab stop + * and `followFocus` moves it with focus. For a stop you manage yourself, set + * `element.tabIndex`. `toggleTabIndex` will be removed in the next major + * version. */ export const toggleTabIndex = ({ root, isActive }: ToggleTabIndexArgs) => { if (!root) return; From bf3d1e277200b9af1fe3644354c637c5becf967c Mon Sep 17 00:00:00 2001 From: mixedrays Date: Sun, 8 Nov 2026 16:31:29 +0100 Subject: [PATCH 13/13] fix: read data-keyrove-orientation trimmed and in any case data-keyrove-orientation="Horizontal" or " horizontal " silently gave a vertical list, while cols="auto", none and the boolean attributes are trimmed and read in any case. The attribute now is too. The orientation option is typed and is still compared as given. --- apps/docs/content/docs/api.md | 4 ++-- packages/keyrove/README.md | 2 +- .../__tests__/keyRove/keyRove.orientation.test.ts | 14 ++++++++++++++ packages/keyrove/src/config.ts | 7 ++++--- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/docs/content/docs/api.md b/apps/docs/content/docs/api.md index 2589c43..d02dc26 100644 --- a/apps/docs/content/docs/api.md +++ b/apps/docs/content/docs/api.md @@ -383,7 +383,7 @@ keyRove(e, { items: '[role="menuitem"]' }); // override item lookup only | `root` | `string` | `data-keyrove-root` | Selector a [root](#roots) answers to, matched at or above the event's target. | | `cols` | `number \| 'auto'` | `data-keyrove-cols` | Columns. Above 1 the group is a grid. `'auto'` counts the root's CSS grid tracks on every keypress that could move, or else the items on a wrapping layout's first line. | | `loop` | `boolean` | `data-keyrove-loop` | Whether next/prev wrap at the ends. Lists only. | -| `orientation` | `'horizontal' \| 'vertical'` | `data-keyrove-orientation` | `'horizontal'` re-points a list's default arrows; see [RTL](#horizontal-groups-and-rtl). | +| `orientation` | `'horizontal' \| 'vertical'` | `data-keyrove-orientation` | `'horizontal'` re-points a list's default arrows; see [RTL](#horizontal-groups-and-rtl). The attribute is trimmed and read in any case. | | `pageLength` | `number` | `data-keyrove-page-length` | Rows per page jump — items, in a list. | | `keys` | `Partial>` | 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` | `Record` | `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. | @@ -792,7 +792,7 @@ On the root, read on every keypress: | `data-keyrove-cols` | `1` | Column count; above 1 the group navigates as a grid. Values below 1 fall back to the default. `auto` counts grid tracks, or else first-line items, per 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-orientation` | — | `horizontal`, trimmed and in any case, 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. | diff --git a/packages/keyrove/README.md b/packages/keyrove/README.md index bb21578..b2f7853 100644 --- a/packages/keyrove/README.md +++ b/packages/keyrove/README.md @@ -387,7 +387,7 @@ while its attribute applies per item. See | `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. | +| `data-keyrove-orientation` | `orientation` | root | — | `horizontal`, in any case, 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`. `rove` takes the same options and diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.orientation.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.orientation.test.ts index 0377242..3ebb98a 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.orientation.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.orientation.test.ts @@ -43,6 +43,20 @@ describe('keyRove', () => { expect(up.defaultPrevented).toBe(false); }); + it('reads the attribute trimmed and in any case', () => { + renderList([createItem('a'), createItem('b')], { + containerAttrs: { [KEYROVE_ATTR_ORIENTATION]: ' Horizontal ' }, + }); + document.getElementById('a')!.focus(); + + pressKey('ArrowRight'); + expect(activeId()).toBe('b'); + + const down = pressKey('ArrowDown'); + expect(down.defaultPrevented).toBe(false); + expect(activeId()).toBe('b'); + }); + it('keeps the vertical defaults for any other value', () => { renderList([createItem('a'), createItem('b')], { containerAttrs: { [KEYROVE_ATTR_ORIENTATION]: 'vertical' }, diff --git a/packages/keyrove/src/config.ts b/packages/keyrove/src/config.ts index d0275f5..6174db0 100644 --- a/packages/keyrove/src/config.ts +++ b/packages/keyrove/src/config.ts @@ -208,10 +208,11 @@ const readLayout = ( kind: 'list', cols: 1, // `horizontal` redirects only the *default* keys — an explicit binding - // still wins in the table. Nothing but the literal value "horizontal" - // switches anything, from either source. + // still wins in the table. Nothing but "horizontal" switches anything: + // the option as typed, the attribute trimmed and in any case. horizontal: - (orientation ?? readAttribute(root, KEYROVE_ATTR_ORIENTATION)) === + (orientation ?? + readAttribute(root, KEYROVE_ATTR_ORIENTATION)?.trim().toLowerCase()) === 'horizontal', loop: loop ?? hasEnabledAttribute(root, KEYROVE_ATTR_LOOP), };