"use client";
import * as React from "react";
export type SortDirection = "asc" | "desc";
export interface SortRule {
key: string;
direction: SortDirection;
}
export interface DataTableColumn<T> {
/** Unique column key; also the property read from each row when no `cell`/`sortValue` is given. */
key: string;
/** Header content. Plain strings are used for accessible names of the sort and resize controls. */
header: React.ReactNode;
/** Custom cell renderer. */
cell?: (row: T) => React.ReactNode;
/** Allow sorting by this column. */
sortable?: boolean;
/** Value used for sorting (defaults to `row[key]`). */
sortValue?: (row: T) => string | number | null | undefined;
align?: "left" | "right" | "center";
/** Initial width in px (default 160). */
width?: number;
/** Minimum width in px when resizing (default 64). */
minWidth?: number;
}
export type Density = "comfortable" | "compact";
export interface DataTableProps<T>
extends Omit<React.ComponentPropsWithoutRef<"div">, "children" | "onChange"> {
columns: DataTableColumn<T>[];
rows: T[];
/** Stable id for each row (used for selection and React keys). */
getRowId: (row: T) => string;
/** Table caption — names the table for assistive tech. */
caption: string;
/** Show the caption visually in the toolbar (it is always present for screen readers). */
showCaption?: boolean;
/** Row selection checkboxes with a tri-state "select all" header. */
selectable?: boolean;
selected?: string[];
defaultSelected?: string[];
onSelectedChange?: (ids: string[]) => void;
/** Controlled sort rules (first rule is the primary sort). */
sort?: SortRule[];
defaultSort?: SortRule[];
onSortChange?: (sort: SortRule[]) => void;
/** Shift + click (or Shift + Enter) on a header adds it as a secondary sort. */
multiSort?: boolean;
/** Drag or keyboard-resize column widths. */
resizable?: boolean;
/** Paged footer or a "Load more" button. */
pagination?: "pages" | "load-more";
pageSize?: number;
density?: Density;
defaultDensity?: Density;
onDensityChange?: (density: Density) => void;
/** Show the comfortable / compact toggle. */
showDensityToggle?: boolean;
/** Keep the header row visible while the body scrolls. */
stickyHeader?: boolean;
/** Scroll area max height in px (0 = no limit). */
maxHeight?: number;
loading?: boolean;
/** Shown when there are no rows. */
emptyMessage?: React.ReactNode;
/** Selection tint, sort indicator, focus rings and resize handle. */
accent?: string;
}
const DEFAULT_WIDTH = 160;
const SELECT_WIDTH = 44;
const collator = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
function compareValues(a: unknown, b: unknown) {
const empty = (v: unknown) => v === null || v === undefined || v === "";
if (empty(a) && empty(b)) return 0;
if (empty(a)) return 1; // empty values sort last in both directions
if (empty(b)) return -1;
if (typeof a === "number" && typeof b === "number") return a - b;
return collator.compare(String(a), String(b));
}
const headerText = (header: React.ReactNode, fallback: string) =>
typeof header === "string" || typeof header === "number" ? String(header) : fallback;
/**
* A sortable, selectable data table on real <table> semantics: headers sort on click or Enter
* (Shift adds a secondary sort), rows select with checkboxes and a tri-state header, columns
* resize by drag or arrow keys, and the body pages or loads more.
*/
export function DataTable<T>({
columns,
rows,
getRowId,
caption,
showCaption = true,
selectable = true,
selected: selectedProp,
defaultSelected,
onSelectedChange,
sort: sortProp,
defaultSort,
onSortChange,
multiSort = true,
resizable = true,
pagination = "pages",
pageSize = 10,
density: densityProp,
defaultDensity = "comfortable",
onDensityChange,
showDensityToggle = true,
stickyHeader = true,
maxHeight = 380,
loading = false,
emptyMessage = "No rows to show.",
accent = "#FF7448",
className = "",
style,
...props
}: DataTableProps<T>) {
const uid = React.useId();
const captionId = `${uid}-caption`;
const [innerSelected, setInnerSelected] = React.useState<string[]>(() => defaultSelected ?? []);
const selected = selectedProp ?? innerSelected;
const selectedSet = new Set(selected);
const [innerSort, setInnerSort] = React.useState<SortRule[]>(() => defaultSort ?? []);
const sort = sortProp ?? innerSort;
const [innerDensity, setInnerDensity] = React.useState<Density>(defaultDensity);
const density = densityProp ?? innerDensity;
const [page, setPage] = React.useState(0);
const [shown, setShown] = React.useState(pageSize);
const [widths, setWidths] = React.useState<Record<string, number>>({});
const [announcement, setAnnouncement] = React.useState("");
const tableRef = React.useRef<HTMLTableElement>(null);
const colRefs = React.useRef<Record<string, HTMLTableColElement | null>>({});
const lastClicked = React.useRef<number | null>(null);
const drag = React.useRef<{ key: string; startX: number; startWidth: number; width: number; pointerId: number } | null>(null);
const widthOf = (c: DataTableColumn<T>) => widths[c.key] ?? c.width ?? DEFAULT_WIDTH;
const totalWidth = columns.reduce((sum, c) => sum + widthOf(c), selectable ? SELECT_WIDTH : 0);
/* ------------------------------- sorting ------------------------------- */
const valueOf = (row: T, column: DataTableColumn<T>) =>
column.sortValue ? column.sortValue(row) : (row as Record<string, unknown>)[column.key];
const sortedRows = (() => {
const rules = sort
.map((r) => ({ rule: r, column: columns.find((c) => c.key === r.key) }))
.filter((x): x is { rule: SortRule; column: DataTableColumn<T> } => Boolean(x.column));
if (!rules.length) return rows;
return rows
.map((row, index) => ({ row, index }))
.sort((a, b) => {
for (const { rule, column } of rules) {
const va = valueOf(a.row, column);
const vb = valueOf(b.row, column);
const empty = va === null || va === undefined || va === "" || vb === null || vb === undefined || vb === "";
const c = compareValues(va, vb);
// Empty values stay last regardless of direction.
if (c !== 0) return empty || rule.direction === "asc" ? c : -c;
}
return a.index - b.index; // stable
})
.map((x) => x.row);
})();
const setSort = (next: SortRule[]) => {
if (sortProp === undefined) setInnerSort(next);
onSortChange?.(next);
const primary = next[0];
const name = (key: string) => headerText(columns.find((c) => c.key === key)?.header, key);
setAnnouncement(
primary
? `Sorted by ${next.map((r) => `${name(r.key)}, ${r.direction === "asc" ? "ascending" : "descending"}`).join(", then ")}.`
: "Sorting removed.",
);
};
const activateHeader = (key: string, additive: boolean) => {
const index = sort.findIndex((r) => r.key === key);
const current = sort[index];
if (additive && multiSort) {
if (!current) setSort([...sort, { key, direction: "asc" }]);
else if (current.direction === "asc") setSort(sort.map((r) => (r.key === key ? { key, direction: "desc" } : r)));
else setSort(sort.filter((r) => r.key !== key));
return;
}
// Plain click: asc → desc → off on the primary column; any other column becomes the only sort.
if (!current || sort.length > 1) setSort([{ key, direction: current?.direction === "asc" && sort.length === 1 ? "desc" : "asc" }]);
else if (current.direction === "asc") setSort([{ key, direction: "desc" }]);
else setSort([]);
};
/* ------------------------------ pagination ----------------------------- */
const pageCount = Math.max(1, Math.ceil(sortedRows.length / pageSize));
const safePage = Math.min(page, pageCount - 1);
const visibleRows =
pagination === "pages"
? sortedRows.slice(safePage * pageSize, safePage * pageSize + pageSize)
: sortedRows.slice(0, Math.max(shown, pageSize));
const firstIndex = pagination === "pages" ? safePage * pageSize : 0;
const goToPage = (p: number) => {
const next = Math.max(0, Math.min(pageCount - 1, p));
setPage(next);
setAnnouncement(`Page ${next + 1} of ${pageCount}.`);
};
const loadMore = () => {
const next = Math.min(sortedRows.length, Math.max(shown, pageSize) + pageSize);
setShown(next);
setAnnouncement(`Showing ${next} of ${sortedRows.length} rows.`);
};
/* ------------------------------ selection ------------------------------ */
const commitSelection = (next: string[]) => {
if (selectedProp === undefined) setInnerSelected(next);
onSelectedChange?.(next);
};
const allIds = rows.map(getRowId);
const selectedCount = allIds.filter((id) => selectedSet.has(id)).length;
const allSelected = rows.length > 0 && selectedCount === rows.length;
const someSelected = selectedCount > 0 && !allSelected;
const toggleAll = () => {
commitSelection(allSelected ? [] : allIds);
setAnnouncement(allSelected ? "Selection cleared." : `All ${rows.length} rows selected.`);
};
const toggleRow = (visibleIndex: number, shiftKey: boolean) => {
const row = visibleRows[visibleIndex];
if (!row) return;
const id = getRowId(row);
const turnOn = !selectedSet.has(id);
const next = new Set(selectedSet);
// Shift + click selects (or clears) the range from the last clicked row.
if (shiftKey && lastClicked.current !== null && lastClicked.current !== visibleIndex) {
const [a, b] = [lastClicked.current, visibleIndex].sort((x, y) => x - y);
for (const r of visibleRows.slice(a, b + 1)) {
if (turnOn) next.add(getRowId(r));
else next.delete(getRowId(r));
}
} else if (turnOn) next.add(id);
else next.delete(id);
lastClicked.current = visibleIndex;
commitSelection(allIds.filter((x) => next.has(x)));
};
/* ------------------------------- density ------------------------------- */
const setDensity = (d: Density) => {
if (densityProp === undefined) setInnerDensity(d);
onDensityChange?.(d);
};
/* ------------------------------- resizing ------------------------------ */
const clampWidth = (c: DataTableColumn<T>, w: number) => Math.round(Math.max(c.minWidth ?? 64, Math.min(640, w)));
const resizeTo = (c: DataTableColumn<T>, w: number) => {
setWidths((prev) => ({ ...prev, [c.key]: clampWidth(c, w) }));
};
// Dragging writes widths straight to the <col> and <table> (no re-render per move); state is committed on release.
const onResizePointerDown = (c: DataTableColumn<T>, event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
drag.current = { key: c.key, startX: event.clientX, startWidth: widthOf(c), width: widthOf(c), pointerId: event.pointerId };
};
const onResizePointerMove = (c: DataTableColumn<T>, event: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.key !== c.key || d.pointerId !== event.pointerId) return;
const w = clampWidth(c, d.startWidth + event.clientX - d.startX);
if (w === d.width) return;
const col = colRefs.current[c.key];
if (col) col.style.width = `${w}px`;
if (tableRef.current) tableRef.current.style.width = `${totalWidth - d.startWidth + w}px`;
event.currentTarget.setAttribute("aria-valuenow", String(w));
d.width = w;
};
const onResizePointerUp = (c: DataTableColumn<T>, event: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.key !== c.key) return;
drag.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
resizeTo(c, d.width);
};
const onResizeKeyDown = (c: DataTableColumn<T>, event: React.KeyboardEvent<HTMLDivElement>) => {
const step = event.shiftKey ? 40 : 10;
const w = widthOf(c);
let next: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowUp") next = w + step;
else if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = w - step;
else if (event.key === "Home") next = c.minWidth ?? 64;
else if (event.key === "End") next = 640;
else if (event.key === "Enter" && widths[c.key] !== undefined) {
// Enter restores the column's default width.
setWidths((prev) => {
const copy = { ...prev };
delete copy[c.key];
return copy;
});
event.preventDefault();
return;
}
if (next === null) return;
event.preventDefault();
resizeTo(c, next);
};
/* -------------------------------- render ------------------------------- */
const cellPad = density === "compact" ? "px-3 py-1.5" : "px-4 py-3";
const alignClass = (a: DataTableColumn<T>["align"]) => (a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left");
const colSpan = columns.length + (selectable ? 1 : 0);
const focusRing =
"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--dt-accent)]";
const checkboxClass = `size-4 cursor-pointer rounded accent-[var(--dt-accent)] ${focusRing}`;
const rangeLabel =
rows.length === 0
? "0 rows"
: pagination === "pages"
? `${firstIndex + 1}–${firstIndex + visibleRows.length} of ${sortedRows.length}`
: `${visibleRows.length} of ${sortedRows.length}`;
return (
<div
className={`flex w-full min-w-0 flex-col overflow-hidden rounded-xl border border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-950 ${className}`}
style={{ ["--dt-accent" as string]: accent, ...style }}
{...props}
>
{/* Toolbar */}
<div className="flex min-h-12 flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-zinc-900/10 px-4 py-2 dark:border-white/10">
<div className="flex min-w-0 items-baseline gap-3">
{showCaption && <span className="truncate text-[14px] font-semibold tracking-tight text-zinc-950 dark:text-white">{caption}</span>}
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{rows.length} rows
{selectable && selectedCount > 0 && <span className="text-zinc-900 dark:text-zinc-100"> · {selectedCount} selected</span>}
</span>
{selectable && selectedCount > 0 && (
<button
type="button"
onClick={() => {
commitSelection([]);
setAnnouncement("Selection cleared.");
}}
className={`rounded font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 underline decoration-zinc-900/20 underline-offset-[3px] hover:text-zinc-950 dark:text-zinc-400 dark:decoration-white/20 dark:hover:text-white ${focusRing}`}
>
Clear
</button>
)}
</div>
{showDensityToggle && (
<div role="group" aria-label="Row density" className="flex rounded-lg border border-zinc-900/10 p-0.5 dark:border-white/10">
{(["comfortable", "compact"] as const).map((d) => (
<button
key={d}
type="button"
aria-pressed={density === d}
onClick={() => setDensity(d)}
className={`rounded-md px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] transition-colors ${focusRing} ${
density === d
? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-950"
: "text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
}`}
>
{d}
</button>
))}
</div>
)}
</div>
{/* Scroll area: focusable so keyboard users can scroll wide or tall tables. */}
<div
role="region"
aria-labelledby={captionId}
tabIndex={0}
className={`relative min-w-0 overflow-auto overscroll-contain ${focusRing} focus-visible:-outline-offset-2`}
style={{ maxHeight: maxHeight > 0 ? maxHeight : undefined }}
>
<table
ref={tableRef}
aria-busy={loading || undefined}
aria-describedby={`${uid}-help`}
className="table-fixed border-separate border-spacing-0 text-[13px] text-zinc-700 dark:text-zinc-300"
style={{ width: totalWidth, minWidth: "100%" }}
>
<caption id={captionId} className="sr-only">
{caption}
</caption>
<colgroup>
{selectable && <col style={{ width: SELECT_WIDTH }} />}
{columns.map((c) => (
<col
key={c.key}
ref={(el) => {
colRefs.current[c.key] = el;
}}
style={{ width: widthOf(c) }}
/>
))}
</colgroup>
<thead>
<tr>
{selectable && (
<th
scope="col"
className={`${stickyHeader ? "sticky top-0 z-10" : ""} border-b border-zinc-900/10 bg-zinc-50 px-0 text-center dark:border-white/10 dark:bg-zinc-900`}
>
<input
type="checkbox"
aria-label={allSelected ? "Deselect all rows" : `Select all ${rows.length} rows`}
checked={allSelected}
disabled={!rows.length || loading}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={toggleAll}
className={`${checkboxClass} align-middle`}
/>
</th>
)}
{columns.map((c) => {
const ruleIndex = sort.findIndex((r) => r.key === c.key);
const rule = sort[ruleIndex];
const name = headerText(c.header, c.key);
const ariaSort = ruleIndex === 0 ? (rule.direction === "asc" ? "ascending" : "descending") : undefined;
return (
<th
key={c.key}
scope="col"
aria-sort={c.sortable ? (ariaSort ?? "none") : undefined}
className={`group/th relative border-b border-zinc-900/10 bg-zinc-50 font-mono text-[10px] font-normal uppercase tracking-[0.14em] text-zinc-500 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-400 ${
stickyHeader ? "sticky top-0 z-10" : ""
} ${alignClass(c.align)}`}
>
{c.sortable ? (
<button
type="button"
onClick={(e) => activateHeader(c.key, e.shiftKey)}
onKeyDown={(e) => {
// Native buttons fire click on Enter without shiftKey in some browsers; handle Shift+Enter here.
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
activateHeader(c.key, true);
}
}}
className={`flex w-full items-center gap-1.5 ${density === "compact" ? "px-3 py-2" : "px-4 py-2.5"} uppercase tracking-[0.14em] transition-colors hover:text-zinc-950 dark:hover:text-white ${
c.align === "right" ? "flex-row-reverse text-right" : c.align === "center" ? "justify-center" : ""
} ${rule ? "text-zinc-950 dark:text-white" : ""} outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--dt-accent)]`}
>
<span className="truncate">{c.header}</span>
<SortGlyph direction={rule?.direction} />
{rule && sort.length > 1 && (
<span aria-hidden="true" className="font-mono text-[9px] tabular-nums text-[var(--dt-accent)]">
{ruleIndex + 1}
</span>
)}
<span className="sr-only">
{rule
? `, sorted ${rule.direction === "asc" ? "ascending" : "descending"}${sort.length > 1 ? `, priority ${ruleIndex + 1}` : ""}`
: ", sortable"}
</span>
</button>
) : (
<span className={`block truncate ${density === "compact" ? "px-3 py-2" : "px-4 py-2.5"}`}>{c.header}</span>
)}
{resizable && (
<div
role="separator"
aria-orientation="vertical"
aria-label={`Resize ${name} column`}
aria-valuenow={widthOf(c)}
aria-valuemin={c.minWidth ?? 64}
aria-valuemax={640}
tabIndex={0}
onPointerDown={(e) => onResizePointerDown(c, e)}
onPointerMove={(e) => onResizePointerMove(c, e)}
onPointerUp={(e) => onResizePointerUp(c, e)}
onPointerCancel={(e) => onResizePointerUp(c, e)}
onKeyDown={(e) => onResizeKeyDown(c, e)}
onDoubleClick={() =>
setWidths((prev) => {
const copy = { ...prev };
delete copy[c.key];
return copy;
})
}
className="group/rs absolute inset-y-0 right-0 z-20 flex w-2.5 cursor-col-resize touch-none justify-end outline-hidden"
>
<span
aria-hidden="true"
className="my-2 w-px bg-zinc-900/10 transition-[background-color,width] group-hover/rs:w-[2px] group-hover/rs:bg-[var(--dt-accent)] group-focus-visible/rs:w-[2px] group-focus-visible/rs:bg-[var(--dt-accent)] group-active/rs:bg-[var(--dt-accent)] dark:bg-white/10"
/>
</div>
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: Math.min(pageSize, 8) }, (_, r) => (
<tr key={`sk-${r}`} aria-hidden="true">
{selectable && <td className="border-b border-zinc-900/[0.06] dark:border-white/[0.06]" />}
{columns.map((c, ci) => (
<td key={c.key} className={`border-b border-zinc-900/[0.06] dark:border-white/[0.06] ${cellPad}`}>
<span
className={`block h-2.5 animate-pulse rounded-full bg-zinc-900/[0.07] motion-reduce:animate-none dark:bg-white/[0.08] ${c.align === "right" ? "ml-auto" : ""}`}
style={{ width: `${40 + ((r * 7 + ci * 13) % 45)}%` }}
/>
</td>
))}
</tr>
))
) : visibleRows.length === 0 ? (
<tr>
<td colSpan={colSpan} className="px-4 py-14 text-center">
<span className="mx-auto mb-3 grid size-9 place-items-center rounded-full border border-dashed border-zinc-900/20 text-zinc-400 dark:border-white/20">
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.4">
<rect x="2.5" y="3" width="11" height="10" rx="1.5" />
<path d="M2.5 6.5h11M6.5 6.5V13" />
</svg>
</span>
<span className="text-[13px] text-zinc-500 dark:text-zinc-400">{emptyMessage}</span>
</td>
</tr>
) : (
visibleRows.map((row, ri) => {
const id = getRowId(row);
const isSelected = selectedSet.has(id);
return (
<tr
key={id}
data-selected={isSelected || undefined}
className={`transition-colors duration-100 ${
isSelected
? "bg-[color-mix(in_srgb,var(--dt-accent)_9%,transparent)]"
: "hover:bg-zinc-900/[0.03] dark:hover:bg-white/[0.04]"
}`}
>
{selectable && (
<td className="border-b border-zinc-900/[0.06] text-center dark:border-white/[0.06]">
<input
type="checkbox"
aria-label={`Select row ${firstIndex + ri + 1}`}
aria-describedby={`${uid}-row-${id}`}
checked={isSelected}
onChange={(e) => toggleRow(ri, (e.nativeEvent as MouseEvent).shiftKey === true)}
className={`${checkboxClass} align-middle`}
/>
</td>
)}
{columns.map((c, ci) => (
<td
key={c.key}
id={ci === 0 ? `${uid}-row-${id}` : undefined}
className={`truncate border-b border-zinc-900/[0.06] dark:border-white/[0.06] ${cellPad} ${alignClass(c.align)} ${
c.align === "right" ? "tabular-nums" : ""
}`}
>
{c.cell ? c.cell(row) : String((row as Record<string, unknown>)[c.key] ?? "")}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Footer */}
<div className="flex min-h-11 flex-wrap items-center justify-between gap-3 border-t border-zinc-900/10 px-4 py-2 dark:border-white/10">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 tabular-nums dark:text-zinc-400">{loading ? "Loading…" : rangeLabel}</span>
{!loading && pagination === "pages" && pageCount > 1 && (
<nav aria-label="Pagination" className="flex items-center gap-1">
<PagerButton label="Previous page" disabled={safePage === 0} onClick={() => goToPage(safePage - 1)} focusRing={focusRing}>
<path d="M10 3.5 5.5 8l4.5 4.5" />
</PagerButton>
<span className="min-w-16 text-center font-mono text-[10px] uppercase tracking-[0.14em] tabular-nums text-zinc-600 dark:text-zinc-300">
{safePage + 1} / {pageCount}
</span>
<PagerButton label="Next page" disabled={safePage >= pageCount - 1} onClick={() => goToPage(safePage + 1)} focusRing={focusRing}>
<path d="M6 3.5 10.5 8 6 12.5" />
</PagerButton>
</nav>
)}
{!loading && pagination === "load-more" && visibleRows.length < sortedRows.length && (
<button
type="button"
onClick={loadMore}
className={`h-8 rounded-lg border border-zinc-900/12 px-3 text-[12px] font-medium text-zinc-700 transition-colors hover:border-zinc-900/30 hover:text-zinc-950 dark:border-white/12 dark:text-zinc-300 dark:hover:border-white/30 dark:hover:text-white ${focusRing}`}
>
Load {Math.min(pageSize, sortedRows.length - visibleRows.length)} more
</button>
)}
</div>
<p id={`${uid}-help`} hidden>
{[
columns.some((c) => c.sortable) ? `Sortable column headers are buttons${multiSort ? "; hold Shift to add a secondary sort" : ""}.` : "",
resizable ? "Column resize handles take Left and Right arrows." : "",
].join(" ")}
</p>
<span className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</span>
</div>
);
}
function SortGlyph({ direction }: { direction?: SortDirection }) {
return (
<svg aria-hidden="true" viewBox="0 0 10 12" className="size-2.5 shrink-0" fill="currentColor">
<path d="M5 1 8.5 5h-7z" className={direction === "asc" ? "text-[var(--dt-accent)]" : direction ? "opacity-20" : "opacity-30"} />
<path d="M5 11 1.5 7h7z" className={direction === "desc" ? "text-[var(--dt-accent)]" : direction ? "opacity-20" : "opacity-30"} />
</svg>
);
}
function PagerButton({
label,
disabled,
onClick,
focusRing,
children,
}: {
label: string;
disabled: boolean;
onClick: () => void;
focusRing: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
disabled={disabled}
onClick={onClick}
className={`grid size-8 place-items-center rounded-lg border border-zinc-900/12 text-zinc-600 transition-colors hover:border-zinc-900/30 hover:text-zinc-950 disabled:pointer-events-none disabled:opacity-35 dark:border-white/12 dark:text-zinc-300 dark:hover:border-white/30 dark:hover:text-white ${focusRing}`}
>
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
</button>
);
}