// biome-ignore-all lint/a11y/noRedundantRoles: the roles are redundant only while the table has `display: table`, and the narrow layout takes that away — `block` on the table, `flex` on a cell. Table semantics have historically travelled with `display`, and the engine that matters most here is the one this sandbox cannot run: the consumer is driven from a tablet, so WebKit is the narrow layout's primary browser. Measured in this sandbox's chromium via CDP `Accessibility.getFullAXTree`, these roles change nothing (see the `ROW_GROUP_ROLE` note); a dropped role, in the one layout where the column headers are no longer on screen, turns the table into a stack of unrelated text. Redundant costs nothing, absent costs everything. import type { ReactNode } from "react" import { useEffect, useRef } from "react" import { Checkbox } from "../Checkbox/Checkbox.tsx" import type { SortDirection } from "../SortableTableHeader/SortableTableHeader.tsx" import { SortableTableHeader } from "../SortableTableHeader/SortableTableHeader.tsx" import { toClassName } from "../toClassName.ts" import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx" export type DataTableColumn = { /** * Lands on this column's `` **and** on every `` under it, * which is what makes it the place a width goes: `w-32` on one * column is the only sizing this component has, and it is * deliberately the consumer's to write. */ className?: string /** * The column's name. Rendered twice on purpose — once in the * header row, and once inside each cell as the label the stacked * layout needs — so it has to be a node that can be rendered more * than once. The in-cell copy is `aria-hidden`, because the * `columnheader` association already says it to assistive * technology. */ header: ReactNode /** * Whether this column offers a sort control. A column without one * renders a plain `` with **no** `aria-sort` at all, which is * the attribute's way of saying "this column cannot be sorted" — * `"none"` would claim the opposite. */ isSortable?: boolean /** Stable id. Also the `columnKey` a sort request comes back with. */ key: string renderCell: (row: Row) => ReactNode } export type DataTableSort = { columnKey: string direction: SortDirection } export type DataTableSelection = { /** * The accessible name of a row's checkbox. A tick box called * "Select row" seven times over is seven identical controls in a * screen reader's forms list, so this is required rather than * defaulted — it is normally the same value the first column * renders. */ getRowLabel: (row: Row) => string /** The whole next set, never a delta. */ onSelectionChange: (selectedRowKeys: string[]) => void selectAllLabel?: string selectedRowKeys: readonly string[] } export type DataTableReflowAt = "md" | "lg" | "xl" export type DataTableProps = { /** Auto uses the container width; stacked keeps the labelled list at every width. */ layout?: "auto" | "stacked" /** Dense tables can reflow earlier: md=32rem, lg=48rem, xl=64rem. */ reflowAt?: DataTableReflowAt className?: string columns: readonly DataTableColumn[] /** * What fills the table when `rows` is empty — an `EmptyState`, * normally. Rendered in a `colSpan` cell **inside** the table, so * the header row and the sort the reader just performed are still * there. Swapping the whole table out for a message is how a * filter that matches nothing takes the controls away with it. */ emptyState?: ReactNode getRowKey: (row: Row) => string /** * Shows the ``. Off by default: most tables sit under a * heading the page already renders, and printing the name twice is * how a table ends up with two titles. */ isLabelVisible?: boolean /** * The table's accessible name, as a real ``. Required, * because a screen reader listing "table with 6 columns, 40 rows" * and no name is a table nobody can choose to enter. */ label: string onSortChange?: (sort: DataTableSort) => void rows: readonly Row[] selection?: DataTableSelection /** * Which column is sorted and which way — **the fact, not the * doing.** The rows arrive already ordered; this only says which * header lights up and what `aria-sort` announces. */ sort?: DataTableSort } /** * The cell padding, in one place because four elements have to agree * on it: the sortable header's button (which brings its own, inside * `SortableTableHeader`), the plain header, the body cell, and the * empty-state cell. */ const CELL_PADDING_CLASS = "px-3 py-2" /** Complete literals keep Tailwind generation and consumer builds identical. */ const WIDE_LAYOUT_CLASS = { "cq-md:align-middle": { md: "cq-md:align-middle", lg: "cq-lg:align-middle", xl: "cq-xl:align-middle", }, "cq-md:bg-transparent": { md: "cq-md:bg-transparent", lg: "cq-lg:bg-transparent", xl: "cq-xl:bg-transparent", }, "cq-md:border-0": { md: "cq-md:border-0", lg: "cq-lg:border-0", xl: "cq-xl:border-0", }, "cq-md:border-b": { md: "cq-md:border-b", lg: "cq-lg:border-b", xl: "cq-xl:border-b", }, "cq-md:border-b-0": { md: "cq-md:border-b-0", lg: "cq-lg:border-b-0", xl: "cq-xl:border-b-0", }, "cq-md:border-border-subtle": { md: "cq-md:border-border-subtle", lg: "cq-lg:border-border-subtle", xl: "cq-xl:border-border-subtle", }, "cq-md:gap-0": { md: "cq-md:gap-0", lg: "cq-lg:gap-0", xl: "cq-xl:gap-0", }, "cq-md:hidden": { md: "cq-md:hidden", lg: "cq-lg:hidden", xl: "cq-xl:hidden", }, "cq-md:p-0": { md: "cq-md:p-0", lg: "cq-lg:p-0", xl: "cq-xl:p-0", }, "cq-md:pt-0": { md: "cq-md:pt-0", lg: "cq-lg:pt-0", xl: "cq-xl:pt-0", }, "cq-md:py-2": { md: "cq-md:py-2", lg: "cq-lg:py-2", xl: "cq-xl:py-2", }, "cq-md:rounded-none": { md: "cq-md:rounded-none", lg: "cq-lg:rounded-none", xl: "cq-xl:rounded-none", }, "cq-md:table": { md: "cq-md:table", lg: "cq-lg:table", xl: "cq-xl:table", }, "cq-md:table-cell": { md: "cq-md:table-cell", lg: "cq-lg:table-cell", xl: "cq-xl:table-cell", }, "cq-md:table-header-group": { md: "cq-md:table-header-group", lg: "cq-lg:table-header-group", xl: "cq-xl:table-header-group", }, "cq-md:table-row": { md: "cq-md:table-row", lg: "cq-lg:table-row", xl: "cq-xl:table-row", }, "cq-md:table-row-group": { md: "cq-md:table-row-group", lg: "cq-lg:table-row-group", xl: "cq-xl:table-row-group", }, "cq-md:wrap-normal": { md: "cq-md:wrap-normal", lg: "cq-lg:wrap-normal", xl: "cq-xl:wrap-normal", }, } as const /** * `role` on every part, and it is not belt-and-braces. * * The narrow layout takes `display` away from the table elements — * `block` on the table, `grid` on the row, `flex` on the cell — and * a table's semantics have historically travelled with its * `display`. Measured in this sandbox's chromium via CDP * (`Accessibility.getFullAXTree`), that engine now keeps * `table` / `row` / `columnheader` / `cell` intact through * `display: block`; the roles below are therefore a **no-op here** * and insurance for the engines this sandbox cannot run. The one * measured difference is ``: chromium ignores it entirely * until it is given `role="rowgroup"`, at which point it joins the * tree as a rowgroup beside ``'s — which is what a table * with two rowgroups is supposed to look like. * * The cost of being wrong in each direction is what settles it. A * redundant role changes nothing anywhere. A dropped one turns the * whole table into a stack of unrelated text in exactly the layout * where the column headers are no longer on screen to read. */ const ROW_GROUP_ROLE = "rowgroup" /** * **A table that reflows instead of scrolling, sized by its * container.** * * ### It composes `SortableTableHeader`; it does not replace it * * The header cell shipped first, on its own, and stays on its own: * it is already in a published 1.0.0 with a consumer of its own * (mux-magic's `FileExplorerModal`), and subsuming it would be a * breaking change bought for nothing. Every sortable column here * renders that component, so there is exactly one place in the fleet * that knows `aria-sort` belongs on the `` while the click * target is a `