From f3e4e5051a1558708a2c8fd1bb039202bf6ab43e Mon Sep 17 00:00:00 2001 From: mixedrays Date: Thu, 15 Oct 2026 21:16:44 +0200 Subject: [PATCH 01/13] refactor: resolve each group once and let it carry the roving stop Every handler built its group from options itself and passed the readings into shallow positional helpers, so one roving-stop rule was spread over four modules and re-tested through five suites. - Add a Group module (`readGroups` in group.ts). The four readings (root, items, skip, roving) resolve once per options object, with the attribute defaults inside. `at(target, listener)` reads a group from an event and `of(root)` from a root. A group answers lazily for its items, focused item, own items, roving items and stop, and holds the one navigable-item rule: neither skipped nor disabled. - `group.move()` lands a move and decides which item the stop is carried from; `group.cross()` lands an exit or enter with the destination group's own stop. `stopSource`, `stopHolder` and focus landing are private to the module, and no handler computes the stop's source. - keyRove, rove, createTypeahead, followFocus and initRovingTabindex keep only their own logic. crossBoundary calls the Group module and returns the group it lands in, and resolveTarget takes the group's navigable test. - A group is one class instance with its getters on the prototype, and options that name none of the readings share one reader, so keydown cost stays within noise against HEAD (`pnpm bench:headless`). - Test the group rules once, in a new group suite, and drop the handler cases that re-tested them, keeping one end-to-end case per rule. Public exports, types and behaviour are unchanged. --- .../createTypeahead.options.test.ts | 64 -- .../createTypeahead/createTypeahead.test.ts | 30 - .../__tests__/followFocus/followFocus.test.ts | 36 -- .../keyrove/src/__tests__/group/group.test.ts | 572 ++++++++++++++++++ .../initRovingTabindex.test.ts | 66 -- .../keyRove/keyRove.navigation.test.ts | 123 +--- .../__tests__/keyRove/keyRove.options.test.ts | 66 -- .../__tests__/keyRove/keyRove.result.test.ts | 21 - .../keyRove/keyRove.rovingTabindex.test.ts | 91 +-- .../src/__tests__/position/position.test.ts | 3 +- .../src/__tests__/position/testUtils.ts | 4 + .../keyrove/src/__tests__/rove/rove.test.ts | 37 -- packages/keyrove/src/boundary.ts | 63 +- packages/keyrove/src/config.ts | 52 +- packages/keyrove/src/createTypeahead.ts | 38 +- packages/keyrove/src/followFocus.ts | 34 +- packages/keyrove/src/group.ts | 394 ++++++++---- packages/keyrove/src/initRovingTabindex.ts | 19 +- packages/keyrove/src/keyRove.ts | 61 +- packages/keyrove/src/position.ts | 31 +- packages/keyrove/src/rove.ts | 47 +- packages/keyrove/src/types.ts | 145 ++++- 22 files changed, 1045 insertions(+), 952 deletions(-) create mode 100644 packages/keyrove/src/__tests__/group/group.test.ts diff --git a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.options.test.ts b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.options.test.ts index e838457..1c91ab4 100644 --- a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.options.test.ts +++ b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.options.test.ts @@ -35,19 +35,6 @@ describe('createTypeahead', () => { expect(activeId()).toBe('drafts'); }); - - it('leaves disabled items out, as the attribute reading does', () => { - renderMenu( - `` + - ``, - { items: '[role="menuitem"]' }, - ); - document.getElementById('second')!.focus(); - - pressKey('d'); - - expect(activeId()).toBe('second'); - }); }); describe('label named in options', () => { @@ -212,57 +199,6 @@ describe('createTypeahead', () => { }); }); - describe('skip named in options', () => { - it('passes over the items its test matches', () => { - renderMenu( - `` + - `${menuItems('Drafts')}`, - { items: '[role="menuitem"]', skip: '.divider' }, - ); - document.getElementById('header')!.focus(); - - pressKey('d'); - - expect(activeId()).toBe('drafts'); - }); - }); - - describe('rovingTabindex named in options', () => { - it('carries the tab stop to the match', () => { - renderMenu(menuItems('Drafts', 'Sent'), { - items: '[role="menuitem"]', - rovingTabindex: true, - }); - document.getElementById('drafts')!.focus(); - - pressKey('s'); - - expect(activeId()).toBe('sent'); - expect(document.getElementById('drafts')!.getAttribute('tabindex')).toBe( - '-1', - ); - expect(document.getElementById('sent')!.getAttribute('tabindex')).toBe( - '0', - ); - }); - }); - - describe('root named in options', () => { - it('scopes matching to the group the press came from', () => { - renderMenu( - `
${menuItems('Drafts', 'Sent')}
` + - `
`, - { root: '.group', items: '[role="menuitem"]' }, - ); - document.getElementById('drafts')!.focus(); - - // 'a' matches only the item in the other group, which is out of reach. - pressKey('a'); - - expect(activeId()).toBe('drafts'); - }); - }); - describe('one object for both handlers', () => { it('configures navigation and typeahead alike', () => { const group = { diff --git a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts index 08f3d3e..e4952e0 100644 --- a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts +++ b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts @@ -4,7 +4,6 @@ import { KEYROVE_ATTR_FOCUS_KEY, KEYROVE_ATTR_NEXT_KEY, KEYROVE_ATTR_ROOT, - KEYROVE_ATTR_SKIP, } from '../../keyRove'; import { activeId, @@ -527,24 +526,6 @@ describe('createTypeahead', () => { pressKey('s', { from: shadow.activeElement! }); expect(shadow.activeElement?.id).toBe('a'); }); - - it('matches the items of a shadow root it listens on', () => { - const host = document.createElement('div'); - document.body.appendChild(host); - const shadow = host.attachShadow({ mode: 'open' }); - shadow.append(...mailbox()); - const typeahead = createTypeahead(); - const results: ReturnType[] = []; - shadow.addEventListener('keydown', (e) => - results.push(typeahead(e as KeyboardEvent)), - ); - shadow.getElementById('a')!.focus(); - - pressKey('s', { from: shadow.activeElement! }); - - expect(shadow.activeElement?.id).toBe('c'); - expect(results.map((result) => result?.to?.id)).toEqual(['c']); - }); }); describe('labels', () => { @@ -658,17 +639,6 @@ describe('createTypeahead', () => { expect(activeId()).toBe('b'); }); - it('matches an item whose skip attribute is false', () => { - const drafts = createItem('a', 'Drafts'); - drafts.setAttribute(KEYROVE_ATTR_SKIP, 'false'); - renderList([drafts, createItem('b', 'Sent')]); - document.getElementById('b')!.focus(); - - pressKey('d'); - - expect(activeId()).toBe('a'); - }); - it('excludes disabled items', () => { renderList([ createItem('a', 'Drafts', { disabled: true }), diff --git a/packages/keyrove/src/__tests__/followFocus/followFocus.test.ts b/packages/keyrove/src/__tests__/followFocus/followFocus.test.ts index ae7bd27..9736700 100644 --- a/packages/keyrove/src/__tests__/followFocus/followFocus.test.ts +++ b/packages/keyrove/src/__tests__/followFocus/followFocus.test.ts @@ -81,23 +81,6 @@ describe('followFocus', () => { expect(tabindexes('a', 'b')).toEqual({ a: '-1', b: '0' }); }); - it("never touches a nested group's stop from the outer group", () => { - listen([ - item('o0', { tabindex: '0' }), - nestedRoot(item('n0', { tabindex: '0' }), item('n1')), - item('o1'), - ]); - - byId('o1').focus(); - - expect(tabindexes('o0', 'o1', 'n0', 'n1')).toEqual({ - o0: '-1', - o1: '0', - n0: '0', - n1: '-1', - }); - }); - it("moves only the nested group's stop for focus inside it", () => { listen([ item('o0', { tabindex: '0' }), @@ -225,25 +208,6 @@ describe('followFocus', () => { ]); }); - it('moves the stop in the group of a shadow root it listens on', () => { - const host = document.createElement('div'); - document.body.appendChild(host); - const shadow = host.attachShadow({ mode: 'open' }); - const items = [item('a', { tabindex: '0' }), item('b'), item('c')]; - shadow.append(...items); - const results: (Element | null)[] = []; - shadow.addEventListener('focusin', (e) => results.push(followFocus(e))); - - items[2].focus(); - - expect(results).toEqual([items[2]]); - expect(items.map((each) => each.getAttribute('tabindex'))).toEqual([ - '-1', - '-1', - '0', - ]); - }); - it('agrees with keyRove, writing nothing after a move it already carried', () => { const { root, results } = listen([item('a', { tabindex: '0' }), item('b')]); root.addEventListener('keydown', (e) => keyRove(e)); diff --git a/packages/keyrove/src/__tests__/group/group.test.ts b/packages/keyrove/src/__tests__/group/group.test.ts new file mode 100644 index 0000000..5863872 --- /dev/null +++ b/packages/keyrove/src/__tests__/group/group.test.ts @@ -0,0 +1,572 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { readGroups } from '../../group'; + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +/** Renders markup into the document. */ +const render = (html: string) => { + document.body.innerHTML = html; +}; + +const byId = (id: string) => document.getElementById(id)!; + +/** Every element's `tabindex`, by id, for the ids given. */ +const tabindexes = (...names: string[]) => + Object.fromEntries( + names.map((id) => [id, byId(id).getAttribute('tabindex')]), + ); + +const ids = (elements: readonly (Element | null | undefined)[]) => + elements.map((element) => element?.id ?? null); + +/** A shadow root on a fresh host, holding `html`. */ +const shadowOf = (html: string) => { + const host = document.createElement('div'); + document.body.appendChild(host); + const shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = html; + + return shadow; +}; + +describe('readGroups', () => { + describe('resolving a group', () => { + it('reads the group of the nearest root at or above the target', () => { + render(` +
+
+ +
+
+ `); + const groups = readGroups({}); + + expect(groups.at(byId('a'), document.body)?.root).toBe(byId('inner')); + expect(groups.at(byId('inner'), document.body)?.root).toBe(byId('inner')); + }); + + it("falls back to the listener's element, the document's for a document or window", () => { + render(``); + const groups = readGroups({}); + + expect(groups.at(byId('a'), byId('list'))?.root).toBe(byId('list')); + expect(groups.at(byId('a'), document)?.root).toBe( + document.documentElement, + ); + expect(groups.at(byId('a'), window)?.root).toBe(document.documentElement); + expect(groups.at(null, null)).toBeNull(); + }); + + it('passes over a root whose attribute is false', () => { + render(` +
+
+ +
+
+ `); + + expect(readGroups({}).at(byId('a'), null)?.root).toBe(byId('outer')); + }); + + it('finds roots by the root option, in place of the attribute', () => { + render(` +
+
+ +
+
+ `); + + expect(readGroups({ root: '.group' }).at(byId('a'), null)?.root).toBe( + byId('named'), + ); + }); + + it('reads a shadow root it listens on as a root with no attributes', () => { + const shadow = shadowOf(` + + + `); + const group = readGroups({}).at(shadow.getElementById('a'), shadow); + + expect(group?.root).toBe(shadow); + expect(ids(group!.items)).toEqual(['a', 'b']); + }); + + it('matches no root selector on a shadow root, and reads its items by selector', () => { + const shadow = shadowOf( + ``, + ); + const group = readGroups({ root: '.group', items: 'button' }).at( + shadow.getElementById('a'), + shadow, + ); + + expect(group?.root).toBe(shadow); + expect(ids(group!.items)).toEqual(['a', 'b']); + }); + }); + + describe('items and focus', () => { + it("keeps skipped items and a nested root's items in the sequence, and leaves disabled ones out", () => { + render(` +
+ + + +
+ +
+
+ `); + + expect(ids(readGroups({}).of(byId('root')).items)).toEqual([ + 'a', + 'b', + 'd', + ]); + }); + + it('leaves out an element whose item attribute is false', () => { + render(` +
+ + +
+ `); + + expect(ids(readGroups({}).of(byId('root')).items)).toEqual(['a']); + }); + + it('reads the items the items option names, disabled ones aside', () => { + render(` + + `); + + expect(ids(readGroups({ items: 'li' }).of(byId('root')).items)).toEqual([ + 'a', + 'c', + ]); + expect( + ids( + readGroups({ items: (root) => Array.from(root.children) }).of( + byId('root'), + ).items, + ), + ).toEqual(['a', 'c']); + }); + + it('counts focus anywhere inside an item, the outermost where items nest', () => { + render(` +
+
+
+
+
+ `); + byId('field').focus(); + + expect(readGroups({}).of(byId('root')).focused).toBe(byId('a')); + }); + + it('has no focused item with focus outside every item, or on a disabled one', () => { + render(` +
+ + +
+ `); + const groups = readGroups({}); + + byId('root').focus(); + expect(groups.of(byId('root')).focused).toBeNull(); + + byId('b').focus(); + expect(groups.of(byId('root')).focused).toBeNull(); + }); + + it('finds the item focused inside a shadow root', () => { + const shadow = shadowOf(` +
+ + +
+ `); + shadow.getElementById('b')!.focus(); + + expect(readGroups({}).of(shadow.getElementById('list')!).focused).toBe( + shadow.getElementById('b'), + ); + expect(readGroups({}).of(shadow).focused).toBe( + shadow.getElementById('b'), + ); + }); + }); + + describe('navigable items', () => { + it('lands on an item neither skipped nor disabled', () => { + render(` +
+ + + + +
+ `); + const { isNavigable } = readGroups({}).of(byId('root')); + + expect(['a', 'b', 'c', 'd'].map((id) => isNavigable(byId(id)))).toEqual([ + true, + false, + true, + false, + ]); + }); + + it('passes over what the skip option names, in place of the attribute', () => { + render(` +
+ + +
+ `); + const bySelector = readGroups({ skip: '.muted' }).of(byId('root')); + const byTest = readGroups({ skip: (item) => item.id === 'b' }).of( + byId('root'), + ); + + expect(bySelector.isNavigable(byId('a'))).toBe(true); + expect(bySelector.isNavigable(byId('b'))).toBe(false); + expect(byTest.isNavigable(byId('b'))).toBe(false); + }); + }); + + describe('own items and the roving stop', () => { + const nested = ` +
+ + +
+ + +
+ +
+ `; + + it("leaves a nested root's items to it, and counts a root that is an item in the group around it", () => { + render(nested); + const groups = readGroups({}); + + expect(ids(groups.of(byId('root')).own)).toEqual([ + 'a', + 'b', + 'panel', + 'e', + ]); + expect(ids(groups.of(byId('panel')).own)).toEqual(['c', 'd']); + }); + + it('tells nested roots apart by the root option, items by the items option', () => { + render(` +
+ +
+ +
+
+ +
+
+ `); + const groups = readGroups({ root: '.group', items: '.item' }); + + expect(ids(groups.of(byId('root')).own)).toEqual(['a', 'c']); + expect(ids(groups.of(byId('inner')).own)).toEqual(['b']); + }); + + it('shares the stop across the own items that carry it, disabled ones included', () => { + render(nested); + + expect(ids(readGroups({}).of(byId('root')).roving)).toEqual([ + 'a', + 'panel', + 'e', + ]); + expect( + ids(readGroups({ rovingTabindex: true }).of(byId('root')).roving), + ).toEqual(['a', 'b', 'panel', 'e']); + expect( + readGroups({ rovingTabindex: false }).of(byId('root')).roving, + ).toEqual([]); + }); + + it("finds each group's own stop, never a nested group's", () => { + render(nested); + const groups = readGroups({}); + + expect(groups.of(byId('root')).stop).toBe(byId('panel')); + expect(groups.of(byId('panel')).stop).toBe(byId('c')); + }); + + it('holds the stop on an item that carries it at tabindex 0, disabled ones aside', () => { + render(` +
+ + + +
+ `); + const group = readGroups({}).of(byId('root')); + + expect(group.holdsStop(byId('b'))).toBe(false); + expect(group.holdsStop(byId('c'))).toBe(true); + expect(group.stop).toBe(byId('c')); + }); + + it('has no stop where no own item holds one', () => { + render(` +
+ +
+ +
+
+ `); + + expect(readGroups({}).of(byId('root')).stop).toBeNull(); + }); + + it("places the stop on one roving item, every other at -1, a nested group's alone", () => { + render(nested); + const group = readGroups({}).of(byId('root')); + + group.placeStop(byId('a')); + + expect(tabindexes('a', 'b', 'panel', 'c', 'd', 'e')).toEqual({ + a: '0', + b: '0', + panel: '-1', + c: '0', + d: '-1', + e: '-1', + }); + + group.placeStop(null); + + expect(tabindexes('a', 'panel', 'e')).toEqual({ + a: '-1', + panel: '-1', + e: '-1', + }); + }); + + it('writes nothing to a group whose stop is in place already', () => { + render(nested); + const group = readGroups({}).of(byId('root')); + const observer = new MutationObserver(() => {}); + observer.observe(byId('root'), { attributes: true, subtree: true }); + + group.placeStop(byId('panel')); + + expect(observer.takeRecords()).toEqual([]); + observer.disconnect(); + }); + }); + + describe('landing a move', () => { + const list = ` +
+ + + +
+ `; + + // Two groups in one order: the outer stop on a, the nested one on c. + const nested = ` +
+ +
+ + +
+ +
+ `; + + it('claims the key, focuses the item, carries the stop and reports the move', () => { + render(list); + byId('a').focus(); + const e = { preventDefault: vi.fn() }; + const onMove = vi.fn(); + const move = { action: 'next', from: byId('a'), to: byId('b') }; + + const result = readGroups({}) + .of(byId('root')) + .move({ e, ...move, onMove }); + + expect(result).toEqual(move); + expect(e.preventDefault).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(byId('b')); + expect(tabindexes('a', 'b')).toEqual({ a: '-1', b: '0' }); + expect(onMove).toHaveBeenCalledWith(move); + }); + + it('is a consumed no-op with nowhere to go, or nowhere new', () => { + render(list); + byId('a').focus(); + const e = { preventDefault: vi.fn() }; + const onMove = vi.fn(); + const group = readGroups({}).of(byId('root')); + + expect( + group.move({ e, action: 'next', from: byId('a'), to: null, onMove }), + ).toEqual({ action: 'next', from: byId('a'), to: null }); + expect( + group.move({ + e, + action: 'home', + from: byId('a'), + to: byId('a'), + onMove, + }), + ).toEqual({ action: 'home', from: byId('a'), to: null }); + expect(e.preventDefault).toHaveBeenCalledTimes(2); + expect(onMove).not.toHaveBeenCalled(); + expect(tabindexes('a', 'b')).toEqual({ a: '0', b: '-1' }); + }); + + it('puts every tabindex back, absent included, where the item refuses focus', () => { + render(list); + byId('a').focus(); + // Inert or hidden in a browser: focusable by its attributes, and still + // refusing focus. + vi.spyOn(byId('c'), 'focus').mockImplementation(() => {}); + const onMove = vi.fn(); + + const result = readGroups({}) + .of(byId('root')) + .move({ action: 'end', from: byId('a'), to: byId('c'), onMove }); + + expect(result).toEqual({ action: 'end', from: byId('a'), to: null }); + expect(document.activeElement).toBe(byId('a')); + expect(tabindexes('a', 'c')).toEqual({ a: '0', c: null }); + expect(onMove).not.toHaveBeenCalled(); + }); + + it('carries no stop from an item that does not carry one, or into the group from outside', () => { + render(list); + byId('a').focus(); + + readGroups({ rovingTabindex: false }) + .of(byId('root')) + .move({ action: 'next', from: byId('a'), to: byId('b') }); + + expect(document.activeElement).toBe(byId('b')); + expect(tabindexes('a', 'b')).toEqual({ a: '0', b: '-1' }); + + readGroups({}) + .of(byId('root')) + .move({ action: 'end', from: null, to: byId('c') }); + + expect(document.activeElement).toBe(byId('c')); + expect(tabindexes('a', 'b', 'c')).toEqual({ a: '0', b: '-1', c: null }); + }); + + it("carries a nested group's own stop into it, and leaves the outer one", () => { + render(nested); + byId('a').focus(); + + readGroups({}) + .of(byId('root')) + .move({ action: 'next', from: byId('a'), to: byId('b') }); + + expect(document.activeElement).toBe(byId('b')); + expect(tabindexes('a', 'b', 'c', 'd')).toEqual({ + a: '0', + b: '0', + c: '-1', + d: '-1', + }); + }); + + it("carries the outer group's own stop out of a nested group, and leaves the nested one", () => { + render(nested); + byId('c').focus(); + + readGroups({}) + .of(byId('root')) + .move({ action: 'next', from: byId('c'), to: byId('d') }); + + expect(document.activeElement).toBe(byId('d')); + expect(tabindexes('a', 'b', 'c', 'd')).toEqual({ + a: '-1', + b: '-1', + c: '0', + d: '0', + }); + }); + + it('carries the stop within a nested group from the item focus left', () => { + render(nested); + byId('c').focus(); + + readGroups({}) + .of(byId('root')) + .move({ action: 'prev', from: byId('c'), to: byId('b') }); + + expect(tabindexes('a', 'b', 'c')).toEqual({ a: '0', b: '0', c: '-1' }); + }); + + it('lands a move in a shadow root, reading focus off the shadow tree', () => { + const shadow = shadowOf(list); + const [a, b] = ['a', 'b'].map((id) => shadow.getElementById(id)!); + a.focus(); + const onMove = vi.fn(); + + readGroups({}) + .of(shadow) + .move({ action: 'next', from: a, to: b, onMove }); + + expect(shadow.activeElement).toBe(b); + expect([a, b].map((item) => item.getAttribute('tabindex'))).toEqual([ + '-1', + '0', + ]); + expect(onMove).toHaveBeenCalledTimes(1); + }); + + it("carries the group's own stop on a move across a boundary into it, from wherever focus was", () => { + render(nested); + const groups = readGroups({}); + + // Out of the nested group, to the outer item after it. + byId('c').focus(); + groups + .of(byId('root')) + .cross({ action: 'exit', from: byId('c'), to: byId('d') }); + + expect(document.activeElement).toBe(byId('d')); + expect(tabindexes('a', 'c', 'd')).toEqual({ a: '-1', c: '0', d: '0' }); + + // Into it with no item focused: its own stop moves all the same. + byId('inner').setAttribute('tabindex', '-1'); + byId('inner').focus(); + groups + .of(byId('inner')) + .cross({ action: 'enter', from: null, to: byId('b') }); + + expect(document.activeElement).toBe(byId('b')); + expect(tabindexes('b', 'c', 'd')).toEqual({ b: '0', c: '-1', d: '0' }); + }); + }); +}); diff --git a/packages/keyrove/src/__tests__/initRovingTabindex/initRovingTabindex.test.ts b/packages/keyrove/src/__tests__/initRovingTabindex/initRovingTabindex.test.ts index 1e22e74..10a3f0a 100644 --- a/packages/keyrove/src/__tests__/initRovingTabindex/initRovingTabindex.test.ts +++ b/packages/keyrove/src/__tests__/initRovingTabindex/initRovingTabindex.test.ts @@ -308,52 +308,6 @@ describe('initRovingTabindex', () => { o1: '-1', }); }); - - it('manages only the nested group when called on it', () => { - const inner = nestedRoot(item('n0'), item('n1', { tabindex: '0' })); - group( - item('o0', { tabindex: '0' }), - inner, - item('o1', { tabindex: '0' }), - ); - - expect(initRovingTabindex(inner)).toBe(document.getElementById('n1')); - expect(tabindexes('o0', 'o1', 'n0', 'n1')).toEqual({ - o0: '0', - o1: '0', - n0: '-1', - n1: '0', - }); - }); - - it('counts an item that is itself a root in the group around it', () => { - const panel = nestedRoot(item('n0', { tabindex: '0' }), item('n1')); - panel.id = 'panel'; - panel.setAttribute(KEYROVE_ATTR_ITEM, ''); - panel.setAttribute(KEYROVE_ATTR_ROVING_TABINDEX, ''); - panel.setAttribute('tabindex', '-1'); - const root = group(panel, item('o1', { tabindex: '0' })); - - expect(initRovingTabindex(root)).toBe(document.getElementById('o1')); - expect(initRovingTabindex(panel)).toBe(document.getElementById('n0')); - expect(tabindexes('panel', 'o1', 'n0', 'n1')).toEqual({ - panel: '-1', - o1: '0', - n0: '0', - n1: '-1', - }); - }); - - it('recognises nested roots by the root option', () => { - const inner = document.createElement('div'); - inner.className = 'group'; - inner.append(item('n0', { tabindex: '0' })); - const root = group(inner, item('o0')); - - initRovingTabindex(root, { root: '.group' }); - - expect(tabindexes('n0', 'o0')).toEqual({ n0: '0', o0: '0' }); - }); }); describe('a group described in options', () => { @@ -371,26 +325,6 @@ describe('initRovingTabindex', () => { expect(stop).toBe(document.getElementById('a')); expect(tabindexes('a', 'b', 'c')).toEqual({ a: '0', b: '-1', c: '-1' }); }); - - it('passes over the items the skip option names', () => { - const heading = item('heading'); - heading.className = 'heading'; - const root = group(heading, item('a')); - - expect(initRovingTabindex(root, { skip: '.heading' })).toBe( - document.getElementById('a'), - ); - }); - - it('leaves marked-up items alone under rovingTabindex: false', () => { - const root = group( - item('a', { tabindex: '0' }), - item('b', { tabindex: '0' }), - ); - - expect(initRovingTabindex(root, { rovingTabindex: false })).toBeNull(); - expect(tabindexes('a', 'b')).toEqual({ a: '0', b: '0' }); - }); }); it('gives the group of a shadow root its stop', () => { diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts index 73c5066..6d67d49 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts @@ -1,10 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { - keyRove, - KEYROVE_ATTR_ITEM, - KEYROVE_ATTR_ROOT, - KEYROVE_ATTR_SKIP, -} from '../../keyRove'; +import { keyRove, KEYROVE_ATTR_ROOT } from '../../keyRove'; import { activeId, createItem, @@ -123,30 +118,6 @@ describe('keyRove', () => { expect(activeId()).toBe('c'); }); - it('skips items marked with the skip attribute when moving backward', () => { - renderList([ - createItem('a'), - createItem('b', { skip: true }), - createItem('c'), - ]); - document.getElementById('c')!.focus(); - - pressKey('ArrowUp'); - - expect(activeId()).toBe('a'); - }); - - it('keeps an item navigable when its skip attribute is false', () => { - const skipped = createItem('b'); - skipped.setAttribute(KEYROVE_ATTR_SKIP, 'false'); - renderList([createItem('a'), skipped, createItem('c')]); - document.getElementById('a')!.focus(); - - pressKey('ArrowDown'); - - expect(activeId()).toBe('b'); - }); - describe('when every item is skipped', () => { // The same group skipped two ways: by attribute, and by the `skip` // option over otherwise plain items. @@ -331,34 +302,6 @@ describe('keyRove', () => { expect(activeId()).toBe('b'); }); - - it('ignores an inner root whose attribute is false', () => { - const inner = document.createElement('div'); - inner.setAttribute(KEYROVE_ATTR_ROOT, 'false'); - inner.append(createItem('a'), createItem('b')); - - const outer = document.createElement('div'); - outer.setAttribute(KEYROVE_ATTR_ROOT, 'true'); - outer.append(inner, createItem('c')); - document.body.appendChild(outer); - outer.addEventListener('keydown', (e) => keyRove(e)); - - document.getElementById('b')!.focus(); - pressKey('ArrowDown'); - - expect(activeId()).toBe('c'); - }); - - it('ignores items whose item attribute is false', () => { - const ignored = createItem('b'); - ignored.setAttribute(KEYROVE_ATTR_ITEM, 'false'); - renderList([createItem('a'), ignored, createItem('c')]); - document.getElementById('a')!.focus(); - - pressKey('ArrowDown'); - - expect(activeId()).toBe('c'); - }); }); describe('listener placement', () => { @@ -476,38 +419,6 @@ describe('keyRove', () => { ]); }); - it('goes Home and End from the focused item', () => { - const { shadow, byId, press, named } = renderShadow(ITEMS); - byId('b').focus(); - - press('End'); - expect(shadow.activeElement?.id).toBe('c'); - - press('End'); - expect(shadow.activeElement?.id).toBe('c'); - - press('Home'); - expect(shadow.activeElement?.id).toBe('a'); - expect(named()).toEqual([ - { action: 'end', from: 'b', to: 'c' }, - { action: 'end', from: 'c', to: null }, - { action: 'home', from: 'c', to: 'a' }, - ]); - }); - - it('navigates from the item holding focus in a descendant', () => { - const { shadow, byId, press, named } = renderShadow(` -
- - `); - byId('control').focus(); - - press('ArrowDown'); - - expect(shadow.activeElement?.id).toBe('b'); - expect(named()).toEqual([{ action: 'next', from: 'a', to: 'b' }]); - }); - it('leaves exit alone on a focused root that is an item of the group around it', () => { const { byId, press, named } = renderShadow(`
@@ -628,22 +539,6 @@ describe('keyRove', () => { expect(shadow.activeElement?.id).toBe('a'); }); - it('finds its items by selector, and leaves a root selector unmatched', () => { - const { shadow, byId, press, named } = renderShadow( - ` - - - `, - { items: 'button', root: '.group' }, - ); - byId('a').focus(); - - press('ArrowDown'); - - expect(shadow.activeElement?.id).toBe('b'); - expect(named()).toEqual([{ action: 'next', from: 'a', to: 'b' }]); - }); - it('leaves a marked root inside it to its own attributes', () => { const { shadow, byId, press, named } = renderShadow(`
@@ -690,22 +585,6 @@ describe('keyRove', () => { expect(named()).toEqual([{ action: 'exit', from: 'inner', to: 'cell' }]); }); - it('carries the roving stop', () => { - const { byId, press } = renderShadow( - ` - - - `, - { rovingTabindex: true }, - ); - byId('a').focus(); - - press('ArrowDown'); - - expect(byId('a').getAttribute('tabindex')).toBe('-1'); - expect(byId('b').getAttribute('tabindex')).toBe('0'); - }); - it('leaves a key alone with focus on no item', () => { const { byId, press, named } = renderShadow(` diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.options.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.options.test.ts index ad4c461..1e38370 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.options.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.options.test.ts @@ -44,29 +44,6 @@ describe('keyRove', () => { expect(activeId()).toBe('b'); }); - - it('takes a reading of its own in place of a selector', () => { - renderMenu(menuItems('a', 'b'), { - items: (root) => Array.from(root.querySelectorAll('[role]')), - }); - document.getElementById('a')!.focus(); - - pressKey('ArrowDown'); - - expect(activeId()).toBe('b'); - }); - - it('leaves disabled items out, as the attribute reading does', () => { - renderMenu( - `${menuItems('a')}${menuItems('c')}`, - { items: '[role="menuitem"]' }, - ); - document.getElementById('a')!.focus(); - - pressKey('ArrowDown'); - - expect(activeId()).toBe('c'); - }); }); describe('settings beside attributes', () => { @@ -198,18 +175,6 @@ describe('keyRove', () => { expect(activeId()).toBe('c'); }); - - it('takes a test of its own in place of a selector', () => { - renderMenu(menuItems('a', 'b', 'c'), { - items: '[role="menuitem"]', - skip: (element) => element.id === 'b', - }); - document.getElementById('a')!.focus(); - - pressKey('ArrowDown'); - - expect(activeId()).toBe('c'); - }); }); describe('rovingTabindex named in options', () => { @@ -225,21 +190,6 @@ describe('keyRove', () => { expect(document.getElementById('a')!.getAttribute('tabindex')).toBe('-1'); expect(document.getElementById('b')!.getAttribute('tabindex')).toBe('0'); }); - - it('leaves the tab stop alone when it names false over marked-up items', () => { - renderList( - [createItem('a', { roving: true }), createItem('b', { roving: true })], - { - options: { rovingTabindex: false }, - }, - ); - document.getElementById('a')!.focus(); - - pressKey('ArrowDown'); - - expect(activeId()).toBe('b'); - expect(document.getElementById('a')!.getAttribute('tabindex')).toBe('0'); - }); }); describe('focusKeys named in options', () => { @@ -353,22 +303,6 @@ describe('keyRove', () => { }); }); - describe('root named in options', () => { - it('scopes the group to the nearest element the selector matches', () => { - renderMenu( - `
${menuItems('a', 'b')}
` + - `
${menuItems('c', 'd')}
`, - { root: '.group', items: '[role="menuitem"]' }, - ); - document.getElementById('b')!.focus(); - - // The end of its own group, not of the markup the listener covers. - pressKey('ArrowDown'); - - expect(activeId()).toBe('b'); - }); - }); - describe('settings options leave unnamed', () => { it('keeps answering from the markup, whoever named the items', () => { renderMenu( diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.result.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.result.test.ts index 1228da7..9de1a1f 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.result.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.result.test.ts @@ -284,27 +284,6 @@ describe('keyRove', () => { to: b, }); }); - - it('counts focus that lands inside a shadow root', () => { - const onMove = vi.fn(); - const host = document.createElement('div'); - document.body.appendChild(host); - const shadow = host.attachShadow({ mode: 'open' }); - const list = document.createElement('div'); - const [a, b] = [createItem('a'), createItem('b')]; - list.append(a, b); - shadow.appendChild(list); - list.addEventListener('keydown', (e) => keyRove(e, { onMove })); - - pressKey('ArrowDown', list); - - expect(shadow.activeElement).toBe(a); - expect(onMove).toHaveBeenCalledWith({ - action: 'next', - from: null, - to: a, - }); - }); }); describe('an event already consumed', () => { diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.rovingTabindex.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.rovingTabindex.test.ts index 6460e21..6fa6c74 100644 --- a/packages/keyrove/src/__tests__/keyRove/keyRove.rovingTabindex.test.ts +++ b/packages/keyrove/src/__tests__/keyRove/keyRove.rovingTabindex.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import { followFocus } from '../../followFocus'; import { keyRove, KEYROVE_ATTR_ROVING_TABINDEX } from '../../keyRove'; import type { Options } from '../../types'; @@ -104,21 +104,6 @@ describe('keyRove', () => { ['without', false], ['with', true], ])('%s a followFocus listener', (_, follow) => { - it("keeps the outer group's stop when an arrow enters the nested group", () => { - const outer = render(NESTED(), { follow }); - byId('a').focus(); - - pressKey('ArrowDown'); - - expect(activeId()).toBe('b'); - expect(tabindexes(outer)).toEqual({ - a: '0', - b: '0', - c: '-1', - d: '-1', - }); - }); - it("moves the nested group's own stop to an item other than it", () => { const outer = render(NESTED({ inner: 'c' }), { follow }); byId('a').focus(); @@ -134,21 +119,6 @@ describe('keyRove', () => { }); }); - it('enters the nested group backwards the same way', () => { - const outer = render(NESTED({ outer: 'd' }), { follow }); - byId('d').focus(); - - pressKey('ArrowUp'); - - expect(activeId()).toBe('c'); - expect(tabindexes(outer)).toEqual({ - a: '-1', - b: '-1', - c: '0', - d: '0', - }); - }); - it("keeps the nested group's stop when a focus key leaves it", () => { const outer = render( NESTED({ focusKey: 'data-keyrove-focus-key="alt+KeyD"' }), @@ -167,65 +137,6 @@ describe('keyRove', () => { }); }); }); - - it('reads the groups through root and item selectors the same way', () => { - const options: Options = { - root: '.group', - items: '.item', - rovingTabindex: true, - }; - const outer = render( - ` - -
- - -
- - `, - { follow: true, options }, - ); - byId('a').focus(); - - pressKey('ArrowDown'); - - expect(activeId()).toBe('b'); - expect(tabindexes(outer)).toEqual({ a: '0', b: '0', c: '-1', d: '-1' }); - }); - - it('counts an item that is itself a root in the group above it', () => { - const outer = render(` - -
- - -
- `); - byId('a').focus(); - - pressKey('ArrowDown'); - - expect(activeId()).toBe('panel'); - expect(tabindexes(outer)).toEqual({ - a: '-1', - panel: '0', - p0: '0', - p1: '-1', - }); - }); - - it('puts every changed tab stop back when the nested item refuses focus', () => { - const outer = render(NESTED({ inner: 'c' })); - // Inert or hidden in a browser: focusable by its attributes, and still - // refusing focus. - vi.spyOn(byId('b'), 'focus').mockImplementation(() => {}); - byId('a').focus(); - - pressKey('ArrowDown'); - - expect(activeId()).toBe('a'); - expect(tabindexes(outer)).toEqual({ a: '0', b: '-1', c: '0', d: '-1' }); - }); }); }); }); diff --git a/packages/keyrove/src/__tests__/position/position.test.ts b/packages/keyrove/src/__tests__/position/position.test.ts index 47e382e..de50763 100644 --- a/packages/keyrove/src/__tests__/position/position.test.ts +++ b/packages/keyrove/src/__tests__/position/position.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { resolveTarget } from '../../position'; import type { Layout, ResolveTargetArgs } from '../../types'; -import { buildElements, idOf } from './testUtils'; +import { buildElements, idOf, isNavigable } from './testUtils'; const LIST: Layout = { kind: 'list', cols: 1, horizontal: false, loop: false }; const LOOP: Layout = { ...LIST, loop: true }; @@ -16,6 +16,7 @@ const resolve = (args: ResolveArgs) => elements: buildElements(4), layout: LIST, pageLength: 10, + isNavigable, ...args, }); diff --git a/packages/keyrove/src/__tests__/position/testUtils.ts b/packages/keyrove/src/__tests__/position/testUtils.ts index f0fbfd3..1bd767e 100644 --- a/packages/keyrove/src/__tests__/position/testUtils.ts +++ b/packages/keyrove/src/__tests__/position/testUtils.ts @@ -10,3 +10,7 @@ export const buildElements = (count: number, skipped: number[] = []) => }); export const idOf = (el: Element | null | undefined) => el?.id; + +/** Lands on every element but the skipped ones, as a group's navigable test does. */ +export const isNavigable = (element: Element) => + !element.hasAttribute(KEYROVE_ATTR_SKIP); diff --git a/packages/keyrove/src/__tests__/rove/rove.test.ts b/packages/keyrove/src/__tests__/rove/rove.test.ts index 4a1fcd4..40bc11a 100644 --- a/packages/keyrove/src/__tests__/rove/rove.test.ts +++ b/packages/keyrove/src/__tests__/rove/rove.test.ts @@ -307,28 +307,6 @@ describe('rove', () => { }); }); - it('moves from the item focused inside a shadow root', () => { - const host = document.createElement('div'); - document.body.appendChild(host); - const shadow = host.attachShadow({ mode: 'open' }); - const list = document.createElement('div'); - // Plain tab stops, so no roving stop stands in for the focused item. - list.append( - ...['a', 'b', 'c'].map((id) => - item(id, { tabindex: '0', roving: false }), - ), - ); - shadow.appendChild(list); - shadow.getElementById('b')!.focus(); - - expect(named(rove(list, 'next'))).toEqual({ - action: 'next', - from: 'b', - to: 'c', - }); - expect(shadow.activeElement?.id).toBe('c'); - }); - it('moves in the group of a shadow root', () => { const host = document.createElement('div'); document.body.appendChild(host); @@ -475,21 +453,6 @@ describe('rove', () => { expect(activeId()).toBe('b'); }); - it("never starts from a nested group's stop", () => { - const root = group([ - item('o0', { tabindex: '0', roving: false }), - nestedRoot(item('n0', { tabindex: '0' }), item('n1')), - item('o1', { tabindex: '0', roving: false }), - ]); - outsideButton(); - - expect(named(rove(root, 'next'))).toEqual({ - action: 'next', - from: null, - to: 'o0', - }); - }); - it('does nothing in an empty group, or without an element', () => { const root = group([]); outsideButton(); diff --git a/packages/keyrove/src/boundary.ts b/packages/keyrove/src/boundary.ts index 3c5225b..e89dd86 100644 --- a/packages/keyrove/src/boundary.ts +++ b/packages/keyrove/src/boundary.ts @@ -8,18 +8,18 @@ * group's own, and the group focus left keeps its stop for the way back. */ -import { activeElementOf, ownItems, stopHolder } from './group.js'; -import type { BoundaryAction, GroupConfig, IsRoot } from './types.js'; +import { activeElementOf } from './group.js'; +import type { BoundaryAction, Group, GroupReader, IsRoot } from './types.js'; // `Node.DOCUMENT_POSITION_FOLLOWING`, spelled out so reading it needs no // global `Node`. const FOLLOWING = 4; /** - * Where a boundary move lands, and the item its group's roving stop is - * carried from — `null` where that group has no stop to carry. + * Where a boundary move lands, and the group it lands in, whose own roving + * stop the move carries. */ -export type Crossing = { to: Element; stopFrom: Element | null }; +export type Crossing = { group: Group; to: Element }; /** * The group a root sits in: the nearest root above it, else the listener's @@ -44,11 +44,8 @@ const outerRoot = ( return scope; }; -/** A group's own items that a move can land on: neither disabled nor skipped. */ -const landings = (root: Element, config: GroupConfig, isRoot: IsRoot) => - ownItems(root, config.readItems, isRoot).filter( - (item) => !item.hasAttribute('disabled') && !config.isSkipped(item), - ); +/** A group's own items that a move can land on. */ +const landings = (group: Group) => group.own.filter(group.isNavigable); /** * Out of a nested root, to the group around it: the item of that group @@ -58,16 +55,16 @@ const landings = (root: Element, config: GroupConfig, isRoot: IsRoot) => * — has nowhere to go. */ const exit = ( + groups: GroupReader, root: Element, scope: Element, - config: GroupConfig, - isRoot: IsRoot, ): Crossing | null => { - const outer = outerRoot(root, scope, isRoot); + const outer = outerRoot(root, scope, groups.isRoot); if (!outer) return null; - const items = landings(outer, config, isRoot); + const group = groups.of(outer); + const items = landings(group); const after = items.findIndex( (item) => root.compareDocumentPosition(item) & FOLLOWING, ); @@ -77,10 +74,7 @@ const exit = ( if (!to || activeElementOf(to) === to) return null; - return { - to, - stopFrom: stopHolder(outer, config.readItems, isRoot, config.isRoving), - }; + return { group, to }; }; /** @@ -88,31 +82,22 @@ const exit = ( * that group's roving stop, where a move can land on it, so the group opens * where the user left it; else to its first item a move can land on. */ -const enter = ( - item: Element, - config: GroupConfig, - isRoot: IsRoot, -): Crossing | null => { - const inner = Array.from(item.querySelectorAll('*')).find(isRoot); +const enter = (groups: GroupReader, item: Element): Crossing | null => { + const inner = Array.from(item.querySelectorAll('*')).find(groups.isRoot); if (!inner) return null; - const items = landings(inner, config, isRoot); - const to = - items.find( - (each) => config.isRoving(each) && each.getAttribute('tabindex') === '0', - ) ?? items[0]; + const group = groups.of(inner); + const items = landings(group); + const to = items.find(group.holdsStop) ?? items[0]; if (!to) return null; - return { - to, - stopFrom: stopHolder(inner, config.readItems, isRoot, config.isRoving), - }; + return { group, to }; }; /** - * Where a boundary move goes from `root`, the group the key was pressed in, + * Where a boundary move goes from `group`, the group the key was pressed in, * or `null` where it has nowhere to go: `exit` from the listener's own group, * or with no item around it; `enter` with no item focused, or none nested in * it to land on. `scope` is the listener's element, past which no group is @@ -120,13 +105,11 @@ const enter = ( */ export const crossBoundary = ( intent: BoundaryAction, - root: Element, + groups: GroupReader, + group: Group, scope: Element, - focused: Element | null, - config: GroupConfig, - isRoot: IsRoot, ): Crossing | null => { - if (intent === 'exit') return exit(root, scope, config, isRoot); + if (intent === 'exit') return exit(groups, group.root, scope); - return focused ? enter(focused, config, isRoot) : null; + return group.focused ? enter(groups, group.focused) : null; }; diff --git a/packages/keyrove/src/config.ts b/packages/keyrove/src/config.ts index 793cc04..bb9828f 100644 --- a/packages/keyrove/src/config.ts +++ b/packages/keyrove/src/config.ts @@ -9,7 +9,10 @@ * options reads exactly the markup it always did. * * Nothing here decides anything. The readings land in one object, and the - * layers below never learn which source answered. + * layers below never learn which source answered. What a group's items are, + * which of them a move passes over, what marks a root and whether the stop + * roves are read the same way, by the group layer (`readGroups`), since every + * handler asks them, and not only of the root a keypress resolved in. */ import { @@ -19,9 +22,8 @@ import { KEYROVE_ATTR_ORIENTATION, KEYROVE_ATTR_PAGE_LENGTH, } from './attributes.js'; -import { attributeItems, attributeRoving } from './group.js'; +import { attributeSkip } from './group.js'; import { keyAttribute } from './keyAttribute.js'; -import { attributeSkip } from './position.js'; import { hasEnabledAttribute, isComboSet, @@ -33,11 +35,7 @@ import type { FocusKey, GroupConfig, GroupOptions, - IsRoot, - IsRoving, - IsSkipped, Layout, - ReadItems, } from './types.js'; // `Node.DOCUMENT_FRAGMENT_NODE`, spelled out so reading it needs no global @@ -72,10 +70,6 @@ const isRtl = (root: Element): boolean => { ); }; -/** A selector or a test of your own, as one test. */ -const asTest = (source: string | IsSkipped): IsSkipped => - typeof source === 'string' ? (element) => element.matches(source) : source; - /** * A count an option names, where it names a usable one: whole, and at least 1. * Anything else falls through to the attribute, as an unusable attribute falls @@ -133,16 +127,6 @@ const readColumns = (root: Element, cols: GroupOptions['cols']): number => { return parseAttributeInt(root, KEYROVE_ATTR_COLS, 1); }; -/** - * Whether an element is a group's root, where a `root` selector names one. - * Undefined otherwise, which leaves `resolveRoot` reading the attribute. - * - * Resolved apart from the rest: it is what finds the root the rest is read - * from. A shadow root handed to `rove` is asked too, and matches no selector. - */ -export const rootTest = ({ root }: GroupOptions): IsRoot | undefined => - root ? (element) => !!element.matches?.(root) : undefined; - /** * How the group folds its sequence. A list is one column; more than one makes * a grid, which has no orientation of its own — its `next`/`prev` axis is @@ -225,29 +209,6 @@ export const readFocusKeys = ( ); }; -/** How the group's items are found: a selector run in the root, a reading of your own, or the attribute. */ -export const itemsReader = ({ items }: GroupOptions): ReadItems => { - if (!items) return attributeItems; - - if (typeof items === 'string') { - return (root) => Array.from(root.querySelectorAll(items)); - } - - return items; -}; - -/** Which items a move passes over: the option where one is named, else the attribute. */ -export const skipTest = ({ skip }: GroupOptions): IsSkipped => - skip ? asTest(skip) : attributeSkip; - -/** - * Whether the item focus is leaving carries the roving tab stop: one boolean - * for the group where options name it, else the attribute's reading of that - * item. - */ -export const rovingTest = ({ rovingTabindex }: GroupOptions): IsRoving => - rovingTabindex === undefined ? attributeRoving : () => rovingTabindex; - /** * Every setting one move needs, read once for the root it resolved in. The * focus keys are read apart, by {@link readFocusKeys}: they span the @@ -265,7 +226,4 @@ export const readConfig = ( pageLength: count(options.pageLength) ?? parseAttributeInt(root, KEYROVE_ATTR_PAGE_LENGTH, 10), - readItems: itemsReader(options), - isSkipped: skipTest(options), - isRoving: rovingTest(options), }); diff --git a/packages/keyrove/src/createTypeahead.ts b/packages/keyrove/src/createTypeahead.ts index 1ee2813..6bd0395 100644 --- a/packages/keyrove/src/createTypeahead.ts +++ b/packages/keyrove/src/createTypeahead.ts @@ -9,14 +9,7 @@ */ import { KEYROVE_ATTR_TYPEAHEAD } from './attributes.js'; -import { itemsReader, rootTest, skipTest, rovingTest } from './config.js'; -import { - attributeRoot, - moveFocus, - readGroup, - resolveRoot, - stopSource, -} from './group.js'; +import { readGroups } from './group.js'; import { hasCommandModifier, isEditableTarget } from './utils.js'; import type { KeyRoveEvent, @@ -81,16 +74,13 @@ export const createTypeahead = ({ matchMode = 'prefix', foldDiacritics = true, onMove, - ...group + ...settings }: TypeaheadOptions = {}) => { const fold = foldDiacritics ? foldMarks : lowerCase; // The group's settings cannot change for the life of the handler, so they // are resolved once here rather than on every keystroke. - const isRoot = rootTest(group); - const readItems = itemsReader(group); - const isSkipped = skipTest(group); - const isRoving = rovingTest(group); + const groups = readGroups(settings); let buffer = ''; let lastPressTime = 0; let lastRoot: Element | null = null; @@ -111,9 +101,11 @@ export const createTypeahead = ({ if (isEditableTarget(eventTarget)) return null; - const root = resolveRoot(eventTarget, e.currentTarget, isRoot); + const group = groups.at(eventTarget, e.currentTarget); - if (!root) return null; + if (!group) return null; + + const { root } = group; // The buffer expires with silence and never survives a change of group — // one delegated listener may serve several. Both are resolved *before* @@ -141,7 +133,7 @@ export const createTypeahead = ({ // growing the buffer, so "s", "s" goes on naming the S items. if (matchMode !== 'cycle' || buffer !== character) buffer += character; - const { items, focused } = readGroup(root, readItems); + const { items, focused } = group; // A one-character prefix in cycle mode searches from just past the focused // item and wraps, so every press — fresh or repeated, quick or slow — lands @@ -153,7 +145,8 @@ export const createTypeahead = ({ : 0; const target = [...items.slice(start), ...items.slice(0, start)].find( (item) => - !isSkipped(item) && fold(getLabel(item, label)).startsWith(buffer), + group.isNavigable(item) && + fold(getLabel(item, label)).startsWith(buffer), ); // No match leaves the key untouched — the character still joined the @@ -163,20 +156,11 @@ export const createTypeahead = ({ // A match that is the focused item already is a consumed no-op, matching // keyRove's edge no-ops; otherwise the roving stop moves with focus, // within the matched item's own group. - return moveFocus({ + return group.move({ e, action: 'typeahead', from: focused, to: target, - isRoving, - stopFrom: stopSource( - root, - focused, - target, - readItems, - isRoot ?? attributeRoot, - isRoving, - ), onMove, }); }; diff --git a/packages/keyrove/src/followFocus.ts b/packages/keyrove/src/followFocus.ts index 013b97c..791d773 100644 --- a/packages/keyrove/src/followFocus.ts +++ b/packages/keyrove/src/followFocus.ts @@ -9,15 +9,7 @@ * way a keypress does, so one config object serves every handler. */ -import { itemsReader, rootTest, rovingTest, skipTest } from './config.js'; -import { - attributeRoot, - listenerElement, - ownItems, - placeStop, - readGroup, - resolveRoot, -} from './group.js'; +import { listenerElement, readGroups } from './group.js'; import type { KeyRoveEvent, RovingTabindexOptions } from './types.js'; /** @@ -46,31 +38,25 @@ export const followFocus = ( e: Pick, options: RovingTabindexOptions = {}, ): Element | null => { - const isRoot = rootTest(options); - const readItems = itemsReader(options); + const groups = readGroups(options); const scope = listenerElement(e.currentTarget); let from = e.target as Element | null; while (from) { - const root = resolveRoot(from, e.currentTarget, isRoot); + const group = groups.at(from, e.currentTarget); - if (!root) return null; + if (!group) return null; - const { focused } = readGroup(root, readItems); + const { root, focused } = group; if (focused) { - const isRoving = rovingTest(options); + // Only a navigable item of the group's own roving ones takes the stop. + if (!group.isNavigable(focused) || !group.roving.includes(focused)) { + return null; + } - if (!isRoving(focused) || skipTest(options)(focused)) return null; - - const items = ownItems(root, readItems, isRoot ?? attributeRoot).filter( - isRoving, - ); - - if (!items.includes(focused)) return null; - - placeStop(items, focused); + group.placeStop(focused); return focused; } diff --git a/packages/keyrove/src/group.ts b/packages/keyrove/src/group.ts index ecf4b66..576e6d7 100644 --- a/packages/keyrove/src/group.ts +++ b/packages/keyrove/src/group.ts @@ -1,44 +1,90 @@ /** * The group layer, shared by every handler. * - * `keyRove` and `createTypeahead` resolve the same root, read the same items - * and position off it, and end the same way: claim the key, land focus, carry - * the roving tab stop, report. That tail *is* the result contract consumers - * chain on with `||`, so it is spelled out once here — which keeps the two - * handlers' promises identical and leaves a third one nothing to re-derive. + * Every handler resolves its group here, once per call — from where an event + * happened, or from a root — and asks the group what it needs: its items, + * which of them a move can land on, which are its own rather than a nested + * root's, and the item holding its roving tab stop. A move ends here too: + * claim the key, land focus, carry the stop, report. That tail *is* the + * result contract consumers chain on with `||`, and which item the stop is + * carried from is the rule nested groups turn on, so both are spelled out + * once, here, and no handler re-derives either. */ import { KEYROVE_ATTR_ITEM, KEYROVE_ATTR_ROOT, KEYROVE_ATTR_ROVING_TABINDEX, + KEYROVE_ATTR_SKIP, } from './attributes.js'; import { hasEnabledAttribute, toggleTabIndex } from './utils.js'; import type { ActionResult, Group, + GroupReader, + GroupReadingOptions, IsRoot, IsRoving, - MoveFocusArgs, + IsSkipped, + MoveArgs, ReadItems, } from './types.js'; -// The attribute source's answers to the three questions this layer asks about -// a group. They are the defaults of the parameters below, so a caller that -// names no other source navigates markup exactly as it always has, and the -// config layer hands them back down where an options object leaves a field -// unnamed. -export const attributeRoot: IsRoot = (element) => +// The attribute source's answers to the four questions this layer asks about +// a group. They stand in for any reading an options object leaves unnamed, so +// a caller that names no other source navigates markup exactly as it always +// has. +const attributeRoot: IsRoot = (element) => hasEnabledAttribute(element, KEYROVE_ATTR_ROOT); -export const attributeItems: ReadItems = (root) => +const attributeItems: ReadItems = (root) => Array.from(root.querySelectorAll(`[${KEYROVE_ATTR_ITEM}]`)).filter((item) => hasEnabledAttribute(item, KEYROVE_ATTR_ITEM), ); -export const attributeRoving: IsRoving = (from) => +const attributeRoving: IsRoving = (from) => hasEnabledAttribute(from, KEYROVE_ATTR_ROVING_TABINDEX); +/** + * Which elements a move passes over, by the skip attribute. Also how the + * focus-key scan reads the attribute, on elements that need not be items. + */ +export const attributeSkip: IsSkipped = (element) => + hasEnabledAttribute(element, KEYROVE_ATTR_SKIP); + +/** + * 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 => + 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 => { + if (!items) return attributeItems; + + if (typeof items === 'string') { + return (root) => Array.from(root.querySelectorAll(items)); + } + + return items; +}; + +/** Which items a move passes over: a selector or a test of your own, else the attribute. */ +const skipTest = ({ skip }: GroupReadingOptions): IsSkipped => { + if (!skip) return attributeSkip; + + return typeof skip === 'string' ? (element) => element.matches(skip) : skip; +}; + +/** + * Whether the item focus is leaving carries the roving tab stop: one boolean + * for the group where options name it, else the attribute's reading of that + * item. + */ +const rovingTest = ({ rovingTabindex }: GroupReadingOptions): IsRoving => + rovingTabindex === undefined ? attributeRoving : () => rovingTabindex; + /** * The focused element as `element`'s own tree sees it. Inside a shadow root * the document sees only the host, so the shadow root is asked instead; focus @@ -94,53 +140,6 @@ export const listenerElement = ( return listener as Element; }; -/** - * The root a keypress is navigated in: the nearest element `isRoot` accepts at - * or above `target` — by default the nearest `data-keyrove-root` — else the - * listener's element. Resolving from the *target* rather than the listener is - * what lets one delegated listener serve several groups, and lets a root nest - * inside another and still win while focus is in it. - */ -export const resolveRoot = ( - target: Element | null | undefined, - listener: EventTarget | null | undefined, - isRoot: IsRoot = attributeRoot, -): Element | null => { - for (let element = target; element; element = element.parentElement) { - if (isRoot(element)) return element; - } - - return listenerElement(listener); -}; - -/** - * What a root governs: its navigable items in DOM order, and the one holding - * focus. An item counts as focused when focus is anywhere inside it - * (`:focus-within`), so an item wrapping a control is still the position after - * Tab lands on that control; where items nest, the outer one is the position, - * being first in DOM order. - * - * The position is looked for among the items themselves, so whatever - * `readItems` leaves out is not a position either. `disabled` is taken out - * here rather than in any one reading: it is the DOM's own word for an element - * that takes no part, and it means the same whichever source named the items. - * The root answers first: focus outside it means no item can hold it, and the - * walk is skipped. - */ -export const readGroup = ( - root: Element, - readItems: ReadItems = attributeItems, -): Group => { - const items = readItems(root).filter( - (item) => !item.hasAttribute('disabled'), - ); - - return { - items, - focused: holdsFocus(root) ? (items.find(holdsFocus) ?? null) : null, - }; -}; - /** * The group an item of `root` belongs to: the nearest root above its * *parent*, short of `root` itself, else `root`. The resolution a focus key's @@ -158,71 +157,17 @@ const ownerRoot = (item: Element, root: Element, isRoot: IsRoot): Element => { return root; }; -/** - * The items a root governs itself: its items, less those of a root nested - * inside it, which belong to that root (see `ownerRoot`). - * - * Deliberately not `readGroup`'s items, which keep a nested root's items so - * the outer order runs straight through them. This is the set one group's - * roving tab stop is shared across: exactly one `0` among them, and a nested - * group's stop left alone. - */ -export const ownItems = ( - root: Element, - readItems: ReadItems = attributeItems, - isRoot: IsRoot = attributeRoot, -): Element[] => - readItems(root).filter((item) => ownerRoot(item, root, isRoot) === root); - -/** - * The item holding a group's roving tab stop: the first of its own items that - * carries the stop and has `tabindex="0"`, disabled ones aside. `null` where - * the group has none. - */ -export const stopHolder = ( - root: Element, - readItems: ReadItems = attributeItems, - isRoot: IsRoot = attributeRoot, - isRoving: IsRoving = attributeRoving, -): Element | null => - ownItems(root, readItems, isRoot).find( - (item) => - isRoving(item) && - item.getAttribute('tabindex') === '0' && - !item.hasAttribute('disabled'), - ) ?? null; - -/** - * The item a move from `from` to `to`, both items of `root`, carries the - * roving tab stop from. `root`'s order runs on through the items of a root - * nested in it, but each group keeps a stop of its own: a move within one - * group carries it from `from`, and a move into another carries that group's - * own stop, leaving the stop of the group focus left where it is. `null` - * where there is no `from`, or the group moved into has no stop to carry. - */ -export const stopSource = ( - root: Element, - from: Element | null, - to: Element | null | undefined, - readItems: ReadItems = attributeItems, - isRoot: IsRoot = attributeRoot, - isRoving: IsRoving = attributeRoving, -): Element | null => { - if (!from || !to) return from; - - const group = ownerRoot(to, root, isRoot); - - return ownerRoot(from, root, isRoot) === group - ? from - : stopHolder(group, readItems, isRoot, isRoving); -}; +// `disabled` is the DOM's own word for an element that takes no part, and it +// means the same whichever source named the items: such an item is in no +// group's sequence, and never holds a stop. +const isDisabled = (item: Element) => item.hasAttribute('disabled'); /** * Gives `stop` the group's one `tabindex="0"` and every other of its roving * `items` `-1` — or all of them `-1` where there is no stop. Only attributes * that change are written, so a group already in order takes no mutations. */ -export const placeStop = (items: Element[], stop: Element | null) => { +const placeStop = (items: Element[], stop: Element | null) => { for (const item of items) { const isActive = item === stop; @@ -262,29 +207,24 @@ 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 — by default, when it carries the attribute — `to` is focused, - * and `onMove` fires with the move that happened. That item is `from`, unless - * `stopFrom` names another: a move into another group carries that group's - * own stop, found by `stopSource` or at a boundary crossing. + * carried from, which `stopSource` names once the move is known to go + * somewhere, `to` is focused, and `onMove` fires with the move that happened. * * A `to` that does not take focus — not focusable, inert, hidden — is the same * consumed no-op, with the tab stop put back where it was. */ -export const moveFocus = ({ - e, - action, - from, - to, - isRoving = attributeRoving, - stopFrom = from, - onMove, -}: MoveFocusArgs): ActionResult => { +const land = ( + { e, action, from, to, onMove }: MoveArgs, + isRoving: IsRoving, + stopSource: () => Element | null, +): ActionResult => { e?.preventDefault(); if (!to || to === from) return { action, from, to: null }; // 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) @@ -305,3 +245,191 @@ export const moveFocus = ({ return move; }; + +/** + * The readings every group read through one reader answers by: the four + * resolved from options, the navigable and stop tests made of them, and the + * way to the group of a nested root. + */ +type Readings = { + isRoot: IsRoot; + readItems: ReadItems; + isRoving: IsRoving; + isNavigable: (item: Element) => boolean; + holdsStop: (item: Element) => boolean; + of: (root: Element | ShadowRoot) => Group; +}; + +/** + * A group, read lazily: each answer is read off the DOM the first time it is + * asked for, and kept for the life of the call. + * + * A class rather than an object literal for the keydown cost: its getters and + * methods live once on the prototype, so resolving a group allocates one + * object, and a press turned away before anything is read pays for nothing + * more. + */ +class ResolvedGroup implements Group { + readonly root: Element; + readonly isNavigable: (item: Element) => boolean; + readonly holdsStop: (item: Element) => boolean; + private readonly readings: Readings; + // `undefined` until first asked for. + private read: Element[] | undefined = undefined; + private sequence: Element[] | undefined = undefined; + private focus: Element | null | undefined = undefined; + private owned: Element[] | undefined = undefined; + private shared: Element[] | undefined = undefined; + private holder: Element | null | undefined = undefined; + + constructor(root: Element | ShadowRoot, readings: Readings) { + // A shadow root is read as a root with no attributes; see + // `listenerElement`. + this.root = root as Element; + this.readings = readings; + this.isNavigable = readings.isNavigable; + this.holdsStop = readings.holdsStop; + } + + private get all() { + return (this.read ??= this.readings.readItems(this.root)); + } + + get items() { + return (this.sequence ??= this.all.filter((item) => !isDisabled(item))); + } + + get focused() { + // The root answers first: focus outside it means no item can hold it, and + // the walk is skipped. + if (this.focus === undefined) { + this.focus = holdsFocus(this.root) + ? (this.items.find(holdsFocus) ?? null) + : null; + } + + return this.focus; + } + + get own() { + // Deliberately not `items`, which keep a nested root's items so the order + // runs straight through them: this is the set one stop is shared across, + // and a nested group's stop is left alone. + const { root } = this; + const { isRoot } = this.readings; + + return (this.owned ??= this.all.filter( + (item) => ownerRoot(item, root, isRoot) === root, + )); + } + + get roving() { + return (this.shared ??= this.own.filter(this.readings.isRoving)); + } + + get stop() { + if (this.holder === undefined) { + this.holder = + this.own.find((item) => !isDisabled(item) && this.holdsStop(item)) ?? + null; + } + + return this.holder; + } + + placeStop(stop: Element | null) { + placeStop(this.roving, stop); + } + + move(move: MoveArgs): ActionResult { + return land(move, this.readings.isRoving, () => + this.stopSource(move.from, move.to), + ); + } + + cross(move: MoveArgs): ActionResult { + return land(move, this.readings.isRoving, () => this.stop); + } + + /** + * The item a move from `from` to `to`, both items of this group, carries the + * stop from: `from` within one group, and the stop of the group moved into + * 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; + + const { root } = this; + const { isRoot } = this.readings; + const owner = ownerRoot(to, root, isRoot); + + if (ownerRoot(from, root, isRoot) === owner) return from; + + return owner === root ? this.stop : this.readings.of(owner).stop; + } +} + +const createReader = ( + isRoot: IsRoot, + readItems: ReadItems, + isSkipped: IsSkipped, + isRoving: IsRoving, +): GroupReader => { + const readings: Readings = { + isRoot, + readItems, + isRoving, + // The one navigable-item rule: an item a move can land on is neither + // skipped nor disabled. + isNavigable: (item) => !isDisabled(item) && !isSkipped(item), + holdsStop: (item) => + isRoving(item) && item.getAttribute('tabindex') === '0', + of: (root) => new ResolvedGroup(root, readings), + }; + + return { + at: (target, listener) => { + for (let element = target; element; element = element.parentElement) { + if (isRoot(element)) return readings.of(element); + } + + const root = listenerElement(listener); + + return root ? readings.of(root) : null; + }, + of: readings.of, + isRoot, + }; +}; + +// The reader for options that name none of the four readings — most calls, +// and every call that passes no options — made once rather than per press. +const attributeReader = createReader( + attributeRoot, + attributeItems, + attributeSkip, + attributeRoving, +); + +/** + * How a handler reads its groups. The four readings — what marks a root, what + * the items are, which a move passes over, and whether the stop roves — are + * 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 => + !options.root && + !options.items && + !options.skip && + options.rovingTabindex === undefined + ? attributeReader + : createReader( + rootTest(options), + itemsReader(options), + skipTest(options), + rovingTest(options), + ); diff --git a/packages/keyrove/src/initRovingTabindex.ts b/packages/keyrove/src/initRovingTabindex.ts index 1ad33e0..f440908 100644 --- a/packages/keyrove/src/initRovingTabindex.ts +++ b/packages/keyrove/src/initRovingTabindex.ts @@ -8,8 +8,7 @@ * user left somewhere valid stays there. */ -import { itemsReader, rootTest, rovingTest, skipTest } from './config.js'; -import { attributeRoot, ownItems, placeStop } from './group.js'; +import { readGroups } from './group.js'; import type { InitRovingTabindexOptions } from './types.js'; /** @@ -41,23 +40,15 @@ export const initRovingTabindex = ( ): Element | null => { if (!root) return null; - const isRoving = rovingTest(options); - const isSkipped = skipTest(options); - const items = ownItems( - root as Element, - itemsReader(options), - rootTest(options) ?? attributeRoot, - ).filter(isRoving); - const navigable = items.filter( - (item) => !isSkipped(item) && !item.hasAttribute('disabled'), - ); + const group = readGroups(options).of(root); + const navigable = group.roving.filter(group.isNavigable); const stop = navigable.find((item) => item === options.initial) ?? - navigable.find((item) => item.getAttribute('tabindex') === '0') ?? + navigable.find(group.holdsStop) ?? navigable[0] ?? null; - placeStop(items, stop); + group.placeStop(stop); return stop; }; diff --git a/packages/keyrove/src/keyRove.ts b/packages/keyrove/src/keyRove.ts index 5f94db9..3fbe541 100644 --- a/packages/keyrove/src/keyRove.ts +++ b/packages/keyrove/src/keyRove.ts @@ -1,15 +1,7 @@ import { buildBindings } from './bindings.js'; import { crossBoundary } from './boundary.js'; -import { readConfig, readFocusKeys, rootTest } from './config.js'; -import { - attributeRoot, - holdsFocus, - listenerElement, - moveFocus, - readGroup, - resolveRoot, - stopSource, -} from './group.js'; +import { readConfig, readFocusKeys } from './config.js'; +import { holdsFocus, listenerElement, readGroups } from './group.js'; import { resolveTarget } from './position.js'; import { hasCommandModifier, isEditableTarget, matchesCombo } from './utils.js'; import type { KeyRoveEvent, KeyRoveOptions, MoveResult } from './types.js'; @@ -56,19 +48,19 @@ export const keyRove = ( // modifier — so there is nothing to look up. if (editable && !hasCommandModifier(e)) return null; - // What marks a root is the one setting read before the root is known: it is - // what finds it. - const isRoot = rootTest(options); - const root = resolveRoot(eventTarget, e.currentTarget, isRoot); + // The group is resolved before anything else is read: its root is what the + // rest of the settings are read off. + const groups = readGroups(options); + const pressed = groups.at(eventTarget, e.currentTarget); - if (!root) return null; + if (!pressed) return null; // A move is relative to the root focus is in; a focus key names its element // outright and is heard as far as the listener reaches — across sibling // groups and out of nested roots — so its lookup spans the listener's // element, not the root. - const scope = listenerElement(e.currentTarget) ?? root; - const config = readConfig(root, options); + const scope = listenerElement(e.currentTarget) ?? pressed.root; + const config = readConfig(pressed.root, options); const { onMove } = options; // First match wins: one keypress resolves to at most one action, and the @@ -95,21 +87,20 @@ export const keyRove = ( // and its group is the one above. const group = binding.intent === 'focus' - ? (resolveRoot(binding.target.parentElement, scope, isRoot) ?? scope) - : root; - const { items: elements, focused } = readGroup(group, config.readItems); + ? (groups.at(binding.target.parentElement, scope) ?? groups.of(scope)) + : pressed; + const { items: elements, focused } = group; // A focus key on an element that is none of that group's items names a // destination in no group: there is no sibling to report as `from` or to // take the roving stop from. Focus inside the element already makes `from` // the element itself — the same consumed no-op an item's key makes. if (binding.intent === 'focus' && !elements.includes(binding.target)) { - return moveFocus({ + return group.move({ e, action: 'focus', from: holdsFocus(binding.target) ? binding.target : null, to: binding.target, - isRoving: config.isRoving, onMove, }); } @@ -119,24 +110,15 @@ export const keyRove = ( // claims its key only where there is somewhere to go: the keys it suits, // Escape and Enter, are otherwise the page's. if (binding.intent === 'exit' || binding.intent === 'enter') { - const crossing = crossBoundary( - binding.intent, - root, - scope, - focused, - config, - isRoot ?? attributeRoot, - ); + const crossing = crossBoundary(binding.intent, groups, group, scope); if (!crossing) return null; - return moveFocus({ + return crossing.group.cross({ e, action: binding.intent, from: focused, to: crossing.to, - isRoving: config.isRoving, - stopFrom: crossing.stopFrom, onMove, }); } @@ -156,7 +138,7 @@ export const keyRove = ( fromIndex: focused ? elements.indexOf(focused) : -1, layout: config.layout, pageLength: config.pageLength, - isSkipped: config.isSkipped, + isNavigable: group.isNavigable, }); // With neither a target nor a focused item, keyrove has nothing to move @@ -166,20 +148,11 @@ export const keyRove = ( // edge) — the group owns its bound keys up to its own boundary. if (!target && !focused) return null; - return moveFocus({ + return group.move({ e, action: binding.intent, from: focused, to: target, - isRoving: config.isRoving, - stopFrom: stopSource( - group, - focused, - target, - config.readItems, - isRoot ?? attributeRoot, - config.isRoving, - ), onMove, }); }; diff --git a/packages/keyrove/src/position.ts b/packages/keyrove/src/position.ts index 7609a9f..794ee2d 100644 --- a/packages/keyrove/src/position.ts +++ b/packages/keyrove/src/position.ts @@ -9,30 +9,21 @@ * table. */ -import { KEYROVE_ATTR_SKIP } from './attributes.js'; -import { hasEnabledAttribute } from './utils.js'; -import type { IsSkipped, ResolveTargetArgs } from './types.js'; +import type { ResolveTargetArgs } from './types.js'; /** - * Which items a move passes over, where the caller names no other test. The - * config layer hands it back down where an options object leaves `skip` - * unnamed. - */ -export const attributeSkip: IsSkipped = (element) => - hasEnabledAttribute(element, KEYROVE_ATTR_SKIP); - -/** - * Binds a skip test into the walk every move is made of: the first element - * `isSkipped` leaves alone, 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 pass over, so that test is bound once per resolution. + * 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. */ const scanner = - (isSkipped: IsSkipped) => + (isNavigable: ResolveTargetArgs['isNavigable']) => (elements: Element[], i: number, step: number): Element | undefined => { for (; i >= 0 && i < elements.length; i += step) { - if (!isSkipped(elements[i])) { + if (isNavigable(elements[i])) { return elements[i]; } } @@ -52,9 +43,9 @@ export const resolveTarget = ({ fromIndex, layout: { kind, cols, loop }, pageLength, - isSkipped = attributeSkip, + isNavigable, }: ResolveTargetArgs): Element | null | undefined => { - const scan = scanner(isSkipped); + const scan = scanner(isNavigable); 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 — diff --git a/packages/keyrove/src/rove.ts b/packages/keyrove/src/rove.ts index 9364243..1a275ce 100644 --- a/packages/keyrove/src/rove.ts +++ b/packages/keyrove/src/rove.ts @@ -8,15 +8,8 @@ * skips, layout and roving stop apply exactly as they do for keys. */ -import { readConfig, rootTest } from './config.js'; -import { - attributeRoot, - moveFocus, - readGroup, - resolveRoot, - stopHolder, - stopSource, -} from './group.js'; +import { readConfig } from './config.js'; +import { readGroups } from './group.js'; import { resolveTarget } from './position.js'; import type { KeyRoveOptions, MoveResult, StrideAction } from './types.js'; @@ -69,24 +62,16 @@ export const rove = ( action: StrideAction, options: KeyRoveOptions = {}, ): MoveResult | null => { - const isRoot = rootTest(options); - const root = resolveRoot(element as Element | null, element, isRoot); + const group = readGroups(options).at(element as Element | null, element); - if (!root) return null; + if (!group) return null; - const config = readConfig(root, options); + const config = readConfig(group.root, options); if (config.layout.kind === 'list' && action.endsWith('Row')) return null; - const { items: elements, focused } = readGroup(root, config.readItems); - const from = - focused ?? - stopHolder( - root, - config.readItems, - isRoot ?? attributeRoot, - config.isRoving, - ); + const { items: elements, focused } = group; + const from = focused ?? group.stop; const intent = from ? action : ENTRY[action]; if (!intent) return null; @@ -99,24 +84,10 @@ export const rove = ( fromIndex: from ? elements.indexOf(from) : 0, layout: config.layout, pageLength: config.pageLength, - isSkipped: config.isSkipped, + isNavigable: group.isNavigable, }); if (!target && !from) return null; - return moveFocus({ - action, - from, - to: target, - isRoving: config.isRoving, - stopFrom: stopSource( - root, - from, - target, - config.readItems, - isRoot ?? attributeRoot, - config.isRoving, - ), - onMove: options.onMove, - }); + return group.move({ action, from, to: target, onMove: options.onMove }); }; diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts index 7538a54..5b9b064 100644 --- a/packages/keyrove/src/types.ts +++ b/packages/keyrove/src/types.ts @@ -253,15 +253,14 @@ export type ItemAttributes = { * Every setting one move needs, resolved for the root it is navigating: * options where they name a field, the root's attributes where they do not. * The layers below take these as given and never read a source of their own. + * What the group's items are, and which of them a move lands on, are the + * group's own readings: see {@link GroupReader}. */ export type GroupConfig = { layout: Layout; explicit: ExplicitBinding; rtl: () => boolean; pageLength: number; - readItems: ReadItems; - isSkipped: IsSkipped; - isRoving: IsRoving; }; /** @@ -386,15 +385,14 @@ export type FocusKey = { }; /** - * The readings the group and position layers need but do not make themselves: - * which elements a root governs, which of them a move passes over, which - * element scopes a group, and whether the item focus is leaving carries the - * roving tab stop. + * The four readings a group is read by: which elements a root governs, which + * of them a move passes over, which element scopes a group, and whether the + * item focus is leaving carries the roving tab stop. * - * Each one defaults to the `data-keyrove-*` reading it stands for, so the - * attribute API passes none of them. They are parameters rather than reads so - * that a second config source can answer the same questions without either - * layer knowing where the answers came from. + * The group layer resolves them once from options, each falling back to the + * `data-keyrove-*` reading it stands for, so the attribute API passes none of + * them. Every layer past it asks the {@link Group} instead, and never learns + * where the answers came from. */ export type ReadItems = (root: Element) => Element[]; export type IsSkipped = (element: Element) => boolean; @@ -428,8 +426,12 @@ export type ResolveTargetArgs = { layout: Layout; /** Rows per page jump — items, in a list. */ pageLength: number; - /** Whether a move passes an item over. Defaults to the skip attribute. */ - isSkipped?: IsSkipped; + /** + * Whether a move can land on an element: the group's navigable test. The + * elements it turns down keep their place in the sequence, and their cell + * in a grid. + */ + isNavigable: (element: Element) => boolean; }; export type ToggleTabIndexArgs = { @@ -437,35 +439,114 @@ export type ToggleTabIndexArgs = { isActive: boolean; }; -/** What a root governs: its navigable items in DOM order, and the item holding focus. */ +/** 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. + * + * Read lazily: each answer is read off the DOM the first time it is asked + * for, and kept for the life of the call, so a press that is turned away + * early reads nothing it does not need, and nothing is read twice. Its two + * tests take no `this`, and can be handed on as callbacks. + */ export type Group = { - items: Element[]; - focused: Element | null; + /** + * The element the group's items are read in: a root, the listener's + * element, or a shadow root, which has no attributes. + */ + readonly root: Element; + /** + * The sequence a move strides through: the root's items in DOM order, + * disabled ones aside — `disabled` is the DOM's own word for an element that + * takes no part, whichever source named the items. A nested root's items + * keep their place in it, and so do skipped ones. + */ + readonly items: Element[]; + /** + * The item holding focus, where focus is anywhere inside it + * (`:focus-within`) — the outermost, where items nest. `null` with focus in + * none of them. + */ + readonly focused: Element | null; + /** + * The items the group governs itself: its items less those of a root nested + * inside it, which belong to that root — an item that is itself a root + * belongs to the group around it. Disabled ones included. + */ + readonly own: Element[]; + /** The own items that carry the roving stop: the set one stop is shared across. */ + readonly roving: Element[]; + /** + * The own item holding the roving stop, disabled ones aside: the first that + * {@link Group.holdsStop}. `null` where the group has none. + */ + readonly stop: Element | null; + /** Whether a move can land on an item: one neither skipped nor disabled. */ + isNavigable: (item: Element) => boolean; + /** Whether an item holds the roving stop: it carries it, and has `tabindex="0"`. */ + holdsStop: (item: Element) => boolean; + /** + * Gives `stop` the group's one `tabindex="0"` and every other roving item + * `-1` — or all of them `-1` where `stop` is `null`. A nested group's stop + * is left alone. Only attributes that change are written, so a group already + * in order takes no mutations. + */ + placeStop(stop: Element | null): void; + /** + * Lands a move through the group's order. The order runs on through a + * nested root's items, but each group keeps a stop of its own: a move within + * one group carries the stop from `from`, and a move into another carries + * that group's own stop, leaving the stop of the group focus left where it + * is. A move with no `from` enters from outside, and carries nothing. + */ + move(move: MoveArgs): ActionResult; + /** + * Lands a move into this group from the one next to it, across a nested + * root's boundary: the stop carried is this group's own, wherever focus + * came from, and the group focus left keeps its stop for the way back. + */ + cross(move: MoveArgs): ActionResult; +}; + +/** + * How a handler reads its groups: the four readings, resolved once from its + * options, and the two ways to a group — from where an event happened, or from + * a root. + */ +export type GroupReader = { + /** + * The group an event at `target` is in: the nearest root at or above it, + * else the listener's element. `null` with neither. Resolving from the + * target rather than the listener is what lets one delegated listener serve + * several groups, and lets a nested root win while focus is in it. + */ + at: ( + target: Element | null | undefined, + listener: EventTarget | null | undefined, + ) => Group | null; + /** The group `root` governs: an element, or a shadow root a listener is attached to. */ + of: (root: Element | ShadowRoot) => Group; + /** Whether an element is a root, for the walks from one group to the next. */ + isRoot: IsRoot; }; /** * 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) and the one it lands - * on (nullish when there is nowhere to go). Generic in the action so each - * handler's result comes back exactly typed. + * 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}. */ -export type MoveFocusArgs = { +export type MoveArgs = { /** The keypress to claim. A move made from code has none. */ e?: Pick; action: Action; from: Element | null; to: Element | null | undefined; - /** - * Whether the item focus is leaving carries the roving tab stop. Defaults to - * the roving-tabindex attribute. - */ - isRoving?: IsRoving; - /** - * The item the roving stop is carried from, where that is not `from`: a - * move onto a nested group's item, or across a nested root's boundary, - * lands in another group, whose own stop moves while the group focus left - * keeps its. Nullish carries nothing. - */ - stopFrom?: Element | null; onMove?: (move: ActionResult & { to: Element }) => void; }; From 1b1c1aa66df483f0d7ec15bbfb1d2de714bf0a93 Mon Sep 17 00:00:00 2001 From: mixedrays Date: Thu, 15 Oct 2026 21:56:59 +0200 Subject: [PATCH 02/13] refactor(docs): define each demo in one place A demo's behaviour was spread over five tables keyed by its name in three files, and the runtime checked the DOM again for what the build had decided. - build/demo-definitions.ts holds one entry per demo: group options, extra handlers and icons. The build stamps icons from it and src/demos.ts wires the demo from it; HISTORY, BAND, ITEMS, EXTRAS and CONFIGS are gone. - Every demo is a panel: the unused history and line logs, their markup and src/demos.css are removed. - src/readout.ts is the one readout for the panels and the hero, with its initial markup, nameOf and isMoveResult written once. The hero keeps its easter eggs behind a flag and uses the panels' format. - Placeholders parse in any attribute order, and a malformed one fails the build. hasDemos is removed. - The page's first live demo takes focus, so the hero no longer has to mount before the panels. --- DEVELOPMENT.md | 52 +- packages/docs/build/demo-definitions.ts | 182 +++ packages/docs/build/demo-icons.ts | 123 +- packages/docs/build/demos.ts | 261 ++-- packages/docs/build/html-tree.ts | 4 +- packages/docs/content/index.md | 2 +- packages/docs/src/app.css | 6 +- packages/docs/src/components/markdown.tsx | 5 +- packages/docs/src/demo-panel.css | 8 +- packages/docs/src/demos.css | 1636 --------------------- packages/docs/src/demos.ts | 523 +------ packages/docs/src/hero.ts | 171 +-- packages/docs/src/readout.ts | 241 +++ packages/docs/src/style.css | 12 +- packages/docs/tests/demos.test.ts | 92 ++ 15 files changed, 774 insertions(+), 2544 deletions(-) create mode 100644 packages/docs/build/demo-definitions.ts delete mode 100644 packages/docs/src/demos.css create mode 100644 packages/docs/src/readout.ts create mode 100644 packages/docs/tests/demos.test.ts diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5b5a503..50882a2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -166,26 +166,8 @@ keys as well while keyboard focus is inside it. ### Live demos -A content file embeds a demo with `
`. The markup -behind it is one file under -[packages/docs/content/_demos](packages/docs/content/_demos), named after the -demo. [packages/docs/build/demos.ts](packages/docs/build/demos.ts) stamps it -into the page twice, once as live elements and once as the source block under -them, so the two cannot drift. Repeated items wrapped in `` … -`` still run but collapse to one comment in the source block, -which keeps a 24-item list from costing 24 lines of a page that is also served -as markdown. - -Layout the demo needs but the library does not teach — the grid's columns, the -long list's scroll box — goes in `data-demo-class` on the placeholder rather -than in the fragment, so what a reader copies is markup they can paste as-is. -[packages/docs/src/demos.ts](packages/docs/src/demos.ts) wires the behaviour -markup cannot carry: the keydown listener and the move log. React draws the -panel around a demo, with its code tabs and copy button, but leaves the live -preview as the fragment's own markup, which keyrove rearranges as keys are -pressed. - -For a compact **demo panel** on any docs page, add a label: +A content file embeds a demo with a placeholder that names it and labels its +panel: ````md
@@ -198,11 +180,35 @@ menu.addEventListener('keydown', (e) => keyRove(e)); ``` ```` +A demo is two things, both named after it: its markup, one file under +[packages/docs/content/_demos](packages/docs/content/_demos), and its entry in +[packages/docs/build/demo-definitions.ts](packages/docs/build/demo-definitions.ts), +which declares what it adds to navigation — group options, extra handlers +such as typeahead, and decorative icons. A fragment without an entry, or an +entry without a fragment, fails the docs tests. + +[packages/docs/build/demos.ts](packages/docs/build/demos.ts) stamps the +fragment into the page twice, once as live elements and once as the source +block under them, so the two cannot drift. Repeated items wrapped in +`` … `` still run but collapse to one comment in the +source block, which keeps a 24-item list from costing 24 lines of a page that +is also served as markdown. + The panel includes a live preview, a single-line readout, and tabs for the fragment's HTML and the adjacent code blocks. It uses the opposite theme to -the page and keeps long code blocks scrollable. Omit `data-demo-label` to use -the existing detailed demo presentation. `data-demo-class`, when needed, -goes before `data-demo-label`. +the page and keeps long code blocks scrollable. Layout the demo needs but the +library does not teach — the grid's columns, the long list's scroll box — goes +in `data-demo-class` on the placeholder rather than in the fragment, so what a +reader copies is markup they can paste as-is. The attributes go in any order; +a placeholder the build cannot read fails it. + +[packages/docs/src/demos.ts](packages/docs/src/demos.ts) wires the behaviour +markup cannot carry: the keydown listener and the extras the entry lists. +[packages/docs/src/readout.ts](packages/docs/src/readout.ts) writes the +readout, for the panels and the landing page's hero alike. React draws the +panel around a demo, with its code tabs and copy button, but leaves the live +preview as the fragment's own markup, which keyrove rearranges as keys are +pressed. [demo-panel.css](packages/docs/src/demo-panel.css) owns the shared presentation; the embedding page owns column widths and spacing. No hero or landing classes diff --git a/packages/docs/build/demo-definitions.ts b/packages/docs/build/demo-definitions.ts new file mode 100644 index 0000000..7c93a33 --- /dev/null +++ b/packages/docs/build/demo-definitions.ts @@ -0,0 +1,182 @@ +import type { GroupOptions } from '@mixedrays/keyrove'; + +import type { IconName } from './icons.ts'; + +/** + * What each docs demo is beyond its markup, in one entry per demo. + * + * A demo is a fragment under content/_demos and the entry here of the same + * name. The fragment is what a reader copies; the entry is everything a page + * adds to make it run and read well. build/demos.ts reads an entry to stamp + * the demo, and src/demos.ts reads it to wire one, so neither has a table of + * demo names of its own to keep in step with the other. + * + * Nothing here touches the filesystem, and the types are all it takes from + * keyrove and the icon set, so the browser bundle imports it too. + */ + +/** + * Behaviour a demo runs beyond navigation, chained after `keyRove` in the + * order listed, so a key one leaves alone falls through to the next exactly + * as the pages' own `||` chains do. src/demos.ts implements each one. + * + * - `typeahead` finds an item by its label, with the demo's `group`. + * - `selection` is the listbox's pick: Space, Enter or a click. + * - `folds` opens and closes the sidebar's folders. + * - `branches` opens, closes and steps through the tree's folders. + */ +export type DemoExtra = 'typeahead' | 'selection' | 'folds' | 'branches'; + +/** One icon, or a folder's pair, which `aria-expanded` picks between. */ +export type ItemIcon = IconName | { closed: IconName; open: IconName }; + +/** A live item, as an entry's `icons` sees it. */ +export type DemoItem = { + /** The item's text, trimmed. */ + label: string; + /** Whether it carries `aria-expanded`: a folder rather than a file. */ + expandable: boolean; +}; + +export type DemoDefinition = { + /** + * The settings the group is described with, for a demo described in + * JavaScript rather than in markup. Handed to every handler exactly as the + * page's snippet hands it to them. Without one, the group is configured by + * its own attributes, which is what every other page teaches. + */ + group?: GroupOptions; + /** What runs after navigation, in order. Without any, navigation alone. */ + extras?: readonly DemoExtra[]; + /** + * Decorative icons for the live preview, by item label or worked out per + * item. Presentation only: the source block stays the fragment as written, + * and the icons are `aria-hidden`, so labels and typeahead are unchanged. + * Only items whose content is plain text are decorated. + */ + icons?: Readonly> | ((item: DemoItem) => ItemIcon); +}; + +const FOLDERS: Record = { + Inbox: 'inbox', + Drafts: 'drafts', + Sent: 'sent', +}; + +const TOOLS: Record = { + Move: 'pointer', + Frame: 'frame', + Rectangle: 'square', + Ellipse: 'circle', + Pen: 'pen', + Text: 'type', + Eyedropper: 'pipette', + Hand: 'hand', +}; + +/** + * Every demo, by the name of its file under content/_demos. + * + * Typeahead is opted into rather than given to every list: on a page that + * never mentions it, letters doing something would be a surprise. + */ +export const DEMO_DEFINITIONS: Readonly> = { + editable: {}, + grid: {}, + keys: { + icons: { + Bold: 'bold', + Italic: 'italic', + Underline: 'underline', + Link: 'link', + Code: 'code', + }, + }, + labels: { extras: ['typeahead'] }, + 'landing-grid': {}, + 'landing-horizontal': { icons: FOLDERS }, + 'landing-loop': { icons: FOLDERS }, + 'landing-roving': { icons: FOLDERS }, + 'landing-tools': { icons: TOOLS }, + list: {}, + listbox: { extras: ['typeahead', 'selection'] }, + loop: {}, + // Described in JavaScript, so its typeahead is handed the same object as + // navigation, which is the point the options page makes. + menu: { + group: { items: '[role="menuitem"]', loop: true, rovingTabindex: true }, + extras: ['typeahead'], + icons: { + 'Copy link': 'link', + 'Email a copy': 'mail', + 'Export as PDF': 'file-download', + 'Post to Slack': 'message', + 'Open in Figma': 'pen', + }, + }, + // The reaction buttons already have emoji; only the menu rows get icons. + nested: { + icons: { + Reply: 'reply', + 'Reply in thread': 'message', + 'Copy link': 'link', + 'Pin to channel': 'pin', + 'Delete message': 'trash', + }, + }, + orientation: {}, + panes: {}, + responsive: {}, + roving: { + icons: { + Backlog: 'circle-dashed', + 'To do': 'circle', + Blocked: 'circle-slash', + Review: 'circle-dot', + Done: 'circle-check', + Archived: 'archive', + }, + }, + rtl: {}, + sidebar: { extras: ['folds'] }, + skip: { + icons: { + 'quarterly-report.pdf': 'file-text', + 'budget-v3-final-FINAL.xlsx': 'file-spreadsheet', + 'roadmap.md': 'file-code', + 'design-review.fig': 'pen', + 'pension-fund.xls': 'file-spreadsheet', + }, + }, + tools: { icons: TOOLS }, + // A row inside a closed folder is still in the DOM, so it is still an item; + // `skip` is what keeps a move from landing on one nobody can see. + tree: { + group: { + items: '[role="treeitem"]', + skip: '[hidden] [role="treeitem"]', + rovingTabindex: true, + }, + extras: ['typeahead', 'branches'], + icons: ({ label, expandable }) => + expandable + ? { closed: 'folder', open: 'folder-open' } + : label.endsWith('.json') + ? 'file-json' + : /\.(tsx?|css)$/.test(label) + ? 'file-code' + : 'file-text', + }, + typeahead: { extras: ['typeahead'] }, +}; + +/** The entry for a demo, or a build-stopping error naming the one missing. */ +export const demoDefinition = (name: string): DemoDefinition => { + if (!Object.hasOwn(DEMO_DEFINITIONS, name)) { + throw new Error( + `[docs] no demo named "${name}" in build/demo-definitions.ts.`, + ); + } + + return DEMO_DEFINITIONS[name]; +}; diff --git a/packages/docs/build/demo-icons.ts b/packages/docs/build/demo-icons.ts index 9fec51f..b1ac4d2 100644 --- a/packages/docs/build/demo-icons.ts +++ b/packages/docs/build/demo-icons.ts @@ -1,97 +1,42 @@ -import { icon, type IconName } from './icons.ts'; - -const FOLDERS: Record = { - Inbox: 'inbox', - Drafts: 'drafts', - Sent: 'sent', -}; - -const TOOLS: Record = { - Move: 'pointer', - Frame: 'frame', - Rectangle: 'square', - Ellipse: 'circle', - Pen: 'pen', - Text: 'type', - Eyedropper: 'pipette', - Hand: 'hand', -}; - -const ITEMS: Record> = { - 'landing-loop': FOLDERS, - 'landing-horizontal': FOLDERS, - 'landing-roving': FOLDERS, - roving: { - Backlog: 'circle-dashed', - 'To do': 'circle', - Blocked: 'circle-slash', - Review: 'circle-dot', - Done: 'circle-check', - Archived: 'archive', - }, - skip: { - 'quarterly-report.pdf': 'file-text', - 'budget-v3-final-FINAL.xlsx': 'file-spreadsheet', - 'roadmap.md': 'file-code', - 'design-review.fig': 'pen', - 'pension-fund.xls': 'file-spreadsheet', - }, - menu: { - 'Copy link': 'link', - 'Email a copy': 'mail', - 'Export as PDF': 'file-download', - 'Post to Slack': 'message', - 'Open in Figma': 'pen', - }, - tools: TOOLS, - 'landing-tools': TOOLS, - keys: { - Bold: 'bold', - Italic: 'italic', - Underline: 'underline', - Link: 'link', - Code: 'code', - }, - nested: { - Reply: 'reply', - 'Reply in thread': 'message', - 'Copy link': 'link', - 'Pin to channel': 'pin', - 'Delete message': 'trash', - }, -}; +import type { DemoDefinition, ItemIcon } from './demo-definitions.ts'; +import { icon } from './icons.ts'; // Only plain-text demo items are decorated. The nested reaction buttons // already have emoji; grids use their time and month labels on their own. const ITEM = /<(li|button|div)\b([^>]*\b(?:data-keyrove-item|role="(?:menuitem|treeitem)")[^>]*)>([^<]*)<\/\1>/g; +const draw = (glyph: ItemIcon) => + typeof glyph === 'string' + ? icon(glyph, 'demo-item-icon') + : icon(glyph.closed, 'demo-item-icon demo-item-icon--closed') + + icon(glyph.open, 'demo-item-icon demo-item-icon--open'); + /** - * Add decorative icons to the live preview at build time. Like the detailed - * demos' CSS icons, these are presentation: copied navigation examples stay - * minimal and the aria-hidden SVGs leave labels and typeahead unchanged. + * Add a demo definition's decorative icons to the live preview at build time. + * Copied navigation examples stay minimal, and the aria-hidden SVGs leave + * labels and typeahead unchanged. */ -export const decorateDemo = (name: string, markup: string): string => - markup.replace(ITEM, (original, tag: string, attrs: string, text: string) => { - const label = text.trim(); - let glyph: string; - if (name === 'tree') { - if (attrs.includes('aria-expanded=')) { - glyph = - icon('folder', 'demo-item-icon demo-item-icon--closed') + - icon('folder-open', 'demo-item-icon demo-item-icon--open'); - } else { - const fileIcon = label.endsWith('.json') - ? 'file-json' - : /\.(tsx?|css)$/.test(label) - ? 'file-code' - : 'file-text'; - glyph = icon(fileIcon, 'demo-item-icon'); - } - } else { - const itemIcon = ITEMS[name]?.[label]; - if (!itemIcon) return original; - glyph = icon(itemIcon, 'demo-item-icon'); - } - return `<${tag}${attrs}>${glyph}${text}`; - }); +export const decorateDemo = ( + markup: string, + icons: DemoDefinition['icons'], +): string => { + if (!icons) return markup; + + return markup.replace( + ITEM, + (original, tag: string, attrs: string, text: string) => { + const label = text.trim(); + const glyph = + typeof icons === 'function' + ? icons({ label, expandable: attrs.includes('aria-expanded=') }) + : Object.hasOwn(icons, label) + ? icons[label] + : undefined; + + return glyph + ? `<${tag}${attrs}>${draw(glyph)}${text}` + : original; + }, + ); +}; diff --git a/packages/docs/build/demos.ts b/packages/docs/build/demos.ts index 45dbce3..6728edf 100644 --- a/packages/docs/build/demos.ts +++ b/packages/docs/build/demos.ts @@ -2,19 +2,22 @@ import { readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { READOUT_HTML } from '../src/readout.ts'; +import { demoDefinition } from './demo-definitions.ts'; import { decorateDemo } from './demo-icons.ts'; -import { copyButton } from './markdown.ts'; /** * Live demos, built from the markup the page documents. * - * One file under `content/_demos` is a demo. It is stamped into the page twice - * — once as real elements a reader can arrow through, once as the source block - * under them — so the two cannot drift: what is on the clipboard is what was - * just pressed a key in. + * One file under `content/_demos` is a demo, with an entry of the same name in + * build/demo-definitions.ts. It is stamped into the page twice — once as real + * elements a reader can arrow through, once as the source block under them — + * so the two cannot drift: what is on the clipboard is what was just pressed a + * key in. * - * A content file writes only the placeholder, `
`, - * and the fragment carries no site classes: styling hangs off the same + * A content file writes only the placeholder, + * `
`, and the + * fragment carries no site classes: styling hangs off the same * `data-keyrove-*` attributes the docs teach, which is what keeps a fragment * markup a reader could paste as-is. * @@ -50,13 +53,9 @@ const FENCE = '```'; /** * The placeholder a content file writes, alone on its line, and the fenced - * blocks directly under it. - * - * `data-demo-class` is layout the demo needs but the library does not teach — - * the grid's columns, the long list's scroll box. It lands on the surface at - * render time rather than in the fragment, so it stays out of the source block. - * `data-demo-label` opts into a compact demo panel and names its live preview. - * It works on any page; omit it to keep the demo's detailed presentation. + * blocks directly under it. Any line opening a `
` with a `data-demo*` + * attribute is taken for one, so a placeholder written wrong fails the build + * (see `parsePlaceholder`) rather than reaching the page as an empty `
`. * * The blocks are the demo's other files, and they are taken with it so they * can share its panel. HTML is left where it stands: a demo has one HTML file, @@ -64,124 +63,68 @@ const FENCE = '```'; * custom keys picks out the two attributes of its root — not a second one. */ const PLACEHOLDER = new RegExp( - String.raw`^
<\/div>$` + + String.raw`^(<\/output>$/gm; /** - * The demos whose log runs across the foot of the preview rather than up its - * right-hand side. + * What a placeholder says, read off its attributes in whatever order. * - * A history costs the stage half its width. A vertical list can spare it — it - * was never using the space to the right of its longest item — but a - * horizontal one is spending that width on the thing it is demonstrating: a - * toolbar given half a preview wraps its buttons onto a second row, and a - * filter bar that wraps has stopped being the shape the page is about. A grid - * is the same argument with a second axis: its columns are the thing being - * navigated, and six of them squeezed into half a preview read as a column of - * crushed cells rather than as rows worth pressing an arrow across. The - * responsive grid puts it strongest: the reader drags that panel to change its - * column count, so its width is not the room the demo needs but the demo - * itself, and a rail would have spent half of it before they started. Those - * demos keep the band across the foot, which is the layout the narrow screens - * already use, at every width instead. - * - * This is a list of names rather than something read off the fragment because - * both spellings of "horizontal" are in use — an orientation on the filter - * bars, an explicit ArrowRight on the toolbar — and a layout that turned on - * which one a fragment happened to choose would be a trap for whoever edited - * it next. What the CSS keys off is the attribute stamped below, so the - * stylesheet stays free of demo names. + * - `data-demo` names the demo. + * - `data-demo-label` names the panel's live preview; every demo has one. + * - `data-demo-class`, optional, is layout the demo needs but the library + * does not teach — the grid's columns, the long list's scroll box. It lands + * on the surface at render time rather than in the fragment, so it stays + * out of the source block. */ -const BAND = new Set([ - 'keys', - 'orientation', - 'rtl', - 'panes', - 'grid', - 'responsive', +type Placeholder = { name: string; label: string; surfaceClass: string }; + +const PLACEHOLDER_ATTRIBUTES = new Set([ + 'data-demo', + 'data-demo-label', + 'data-demo-class', ]); +const ATTRIBUTE = /\s+([\w-]+)="([^"]*)"/g; + /** - * The log under — or beside — a demo's preview. - * - * The history's row is stamped here as a