` and `` inside
* injected markup, naming each by the heading that precedes it, and disambiguating
* names a section shares. React never renders these surfaces, so they are located
* and wrapped imperatively.
*/
import { SCROLL_REGION_CLASS, type TrackedSurface } from './overflow-measure';
/**
* The names for a prose surface that has no heading above it.
*
* They say what the surface IS and nothing about which document it came from,
* because this walk cannot know: it instruments whatever markup the caller
* injected. A default that named one document put that document's name on every
* caller's regions, and a region a reader hears the wrong name for is worse than
* a plain one.
*/
export const DEFAULT_PROSE_LABELS = { table: 'Table', pre: 'Code block' } as const;
const HEADING_SELECTOR = 'h1, h2, h3, h4, h5, h6';
/**
* Headings AND the surfaces they name, in one selector: a single query returns
* both interleaved in document order, which is what lets one walk name every
* surface (see `labelledProseSurfaces`).
*/
const PROSE_SURFACE_SELECTOR = `${HEADING_SELECTOR}, table, pre`;
/**
* The most surfaces one rendered document is given regions for.
*
* The prose is publisher-authored and unbounded in length, and every region is
* an entry in the landmark list a screen-reader user navigates the page by: a
* README with more surfaces than this turns that list into the very thing the
* regions exist to spare a reader. Past the cap a table still gets its scrolling
* wrapper — the wrapper is what keeps it inside the column — but no name and no
* tab stop, so the landmark list stays a list a person can read.
*/
export const MAX_PROSE_REGIONS = 200;
/**
* The wrapper a prose table sits inside, creating it on first pass. Idempotent:
* a table already inside a `.tai-scroll-region` keeps the wrapper it has.
*/
export function ensureScrollWrapper(table: HTMLTableElement): HTMLElement {
const parent = table.parentElement;
if (parent?.classList.contains(SCROLL_REGION_CLASS) === true) return parent;
const wrapper = table.ownerDocument.createElement('div');
wrapper.className = SCROLL_REGION_CLASS;
table.replaceWith(wrapper);
wrapper.append(table);
return wrapper;
}
/** A heading that can name the surfaces below it, and the text it would give. */
interface ProseHeading {
readonly element: Element;
readonly text: string;
}
/** A scrollable prose surface, with the heading text that names it. */
interface LabelledProseSurface {
readonly element: HTMLElement;
readonly heading: string | undefined;
}
/**
* The text of the last heading in `headings` that does not ENCLOSE `surface`.
*
* A heading wrapping the surface does precede it in document order, but it is
* not a section the surface sits UNDER, so it is stepped over in favour of the
* heading before it — which is the answer an outward walk from the surface
* reaches. In well-formed prose no heading contains a table or a code block, so
* this returns the last heading on the first look.
*/
function namingHeadingText(
headings: readonly ProseHeading[],
surface: Element,
): string | undefined {
for (let index = headings.length - 1; index >= 0; index -= 1) {
const heading = headings[index];
if (heading !== undefined && !heading.element.contains(surface)) return heading.text;
}
return undefined;
}
/**
* Every scrollable prose surface under `root` — each `` and each ``
* — paired with the text of the nearest heading PRECEDING it, or `undefined`
* when no heading does.
*
* One query returns the headings and the surfaces interleaved in document order,
* so a single walk over that list names all of them: the heading a surface
* belongs to is the last one seen before reaching it. Naming each surface by
* searching backwards from it instead would re-read the same prose once per
* surface, at a cost that grows with the product of the two.
*
* Blank headings never enter the list, so a decorative empty heading names
* nothing and the section above it is used instead. A heading INSIDE a surface
* (a `` or a table cell holding one) comes AFTER that surface in document
* order and so can never name it, though it does name later surfaces — exactly
* as being the last heading before them implies.
*
* Read in full before the pass wraps anything, and safely so: a name depends
* only on what PRECEDES its surface, and `ensureScrollWrapper` puts the wrapper
* in the table's own place, moving nothing else and leaving every heading where
* it was — so a name stays true whether its table is wrapped yet or not.
*/
export function labelledProseSurfaces(root: Element): LabelledProseSurface[] {
const headings: ProseHeading[] = [];
const surfaces: LabelledProseSurface[] = [];
for (const node of root.querySelectorAll(PROSE_SURFACE_SELECTOR)) {
if (node.matches(HEADING_SELECTOR)) {
// textContent is nullable under strict DOM typings; the local eslint
// profile assumes non-null, so the necessary guard trips its rule.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const text = (node.textContent ?? '').trim();
if (text !== '') headings.push({ element: node, text });
continue;
}
surfaces.push({ element: node, heading: namingHeadingText(headings, node) });
}
return surfaces;
}
/** A prose surface's scrolling box and the name it would take on its own. */
interface ProseSurfaceBox {
readonly surface: HTMLElement;
readonly name: string;
}
/**
* The same surfaces, each given a name no other one in the list carries.
*
* A name is only as useful as it is distinguishing, and one heading names every
* surface under it: two wide tables in one section, or two code blocks under no
* heading at all, would otherwise be two landmarks a reader hears identically
* and cannot choose between. A name more than one surface claims is therefore
* numbered, in document order, and a name only one surface claims is left alone.
*/
export function uniquelyNamed(boxes: readonly ProseSurfaceBox[]): TrackedSurface[] {
const claims = new Map();
for (const { name } of boxes) claims.set(name, (claims.get(name) ?? 0) + 1);
const taken = new Map();
return boxes.map(({ surface, name }) => {
if (claims.get(name) === 1) return { surface, label: name };
const ordinal = (taken.get(name) ?? 0) + 1;
taken.set(name, ordinal);
return { surface, label: `${name} (${String(ordinal)})` };
});
}