/** * `ScrollRegion` — a horizontally scrollable container that becomes a keyboard * target ONLY while it actually overflows. * * A pane that scrolls must be reachable without a pointer (WCAG 2.1.1), which * needs `tabIndex`, `role="region"` and a name. Applying those unconditionally * would litter every screen with phantom landmarks and dead tab stops for tables * that happen to fit, so the attributes are driven by a live measurement: * `scrollWidth > clientWidth`, re-taken whenever the container or its content * resizes and whenever the content is replaced. * * `useOverflowRegion` is the same measurement for a component that IS its own * scrolling box (a `
`, a JSON pane) and therefore cannot be wrapped in a * `` without gaining a second scroller. `useProseScrollRegions` applies the * rules to the surfaces React never renders — the tables and code blocks inside * `dangerouslySetInnerHTML` (rendered README/markdown), which cannot be wrapped * in a component and are therefore instrumented imperatively. * * Both hooks hand back a CALLBACK REF for the element they measure, rather than * reading one the caller holds: a ref object tells a hook nothing about WHEN it * is filled, so a hook that read one would never instrument an element that * mounts later than the hook (a README that arrives with the second render) and * would never notice one being swapped for another. */ import type { CSSProperties, ReactNode, Ref, RefCallback } from 'react'; import { useCallback, useState } from 'react'; import { holdContentTarget, holdResizeTarget, releaseContentTarget, releaseResizeTarget, } from './observer-registry'; import { clearScrollRegionAttributes, needsRegion, type OverflowAxis, refreshRegions, SCROLL_REGION_CLASS, type TrackedSurface, } from './overflow-measure'; import { DEFAULT_PROSE_LABELS, ensureScrollWrapper, labelledProseSurfaces, MAX_PROSE_REGIONS, uniquelyNamed, } from './prose-regions'; export type { OverflowAxis } from './overflow-measure'; export interface ScrollRegionProps { /** The region's accessible name, applied only while it actually scrolls. */ readonly label: string; readonly children: ReactNode; readonly className?: string; readonly style?: CSSProperties; /** * The dimension overflow is measured on. `horizontal` (the default) fits a pane * that scrolls sideways; `vertical` fits a bounded, capped-height box that scrolls * down. The caller styles the box's own overflow to match. */ readonly axis?: OverflowAxis; /** * The one arbitrary attribute this component forwards. The measured attributes * — `tabindex`, `role` and `aria-label` — are the component's own and appear * and disappear with the overflow, so a general attribute spread would let a * caller set them statically and defeat the measurement. A test hook cannot, * which is why it is the exception rather than the first of a set. */ readonly 'data-testid'?: string; /** A consumer ref for the scrolling `div` itself. */ readonly ref?: Ref; } /** The fallback name per instrumented surface, when no heading precedes it. */ export interface ProseScrollLabels { readonly table?: string; readonly pre?: string; } /** * Attaches `element` to a consumer ref of either form, and returns the detach * for it. A callback ref that answers with its own cleanup gets that cleanup * called rather than a second call with `null` — the contract React itself * follows for a ref it owns. */ function attachRef(ref: Ref | undefined, element: HTMLElement): () => void { if (ref === undefined || ref === null) return () => undefined; if (typeof ref !== 'function') { ref.current = element; return () => { ref.current = null; }; } const cleanup = ref(element); if (typeof cleanup === 'function') return cleanup; return () => { ref(null); }; } /** The attribute set a scrolling box wears; every value is absent while it fits. */ export interface OverflowRegionAttributes { /** Attach to the scrolling element; it is what the measurement follows. */ readonly ref: RefCallback ; readonly tabIndex?: 0; readonly role?: 'region'; readonly 'aria-label'?: string; } /** * The conditional region attributes for a scrolling box, alongside the ref that * names it. The ref must go on the scrolling box ITSELF — a ` `, a JSON * pane, or the `div` `ScrollRegion` renders. Use the hook when wrapping the * content in a `ScrollRegion` would give the surface a second scroller. * * The box is measured as it is attached, whenever it or any child resizes, and * whenever its content changes — replaced, appended, or edited in place. A * replaced child is a NEW element, so the registration is re-pointed at the * current children before each measurement, adding and dropping only the ones * that actually changed; both sides of that are registrations on the shared * observers, not observers of this mount's own. The only DOM change a * measurement can cause is the region ATTRIBUTES this hook returns, and * attributes are deliberately left unobserved — that, not an absence of * mutation, is what stops the pair from re-triggering each other. * * @param ref - a consumer ref that wants the same element, or `undefined`. * @param label - its accessible name, applied only while it actually scrolls. * @param axis - the dimension to measure overflow on (`horizontal` by default; * `vertical` for a bounded, capped-height box that scrolls down). */ export function useOverflowRegion( ref: Ref| undefined, label: string, axis: OverflowAxis = 'horizontal', ): OverflowRegionAttributes { const [scrollable, setScrollable] = useState(false); // Attached and released with the element, so a box that mounts later than the // hook, or is swapped for another, is instrumented exactly once either way. const measuredRef = useCallback( (box: HTMLElement): (() => void) => { const detachConsumer = attachRef(ref, box); const measure = (): void => { setScrollable(needsRegion(box, axis)); }; // The box gives resize; its children give the overflowing width. The // shared observer is released target by target rather than disconnected, // because every other scrolling box on the page is registered on the same // one — and only the targets that actually came or went are touched, so an // edit in place re-registers nothing and costs one measurement. let observed: Element[] = []; const observeAll = (): void => { const current = [box, ...box.children]; const kept = new Set (current); for (const target of observed) { if (!kept.has(target)) releaseResizeTarget(target, measure); } for (const target of current) holdResizeTarget(target, measure); observed = current; measure(); }; // The whole subtree, text included: content is as often EDITED IN PLACE — // a longer code string, a different JSON body — as it is replaced, and // React reuses the element when it does, so watching the direct child list // alone would freeze the measurement at whatever the first content needed. // ATTRIBUTES are deliberately absent: the region attributes are what a // measurement writes, and observing them would make each pass trigger the // next. holdContentTarget(box, observeAll); observeAll(); // A stop held open only because the box had focus outlives its reason the // moment the reader leaves, so re-measure then and let it go. const releaseHeldStop = (): void => { measure(); }; box.addEventListener('blur', releaseHeldStop); return () => { for (const target of observed) releaseResizeTarget(target, measure); observed = []; releaseContentTarget(box, observeAll); box.removeEventListener('blur', releaseHeldStop); detachConsumer(); }; }, [ref, axis], ); if (!scrollable) return { ref: measuredRef }; return { ref: measuredRef, tabIndex: 0, role: 'region', 'aria-label': label }; } export function ScrollRegion({ label, children, className, style, axis, 'data-testid': testId, ref, }: ScrollRegionProps) { const region = useOverflowRegion(ref, label, axis); return ( {children}); } /** * Instruments the scrollable surfaces under the element the returned ref is * attached to — every `` and every `
` — as scroll regions. React * cannot wrap markup it did not create, so this walks the DOM instead. A table * is moved into a `div.tai-scroll-region` (once — the pass is idempotent) * because the table itself is not the scrolling box; a `` already IS its * own scrolling box, so it is instrumented in place. Either way the scrolling * element carries the same conditional `tabindex`/`role`/`aria-label` as * `ScrollRegion`. * * The name is the nearest heading preceding the surface — read for all of them * in one document-order pass — so a reader landing on the region hears which * section it belongs to; `labels` covers a surface with no heading above it, and * a name shared by several surfaces is numbered so no two regions answer to the * same one. * * The pass runs from the ref callback rather than an effect, which puts it in * the commit phase, BEFORE the browser paints: `.tai-prose table` is * `width: 100%` with no overflow of its own — the scroller exists only on the * wrapper this builds — so a pass that ran after paint would show one frame at * 320/360 px with the table overflowing the document, then jolt sideways. A * passive-effect probe measured exactly that: at first paint wrappers=0 and the * table's parent was `.tai-prose`. * * Injected HTML is replaced wholesale when its source changes, so the pass is * re-run from a `MutationObserver` on the subtree rather than on attachment * alone. * * @param labels - the names for surfaces with no preceding heading. * @returns the ref for the element whose subtree holds the injected markup. */ export function useProseScrollRegions(labels?: ProseScrollLabels): RefCallback{ const tableLabel = labels?.table ?? DEFAULT_PROSE_LABELS.table; const preLabel = labels?.pre ?? DEFAULT_PROSE_LABELS.pre; return useCallback( (root: HTMLElement): (() => void) => { // Every observed element, mapped to the surface whose width it reports and // that surface's current name. This is what lets a resize re-measure just // the surfaces that moved: an observer callback carries the elements that // resized, and each of them answers here with the region it belongs to. const surfaceOf = new WeakMap (); // What the observer holds and what wears region attributes right now. A // re-instrumentation replaces the surfaces wholesale, and the elements it // drops are detached ones: kept observed they would be registrations on // dead nodes, kept named they would be landmarks nobody can reach. let observed = new Set (); let instrumented = new Set (); // The box gives resize; its children give the overflowing width. A table // that grows wider inside a parent-constrained wrapper resizes nothing // else, so watching the wrapper alone would freeze the mount-time // measurement. Membership of `observed` doubles as the re-observation // guard: re-observing an element already under a ResizeObserver re-arms // its initial notification, which would make each pass trigger the next // one forever. const track = (entry: TrackedSurface, next: Set ): void => { for (const target of [entry.surface, ...entry.surface.children]) { // Rewritten even when already observed: a re-run may have found a new // heading above the surface, and the name has to follow it. surfaceOf.set(target, entry); next.add(target); if (!observed.has(target)) resizeObserver.observe(target); } }; // A pass over the whole subtree. It reads the observers declared below it, // which is safe because nothing calls it until both exist. const instrument = (): void => { // Wrapping mutates the subtree; pause the observer so this pass cannot // re-trigger itself, and drop the records it generated before resuming. mutationObserver.disconnect(); // Every name comes from one document-order read of the prose, taken // before the first wrapper goes in. The list is a static snapshot and // the surfaces in it stay the same elements: wrapping moves a table one // level down, into a `div` standing exactly where the table stood, so // neither the remaining entries nor the names already computed for them // change. Every table is wrapped, cap or no cap — the wrapper is what // keeps a `width: 100%` table inside the column. const boxes = labelledProseSurfaces(root).map(({ element, heading }) => { const table = element instanceof HTMLTableElement; return { surface: table ? ensureScrollWrapper(element) : element, name: heading ?? (table ? tableLabel : preLabel), }; }); const tracked = uniquelyNamed(boxes.slice(0, MAX_PROSE_REGIONS)); const next = new Set (); for (const entry of tracked) track(entry, next); for (const target of observed) { if (!next.has(target)) resizeObserver.unobserve(target); } observed = next; const dropped = instrumented; instrumented = new Set(tracked.map((entry) => entry.surface)); // Wrapping is done for every surface before the first measurement, so // the whole pass costs one layout rather than one per surface. refreshRegions(tracked); for (const surface of dropped) { if (!instrumented.has(surface) && root.contains(surface)) { clearScrollRegionAttributes(surface); } } mutationObserver.takeRecords(); mutationObserver.observe(root, { childList: true, subtree: true }); }; // Only the surfaces that actually resized are re-measured. Re-running the // whole instrumentation pass from here re-queried the entire prose subtree // on every resize frame, and a document with many surfaces spends that // cost once per frame for the whole time a pane is being dragged. const resizeObserver = new ResizeObserver((entries) => { // A surface and its children are observed separately, so one frame can // deliver several entries naming the same region; measuring it once is // enough. const affected = new Map (); for (const entry of entries) { const tracked = surfaceOf.get(entry.target); if (tracked !== undefined) affected.set(tracked.surface, tracked); } refreshRegions([...affected.values()]); }); const mutationObserver = new MutationObserver(instrument); // A stop held open only because the region had focus outlives its reason // the moment the reader leaves. `focusout` bubbles, so one listener on the // root covers every instrumented surface under it, however often they are // replaced; the attribute pair is exactly what this hook writes, so it is // also what identifies a region among the prose's other focusable content. const releaseHeldStop = (event: FocusEvent): void => { const left = event.target; if (!(left instanceof HTMLElement)) return; if (left.getAttribute('role') !== 'region' || !left.hasAttribute('tabindex')) return; // The stop being released belongs to exactly one region, so re-measure // that one rather than re-walking the prose. A region this hook did not // instrument is not ours to release. const tracked = surfaceOf.get(left); if (tracked !== undefined) refreshRegions([tracked]); }; root.addEventListener('focusout', releaseHeldStop); instrument(); return () => { resizeObserver.disconnect(); mutationObserver.disconnect(); root.removeEventListener('focusout', releaseHeldStop); }; }, [tableLabel, preLabel], ); }