Component
Responsive Table
A table with an intentionally designed narrow-screen presentation: a full semantic table at desktop, replaced below 640px by a card list built from the same data — not a squeezed table and not an afterthought scroll.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/tables/table-responsive/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Tables/table-responsive React/Components/Tables/table-responsive 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-responsive.
*
* The shared compound core (identical to the reference `table` variant) with
* the showcase focused on the narrow-screen presentation: the full semantic
* table renders at `sm` and up, and below `sm` the SAME data renders as a
* deliberately designed card list (name + status header, `dl` label/value
* pairs, actions footer) — not a squeezed table, not an afterthought
* scroll.
*/
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; # Responsive Table
## Overview
A table with an intentionally designed narrow-screen presentation: a full semantic table at desktop, replaced below 640px by a card list built from the same data — not a squeezed table and not an afterthought scroll.
## 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,
} from "./table";
{/* Desktop: the full semantic table */}
<div className="hidden sm:block">
<Table>…all columns…</Table>
</div>
{/* Mobile: a card list built from the SAME data */}
<ul className="m-0 list-none space-y-3 p-0 sm:hidden">
{projects.map((p) => (
<li key={p.id} className="rounded-[var(--ds-radius-md)] border p-3">
<header>{p.name} — {p.status}</header>
<dl>…Owner / Updated / Budget label-value pairs…</dl>
<footer>…View link + Edit button…</footer>
</li>
))}
</ul>
```
## 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,
} from "./table";
{/* Desktop: the full semantic table */}
<div className="hidden sm:block">
<Table>…all columns…</Table>
</div>
{/* Mobile: a card list built from the SAME data */}
<ul className="m-0 list-none space-y-3 p-0 sm:hidden">
{projects.map((p) => (
<li key={p.id} className="rounded-[var(--ds-radius-md)] border p-3">
<header>{p.name} — {p.status}</header>
<dl>…Owner / Updated / Budget label-value pairs…</dl>
<footer>…View link + Edit button…</footer>
</li>
))}
</ul>
```
## 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.
### `<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).
The responsive pattern is a composition, not a primitive: the desktop table and the mobile card list render side by side from the same data array, each hidden at the other's breakpoint (`hidden sm:block` / `sm:hidden`). Your app owns the card mapping — the table primitives own the desktop half.
## 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
Below 640px, four columns of project data stop being a table. The mobile presentation is a deliberately designed card list: each project becomes a bordered card with its name and status badge up top, the remaining fields as label/value pairs in a description list, and the same actions (View link, Edit button) in a footer row.
Both presentations render from the SAME data array — the cards are not a second, stale copy of the content. Nothing is hidden on mobile: every field and every action present on desktop is present in the cards, just re-laid-out for a narrow column.
This is the honest alternative to two common non-solutions: squeezing the table until cells are unreadable, or adding `overflow-x-auto` and calling the problem solved. (Horizontal scrolling IS the right answer for genuinely wide datasets — the container does it deliberately — but four columns of project metadata reads better as cards.)
## Responsive behavior
The whole point of the variant: at 1280px and 768px the full table renders; below 640px the card list renders instead. Both are fully usable and neither produces horizontal page overflow at 375 / 768 / 1280px.
## 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.
Only one presentation is visible at a time, and the hidden one is `display: none` — so keyboard users tab through exactly the controls they can see, never into the hidden presentation.
## 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.
- The hidden presentation is `display: none`, so it is removed from the accessibility tree — screen-reader users get exactly one presentation.
- Cards keep a `dl` label/value structure, so "Owner: Grace Hopper" reads as a pair, not as two orphaned text runs.
## 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).
- **Mobile card** — `surface` + 1px `color.border` + `radius-md`, name + status badge header, `dl` label/value pairs, actions footer separated by a `border-subtle` rule.
## 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
Choose the card threshold per dataset (`sm` here). Genuinely wide or numeric-heavy datasets should stay tables and use the container's deliberate horizontal scroll instead — see the compact and status variants.
## 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