/**
* Wraps each `
` in a scroll container. Satteri does not re-descend into a
* visitor's returned replacement, so the wrapped table is not re-visited.
*
* The wrapper carries `tabindex="0"` so a horizontally scrolling table is
* reachable and scrollable by keyboard, not just pointer (WCAG 2.1.1; axe's
* `scrollable-region-focusable`). It's added unconditionally — whether a given
* table overflows isn't known at build time — which costs a tab stop on tables
* that happen to fit; no ARIA label is set to avoid an untranslated string.
*
* GFM has no headerless-table syntax — the delimiter row is mandatory, so a
* table that doesn't want headers is authored with an empty first row
* (`| | |`). That parses to a `` of blank cells, which renders as a
* dead band above the body; such a header row is dropped here. A header cell
* containing any non-text content (an image, an icon) counts as non-empty.
*/
/** The value shapes hast allows on an element's `properties`. */
type HastPropertyValue = string | number | boolean | (string | number)[];
/** A minimal hast node (avoids a hast type dependency). */
interface HastNode {
children?: HastNode[];
properties?: Record;
tagName?: string;
type: string;
value?: string;
}
/** A Satteri hast plugin, typed structurally to avoid a Satteri dep. */
export interface TableWrapPlugin {
name: string;
element: {
filter: string[];
visit: (node: HastNode) => HastNode;
};
}
/** Structural elements whose presence alone doesn't make a header non-empty. */
const HEADER_STRUCTURE = new Set(["thead", "tr", "th"]);
/** Whether a header subtree contains anything that would render visibly. */
const hasVisibleContent = (node: HastNode): boolean => {
if (node.type === "text") {
return (node.value ?? "").trim() !== "";
}
if (node.tagName && !HEADER_STRUCTURE.has(node.tagName)) {
return true;
}
return (node.children ?? []).some(hasVisibleContent);
};
const isEmptyThead = (node: HastNode): boolean =>
node.tagName === "thead" && !hasVisibleContent(node);
export const tableWrapPlugin = (): TableWrapPlugin => ({
element: {
filter: ["table"],
visit(node) {
// Satteri serializes an unchanged node by identity, so stripping the
// header must produce a new table object — mutating `node.children`
// in place has no effect on the output.
const table = node.children?.some(isEmptyThead)
? {
...node,
children: node.children.filter((child) => !isEmptyThead(child)),
}
: node;
return {
children: [table],
properties: { className: ["blume-table-scroll"], tabIndex: 0 },
tagName: "div",
type: "element",
};
},
},
name: "blume:table-wrap",
});