/** * Roving-tabindex keyboard helpers shared by the composite widgets in the admin * (radiogroups and the section tablist). * * A composite widget exposes exactly one tab stop: the selected item carries * `tabIndex={0}` and every other item carries `tabIndex={-1}`. Arrow keys move * between items inside the widget, so the helpers here only need to answer * "which index does this key land on". */ /** Which arrow keys a widget claims. Unclaimed keys must keep their default behavior. */ export type RovingOrientation = 'horizontal' | 'vertical' | 'both'; /** * Resolve the writing direction that applies to `element`. * * The nearest explicit `dir` attribute wins (this is also the only signal jsdom * reports), otherwise fall back to the computed style so a direction inherited * from CSS is still honored. */ export function isRtlElement(element: Element | null | undefined): boolean { if (!element) { return false; } const explicitDir = element.closest('[dir]')?.getAttribute('dir')?.toLowerCase(); if (explicitDir === 'rtl') { return true; } if (explicitDir === 'ltr') { return false; } try { const view = element.ownerDocument?.defaultView; return view ? view.getComputedStyle(element).direction === 'rtl' : false; } catch { return false; } } /** * Map a key press onto the index it should move to, or `null` when the widget * does not claim the key. Callers must only call `preventDefault()` for a * non-null result, otherwise a horizontal tablist would swallow page scrolling. * * Movement wraps at both ends, matching the WAI-ARIA radiogroup and tablist * patterns. */ export function resolveRovingIndex( key: string, currentIndex: number, count: number, orientation: RovingOrientation, rtl: boolean, ): number | null { if (count <= 0) { return null; } const origin = currentIndex >= 0 && currentIndex < count ? currentIndex : 0; const step = (delta: number): number => (origin + delta + count) % count; const claimsHorizontal = orientation !== 'vertical'; const claimsVertical = orientation !== 'horizontal'; switch (key) { case 'ArrowRight': return claimsHorizontal ? step(rtl ? -1 : 1) : null; case 'ArrowLeft': return claimsHorizontal ? step(rtl ? 1 : -1) : null; case 'ArrowDown': return claimsVertical ? step(1) : null; case 'ArrowUp': return claimsVertical ? step(-1) : null; case 'Home': return 0; case 'End': return count - 1; default: return null; } } /** * The tab stop of a composite widget. The selected item owns it; when nothing is * selected the first item does, so the widget always stays reachable by Tab. */ export function rovingTabIndex(isSelected: boolean, index: number, selectedIndex: number): 0 | -1 { if (isSelected) { return 0; } return selectedIndex < 0 && index === 0 ? 0 : -1; }