tab-cycle: the walk's rules move onto deepTabbables
commit 75428b734b4d48a810d81c2604de61079fe18bf1 · parent e5e792966c
· browse this tree
Ximianth, 2026-10-18 10:49:22 UTC
The header's attribute-based-walk paragraph is deepTabbables' contract and now lives on it; every other documented function and the installed set get their comments as /** */ blocks. The header keeps the purpose and the install-once overview. Wording is unchanged apart from name-first prefixes; lines break at clauses.
diff --git a/packages/yukey/src/tab-cycle.ts b/packages/yukey/src/tab-cycle.ts index e43c250..abbc946 100644 --- a/packages/yukey/src/tab-cycle.ts +++ b/packages/yukey/src/tab-cycle.ts @@ -1,18 +1,9 @@ -// Shadow-DOM-aware Tab containment for page shells: the kit's default is that -// the Tab key never leaves the page and wanders into browser chrome (the -// guidelines' "Focus containment and lifecycle" norm). A shell installs it -// once with installTabContainment(document); the helpers underneath -// (deepTabbables, deepActiveElement, containTab) stay exported for tests and -// for shells that need the walk itself (the gallery seats focus with it). -// -// The walk is attribute-based (tabIndex >= 0 on the usual focusable tags plus -// anything with an explicit tabindex), not layout-based: it does not check -// visibility or computed styles. That is deliberate -- the callers' content is -// megacolumn containers and native buttons, all visible by construction, and -// staying layout-free keeps the walk cheap and jsdom-testable. aria-hidden -// subtrees are skipped: components here hide closed content that way -// (yk-live-list's collapsing rows), and a tab stop invisible to AT must not be -// a tab stop at all. +// Shadow-DOM-aware Tab containment for page shells: +// the kit's default is that the Tab key never leaves the page and wanders into browser chrome +// (the guidelines' "Focus containment and lifecycle" norm). +// A shell installs it once with installTabContainment(document); +// the helpers underneath (deepTabbables, deepActiveElement, containTab) stay exported for tests +// and for shells that need the walk itself (the gallery seats focus with it). function isTabbable(el: Element): boolean { if (!(el instanceof HTMLElement)) return false; @@ -21,7 +12,17 @@ function isTabbable(el: Element): boolean { return el.matches("a[href], button, input, select, textarea, [contenteditable='true']"); } -// All tab stops under `root` in tree order, descending open shadow roots. +/** + * deepTabbables is all tab stops under `root` in tree order, descending open shadow roots. + * + * The walk is attribute-based (tabIndex >= 0 on the usual focusable tags plus anything with an explicit tabindex), + * not layout-based: it does not check visibility or computed styles. + * That is deliberate -- the callers' content is megacolumn containers and native buttons, all visible by construction, + * and staying layout-free keeps the walk cheap and jsdom-testable. + * aria-hidden subtrees are skipped: + * components here hide closed content that way (yk-live-list's collapsing rows), + * and a tab stop invisible to AT must not be a tab stop at all. + */ export function deepTabbables(root: ParentNode): HTMLElement[] { const out: HTMLElement[] = []; const walk = (node: ParentNode): void => { @@ -36,7 +37,7 @@ export function deepTabbables(root: ParentNode): HTMLElement[] { return out; } -// The really-focused element, descending the shadow activeElement chain. +/** deepActiveElement is the really-focused element, descending the shadow activeElement chain. */ export function deepActiveElement(doc: Document): Element | null { let active = doc.activeElement; while (active?.shadowRoot?.activeElement != null) { @@ -45,11 +46,13 @@ export function deepActiveElement(doc: Document): Element | null { return active; } -// Tab containment: keep Tab cycling within the document instead of escaping to -// the browser chrome. Only the edges are intercepted (last stop wrapping to -// first, first back to last); interior Tabs stay native, so within-page traversal -// is exactly the browser's own. Focus outside the tab order (a text-entry dive -// into a tabindex=-1 input) is left alone: Escape/Enter own that exit path. +/** + * containTab is Tab containment: keep Tab cycling within the document instead of escaping to the browser chrome. + * Only the edges are intercepted (last stop wrapping to first, first back to last); + * interior Tabs stay native, so within-page traversal is exactly the browser's own. + * Focus outside the tab order (a text-entry dive into a tabindex=-1 input) is left alone: + * Escape/Enter own that exit path. + */ export function containTab(e: KeyboardEvent, doc: Document): void { if (e.key !== "Tab") return; const stops = deepTabbables(doc.body); @@ -63,14 +66,17 @@ export function containTab(e: KeyboardEvent, doc: Document): void { (e.shiftKey ? last : first).focus(); } -// Documents where containment is already installed. A WeakSet (not a flag on -// the document) so tests with fresh jsdom documents never see stale state. +/** + * installed is the documents where containment is already installed. + * A WeakSet (not a flag on the document) so tests with fresh jsdom documents never see stale state. + */ const installed = new WeakSet<Document>(); -// Install Tab containment on a document: the one call a page shell makes to -// get the kit's default. Idempotent per document (repeat calls add nothing and -// return false); the listener lives for the document's life, matching the -// shells that call it (they never uninstall themselves either). +/** + * installTabContainment installs Tab containment on a document: the one call a page shell makes to get the kit's default. + * Idempotent per document (repeat calls add nothing and return false); + * the listener lives for the document's life, matching the shells that call it (they never uninstall themselves either). + */ export function installTabContainment(doc: Document): boolean { if (installed.has(doc)) return false; installed.add(doc);