Component
Status Table
A table of realistic product data: semantic status badges (text + restrained token tints, never color alone), user cells with avatar initials, dates, right-aligned durations, rollout progress bars with real progressbar semantics, and row actions.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/tables/table-status/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Tables/table-status React/Components/Tables/table-status import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type {
ButtonHTMLAttributes,
HTMLAttributes,
InputHTMLAttributes,
ReactNode,
TableHTMLAttributes,
TdHTMLAttributes,
ThHTMLAttributes,
} from "react";
/**
* DevSnips React Table — table-status.
*
* The shared compound core (identical to the reference `table` variant) with
* the showcase focused on realistic mixed content: semantic status badges
* (text + token-derived tints, never color alone), user cells with avatar
* initials, mono timestamps, right-aligned tabular durations, rollout
* progress bars with real `role="progressbar"` semantics, and row actions —
* product data without the over-designed dashboard.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
export type TableDensity = "default" | "compact";
export type TableAlign = "left" | "center" | "right";
/** `null` means the column is sortable but currently unsorted. */
export type SortDirection = "asc" | "desc" | null;
const ALIGN_CLASSES: Record<TableAlign, string> = {
left: "text-left",
center: "text-center",
right: "text-right",
};
const DENSITY_CLASSES: Record<
TableDensity,
{ head: string; cell: string; control: string; skeleton: string }
> = {
default: {
head: "h-10 px-3",
cell: "px-3 py-2.5",
control: "size-[18px]",
skeleton: "px-3 py-[13px]",
},
compact: {
head: "h-8 px-3",
cell: "px-3 py-1.5 text-[13px] leading-4",
control: "size-4",
skeleton: "px-3 py-2",
},
};
/* ------------------------------------------------------------------------ */
/* Shared glyphs (lucide-style, 24px grid, currentColor) */
/* ------------------------------------------------------------------------ */
const GLYPH_PROPS = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.75,
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true,
focusable: false,
} as const;
const SORT_GLYPHS: Record<"asc" | "desc" | "none", ReactNode> = {
asc: (
<svg {...GLYPH_PROPS}>
<path d="m5 12 7-7 7 7" />
<path d="M12 19V5" />
</svg>
),
desc: (
<svg {...GLYPH_PROPS}>
<path d="M12 5v14" />
<path d="m19 12-7 7-7-7" />
</svg>
),
none: (
<svg {...GLYPH_PROPS}>
<path d="m21 16-4 4-4-4" />
<path d="M17 20V4" />
<path d="m3 8 4-4 4 4" />
<path d="M7 4v16" />
</svg>
),
};
const CHEVRON_DOWN_GLYPH = (
<svg {...GLYPH_PROPS}>
<path d="m6 9 6 6 6-6" />
</svg>
);
const CHEVRON_LEFT_GLYPH = (
<svg {...GLYPH_PROPS}>
<path d="m15 6-6 6 6 6" />
</svg>
);
const CHEVRON_RIGHT_GLYPH = (
<svg {...GLYPH_PROPS}>
<path d="m9 6 6 6-6 6" />
</svg>
);
const CHECK_GLYPH = (
<svg {...GLYPH_PROPS} strokeWidth={3}>
<path d="M20 6 9 17l-5-5" />
</svg>
);
const DASH_GLYPH = (
<svg {...GLYPH_PROPS} strokeWidth={3}>
<path d="M5 12h14" />
</svg>
);
/* ------------------------------------------------------------------------ */
/* Table context (density is the only shared visual state) */
/* ------------------------------------------------------------------------ */
interface TableContextValue {
density: TableDensity;
}
const TableContext = createContext<TableContextValue | null>(null);
function useTable(component: string): TableContextValue {
const context = useContext(TableContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Table>.`);
}
return context;
}
/* ------------------------------------------------------------------------ */
/* Table (root) */
/* ------------------------------------------------------------------------ */
export interface TableProps extends TableHTMLAttributes<HTMLTableElement> {
/** Cell/header density: `default` (comfortable) or `compact` (dense data). */
density?: TableDensity;
/** Marks the table `aria-busy` while data is loading (pair with `TableLoading`). */
loading?: boolean;
/** Extra classes on the bordered scroll container around the `<table>`. */
containerClassName?: string;
className?: string;
children?: ReactNode;
}
/**
* The root: a bordered, deliberately scrollable container (`overflow-x-auto`
* — the intentional scroll region when a dataset is genuinely wider than the
* viewport) around a native `<table>`. Provides density context. Compose the
* region primitives inside; do not replace them with divs.
*/
export function Table({
density = "default",
loading = false,
containerClassName,
className,
children,
...rest
}: TableProps) {
const context = useMemo<TableContextValue>(() => ({ density }), [density]);
return (
<TableContext.Provider value={context}>
<div
className={cx(
"relative w-full overflow-x-auto rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)]",
containerClassName,
)}
>
<table
aria-busy={loading || undefined}
className={cx(
"w-full border-collapse text-left text-sm leading-5 text-[var(--ds-color-foreground)]",
className,
)}
{...rest}
>
{children}
</table>
</div>
</TableContext.Provider>
);
}
/* ------------------------------------------------------------------------ */
/* TableCaption */
/* ------------------------------------------------------------------------ */
export interface TableCaptionProps extends HTMLAttributes<HTMLTableCaptionElement> {
children?: ReactNode;
}
/**
* The table's `<caption>` — the accessible name/description of the dataset,
* rendered above the table. Prefer a caption over an off-table heading so the
* name travels with the table for assistive technology.
*/
export function TableCaption({ className, children, ...rest }: TableCaptionProps) {
return (
<caption
className={cx(
"px-3 py-3 text-left text-[13px] leading-5 text-[var(--ds-color-muted-foreground)]",
className,
)}
{...rest}
>
{children}
</caption>
);
}
/* ------------------------------------------------------------------------ */
/* TableHeader / TableBody / TableFooter */
/* ------------------------------------------------------------------------ */
export interface TableHeaderProps extends HTMLAttributes<HTMLTableSectionElement> {
children?: ReactNode;
}
/** The `<thead>`. Neutralizes body-row hover/dividers so plain `TableRow`s compose cleanly inside it. */
export function TableHeader({ className, children, ...rest }: TableHeaderProps) {
return (
<thead
className={cx(
"border-b border-[var(--ds-color-border)] bg-[var(--ds-color-surface-subtle)] [&>tr:hover]:bg-transparent [&>tr]:border-0",
className,
)}
{...rest}
>
{children}
</thead>
);
}
export interface TableBodyProps extends HTMLAttributes<HTMLTableSectionElement> {
children?: ReactNode;
}
/** The `<tbody>`. The last row's divider is removed so the table edge stays clean. */
export function TableBody({ className, children, ...rest }: TableBodyProps) {
return (
<tbody className={cx("[&>tr:last-child]:border-b-0", className)} {...rest}>
{children}
</tbody>
);
}
export interface TableFooterProps extends HTMLAttributes<HTMLTableSectionElement> {
children?: ReactNode;
}
/** The `<tfoot>` — totals and summaries, visually anchored with a top rule and a subtle surface. */
export function TableFooter({ className, children, ...rest }: TableFooterProps) {
return (
<tfoot
className={cx(
"border-t border-[var(--ds-color-border)] bg-[var(--ds-color-surface-subtle)] font-medium [&>tr:hover]:bg-transparent [&>tr]:border-0",
className,
)}
{...rest}
>
{children}
</tfoot>
);
}
/* ------------------------------------------------------------------------ */
/* TableRow */
/* ------------------------------------------------------------------------ */
export interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> {
/** Marks the row selected: `aria-selected` + accent-tinted surface (token-derived via color-mix). */
selected?: boolean;
/** Marks the row unavailable: `aria-disabled`, reduced opacity, no hover affordance. Row controls must be disabled too. */
disabled?: boolean;
children?: ReactNode;
}
/**
* A `<tr>` with a restrained hover affordance. Selection is a real state
* (`aria-selected` + an accent-tinted surface), not just a color swap; a
* disabled row is announced with `aria-disabled` and loses its hover shift.
* Rows are never fake buttons — interactive content lives in real controls
* inside the cells.
*/
export function TableRow({
selected = false,
disabled = false,
className,
children,
...rest
}: TableRowProps) {
return (
<tr
aria-selected={selected || undefined}
aria-disabled={disabled || undefined}
className={cx(
"border-b border-[var(--ds-color-border-subtle)] transition-colors duration-150 ease-out motion-reduce:transition-none",
selected
? "bg-[color-mix(in_srgb,var(--ds-color-accent)_8%,var(--ds-color-surface))] hover:bg-[color-mix(in_srgb,var(--ds-color-accent)_12%,var(--ds-color-surface))]"
: !disabled && "hover:bg-[var(--ds-color-surface-hover)]",
disabled && "opacity-60",
className,
)}
{...rest}
>
{children}
</tr>
);
}
/* ------------------------------------------------------------------------ */
/* TableHead */
/* ------------------------------------------------------------------------ */
export interface TableHeadProps extends ThHTMLAttributes<HTMLTableCellElement> {
/** Horizontal alignment of the column header (match the column's cells). */
align?: TableAlign;
/** Renders the header content as a visible sort `<button>` and manages `aria-sort`. */
sortable?: boolean;
/** Current sort direction for this column; `null` = sortable but unsorted. */
sortDirection?: SortDirection;
/** Called when the sort button is activated (click, Enter, or Space). */
onSort?: () => void;
className?: string;
children?: ReactNode;
}
/**
* A `<th scope="col">`. When `sortable`, the header label becomes a REAL,
* visible `<button type="button">` (never a mysteriously clickable `<th>`),
* the `<th>` carries `aria-sort` (`ascending` / `descending` / `none`), and a
* direction glyph shows the state to sighted users. Keyboard users reach the
* button with Tab and activate it with Enter/Space.
*/
export function TableHead({
align = "left",
sortable = false,
sortDirection = null,
onSort,
scope,
className,
children,
...rest
}: TableHeadProps) {
const { density } = useTable("TableHead");
const ariaSort = sortable
? sortDirection === "asc"
? "ascending"
: sortDirection === "desc"
? "descending"
: "none"
: undefined;
return (
<th
scope={scope ?? "col"}
aria-sort={ariaSort}
className={cx(
DENSITY_CLASSES[density].head,
"align-middle text-xs font-medium leading-4 tracking-[0.01em] text-[var(--ds-color-muted-foreground)]",
ALIGN_CLASSES[align],
className,
)}
{...rest}
>
{sortable ? (
<button
type="button"
onClick={onSort}
className={cx(
"inline-flex items-center gap-1 rounded-[var(--ds-radius-xs)] font-medium tracking-[0.01em] transition-colors duration-150 ease-out hover:text-[var(--ds-color-foreground)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none",
align === "right" && "flex-row-reverse",
)}
>
<span>{children}</span>
<span
aria-hidden="true"
className={cx(
"inline-flex [&_svg]:size-3.5",
sortDirection === null
? "opacity-40"
: "text-[var(--ds-color-foreground)] opacity-100",
)}
>
{SORT_GLYPHS[sortDirection ?? "none"]}
</span>
</button>
) : (
children
)}
</th>
);
}
/* ------------------------------------------------------------------------ */
/* TableCell */
/* ------------------------------------------------------------------------ */
export interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> {
/** Horizontal alignment: text is usually `left`, numeric values `right`. */
align?: TableAlign;
/** Tabular figures for numeric content (pair with `align="right"`). */
numeric?: boolean;
className?: string;
children?: ReactNode;
}
/** A `<td>`. Alignment is intentional and `colSpan` forwards natively. */
export function TableCell({
align = "left",
numeric = false,
className,
children,
...rest
}: TableCellProps) {
const { density } = useTable("TableCell");
return (
<td
className={cx(
"align-middle",
DENSITY_CLASSES[density].cell,
ALIGN_CLASSES[align],
numeric && "tabular-nums",
className,
)}
{...rest}
>
{children}
</td>
);
}
/* ------------------------------------------------------------------------ */
/* TableEmpty */
/* ------------------------------------------------------------------------ */
export interface TableEmptyProps {
/** Number of columns the message spans (must match the table's column count). */
colSpan: number;
/** The zero-data headline, e.g. "No saved views". */
title: string;
/** Supporting explanation. */
description?: string;
/** A real control (`<button>` / `<a>`) that resolves the empty state. */
action?: ReactNode;
/** Optional leading glyph (decorative: always `aria-hidden`). */
icon?: ReactNode;
className?: string;
}
/**
* An honest empty state: one real row with one spanning cell — no fake
* placeholder rows just to look populated. The message is plain text in the
* table flow (readable by everyone); the optional action must be a real
* control that does something real.
*/
export function TableEmpty({
colSpan,
title,
description,
action,
icon,
className,
}: TableEmptyProps) {
return (
<tr>
<td colSpan={colSpan} className={cx("px-3 py-12 text-center", className)}>
<div className="mx-auto flex max-w-sm flex-col items-center gap-1.5">
{icon ? (
<span
aria-hidden="true"
className="mb-1 inline-flex text-[var(--ds-color-muted-foreground)] [&_svg]:size-6"
>
{icon}
</span>
) : null}
<p className="m-0 text-sm font-medium leading-5 text-[var(--ds-color-foreground)]">
{title}
</p>
{description ? (
<p className="m-0 text-[13px] leading-5 text-[var(--ds-color-muted-foreground)]">
{description}
</p>
) : null}
{action ? <div className="mt-3 flex flex-wrap justify-center gap-2">{action}</div> : null}
</div>
</td>
</tr>
);
}
/* ------------------------------------------------------------------------ */
/* TableLoading */
/* ------------------------------------------------------------------------ */
const SKELETON_WIDTHS = ["w-3/4", "w-1/2", "w-2/3", "w-1/3"] as const;
export interface TableLoadingProps {
/** Number of columns (must match the table's column count). */
columns: number;
/** Number of skeleton rows (default 5 — pick a value close to the expected page size). */
rows?: number;
/** Visually hidden announcement while loading. */
label?: string;
}
/**
* Skeleton rows that preserve the table's approximate geometry (same column
* count, near-identical row heights) so content does not jump when data
* arrives. Skeleton bars are `aria-hidden` decorative placeholders with a
* subtle pulse that respects reduced motion; the `label` is announced in a
* visually hidden row. Set `loading` on `<Table>` so the table reports
* `aria-busy` for the duration.
*/
export function TableLoading({ columns, rows = 5, label = "Loading data" }: TableLoadingProps) {
const { density } = useTable("TableLoading");
return (
<>
<tr className="sr-only">
<td colSpan={columns}>{label}</td>
</tr>
{Array.from({ length: rows }, (_, rowIndex) => (
<tr key={rowIndex} aria-hidden="true" className="border-b border-[var(--ds-color-border-subtle)] last:border-b-0">
{Array.from({ length: columns }, (_, columnIndex) => (
<td key={columnIndex} className={DENSITY_CLASSES[density].skeleton}>
<span
className={cx(
"block h-3 rounded-[var(--ds-radius-xs)] bg-[var(--ds-color-muted)] motion-reduce:animate-none",
SKELETON_WIDTHS[(rowIndex + columnIndex) % SKELETON_WIDTHS.length],
"animate-pulse",
)}
/>
</td>
))}
</tr>
))}
</>
);
}
/* ------------------------------------------------------------------------ */
/* TableActions / TableToolbar */
/* ------------------------------------------------------------------------ */
export interface TableActionsProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
/**
* An end-aligned cluster of real controls for a cell (`<TableCell
* align="right"><TableActions>…`). Compose only real `<button>` / `<a>`
* children — never nest a control inside another control.
*/
export function TableActions({ className, children, ...rest }: TableActionsProps) {
return (
<div className={cx("flex items-center justify-end gap-1", className)} {...rest}>
{children}
</div>
);
}
export interface TableToolbarProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
/**
* The region above the table: selection counts, filters, and primary actions.
* A layout-only `flex-wrap` container — it renders no table markup and adds
* no ARIA of its own, so it never interferes with the table's semantics.
*/
export function TableToolbar({ className, children, ...rest }: TableToolbarProps) {
return (
<div className={cx("flex flex-wrap items-center justify-between gap-3", className)} {...rest}>
{children}
</div>
);
}
/* ------------------------------------------------------------------------ */
/* TableSelection */
/* ------------------------------------------------------------------------ */
export interface TableSelectionProps
extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"type" | "checked" | "defaultChecked" | "onChange" | "size"
> {
/** Current checked state (tracked by the caller — works controlled or uncontrolled). */
checked: boolean;
/** Tri-state "some selected" state: set imperatively on the DOM node (there is no HTML attribute). */
indeterminate?: boolean;
onCheckedChange?: (checked: boolean) => void;
/** Accessible name, e.g. "Select all rows" or "Select Ada Lovelace". Required (the control is icon-only). */
label: string;
className?: string;
}
/**
* The selection control: a REAL native `<input type="checkbox">` styled after
* the DevSnips Checkboxes family — the input carries the value, the focus
* ring, and all native behavior (Space toggles, form submission). The
* select-all tri-state uses the true `.indeterminate` IDL property, set
* imperatively via a ref. Never a div fake.
*/
export function TableSelection({
checked,
indeterminate = false,
onCheckedChange,
label,
disabled,
className,
...rest
}: TableSelectionProps) {
const { density } = useTable("TableSelection");
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.indeterminate = indeterminate && !checked;
}
}, [indeterminate, checked]);
const active = checked || indeterminate;
return (
<span
className={cx(
"relative inline-flex shrink-0 items-center justify-center",
DENSITY_CLASSES[density].control,
)}
>
<input
ref={inputRef}
type="checkbox"
aria-label={label}
checked={checked}
disabled={disabled}
onChange={(event) => onCheckedChange?.(event.target.checked)}
className={cx(
"size-full cursor-pointer appearance-none rounded-[var(--ds-radius-xs)] border bg-[var(--ds-color-input)] transition-colors duration-150 ease-out hover:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none",
active
? "border-[var(--ds-color-primary)] bg-[var(--ds-color-primary)]"
: "border-[var(--ds-color-border)]",
className,
)}
{...rest}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-center text-[var(--ds-color-primary-foreground)] [&_svg]:size-3"
>
{indeterminate && !checked ? (
DASH_GLYPH
) : (
<span
className={cx(
"inline-flex transition-opacity duration-150 ease-out motion-reduce:transition-none",
checked ? "opacity-100" : "opacity-0",
)}
>
{CHECK_GLYPH}
</span>
)}
</span>
</span>
);
}
/* ------------------------------------------------------------------------ */
/* TableExpand */
/* ------------------------------------------------------------------------ */
export interface TableExpandProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-expanded" | "aria-controls" | "aria-label"> {
/** Whether the associated content is currently expanded. */
expanded: boolean;
/** `id` of the expanded content region (rendered as `aria-controls`). */
controls?: string;
/** Noun phrase completing the accessible name: "details for invoice INV-1042". */
label?: string;
className?: string;
}
/**
* The row expand/collapse trigger: a real `<button type="button">` with
* `aria-expanded` and `aria-controls`, keyboard-operable (Tab + Enter/Space)
* with a `focus-visible` ring. The chevron rotation is the only motion and it
* is reduced-motion safe. The consumer renders the expanded content as a real
* row (a `<TableCell colSpan>` panel) whose `id` matches `controls`.
*/
export function TableExpand({
expanded,
controls,
label,
className,
...rest
}: TableExpandProps) {
const { density } = useTable("TableExpand");
return (
<button
type="button"
aria-expanded={expanded}
aria-controls={controls}
aria-label={`${expanded ? "Collapse" : "Expand"} ${label ?? "row"}`}
className={cx(
"inline-flex items-center justify-center rounded-[var(--ds-radius-sm)] text-[var(--ds-color-muted-foreground)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
density === "compact" ? "size-7" : "size-8",
className,
)}
{...rest}
>
<span
aria-hidden="true"
className={cx(
"inline-flex transition-transform duration-150 ease-out motion-reduce:transition-none [&_svg]:size-4",
expanded && "rotate-180",
)}
>
{CHEVRON_DOWN_GLYPH}
</span>
</button>
);
}
/* ------------------------------------------------------------------------ */
/* TablePagination */
/* ------------------------------------------------------------------------ */
/** Clamp a 1-based page into the valid range for `totalPages` (minimum 1). */
export function clampPage(page: number, totalPages: number): number {
return Math.min(Math.max(1, page), Math.max(1, totalPages));
}
/** Windowed page list: all pages when small, otherwise first/last/current±1 with "ellipsis" markers for hidden ranges. */
export function pageRange(current: number, totalPages: number): Array<number | "ellipsis"> {
const total = Math.max(1, totalPages);
const page = clampPage(current, total);
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1);
}
const wanted = new Set<number>([1, total, page - 1, page, page + 1]);
const sorted = Array.from(wanted)
.filter((p) => p >= 1 && p <= total)
.sort((a, b) => a - b);
const range: Array<number | "ellipsis"> = [];
let previous = 0;
for (const p of sorted) {
if (p - previous > 1) {
range.push("ellipsis");
}
range.push(p);
previous = p;
}
return range;
}
export interface TablePaginationProps {
/** Current page, 1-based (controlled). Omit to run uncontrolled. */
page?: number;
/** Initial page, 1-based (uncontrolled). */
defaultPage?: number;
/** Called with the next 1-based page whenever it changes. */
onPageChange?: (page: number) => void;
/** Total number of rows across all pages (required). */
totalItems: number;
/** Rows per page. */
pageSize?: number;
/** Selectable page sizes; when provided, a labelled native `<select>` is rendered. */
pageSizeOptions?: readonly number[];
/** Called when the user picks a new page size (reset to page 1 here). */
onPageSizeChange?: (pageSize: number) => void;
/** Accessible label for the navigation landmark. */
label?: string;
className?: string;
}
const PAGINATION_CONTROL_BASE =
"inline-flex h-8 min-w-8 select-none items-center justify-center gap-1 whitespace-nowrap rounded-[var(--ds-radius-sm)] px-2 text-[13px] font-medium leading-4 tabular-nums transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const PAGINATION_CONTROL_IDLE =
"text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)]";
const PAGINATION_CONTROL_CURRENT =
"border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)]";
/**
* The table pagination bar — a self-contained `<nav aria-label>` that follows
* the DevSnips Pagination family's semantics: real `<button type="button">`
* controls, `aria-current="page"` on the current page, natively disabled
* Previous/Next at the boundaries, a windowed page list with non-interactive
* ellipses, and an `aria-live` "Showing X–Y of Z" status. Every page is
* clamped into range, so an empty page cannot occur through invalid state.
* The parent slices the dataset by the current page — this component only
* reports and changes it.
*/
export function TablePagination({
page,
defaultPage = 1,
onPageChange,
totalItems,
pageSize = 10,
pageSizeOptions,
onPageSizeChange,
label = "Table pagination",
className,
}: TablePaginationProps) {
const isControlled = page !== undefined;
const [internal, setInternal] = useState(defaultPage);
const totalPages = Math.max(1, Math.ceil(totalItems / Math.max(1, pageSize)));
const current = clampPage(isControlled ? page : internal, totalPages);
function setPage(next: number) {
const clamped = clampPage(next, totalPages);
if (clamped === current) {
return;
}
if (!isControlled) {
setInternal(clamped);
}
onPageChange?.(clamped);
}
const from = totalItems === 0 ? 0 : (current - 1) * pageSize + 1;
const to = Math.min(current * pageSize, totalItems);
const range = pageRange(current, totalPages);
return (
<nav
aria-label={label}
className={cx("flex flex-wrap items-center justify-between gap-3", className)}
>
<p aria-live="polite" className="m-0 text-[13px] leading-4 text-[var(--ds-color-muted-foreground)]">
Showing <span className="font-medium text-[var(--ds-color-foreground)]">{from}</span>
{" – "}
<span className="font-medium text-[var(--ds-color-foreground)]">{to}</span> of{" "}
<span className="font-medium text-[var(--ds-color-foreground)]">{totalItems}</span>
</p>
<div className="flex flex-wrap items-center gap-3">
{pageSizeOptions ? (
<label className="flex items-center gap-2 text-[13px] leading-4 text-[var(--ds-color-muted-foreground)]">
Rows per page
<select
value={pageSize}
onChange={(event) => onPageSizeChange?.(Number(event.target.value))}
className="h-8 rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-input)] px-2 text-[13px] leading-4 text-[var(--ds-color-foreground)] transition-colors duration-150 ease-out hover:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none"
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
) : null}
<ul className="m-0 flex max-w-full list-none flex-wrap items-center gap-1 p-0">
<li className="inline-flex">
<button
type="button"
aria-label="Go to previous page"
disabled={current <= 1}
onClick={() => setPage(current - 1)}
className={cx(PAGINATION_CONTROL_BASE, PAGINATION_CONTROL_IDLE)}
>
<span aria-hidden="true" className="inline-flex [&_svg]:size-3.5">
{CHEVRON_LEFT_GLYPH}
</span>
<span>Previous</span>
</button>
</li>
{range.map((item, index) =>
item === "ellipsis" ? (
<li key={`ellipsis-${index}`} className="inline-flex">
<span
className={cx(PAGINATION_CONTROL_BASE, "pointer-events-none text-[var(--ds-color-muted-foreground)]")}
>
<span aria-hidden="true">…</span>
<span className="sr-only">More pages</span>
</span>
</li>
) : (
<li key={item} className="inline-flex">
<button
type="button"
aria-label={item === current ? `Page ${item}` : `Go to page ${item}`}
aria-current={item === current ? "page" : undefined}
onClick={() => setPage(item)}
className={cx(
PAGINATION_CONTROL_BASE,
item === current ? PAGINATION_CONTROL_CURRENT : PAGINATION_CONTROL_IDLE,
)}
>
{item}
</button>
</li>
),
)}
<li className="inline-flex">
<button
type="button"
aria-label="Go to next page"
disabled={current >= totalPages}
onClick={() => setPage(current + 1)}
className={cx(PAGINATION_CONTROL_BASE, PAGINATION_CONTROL_IDLE)}
>
<span>Next</span>
<span aria-hidden="true" className="inline-flex [&_svg]:size-3.5">
{CHEVRON_RIGHT_GLYPH}
</span>
</button>
</li>
</ul>
</div>
</nav>
);
}
/* ------------------------------------------------------------------------ */
/* Typed helpers (real sorting + real selection state) */
/* ------------------------------------------------------------------------ */
/**
* Return a sorted COPY of `rows` by `accessor` (strings use `localeCompare`,
* numbers sort numerically). The input array is never mutated.
*/
export function sortRows<T>(
rows: readonly T[],
accessor: (row: T) => string | number,
direction: Exclude<SortDirection, null>,
): T[] {
const sorted = [...rows];
sorted.sort((a, b) => {
const av = accessor(a);
const bv = accessor(b);
const compared =
typeof av === "number" && typeof bv === "number"
? av - bv
: String(av).localeCompare(String(bv));
return direction === "asc" ? compared : -compared;
});
return sorted;
}
export interface RowSelection<K extends string | number> {
/** Read-only view of the currently selected keys. */
readonly selected: ReadonlySet<K>;
/** How many of the tracked `keys` are selected. */
readonly count: number;
/** Every tracked key is selected. */
readonly allSelected: boolean;
/** Some — but not all — tracked keys are selected (drives the indeterminate state). */
readonly someSelected: boolean;
isSelected(key: K): boolean;
toggle(key: K, checked?: boolean): void;
toggleAll(checked?: boolean): void;
clear(): void;
}
/**
* Real selection state for a table: pass the SELECTABLE row keys (exclude
* disabled rows) and get the checked set, the derived all/some flags for the
* header checkbox's checked/indeterminate states, and a selected count.
* Selection is a real `Set` of keys — it survives re-renders and stays
* correct as rows are added or removed.
*/
export function useRowSelection<K extends string | number>(
keys: readonly K[],
): RowSelection<K> {
const [selected, setSelected] = useState<ReadonlySet<K>>(() => new Set<K>());
const count = useMemo(
() => keys.reduce((total, key) => (selected.has(key) ? total + 1 : total), 0),
[keys, selected],
);
const allSelected = keys.length > 0 && count === keys.length;
const someSelected = count > 0 && !allSelected;
const isSelected = useCallback((key: K) => selected.has(key), [selected]);
const toggle = useCallback((key: K, checked?: boolean) => {
setSelected((previous) => {
const next = new Set(previous);
if (checked ?? !next.has(key)) {
next.add(key);
} else {
next.delete(key);
}
return next;
});
}, []);
const toggleAll = useCallback(
(checked?: boolean) => {
setSelected((previous) => {
const currentlyAll = keys.length > 0 && keys.every((key) => previous.has(key));
if (checked ?? !currentlyAll) {
return new Set<K>(keys);
}
return new Set<K>();
});
},
[keys],
);
const clear = useCallback(() => setSelected(new Set<K>()), []);
return { selected, count, allSelected, someSelected, isSelected, toggle, toggleAll, clear };
}
export default Table; /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const ALIGN_CLASSES = {
left: "text-left",
center: "text-center",
right: "text-right"
};
const DENSITY_CLASSES = {
default: {
head: "h-10 px-3",
cell: "px-3 py-2.5",
control: "size-[18px]",
skeleton: "px-3 py-[13px]"
},
compact: {
head: "h-8 px-3",
cell: "px-3 py-1.5 text-[13px] leading-4",
control: "size-4",
skeleton: "px-3 py-2"
}
};
const GLYPH_PROPS = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.75,
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true,
focusable: false
};
const SORT_GLYPHS = {
asc: <svg {...GLYPH_PROPS}>
<path d="m5 12 7-7 7 7" />
<path d="M12 19V5" />
</svg>,
desc: <svg {...GLYPH_PROPS}>
<path d="M12 5v14" />
<path d="m19 12-7 7-7-7" />
</svg>,
none: <svg {...GLYPH_PROPS}>
<path d="m21 16-4 4-4-4" />
<path d="M17 20V4" />
<path d="m3 8 4-4 4 4" />
<path d="M7 4v16" />
</svg>
};
const CHEVRON_DOWN_GLYPH = <svg {...GLYPH_PROPS}>
<path d="m6 9 6 6 6-6" />
</svg>;
const CHEVRON_LEFT_GLYPH = <svg {...GLYPH_PROPS}>
<path d="m15 6-6 6 6 6" />
</svg>;
const CHEVRON_RIGHT_GLYPH = <svg {...GLYPH_PROPS}>
<path d="m9 6 6 6-6 6" />
</svg>;
const CHECK_GLYPH = <svg {...GLYPH_PROPS} strokeWidth={3}>
<path d="M20 6 9 17l-5-5" />
</svg>;
const DASH_GLYPH = <svg {...GLYPH_PROPS} strokeWidth={3}>
<path d="M5 12h14" />
</svg>;
const TableContext = createContext(null);
function useTable(component) {
const context = useContext(TableContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Table>.`);
}
return context;
}
function Table({
density = "default",
loading = false,
containerClassName,
className,
children,
...rest
}) {
const context = useMemo(() => ({ density }), [density]);
return <TableContext.Provider value={context}>
<div
className={cx(
"relative w-full overflow-x-auto rounded-[var(--ds-radius-md)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)]",
containerClassName
)}
>
<table
aria-busy={loading || undefined}
className={cx(
"w-full border-collapse text-left text-sm leading-5 text-[var(--ds-color-foreground)]",
className
)}
{...rest}
>
{children}
</table>
</div>
</TableContext.Provider>;
}
function TableCaption({ className, children, ...rest }) {
return <caption
className={cx(
"px-3 py-3 text-left text-[13px] leading-5 text-[var(--ds-color-muted-foreground)]",
className
)}
{...rest}
>
{children}
</caption>;
}
function TableHeader({ className, children, ...rest }) {
return <thead
className={cx(
"border-b border-[var(--ds-color-border)] bg-[var(--ds-color-surface-subtle)] [&>tr:hover]:bg-transparent [&>tr]:border-0",
className
)}
{...rest}
>
{children}
</thead>;
}
function TableBody({ className, children, ...rest }) {
return <tbody className={cx("[&>tr:last-child]:border-b-0", className)} {...rest}>
{children}
</tbody>;
}
function TableFooter({ className, children, ...rest }) {
return <tfoot
className={cx(
"border-t border-[var(--ds-color-border)] bg-[var(--ds-color-surface-subtle)] font-medium [&>tr:hover]:bg-transparent [&>tr]:border-0",
className
)}
{...rest}
>
{children}
</tfoot>;
}
function TableRow({
selected = false,
disabled = false,
className,
children,
...rest
}) {
return <tr
aria-selected={selected || undefined}
aria-disabled={disabled || undefined}
className={cx(
"border-b border-[var(--ds-color-border-subtle)] transition-colors duration-150 ease-out motion-reduce:transition-none",
selected ? "bg-[color-mix(in_srgb,var(--ds-color-accent)_8%,var(--ds-color-surface))] hover:bg-[color-mix(in_srgb,var(--ds-color-accent)_12%,var(--ds-color-surface))]" : !disabled && "hover:bg-[var(--ds-color-surface-hover)]",
disabled && "opacity-60",
className
)}
{...rest}
>
{children}
</tr>;
}
function TableHead({
align = "left",
sortable = false,
sortDirection = null,
onSort,
scope,
className,
children,
...rest
}) {
const { density } = useTable("TableHead");
const ariaSort = sortable ? sortDirection === "asc" ? "ascending" : sortDirection === "desc" ? "descending" : "none" : undefined;
return <th
scope={scope ?? "col"}
aria-sort={ariaSort}
className={cx(
DENSITY_CLASSES[density].head,
"align-middle text-xs font-medium leading-4 tracking-[0.01em] text-[var(--ds-color-muted-foreground)]",
ALIGN_CLASSES[align],
className
)}
{...rest}
>
{sortable ? <button
type="button"
onClick={onSort}
className={cx(
"inline-flex items-center gap-1 rounded-[var(--ds-radius-xs)] font-medium tracking-[0.01em] transition-colors duration-150 ease-out hover:text-[var(--ds-color-foreground)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none",
align === "right" && "flex-row-reverse"
)}
>
<span>{children}</span>
<span
aria-hidden="true"
className={cx(
"inline-flex [&_svg]:size-3.5",
sortDirection === null ? "opacity-40" : "text-[var(--ds-color-foreground)] opacity-100"
)}
>
{SORT_GLYPHS[sortDirection ?? "none"]}
</span>
</button> : children}
</th>;
}
function TableCell({
align = "left",
numeric = false,
className,
children,
...rest
}) {
const { density } = useTable("TableCell");
return <td
className={cx(
"align-middle",
DENSITY_CLASSES[density].cell,
ALIGN_CLASSES[align],
numeric && "tabular-nums",
className
)}
{...rest}
>
{children}
</td>;
}
function TableEmpty({
colSpan,
title,
description,
action,
icon,
className
}) {
return <tr>
<td colSpan={colSpan} className={cx("px-3 py-12 text-center", className)}>
<div className="mx-auto flex max-w-sm flex-col items-center gap-1.5">
{icon ? <span
aria-hidden="true"
className="mb-1 inline-flex text-[var(--ds-color-muted-foreground)] [&_svg]:size-6"
>
{icon}
</span> : null}
<p className="m-0 text-sm font-medium leading-5 text-[var(--ds-color-foreground)]">
{title}
</p>
{description ? <p className="m-0 text-[13px] leading-5 text-[var(--ds-color-muted-foreground)]">
{description}
</p> : null}
{action ? <div className="mt-3 flex flex-wrap justify-center gap-2">{action}</div> : null}
</div>
</td>
</tr>;
}
const SKELETON_WIDTHS = ["w-3/4", "w-1/2", "w-2/3", "w-1/3"];
function TableLoading({ columns, rows = 5, label = "Loading data" }) {
const { density } = useTable("TableLoading");
return <>
<tr className="sr-only">
<td colSpan={columns}>{label}</td>
</tr>
{Array.from({ length: rows }, (_, rowIndex) => <tr key={rowIndex} aria-hidden="true" className="border-b border-[var(--ds-color-border-subtle)] last:border-b-0">
{Array.from({ length: columns }, (_2, columnIndex) => <td key={columnIndex} className={DENSITY_CLASSES[density].skeleton}>
<span
className={cx(
"block h-3 rounded-[var(--ds-radius-xs)] bg-[var(--ds-color-muted)] motion-reduce:animate-none",
SKELETON_WIDTHS[(rowIndex + columnIndex) % SKELETON_WIDTHS.length],
"animate-pulse"
)}
/>
</td>)}
</tr>)}
</>;
}
function TableActions({ className, children, ...rest }) {
return <div className={cx("flex items-center justify-end gap-1", className)} {...rest}>
{children}
</div>;
}
function TableToolbar({ className, children, ...rest }) {
return <div className={cx("flex flex-wrap items-center justify-between gap-3", className)} {...rest}>
{children}
</div>;
}
function TableSelection({
checked,
indeterminate = false,
onCheckedChange,
label,
disabled,
className,
...rest
}) {
const { density } = useTable("TableSelection");
const inputRef = useRef(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.indeterminate = indeterminate && !checked;
}
}, [indeterminate, checked]);
const active = checked || indeterminate;
return <span
className={cx(
"relative inline-flex shrink-0 items-center justify-center",
DENSITY_CLASSES[density].control
)}
>
<input
ref={inputRef}
type="checkbox"
aria-label={label}
checked={checked}
disabled={disabled}
onChange={(event) => onCheckedChange?.(event.target.checked)}
className={cx(
"size-full cursor-pointer appearance-none rounded-[var(--ds-radius-xs)] border bg-[var(--ds-color-input)] transition-colors duration-150 ease-out hover:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none",
active ? "border-[var(--ds-color-primary)] bg-[var(--ds-color-primary)]" : "border-[var(--ds-color-border)]",
className
)}
{...rest}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-center text-[var(--ds-color-primary-foreground)] [&_svg]:size-3"
>
{indeterminate && !checked ? DASH_GLYPH : <span
className={cx(
"inline-flex transition-opacity duration-150 ease-out motion-reduce:transition-none",
checked ? "opacity-100" : "opacity-0"
)}
>
{CHECK_GLYPH}
</span>}
</span>
</span>;
}
function TableExpand({
expanded,
controls,
label,
className,
...rest
}) {
const { density } = useTable("TableExpand");
return <button
type="button"
aria-expanded={expanded}
aria-controls={controls}
aria-label={`${expanded ? "Collapse" : "Expand"} ${label ?? "row"}`}
className={cx(
"inline-flex items-center justify-center rounded-[var(--ds-radius-sm)] text-[var(--ds-color-muted-foreground)] transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
density === "compact" ? "size-7" : "size-8",
className
)}
{...rest}
>
<span
aria-hidden="true"
className={cx(
"inline-flex transition-transform duration-150 ease-out motion-reduce:transition-none [&_svg]:size-4",
expanded && "rotate-180"
)}
>
{CHEVRON_DOWN_GLYPH}
</span>
</button>;
}
function clampPage(page, totalPages) {
return Math.min(Math.max(1, page), Math.max(1, totalPages));
}
function pageRange(current, totalPages) {
const total = Math.max(1, totalPages);
const page = clampPage(current, total);
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1);
}
const wanted = /* @__PURE__ */ new Set([1, total, page - 1, page, page + 1]);
const sorted = Array.from(wanted).filter((p) => p >= 1 && p <= total).sort((a, b) => a - b);
const range = [];
let previous = 0;
for (const p of sorted) {
if (p - previous > 1) {
range.push("ellipsis");
}
range.push(p);
previous = p;
}
return range;
}
const PAGINATION_CONTROL_BASE = "inline-flex h-8 min-w-8 select-none items-center justify-center gap-1 whitespace-nowrap rounded-[var(--ds-radius-sm)] px-2 text-[13px] font-medium leading-4 tabular-nums transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const PAGINATION_CONTROL_IDLE = "text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-surface-hover)] hover:text-[var(--ds-color-foreground)]";
const PAGINATION_CONTROL_CURRENT = "border border-[var(--ds-color-border)] bg-[var(--ds-color-surface)] text-[var(--ds-color-foreground)] shadow-[var(--ds-shadow-xs)]";
function TablePagination({
page,
defaultPage = 1,
onPageChange,
totalItems,
pageSize = 10,
pageSizeOptions,
onPageSizeChange,
label = "Table pagination",
className
}) {
const isControlled = page !== undefined;
const [internal, setInternal] = useState(defaultPage);
const totalPages = Math.max(1, Math.ceil(totalItems / Math.max(1, pageSize)));
const current = clampPage(isControlled ? page : internal, totalPages);
function setPage(next) {
const clamped = clampPage(next, totalPages);
if (clamped === current) {
return;
}
if (!isControlled) {
setInternal(clamped);
}
onPageChange?.(clamped);
}
const from = totalItems === 0 ? 0 : (current - 1) * pageSize + 1;
const to = Math.min(current * pageSize, totalItems);
const range = pageRange(current, totalPages);
return <nav
aria-label={label}
className={cx("flex flex-wrap items-center justify-between gap-3", className)}
>
<p aria-live="polite" className="m-0 text-[13px] leading-4 text-[var(--ds-color-muted-foreground)]">
Showing <span className="font-medium text-[var(--ds-color-foreground)]">{from}</span>
{" \u2013 "}
<span className="font-medium text-[var(--ds-color-foreground)]">{to}</span> of{" "}
<span className="font-medium text-[var(--ds-color-foreground)]">{totalItems}</span>
</p>
<div className="flex flex-wrap items-center gap-3">
{pageSizeOptions ? <label className="flex items-center gap-2 text-[13px] leading-4 text-[var(--ds-color-muted-foreground)]">
Rows per page
<select
value={pageSize}
onChange={(event) => onPageSizeChange?.(Number(event.target.value))}
className="h-8 rounded-[var(--ds-radius-sm)] border border-[var(--ds-color-border)] bg-[var(--ds-color-input)] px-2 text-[13px] leading-4 text-[var(--ds-color-foreground)] transition-colors duration-150 ease-out hover:border-[var(--ds-color-border-strong)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] motion-reduce:transition-none"
>
{pageSizeOptions.map((option) => <option key={option} value={option}>
{option}
</option>)}
</select>
</label> : null}
<ul className="m-0 flex max-w-full list-none flex-wrap items-center gap-1 p-0">
<li className="inline-flex">
<button
type="button"
aria-label="Go to previous page"
disabled={current <= 1}
onClick={() => setPage(current - 1)}
className={cx(PAGINATION_CONTROL_BASE, PAGINATION_CONTROL_IDLE)}
>
<span aria-hidden="true" className="inline-flex [&_svg]:size-3.5">
{CHEVRON_LEFT_GLYPH}
</span>
<span>Previous</span>
</button>
</li>
{range.map(
(item, index) => item === "ellipsis" ? <li key={`ellipsis-${index}`} className="inline-flex">
<span
className={cx(PAGINATION_CONTROL_BASE, "pointer-events-none text-[var(--ds-color-muted-foreground)]")}
>
<span aria-hidden="true">…</span>
<span className="sr-only">More pages</span>
</span>
</li> : <li key={item} className="inline-flex">
<button
type="button"
aria-label={item === current ? `Page ${item}` : `Go to page ${item}`}
aria-current={item === current ? "page" : undefined}
onClick={() => setPage(item)}
className={cx(
PAGINATION_CONTROL_BASE,
item === current ? PAGINATION_CONTROL_CURRENT : PAGINATION_CONTROL_IDLE
)}
>
{item}
</button>
</li>
)}
<li className="inline-flex">
<button
type="button"
aria-label="Go to next page"
disabled={current >= totalPages}
onClick={() => setPage(current + 1)}
className={cx(PAGINATION_CONTROL_BASE, PAGINATION_CONTROL_IDLE)}
>
<span>Next</span>
<span aria-hidden="true" className="inline-flex [&_svg]:size-3.5">
{CHEVRON_RIGHT_GLYPH}
</span>
</button>
</li>
</ul>
</div>
</nav>;
}
function sortRows(rows, accessor, direction) {
const sorted = [...rows];
sorted.sort((a, b) => {
const av = accessor(a);
const bv = accessor(b);
const compared = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
return direction === "asc" ? compared : -compared;
});
return sorted;
}
function useRowSelection(keys) {
const [selected, setSelected] = useState(() => /* @__PURE__ */ new Set());
const count = useMemo(
() => keys.reduce((total, key) => selected.has(key) ? total + 1 : total, 0),
[keys, selected]
);
const allSelected = keys.length > 0 && count === keys.length;
const someSelected = count > 0 && !allSelected;
const isSelected = useCallback((key) => selected.has(key), [selected]);
const toggle = useCallback((key, checked) => {
setSelected((previous) => {
const next = new Set(previous);
if (checked ?? !next.has(key)) {
next.add(key);
} else {
next.delete(key);
}
return next;
});
}, []);
const toggleAll = useCallback(
(checked) => {
setSelected((previous) => {
const currentlyAll = keys.length > 0 && keys.every((key) => previous.has(key));
if (checked ?? !currentlyAll) {
return new Set(keys);
}
return /* @__PURE__ */ new Set();
});
},
[keys]
);
const clear = useCallback(() => setSelected(/* @__PURE__ */ new Set()), []);
return { selected, count, allSelected, someSelected, isSelected, toggle, toggleAll, clear };
}
export { Table, TableCaption, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableEmpty, TableLoading, TableActions, TableToolbar, TableSelection, TableExpand, clampPage, pageRange, TablePagination, sortRows, useRowSelection };
export default Table; # Status Table
## Overview
A table of realistic product data: semantic status badges (text + restrained token tints, never color alone), user cells with avatar initials, dates, right-aligned durations, rollout progress bars with real progressbar semantics, and row actions.
## Installation
This component requires **React** and **Tailwind CSS**. Drop `code.tsx` (or `code.jsx` for JavaScript projects) into your project. Tailwind utility classes are included directly in the component, so no separate CSS file is required.
The component consumes the DevSnips semantic design tokens through Tailwind arbitrary values (for example `bg-[var(--ds-color-surface)]`). Define the `--ds-*` tokens once in your theme — see [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the full token spec.
## Usage
```tsx
import Table, {
TableHeader, TableBody, TableRow, TableHead, TableCell, TableActions,
} from "./table";
<TableCell>
<span className={badgeClasses(job.status)}>
<span aria-hidden="true" className="size-1.5 rounded-full bg-current" />
{job.status}
</span>
</TableCell>
<TableCell>
<div role="progressbar" aria-valuenow={job.rollout} aria-valuemin={0}
aria-valuemax={100} aria-label={`Rollout of ${job.service}`}>
<div className={widthClass(job.rollout)} />
</div>
</TableCell>
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
import Table, {
TableHeader, TableBody, TableRow, TableHead, TableCell, TableActions,
} from "./table";
<TableCell>
<span className={badgeClasses(job.status)}>
<span aria-hidden="true" className="size-1.5 rounded-full bg-current" />
{job.status}
</span>
</TableCell>
<TableCell>
<div role="progressbar" aria-valuenow={job.rollout} aria-valuemin={0}
aria-valuemax={100} aria-label={`Rollout of ${job.service}`}>
<div className={widthClass(job.rollout)} />
</div>
</TableCell>
```
## Props
### `<Table>`
| Name | Type | Default | Description |
|---|---|---|---|
| `density` | `"default" \| "compact"` | `"default"` | Cell/header density shared by every region primitive via context. |
| `loading` | `boolean` | `false` | Sets `aria-busy="true"` on the `<table>` (pair with `<TableLoading>`). |
| `containerClassName` | `string` | — | Extra classes on the bordered `overflow-x-auto` scroll container. |
| `className` | `string` | — | Extra classes on the `<table>` itself. |
| `children` | `ReactNode` | — | `TableCaption`, `TableHeader`, `TableBody`, `TableFooter` compositions. |
Every other attribute of a plain `<table>` (`id`, `aria-*`, `data-*`) is forwarded.
### `<TableRow>`
| Name | Type | Default | Description |
|---|---|---|---|
| `selected` | `boolean` | `false` | `aria-selected="true"` + accent-tinted surface. |
| `disabled` | `boolean` | `false` | `aria-disabled="true"` + reduced opacity, no hover. Disable the row's controls too. |
| `className` | `string` | — | Extra classes on the row. |
| `children` | `ReactNode` | — | `TableHead` / `TableCell` children. |
A real `<tr>`. Rows are never fake buttons — interactive content lives in real controls inside the cells.
### `<TableHead>`
| Name | Type | Default | Description |
|---|---|---|---|
| `align` | `"left" \| "center" \| "right"` | `"left"` | Header alignment (match the column's cells). |
| `sortable` | `boolean` | `false` | Render the label as a real sort `<button>` and manage `aria-sort`. |
| `sortDirection` | `"asc" \| "desc" \| null` | `null` | Current direction; `null` = sortable but unsorted (`aria-sort="none"`). |
| `onSort` | `() => void` | — | Called when the sort button is activated. |
| `scope` | `string` | `"col"` | Header scope; use `"rowgroup"` for group header rows. |
| `className` | `string` | — | Extra classes on the `<th>`. |
| `children` | `ReactNode` | — | Header content. |
A real `<th scope="col">`; `colSpan` and the other native `<th>` attributes forward.
### `<TableCell>`
| Name | Type | Default | Description |
|---|---|---|---|
| `align` | `"left" \| "center" \| "right"` | `"left"` | Cell alignment (numeric columns: `right`). |
| `numeric` | `boolean` | `false` | Tabular figures (`tabular-nums`) for numeric content. |
| `className` | `string` | — | Extra classes on the `<td>`. |
| `children` | `ReactNode` | — | Cell content — text, links, badges, controls. |
A real `<td>`; `colSpan` / `rowSpan` / `headers` forward natively.
### `<TableActions>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the cluster. |
| `children` | `ReactNode` | — | Real `<button>` / `<a>` controls. |
An end-aligned control cluster for a cell (`<TableCell align="right">`). Never nest a control inside another control.
## Compound components
The family is a compound component over real table semantics. Compose only the regions a table needs:
```tsx
<Table>
<TableCaption />
<TableHeader>
<TableRow>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell />
</TableRow>
</TableBody>
<TableFooter />
</Table>
```
| Primitive | Element | Purpose |
|---|---|---|
| `<Table>` | bordered container + `<table>` | Root; provides the `density` context and the deliberate `overflow-x-auto` scroll region; `loading` sets `aria-busy`. |
| `<TableCaption>` | `<caption>` | The table's accessible name/description, rendered above the table. |
| `<TableHeader>` | `<thead>` | Column-header section (subtle surface, bottom rule). |
| `<TableBody>` | `<tbody>` | Data rows; the last row's divider is removed. |
| `<TableFooter>` | `<tfoot>` | Totals/summaries (subtle surface, top rule). |
| `<TableRow>` | `<tr>` | Hover affordance; `selected` (`aria-selected` + accent tint) and `disabled` (`aria-disabled` + reduced opacity) states. |
| `<TableHead>` | `<th scope="col">` | Column header; `sortable` renders a real sort button and manages `aria-sort`. |
| `<TableCell>` | `<td>` | Data cell; `align` + `numeric` (tabular figures); `colSpan` forwards natively. |
| `<TableEmpty>` | `<tr>` + spanning `<td>` | Honest zero-data state (title, description, optional real action). |
| `<TableLoading>` | skeleton `<tr>`s | Geometry-preserving skeleton rows (pair with `<Table loading>`). |
| `<TableActions>` | `<div>` in a cell | End-aligned cluster of real controls. |
| `<TableToolbar>` | `<div>` above the table | Selection counts, filters, primary actions (layout only). |
| `<TablePagination>` | `<nav aria-label>` | Self-contained pagination bar (status, windowed pages, Previous/Next, optional page-size select). |
| `<TableSelection>` | native `<input type="checkbox">` | Row / select-all selection with a true `.indeterminate` tri-state. |
| `<TableExpand>` | `<button>` | Row expand/collapse trigger (`aria-expanded` / `aria-controls`). |
Every region primitive throws a descriptive error when rendered outside `<Table>` (except `TableToolbar` and `TablePagination`, which live next to the table).
Mixed cell content is just composition inside `<TableCell>`: badges, avatar + name stacks, mono timestamps, progress bars, and an actions cluster — all on the same restrained row grid, with no card-in-cell nesting.
## Data modeling
Tables are data-driven but unopinionated about your data shape. The conventions that keep them sound:
- **Row keys** — give every `<TableRow>` a stable, unique React `key` (an id from your data, never the array index of a sorted/filtered list).
- **Column definitions** — for data-driven tables, describe columns once (`key`, `label`, `accessor`, optional `align` / `numeric` / `format`) and map them to `<TableHead>` / `<TableCell>`; the sortable variant shows the pattern.
- **Custom cell rendering** — cells are just `<td>`s: render links, badges, avatars, progress bars, or controls inside `<TableCell>`; use `<TableActions>` for the trailing actions column.
- **Custom header rendering** — `<TableHead>` accepts any `children`; pass `sortable` + `sortDirection` + `onSort` only for columns that genuinely sort.
- **Alignment** — text columns stay `left`; numeric columns use `align="right"` + `numeric` (tabular figures) on BOTH the header and the cells so digits line up.
- **Column sizing** — the table is `w-full` with automatic layout; constrain a column with a `max-w-*` + `truncate` class on its cells (keep the full value available via `title` or an expansion panel), or size the whole table with `containerClassName`.
## Sorting
Sorting is primitive-driven and real — there is no fake "sorted-looking" state:
1. Mark the column `<TableHead sortable sortDirection={direction} onSort={cycle}>`. The head renders a visible `<button type="button">` (click, Enter, and Space all work) and the `<th>` carries `aria-sort` — `"ascending"` / `"descending"` on the active column, `"none"` on sortable-but-inactive columns.
2. Track `{ key, direction }` in state. The recommended cycle is **ascending → descending → unsorted** (unsorted restores the original data order — a real reset, not a third sort).
3. Order the data with the typed `sortRows(rows, accessor, direction)` helper: it returns a sorted COPY (strings via `localeCompare`, numbers numerically) and immutably leaves the source array alone.
Only one column sorts at a time in this system — that keeps `aria-sort` honest (exactly one column announces a direction) and the model understandable. Multi-column sorting is deliberately out of scope (see Limitations).
## Selection
Selection uses REAL native checkboxes — never div fakes:
- Each selectable row renders `<TableSelection checked={...} onCheckedChange={...} label="Select <row name>" />` in its first cell; the header renders a `<TableSelection>` for select-all.
- The typed `useRowSelection(selectableKeys)` hook tracks the selected key set and derives `count`, `allSelected`, and `someSelected`. Pass `allSelected` to the header checkbox's `checked` and `someSelected` to its `indeterminate` — the tri-state is the true `.indeterminate` IDL property set imperatively on the DOM node (no HTML attribute exists), so it renders a dash distinct from the check mark.
- Disabled rows keep their checkbox `disabled`, are excluded from the selectable key list, and therefore never count toward select-all.
- Selected rows get `selected` on `<TableRow>`: `aria-selected="true"` plus an accent-tinted surface derived from tokens via `color-mix` — strong, and never color alone (the checkbox state carries the same information).
## Expansion
Row expansion uses a real toggle button and a real content row:
- The trigger is `<TableExpand expanded={...} controls={panelId} label="details for <row>" onClick={toggle} />` — a `<button type="button">` with `aria-expanded` and `aria-controls`, operable from the keyboard.
- The expanded content is a real `<TableRow>` whose `<TableCell colSpan={columnCount} id={panelId}>` holds the panel (a description list, text, or any composition — avoid nested tables unless the data genuinely is tabular).
- Expansion toggles instantly (no height animation), so there is no layout thrash and nothing is ever hidden from keyboard users in a half-open state; focus stays on the trigger when a row opens or closes.
- Track the open rows as a `Set` of keys — multiple rows can be open at once unless you deliberately close siblings.
## Pagination
`<TablePagination>` is a self-contained pagination bar that follows the DevSnips Pagination family's semantics. It reports and changes the current page; the parent slices the dataset:
```tsx
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
const safePage = clampPage(page, totalPages);
const visible = rows.slice((safePage - 1) * pageSize, safePage * pageSize);
<TablePagination
page={safePage}
onPageChange={setPage}
totalItems={rows.length}
pageSize={pageSize}
pageSizeOptions={[8, 12, 20]}
onPageSizeChange={(size) => { setPageSize(size); setPage(1); }}
/>
```
- Changing the page changes the visible rows — there is no decorative pagination footer.
- Previous/Next disable natively at the boundaries; the current page carries `aria-current="page"`; the page list windows with non-interactive ellipses for large counts.
- Every page value is clamped with the exported `clampPage`, so an empty page cannot occur through invalid state (for example after the dataset shrinks or the page size grows — reset to page 1 on page-size change, as above).
- The "Showing X–Y of Z" status is an `aria-live="polite"` region so page changes are announced.
## Behavior
This variant is the reality check: six deployment jobs with genuinely mixed content — a service name, a semantic status badge, the person who deployed, a mono timestamp, a right-aligned duration, a rollout progress bar, and row actions — and the table stays a table, not an over-designed dashboard.
Status badges pair a small tinted dot with readable text on a token-derived soft tint (the same `color-mix` derivation the Alerts family uses): Running = info, Complete = success, Failed = destructive, Queued = neutral. The text carries the meaning; the tint only speeds scanning.
The rollout bar is a real `role="progressbar"` with `aria-valuenow` / `aria-valuemin` / `aria-valuemax` and an `aria-label` naming the service — the fill width is visual, the values are the content. Row actions (View link + Retry button) follow the actions variant's rules: real controls, accessible names, nothing nested.
## Responsive behavior
The table fills its container (`w-full`) inside a deliberate `overflow-x-auto` scroll region: when a dataset is genuinely wider than the viewport, the table scrolls horizontally inside its bordered container — the page itself never gains a scrollbar, and the caption, header rule, and row geometry stay intact. Cell text wraps by default; numeric columns use tabular figures so values stay comparable at any width. No horizontal page overflow at 375 / 768 / 1280px. Seven columns of mixed content is a genuinely wide dataset — the deliberate `overflow-x-auto` container handles it at 375px while every column keeps its alignment and every control stays reachable.
## Keyboard interaction
Every interactive element in the table is a real native control, so the keyboard model is the browser's: Tab / Shift+Tab moves through sort buttons, checkboxes, expand triggers, links, action buttons, and pagination controls; Enter/Space activates them; and a `focus-visible` ring (2px, `color.focus-ring` token) marks keyboard focus. Rows are never fake buttons, so Tab never stops on a row itself — only on the operable controls inside it.
Row actions are real controls (a link and a button): Tab reaches them, Enter activates them, and the retry action records to the live log. Progress bars and badges are not focusable — they are content, not controls.
## Accessibility
- Real table semantics throughout: `<table>` / `<caption>` / `<thead>` / `<tbody>` / `<tfoot>` / `<tr>` / `<th scope="col">` / `<td>` — never a grid of divs, and no `role="grid"` re-declaration (native semantics are the correct semantics here).
- The `<TableCaption>` names the dataset for assistive technology; prefer it over an off-table heading.
- Controls are real native elements with accessible names: sort buttons announce their label, checkboxes carry `aria-label`, the expand trigger carries `aria-expanded` + `aria-controls`, and pagination controls carry `aria-current` / native `disabled`.
- State is never communicated by color alone: selection pairs its tinted surface with the checkbox's checked state and `aria-selected`; status badges pair a tinted dot with readable text; the indeterminate select-all state is the true `.indeterminate` IDL property.
- Status is text + tint, never color alone; the progress bar exposes its value through `role="progressbar"` + `aria-value*`; the avatar initials are `aria-hidden` because the adjacent name is the accessible content.
## States
- **Header / footer** — `surface-subtle` with 1px `color.border` rules and `label-sm` type (12px, medium, muted).
- **Row** — `border-subtle` divider, a restrained `surface-hover` shift on hover, and a `transition-colors` that is disabled under reduced motion.
- **Selected row** — `aria-selected` + an accent-tinted surface derived from the accent token via `color-mix` (8% rest, 12% hover).
- **Disabled row** — `aria-disabled`, 60% opacity, no hover affordance; its controls are natively disabled.
- **Controls** — muted-at-rest with a foreground hover shift, a `focus-visible` ring, and native `disabled` styling (50% opacity, no pointer events).
- **Status badge** — 1px tinted border + soft tinted surface + tinted text + dot, all derived from the semantic tokens via `color-mix`; Queued is a neutral bordered chip.
- **Progress bar** — `color.muted` track, `color.accent` fill, `radius-full`, 6px tall.
- **Avatar** — `accent-soft` circle with accent-colored initials, always `aria-hidden` (the name next to it is the content).
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-surface)]`); the selected-row tint derives from the accent token with `color-mix`, so no component-specific color values are invented. Define the tokens once in your theme — no component-specific CSS file is required.
## Design tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This table follows the token system's Table rules: compact-or-default density, clear header styling (`surface-subtle` header/footer, `label-sm` header type), 1px `color.border` / `color.border-subtle` rules instead of shadows, `surface-hover` row affordance, an accent-tinted selected state, semantic status colors for badges, and the `color.focus-ring` token on every control.
## Loading and empty states
**Loading.** Set `loading` on `<Table>` (adds `aria-busy="true"`) and render `<TableLoading columns={n} rows={m} />` inside `<TableBody>`. The skeleton rows keep the table's approximate geometry (same column count, near-identical row heights) to minimize layout shift; the bars are `aria-hidden` decorative placeholders with a subtle pulse that is disabled under reduced motion, and a visually hidden row announces "Loading data".
**Empty.** Render `<TableEmpty colSpan={n} title="…" description="…" action={…} />` inside `<TableBody>` when the dataset is empty. It is one real row with one spanning cell — never fake placeholder rows — and the optional action must be a real control that resolves the state (create, clear filters, retry).
## Notes
Badge tints derive from the semantic tokens with `color-mix` (border ~35% tone + border, surface ~8% tone + surface) — no new color values are invented, and dark mode stays in sync for free.
## Limitations
- **Single-column sorting only.** `aria-sort` is only honest when exactly one column is sorted; multi-column sort is deliberately not built in.
- **No virtualized scrolling.** The table renders every row it is given; for very large datasets paginate (see `<TablePagination>`) or window the data yourself.
- **No column resizing or reordering.** Column sizing is class-based (`max-w-*` + `truncate`, `containerClassName`).
- **No ARIA grid mode.** The family intentionally keeps native table semantics; spreadsheet-style cell-to-cell arrow-key navigation would require `role="grid"` and is out of scope.
- **Sticky headers/columns are not built in.** The scroll container is `overflow-x-auto`; sticky positioning can be layered on with classes but is not part of the shipped core.
- **The responsive card presentation is a composition pattern**, not a primitive: the table-responsive variant shows how to render the same data as a card list below `sm` — your app owns that mapping. 1021 lines UTF-8 · LF · Spaces: 2
Continue browsing