// Keyboard and screen-reader plumbing shared by the interactive components. // The decision logic is pure (and tested); only the thin shells touch the DOM. import type { Direction } from '@ultimat3/i18n'; import { handlesOwnArrowKeys } from './roving'; export const FOCUSABLE_SELECTOR = [ 'a[href]', 'area[href]', 'button:not([disabled])', 'input:not([disabled]):not([type="hidden"])', 'select:not([disabled])', 'textarea:not([disabled])', 'summary', 'iframe', 'audio[controls]', 'video[controls]', '[contenteditable]:not([contenteditable="false"])', '[tabindex]:not([tabindex="-1"])', ].join(','); let idCounter = 0; let islandScope: string | undefined; /** * The part of an id that tells two copies of this module apart. Each island is its own bundle by * default (`islands.sharedChunks` off — measured: every island chunk inlines this counter; with it * on, one shared chunk holds it), so each island counts from zero and two of them both minted * `field-1`. In a browser every copy draws its own scope once; a server render has one copy and no * scope, so a document's ids carry no random part. */ function scope(): string { if (typeof document === 'undefined') return ''; if (islandScope === undefined) { const [draw = 0] = crypto.getRandomValues(new Uint32Array(1)); islandScope = draw.toString(36); } return `${islandScope}-`; } /** * Unique id for label/description wiring. Prefixed so a mismatch is obvious in the DOM. * * On the server it comes from a PROCESS-wide counter: every id in one document is distinct, which * is all `for`/`aria-describedby` need, but two renders of one page mint different numbers — a * render-scoped counter needs a seam in `@ultimat3/render`. An island never reuses a server id: it * replaces its shell rather than hydrating it, and its own ids carry its scope (above). */ export function useId(prefix = 'u'): string { idCounter += 1; return `${prefix}-${scope()}${idCounter.toString(36)}`; } /** Test-only: make id assertions deterministic. */ export function resetUseIdCounter(): void { idCounter = 0; islandScope = undefined; } /** * `aria-busy`, `aria-checked`, `aria-disabled`, `aria-expanded`, `aria-invalid`, * `aria-pressed` and `aria-selected` are ENUMERATED attributes, not boolean ones: their * values are the literal strings "true" and "false", and an absent attribute is a third, * different state. Converting at the DOM boundary keeps `undefined` meaning "omit" instead * of collapsing it into "false". */ export function ariaBool(value: boolean | undefined): 'true' | 'false' | undefined { if (value === undefined) return undefined; return value ? 'true' : 'false'; } export function focusableWithin(root: ParentNode): HTMLElement[] { return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter( (el) => el.offsetParent !== null || el.getClientRects().length > 0, ); } export interface FocusTrap { activate(): void; release(): void; } /** * Cycles Tab within `root`, moves focus into it on `activate`, and restores focus to the element * that had it on `release` — what `Menu` and `Popover` need, because a panel unmounted with focus * inside it resets focus to `` and the next Tab restarts at the top of the document. * * The listener sits on `document`, not on `root`: a keydown while focus is OUTSIDE never reaches * `root`, so a trap listening on its own root can never recapture focus that already left — the * branch that pulls it back was unreachable by construction. Every branch still tests * `root.contains`, so the trap only acts while it is the active one. */ export function focusTrap(root: HTMLElement): FocusTrap { let previous: HTMLElement | null = null; /** * The empty-panel fallback, and the reason it needs a line of its own: a plain `
` is not * focusable, so `focus()` on it is a no-op that reports nothing — and `Menu` and `Popover` both * hand this trap exactly that. `tabindex="-1"` is the one value that makes an element reachable * programmatically without putting it in the Tab order, so the root never becomes a stop of its * own (`FOCUSABLE_SELECTOR` excludes `-1`). A root that already declares one keeps it. */ function focusRoot(): void { if (root.getAttribute('tabindex') === null) root.tabIndex = -1; root.focus(); } function onKeyDown(event: KeyboardEvent): void { if (event.key !== 'Tab') return; const items = focusableWithin(root); if (items.length === 0) { event.preventDefault(); focusRoot(); return; } const first = items[0] as HTMLElement; const last = items[items.length - 1] as HTMLElement; const active = document.activeElement; const outside = !root.contains(active); if (event.shiftKey && (outside || active === first)) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && (outside || active === last)) { event.preventDefault(); first.focus(); } } return { activate() { previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; document.addEventListener('keydown', onKeyDown); const first = focusableWithin(root)[0]; if (first === undefined) focusRoot(); else first.focus(); }, release() { document.removeEventListener('keydown', onKeyDown); previous?.focus(); }, }; } export type RovingOrientation = 'horizontal' | 'vertical' | 'both'; export interface RovingOptions { orientation?: RovingOrientation; dir?: Direction; loop?: boolean; } /** * Pure roving-tabindex reducer. Returns the next index, or the current one when * the key is not a navigation key. Inline arrows invert under `dir: 'rtl'`. */ export function nextRovingIndex( current: number, key: string, count: number, options: RovingOptions = {}, ): number { if (count <= 0) return -1; const { orientation = 'horizontal', dir = 'ltr', loop = true } = options; const inline = orientation !== 'vertical'; const block = orientation !== 'horizontal'; const forward = dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; const backward = dir === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; let step = 0; if (inline && key === forward) step = 1; else if (inline && key === backward) step = -1; else if (block && key === 'ArrowDown') step = 1; else if (block && key === 'ArrowUp') step = -1; else if (key === 'Home') return 0; else if (key === 'End') return count - 1; else return current; const raw = current + step; if (loop) return (raw + count) % count; return Math.min(count - 1, Math.max(0, raw)); } /** Wire a roving group: one tabbable item, arrows move focus and selection. */ export function rovingTabindex( getItems: () => readonly HTMLElement[], options: RovingOptions = {}, ): (event: KeyboardEvent) => void { return (event) => { const items = getItems(); const active = document.activeElement; // A control with its own arrow-key behaviour keeps it. A group cannot know whether the caret // in the Input it contains was about to move, so it never guesses — it declines. if (handlesOwnArrowKeys(active)) return; // Focus may sit on nothing, on ``, or on a non-HTML element (an SVG child): none of // those are in `items`, and all of them mean "start from the first item". const current = active instanceof HTMLElement ? items.indexOf(active) : -1; const start = Math.max(current, 0); const next = nextRovingIndex(start, event.key, items.length, options); // Compared against `start`, the value the reducer was actually given — not the unclamped // `current`. A key the reducer does not navigate answers its own input, so comparing against // `-1` made every such key (Tab included) look like a move and steal focus out of whatever // held it whenever the active element was not one of the group's items. if (next === start || next < 0) return; event.preventDefault(); for (const [index, item] of items.entries()) { item.tabIndex = index === next ? 0 : -1; } items[next]?.focus(); }; } export type Politeness = 'polite' | 'assertive'; /** * One region per politeness level — this plus `-polite` / `-assertive` is the id — and this on its * own is the CLASS both of them wear, because they are hidden identically. * * A class, and not the `region.style.cssText` this wrote until 19.1.3: a style a script applies at * runtime is not among the inline `