Table
Two data tables: a virtualized Table that renders only the visible rows of large datasets, with sorting, row selection, resizable and movable columns and editable cells, and a paginated DataTable with filters and expandable rows. Both can turn rows into cards on narrow screens with mobileLayout="stack".
Data Table
index.tsxVirtualized rows under a sticky header, click-to-sort columns, checkbox selection with select-all, and drag handles to resize or move columns; a fixed row height keeps 10,000 rows scrolling smoothly.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i @tanstack/react-virtual clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/table
import { useVirtualizer } from "@tanstack/react-virtual";
import { MoreVertical } from "lucide-react";
import { useReducedMotion } from "motion/react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { Checkbox } from "@/components/motion/checkbox";
import { cn } from "@/lib/utils";
import { EditableCell } from "./editable-cell";
import { RowHandle, rowMenuItems } from "./row-handle";
import { SkeletonCards, SkeletonRows } from "./skeleton-rows";
import {
DEFAULT_STACK_BELOW,
estimateCardHeight,
StackedFields,
StackSortSelect,
splitStackColumns,
useStackedLayout,
} from "./stacked";
import { TableMenu } from "./table-menu";
import { TableHeader } from "./table-header";
import type { HeaderCellRefs, TableProps } from "./types";
import { useColumnReorder } from "./use-column-reorder";
import { useColumnResize } from "./use-column-resize";
import { useColumnSort } from "./use-column-sort";
import { useRowSelection } from "./use-row-selection";
import { alignText, CHECKBOX_PX, CHECKBOX_WIDTH, readCell } from "./utils";
export type {
MobileLayout,
SortDirection,
SortState,
TableColumn,
TableProps,
} from "./types";
/**
* Narrowest a column of bare inputs may be floored to and still show a value:
* the cell's own `px-4` eats 32 of it.
*/
const INPUT_COLUMN_WIDTH = 120;
/** The root font size Tailwind's rem scale assumes, and the pre-measure guess. */
const DEFAULT_ROOT_FONT_SIZE = 16;
/**
* What one `rem` is worth here, in px. The default until the first client
* layout, so the server and the hydrating client emit the same floor; measured
* once after that, because a document that sets its own `html { font-size }`
* lays a rem column out against that size and a floor computed from 16 would
* fall short by the same factor.
*/
function useRootFontSize() {
const [size, setSize] = useState(DEFAULT_ROOT_FONT_SIZE);
useEffect(() => {
const measured = Number.parseFloat(
getComputedStyle(document.documentElement).fontSize,
);
if (measured > 0) setSize(measured);
}, []);
return size;
}
/**
* The absolute width a column declared, in px, or null when it declared a share
* of the remainder instead (`fr`, `%`, `auto`, `calc()`, nothing at all) — those
* are worth whatever is left over, which is not a width this can add up.
*/
function resolveColumnWidth(
width: string | undefined,
rootFontSize: number,
): number | null {
if (!width) return null;
const value = Number.parseFloat(width);
if (!Number.isFinite(value)) return null;
if (width.endsWith("px")) return value;
// rem is the other absolute length the repo writes.
if (width.endsWith("rem")) return value * rootFontSize;
return null;
}
export function Table<T>({
data,
columns,
getRowId,
selectable = false,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
sort: sortProp,
defaultSort = null,
onSortChange,
resizable = false,
minColumnWidth = 64,
onColumnResize,
reorderable = false,
onColumnOrderChange,
onCellEdit,
onColumnRename,
onInsertRow,
onDeleteRow,
onInsertColumn,
onDeleteColumn,
rowHeight = 48,
height = 440,
overscan = 10,
onEndReached,
loading = false,
skeletonRows = 3,
emptyState = "No data",
mobileLayout = "scroll",
stackBelow = DEFAULT_STACK_BELOW,
className,
}: TableProps<T>) {
const reduce = useReducedMotion();
const rootRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const stacked = useStackedLayout(
rootRef,
mobileLayout === "stack",
stackBelow,
);
const thRefs: HeaderCellRefs = useRef<
Record<string, HTMLTableCellElement | null>
>({});
const rows = useMemo(
() =>
data.map((row, index) => ({
row,
id: getRowId ? getRowId(row, index) : String(index),
})),
[data, getRowId],
);
const {
orderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
} = useColumnReorder({ columns, thRefs, onColumnOrderChange });
const { sort, sortedRows, toggleSort, setSort } = useColumnSort({
rows,
columns,
sort: sortProp,
defaultSort,
onSortChange,
});
const { widths, startResize, moveResize, endResize } = useColumnResize({
orderedColumns,
thRefs,
minColumnWidth,
onColumnResize,
});
const { selected, allSelected, someSelected, toggleAll, toggleRow } =
useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
// Stacked mode keeps the virtualizer: cards vary in height, so each one is
// measured (`measureElement`) once rendered, starting from an estimate.
const { primary: titleColumns, fields: fieldColumns } = useMemo(
() => splitStackColumns(orderedColumns),
[orderedColumns],
);
const itemEstimate = stacked
? estimateCardHeight(fieldColumns.length)
: rowHeight;
const getItemKey = useCallback(
(index: number) => sortedRows[index]?.id ?? index,
[sortedRows],
);
const virtualizer = useVirtualizer<HTMLDivElement, HTMLLIElement>({
count: sortedRows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => itemEstimate,
getItemKey,
overscan,
});
// Switching layouts (or card shape) invalidates every cached size.
// biome-ignore lint/correctness/useExhaustiveDependencies: itemEstimate is the reset trigger.
useLayoutEffect(() => {
virtualizer.measure();
}, [virtualizer, itemEstimate]);
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0;
const paddingBottom =
virtualItems.length > 0
? totalSize - virtualItems[virtualItems.length - 1].end
: 0;
const hasRowMenu = !!(onInsertRow || onDeleteRow);
const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);
// Only shrink-wrap (w-max) once every column has an explicit resized width;
// otherwise stay fill-width so a flexible column can't size to cell content.
const sized =
orderedColumns.length > 0 &&
orderedColumns.every((c) => widths[c.key] != null);
const rootFontSize = useRootFontSize();
// In a container narrower than the columns, `table-layout: fixed` shrinks
// every column toward zero instead of scrolling. Floor the table at what the
// columns actually asked for — an absolute declared width where there is one,
// and for the ones sharing the remainder whatever content they can fall back
// on — then let the viewport scroll past it.
const minTableWidth = useMemo(() => {
// A column whose cells render bare inputs has no content for the fallback
// to measure, which is exactly the column that collapses; a renamable
// header is an input too, and in a fixed layout the header row is what
// sizes the column.
const inputOnly = (column: (typeof orderedColumns)[number]) =>
Boolean(onColumnRename) || (!column.cell && Boolean(column.editable));
const total = orderedColumns.reduce((sum, column) => {
const resized = widths[column.key];
if (resized != null) return sum + resized;
const declared = resolveColumnWidth(column.width, rootFontSize);
if (declared != null) return sum + declared;
return (
sum +
(inputOnly(column)
? Math.max(minColumnWidth, INPUT_COLUMN_WIDTH)
: minColumnWidth)
);
}, selectable ? CHECKBOX_PX : 0);
return Math.round(total);
}, [
minColumnWidth,
onColumnRename,
orderedColumns,
rootFontSize,
selectable,
widths,
]);
// Infinite scroll: fire onEndReached once per near-bottom dwell, paused while
// loading; the guard resets when the load completes.
const endReachedRef = useRef(false);
useEffect(() => {
if (!loading) endReachedRef.current = false;
}, [loading]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || !onEndReached || loading || endReachedRef.current) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight < itemEstimate * 4) {
endReachedRef.current = true;
onEndReached();
}
}, [onEndReached, loading, itemEstimate]);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
// Small delay on leave so the pointer can cross the gap from the header cell
// to the portal handle without the column deactivating.
const deactivateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateColumn = useCallback((key: string) => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = null;
setActiveColumn(key);
}, []);
const deactivateColumn = useCallback(() => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = setTimeout(() => setActiveColumn(null), 100);
}, []);
const rowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
const [activeRow, setActiveRow] = useState<{ id: string; index: number } | null>(
null,
);
const rowTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateRow = useCallback((id: string, index: number) => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = null;
setActiveRow({ id, index });
}, []);
const deactivateRow = useCallback(() => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = setTimeout(() => setActiveRow(null), 100);
}, []);
const activeRowEl = activeRow ? rowRefs.current[activeRow.id] : null;
// Real columns + checkbox; the trailing spacer adds one more in colSpans.
const leadColumns = columns.length + (selectable ? 1 : 0);
const showStackControls =
selectable || orderedColumns.some((column) => column.sortable);
const renderValue = (
entry: (typeof sortedRows)[number],
column: (typeof orderedColumns)[number],
index: number,
) =>
!column.cell && column.editable ? (
<EditableCell
value={String(readCell(entry.row, column) ?? "")}
label={`${column.key} for row ${index + 1}`}
onChange={(next) => onCellEdit?.(entry.id, column.key, next)}
/>
) : (
readCell(entry.row, column)
);
return (
<div
ref={rootRef}
data-layout={stacked ? "stack" : "table"}
className={cn(
"w-full overflow-hidden border border-border bg-background text-sm",
className,
)}
>
{stacked && showStackControls ? (
<div className="flex min-h-11 flex-wrap items-center gap-x-4 gap-y-2 border-border border-b bg-muted/40 px-3 py-1.5">
{selectable ? (
<Checkbox
checked={allSelected}
indeterminate={!allSelected && someSelected}
onCheckedChange={toggleAll}
label="Select all"
className="text-muted-foreground text-xs"
/>
) : null}
<StackSortSelect
columns={orderedColumns}
value={sort}
onChange={setSort}
className="ml-auto"
/>
</div>
) : null}
<div
ref={scrollRef}
onScroll={handleScroll}
className="overflow-auto"
style={{ height }}
>
{stacked ? (
sortedRows.length === 0 && !loading ? (
<div className="p-10 text-center text-muted-foreground">
{emptyState}
</div>
) : (
<>
{sortedRows.length > 0 ? (
<ul
aria-label="Rows"
className="relative"
style={{ height: totalSize }}
>
{virtualItems.map((vItem) => {
const entry = sortedRows[vItem.index];
const isSelected = selected.has(entry.id);
return (
<li
key={entry.id}
ref={virtualizer.measureElement}
data-index={vItem.index}
data-selected={isSelected}
aria-setsize={sortedRows.length}
aria-posinset={vItem.index + 1}
className={cn(
"absolute inset-x-0 top-0 border-border/60 border-b px-3 py-3 transition-colors",
"data-[selected=true]:bg-primary/5",
)}
style={{ transform: `translateY(${vItem.start}px)` }}
>
<div className="flex min-w-0 items-center gap-3">
{selectable ? (
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRow(entry.id)}
aria-label={`Select row ${vItem.index + 1}`}
/>
) : null}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 font-medium text-foreground">
{titleColumns.map((column) => (
<span
key={column.key}
className="min-w-0 flex-1 truncate"
>
{renderValue(entry, column, vItem.index)}
</span>
))}
</div>
{hasRowMenu ? (
<TableMenu
ariaLabel={`Row ${vItem.index + 1} options`}
triggerClassName="grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
trigger={<MoreVertical className="size-4" />}
items={rowMenuItems({
id: entry.id,
index: vItem.index,
onInsertRow,
onDeleteRow,
})}
/>
) : null}
</div>
<StackedFields
className={cn("mt-2", selectable && "pl-8")}
fields={fieldColumns.map((column) => ({
key: column.key,
label: column.header,
value: renderValue(entry, column, vItem.index),
control: !column.cell && !!column.editable,
}))}
/>
</li>
);
})}
</ul>
) : null}
{loading ? (
<SkeletonCards
count={
sortedRows.length === 0
? Math.max(1, Math.ceil(height / itemEstimate))
: skeletonRows
}
fields={fieldColumns.length}
/>
) : null}
</>
)
) : (
<table
className={cn("border-collapse", sized ? "w-max" : undefined)}
style={{
tableLayout: "fixed",
minWidth: `max(100%, ${minTableWidth}px)`,
}}
>
<colgroup>
{selectable ? <col style={{ width: CHECKBOX_WIDTH }} /> : null}
{orderedColumns.map((column) => {
const override = widths[column.key];
const width = override ? `${override}px` : column.width;
return (
<col key={column.key} style={width ? { width } : undefined} />
);
})}
{/* Empty filler owns the leftover space — no gap, content unpinned. */}
<col />
</colgroup>
<TableHeader
columns={orderedColumns}
rowHeight={rowHeight}
reduce={!!reduce}
thRefs={thRefs}
selectable={selectable}
allSelected={allSelected}
someSelected={someSelected}
onToggleAll={toggleAll}
sort={sort}
onToggleSort={toggleSort}
resizable={resizable}
onResizeStart={startResize}
onResizeMove={moveResize}
onResizeEnd={endResize}
reorderable={reorderable}
dragKey={dragKey}
dropIndex={dropIndex}
onReorderStart={startReorder}
onReorderMove={moveReorder}
onReorderEnd={endReorder}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onColumnRename={onColumnRename}
activeColumn={hasColumnMenu ? activeColumn : null}
onColumnActivate={hasColumnMenu ? activateColumn : undefined}
onColumnDeactivate={hasColumnMenu ? deactivateColumn : undefined}
/>
<tbody>
{sortedRows.length === 0 ? (
loading ? (
<SkeletonRows
count={Math.max(1, Math.ceil(height / rowHeight))}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : (
<tr>
<td
colSpan={leadColumns + 1}
className="p-10 text-center text-muted-foreground"
>
{emptyState}
</td>
</tr>
)
) : (
<>
{paddingTop > 0 ? (
<tr aria-hidden style={{ height: paddingTop }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{virtualItems.map((vItem) => {
const entry = sortedRows[vItem.index];
const isSelected = selected.has(entry.id);
return (
<tr
key={entry.id}
ref={(el) => {
rowRefs.current[entry.id] = el;
}}
data-selected={isSelected}
style={{ height: rowHeight }}
onPointerEnter={
hasRowMenu
? () => activateRow(entry.id, vItem.index)
: undefined
}
onPointerLeave={hasRowMenu ? deactivateRow : undefined}
className={cn(
"border-border/60 border-b transition-colors",
"data-[selected=true]:bg-primary/5",
"hover:bg-muted/50",
)}
>
{selectable ? (
<td className="text-center">
<div className="flex items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRow(entry.id)}
aria-label={`Select row ${vItem.index + 1}`}
/>
</div>
</td>
) : null}
{orderedColumns.map((column) => (
<td
key={column.key}
className={cn(
"truncate px-4 text-foreground",
alignText(column.align),
)}
>
{renderValue(entry, column, vItem.index)}
</td>
))}
<td aria-hidden />
</tr>
);
})}
{paddingBottom > 0 ? (
<tr aria-hidden style={{ height: paddingBottom }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{loading ? (
<SkeletonRows
count={skeletonRows}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : null}
</>
)}
</tbody>
</table>
)}
</div>
{hasRowMenu && activeRow && !stacked ? (
<RowHandle
rowEl={activeRowEl}
id={activeRow.id}
index={activeRow.index}
onInsertRow={onInsertRow}
onDeleteRow={onDeleteRow}
onEnter={() => activateRow(activeRow.id, activeRow.index)}
onLeave={deactivateRow}
/>
) : null}
</div>
);
}
"use client";
export function EditableCell({
value,
label,
onChange,
}: {
value: string;
label: string;
onChange: (next: string) => void;
}) {
return (
<input
value={value}
aria-label={label}
size={1}
onChange={(e) => onChange(e.target.value)}
placeholder="Empty"
className="-mx-2 w-full min-w-0 appearance-none rounded-md border-0 bg-transparent px-2 py-1 text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:bg-muted focus:ring-1 focus:ring-ring"
/>
);
}
"use client";
import { ArrowDownToLine, ArrowUpToLine, MoreVertical, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { TableMenu, type TableMenuItem } from "./table-menu";
/** Row menu entries (Insert before / after, Delete) for whichever callbacks are set. */
export function rowMenuItems({
id,
index,
onInsertRow,
onDeleteRow,
}: {
id: string;
index: number;
onInsertRow?: (index: number, position: "before" | "after") => void;
onDeleteRow?: (rowId: string, index: number) => void;
}): TableMenuItem[] {
return [
...(onInsertRow
? [
{
label: "Insert before",
icon: <ArrowUpToLine />,
onSelect: () => onInsertRow(index, "before"),
},
{
label: "Insert after",
icon: <ArrowDownToLine />,
onSelect: () => onInsertRow(index, "after"),
},
]
: []),
...(onDeleteRow
? [
{
label: "Delete row",
icon: <Trash2 />,
destructive: true,
onSelect: () => onDeleteRow(id, index),
},
]
: []),
];
}
/** The row handle, portaled so it can sit on the row's left border without the
* scroll container clipping it. Straddles the border to bridge hover. */
export function RowHandle({
rowEl,
id,
index,
onInsertRow,
onDeleteRow,
onEnter,
onLeave,
}: {
rowEl: HTMLTableRowElement | null;
id: string;
index: number;
onInsertRow?: (index: number, position: "before" | "after") => void;
onDeleteRow?: (rowId: string, index: number) => void;
onEnter: () => void;
onLeave: () => void;
}) {
useEffect(() => {
window.addEventListener("scroll", onLeave, true);
return () => window.removeEventListener("scroll", onLeave, true);
}, [onLeave]);
if (!rowEl || typeof document === "undefined") return null;
const rect = rowEl.getBoundingClientRect();
return createPortal(
<div
style={{
position: "fixed",
top: rect.top + rect.height / 2,
left: rect.left,
transform: "translate(-50%, -50%)",
zIndex: 40,
}}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
>
<TableMenu
ariaLabel={`Row ${index + 1} options`}
triggerClassName="flex h-6 w-2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
trigger={<MoreVertical className="h-3 w-3" />}
items={rowMenuItems({ id, index, onInsertRow, onDeleteRow })}
/>
</div>,
document.body,
);
}
"use client";
import { cn } from "@/lib/utils";
import type { TableColumn } from "./types";
import { alignText } from "./utils";
/** Placeholder cards for the stacked layout; hidden from assistive tech. */
export function SkeletonCards({
count,
fields,
}: {
count: number;
fields: number;
}) {
return (
<div aria-hidden>
{Array.from({ length: count }, (_, r) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder cards
<div key={r} className="border-border/60 border-b px-3 py-3">
<div className="h-3.5 w-1/2 animate-pulse rounded-full bg-muted" />
<div className="mt-3 grid gap-2.5">
{Array.from({ length: fields }, (_, f) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder lines
key={f}
className="flex items-center justify-between gap-3"
>
<div className="h-2.5 w-16 animate-pulse rounded-full bg-muted" />
<div className="h-2.5 w-1/3 animate-pulse rounded-full bg-muted" />
</div>
))}
</div>
</div>
))}
</div>
);
}
export function SkeletonRows<T>({
count,
columns,
selectable,
rowHeight,
}: {
count: number;
columns: TableColumn<T>[];
selectable: boolean;
rowHeight: number;
}) {
return (
<>
{Array.from({ length: count }, (_, r) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows
<tr key={r} style={{ height: rowHeight }} className="border-border/60 border-b">
{selectable ? <td /> : null}
{columns.map((column) => (
<td key={column.key} className={cn("px-4", alignText(column.align))}>
<div
className={cn(
"h-3 animate-pulse rounded-full bg-muted",
column.align === "right" ? "ml-auto w-10" : "w-2/3",
)}
/>
</td>
))}
<td aria-hidden />
</tr>
))}
</>
);
}
"use client";
import {
type ReactNode,
type RefObject,
useId,
useLayoutEffect,
useState,
} from "react";
import { cn } from "@/lib/utils";
import type { SortState, TableColumn } from "./types";
/*
* Shared pieces of the stacked ("card list") layout both tables switch to in a
* narrow container (`mobileLayout="stack"`).
*/
/** Container width (px) below which `mobileLayout="stack"` shows cards. */
export const DEFAULT_STACK_BELOW = 560;
/**
* True once the element measures narrower than `stackBelow`. Starts false, so
* the server and the hydrating client both render the table, then switches in
* a layout effect (before the first paint after hydration) and follows the
* element's width from there. The element must not size to its content, or the
* swap could feed back into the measurement — both tables measure their
* full-width root.
*/
export function useStackedLayout(
ref: RefObject<HTMLElement | null>,
enabled: boolean,
stackBelow: number,
) {
const [stacked, setStacked] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!enabled || !el) {
setStacked(false);
return;
}
const update = (width: number) => setStacked(width < stackBelow);
update(el.getBoundingClientRect().width);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const entry = entries[entries.length - 1];
if (entry) update(entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width);
});
observer.observe(el);
return () => observer.disconnect();
}, [ref, enabled, stackBelow]);
return enabled && stacked;
}
/** Plain-text name of a column, for option labels and announcements. */
export function columnLabel<T>(column: TableColumn<T>) {
return typeof column.header === "string" ? column.header : column.key;
}
/**
* Splits visible columns into the card title (`primary` columns, or the first
* column when none opts in) and the label/value fields under it, leaving out
* `hideOnMobile` columns. A primary column is always shown.
*/
export function splitStackColumns<C extends TableColumn<never>>(columns: C[]) {
const flagged = columns.filter((c) => c.primary);
const primary = flagged.length > 0 ? flagged : columns.slice(0, 1);
const titleKeys = new Set(primary.map((c) => c.key));
const fields = columns.filter((c) => !titleKeys.has(c.key) && !c.hideOnMobile);
return { primary, fields };
}
/** Rough card height before measurement: title row + one line per field. */
export function estimateCardHeight(fieldCount: number) {
return 56 + fieldCount * 28;
}
export type StackedField = {
key: string;
label: ReactNode;
value: ReactNode;
/** The value is an input: let it take the full width instead of hugging the right edge. */
control?: boolean;
};
/**
* A card's label/value pairs as a description list, so each value is read
* with its column header.
*/
export function StackedFields({
fields,
className,
}: {
fields: StackedField[];
className?: string;
}) {
if (fields.length === 0) return null;
return (
<dl className={cn("grid gap-1.5", className)}>
{fields.map((field) => (
<div key={field.key} className="flex min-w-0 items-center gap-3">
<dt className="w-2/5 max-w-40 shrink-0 truncate text-muted-foreground text-xs">
{field.label}
</dt>
<dd
className={cn(
"min-w-0 flex-1 text-foreground",
field.control ? "pl-2" : "truncate text-right",
)}
>
{field.value}
</dd>
</div>
))}
</dl>
);
}
const SORT_NONE = "";
/**
* Compact sort control shown above the cards, where there are no headers to
* click: one native select of "column, direction" pairs.
*/
export function StackSortSelect<T>({
columns,
value,
onChange,
className,
}: {
columns: TableColumn<T>[];
value: SortState | null;
onChange: (next: SortState | null) => void;
className?: string;
}) {
const id = useId();
const sortable = columns.filter((c) => c.sortable);
if (sortable.length === 0) return null;
const current =
value && sortable.some((c) => c.key === value.key)
? `${value.direction}:${value.key}`
: SORT_NONE;
return (
<div className={cn("flex min-w-0 items-center gap-2", className)}>
<label htmlFor={id} className="shrink-0 text-muted-foreground text-xs">
Sort by
</label>
<select
id={id}
value={current}
onChange={(e) => {
const raw = e.target.value;
if (raw === SORT_NONE) return onChange(null);
const split = raw.indexOf(":");
onChange({
direction: raw.slice(0, split) === "desc" ? "desc" : "asc",
key: raw.slice(split + 1),
});
}}
className="h-8 min-w-0 max-w-full rounded-lg border border-border bg-background px-2 text-foreground text-sm outline-none transition-colors focus-visible:border-border-strong focus-visible:ring-2 focus-visible:ring-ring/30"
>
<option value={SORT_NONE}>None</option>
{sortable.map((column) => {
const label = columnLabel(column);
return [
<option key={`asc:${column.key}`} value={`asc:${column.key}`}>
{label} (ascending)
</option>,
<option key={`desc:${column.key}`} value={`desc:${column.key}`}>
{label} (descending)
</option>,
];
})}
</select>
</div>
);
}
"use client";
import {
ArrowLeftToLine,
ArrowRightToLine,
ChevronUp,
GripVertical,
MoreHorizontal,
Trash2,
} from "lucide-react";
import { motion } from "motion/react";
import { type PointerEvent as ReactPointerEvent, useEffect } from "react";
import { createPortal } from "react-dom";
import { Checkbox } from "@/components/motion/checkbox";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
import { TableMenu } from "./table-menu";
import type {
HeaderCellRefs,
InsertPosition,
SortState,
TableColumn,
} from "./types";
import { alignFlex, alignText, COLUMN_ACTIVE_SHADOW } from "./utils";
export interface TableHeaderProps<T> {
columns: TableColumn<T>[];
rowHeight: number;
reduce: boolean;
thRefs: HeaderCellRefs;
selectable: boolean;
allSelected: boolean;
someSelected: boolean;
onToggleAll: () => void;
sort: SortState | null;
onToggleSort: (key: string) => void;
resizable: boolean;
onResizeStart: (key: string, e: ReactPointerEvent) => void;
onResizeMove: (e: ReactPointerEvent) => void;
onResizeEnd: (e: ReactPointerEvent) => void;
reorderable: boolean;
dragKey: string | null;
dropIndex: number | null;
onReorderStart: (key: string, e: ReactPointerEvent) => void;
onReorderMove: (e: ReactPointerEvent) => void;
onReorderEnd: (e: ReactPointerEvent) => void;
onInsertColumn?: (index: number, position: InsertPosition) => void;
onDeleteColumn?: (columnKey: string, index: number) => void;
onColumnRename?: (columnKey: string, value: string) => void;
activeColumn: string | null;
onColumnActivate?: (key: string) => void;
onColumnDeactivate?: () => void;
}
/** Column insert / delete menu items shared by the header cell and the portal handle. */
function columnMenuItems<T>(
column: TableColumn<T>,
index: number,
onInsertColumn?: (index: number, position: InsertPosition) => void,
onDeleteColumn?: (columnKey: string, index: number) => void,
) {
return [
...(onInsertColumn
? [
{
label: "Insert before",
icon: <ArrowLeftToLine />,
onSelect: () => onInsertColumn(index, "before"),
},
{
label: "Insert after",
icon: <ArrowRightToLine />,
onSelect: () => onInsertColumn(index, "after"),
},
]
: []),
...(onDeleteColumn
? [
{
label: "Delete column",
icon: <Trash2 />,
destructive: true,
onSelect: () => onDeleteColumn(column.key, index),
},
]
: []),
];
}
/** The ellipse handle, portaled so it can sit on the column's top border without
* the scroll container clipping it. Straddles the border to bridge hover. */
function ColumnHandle<T>({
column,
index,
thRefs,
onInsertColumn,
onDeleteColumn,
onEnter,
onLeave,
}: {
column: TableColumn<T>;
index: number;
thRefs: HeaderCellRefs;
onInsertColumn?: (index: number, position: InsertPosition) => void;
onDeleteColumn?: (columnKey: string, index: number) => void;
onEnter: () => void;
onLeave: () => void;
}) {
useEffect(() => {
window.addEventListener("scroll", onLeave, true);
return () => window.removeEventListener("scroll", onLeave, true);
}, [onLeave]);
const el = thRefs.current[column.key];
if (!el || typeof document === "undefined") return null;
const rect = el.getBoundingClientRect();
return createPortal(
<div
style={{
position: "fixed",
top: rect.top,
left: rect.left + rect.width / 2,
transform: "translate(-50%, -50%)",
zIndex: 40,
}}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
>
<TableMenu
ariaLabel={`${column.key} column options`}
triggerClassName="flex h-2 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
trigger={<MoreHorizontal className="h-3 w-3" />}
items={columnMenuItems(column, index, onInsertColumn, onDeleteColumn)}
/>
</div>,
document.body,
);
}
export function TableHeader<T>({
columns,
rowHeight,
reduce,
thRefs,
selectable,
allSelected,
someSelected,
onToggleAll,
sort,
onToggleSort,
resizable,
onResizeStart,
onResizeMove,
onResizeEnd,
reorderable,
dragKey,
dropIndex,
onReorderStart,
onReorderMove,
onReorderEnd,
onInsertColumn,
onDeleteColumn,
onColumnRename,
activeColumn,
onColumnActivate,
onColumnDeactivate,
}: TableHeaderProps<T>) {
const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);
const activeIndex = columns.findIndex((c) => c.key === activeColumn);
return (
<>
{hasColumnMenu && activeColumn && activeIndex >= 0 ? (
<ColumnHandle
column={columns[activeIndex]}
index={activeIndex}
thRefs={thRefs}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onEnter={() => onColumnActivate?.(activeColumn)}
onLeave={() => onColumnDeactivate?.()}
/>
) : null}
<thead>
<tr style={{ height: rowHeight }}>
{selectable ? (
<th className="sticky top-0 z-10 border-border border-b bg-muted">
<div className="flex items-center justify-center">
<Checkbox
checked={allSelected}
indeterminate={!allSelected && someSelected}
onCheckedChange={onToggleAll}
aria-label="Select all rows"
className="size-6"
/>
</div>
</th>
) : null}
{columns.map((column, index) => {
const active = sort?.key === column.key;
const isDragging = dragKey === column.key;
const isActive = activeColumn === column.key;
return (
<th
key={column.key}
ref={(el) => {
thRefs.current[column.key] = el;
}}
onPointerEnter={() => onColumnActivate?.(column.key)}
onPointerLeave={() => onColumnDeactivate?.()}
style={isActive ? { boxShadow: COLUMN_ACTIVE_SHADOW } : undefined}
aria-sort={
active
? sort?.direction === "asc"
? "ascending"
: "descending"
: undefined
}
data-drop={dragKey ? dropIndex === index : undefined}
data-dropend={
dragKey
? dropIndex === columns.length && index === columns.length - 1
: undefined
}
className={cn(
"group sticky top-0 z-10 border-border border-b bg-muted p-0 font-medium text-muted-foreground",
"data-[drop=true]:before:absolute data-[drop=true]:before:inset-y-0 data-[drop=true]:before:left-0 data-[drop=true]:before:w-0.5 data-[drop=true]:before:bg-primary",
"data-[dropend=true]:after:absolute data-[dropend=true]:after:inset-y-0 data-[dropend=true]:after:right-0 data-[dropend=true]:after:w-0.5 data-[dropend=true]:after:bg-primary",
)}
>
<motion.div
className={cn(
"flex h-full items-center",
alignFlex(column.align),
)}
style={{ height: rowHeight }}
animate={
reduce
? { opacity: isDragging ? 0.5 : 1 }
: {
scale: isDragging ? 1.04 : 1,
opacity: isDragging ? 0.5 : 1,
}
}
transition={SPRING_PRESS}
>
{reorderable ? (
<button
type="button"
aria-label={`Reorder ${column.key} column`}
onPointerDown={(e) => onReorderStart(column.key, e)}
onPointerMove={onReorderMove}
onPointerUp={onReorderEnd}
className={cn(
"flex h-full w-6 cursor-grab touch-none items-center justify-center text-muted-foreground/60 transition-colors hover:text-foreground active:cursor-grabbing",
// The grip owns the whole press, so iOS must not open its
// callout out of the same one and cancel the drag.
TOUCH_GESTURE_CLASS,
)}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
) : null}
{column.sortable ? (
<button
type="button"
onClick={() => onToggleSort(column.key)}
className={cn(
"flex h-full min-w-0 flex-1 select-none items-center gap-1 px-4 transition-colors hover:text-foreground",
alignFlex(column.align),
active && "text-foreground",
)}
>
<span className="truncate">{column.header}</span>
<motion.span
aria-hidden
className="inline-flex shrink-0"
animate={{
rotate: active && sort?.direction === "desc" ? 180 : 0,
opacity: active ? 1 : 0.35,
}}
transition={
reduce
? { duration: 0 }
: { duration: 0.18, ease: EASE_OUT }
}
>
<ChevronUp className="h-3.5 w-3.5" />
</motion.span>
</button>
) : onColumnRename ? (
<input
value={
typeof column.header === "string" ? column.header : ""
}
aria-label={`Rename ${column.key} column`}
size={1}
onChange={(e) =>
onColumnRename(column.key, e.target.value)
}
className={cn(
"min-w-0 flex-1 truncate appearance-none rounded-md border-0 bg-transparent px-4 font-medium text-muted-foreground outline-none transition-colors focus:bg-muted focus:text-foreground",
alignText(column.align),
)}
/>
) : (
<span
className={cn(
"min-w-0 flex-1 truncate px-4",
alignText(column.align),
)}
>
{column.header}
</span>
)}
</motion.div>
{resizable ? (
<button
type="button"
aria-label={`Resize ${column.key} column`}
tabIndex={-1}
onPointerDown={(e) => onResizeStart(column.key, e)}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
className={cn(
"absolute top-0 right-0 h-full w-1.5 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-primary/40",
// Same for the resize drag: the handle drives it end to end.
TOUCH_GESTURE_CLASS,
)}
/>
) : null}
</th>
);
})}
<th
aria-hidden
className="sticky top-0 z-10 border-border border-b bg-muted"
/>
</tr>
</thead>
</>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type TableMenuItem = {
label: string;
icon?: ReactNode;
onSelect: () => void;
destructive?: boolean;
};
const MENU_WIDTH = 188;
export function TableMenu({
items,
ariaLabel,
trigger,
triggerClassName,
}: {
items: TableMenuItem[];
ariaLabel: string;
trigger: ReactNode;
triggerClassName?: string;
}) {
const reduce = useReducedMotion();
const triggerRef = useRef<HTMLButtonElement>(null);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(
null,
);
const open = coords !== null;
useEffect(() => {
if (!open) return;
const close = () => setCoords(null);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
// Close on any scroll (the trigger moves) or resize; fixed coords go stale.
window.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
window.removeEventListener("keydown", onKey);
};
}, [open]);
const toggle = () => {
if (open) {
setCoords(null);
return;
}
const el = triggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setCoords({
top: r.bottom + 4,
left: Math.max(8, r.right - MENU_WIDTH),
});
};
return (
<>
<button
ref={triggerRef}
type="button"
aria-label={ariaLabel}
aria-haspopup="menu"
aria-expanded={open}
onClick={(e) => {
e.stopPropagation();
toggle();
}}
className={triggerClassName}
>
{trigger}
</button>
{open && typeof document !== "undefined"
? createPortal(
<>
<div
className="fixed inset-0 z-40"
onPointerDown={() => setCoords(null)}
/>
<motion.div
role="menu"
className="fixed z-50 overflow-hidden rounded-xl border border-border bg-background p-1 shadow-xl"
style={{ top: coords.top, left: coords.left, width: MENU_WIDTH }}
initial={
reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -4 }
}
animate={
reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }
}
transition={reduce ? { duration: 0 } : SPRING_PANEL}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
onClick={() => {
setCoords(null);
item.onSelect();
}}
className={cn(
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm transition-colors [&_svg]:h-4 [&_svg]:w-4",
item.destructive
? "text-rose-500 hover:bg-rose-500/10"
: "text-foreground hover:bg-muted",
)}
>
{item.icon}
{item.label}
</button>
))}
</motion.div>
</>,
document.body,
)
: null}
</>
);
}
import type { ReactNode } from "react";
export type SortDirection = "asc" | "desc";
export type SortState = {
key: string;
direction: SortDirection;
};
export type TableColumn<T> = {
/** Stable key; also the default object property read for the cell + sort value. */
key: string;
/** Header content. */
header: ReactNode;
/** Allow clicking the header to sort by this column. */
sortable?: boolean;
/** Cell text alignment. */
align?: "left" | "center" | "right";
/** Column width as a CSS length, e.g. "160px" or "20%". Omit to share remaining space equally. */
width?: string;
/** Custom cell renderer. Falls back to `row[key]`. */
cell?: (row: T) => ReactNode;
/** Render an inline text input for this column's cells (ignored when `cell` is set). */
editable?: boolean;
/** Value used for sorting. Falls back to `row[key]`. */
sortValue?: (row: T) => string | number;
/** Stacked layout (`mobileLayout="stack"`): show this cell in the card title.
* Defaults to the first column when no column sets it. */
primary?: boolean;
/** Stacked layout: leave this column out of the card's label/value pairs. */
hideOnMobile?: boolean;
};
/**
* Narrow-container layout. `"scroll"` keeps the table and scrolls it sideways;
* `"stack"` renders one card per row while the table's own width is below
* `stackBelow`.
*/
export type MobileLayout = "scroll" | "stack";
export type InsertPosition = "before" | "after";
export interface TableProps<T> {
data: T[];
columns: TableColumn<T>[];
/** Stable id per row, required for correct selection across sorts. Defaults to row index. */
getRowId?: (row: T, index: number) => string;
/** Render a leading checkbox column with select-all in the header. */
selectable?: boolean;
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
onSelectionChange?: (ids: string[]) => void;
sort?: SortState | null;
defaultSort?: SortState | null;
onSortChange?: (sort: SortState | null) => void;
/** Allow dragging the right edge of a header to resize that column. */
resizable?: boolean;
/** Minimum column width in px when resizing. */
minColumnWidth?: number;
onColumnResize?: (key: string, width: number) => void;
/** Allow dragging a header grip to reorder columns. */
reorderable?: boolean;
onColumnOrderChange?: (keys: string[]) => void;
/** Called when an `editable` cell changes. */
onCellEdit?: (rowId: string, columnKey: string, value: string) => void;
/** When set, non-sortable headers become editable inputs for the column name. */
onColumnRename?: (columnKey: string, value: string) => void;
/** Enables the row menu (Insert before / after). Receives the target index. */
onInsertRow?: (index: number, position: InsertPosition) => void;
/** Enables Delete in the row menu. */
onDeleteRow?: (rowId: string, index: number) => void;
/** Enables the column menu (Insert before / after). Receives the target column index. */
onInsertColumn?: (index: number, position: InsertPosition) => void;
/** Enables Delete in the column menu. */
onDeleteColumn?: (columnKey: string, index: number) => void;
/** Fixed row height in px — required for virtualization. */
rowHeight?: number;
/** Scroll viewport height in px. */
height?: number;
/** Rows rendered above/below the viewport. */
overscan?: number;
/** Fires when the viewport scrolls near the bottom — load the next page. */
onEndReached?: () => void;
/** Currently fetching — shows skeleton rows and pauses `onEndReached`. */
loading?: boolean;
/** How many skeleton rows to show while loading more (default 3). */
skeletonRows?: number;
emptyState?: ReactNode;
/**
* `"scroll"` (default) keeps today's sideways-scrolling table. `"stack"`
* swaps to a virtualized card list (title from `primary` columns, the rest as
* header: value pairs, editable cells stay inputs) whenever the table's own
* width is below `stackBelow`. The server always renders the table.
*/
mobileLayout?: MobileLayout;
/** Width in px under which `mobileLayout="stack"` shows cards (default 560). */
stackBelow?: number;
className?: string;
}
/** A data row paired with its stable id. */
export type TableRow<T> = { row: T; id: string };
/** Ref map from column key to its header cell, shared across the resize/reorder hooks. */
export type HeaderCellRefs = {
current: Record<string, HTMLTableCellElement | null>;
};
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useMemo,
useState,
} from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
import type { HeaderCellRefs, TableColumn } from "./types";
export function useColumnReorder<T>({
columns,
thRefs,
order: orderProp,
defaultOrder,
onColumnOrderChange,
}: {
columns: TableColumn<T>[];
thRefs: HeaderCellRefs;
/** Controlled column key order. Omit to keep the order internally. */
order?: string[];
/** Initial order for uncontrolled use; defaults to the `columns` order. */
defaultOrder?: string[];
onColumnOrderChange?: (keys: string[]) => void;
}) {
const [internalOrder, setOrder] = useState<string[]>(
() => defaultOrder ?? columns.map((c) => c.key),
);
const order = orderProp ?? internalOrder;
const [dragKey, setDragKey] = useState<string | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
// Apply the current order, tolerating columns added/removed at runtime. New
// columns are placed at their position in `columns` (after their left
// neighbor), not appended — so an inserted column lands where it was added.
const orderedColumns = useMemo(() => {
const byKey = new Map(columns.map((c) => [c.key, c]));
const resultKeys = order.filter((k) => byKey.has(k));
const present = new Set(resultKeys);
columns.forEach((column, i) => {
if (present.has(column.key)) return;
let at = resultKeys.length;
if (i === 0) {
at = 0;
} else {
const idx = resultKeys.indexOf(columns[i - 1].key);
at = idx === -1 ? i : idx + 1;
}
resultKeys.splice(at, 0, column.key);
present.add(column.key);
});
return resultKeys
.map((k) => byKey.get(k))
.filter((c): c is TableColumn<T> => c !== undefined);
}, [order, columns]);
const dropIndexFor = useCallback(
(clientX: number) => {
for (let i = 0; i < orderedColumns.length; i++) {
const rect =
thRefs.current[orderedColumns[i].key]?.getBoundingClientRect();
if (rect && clientX < rect.left + rect.width / 2) return i;
}
return orderedColumns.length;
},
[orderedColumns, thRefs],
);
/** Commit a full key order — the drop of a drag, or a keyboard move. */
const commitOrder = useCallback(
(keys: string[]) => {
if (orderProp === undefined) setOrder(keys);
onColumnOrderChange?.(keys);
},
[orderProp, onColumnOrderChange],
);
const startReorder = useCallback((key: string, e: ReactPointerEvent) => {
e.preventDefault();
e.stopPropagation();
setDragKey(key);
capturePointer(e.currentTarget, e.pointerId);
}, []);
const moveReorder = useCallback(
(e: ReactPointerEvent) => {
if (!dragKey) return;
setDropIndex(dropIndexFor(e.clientX));
},
[dragKey, dropIndexFor],
);
const endReorder = useCallback(
(e: ReactPointerEvent) => {
releasePointer(e.currentTarget, e.pointerId);
if (dragKey && dropIndex !== null) {
const keys = orderedColumns.map((c) => c.key);
const from = keys.indexOf(dragKey);
if (from !== -1) {
const without = keys.filter((_, i) => i !== from);
let to = dropIndex;
if (from < to) to--;
without.splice(to, 0, dragKey);
commitOrder(without);
}
}
setDragKey(null);
setDropIndex(null);
},
[dragKey, dropIndex, orderedColumns, commitOrder],
);
return {
orderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
commitOrder,
};
}
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useRef,
useState,
} from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
import type { HeaderCellRefs, TableColumn } from "./types";
export function useColumnResize<T>({
orderedColumns,
thRefs,
minColumnWidth,
onColumnResize,
}: {
orderedColumns: TableColumn<T>[];
thRefs: HeaderCellRefs;
minColumnWidth: number;
onColumnResize?: (key: string, width: number) => void;
}) {
const resizeRef = useRef<{
key: string;
startX: number;
startWidth: number;
} | null>(null);
const [widths, setWidths] = useState<Record<string, number>>({});
const startResize = useCallback(
(key: string, e: ReactPointerEvent) => {
e.preventDefault();
e.stopPropagation();
// Freeze every column to its current pixel width so resizing one only
// moves the trailing spacer, never the other columns.
const snapshot = { ...widths };
for (const column of orderedColumns) {
if (snapshot[column.key] == null) {
const measured = thRefs.current[column.key]?.getBoundingClientRect()
.width;
snapshot[column.key] = measured
? Math.round(measured)
: minColumnWidth;
}
}
resizeRef.current = {
key,
startX: e.clientX,
startWidth: snapshot[key],
};
setWidths(snapshot);
capturePointer(e.currentTarget, e.pointerId);
},
[minColumnWidth, orderedColumns, thRefs, widths],
);
const moveResize = useCallback(
(e: ReactPointerEvent) => {
const state = resizeRef.current;
if (!state) return;
const width = Math.max(
minColumnWidth,
state.startWidth + (e.clientX - state.startX),
);
setWidths((prev) => ({ ...prev, [state.key]: width }));
},
[minColumnWidth],
);
const endResize = useCallback(
(e: ReactPointerEvent) => {
const state = resizeRef.current;
resizeRef.current = null;
releasePointer(e.currentTarget, e.pointerId);
if (state) {
onColumnResize?.(state.key, widths[state.key] ?? state.startWidth);
}
},
[onColumnResize, widths],
);
return { widths, startResize, moveResize, endResize };
}
import { useCallback, useMemo, useState } from "react";
import type { SortState, TableColumn, TableRow } from "./types";
import { readSortValue } from "./utils";
export function useColumnSort<T>({
rows,
columns,
sort: sortProp,
defaultSort = null,
onSortChange,
}: {
rows: TableRow<T>[];
columns: TableColumn<T>[];
sort?: SortState | null;
defaultSort?: SortState | null;
onSortChange?: (sort: SortState | null) => void;
}) {
const [internalSort, setInternalSort] = useState<SortState | null>(
defaultSort,
);
const sort = sortProp !== undefined ? sortProp : internalSort;
const commit = useCallback(
(next: SortState | null) => {
if (sortProp === undefined) setInternalSort(next);
onSortChange?.(next);
},
[sortProp, onSortChange],
);
const toggleSort = useCallback(
(key: string) => {
if (!sort || sort.key !== key) {
commit({ key, direction: "asc" });
} else if (sort.direction === "asc") {
commit({ key, direction: "desc" });
} else {
commit(null);
}
},
[sort, commit],
);
const sortedRows = useMemo(() => {
if (!sort) return rows;
const column = columns.find((c) => c.key === sort.key);
if (!column) return rows;
const copy = [...rows];
copy.sort((a, b) => {
const av = readSortValue(a.row, column);
const bv = readSortValue(b.row, column);
let cmp: number;
if (typeof av === "number" && typeof bv === "number") {
cmp = av - bv;
} else {
cmp = String(av).localeCompare(String(bv));
}
return sort.direction === "asc" ? cmp : -cmp;
});
return copy;
}, [rows, sort, columns]);
return { sort, sortedRows, toggleSort, setSort: commit };
}
import { useCallback, useMemo, useState } from "react";
import type { TableRow } from "./types";
export function useRowSelection<T>({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
}: {
sortedRows: TableRow<T>[];
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
onSelectionChange?: (ids: string[]) => void;
}) {
const [internalSelected, setInternalSelected] = useState<Set<string>>(
() => new Set(defaultSelectedRowIds),
);
const selected = useMemo(
() =>
selectedRowIds !== undefined
? new Set(selectedRowIds)
: internalSelected,
[selectedRowIds, internalSelected],
);
const commit = useCallback(
(next: Set<string>) => {
if (selectedRowIds === undefined) setInternalSelected(next);
onSelectionChange?.([...next]);
},
[selectedRowIds, onSelectionChange],
);
const allSelected =
sortedRows.length > 0 && sortedRows.every((r) => selected.has(r.id));
const someSelected = sortedRows.some((r) => selected.has(r.id));
const toggleAll = useCallback(() => {
const next = new Set(selected);
if (allSelected) {
for (const r of sortedRows) next.delete(r.id);
} else {
for (const r of sortedRows) next.add(r.id);
}
commit(next);
}, [allSelected, sortedRows, selected, commit]);
const toggleRow = useCallback(
(id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
commit(next);
},
[selected, commit],
);
return { selected, allSelected, someSelected, toggleAll, toggleRow };
}
import type { ReactNode } from "react";
import type { TableColumn } from "./types";
export const CHECKBOX_PX = 48;
export const CHECKBOX_WIDTH = `${CHECKBOX_PX}px`;
/** Highlights the top edge of the active column's header cell. */
export const COLUMN_ACTIVE_SHADOW = "inset 0 1px 0 var(--color-primary)";
export function alignFlex(align: TableColumn<unknown>["align"]) {
if (align === "right") return "justify-end";
if (align === "center") return "justify-center";
return "justify-start";
}
export function alignText(align: TableColumn<unknown>["align"]) {
if (align === "right") return "text-right";
if (align === "center") return "text-center";
return "text-left";
}
export function readCell<T>(row: T, column: TableColumn<T>): ReactNode {
if (column.cell) return column.cell(row);
return (row as Record<string, ReactNode>)[column.key];
}
export function readSortValue<T>(
row: T,
column: TableColumn<T>,
): string | number {
if (column.sortValue) return column.sortValue(row);
return (row as Record<string, string | number>)[column.key];
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId } from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
const CHECK_PATH = "M5 13l4 4L19 7";
const INDETERMINATE_PATH = "M6 12h12";
export interface CheckboxProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
indeterminate?: boolean;
label?: string;
className?: string;
id?: string;
"aria-label"?: string;
/** Associates an external message (e.g. a form error) with the control. */
"aria-describedby"?: string;
}
export function Checkbox({
checked,
onCheckedChange,
disabled,
indeterminate,
label,
className,
id: idProp,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
}: CheckboxProps) {
const autoId = useId();
const id = idProp ?? autoId;
const reduce = useReducedMotion();
const showMark = checked || indeterminate;
const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;
return (
<label
htmlFor={id}
className={cn(
"inline-flex items-center gap-3",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
)}
>
<motion.button
id={id}
type="button"
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={
checked ? "checked" : indeterminate ? "indeterminate" : "unchecked"
}
className={cn(
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200",
TOUCH_TARGET_CLASS,
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
showMark
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50 bg-background hover:border-muted-foreground",
)}
>
<AnimatePresence initial={false}>
{showMark ? (
<motion.svg
key={indeterminate ? "indeterminate" : "checked"}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
transition={
reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }
}
aria-hidden
>
<title>{indeterminate ? "Partially selected" : "Selected"}</title>
<motion.path
d={path}
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce
? { duration: 0 }
: {
duration: indeterminate ? 0.2 : 0.3,
ease: EASE_OUT,
delay: 0.04,
}
}
/>
</motion.svg>
) : null}
</AnimatePresence>
</motion.button>
{label ? (
<span className={cn("select-none text-sm text-foreground", disabled && "opacity-60")}>
{label}
</span>
) : null}
</label>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
dataT[]—columnsTableColumn<T>[]—getRowId?((row: T, index: number) => string)Stable id per row, required for correct selection across sorts. Defaults to row index.
—selectable?booleanRender a leading checkbox column with select-all in the header.
falseselectedRowIds?string[]—defaultSelectedRowIds?string[]—onSelectionChange?((ids: string[]) => void)—sort?SortState | null—defaultSort?SortState | nullnullonSortChange?((sort: SortState | null) => void)—resizable?booleanAllow dragging the right edge of a header to resize that column.
falseminColumnWidth?numberMinimum column width in px when resizing.
64onColumnResize?((key: string, width: number) => void)—reorderable?booleanAllow dragging a header grip to reorder columns.
falseonColumnOrderChange?((keys: string[]) => void)—onCellEdit?((rowId: string, columnKey: string, value: string) => void)Called when an `editable` cell changes.
—onColumnRename?((columnKey: string, value: string) => void)When set, non-sortable headers become editable inputs for the column name.
—onInsertRow?((index: number, position: InsertPosition) => void)Enables the row menu (Insert before / after). Receives the target index.
—onDeleteRow?((rowId: string, index: number) => void)Enables Delete in the row menu.
—onInsertColumn?((index: number, position: InsertPosition) => void)Enables the column menu (Insert before / after). Receives the target column index.
—onDeleteColumn?((columnKey: string, index: number) => void)Enables Delete in the column menu.
—rowHeight?numberFixed row height in px — required for virtualization.
48height?numberScroll viewport height in px.
440overscan?numberRows rendered above/below the viewport.
10onEndReached?(() => void)Fires when the viewport scrolls near the bottom — load the next page.
—loading?booleanCurrently fetching — shows skeleton rows and pauses `onEndReached`.
falseskeletonRows?numberHow many skeleton rows to show while loading more (default 3).
3emptyState?ReactNodeNo datamobileLayout?"scroll" | "stack"`"scroll"` (default) keeps today's sideways-scrolling table. `"stack"` swaps to a virtualized card list (title from `primary` columns, the rest as header: value pairs, editable cells stay inputs) whenever the table's own width is below `stackBelow`. The server always renders the table.
scrollstackBelow?numberWidth in px under which `mobileLayout="stack"` shows cards (default 560).
560className?string—Editable Table
index.tsxMark columns editable to type straight into cells, and use the row and column menus to add or remove rows and columns; your data and column definitions stay the single source of truth.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i @tanstack/react-virtual clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/table
import { useVirtualizer } from "@tanstack/react-virtual";
import { MoreVertical } from "lucide-react";
import { useReducedMotion } from "motion/react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { Checkbox } from "@/components/motion/checkbox";
import { cn } from "@/lib/utils";
import { EditableCell } from "./editable-cell";
import { RowHandle, rowMenuItems } from "./row-handle";
import { SkeletonCards, SkeletonRows } from "./skeleton-rows";
import {
DEFAULT_STACK_BELOW,
estimateCardHeight,
StackedFields,
StackSortSelect,
splitStackColumns,
useStackedLayout,
} from "./stacked";
import { TableMenu } from "./table-menu";
import { TableHeader } from "./table-header";
import type { HeaderCellRefs, TableProps } from "./types";
import { useColumnReorder } from "./use-column-reorder";
import { useColumnResize } from "./use-column-resize";
import { useColumnSort } from "./use-column-sort";
import { useRowSelection } from "./use-row-selection";
import { alignText, CHECKBOX_PX, CHECKBOX_WIDTH, readCell } from "./utils";
export type {
MobileLayout,
SortDirection,
SortState,
TableColumn,
TableProps,
} from "./types";
/**
* Narrowest a column of bare inputs may be floored to and still show a value:
* the cell's own `px-4` eats 32 of it.
*/
const INPUT_COLUMN_WIDTH = 120;
/** The root font size Tailwind's rem scale assumes, and the pre-measure guess. */
const DEFAULT_ROOT_FONT_SIZE = 16;
/**
* What one `rem` is worth here, in px. The default until the first client
* layout, so the server and the hydrating client emit the same floor; measured
* once after that, because a document that sets its own `html { font-size }`
* lays a rem column out against that size and a floor computed from 16 would
* fall short by the same factor.
*/
function useRootFontSize() {
const [size, setSize] = useState(DEFAULT_ROOT_FONT_SIZE);
useEffect(() => {
const measured = Number.parseFloat(
getComputedStyle(document.documentElement).fontSize,
);
if (measured > 0) setSize(measured);
}, []);
return size;
}
/**
* The absolute width a column declared, in px, or null when it declared a share
* of the remainder instead (`fr`, `%`, `auto`, `calc()`, nothing at all) — those
* are worth whatever is left over, which is not a width this can add up.
*/
function resolveColumnWidth(
width: string | undefined,
rootFontSize: number,
): number | null {
if (!width) return null;
const value = Number.parseFloat(width);
if (!Number.isFinite(value)) return null;
if (width.endsWith("px")) return value;
// rem is the other absolute length the repo writes.
if (width.endsWith("rem")) return value * rootFontSize;
return null;
}
export function Table<T>({
data,
columns,
getRowId,
selectable = false,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
sort: sortProp,
defaultSort = null,
onSortChange,
resizable = false,
minColumnWidth = 64,
onColumnResize,
reorderable = false,
onColumnOrderChange,
onCellEdit,
onColumnRename,
onInsertRow,
onDeleteRow,
onInsertColumn,
onDeleteColumn,
rowHeight = 48,
height = 440,
overscan = 10,
onEndReached,
loading = false,
skeletonRows = 3,
emptyState = "No data",
mobileLayout = "scroll",
stackBelow = DEFAULT_STACK_BELOW,
className,
}: TableProps<T>) {
const reduce = useReducedMotion();
const rootRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const stacked = useStackedLayout(
rootRef,
mobileLayout === "stack",
stackBelow,
);
const thRefs: HeaderCellRefs = useRef<
Record<string, HTMLTableCellElement | null>
>({});
const rows = useMemo(
() =>
data.map((row, index) => ({
row,
id: getRowId ? getRowId(row, index) : String(index),
})),
[data, getRowId],
);
const {
orderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
} = useColumnReorder({ columns, thRefs, onColumnOrderChange });
const { sort, sortedRows, toggleSort, setSort } = useColumnSort({
rows,
columns,
sort: sortProp,
defaultSort,
onSortChange,
});
const { widths, startResize, moveResize, endResize } = useColumnResize({
orderedColumns,
thRefs,
minColumnWidth,
onColumnResize,
});
const { selected, allSelected, someSelected, toggleAll, toggleRow } =
useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
// Stacked mode keeps the virtualizer: cards vary in height, so each one is
// measured (`measureElement`) once rendered, starting from an estimate.
const { primary: titleColumns, fields: fieldColumns } = useMemo(
() => splitStackColumns(orderedColumns),
[orderedColumns],
);
const itemEstimate = stacked
? estimateCardHeight(fieldColumns.length)
: rowHeight;
const getItemKey = useCallback(
(index: number) => sortedRows[index]?.id ?? index,
[sortedRows],
);
const virtualizer = useVirtualizer<HTMLDivElement, HTMLLIElement>({
count: sortedRows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => itemEstimate,
getItemKey,
overscan,
});
// Switching layouts (or card shape) invalidates every cached size.
// biome-ignore lint/correctness/useExhaustiveDependencies: itemEstimate is the reset trigger.
useLayoutEffect(() => {
virtualizer.measure();
}, [virtualizer, itemEstimate]);
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0;
const paddingBottom =
virtualItems.length > 0
? totalSize - virtualItems[virtualItems.length - 1].end
: 0;
const hasRowMenu = !!(onInsertRow || onDeleteRow);
const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);
// Only shrink-wrap (w-max) once every column has an explicit resized width;
// otherwise stay fill-width so a flexible column can't size to cell content.
const sized =
orderedColumns.length > 0 &&
orderedColumns.every((c) => widths[c.key] != null);
const rootFontSize = useRootFontSize();
// In a container narrower than the columns, `table-layout: fixed` shrinks
// every column toward zero instead of scrolling. Floor the table at what the
// columns actually asked for — an absolute declared width where there is one,
// and for the ones sharing the remainder whatever content they can fall back
// on — then let the viewport scroll past it.
const minTableWidth = useMemo(() => {
// A column whose cells render bare inputs has no content for the fallback
// to measure, which is exactly the column that collapses; a renamable
// header is an input too, and in a fixed layout the header row is what
// sizes the column.
const inputOnly = (column: (typeof orderedColumns)[number]) =>
Boolean(onColumnRename) || (!column.cell && Boolean(column.editable));
const total = orderedColumns.reduce((sum, column) => {
const resized = widths[column.key];
if (resized != null) return sum + resized;
const declared = resolveColumnWidth(column.width, rootFontSize);
if (declared != null) return sum + declared;
return (
sum +
(inputOnly(column)
? Math.max(minColumnWidth, INPUT_COLUMN_WIDTH)
: minColumnWidth)
);
}, selectable ? CHECKBOX_PX : 0);
return Math.round(total);
}, [
minColumnWidth,
onColumnRename,
orderedColumns,
rootFontSize,
selectable,
widths,
]);
// Infinite scroll: fire onEndReached once per near-bottom dwell, paused while
// loading; the guard resets when the load completes.
const endReachedRef = useRef(false);
useEffect(() => {
if (!loading) endReachedRef.current = false;
}, [loading]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || !onEndReached || loading || endReachedRef.current) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight < itemEstimate * 4) {
endReachedRef.current = true;
onEndReached();
}
}, [onEndReached, loading, itemEstimate]);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
// Small delay on leave so the pointer can cross the gap from the header cell
// to the portal handle without the column deactivating.
const deactivateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateColumn = useCallback((key: string) => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = null;
setActiveColumn(key);
}, []);
const deactivateColumn = useCallback(() => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = setTimeout(() => setActiveColumn(null), 100);
}, []);
const rowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
const [activeRow, setActiveRow] = useState<{ id: string; index: number } | null>(
null,
);
const rowTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateRow = useCallback((id: string, index: number) => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = null;
setActiveRow({ id, index });
}, []);
const deactivateRow = useCallback(() => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = setTimeout(() => setActiveRow(null), 100);
}, []);
const activeRowEl = activeRow ? rowRefs.current[activeRow.id] : null;
// Real columns + checkbox; the trailing spacer adds one more in colSpans.
const leadColumns = columns.length + (selectable ? 1 : 0);
const showStackControls =
selectable || orderedColumns.some((column) => column.sortable);
const renderValue = (
entry: (typeof sortedRows)[number],
column: (typeof orderedColumns)[number],
index: number,
) =>
!column.cell && column.editable ? (
<EditableCell
value={String(readCell(entry.row, column) ?? "")}
label={`${column.key} for row ${index + 1}`}
onChange={(next) => onCellEdit?.(entry.id, column.key, next)}
/>
) : (
readCell(entry.row, column)
);
return (
<div
ref={rootRef}
data-layout={stacked ? "stack" : "table"}
className={cn(
"w-full overflow-hidden border border-border bg-background text-sm",
className,
)}
>
{stacked && showStackControls ? (
<div className="flex min-h-11 flex-wrap items-center gap-x-4 gap-y-2 border-border border-b bg-muted/40 px-3 py-1.5">
{selectable ? (
<Checkbox
checked={allSelected}
indeterminate={!allSelected && someSelected}
onCheckedChange={toggleAll}
label="Select all"
className="text-muted-foreground text-xs"
/>
) : null}
<StackSortSelect
columns={orderedColumns}
value={sort}
onChange={setSort}
className="ml-auto"
/>
</div>
) : null}
<div
ref={scrollRef}
onScroll={handleScroll}
className="overflow-auto"
style={{ height }}
>
{stacked ? (
sortedRows.length === 0 && !loading ? (
<div className="p-10 text-center text-muted-foreground">
{emptyState}
</div>
) : (
<>
{sortedRows.length > 0 ? (
<ul
aria-label="Rows"
className="relative"
style={{ height: totalSize }}
>
{virtualItems.map((vItem) => {
const entry = sortedRows[vItem.index];
const isSelected = selected.has(entry.id);
return (
<li
key={entry.id}
ref={virtualizer.measureElement}
data-index={vItem.index}
data-selected={isSelected}
aria-setsize={sortedRows.length}
aria-posinset={vItem.index + 1}
className={cn(
"absolute inset-x-0 top-0 border-border/60 border-b px-3 py-3 transition-colors",
"data-[selected=true]:bg-primary/5",
)}
style={{ transform: `translateY(${vItem.start}px)` }}
>
<div className="flex min-w-0 items-center gap-3">
{selectable ? (
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRow(entry.id)}
aria-label={`Select row ${vItem.index + 1}`}
/>
) : null}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 font-medium text-foreground">
{titleColumns.map((column) => (
<span
key={column.key}
className="min-w-0 flex-1 truncate"
>
{renderValue(entry, column, vItem.index)}
</span>
))}
</div>
{hasRowMenu ? (
<TableMenu
ariaLabel={`Row ${vItem.index + 1} options`}
triggerClassName="grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
trigger={<MoreVertical className="size-4" />}
items={rowMenuItems({
id: entry.id,
index: vItem.index,
onInsertRow,
onDeleteRow,
})}
/>
) : null}
</div>
<StackedFields
className={cn("mt-2", selectable && "pl-8")}
fields={fieldColumns.map((column) => ({
key: column.key,
label: column.header,
value: renderValue(entry, column, vItem.index),
control: !column.cell && !!column.editable,
}))}
/>
</li>
);
})}
</ul>
) : null}
{loading ? (
<SkeletonCards
count={
sortedRows.length === 0
? Math.max(1, Math.ceil(height / itemEstimate))
: skeletonRows
}
fields={fieldColumns.length}
/>
) : null}
</>
)
) : (
<table
className={cn("border-collapse", sized ? "w-max" : undefined)}
style={{
tableLayout: "fixed",
minWidth: `max(100%, ${minTableWidth}px)`,
}}
>
<colgroup>
{selectable ? <col style={{ width: CHECKBOX_WIDTH }} /> : null}
{orderedColumns.map((column) => {
const override = widths[column.key];
const width = override ? `${override}px` : column.width;
return (
<col key={column.key} style={width ? { width } : undefined} />
);
})}
{/* Empty filler owns the leftover space — no gap, content unpinned. */}
<col />
</colgroup>
<TableHeader
columns={orderedColumns}
rowHeight={rowHeight}
reduce={!!reduce}
thRefs={thRefs}
selectable={selectable}
allSelected={allSelected}
someSelected={someSelected}
onToggleAll={toggleAll}
sort={sort}
onToggleSort={toggleSort}
resizable={resizable}
onResizeStart={startResize}
onResizeMove={moveResize}
onResizeEnd={endResize}
reorderable={reorderable}
dragKey={dragKey}
dropIndex={dropIndex}
onReorderStart={startReorder}
onReorderMove={moveReorder}
onReorderEnd={endReorder}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onColumnRename={onColumnRename}
activeColumn={hasColumnMenu ? activeColumn : null}
onColumnActivate={hasColumnMenu ? activateColumn : undefined}
onColumnDeactivate={hasColumnMenu ? deactivateColumn : undefined}
/>
<tbody>
{sortedRows.length === 0 ? (
loading ? (
<SkeletonRows
count={Math.max(1, Math.ceil(height / rowHeight))}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : (
<tr>
<td
colSpan={leadColumns + 1}
className="p-10 text-center text-muted-foreground"
>
{emptyState}
</td>
</tr>
)
) : (
<>
{paddingTop > 0 ? (
<tr aria-hidden style={{ height: paddingTop }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{virtualItems.map((vItem) => {
const entry = sortedRows[vItem.index];
const isSelected = selected.has(entry.id);
return (
<tr
key={entry.id}
ref={(el) => {
rowRefs.current[entry.id] = el;
}}
data-selected={isSelected}
style={{ height: rowHeight }}
onPointerEnter={
hasRowMenu
? () => activateRow(entry.id, vItem.index)
: undefined
}
onPointerLeave={hasRowMenu ? deactivateRow : undefined}
className={cn(
"border-border/60 border-b transition-colors",
"data-[selected=true]:bg-primary/5",
"hover:bg-muted/50",
)}
>
{selectable ? (
<td className="text-center">
<div className="flex items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRow(entry.id)}
aria-label={`Select row ${vItem.index + 1}`}
/>
</div>
</td>
) : null}
{orderedColumns.map((column) => (
<td
key={column.key}
className={cn(
"truncate px-4 text-foreground",
alignText(column.align),
)}
>
{renderValue(entry, column, vItem.index)}
</td>
))}
<td aria-hidden />
</tr>
);
})}
{paddingBottom > 0 ? (
<tr aria-hidden style={{ height: paddingBottom }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{loading ? (
<SkeletonRows
count={skeletonRows}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : null}
</>
)}
</tbody>
</table>
)}
</div>
{hasRowMenu && activeRow && !stacked ? (
<RowHandle
rowEl={activeRowEl}
id={activeRow.id}
index={activeRow.index}
onInsertRow={onInsertRow}
onDeleteRow={onDeleteRow}
onEnter={() => activateRow(activeRow.id, activeRow.index)}
onLeave={deactivateRow}
/>
) : null}
</div>
);
}
"use client";
export function EditableCell({
value,
label,
onChange,
}: {
value: string;
label: string;
onChange: (next: string) => void;
}) {
return (
<input
value={value}
aria-label={label}
size={1}
onChange={(e) => onChange(e.target.value)}
placeholder="Empty"
className="-mx-2 w-full min-w-0 appearance-none rounded-md border-0 bg-transparent px-2 py-1 text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:bg-muted focus:ring-1 focus:ring-ring"
/>
);
}
"use client";
import { ArrowDownToLine, ArrowUpToLine, MoreVertical, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { TableMenu, type TableMenuItem } from "./table-menu";
/** Row menu entries (Insert before / after, Delete) for whichever callbacks are set. */
export function rowMenuItems({
id,
index,
onInsertRow,
onDeleteRow,
}: {
id: string;
index: number;
onInsertRow?: (index: number, position: "before" | "after") => void;
onDeleteRow?: (rowId: string, index: number) => void;
}): TableMenuItem[] {
return [
...(onInsertRow
? [
{
label: "Insert before",
icon: <ArrowUpToLine />,
onSelect: () => onInsertRow(index, "before"),
},
{
label: "Insert after",
icon: <ArrowDownToLine />,
onSelect: () => onInsertRow(index, "after"),
},
]
: []),
...(onDeleteRow
? [
{
label: "Delete row",
icon: <Trash2 />,
destructive: true,
onSelect: () => onDeleteRow(id, index),
},
]
: []),
];
}
/** The row handle, portaled so it can sit on the row's left border without the
* scroll container clipping it. Straddles the border to bridge hover. */
export function RowHandle({
rowEl,
id,
index,
onInsertRow,
onDeleteRow,
onEnter,
onLeave,
}: {
rowEl: HTMLTableRowElement | null;
id: string;
index: number;
onInsertRow?: (index: number, position: "before" | "after") => void;
onDeleteRow?: (rowId: string, index: number) => void;
onEnter: () => void;
onLeave: () => void;
}) {
useEffect(() => {
window.addEventListener("scroll", onLeave, true);
return () => window.removeEventListener("scroll", onLeave, true);
}, [onLeave]);
if (!rowEl || typeof document === "undefined") return null;
const rect = rowEl.getBoundingClientRect();
return createPortal(
<div
style={{
position: "fixed",
top: rect.top + rect.height / 2,
left: rect.left,
transform: "translate(-50%, -50%)",
zIndex: 40,
}}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
>
<TableMenu
ariaLabel={`Row ${index + 1} options`}
triggerClassName="flex h-6 w-2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
trigger={<MoreVertical className="h-3 w-3" />}
items={rowMenuItems({ id, index, onInsertRow, onDeleteRow })}
/>
</div>,
document.body,
);
}
"use client";
import { cn } from "@/lib/utils";
import type { TableColumn } from "./types";
import { alignText } from "./utils";
/** Placeholder cards for the stacked layout; hidden from assistive tech. */
export function SkeletonCards({
count,
fields,
}: {
count: number;
fields: number;
}) {
return (
<div aria-hidden>
{Array.from({ length: count }, (_, r) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder cards
<div key={r} className="border-border/60 border-b px-3 py-3">
<div className="h-3.5 w-1/2 animate-pulse rounded-full bg-muted" />
<div className="mt-3 grid gap-2.5">
{Array.from({ length: fields }, (_, f) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder lines
key={f}
className="flex items-center justify-between gap-3"
>
<div className="h-2.5 w-16 animate-pulse rounded-full bg-muted" />
<div className="h-2.5 w-1/3 animate-pulse rounded-full bg-muted" />
</div>
))}
</div>
</div>
))}
</div>
);
}
export function SkeletonRows<T>({
count,
columns,
selectable,
rowHeight,
}: {
count: number;
columns: TableColumn<T>[];
selectable: boolean;
rowHeight: number;
}) {
return (
<>
{Array.from({ length: count }, (_, r) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows
<tr key={r} style={{ height: rowHeight }} className="border-border/60 border-b">
{selectable ? <td /> : null}
{columns.map((column) => (
<td key={column.key} className={cn("px-4", alignText(column.align))}>
<div
className={cn(
"h-3 animate-pulse rounded-full bg-muted",
column.align === "right" ? "ml-auto w-10" : "w-2/3",
)}
/>
</td>
))}
<td aria-hidden />
</tr>
))}
</>
);
}
"use client";
import {
type ReactNode,
type RefObject,
useId,
useLayoutEffect,
useState,
} from "react";
import { cn } from "@/lib/utils";
import type { SortState, TableColumn } from "./types";
/*
* Shared pieces of the stacked ("card list") layout both tables switch to in a
* narrow container (`mobileLayout="stack"`).
*/
/** Container width (px) below which `mobileLayout="stack"` shows cards. */
export const DEFAULT_STACK_BELOW = 560;
/**
* True once the element measures narrower than `stackBelow`. Starts false, so
* the server and the hydrating client both render the table, then switches in
* a layout effect (before the first paint after hydration) and follows the
* element's width from there. The element must not size to its content, or the
* swap could feed back into the measurement — both tables measure their
* full-width root.
*/
export function useStackedLayout(
ref: RefObject<HTMLElement | null>,
enabled: boolean,
stackBelow: number,
) {
const [stacked, setStacked] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!enabled || !el) {
setStacked(false);
return;
}
const update = (width: number) => setStacked(width < stackBelow);
update(el.getBoundingClientRect().width);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const entry = entries[entries.length - 1];
if (entry) update(entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width);
});
observer.observe(el);
return () => observer.disconnect();
}, [ref, enabled, stackBelow]);
return enabled && stacked;
}
/** Plain-text name of a column, for option labels and announcements. */
export function columnLabel<T>(column: TableColumn<T>) {
return typeof column.header === "string" ? column.header : column.key;
}
/**
* Splits visible columns into the card title (`primary` columns, or the first
* column when none opts in) and the label/value fields under it, leaving out
* `hideOnMobile` columns. A primary column is always shown.
*/
export function splitStackColumns<C extends TableColumn<never>>(columns: C[]) {
const flagged = columns.filter((c) => c.primary);
const primary = flagged.length > 0 ? flagged : columns.slice(0, 1);
const titleKeys = new Set(primary.map((c) => c.key));
const fields = columns.filter((c) => !titleKeys.has(c.key) && !c.hideOnMobile);
return { primary, fields };
}
/** Rough card height before measurement: title row + one line per field. */
export function estimateCardHeight(fieldCount: number) {
return 56 + fieldCount * 28;
}
export type StackedField = {
key: string;
label: ReactNode;
value: ReactNode;
/** The value is an input: let it take the full width instead of hugging the right edge. */
control?: boolean;
};
/**
* A card's label/value pairs as a description list, so each value is read
* with its column header.
*/
export function StackedFields({
fields,
className,
}: {
fields: StackedField[];
className?: string;
}) {
if (fields.length === 0) return null;
return (
<dl className={cn("grid gap-1.5", className)}>
{fields.map((field) => (
<div key={field.key} className="flex min-w-0 items-center gap-3">
<dt className="w-2/5 max-w-40 shrink-0 truncate text-muted-foreground text-xs">
{field.label}
</dt>
<dd
className={cn(
"min-w-0 flex-1 text-foreground",
field.control ? "pl-2" : "truncate text-right",
)}
>
{field.value}
</dd>
</div>
))}
</dl>
);
}
const SORT_NONE = "";
/**
* Compact sort control shown above the cards, where there are no headers to
* click: one native select of "column, direction" pairs.
*/
export function StackSortSelect<T>({
columns,
value,
onChange,
className,
}: {
columns: TableColumn<T>[];
value: SortState | null;
onChange: (next: SortState | null) => void;
className?: string;
}) {
const id = useId();
const sortable = columns.filter((c) => c.sortable);
if (sortable.length === 0) return null;
const current =
value && sortable.some((c) => c.key === value.key)
? `${value.direction}:${value.key}`
: SORT_NONE;
return (
<div className={cn("flex min-w-0 items-center gap-2", className)}>
<label htmlFor={id} className="shrink-0 text-muted-foreground text-xs">
Sort by
</label>
<select
id={id}
value={current}
onChange={(e) => {
const raw = e.target.value;
if (raw === SORT_NONE) return onChange(null);
const split = raw.indexOf(":");
onChange({
direction: raw.slice(0, split) === "desc" ? "desc" : "asc",
key: raw.slice(split + 1),
});
}}
className="h-8 min-w-0 max-w-full rounded-lg border border-border bg-background px-2 text-foreground text-sm outline-none transition-colors focus-visible:border-border-strong focus-visible:ring-2 focus-visible:ring-ring/30"
>
<option value={SORT_NONE}>None</option>
{sortable.map((column) => {
const label = columnLabel(column);
return [
<option key={`asc:${column.key}`} value={`asc:${column.key}`}>
{label} (ascending)
</option>,
<option key={`desc:${column.key}`} value={`desc:${column.key}`}>
{label} (descending)
</option>,
];
})}
</select>
</div>
);
}
"use client";
import {
ArrowLeftToLine,
ArrowRightToLine,
ChevronUp,
GripVertical,
MoreHorizontal,
Trash2,
} from "lucide-react";
import { motion } from "motion/react";
import { type PointerEvent as ReactPointerEvent, useEffect } from "react";
import { createPortal } from "react-dom";
import { Checkbox } from "@/components/motion/checkbox";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
import { TableMenu } from "./table-menu";
import type {
HeaderCellRefs,
InsertPosition,
SortState,
TableColumn,
} from "./types";
import { alignFlex, alignText, COLUMN_ACTIVE_SHADOW } from "./utils";
export interface TableHeaderProps<T> {
columns: TableColumn<T>[];
rowHeight: number;
reduce: boolean;
thRefs: HeaderCellRefs;
selectable: boolean;
allSelected: boolean;
someSelected: boolean;
onToggleAll: () => void;
sort: SortState | null;
onToggleSort: (key: string) => void;
resizable: boolean;
onResizeStart: (key: string, e: ReactPointerEvent) => void;
onResizeMove: (e: ReactPointerEvent) => void;
onResizeEnd: (e: ReactPointerEvent) => void;
reorderable: boolean;
dragKey: string | null;
dropIndex: number | null;
onReorderStart: (key: string, e: ReactPointerEvent) => void;
onReorderMove: (e: ReactPointerEvent) => void;
onReorderEnd: (e: ReactPointerEvent) => void;
onInsertColumn?: (index: number, position: InsertPosition) => void;
onDeleteColumn?: (columnKey: string, index: number) => void;
onColumnRename?: (columnKey: string, value: string) => void;
activeColumn: string | null;
onColumnActivate?: (key: string) => void;
onColumnDeactivate?: () => void;
}
/** Column insert / delete menu items shared by the header cell and the portal handle. */
function columnMenuItems<T>(
column: TableColumn<T>,
index: number,
onInsertColumn?: (index: number, position: InsertPosition) => void,
onDeleteColumn?: (columnKey: string, index: number) => void,
) {
return [
...(onInsertColumn
? [
{
label: "Insert before",
icon: <ArrowLeftToLine />,
onSelect: () => onInsertColumn(index, "before"),
},
{
label: "Insert after",
icon: <ArrowRightToLine />,
onSelect: () => onInsertColumn(index, "after"),
},
]
: []),
...(onDeleteColumn
? [
{
label: "Delete column",
icon: <Trash2 />,
destructive: true,
onSelect: () => onDeleteColumn(column.key, index),
},
]
: []),
];
}
/** The ellipse handle, portaled so it can sit on the column's top border without
* the scroll container clipping it. Straddles the border to bridge hover. */
function ColumnHandle<T>({
column,
index,
thRefs,
onInsertColumn,
onDeleteColumn,
onEnter,
onLeave,
}: {
column: TableColumn<T>;
index: number;
thRefs: HeaderCellRefs;
onInsertColumn?: (index: number, position: InsertPosition) => void;
onDeleteColumn?: (columnKey: string, index: number) => void;
onEnter: () => void;
onLeave: () => void;
}) {
useEffect(() => {
window.addEventListener("scroll", onLeave, true);
return () => window.removeEventListener("scroll", onLeave, true);
}, [onLeave]);
const el = thRefs.current[column.key];
if (!el || typeof document === "undefined") return null;
const rect = el.getBoundingClientRect();
return createPortal(
<div
style={{
position: "fixed",
top: rect.top,
left: rect.left + rect.width / 2,
transform: "translate(-50%, -50%)",
zIndex: 40,
}}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
>
<TableMenu
ariaLabel={`${column.key} column options`}
triggerClassName="flex h-2 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
trigger={<MoreHorizontal className="h-3 w-3" />}
items={columnMenuItems(column, index, onInsertColumn, onDeleteColumn)}
/>
</div>,
document.body,
);
}
export function TableHeader<T>({
columns,
rowHeight,
reduce,
thRefs,
selectable,
allSelected,
someSelected,
onToggleAll,
sort,
onToggleSort,
resizable,
onResizeStart,
onResizeMove,
onResizeEnd,
reorderable,
dragKey,
dropIndex,
onReorderStart,
onReorderMove,
onReorderEnd,
onInsertColumn,
onDeleteColumn,
onColumnRename,
activeColumn,
onColumnActivate,
onColumnDeactivate,
}: TableHeaderProps<T>) {
const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);
const activeIndex = columns.findIndex((c) => c.key === activeColumn);
return (
<>
{hasColumnMenu && activeColumn && activeIndex >= 0 ? (
<ColumnHandle
column={columns[activeIndex]}
index={activeIndex}
thRefs={thRefs}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onEnter={() => onColumnActivate?.(activeColumn)}
onLeave={() => onColumnDeactivate?.()}
/>
) : null}
<thead>
<tr style={{ height: rowHeight }}>
{selectable ? (
<th className="sticky top-0 z-10 border-border border-b bg-muted">
<div className="flex items-center justify-center">
<Checkbox
checked={allSelected}
indeterminate={!allSelected && someSelected}
onCheckedChange={onToggleAll}
aria-label="Select all rows"
className="size-6"
/>
</div>
</th>
) : null}
{columns.map((column, index) => {
const active = sort?.key === column.key;
const isDragging = dragKey === column.key;
const isActive = activeColumn === column.key;
return (
<th
key={column.key}
ref={(el) => {
thRefs.current[column.key] = el;
}}
onPointerEnter={() => onColumnActivate?.(column.key)}
onPointerLeave={() => onColumnDeactivate?.()}
style={isActive ? { boxShadow: COLUMN_ACTIVE_SHADOW } : undefined}
aria-sort={
active
? sort?.direction === "asc"
? "ascending"
: "descending"
: undefined
}
data-drop={dragKey ? dropIndex === index : undefined}
data-dropend={
dragKey
? dropIndex === columns.length && index === columns.length - 1
: undefined
}
className={cn(
"group sticky top-0 z-10 border-border border-b bg-muted p-0 font-medium text-muted-foreground",
"data-[drop=true]:before:absolute data-[drop=true]:before:inset-y-0 data-[drop=true]:before:left-0 data-[drop=true]:before:w-0.5 data-[drop=true]:before:bg-primary",
"data-[dropend=true]:after:absolute data-[dropend=true]:after:inset-y-0 data-[dropend=true]:after:right-0 data-[dropend=true]:after:w-0.5 data-[dropend=true]:after:bg-primary",
)}
>
<motion.div
className={cn(
"flex h-full items-center",
alignFlex(column.align),
)}
style={{ height: rowHeight }}
animate={
reduce
? { opacity: isDragging ? 0.5 : 1 }
: {
scale: isDragging ? 1.04 : 1,
opacity: isDragging ? 0.5 : 1,
}
}
transition={SPRING_PRESS}
>
{reorderable ? (
<button
type="button"
aria-label={`Reorder ${column.key} column`}
onPointerDown={(e) => onReorderStart(column.key, e)}
onPointerMove={onReorderMove}
onPointerUp={onReorderEnd}
className={cn(
"flex h-full w-6 cursor-grab touch-none items-center justify-center text-muted-foreground/60 transition-colors hover:text-foreground active:cursor-grabbing",
// The grip owns the whole press, so iOS must not open its
// callout out of the same one and cancel the drag.
TOUCH_GESTURE_CLASS,
)}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
) : null}
{column.sortable ? (
<button
type="button"
onClick={() => onToggleSort(column.key)}
className={cn(
"flex h-full min-w-0 flex-1 select-none items-center gap-1 px-4 transition-colors hover:text-foreground",
alignFlex(column.align),
active && "text-foreground",
)}
>
<span className="truncate">{column.header}</span>
<motion.span
aria-hidden
className="inline-flex shrink-0"
animate={{
rotate: active && sort?.direction === "desc" ? 180 : 0,
opacity: active ? 1 : 0.35,
}}
transition={
reduce
? { duration: 0 }
: { duration: 0.18, ease: EASE_OUT }
}
>
<ChevronUp className="h-3.5 w-3.5" />
</motion.span>
</button>
) : onColumnRename ? (
<input
value={
typeof column.header === "string" ? column.header : ""
}
aria-label={`Rename ${column.key} column`}
size={1}
onChange={(e) =>
onColumnRename(column.key, e.target.value)
}
className={cn(
"min-w-0 flex-1 truncate appearance-none rounded-md border-0 bg-transparent px-4 font-medium text-muted-foreground outline-none transition-colors focus:bg-muted focus:text-foreground",
alignText(column.align),
)}
/>
) : (
<span
className={cn(
"min-w-0 flex-1 truncate px-4",
alignText(column.align),
)}
>
{column.header}
</span>
)}
</motion.div>
{resizable ? (
<button
type="button"
aria-label={`Resize ${column.key} column`}
tabIndex={-1}
onPointerDown={(e) => onResizeStart(column.key, e)}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
className={cn(
"absolute top-0 right-0 h-full w-1.5 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-primary/40",
// Same for the resize drag: the handle drives it end to end.
TOUCH_GESTURE_CLASS,
)}
/>
) : null}
</th>
);
})}
<th
aria-hidden
className="sticky top-0 z-10 border-border border-b bg-muted"
/>
</tr>
</thead>
</>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type TableMenuItem = {
label: string;
icon?: ReactNode;
onSelect: () => void;
destructive?: boolean;
};
const MENU_WIDTH = 188;
export function TableMenu({
items,
ariaLabel,
trigger,
triggerClassName,
}: {
items: TableMenuItem[];
ariaLabel: string;
trigger: ReactNode;
triggerClassName?: string;
}) {
const reduce = useReducedMotion();
const triggerRef = useRef<HTMLButtonElement>(null);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(
null,
);
const open = coords !== null;
useEffect(() => {
if (!open) return;
const close = () => setCoords(null);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
// Close on any scroll (the trigger moves) or resize; fixed coords go stale.
window.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
window.removeEventListener("keydown", onKey);
};
}, [open]);
const toggle = () => {
if (open) {
setCoords(null);
return;
}
const el = triggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setCoords({
top: r.bottom + 4,
left: Math.max(8, r.right - MENU_WIDTH),
});
};
return (
<>
<button
ref={triggerRef}
type="button"
aria-label={ariaLabel}
aria-haspopup="menu"
aria-expanded={open}
onClick={(e) => {
e.stopPropagation();
toggle();
}}
className={triggerClassName}
>
{trigger}
</button>
{open && typeof document !== "undefined"
? createPortal(
<>
<div
className="fixed inset-0 z-40"
onPointerDown={() => setCoords(null)}
/>
<motion.div
role="menu"
className="fixed z-50 overflow-hidden rounded-xl border border-border bg-background p-1 shadow-xl"
style={{ top: coords.top, left: coords.left, width: MENU_WIDTH }}
initial={
reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -4 }
}
animate={
reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }
}
transition={reduce ? { duration: 0 } : SPRING_PANEL}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
onClick={() => {
setCoords(null);
item.onSelect();
}}
className={cn(
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm transition-colors [&_svg]:h-4 [&_svg]:w-4",
item.destructive
? "text-rose-500 hover:bg-rose-500/10"
: "text-foreground hover:bg-muted",
)}
>
{item.icon}
{item.label}
</button>
))}
</motion.div>
</>,
document.body,
)
: null}
</>
);
}
import type { ReactNode } from "react";
export type SortDirection = "asc" | "desc";
export type SortState = {
key: string;
direction: SortDirection;
};
export type TableColumn<T> = {
/** Stable key; also the default object property read for the cell + sort value. */
key: string;
/** Header content. */
header: ReactNode;
/** Allow clicking the header to sort by this column. */
sortable?: boolean;
/** Cell text alignment. */
align?: "left" | "center" | "right";
/** Column width as a CSS length, e.g. "160px" or "20%". Omit to share remaining space equally. */
width?: string;
/** Custom cell renderer. Falls back to `row[key]`. */
cell?: (row: T) => ReactNode;
/** Render an inline text input for this column's cells (ignored when `cell` is set). */
editable?: boolean;
/** Value used for sorting. Falls back to `row[key]`. */
sortValue?: (row: T) => string | number;
/** Stacked layout (`mobileLayout="stack"`): show this cell in the card title.
* Defaults to the first column when no column sets it. */
primary?: boolean;
/** Stacked layout: leave this column out of the card's label/value pairs. */
hideOnMobile?: boolean;
};
/**
* Narrow-container layout. `"scroll"` keeps the table and scrolls it sideways;
* `"stack"` renders one card per row while the table's own width is below
* `stackBelow`.
*/
export type MobileLayout = "scroll" | "stack";
export type InsertPosition = "before" | "after";
export interface TableProps<T> {
data: T[];
columns: TableColumn<T>[];
/** Stable id per row, required for correct selection across sorts. Defaults to row index. */
getRowId?: (row: T, index: number) => string;
/** Render a leading checkbox column with select-all in the header. */
selectable?: boolean;
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
onSelectionChange?: (ids: string[]) => void;
sort?: SortState | null;
defaultSort?: SortState | null;
onSortChange?: (sort: SortState | null) => void;
/** Allow dragging the right edge of a header to resize that column. */
resizable?: boolean;
/** Minimum column width in px when resizing. */
minColumnWidth?: number;
onColumnResize?: (key: string, width: number) => void;
/** Allow dragging a header grip to reorder columns. */
reorderable?: boolean;
onColumnOrderChange?: (keys: string[]) => void;
/** Called when an `editable` cell changes. */
onCellEdit?: (rowId: string, columnKey: string, value: string) => void;
/** When set, non-sortable headers become editable inputs for the column name. */
onColumnRename?: (columnKey: string, value: string) => void;
/** Enables the row menu (Insert before / after). Receives the target index. */
onInsertRow?: (index: number, position: InsertPosition) => void;
/** Enables Delete in the row menu. */
onDeleteRow?: (rowId: string, index: number) => void;
/** Enables the column menu (Insert before / after). Receives the target column index. */
onInsertColumn?: (index: number, position: InsertPosition) => void;
/** Enables Delete in the column menu. */
onDeleteColumn?: (columnKey: string, index: number) => void;
/** Fixed row height in px — required for virtualization. */
rowHeight?: number;
/** Scroll viewport height in px. */
height?: number;
/** Rows rendered above/below the viewport. */
overscan?: number;
/** Fires when the viewport scrolls near the bottom — load the next page. */
onEndReached?: () => void;
/** Currently fetching — shows skeleton rows and pauses `onEndReached`. */
loading?: boolean;
/** How many skeleton rows to show while loading more (default 3). */
skeletonRows?: number;
emptyState?: ReactNode;
/**
* `"scroll"` (default) keeps today's sideways-scrolling table. `"stack"`
* swaps to a virtualized card list (title from `primary` columns, the rest as
* header: value pairs, editable cells stay inputs) whenever the table's own
* width is below `stackBelow`. The server always renders the table.
*/
mobileLayout?: MobileLayout;
/** Width in px under which `mobileLayout="stack"` shows cards (default 560). */
stackBelow?: number;
className?: string;
}
/** A data row paired with its stable id. */
export type TableRow<T> = { row: T; id: string };
/** Ref map from column key to its header cell, shared across the resize/reorder hooks. */
export type HeaderCellRefs = {
current: Record<string, HTMLTableCellElement | null>;
};
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useMemo,
useState,
} from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
import type { HeaderCellRefs, TableColumn } from "./types";
export function useColumnReorder<T>({
columns,
thRefs,
order: orderProp,
defaultOrder,
onColumnOrderChange,
}: {
columns: TableColumn<T>[];
thRefs: HeaderCellRefs;
/** Controlled column key order. Omit to keep the order internally. */
order?: string[];
/** Initial order for uncontrolled use; defaults to the `columns` order. */
defaultOrder?: string[];
onColumnOrderChange?: (keys: string[]) => void;
}) {
const [internalOrder, setOrder] = useState<string[]>(
() => defaultOrder ?? columns.map((c) => c.key),
);
const order = orderProp ?? internalOrder;
const [dragKey, setDragKey] = useState<string | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
// Apply the current order, tolerating columns added/removed at runtime. New
// columns are placed at their position in `columns` (after their left
// neighbor), not appended — so an inserted column lands where it was added.
const orderedColumns = useMemo(() => {
const byKey = new Map(columns.map((c) => [c.key, c]));
const resultKeys = order.filter((k) => byKey.has(k));
const present = new Set(resultKeys);
columns.forEach((column, i) => {
if (present.has(column.key)) return;
let at = resultKeys.length;
if (i === 0) {
at = 0;
} else {
const idx = resultKeys.indexOf(columns[i - 1].key);
at = idx === -1 ? i : idx + 1;
}
resultKeys.splice(at, 0, column.key);
present.add(column.key);
});
return resultKeys
.map((k) => byKey.get(k))
.filter((c): c is TableColumn<T> => c !== undefined);
}, [order, columns]);
const dropIndexFor = useCallback(
(clientX: number) => {
for (let i = 0; i < orderedColumns.length; i++) {
const rect =
thRefs.current[orderedColumns[i].key]?.getBoundingClientRect();
if (rect && clientX < rect.left + rect.width / 2) return i;
}
return orderedColumns.length;
},
[orderedColumns, thRefs],
);
/** Commit a full key order — the drop of a drag, or a keyboard move. */
const commitOrder = useCallback(
(keys: string[]) => {
if (orderProp === undefined) setOrder(keys);
onColumnOrderChange?.(keys);
},
[orderProp, onColumnOrderChange],
);
const startReorder = useCallback((key: string, e: ReactPointerEvent) => {
e.preventDefault();
e.stopPropagation();
setDragKey(key);
capturePointer(e.currentTarget, e.pointerId);
}, []);
const moveReorder = useCallback(
(e: ReactPointerEvent) => {
if (!dragKey) return;
setDropIndex(dropIndexFor(e.clientX));
},
[dragKey, dropIndexFor],
);
const endReorder = useCallback(
(e: ReactPointerEvent) => {
releasePointer(e.currentTarget, e.pointerId);
if (dragKey && dropIndex !== null) {
const keys = orderedColumns.map((c) => c.key);
const from = keys.indexOf(dragKey);
if (from !== -1) {
const without = keys.filter((_, i) => i !== from);
let to = dropIndex;
if (from < to) to--;
without.splice(to, 0, dragKey);
commitOrder(without);
}
}
setDragKey(null);
setDropIndex(null);
},
[dragKey, dropIndex, orderedColumns, commitOrder],
);
return {
orderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
commitOrder,
};
}
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useRef,
useState,
} from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
import type { HeaderCellRefs, TableColumn } from "./types";
export function useColumnResize<T>({
orderedColumns,
thRefs,
minColumnWidth,
onColumnResize,
}: {
orderedColumns: TableColumn<T>[];
thRefs: HeaderCellRefs;
minColumnWidth: number;
onColumnResize?: (key: string, width: number) => void;
}) {
const resizeRef = useRef<{
key: string;
startX: number;
startWidth: number;
} | null>(null);
const [widths, setWidths] = useState<Record<string, number>>({});
const startResize = useCallback(
(key: string, e: ReactPointerEvent) => {
e.preventDefault();
e.stopPropagation();
// Freeze every column to its current pixel width so resizing one only
// moves the trailing spacer, never the other columns.
const snapshot = { ...widths };
for (const column of orderedColumns) {
if (snapshot[column.key] == null) {
const measured = thRefs.current[column.key]?.getBoundingClientRect()
.width;
snapshot[column.key] = measured
? Math.round(measured)
: minColumnWidth;
}
}
resizeRef.current = {
key,
startX: e.clientX,
startWidth: snapshot[key],
};
setWidths(snapshot);
capturePointer(e.currentTarget, e.pointerId);
},
[minColumnWidth, orderedColumns, thRefs, widths],
);
const moveResize = useCallback(
(e: ReactPointerEvent) => {
const state = resizeRef.current;
if (!state) return;
const width = Math.max(
minColumnWidth,
state.startWidth + (e.clientX - state.startX),
);
setWidths((prev) => ({ ...prev, [state.key]: width }));
},
[minColumnWidth],
);
const endResize = useCallback(
(e: ReactPointerEvent) => {
const state = resizeRef.current;
resizeRef.current = null;
releasePointer(e.currentTarget, e.pointerId);
if (state) {
onColumnResize?.(state.key, widths[state.key] ?? state.startWidth);
}
},
[onColumnResize, widths],
);
return { widths, startResize, moveResize, endResize };
}
import { useCallback, useMemo, useState } from "react";
import type { SortState, TableColumn, TableRow } from "./types";
import { readSortValue } from "./utils";
export function useColumnSort<T>({
rows,
columns,
sort: sortProp,
defaultSort = null,
onSortChange,
}: {
rows: TableRow<T>[];
columns: TableColumn<T>[];
sort?: SortState | null;
defaultSort?: SortState | null;
onSortChange?: (sort: SortState | null) => void;
}) {
const [internalSort, setInternalSort] = useState<SortState | null>(
defaultSort,
);
const sort = sortProp !== undefined ? sortProp : internalSort;
const commit = useCallback(
(next: SortState | null) => {
if (sortProp === undefined) setInternalSort(next);
onSortChange?.(next);
},
[sortProp, onSortChange],
);
const toggleSort = useCallback(
(key: string) => {
if (!sort || sort.key !== key) {
commit({ key, direction: "asc" });
} else if (sort.direction === "asc") {
commit({ key, direction: "desc" });
} else {
commit(null);
}
},
[sort, commit],
);
const sortedRows = useMemo(() => {
if (!sort) return rows;
const column = columns.find((c) => c.key === sort.key);
if (!column) return rows;
const copy = [...rows];
copy.sort((a, b) => {
const av = readSortValue(a.row, column);
const bv = readSortValue(b.row, column);
let cmp: number;
if (typeof av === "number" && typeof bv === "number") {
cmp = av - bv;
} else {
cmp = String(av).localeCompare(String(bv));
}
return sort.direction === "asc" ? cmp : -cmp;
});
return copy;
}, [rows, sort, columns]);
return { sort, sortedRows, toggleSort, setSort: commit };
}
import { useCallback, useMemo, useState } from "react";
import type { TableRow } from "./types";
export function useRowSelection<T>({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
}: {
sortedRows: TableRow<T>[];
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
onSelectionChange?: (ids: string[]) => void;
}) {
const [internalSelected, setInternalSelected] = useState<Set<string>>(
() => new Set(defaultSelectedRowIds),
);
const selected = useMemo(
() =>
selectedRowIds !== undefined
? new Set(selectedRowIds)
: internalSelected,
[selectedRowIds, internalSelected],
);
const commit = useCallback(
(next: Set<string>) => {
if (selectedRowIds === undefined) setInternalSelected(next);
onSelectionChange?.([...next]);
},
[selectedRowIds, onSelectionChange],
);
const allSelected =
sortedRows.length > 0 && sortedRows.every((r) => selected.has(r.id));
const someSelected = sortedRows.some((r) => selected.has(r.id));
const toggleAll = useCallback(() => {
const next = new Set(selected);
if (allSelected) {
for (const r of sortedRows) next.delete(r.id);
} else {
for (const r of sortedRows) next.add(r.id);
}
commit(next);
}, [allSelected, sortedRows, selected, commit]);
const toggleRow = useCallback(
(id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
commit(next);
},
[selected, commit],
);
return { selected, allSelected, someSelected, toggleAll, toggleRow };
}
import type { ReactNode } from "react";
import type { TableColumn } from "./types";
export const CHECKBOX_PX = 48;
export const CHECKBOX_WIDTH = `${CHECKBOX_PX}px`;
/** Highlights the top edge of the active column's header cell. */
export const COLUMN_ACTIVE_SHADOW = "inset 0 1px 0 var(--color-primary)";
export function alignFlex(align: TableColumn<unknown>["align"]) {
if (align === "right") return "justify-end";
if (align === "center") return "justify-center";
return "justify-start";
}
export function alignText(align: TableColumn<unknown>["align"]) {
if (align === "right") return "text-right";
if (align === "center") return "text-center";
return "text-left";
}
export function readCell<T>(row: T, column: TableColumn<T>): ReactNode {
if (column.cell) return column.cell(row);
return (row as Record<string, ReactNode>)[column.key];
}
export function readSortValue<T>(
row: T,
column: TableColumn<T>,
): string | number {
if (column.sortValue) return column.sortValue(row);
return (row as Record<string, string | number>)[column.key];
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId } from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
const CHECK_PATH = "M5 13l4 4L19 7";
const INDETERMINATE_PATH = "M6 12h12";
export interface CheckboxProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
indeterminate?: boolean;
label?: string;
className?: string;
id?: string;
"aria-label"?: string;
/** Associates an external message (e.g. a form error) with the control. */
"aria-describedby"?: string;
}
export function Checkbox({
checked,
onCheckedChange,
disabled,
indeterminate,
label,
className,
id: idProp,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
}: CheckboxProps) {
const autoId = useId();
const id = idProp ?? autoId;
const reduce = useReducedMotion();
const showMark = checked || indeterminate;
const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;
return (
<label
htmlFor={id}
className={cn(
"inline-flex items-center gap-3",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
)}
>
<motion.button
id={id}
type="button"
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={
checked ? "checked" : indeterminate ? "indeterminate" : "unchecked"
}
className={cn(
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200",
TOUCH_TARGET_CLASS,
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
showMark
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50 bg-background hover:border-muted-foreground",
)}
>
<AnimatePresence initial={false}>
{showMark ? (
<motion.svg
key={indeterminate ? "indeterminate" : "checked"}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
transition={
reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }
}
aria-hidden
>
<title>{indeterminate ? "Partially selected" : "Selected"}</title>
<motion.path
d={path}
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce
? { duration: 0 }
: {
duration: indeterminate ? 0.2 : 0.3,
ease: EASE_OUT,
delay: 0.04,
}
}
/>
</motion.svg>
) : null}
</AnimatePresence>
</motion.button>
{label ? (
<span className={cn("select-none text-sm text-foreground", disabled && "opacity-60")}>
{label}
</span>
) : null}
</label>
);
}
"use client";
import { animate, motion, MotionConfig, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// Heavy, deliberate thumb — high mass keeps the travel weighty without wobble.
const THUMB_SPRING = { type: "spring", stiffness: 800, damping: 80, mass: 4 } as const;
export interface SwitchProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
label?: string;
ariaLabel?: string;
className?: string;
}
export function Switch({
checked,
onCheckedChange,
disabled,
label,
ariaLabel,
className,
}: SwitchProps) {
const id = useId();
const thumbRef = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const [isPressed, setIsPressed] = useState(false);
const [isPointer, setIsPointer] = useState(false);
// Disabled shake feedback when pressed.
useEffect(() => {
if (!thumbRef.current || reduce) return;
if (disabled && isPressed) {
animate(
thumbRef.current,
{ x: [0, -2, 2, -1, 0] },
{ delay: 0.2, duration: 0.6 },
);
}
}, [disabled, isPressed, reduce]);
const squish = !disabled && isPointer && isPressed && !reduce;
return (
<MotionConfig transition={reduce ? { duration: 0 } : THUMB_SPRING}>
<span className={cn("inline-flex items-center gap-3", className)}>
<motion.button
id={id}
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
onPointerDown={(e) => {
setIsPressed(true);
setIsPointer(e.type.startsWith("pointer"));
}}
onPointerUp={() => setIsPressed(false)}
onPointerLeave={() => setIsPressed(false)}
initial={false}
data-state={checked ? "checked" : "unchecked"}
className={cn(
"group peer relative inline-flex h-7 w-12 shrink-0 cursor-pointer items-center px-1 rounded-full outline-none transition-colors duration-200",
TOUCH_TARGET_CLASS,
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
checked ? "justify-end bg-primary" : "justify-start bg-muted-foreground/60",
)}
>
<motion.div
ref={thumbRef}
layout
animate={{ scale: squish ? 0.9 : 1 }}
className="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-md"
>
{/* Stretch toward the destination while active. */}
<div
className={cn(
"size-5",
squish && (checked ? "ml-1" : "mr-1"),
)}
/>
</motion.div>
</motion.button>
{label ? (
<label htmlFor={id} className="cursor-pointer text-sm text-foreground">
{label}
</label>
) : null}
</span>
</MotionConfig>
);
}
API Reference
dataT[]—columnsTableColumn<T>[]—getRowId?((row: T, index: number) => string)Stable id per row, required for correct selection across sorts. Defaults to row index.
—selectable?booleanRender a leading checkbox column with select-all in the header.
falseselectedRowIds?string[]—defaultSelectedRowIds?string[]—onSelectionChange?((ids: string[]) => void)—sort?SortState | null—defaultSort?SortState | nullnullonSortChange?((sort: SortState | null) => void)—resizable?booleanAllow dragging the right edge of a header to resize that column.
falseminColumnWidth?numberMinimum column width in px when resizing.
64onColumnResize?((key: string, width: number) => void)—reorderable?booleanAllow dragging a header grip to reorder columns.
falseonColumnOrderChange?((keys: string[]) => void)—onCellEdit?((rowId: string, columnKey: string, value: string) => void)Called when an `editable` cell changes.
—onColumnRename?((columnKey: string, value: string) => void)When set, non-sortable headers become editable inputs for the column name.
—onInsertRow?((index: number, position: InsertPosition) => void)Enables the row menu (Insert before / after). Receives the target index.
—onDeleteRow?((rowId: string, index: number) => void)Enables Delete in the row menu.
—onInsertColumn?((index: number, position: InsertPosition) => void)Enables the column menu (Insert before / after). Receives the target column index.
—onDeleteColumn?((columnKey: string, index: number) => void)Enables Delete in the column menu.
—rowHeight?numberFixed row height in px — required for virtualization.
48height?numberScroll viewport height in px.
440overscan?numberRows rendered above/below the viewport.
10onEndReached?(() => void)Fires when the viewport scrolls near the bottom — load the next page.
—loading?booleanCurrently fetching — shows skeleton rows and pauses `onEndReached`.
falseskeletonRows?numberHow many skeleton rows to show while loading more (default 3).
3emptyState?ReactNodeNo datamobileLayout?"scroll" | "stack"`"scroll"` (default) keeps today's sideways-scrolling table. `"stack"` swaps to a virtualized card list (title from `primary` columns, the rest as header: value pairs, editable cells stay inputs) whenever the table's own width is below `stackBelow`. The server always renders the table.
scrollstackBelow?numberWidth in px under which `mobileLayout="stack"` shows cards (default 560).
560className?string—Async Table
index.tsxFetches more as you near the end: skeleton rows fill in while loading, onEndReached asks for the next page, and the loading flag holds back further requests until it arrives.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i @tanstack/react-virtual clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/table
import { useVirtualizer } from "@tanstack/react-virtual";
import { MoreVertical } from "lucide-react";
import { useReducedMotion } from "motion/react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { Checkbox } from "@/components/motion/checkbox";
import { cn } from "@/lib/utils";
import { EditableCell } from "./editable-cell";
import { RowHandle, rowMenuItems } from "./row-handle";
import { SkeletonCards, SkeletonRows } from "./skeleton-rows";
import {
DEFAULT_STACK_BELOW,
estimateCardHeight,
StackedFields,
StackSortSelect,
splitStackColumns,
useStackedLayout,
} from "./stacked";
import { TableMenu } from "./table-menu";
import { TableHeader } from "./table-header";
import type { HeaderCellRefs, TableProps } from "./types";
import { useColumnReorder } from "./use-column-reorder";
import { useColumnResize } from "./use-column-resize";
import { useColumnSort } from "./use-column-sort";
import { useRowSelection } from "./use-row-selection";
import { alignText, CHECKBOX_PX, CHECKBOX_WIDTH, readCell } from "./utils";
export type {
MobileLayout,
SortDirection,
SortState,
TableColumn,
TableProps,
} from "./types";
/**
* Narrowest a column of bare inputs may be floored to and still show a value:
* the cell's own `px-4` eats 32 of it.
*/
const INPUT_COLUMN_WIDTH = 120;
/** The root font size Tailwind's rem scale assumes, and the pre-measure guess. */
const DEFAULT_ROOT_FONT_SIZE = 16;
/**
* What one `rem` is worth here, in px. The default until the first client
* layout, so the server and the hydrating client emit the same floor; measured
* once after that, because a document that sets its own `html { font-size }`
* lays a rem column out against that size and a floor computed from 16 would
* fall short by the same factor.
*/
function useRootFontSize() {
const [size, setSize] = useState(DEFAULT_ROOT_FONT_SIZE);
useEffect(() => {
const measured = Number.parseFloat(
getComputedStyle(document.documentElement).fontSize,
);
if (measured > 0) setSize(measured);
}, []);
return size;
}
/**
* The absolute width a column declared, in px, or null when it declared a share
* of the remainder instead (`fr`, `%`, `auto`, `calc()`, nothing at all) — those
* are worth whatever is left over, which is not a width this can add up.
*/
function resolveColumnWidth(
width: string | undefined,
rootFontSize: number,
): number | null {
if (!width) return null;
const value = Number.parseFloat(width);
if (!Number.isFinite(value)) return null;
if (width.endsWith("px")) return value;
// rem is the other absolute length the repo writes.
if (width.endsWith("rem")) return value * rootFontSize;
return null;
}
export function Table<T>({
data,
columns,
getRowId,
selectable = false,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
sort: sortProp,
defaultSort = null,
onSortChange,
resizable = false,
minColumnWidth = 64,
onColumnResize,
reorderable = false,
onColumnOrderChange,
onCellEdit,
onColumnRename,
onInsertRow,
onDeleteRow,
onInsertColumn,
onDeleteColumn,
rowHeight = 48,
height = 440,
overscan = 10,
onEndReached,
loading = false,
skeletonRows = 3,
emptyState = "No data",
mobileLayout = "scroll",
stackBelow = DEFAULT_STACK_BELOW,
className,
}: TableProps<T>) {
const reduce = useReducedMotion();
const rootRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const stacked = useStackedLayout(
rootRef,
mobileLayout === "stack",
stackBelow,
);
const thRefs: HeaderCellRefs = useRef<
Record<string, HTMLTableCellElement | null>
>({});
const rows = useMemo(
() =>
data.map((row, index) => ({
row,
id: getRowId ? getRowId(row, index) : String(index),
})),
[data, getRowId],
);
const {
orderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
} = useColumnReorder({ columns, thRefs, onColumnOrderChange });
const { sort, sortedRows, toggleSort, setSort } = useColumnSort({
rows,
columns,
sort: sortProp,
defaultSort,
onSortChange,
});
const { widths, startResize, moveResize, endResize } = useColumnResize({
orderedColumns,
thRefs,
minColumnWidth,
onColumnResize,
});
const { selected, allSelected, someSelected, toggleAll, toggleRow } =
useRowSelection({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
});
// Stacked mode keeps the virtualizer: cards vary in height, so each one is
// measured (`measureElement`) once rendered, starting from an estimate.
const { primary: titleColumns, fields: fieldColumns } = useMemo(
() => splitStackColumns(orderedColumns),
[orderedColumns],
);
const itemEstimate = stacked
? estimateCardHeight(fieldColumns.length)
: rowHeight;
const getItemKey = useCallback(
(index: number) => sortedRows[index]?.id ?? index,
[sortedRows],
);
const virtualizer = useVirtualizer<HTMLDivElement, HTMLLIElement>({
count: sortedRows.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => itemEstimate,
getItemKey,
overscan,
});
// Switching layouts (or card shape) invalidates every cached size.
// biome-ignore lint/correctness/useExhaustiveDependencies: itemEstimate is the reset trigger.
useLayoutEffect(() => {
virtualizer.measure();
}, [virtualizer, itemEstimate]);
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0;
const paddingBottom =
virtualItems.length > 0
? totalSize - virtualItems[virtualItems.length - 1].end
: 0;
const hasRowMenu = !!(onInsertRow || onDeleteRow);
const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);
// Only shrink-wrap (w-max) once every column has an explicit resized width;
// otherwise stay fill-width so a flexible column can't size to cell content.
const sized =
orderedColumns.length > 0 &&
orderedColumns.every((c) => widths[c.key] != null);
const rootFontSize = useRootFontSize();
// In a container narrower than the columns, `table-layout: fixed` shrinks
// every column toward zero instead of scrolling. Floor the table at what the
// columns actually asked for — an absolute declared width where there is one,
// and for the ones sharing the remainder whatever content they can fall back
// on — then let the viewport scroll past it.
const minTableWidth = useMemo(() => {
// A column whose cells render bare inputs has no content for the fallback
// to measure, which is exactly the column that collapses; a renamable
// header is an input too, and in a fixed layout the header row is what
// sizes the column.
const inputOnly = (column: (typeof orderedColumns)[number]) =>
Boolean(onColumnRename) || (!column.cell && Boolean(column.editable));
const total = orderedColumns.reduce((sum, column) => {
const resized = widths[column.key];
if (resized != null) return sum + resized;
const declared = resolveColumnWidth(column.width, rootFontSize);
if (declared != null) return sum + declared;
return (
sum +
(inputOnly(column)
? Math.max(minColumnWidth, INPUT_COLUMN_WIDTH)
: minColumnWidth)
);
}, selectable ? CHECKBOX_PX : 0);
return Math.round(total);
}, [
minColumnWidth,
onColumnRename,
orderedColumns,
rootFontSize,
selectable,
widths,
]);
// Infinite scroll: fire onEndReached once per near-bottom dwell, paused while
// loading; the guard resets when the load completes.
const endReachedRef = useRef(false);
useEffect(() => {
if (!loading) endReachedRef.current = false;
}, [loading]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el || !onEndReached || loading || endReachedRef.current) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight < itemEstimate * 4) {
endReachedRef.current = true;
onEndReached();
}
}, [onEndReached, loading, itemEstimate]);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
// Small delay on leave so the pointer can cross the gap from the header cell
// to the portal handle without the column deactivating.
const deactivateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateColumn = useCallback((key: string) => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = null;
setActiveColumn(key);
}, []);
const deactivateColumn = useCallback(() => {
if (deactivateTimer.current) clearTimeout(deactivateTimer.current);
deactivateTimer.current = setTimeout(() => setActiveColumn(null), 100);
}, []);
const rowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
const [activeRow, setActiveRow] = useState<{ id: string; index: number } | null>(
null,
);
const rowTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const activateRow = useCallback((id: string, index: number) => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = null;
setActiveRow({ id, index });
}, []);
const deactivateRow = useCallback(() => {
if (rowTimer.current) clearTimeout(rowTimer.current);
rowTimer.current = setTimeout(() => setActiveRow(null), 100);
}, []);
const activeRowEl = activeRow ? rowRefs.current[activeRow.id] : null;
// Real columns + checkbox; the trailing spacer adds one more in colSpans.
const leadColumns = columns.length + (selectable ? 1 : 0);
const showStackControls =
selectable || orderedColumns.some((column) => column.sortable);
const renderValue = (
entry: (typeof sortedRows)[number],
column: (typeof orderedColumns)[number],
index: number,
) =>
!column.cell && column.editable ? (
<EditableCell
value={String(readCell(entry.row, column) ?? "")}
label={`${column.key} for row ${index + 1}`}
onChange={(next) => onCellEdit?.(entry.id, column.key, next)}
/>
) : (
readCell(entry.row, column)
);
return (
<div
ref={rootRef}
data-layout={stacked ? "stack" : "table"}
className={cn(
"w-full overflow-hidden border border-border bg-background text-sm",
className,
)}
>
{stacked && showStackControls ? (
<div className="flex min-h-11 flex-wrap items-center gap-x-4 gap-y-2 border-border border-b bg-muted/40 px-3 py-1.5">
{selectable ? (
<Checkbox
checked={allSelected}
indeterminate={!allSelected && someSelected}
onCheckedChange={toggleAll}
label="Select all"
className="text-muted-foreground text-xs"
/>
) : null}
<StackSortSelect
columns={orderedColumns}
value={sort}
onChange={setSort}
className="ml-auto"
/>
</div>
) : null}
<div
ref={scrollRef}
onScroll={handleScroll}
className="overflow-auto"
style={{ height }}
>
{stacked ? (
sortedRows.length === 0 && !loading ? (
<div className="p-10 text-center text-muted-foreground">
{emptyState}
</div>
) : (
<>
{sortedRows.length > 0 ? (
<ul
aria-label="Rows"
className="relative"
style={{ height: totalSize }}
>
{virtualItems.map((vItem) => {
const entry = sortedRows[vItem.index];
const isSelected = selected.has(entry.id);
return (
<li
key={entry.id}
ref={virtualizer.measureElement}
data-index={vItem.index}
data-selected={isSelected}
aria-setsize={sortedRows.length}
aria-posinset={vItem.index + 1}
className={cn(
"absolute inset-x-0 top-0 border-border/60 border-b px-3 py-3 transition-colors",
"data-[selected=true]:bg-primary/5",
)}
style={{ transform: `translateY(${vItem.start}px)` }}
>
<div className="flex min-w-0 items-center gap-3">
{selectable ? (
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRow(entry.id)}
aria-label={`Select row ${vItem.index + 1}`}
/>
) : null}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 font-medium text-foreground">
{titleColumns.map((column) => (
<span
key={column.key}
className="min-w-0 flex-1 truncate"
>
{renderValue(entry, column, vItem.index)}
</span>
))}
</div>
{hasRowMenu ? (
<TableMenu
ariaLabel={`Row ${vItem.index + 1} options`}
triggerClassName="grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
trigger={<MoreVertical className="size-4" />}
items={rowMenuItems({
id: entry.id,
index: vItem.index,
onInsertRow,
onDeleteRow,
})}
/>
) : null}
</div>
<StackedFields
className={cn("mt-2", selectable && "pl-8")}
fields={fieldColumns.map((column) => ({
key: column.key,
label: column.header,
value: renderValue(entry, column, vItem.index),
control: !column.cell && !!column.editable,
}))}
/>
</li>
);
})}
</ul>
) : null}
{loading ? (
<SkeletonCards
count={
sortedRows.length === 0
? Math.max(1, Math.ceil(height / itemEstimate))
: skeletonRows
}
fields={fieldColumns.length}
/>
) : null}
</>
)
) : (
<table
className={cn("border-collapse", sized ? "w-max" : undefined)}
style={{
tableLayout: "fixed",
minWidth: `max(100%, ${minTableWidth}px)`,
}}
>
<colgroup>
{selectable ? <col style={{ width: CHECKBOX_WIDTH }} /> : null}
{orderedColumns.map((column) => {
const override = widths[column.key];
const width = override ? `${override}px` : column.width;
return (
<col key={column.key} style={width ? { width } : undefined} />
);
})}
{/* Empty filler owns the leftover space — no gap, content unpinned. */}
<col />
</colgroup>
<TableHeader
columns={orderedColumns}
rowHeight={rowHeight}
reduce={!!reduce}
thRefs={thRefs}
selectable={selectable}
allSelected={allSelected}
someSelected={someSelected}
onToggleAll={toggleAll}
sort={sort}
onToggleSort={toggleSort}
resizable={resizable}
onResizeStart={startResize}
onResizeMove={moveResize}
onResizeEnd={endResize}
reorderable={reorderable}
dragKey={dragKey}
dropIndex={dropIndex}
onReorderStart={startReorder}
onReorderMove={moveReorder}
onReorderEnd={endReorder}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onColumnRename={onColumnRename}
activeColumn={hasColumnMenu ? activeColumn : null}
onColumnActivate={hasColumnMenu ? activateColumn : undefined}
onColumnDeactivate={hasColumnMenu ? deactivateColumn : undefined}
/>
<tbody>
{sortedRows.length === 0 ? (
loading ? (
<SkeletonRows
count={Math.max(1, Math.ceil(height / rowHeight))}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : (
<tr>
<td
colSpan={leadColumns + 1}
className="p-10 text-center text-muted-foreground"
>
{emptyState}
</td>
</tr>
)
) : (
<>
{paddingTop > 0 ? (
<tr aria-hidden style={{ height: paddingTop }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{virtualItems.map((vItem) => {
const entry = sortedRows[vItem.index];
const isSelected = selected.has(entry.id);
return (
<tr
key={entry.id}
ref={(el) => {
rowRefs.current[entry.id] = el;
}}
data-selected={isSelected}
style={{ height: rowHeight }}
onPointerEnter={
hasRowMenu
? () => activateRow(entry.id, vItem.index)
: undefined
}
onPointerLeave={hasRowMenu ? deactivateRow : undefined}
className={cn(
"border-border/60 border-b transition-colors",
"data-[selected=true]:bg-primary/5",
"hover:bg-muted/50",
)}
>
{selectable ? (
<td className="text-center">
<div className="flex items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRow(entry.id)}
aria-label={`Select row ${vItem.index + 1}`}
/>
</div>
</td>
) : null}
{orderedColumns.map((column) => (
<td
key={column.key}
className={cn(
"truncate px-4 text-foreground",
alignText(column.align),
)}
>
{renderValue(entry, column, vItem.index)}
</td>
))}
<td aria-hidden />
</tr>
);
})}
{paddingBottom > 0 ? (
<tr aria-hidden style={{ height: paddingBottom }}>
<td colSpan={leadColumns + 1} />
</tr>
) : null}
{loading ? (
<SkeletonRows
count={skeletonRows}
columns={orderedColumns}
selectable={selectable}
rowHeight={rowHeight}
/>
) : null}
</>
)}
</tbody>
</table>
)}
</div>
{hasRowMenu && activeRow && !stacked ? (
<RowHandle
rowEl={activeRowEl}
id={activeRow.id}
index={activeRow.index}
onInsertRow={onInsertRow}
onDeleteRow={onDeleteRow}
onEnter={() => activateRow(activeRow.id, activeRow.index)}
onLeave={deactivateRow}
/>
) : null}
</div>
);
}
"use client";
export function EditableCell({
value,
label,
onChange,
}: {
value: string;
label: string;
onChange: (next: string) => void;
}) {
return (
<input
value={value}
aria-label={label}
size={1}
onChange={(e) => onChange(e.target.value)}
placeholder="Empty"
className="-mx-2 w-full min-w-0 appearance-none rounded-md border-0 bg-transparent px-2 py-1 text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:bg-muted focus:ring-1 focus:ring-ring"
/>
);
}
"use client";
import { ArrowDownToLine, ArrowUpToLine, MoreVertical, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { TableMenu, type TableMenuItem } from "./table-menu";
/** Row menu entries (Insert before / after, Delete) for whichever callbacks are set. */
export function rowMenuItems({
id,
index,
onInsertRow,
onDeleteRow,
}: {
id: string;
index: number;
onInsertRow?: (index: number, position: "before" | "after") => void;
onDeleteRow?: (rowId: string, index: number) => void;
}): TableMenuItem[] {
return [
...(onInsertRow
? [
{
label: "Insert before",
icon: <ArrowUpToLine />,
onSelect: () => onInsertRow(index, "before"),
},
{
label: "Insert after",
icon: <ArrowDownToLine />,
onSelect: () => onInsertRow(index, "after"),
},
]
: []),
...(onDeleteRow
? [
{
label: "Delete row",
icon: <Trash2 />,
destructive: true,
onSelect: () => onDeleteRow(id, index),
},
]
: []),
];
}
/** The row handle, portaled so it can sit on the row's left border without the
* scroll container clipping it. Straddles the border to bridge hover. */
export function RowHandle({
rowEl,
id,
index,
onInsertRow,
onDeleteRow,
onEnter,
onLeave,
}: {
rowEl: HTMLTableRowElement | null;
id: string;
index: number;
onInsertRow?: (index: number, position: "before" | "after") => void;
onDeleteRow?: (rowId: string, index: number) => void;
onEnter: () => void;
onLeave: () => void;
}) {
useEffect(() => {
window.addEventListener("scroll", onLeave, true);
return () => window.removeEventListener("scroll", onLeave, true);
}, [onLeave]);
if (!rowEl || typeof document === "undefined") return null;
const rect = rowEl.getBoundingClientRect();
return createPortal(
<div
style={{
position: "fixed",
top: rect.top + rect.height / 2,
left: rect.left,
transform: "translate(-50%, -50%)",
zIndex: 40,
}}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
>
<TableMenu
ariaLabel={`Row ${index + 1} options`}
triggerClassName="flex h-6 w-2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
trigger={<MoreVertical className="h-3 w-3" />}
items={rowMenuItems({ id, index, onInsertRow, onDeleteRow })}
/>
</div>,
document.body,
);
}
"use client";
import { cn } from "@/lib/utils";
import type { TableColumn } from "./types";
import { alignText } from "./utils";
/** Placeholder cards for the stacked layout; hidden from assistive tech. */
export function SkeletonCards({
count,
fields,
}: {
count: number;
fields: number;
}) {
return (
<div aria-hidden>
{Array.from({ length: count }, (_, r) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder cards
<div key={r} className="border-border/60 border-b px-3 py-3">
<div className="h-3.5 w-1/2 animate-pulse rounded-full bg-muted" />
<div className="mt-3 grid gap-2.5">
{Array.from({ length: fields }, (_, f) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder lines
key={f}
className="flex items-center justify-between gap-3"
>
<div className="h-2.5 w-16 animate-pulse rounded-full bg-muted" />
<div className="h-2.5 w-1/3 animate-pulse rounded-full bg-muted" />
</div>
))}
</div>
</div>
))}
</div>
);
}
export function SkeletonRows<T>({
count,
columns,
selectable,
rowHeight,
}: {
count: number;
columns: TableColumn<T>[];
selectable: boolean;
rowHeight: number;
}) {
return (
<>
{Array.from({ length: count }, (_, r) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows
<tr key={r} style={{ height: rowHeight }} className="border-border/60 border-b">
{selectable ? <td /> : null}
{columns.map((column) => (
<td key={column.key} className={cn("px-4", alignText(column.align))}>
<div
className={cn(
"h-3 animate-pulse rounded-full bg-muted",
column.align === "right" ? "ml-auto w-10" : "w-2/3",
)}
/>
</td>
))}
<td aria-hidden />
</tr>
))}
</>
);
}
"use client";
import {
type ReactNode,
type RefObject,
useId,
useLayoutEffect,
useState,
} from "react";
import { cn } from "@/lib/utils";
import type { SortState, TableColumn } from "./types";
/*
* Shared pieces of the stacked ("card list") layout both tables switch to in a
* narrow container (`mobileLayout="stack"`).
*/
/** Container width (px) below which `mobileLayout="stack"` shows cards. */
export const DEFAULT_STACK_BELOW = 560;
/**
* True once the element measures narrower than `stackBelow`. Starts false, so
* the server and the hydrating client both render the table, then switches in
* a layout effect (before the first paint after hydration) and follows the
* element's width from there. The element must not size to its content, or the
* swap could feed back into the measurement — both tables measure their
* full-width root.
*/
export function useStackedLayout(
ref: RefObject<HTMLElement | null>,
enabled: boolean,
stackBelow: number,
) {
const [stacked, setStacked] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!enabled || !el) {
setStacked(false);
return;
}
const update = (width: number) => setStacked(width < stackBelow);
update(el.getBoundingClientRect().width);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const entry = entries[entries.length - 1];
if (entry) update(entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width);
});
observer.observe(el);
return () => observer.disconnect();
}, [ref, enabled, stackBelow]);
return enabled && stacked;
}
/** Plain-text name of a column, for option labels and announcements. */
export function columnLabel<T>(column: TableColumn<T>) {
return typeof column.header === "string" ? column.header : column.key;
}
/**
* Splits visible columns into the card title (`primary` columns, or the first
* column when none opts in) and the label/value fields under it, leaving out
* `hideOnMobile` columns. A primary column is always shown.
*/
export function splitStackColumns<C extends TableColumn<never>>(columns: C[]) {
const flagged = columns.filter((c) => c.primary);
const primary = flagged.length > 0 ? flagged : columns.slice(0, 1);
const titleKeys = new Set(primary.map((c) => c.key));
const fields = columns.filter((c) => !titleKeys.has(c.key) && !c.hideOnMobile);
return { primary, fields };
}
/** Rough card height before measurement: title row + one line per field. */
export function estimateCardHeight(fieldCount: number) {
return 56 + fieldCount * 28;
}
export type StackedField = {
key: string;
label: ReactNode;
value: ReactNode;
/** The value is an input: let it take the full width instead of hugging the right edge. */
control?: boolean;
};
/**
* A card's label/value pairs as a description list, so each value is read
* with its column header.
*/
export function StackedFields({
fields,
className,
}: {
fields: StackedField[];
className?: string;
}) {
if (fields.length === 0) return null;
return (
<dl className={cn("grid gap-1.5", className)}>
{fields.map((field) => (
<div key={field.key} className="flex min-w-0 items-center gap-3">
<dt className="w-2/5 max-w-40 shrink-0 truncate text-muted-foreground text-xs">
{field.label}
</dt>
<dd
className={cn(
"min-w-0 flex-1 text-foreground",
field.control ? "pl-2" : "truncate text-right",
)}
>
{field.value}
</dd>
</div>
))}
</dl>
);
}
const SORT_NONE = "";
/**
* Compact sort control shown above the cards, where there are no headers to
* click: one native select of "column, direction" pairs.
*/
export function StackSortSelect<T>({
columns,
value,
onChange,
className,
}: {
columns: TableColumn<T>[];
value: SortState | null;
onChange: (next: SortState | null) => void;
className?: string;
}) {
const id = useId();
const sortable = columns.filter((c) => c.sortable);
if (sortable.length === 0) return null;
const current =
value && sortable.some((c) => c.key === value.key)
? `${value.direction}:${value.key}`
: SORT_NONE;
return (
<div className={cn("flex min-w-0 items-center gap-2", className)}>
<label htmlFor={id} className="shrink-0 text-muted-foreground text-xs">
Sort by
</label>
<select
id={id}
value={current}
onChange={(e) => {
const raw = e.target.value;
if (raw === SORT_NONE) return onChange(null);
const split = raw.indexOf(":");
onChange({
direction: raw.slice(0, split) === "desc" ? "desc" : "asc",
key: raw.slice(split + 1),
});
}}
className="h-8 min-w-0 max-w-full rounded-lg border border-border bg-background px-2 text-foreground text-sm outline-none transition-colors focus-visible:border-border-strong focus-visible:ring-2 focus-visible:ring-ring/30"
>
<option value={SORT_NONE}>None</option>
{sortable.map((column) => {
const label = columnLabel(column);
return [
<option key={`asc:${column.key}`} value={`asc:${column.key}`}>
{label} (ascending)
</option>,
<option key={`desc:${column.key}`} value={`desc:${column.key}`}>
{label} (descending)
</option>,
];
})}
</select>
</div>
);
}
"use client";
import {
ArrowLeftToLine,
ArrowRightToLine,
ChevronUp,
GripVertical,
MoreHorizontal,
Trash2,
} from "lucide-react";
import { motion } from "motion/react";
import { type PointerEvent as ReactPointerEvent, useEffect } from "react";
import { createPortal } from "react-dom";
import { Checkbox } from "@/components/motion/checkbox";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
import { TableMenu } from "./table-menu";
import type {
HeaderCellRefs,
InsertPosition,
SortState,
TableColumn,
} from "./types";
import { alignFlex, alignText, COLUMN_ACTIVE_SHADOW } from "./utils";
export interface TableHeaderProps<T> {
columns: TableColumn<T>[];
rowHeight: number;
reduce: boolean;
thRefs: HeaderCellRefs;
selectable: boolean;
allSelected: boolean;
someSelected: boolean;
onToggleAll: () => void;
sort: SortState | null;
onToggleSort: (key: string) => void;
resizable: boolean;
onResizeStart: (key: string, e: ReactPointerEvent) => void;
onResizeMove: (e: ReactPointerEvent) => void;
onResizeEnd: (e: ReactPointerEvent) => void;
reorderable: boolean;
dragKey: string | null;
dropIndex: number | null;
onReorderStart: (key: string, e: ReactPointerEvent) => void;
onReorderMove: (e: ReactPointerEvent) => void;
onReorderEnd: (e: ReactPointerEvent) => void;
onInsertColumn?: (index: number, position: InsertPosition) => void;
onDeleteColumn?: (columnKey: string, index: number) => void;
onColumnRename?: (columnKey: string, value: string) => void;
activeColumn: string | null;
onColumnActivate?: (key: string) => void;
onColumnDeactivate?: () => void;
}
/** Column insert / delete menu items shared by the header cell and the portal handle. */
function columnMenuItems<T>(
column: TableColumn<T>,
index: number,
onInsertColumn?: (index: number, position: InsertPosition) => void,
onDeleteColumn?: (columnKey: string, index: number) => void,
) {
return [
...(onInsertColumn
? [
{
label: "Insert before",
icon: <ArrowLeftToLine />,
onSelect: () => onInsertColumn(index, "before"),
},
{
label: "Insert after",
icon: <ArrowRightToLine />,
onSelect: () => onInsertColumn(index, "after"),
},
]
: []),
...(onDeleteColumn
? [
{
label: "Delete column",
icon: <Trash2 />,
destructive: true,
onSelect: () => onDeleteColumn(column.key, index),
},
]
: []),
];
}
/** The ellipse handle, portaled so it can sit on the column's top border without
* the scroll container clipping it. Straddles the border to bridge hover. */
function ColumnHandle<T>({
column,
index,
thRefs,
onInsertColumn,
onDeleteColumn,
onEnter,
onLeave,
}: {
column: TableColumn<T>;
index: number;
thRefs: HeaderCellRefs;
onInsertColumn?: (index: number, position: InsertPosition) => void;
onDeleteColumn?: (columnKey: string, index: number) => void;
onEnter: () => void;
onLeave: () => void;
}) {
useEffect(() => {
window.addEventListener("scroll", onLeave, true);
return () => window.removeEventListener("scroll", onLeave, true);
}, [onLeave]);
const el = thRefs.current[column.key];
if (!el || typeof document === "undefined") return null;
const rect = el.getBoundingClientRect();
return createPortal(
<div
style={{
position: "fixed",
top: rect.top,
left: rect.left + rect.width / 2,
transform: "translate(-50%, -50%)",
zIndex: 40,
}}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
>
<TableMenu
ariaLabel={`${column.key} column options`}
triggerClassName="flex h-2 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
trigger={<MoreHorizontal className="h-3 w-3" />}
items={columnMenuItems(column, index, onInsertColumn, onDeleteColumn)}
/>
</div>,
document.body,
);
}
export function TableHeader<T>({
columns,
rowHeight,
reduce,
thRefs,
selectable,
allSelected,
someSelected,
onToggleAll,
sort,
onToggleSort,
resizable,
onResizeStart,
onResizeMove,
onResizeEnd,
reorderable,
dragKey,
dropIndex,
onReorderStart,
onReorderMove,
onReorderEnd,
onInsertColumn,
onDeleteColumn,
onColumnRename,
activeColumn,
onColumnActivate,
onColumnDeactivate,
}: TableHeaderProps<T>) {
const hasColumnMenu = !!(onInsertColumn || onDeleteColumn);
const activeIndex = columns.findIndex((c) => c.key === activeColumn);
return (
<>
{hasColumnMenu && activeColumn && activeIndex >= 0 ? (
<ColumnHandle
column={columns[activeIndex]}
index={activeIndex}
thRefs={thRefs}
onInsertColumn={onInsertColumn}
onDeleteColumn={onDeleteColumn}
onEnter={() => onColumnActivate?.(activeColumn)}
onLeave={() => onColumnDeactivate?.()}
/>
) : null}
<thead>
<tr style={{ height: rowHeight }}>
{selectable ? (
<th className="sticky top-0 z-10 border-border border-b bg-muted">
<div className="flex items-center justify-center">
<Checkbox
checked={allSelected}
indeterminate={!allSelected && someSelected}
onCheckedChange={onToggleAll}
aria-label="Select all rows"
className="size-6"
/>
</div>
</th>
) : null}
{columns.map((column, index) => {
const active = sort?.key === column.key;
const isDragging = dragKey === column.key;
const isActive = activeColumn === column.key;
return (
<th
key={column.key}
ref={(el) => {
thRefs.current[column.key] = el;
}}
onPointerEnter={() => onColumnActivate?.(column.key)}
onPointerLeave={() => onColumnDeactivate?.()}
style={isActive ? { boxShadow: COLUMN_ACTIVE_SHADOW } : undefined}
aria-sort={
active
? sort?.direction === "asc"
? "ascending"
: "descending"
: undefined
}
data-drop={dragKey ? dropIndex === index : undefined}
data-dropend={
dragKey
? dropIndex === columns.length && index === columns.length - 1
: undefined
}
className={cn(
"group sticky top-0 z-10 border-border border-b bg-muted p-0 font-medium text-muted-foreground",
"data-[drop=true]:before:absolute data-[drop=true]:before:inset-y-0 data-[drop=true]:before:left-0 data-[drop=true]:before:w-0.5 data-[drop=true]:before:bg-primary",
"data-[dropend=true]:after:absolute data-[dropend=true]:after:inset-y-0 data-[dropend=true]:after:right-0 data-[dropend=true]:after:w-0.5 data-[dropend=true]:after:bg-primary",
)}
>
<motion.div
className={cn(
"flex h-full items-center",
alignFlex(column.align),
)}
style={{ height: rowHeight }}
animate={
reduce
? { opacity: isDragging ? 0.5 : 1 }
: {
scale: isDragging ? 1.04 : 1,
opacity: isDragging ? 0.5 : 1,
}
}
transition={SPRING_PRESS}
>
{reorderable ? (
<button
type="button"
aria-label={`Reorder ${column.key} column`}
onPointerDown={(e) => onReorderStart(column.key, e)}
onPointerMove={onReorderMove}
onPointerUp={onReorderEnd}
className={cn(
"flex h-full w-6 cursor-grab touch-none items-center justify-center text-muted-foreground/60 transition-colors hover:text-foreground active:cursor-grabbing",
// The grip owns the whole press, so iOS must not open its
// callout out of the same one and cancel the drag.
TOUCH_GESTURE_CLASS,
)}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
) : null}
{column.sortable ? (
<button
type="button"
onClick={() => onToggleSort(column.key)}
className={cn(
"flex h-full min-w-0 flex-1 select-none items-center gap-1 px-4 transition-colors hover:text-foreground",
alignFlex(column.align),
active && "text-foreground",
)}
>
<span className="truncate">{column.header}</span>
<motion.span
aria-hidden
className="inline-flex shrink-0"
animate={{
rotate: active && sort?.direction === "desc" ? 180 : 0,
opacity: active ? 1 : 0.35,
}}
transition={
reduce
? { duration: 0 }
: { duration: 0.18, ease: EASE_OUT }
}
>
<ChevronUp className="h-3.5 w-3.5" />
</motion.span>
</button>
) : onColumnRename ? (
<input
value={
typeof column.header === "string" ? column.header : ""
}
aria-label={`Rename ${column.key} column`}
size={1}
onChange={(e) =>
onColumnRename(column.key, e.target.value)
}
className={cn(
"min-w-0 flex-1 truncate appearance-none rounded-md border-0 bg-transparent px-4 font-medium text-muted-foreground outline-none transition-colors focus:bg-muted focus:text-foreground",
alignText(column.align),
)}
/>
) : (
<span
className={cn(
"min-w-0 flex-1 truncate px-4",
alignText(column.align),
)}
>
{column.header}
</span>
)}
</motion.div>
{resizable ? (
<button
type="button"
aria-label={`Resize ${column.key} column`}
tabIndex={-1}
onPointerDown={(e) => onResizeStart(column.key, e)}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
className={cn(
"absolute top-0 right-0 h-full w-1.5 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-primary/40",
// Same for the resize drag: the handle drives it end to end.
TOUCH_GESTURE_CLASS,
)}
/>
) : null}
</th>
);
})}
<th
aria-hidden
className="sticky top-0 z-10 border-border border-b bg-muted"
/>
</tr>
</thead>
</>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type TableMenuItem = {
label: string;
icon?: ReactNode;
onSelect: () => void;
destructive?: boolean;
};
const MENU_WIDTH = 188;
export function TableMenu({
items,
ariaLabel,
trigger,
triggerClassName,
}: {
items: TableMenuItem[];
ariaLabel: string;
trigger: ReactNode;
triggerClassName?: string;
}) {
const reduce = useReducedMotion();
const triggerRef = useRef<HTMLButtonElement>(null);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(
null,
);
const open = coords !== null;
useEffect(() => {
if (!open) return;
const close = () => setCoords(null);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
// Close on any scroll (the trigger moves) or resize; fixed coords go stale.
window.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
window.removeEventListener("keydown", onKey);
};
}, [open]);
const toggle = () => {
if (open) {
setCoords(null);
return;
}
const el = triggerRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
setCoords({
top: r.bottom + 4,
left: Math.max(8, r.right - MENU_WIDTH),
});
};
return (
<>
<button
ref={triggerRef}
type="button"
aria-label={ariaLabel}
aria-haspopup="menu"
aria-expanded={open}
onClick={(e) => {
e.stopPropagation();
toggle();
}}
className={triggerClassName}
>
{trigger}
</button>
{open && typeof document !== "undefined"
? createPortal(
<>
<div
className="fixed inset-0 z-40"
onPointerDown={() => setCoords(null)}
/>
<motion.div
role="menu"
className="fixed z-50 overflow-hidden rounded-xl border border-border bg-background p-1 shadow-xl"
style={{ top: coords.top, left: coords.left, width: MENU_WIDTH }}
initial={
reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -4 }
}
animate={
reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }
}
transition={reduce ? { duration: 0 } : SPRING_PANEL}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
onClick={() => {
setCoords(null);
item.onSelect();
}}
className={cn(
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm transition-colors [&_svg]:h-4 [&_svg]:w-4",
item.destructive
? "text-rose-500 hover:bg-rose-500/10"
: "text-foreground hover:bg-muted",
)}
>
{item.icon}
{item.label}
</button>
))}
</motion.div>
</>,
document.body,
)
: null}
</>
);
}
import type { ReactNode } from "react";
export type SortDirection = "asc" | "desc";
export type SortState = {
key: string;
direction: SortDirection;
};
export type TableColumn<T> = {
/** Stable key; also the default object property read for the cell + sort value. */
key: string;
/** Header content. */
header: ReactNode;
/** Allow clicking the header to sort by this column. */
sortable?: boolean;
/** Cell text alignment. */
align?: "left" | "center" | "right";
/** Column width as a CSS length, e.g. "160px" or "20%". Omit to share remaining space equally. */
width?: string;
/** Custom cell renderer. Falls back to `row[key]`. */
cell?: (row: T) => ReactNode;
/** Render an inline text input for this column's cells (ignored when `cell` is set). */
editable?: boolean;
/** Value used for sorting. Falls back to `row[key]`. */
sortValue?: (row: T) => string | number;
/** Stacked layout (`mobileLayout="stack"`): show this cell in the card title.
* Defaults to the first column when no column sets it. */
primary?: boolean;
/** Stacked layout: leave this column out of the card's label/value pairs. */
hideOnMobile?: boolean;
};
/**
* Narrow-container layout. `"scroll"` keeps the table and scrolls it sideways;
* `"stack"` renders one card per row while the table's own width is below
* `stackBelow`.
*/
export type MobileLayout = "scroll" | "stack";
export type InsertPosition = "before" | "after";
export interface TableProps<T> {
data: T[];
columns: TableColumn<T>[];
/** Stable id per row, required for correct selection across sorts. Defaults to row index. */
getRowId?: (row: T, index: number) => string;
/** Render a leading checkbox column with select-all in the header. */
selectable?: boolean;
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
onSelectionChange?: (ids: string[]) => void;
sort?: SortState | null;
defaultSort?: SortState | null;
onSortChange?: (sort: SortState | null) => void;
/** Allow dragging the right edge of a header to resize that column. */
resizable?: boolean;
/** Minimum column width in px when resizing. */
minColumnWidth?: number;
onColumnResize?: (key: string, width: number) => void;
/** Allow dragging a header grip to reorder columns. */
reorderable?: boolean;
onColumnOrderChange?: (keys: string[]) => void;
/** Called when an `editable` cell changes. */
onCellEdit?: (rowId: string, columnKey: string, value: string) => void;
/** When set, non-sortable headers become editable inputs for the column name. */
onColumnRename?: (columnKey: string, value: string) => void;
/** Enables the row menu (Insert before / after). Receives the target index. */
onInsertRow?: (index: number, position: InsertPosition) => void;
/** Enables Delete in the row menu. */
onDeleteRow?: (rowId: string, index: number) => void;
/** Enables the column menu (Insert before / after). Receives the target column index. */
onInsertColumn?: (index: number, position: InsertPosition) => void;
/** Enables Delete in the column menu. */
onDeleteColumn?: (columnKey: string, index: number) => void;
/** Fixed row height in px — required for virtualization. */
rowHeight?: number;
/** Scroll viewport height in px. */
height?: number;
/** Rows rendered above/below the viewport. */
overscan?: number;
/** Fires when the viewport scrolls near the bottom — load the next page. */
onEndReached?: () => void;
/** Currently fetching — shows skeleton rows and pauses `onEndReached`. */
loading?: boolean;
/** How many skeleton rows to show while loading more (default 3). */
skeletonRows?: number;
emptyState?: ReactNode;
/**
* `"scroll"` (default) keeps today's sideways-scrolling table. `"stack"`
* swaps to a virtualized card list (title from `primary` columns, the rest as
* header: value pairs, editable cells stay inputs) whenever the table's own
* width is below `stackBelow`. The server always renders the table.
*/
mobileLayout?: MobileLayout;
/** Width in px under which `mobileLayout="stack"` shows cards (default 560). */
stackBelow?: number;
className?: string;
}
/** A data row paired with its stable id. */
export type TableRow<T> = { row: T; id: string };
/** Ref map from column key to its header cell, shared across the resize/reorder hooks. */
export type HeaderCellRefs = {
current: Record<string, HTMLTableCellElement | null>;
};
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useMemo,
useState,
} from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
import type { HeaderCellRefs, TableColumn } from "./types";
export function useColumnReorder<T>({
columns,
thRefs,
order: orderProp,
defaultOrder,
onColumnOrderChange,
}: {
columns: TableColumn<T>[];
thRefs: HeaderCellRefs;
/** Controlled column key order. Omit to keep the order internally. */
order?: string[];
/** Initial order for uncontrolled use; defaults to the `columns` order. */
defaultOrder?: string[];
onColumnOrderChange?: (keys: string[]) => void;
}) {
const [internalOrder, setOrder] = useState<string[]>(
() => defaultOrder ?? columns.map((c) => c.key),
);
const order = orderProp ?? internalOrder;
const [dragKey, setDragKey] = useState<string | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
// Apply the current order, tolerating columns added/removed at runtime. New
// columns are placed at their position in `columns` (after their left
// neighbor), not appended — so an inserted column lands where it was added.
const orderedColumns = useMemo(() => {
const byKey = new Map(columns.map((c) => [c.key, c]));
const resultKeys = order.filter((k) => byKey.has(k));
const present = new Set(resultKeys);
columns.forEach((column, i) => {
if (present.has(column.key)) return;
let at = resultKeys.length;
if (i === 0) {
at = 0;
} else {
const idx = resultKeys.indexOf(columns[i - 1].key);
at = idx === -1 ? i : idx + 1;
}
resultKeys.splice(at, 0, column.key);
present.add(column.key);
});
return resultKeys
.map((k) => byKey.get(k))
.filter((c): c is TableColumn<T> => c !== undefined);
}, [order, columns]);
const dropIndexFor = useCallback(
(clientX: number) => {
for (let i = 0; i < orderedColumns.length; i++) {
const rect =
thRefs.current[orderedColumns[i].key]?.getBoundingClientRect();
if (rect && clientX < rect.left + rect.width / 2) return i;
}
return orderedColumns.length;
},
[orderedColumns, thRefs],
);
/** Commit a full key order — the drop of a drag, or a keyboard move. */
const commitOrder = useCallback(
(keys: string[]) => {
if (orderProp === undefined) setOrder(keys);
onColumnOrderChange?.(keys);
},
[orderProp, onColumnOrderChange],
);
const startReorder = useCallback((key: string, e: ReactPointerEvent) => {
e.preventDefault();
e.stopPropagation();
setDragKey(key);
capturePointer(e.currentTarget, e.pointerId);
}, []);
const moveReorder = useCallback(
(e: ReactPointerEvent) => {
if (!dragKey) return;
setDropIndex(dropIndexFor(e.clientX));
},
[dragKey, dropIndexFor],
);
const endReorder = useCallback(
(e: ReactPointerEvent) => {
releasePointer(e.currentTarget, e.pointerId);
if (dragKey && dropIndex !== null) {
const keys = orderedColumns.map((c) => c.key);
const from = keys.indexOf(dragKey);
if (from !== -1) {
const without = keys.filter((_, i) => i !== from);
let to = dropIndex;
if (from < to) to--;
without.splice(to, 0, dragKey);
commitOrder(without);
}
}
setDragKey(null);
setDropIndex(null);
},
[dragKey, dropIndex, orderedColumns, commitOrder],
);
return {
orderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
commitOrder,
};
}
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useRef,
useState,
} from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
import type { HeaderCellRefs, TableColumn } from "./types";
export function useColumnResize<T>({
orderedColumns,
thRefs,
minColumnWidth,
onColumnResize,
}: {
orderedColumns: TableColumn<T>[];
thRefs: HeaderCellRefs;
minColumnWidth: number;
onColumnResize?: (key: string, width: number) => void;
}) {
const resizeRef = useRef<{
key: string;
startX: number;
startWidth: number;
} | null>(null);
const [widths, setWidths] = useState<Record<string, number>>({});
const startResize = useCallback(
(key: string, e: ReactPointerEvent) => {
e.preventDefault();
e.stopPropagation();
// Freeze every column to its current pixel width so resizing one only
// moves the trailing spacer, never the other columns.
const snapshot = { ...widths };
for (const column of orderedColumns) {
if (snapshot[column.key] == null) {
const measured = thRefs.current[column.key]?.getBoundingClientRect()
.width;
snapshot[column.key] = measured
? Math.round(measured)
: minColumnWidth;
}
}
resizeRef.current = {
key,
startX: e.clientX,
startWidth: snapshot[key],
};
setWidths(snapshot);
capturePointer(e.currentTarget, e.pointerId);
},
[minColumnWidth, orderedColumns, thRefs, widths],
);
const moveResize = useCallback(
(e: ReactPointerEvent) => {
const state = resizeRef.current;
if (!state) return;
const width = Math.max(
minColumnWidth,
state.startWidth + (e.clientX - state.startX),
);
setWidths((prev) => ({ ...prev, [state.key]: width }));
},
[minColumnWidth],
);
const endResize = useCallback(
(e: ReactPointerEvent) => {
const state = resizeRef.current;
resizeRef.current = null;
releasePointer(e.currentTarget, e.pointerId);
if (state) {
onColumnResize?.(state.key, widths[state.key] ?? state.startWidth);
}
},
[onColumnResize, widths],
);
return { widths, startResize, moveResize, endResize };
}
import { useCallback, useMemo, useState } from "react";
import type { SortState, TableColumn, TableRow } from "./types";
import { readSortValue } from "./utils";
export function useColumnSort<T>({
rows,
columns,
sort: sortProp,
defaultSort = null,
onSortChange,
}: {
rows: TableRow<T>[];
columns: TableColumn<T>[];
sort?: SortState | null;
defaultSort?: SortState | null;
onSortChange?: (sort: SortState | null) => void;
}) {
const [internalSort, setInternalSort] = useState<SortState | null>(
defaultSort,
);
const sort = sortProp !== undefined ? sortProp : internalSort;
const commit = useCallback(
(next: SortState | null) => {
if (sortProp === undefined) setInternalSort(next);
onSortChange?.(next);
},
[sortProp, onSortChange],
);
const toggleSort = useCallback(
(key: string) => {
if (!sort || sort.key !== key) {
commit({ key, direction: "asc" });
} else if (sort.direction === "asc") {
commit({ key, direction: "desc" });
} else {
commit(null);
}
},
[sort, commit],
);
const sortedRows = useMemo(() => {
if (!sort) return rows;
const column = columns.find((c) => c.key === sort.key);
if (!column) return rows;
const copy = [...rows];
copy.sort((a, b) => {
const av = readSortValue(a.row, column);
const bv = readSortValue(b.row, column);
let cmp: number;
if (typeof av === "number" && typeof bv === "number") {
cmp = av - bv;
} else {
cmp = String(av).localeCompare(String(bv));
}
return sort.direction === "asc" ? cmp : -cmp;
});
return copy;
}, [rows, sort, columns]);
return { sort, sortedRows, toggleSort, setSort: commit };
}
import { useCallback, useMemo, useState } from "react";
import type { TableRow } from "./types";
export function useRowSelection<T>({
sortedRows,
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
}: {
sortedRows: TableRow<T>[];
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
onSelectionChange?: (ids: string[]) => void;
}) {
const [internalSelected, setInternalSelected] = useState<Set<string>>(
() => new Set(defaultSelectedRowIds),
);
const selected = useMemo(
() =>
selectedRowIds !== undefined
? new Set(selectedRowIds)
: internalSelected,
[selectedRowIds, internalSelected],
);
const commit = useCallback(
(next: Set<string>) => {
if (selectedRowIds === undefined) setInternalSelected(next);
onSelectionChange?.([...next]);
},
[selectedRowIds, onSelectionChange],
);
const allSelected =
sortedRows.length > 0 && sortedRows.every((r) => selected.has(r.id));
const someSelected = sortedRows.some((r) => selected.has(r.id));
const toggleAll = useCallback(() => {
const next = new Set(selected);
if (allSelected) {
for (const r of sortedRows) next.delete(r.id);
} else {
for (const r of sortedRows) next.add(r.id);
}
commit(next);
}, [allSelected, sortedRows, selected, commit]);
const toggleRow = useCallback(
(id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
commit(next);
},
[selected, commit],
);
return { selected, allSelected, someSelected, toggleAll, toggleRow };
}
import type { ReactNode } from "react";
import type { TableColumn } from "./types";
export const CHECKBOX_PX = 48;
export const CHECKBOX_WIDTH = `${CHECKBOX_PX}px`;
/** Highlights the top edge of the active column's header cell. */
export const COLUMN_ACTIVE_SHADOW = "inset 0 1px 0 var(--color-primary)";
export function alignFlex(align: TableColumn<unknown>["align"]) {
if (align === "right") return "justify-end";
if (align === "center") return "justify-center";
return "justify-start";
}
export function alignText(align: TableColumn<unknown>["align"]) {
if (align === "right") return "text-right";
if (align === "center") return "text-center";
return "text-left";
}
export function readCell<T>(row: T, column: TableColumn<T>): ReactNode {
if (column.cell) return column.cell(row);
return (row as Record<string, ReactNode>)[column.key];
}
export function readSortValue<T>(
row: T,
column: TableColumn<T>,
): string | number {
if (column.sortValue) return column.sortValue(row);
return (row as Record<string, string | number>)[column.key];
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId } from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
const CHECK_PATH = "M5 13l4 4L19 7";
const INDETERMINATE_PATH = "M6 12h12";
export interface CheckboxProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
indeterminate?: boolean;
label?: string;
className?: string;
id?: string;
"aria-label"?: string;
/** Associates an external message (e.g. a form error) with the control. */
"aria-describedby"?: string;
}
export function Checkbox({
checked,
onCheckedChange,
disabled,
indeterminate,
label,
className,
id: idProp,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
}: CheckboxProps) {
const autoId = useId();
const id = idProp ?? autoId;
const reduce = useReducedMotion();
const showMark = checked || indeterminate;
const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;
return (
<label
htmlFor={id}
className={cn(
"inline-flex items-center gap-3",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
)}
>
<motion.button
id={id}
type="button"
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={
checked ? "checked" : indeterminate ? "indeterminate" : "unchecked"
}
className={cn(
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200",
TOUCH_TARGET_CLASS,
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
showMark
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50 bg-background hover:border-muted-foreground",
)}
>
<AnimatePresence initial={false}>
{showMark ? (
<motion.svg
key={indeterminate ? "indeterminate" : "checked"}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
transition={
reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }
}
aria-hidden
>
<title>{indeterminate ? "Partially selected" : "Selected"}</title>
<motion.path
d={path}
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce
? { duration: 0 }
: {
duration: indeterminate ? 0.2 : 0.3,
ease: EASE_OUT,
delay: 0.04,
}
}
/>
</motion.svg>
) : null}
</AnimatePresence>
</motion.button>
{label ? (
<span className={cn("select-none text-sm text-foreground", disabled && "opacity-60")}>
{label}
</span>
) : null}
</label>
);
}
API Reference
dataT[]—columnsTableColumn<T>[]—getRowId?((row: T, index: number) => string)Stable id per row, required for correct selection across sorts. Defaults to row index.
—selectable?booleanRender a leading checkbox column with select-all in the header.
falseselectedRowIds?string[]—defaultSelectedRowIds?string[]—onSelectionChange?((ids: string[]) => void)—sort?SortState | null—defaultSort?SortState | nullnullonSortChange?((sort: SortState | null) => void)—resizable?booleanAllow dragging the right edge of a header to resize that column.
falseminColumnWidth?numberMinimum column width in px when resizing.
64onColumnResize?((key: string, width: number) => void)—reorderable?booleanAllow dragging a header grip to reorder columns.
falseonColumnOrderChange?((keys: string[]) => void)—onCellEdit?((rowId: string, columnKey: string, value: string) => void)Called when an `editable` cell changes.
—onColumnRename?((columnKey: string, value: string) => void)When set, non-sortable headers become editable inputs for the column name.
—onInsertRow?((index: number, position: InsertPosition) => void)Enables the row menu (Insert before / after). Receives the target index.
—onDeleteRow?((rowId: string, index: number) => void)Enables Delete in the row menu.
—onInsertColumn?((index: number, position: InsertPosition) => void)Enables the column menu (Insert before / after). Receives the target column index.
—onDeleteColumn?((columnKey: string, index: number) => void)Enables Delete in the column menu.
—rowHeight?numberFixed row height in px — required for virtualization.
48height?numberScroll viewport height in px.
440overscan?numberRows rendered above/below the viewport.
10onEndReached?(() => void)Fires when the viewport scrolls near the bottom — load the next page.
—loading?booleanCurrently fetching — shows skeleton rows and pauses `onEndReached`.
falseskeletonRows?numberHow many skeleton rows to show while loading more (default 3).
3emptyState?ReactNodeNo datamobileLayout?"scroll" | "stack"`"scroll"` (default) keeps today's sideways-scrolling table. `"stack"` swaps to a virtualized card list (title from `primary` columns, the rest as header: value pairs, editable cells stay inputs) whenever the table's own width is below `stackBelow`. The server always renders the table.
scrollstackBelow?numberWidth in px under which `mobileLayout="stack"` shows cards (default 560).
560className?string—Advanced Data Table
data-table.tsxThe paginated DataTable: Shift+click to sort by several columns, per-column text or select filters plus a global search, a column visibility picker, column reordering, rows that expand into detail panels and range selection with a bulk-action bar.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/table
import {
ArrowLeft,
ArrowRight,
ArrowUp,
ChevronDown,
ChevronRight,
Columns3,
EyeOff,
GripVertical,
ListFilter,
MoreHorizontal,
Search,
X,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
Fragment,
type ReactNode,
type RefObject,
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { Checkbox } from "@/components/motion/checkbox";
import { Pagination } from "@/components/motion/pagination";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL, SPRING_SWAP } from "@/lib/ease";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
import {
DEFAULT_STACK_BELOW,
StackedFields,
StackSortSelect,
splitStackColumns,
useStackedLayout,
} from "./stacked";
import type {
HeaderCellRefs,
MobileLayout,
SortState,
TableColumn,
} from "./types";
import { useColumnReorder } from "./use-column-reorder";
import { alignFlex, alignText, readCell, readSortValue } from "./utils";
/**
* DataTable is the paginated sibling of the virtualized `Table`. The virtual
* table positions a recycled window of fixed-height rows, which cannot carry
* layout-animated reorders or variable-height expanded rows; this one renders
* a page of real rows so both can move. It reuses the table's column type,
* cell readers, reorder hook, `Checkbox` and `Pagination`.
*/
export type { MobileLayout, SortDirection, SortState } from "./types";
/** `string` filters by "contains" (text); `string[]` keeps rows whose value is one of the options (select). */
export type DataTableFilterValue = string | string[];
export type DataTableFilters = Record<string, DataTableFilterValue>;
export type DataTableColumn<T> = TableColumn<T> & {
/** Adds a filter to the column menu: a "contains" text field or a multi-select of options. */
filter?: "text" | "select";
/** Options for a `select` filter. Defaults to the distinct values in `data`. */
filterOptions?: string[];
/** Text used by filters and global search. Falls back to the sort value / `row[key]`. */
filterValue?: (row: T) => string;
/** Allow hiding the column from the column menu and the Columns picker (default true). */
hideable?: boolean;
/** Include the column in the global search (default true). */
searchable?: boolean;
};
export interface DataTableProps<T> {
data: T[];
columns: DataTableColumn<T>[];
/** Stable id per row — keeps selection, expansion and the reorder animation attached to the right row. */
getRowId?: (row: T, index: number) => string;
/** Accessible name of a row, used by its checkbox and expand button. Defaults to the first column's text. */
getRowLabel?: (row: T) => string;
/** Sort stack, primary first. Click a header to sort by it alone; Shift+click adds a secondary sort. */
sort?: SortState[];
defaultSort?: SortState[];
onSortChange?: (sort: SortState[]) => void;
/** Per-column filters keyed by column key. */
filters?: DataTableFilters;
defaultFilters?: DataTableFilters;
onFiltersChange?: (filters: DataTableFilters) => void;
/** Global search across every `searchable` column. */
search?: string;
defaultSearch?: string;
onSearchChange?: (search: string) => void;
/** Render the search field in the toolbar (default true). */
showSearch?: boolean;
searchPlaceholder?: string;
/** Keys of hidden columns. */
hiddenColumns?: string[];
defaultHiddenColumns?: string[];
onHiddenColumnsChange?: (keys: string[]) => void;
/** Column key order. */
columnOrder?: string[];
defaultColumnOrder?: string[];
onColumnOrderChange?: (keys: string[]) => void;
/** Drag headers by their grip, or use Move left/right in the column menu (default true). */
reorderable?: boolean;
/** Renders a chevron column; the returned node fills a height-morphing detail panel under the row. */
renderExpanded?: (row: T) => ReactNode;
expandedRowIds?: string[];
defaultExpandedRowIds?: string[];
onExpandedChange?: (ids: string[]) => void;
/** Leading checkbox column; Shift+click selects a range. */
selectable?: boolean;
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
onSelectionChange?: (ids: string[]) => void;
/** Extra controls in the selected-count bar, e.g. bulk actions. */
selectionActions?: (ids: string[]) => ReactNode;
/** Rows per page; `0` shows every row without pagination (default 10). */
pageSize?: number;
/** 1-based page. Resets to 1 when search, filters or sort change. */
page?: number;
defaultPage?: number;
onPageChange?: (page: number) => void;
/** Extra toolbar content, rendered right of the search field. */
toolbar?: ReactNode;
emptyState?: ReactNode;
/** Narrowest a column without an explicit px width may get before the table scrolls (default 120). */
minColumnWidth?: number;
/**
* `"scroll"` (default) keeps today's sideways-scrolling table. `"stack"`
* renders each row as a card (title from `primary` columns, the rest as
* header: value pairs, with selection, expand and a sort select plus filter
* chips above the list) whenever the table's own width is below
* `stackBelow`. The server always renders the table.
*/
mobileLayout?: MobileLayout;
/** Width in px under which `mobileLayout="stack"` shows cards (default 560). */
stackBelow?: number;
className?: string;
}
type Entry<T> = { row: T; id: string };
const SELECT_COL_PX = 44;
const EXPAND_COL_PX = 40;
const PANEL_WIDTH = 232;
function useControllable<V>(
value: V | undefined,
defaultValue: V,
onChange?: (next: V) => void,
) {
const [internal, setInternal] = useState(defaultValue);
const controlled = value !== undefined;
const current = controlled ? value : internal;
const set = useCallback(
(next: V) => {
if (!controlled) setInternal(next);
onChange?.(next);
},
[controlled, onChange],
);
return [current, set] as const;
}
const EMPTY_SORT: SortState[] = [];
const EMPTY_FILTERS: DataTableFilters = {};
const EMPTY_KEYS: string[] = [];
function labelOf<T>(column: DataTableColumn<T>) {
return typeof column.header === "string" ? column.header : column.key;
}
function textOf<T>(row: T, column: DataTableColumn<T>): string {
if (column.filterValue) return column.filterValue(row);
const value = readSortValue(row, column) as unknown;
return value == null ? "" : String(value);
}
function isFilterActive(value: DataTableFilterValue | undefined) {
if (value == null) return false;
return Array.isArray(value) ? value.length > 0 : value.trim() !== "";
}
/** Next sort stack for a header click. Plain click cycles this column alone (asc → desc → off);
* Shift+click cycles it inside the stack, appending it as a secondary key when new. */
export function nextSortStack(
sorts: SortState[],
key: string,
additive: boolean,
): SortState[] {
const existing = sorts.find((s) => s.key === key);
if (!additive) {
if (!existing) return [{ key, direction: "asc" }];
// Part of a multi-sort: collapse to this column alone, flipped.
if (sorts.length > 1)
return [
{ key, direction: existing.direction === "asc" ? "desc" : "asc" },
];
return existing.direction === "asc" ? [{ key, direction: "desc" }] : [];
}
if (!existing) return [...sorts, { key, direction: "asc" }];
if (existing.direction === "asc")
return sorts.map((s) => (s.key === key ? { key, direction: "desc" } : s));
return sorts.filter((s) => s.key !== key);
}
function compareValues(a: unknown, b: unknown) {
if (typeof a === "number" && typeof b === "number") return a - b;
// Explicit locale so server and client sort identically.
return String(a ?? "").localeCompare(String(b ?? ""), "en-US", {
numeric: true,
sensitivity: "base",
});
}
/* ------------------------------------------------------------------------ */
/* Popover panel (column menu + Columns picker) */
/* ------------------------------------------------------------------------ */
type AnchorRect = { left: number; right: number; bottom: number };
function Panel({
open,
onClose,
getAnchor,
triggerRef,
label,
repositionKey,
children,
}: {
open: boolean;
onClose: () => void;
getAnchor: () => AnchorRect | null;
triggerRef: RefObject<HTMLButtonElement | null>;
label: string;
/** Re-measure the anchor when this changes (the column moved). */
repositionKey?: string | number;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const panelRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// biome-ignore lint/correctness/useExhaustiveDependencies: repositionKey is the re-measure trigger.
useLayoutEffect(() => {
if (!open) return;
const measure = () => {
const r = getAnchor();
if (!r) return;
const left = Math.min(
Math.max(8, r.right - PANEL_WIDTH),
window.innerWidth - PANEL_WIDTH - 8,
);
const top = r.bottom + 6;
setPos((prev) =>
prev && prev.top === top && prev.left === left ? prev : { top, left },
);
};
measure();
// After a column move the header glides to its new slot (a layout
// animation); follow it for a moment so the panel stays attached.
let frame = 0;
const until = performance.now() + 600;
const follow = () => {
measure();
if (performance.now() < until) frame = requestAnimationFrame(follow);
};
frame = requestAnimationFrame(follow);
return () => cancelAnimationFrame(frame);
}, [open, getAnchor, repositionKey]);
useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => {
panelRef.current
?.querySelector<HTMLElement>(
"input, button:not([disabled]):not([aria-disabled='true'])",
)
?.focus();
});
const inside = (target: EventTarget | null) =>
target instanceof Node &&
(panelRef.current?.contains(target) ||
triggerRef.current?.contains(target));
const onPointer = (e: PointerEvent) => {
if (!inside(e.target)) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
onClose();
triggerRef.current?.focus();
};
// Fixed coordinates go stale once anything outside the panel scrolls.
const onScroll = (e: Event) => {
if (!(e.target instanceof Node && panelRef.current?.contains(e.target)))
onClose();
};
document.addEventListener("pointerdown", onPointer, true);
window.addEventListener("keydown", onKey);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onClose);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener("pointerdown", onPointer, true);
window.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onClose);
};
}, [open, onClose, triggerRef]);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{open && pos ? (
<motion.div
ref={panelRef}
role="dialog"
aria-label={label}
onBlur={(e) => {
const next = e.relatedTarget;
if (
next instanceof Node &&
!panelRef.current?.contains(next) &&
!triggerRef.current?.contains(next)
)
onClose();
}}
className="fixed z-50 overflow-hidden rounded-xl border border-border bg-popover p-1 text-popover-foreground text-sm shadow-xl"
style={{ top: pos.top, left: pos.left, width: PANEL_WIDTH }}
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -4 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
exit={
reduce
? { opacity: 0, transition: { duration: 0.1 } }
: {
opacity: 0,
scale: 0.98,
y: -2,
transition: { duration: 0.12, ease: EASE_OUT },
}
}
transition={reduce ? { duration: 0.12 } : SPRING_PANEL}
>
{children}
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}
const PANEL_ITEM =
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors hover:bg-muted focus-visible:bg-muted disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground";
const PANEL_HEADING =
"px-2.5 pt-2 pb-1 font-medium text-[11px] text-muted-foreground uppercase tracking-wide";
const PANEL_DIVIDER = "my-1 h-px bg-border";
/* ------------------------------------------------------------------------ */
/* DataTable */
/* ------------------------------------------------------------------------ */
export function DataTable<T>({
data,
columns,
getRowId,
getRowLabel,
sort: sortProp,
defaultSort = EMPTY_SORT,
onSortChange,
filters: filtersProp,
defaultFilters = EMPTY_FILTERS,
onFiltersChange,
search: searchProp,
defaultSearch = "",
onSearchChange,
showSearch = true,
searchPlaceholder = "Search…",
hiddenColumns: hiddenProp,
defaultHiddenColumns = EMPTY_KEYS,
onHiddenColumnsChange,
columnOrder,
defaultColumnOrder,
onColumnOrderChange,
reorderable = true,
renderExpanded,
expandedRowIds,
defaultExpandedRowIds = EMPTY_KEYS,
onExpandedChange,
selectable = false,
selectedRowIds,
defaultSelectedRowIds = EMPTY_KEYS,
onSelectionChange,
selectionActions,
pageSize = 10,
page: pageProp,
defaultPage = 1,
onPageChange,
toolbar,
emptyState = "No results",
minColumnWidth = 120,
mobileLayout = "scroll",
stackBelow = DEFAULT_STACK_BELOW,
className,
}: DataTableProps<T>) {
const reduce = useReducedMotion() ?? false;
const uid = useId();
const rootRef = useRef<HTMLDivElement>(null);
const stacked = useStackedLayout(
rootRef,
mobileLayout === "stack",
stackBelow,
);
const thRefs: HeaderCellRefs = useRef<
Record<string, HTMLTableCellElement | null>
>({});
const [sorts, setSorts] = useControllable(sortProp, defaultSort, onSortChange);
const [filters, setFilters] = useControllable(
filtersProp,
defaultFilters,
onFiltersChange,
);
const [search, setSearch] = useControllable(
searchProp,
defaultSearch,
onSearchChange,
);
const [hidden, setHidden] = useControllable(
hiddenProp,
defaultHiddenColumns,
onHiddenColumnsChange,
);
const [expanded, setExpanded] = useControllable(
expandedRowIds,
defaultExpandedRowIds,
onExpandedChange,
);
const [selectedIds, setSelectedIds] = useControllable(
selectedRowIds,
defaultSelectedRowIds,
onSelectionChange,
);
const [pageRaw, setPage] = useControllable(pageProp, defaultPage, onPageChange);
const [announcement, setAnnouncement] = useState("");
// Rows that mount after the first paint (filter, page, data) fade in; the
// first render stays static so server and client output match.
const [hydrated, setHydrated] = useState(false);
useEffect(() => setHydrated(true), []);
const {
orderedColumns: reorderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
commitOrder,
} = useColumnReorder({
columns,
thRefs,
order: columnOrder,
defaultOrder: defaultColumnOrder,
onColumnOrderChange,
});
// The hook returns the same column objects it was given, just reordered.
const orderedColumns = reorderedColumns as DataTableColumn<T>[];
const hiddenSet = useMemo(() => new Set(hidden), [hidden]);
const visibleColumns = useMemo(
() => orderedColumns.filter((c) => !hiddenSet.has(c.key)),
[orderedColumns, hiddenSet],
);
/* ---- data pipeline: rows → search → filters → sort → page ---- */
const rows = useMemo<Entry<T>[]>(
() =>
data.map((row, index) => ({
row,
id: getRowId ? getRowId(row, index) : String(index),
})),
[data, getRowId],
);
const filteredRows = useMemo(() => {
const query = search.trim().toLowerCase();
const searchable = columns.filter((c) => c.searchable !== false);
const active = columns.filter((c) => isFilterActive(filters[c.key]));
if (!query && active.length === 0) return rows;
return rows.filter(({ row }) => {
if (
query &&
!searchable.some((c) => textOf(row, c).toLowerCase().includes(query))
)
return false;
for (const column of active) {
const value = filters[column.key];
const text = textOf(row, column);
if (Array.isArray(value)) {
if (!value.includes(text)) return false;
} else if (!text.toLowerCase().includes(value.trim().toLowerCase())) {
return false;
}
}
return true;
});
}, [rows, columns, search, filters]);
const sortedRows = useMemo(() => {
const keys = sorts
.map((s) => ({ s, column: columns.find((c) => c.key === s.key) }))
.filter(
(k): k is { s: SortState; column: DataTableColumn<T> } =>
k.column !== undefined,
);
if (keys.length === 0) return filteredRows;
return [...filteredRows].sort((a, b) => {
for (const { s, column } of keys) {
const cmp = compareValues(
readSortValue(a.row, column),
readSortValue(b.row, column),
);
if (cmp !== 0) return s.direction === "asc" ? cmp : -cmp;
}
return 0;
});
}, [filteredRows, sorts, columns]);
const paged = pageSize > 0;
const pageCount = paged
? Math.max(1, Math.ceil(sortedRows.length / pageSize))
: 1;
const page = Math.min(Math.max(1, pageRaw), pageCount);
const pageRows = paged
? sortedRows.slice((page - 1) * pageSize, page * pageSize)
: sortedRows;
/* ---- sorting ---- */
const applySort = useCallback(
(next: SortState[]) => {
setSorts(next);
setPage(1);
const primary = next[0];
const column = primary && columns.find((c) => c.key === primary.key);
setAnnouncement(
column
? `Sorted by ${next
.map((s) => {
const c = columns.find((col) => col.key === s.key);
return `${c ? labelOf(c) : s.key} ${s.direction === "asc" ? "ascending" : "descending"}`;
})
.join(", then ")}`
: "Sorting cleared",
);
},
[columns, setSorts, setPage],
);
/* ---- filtering ---- */
const setColumnFilter = useCallback(
(key: string, value: DataTableFilterValue) => {
const next = { ...filters };
if (isFilterActive(value)) next[key] = value;
else delete next[key];
setFilters(next);
setPage(1);
},
[filters, setFilters, setPage],
);
const activeFilterCount = columns.filter((c) =>
isFilterActive(filters[c.key]),
).length;
const clearFilters = useCallback(() => {
setFilters({});
if (search) setSearch("");
setPage(1);
}, [setFilters, search, setSearch, setPage]);
const selectOptions = useCallback(
(column: DataTableColumn<T>) => {
if (column.filterOptions) return column.filterOptions;
const seen = new Set<string>();
for (const { row } of rows) seen.add(textOf(row, column));
return [...seen].sort((a, b) => compareValues(a, b));
},
[rows],
);
/* ---- columns: visibility + keyboard move ---- */
const toggleHidden = useCallback(
(key: string) => {
if (hiddenSet.has(key)) setHidden(hidden.filter((k) => k !== key));
else if (visibleColumns.length > 1) setHidden([...hidden, key]);
},
[hidden, hiddenSet, setHidden, visibleColumns.length],
);
const moveColumn = useCallback(
(key: string, dir: -1 | 1) => {
const visibleKeys = visibleColumns.map((c) => c.key);
const at = visibleKeys.indexOf(key);
const neighbor = visibleKeys[at + dir];
if (at === -1 || !neighbor) return;
const keys = orderedColumns.map((c) => c.key).filter((k) => k !== key);
const target = keys.indexOf(neighbor) + (dir === 1 ? 1 : 0);
keys.splice(target, 0, key);
commitOrder(keys);
const column = visibleColumns[at];
setAnnouncement(
`${labelOf(column)} column moved to position ${at + dir + 1} of ${visibleKeys.length}`,
);
},
[visibleColumns, orderedColumns, commitOrder],
);
/* ---- expansion ---- */
const expandedSet = useMemo(() => new Set(expanded), [expanded]);
const toggleExpanded = useCallback(
(id: string) => {
setExpanded(
expandedSet.has(id)
? expanded.filter((x) => x !== id)
: [...expanded, id],
);
},
[expanded, expandedSet, setExpanded],
);
/* ---- selection (Shift+click selects a range in the current order) ---- */
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const anchorRef = useRef<string | null>(null);
const shiftRef = useRef(false);
useEffect(() => {
if (!selectable) return;
let held = false;
const onKey = (e: KeyboardEvent) => {
held = e.shiftKey;
};
// Capture phase runs before the checkbox's own click handler, so the
// toggle knows whether this click (or Space/Enter press) was shifted.
const onClick = (e: MouseEvent) => {
shiftRef.current = e.shiftKey || held;
};
window.addEventListener("keydown", onKey, true);
window.addEventListener("keyup", onKey, true);
window.addEventListener("click", onClick, true);
return () => {
window.removeEventListener("keydown", onKey, true);
window.removeEventListener("keyup", onKey, true);
window.removeEventListener("click", onClick, true);
};
}, [selectable]);
const toggleRow = useCallback(
(id: string, checked: boolean) => {
const next = new Set(selectedSet);
const order = sortedRows.map((r) => r.id);
const from = anchorRef.current ? order.indexOf(anchorRef.current) : -1;
const to = order.indexOf(id);
if (shiftRef.current && from !== -1 && to !== -1) {
const [lo, hi] = from < to ? [from, to] : [to, from];
for (let i = lo; i <= hi; i++) {
if (checked) next.add(order[i]);
else next.delete(order[i]);
}
} else if (checked) next.add(id);
else next.delete(id);
shiftRef.current = false;
anchorRef.current = id;
setSelectedIds([...next]);
},
[selectedSet, sortedRows, setSelectedIds],
);
const allSelected =
sortedRows.length > 0 && sortedRows.every((r) => selectedSet.has(r.id));
const someSelected = sortedRows.some((r) => selectedSet.has(r.id));
const toggleAll = useCallback(() => {
const next = new Set(selectedSet);
for (const r of sortedRows) {
if (allSelected) next.delete(r.id);
else next.add(r.id);
}
setSelectedIds([...next]);
}, [allSelected, selectedSet, sortedRows, setSelectedIds]);
const rowLabel = useCallback(
(row: T) => {
if (getRowLabel) return getRowLabel(row);
const first = visibleColumns[0] ?? columns[0];
return first ? textOf(row, first) : "row";
},
[getRowLabel, visibleColumns, columns],
);
/* ---- layout ---- */
const leadCount = (selectable ? 1 : 0) + (renderExpanded ? 1 : 0);
const spanAll = visibleColumns.length + leadCount;
const minTableWidth = useMemo(() => {
let total =
(selectable ? SELECT_COL_PX : 0) + (renderExpanded ? EXPAND_COL_PX : 0);
for (const column of visibleColumns) {
const px =
column.width?.endsWith("px") && Number.parseFloat(column.width);
total += px && Number.isFinite(px) ? px : minColumnWidth;
}
return Math.round(total);
}, [visibleColumns, selectable, renderExpanded, minColumnWidth]);
const layoutTransition = reduce ? { duration: 0 } : SPRING_LAYOUT;
const rowLayout = reduce ? false : ("position" as const);
const firstIndex = paged ? (page - 1) * pageSize : 0;
const selectedCount = selectedIds.length;
const numberFormat = useMemo(() => new Intl.NumberFormat("en-US"), []);
// Index of the visible header showing the drop line (`dropIndex` counts
// hidden columns too, so translate it to the drawn header's key).
const dropKey =
dragKey && dropIndex !== null
? (orderedColumns[dropIndex]?.key ?? null)
: null;
const dropAtEnd =
dragKey !== null && dropIndex !== null && dropIndex >= orderedColumns.length;
const { primary: titleColumns, fields: fieldColumns } = splitStackColumns(
visibleColumns,
);
const filterChipColumns = visibleColumns.filter((c) => c.filter);
const hasSortable = visibleColumns.some((c) => c.sortable);
const clearFiltersButton =
activeFilterCount > 0 || search ? (
<button
type="button"
onClick={clearFilters}
className="rounded-md px-2 py-1 font-medium text-foreground text-xs underline-offset-4 hover:underline"
>
Clear search and filters
</button>
) : null;
const expandButton = (id: string, name: string, isOpen: boolean) => (
<button
type="button"
aria-expanded={isOpen}
aria-controls={isOpen ? `${uid}-detail-${id}` : undefined}
aria-label={`${isOpen ? "Collapse" : "Expand"} ${name}`}
onClick={() => toggleExpanded(id)}
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 pointer-coarse:size-9"
>
<motion.span
aria-hidden
className="grid place-items-center"
animate={{ rotate: isOpen ? 90 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
>
<ChevronRight className="size-4" />
</motion.span>
</button>
);
// Height is the point of this panel: it morphs open from 0 to its content height.
const detailPanel = (entry: Entry<T>, stackedCard = false) => (
<motion.div
key="detail"
id={`${uid}-detail-${entry.id}`}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: reduce ? { duration: 0 } : { duration: 0.26, ease: EASE_OUT },
opacity: { duration: reduce ? 0.12 : 0.2 },
}}
className="overflow-hidden"
>
<div className={stackedCard ? "pt-2" : undefined}>
<div
className={cn("bg-muted/30 px-4 py-3", stackedCard && "rounded-lg")}
>
{renderExpanded?.(entry.row)}
</div>
</div>
</motion.div>
);
return (
<div
ref={rootRef}
data-layout={stacked ? "stack" : "table"}
className={cn(
"w-full overflow-hidden rounded-2xl border border-border bg-card text-card-foreground text-sm",
className,
)}
>
{/* Toolbar; the selection bar slides over it while rows are selected. */}
<div className="relative flex items-center gap-2 border-border border-b p-2">
{showSearch ? (
<div className="relative min-w-0 flex-1">
<Search
aria-hidden
className="-translate-y-1/2 pointer-events-none absolute top-1/2 left-2.5 size-4 text-muted-foreground"
/>
<input
type="search"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder={searchPlaceholder}
aria-label="Search rows"
className="h-8 w-full min-w-0 rounded-lg border border-border bg-background pr-8 pl-8 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 focus-visible:border-border-strong focus-visible:ring-2 focus-visible:ring-ring/30 [&::-webkit-search-cancel-button]:hidden"
/>
<AnimatePresence initial={false}>
{search ? (
<motion.button
type="button"
aria-label="Clear search"
onClick={() => {
setSearch("");
setPage(1);
}}
initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: reduce ? 1 : 0.6 }}
transition={reduce ? { duration: 0.1 } : SPRING_SWAP}
className="-translate-y-1/2 absolute top-1/2 right-1.5 grid size-5 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-3.5" />
</motion.button>
) : null}
</AnimatePresence>
</div>
) : (
<div className="flex-1" />
)}
{activeFilterCount > 0 ? (
<button
type="button"
onClick={clearFilters}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-2.5 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground"
>
<ListFilter className="size-3.5 text-primary" />
{activeFilterCount} {activeFilterCount === 1 ? "filter" : "filters"}
<X className="size-3" />
</button>
) : null}
{toolbar}
<ColumnsPicker
columns={orderedColumns}
hiddenSet={hiddenSet}
visibleCount={visibleColumns.length}
onToggle={toggleHidden}
/>
<AnimatePresence initial={false}>
{selectable && selectedCount > 0 ? (
<motion.div
key="selection-bar"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: "-100%" }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: "0%" }}
exit={
reduce
? { opacity: 0, transition: { duration: 0.1 } }
: {
opacity: 0,
y: "-100%",
transition: { duration: 0.16, ease: EASE_OUT },
}
}
transition={reduce ? { duration: 0.12 } : SPRING_PANEL}
className="absolute inset-0 flex items-center gap-2 bg-primary px-3 text-primary-foreground"
>
<span className="inline-flex items-center gap-1 font-medium tabular-nums">
<span className="relative inline-grid overflow-hidden">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={selectedCount}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: "80%" }}
animate={{ opacity: 1, y: "0%" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: "-80%" }}
transition={reduce ? { duration: 0.1 } : SPRING_SWAP}
>
{numberFormat.format(selectedCount)}
</motion.span>
</AnimatePresence>
</span>
selected
</span>
<div className="ml-auto flex items-center gap-1">
{selectionActions?.(selectedIds)}
<button
type="button"
onClick={() => {
setSelectedIds([]);
anchorRef.current = null;
}}
className="inline-flex h-7 items-center gap-1 rounded-md px-2 font-medium text-xs transition-colors hover:bg-primary-foreground/15"
>
<X className="size-3.5" />
Clear
</button>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
{stacked ? (
<>
{selectable || hasSortable || filterChipColumns.length > 0 ? (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-border border-b bg-muted/40 px-3 py-2">
{selectable ? (
<Checkbox
checked={allSelected}
indeterminate={!allSelected && someSelected}
onCheckedChange={toggleAll}
label="Select all"
className="text-muted-foreground text-xs"
/>
) : null}
<StackSortSelect
columns={visibleColumns}
value={sorts[0] ?? null}
onChange={(next) => applySort(next ? [next] : [])}
className="ml-auto"
/>
{filterChipColumns.length > 0 ? (
<div className="flex w-full flex-wrap gap-1.5">
{filterChipColumns.map((column) => {
const index = visibleColumns.indexOf(column);
const sortIndex = sorts.findIndex(
(s) => s.key === column.key,
);
return (
<ColumnMenu
key={column.key}
chip
column={column}
thRefs={thRefs}
position={index}
sortState={sortIndex === -1 ? null : sorts[sortIndex]}
filtered={isFilterActive(filters[column.key])}
filterValue={filters[column.key]}
options={
column.filter === "select"
? selectOptions(column)
: null
}
canMoveLeft={reorderable && index > 0}
canMoveRight={
reorderable && index < visibleColumns.length - 1
}
reorderable={reorderable}
canHide={
column.hideable !== false && visibleColumns.length > 1
}
onSort={(direction) =>
applySort(
direction
? [{ key: column.key, direction }]
: sorts.filter((s) => s.key !== column.key),
)
}
onFilter={(value) => setColumnFilter(column.key, value)}
onMove={(dir) => moveColumn(column.key, dir)}
onHide={() => toggleHidden(column.key)}
/>
);
})}
</div>
) : null}
</div>
) : null}
{pageRows.length === 0 ? (
<div className="flex flex-col items-center gap-2 px-4 py-12 text-center text-muted-foreground">
{emptyState}
{clearFiltersButton}
</div>
) : (
<ul aria-label="Rows">
{pageRows.map((entry) => {
const isSelected = selectedSet.has(entry.id);
const isOpen = expandedSet.has(entry.id);
const name = rowLabel(entry.row);
return (
<motion.li
key={entry.id}
layout={rowLayout}
transition={layoutTransition}
initial={hydrated ? { opacity: 0 } : false}
animate={{ opacity: 1 }}
data-selected={isSelected || undefined}
className="border-border/60 border-b px-3 py-3 transition-colors data-[selected=true]:bg-primary/[0.06]"
>
<div className="flex min-w-0 items-center gap-3">
{selectable ? (
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
toggleRow(entry.id, checked)
}
aria-label={`Select ${name}`}
/>
) : null}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 font-medium text-foreground">
{titleColumns.map((column) => (
<span key={column.key} className="min-w-0 truncate">
{readCell(entry.row, column)}
</span>
))}
</div>
{renderExpanded
? expandButton(entry.id, name, isOpen)
: null}
</div>
<StackedFields
className={cn("mt-2", selectable && "pl-8")}
fields={fieldColumns.map((column) => ({
key: column.key,
label: column.header,
value: readCell(entry.row, column),
}))}
/>
<AnimatePresence initial={false}>
{renderExpanded && isOpen ? (
detailPanel(entry, true)
) : null}
</AnimatePresence>
</motion.li>
);
})}
</ul>
)}
</>
) : (
<div className="overflow-x-auto [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]">
<table
className="w-full border-collapse"
style={{ tableLayout: "fixed", minWidth: `max(100%, ${minTableWidth}px)` }}
>
<colgroup>
{selectable ? <col style={{ width: SELECT_COL_PX }} /> : null}
{renderExpanded ? <col style={{ width: EXPAND_COL_PX }} /> : null}
{visibleColumns.map((column) => (
<col
key={column.key}
style={
column.width && !column.width.endsWith("fr")
? { width: column.width }
: undefined
}
/>
))}
</colgroup>
<thead>
<tr className="h-10">
{selectable ? (
<th className="border-border border-b bg-muted/60">
<div className="flex items-center justify-center">
<Checkbox
checked={allSelected}
indeterminate={!allSelected && someSelected}
onCheckedChange={toggleAll}
aria-label="Select all rows"
/>
</div>
</th>
) : null}
{renderExpanded ? (
<th className="border-border border-b bg-muted/60">
<span className="sr-only">Expand</span>
</th>
) : null}
{visibleColumns.map((column, index) => {
const sortIndex = sorts.findIndex((s) => s.key === column.key);
const sortState = sortIndex === -1 ? null : sorts[sortIndex];
const filtered = isFilterActive(filters[column.key]);
const isDragging = dragKey === column.key;
const isLast = index === visibleColumns.length - 1;
return (
<motion.th
key={column.key}
ref={(el: HTMLTableCellElement | null) => {
thRefs.current[column.key] = el;
}}
layout={rowLayout}
transition={layoutTransition}
scope="col"
aria-sort={
sortIndex === 0 && sortState
? sortState.direction === "asc"
? "ascending"
: "descending"
: undefined
}
data-drop={dropKey === column.key || undefined}
data-dropend={(dropAtEnd && isLast) || undefined}
className={cn(
"group/th relative border-border border-b bg-muted/60 p-0 font-medium text-muted-foreground",
"data-[drop=true]:before:absolute data-[drop=true]:before:inset-y-1 data-[drop=true]:before:left-0 data-[drop=true]:before:w-0.5 data-[drop=true]:before:rounded-full data-[drop=true]:before:bg-primary",
"data-[dropend=true]:after:absolute data-[dropend=true]:after:inset-y-1 data-[dropend=true]:after:right-0 data-[dropend=true]:after:w-0.5 data-[dropend=true]:after:rounded-full data-[dropend=true]:after:bg-primary",
isDragging && "opacity-50",
)}
>
<div
className={cn(
"relative flex h-10 items-center",
alignFlex(column.align),
)}
>
{reorderable ? (
<button
type="button"
tabIndex={-1}
aria-label={`Drag to reorder ${labelOf(column)} column`}
onPointerDown={(e) => startReorder(column.key, e)}
onPointerMove={moveReorder}
onPointerUp={endReorder}
onPointerCancel={endReorder}
className={cn(
"absolute inset-y-0 left-0 z-[1] flex w-4 cursor-grab touch-none items-center justify-center text-muted-foreground/50 opacity-0 transition-[color,opacity] hover:text-foreground active:cursor-grabbing group-hover/th:opacity-100 [@media(hover:none)]:opacity-100",
TOUCH_GESTURE_CLASS,
)}
>
<GripVertical className="size-3.5" />
</button>
) : null}
{column.sortable ? (
<button
type="button"
title="Click to sort, Shift+click to add a secondary sort"
onClick={(e) =>
applySort(
nextSortStack(sorts, column.key, e.shiftKey),
)
}
className={cn(
"flex h-full min-w-0 flex-1 select-none items-center gap-1 outline-none transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:underline",
"px-4",
filtered && "pr-9",
alignFlex(column.align),
sortState && "text-foreground",
)}
>
<span className="truncate">{column.header}</span>
<SortIndicator
direction={sortState?.direction ?? null}
priority={sorts.length > 1 && sortState ? sortIndex + 1 : null}
reduce={reduce}
/>
</button>
) : (
<span
className={cn(
"min-w-0 flex-1 truncate",
"px-4",
filtered && "pr-9",
alignText(column.align),
)}
>
{column.header}
</span>
)}
<ColumnMenu
column={column}
thRefs={thRefs}
position={index}
sortState={sortState}
filtered={filtered}
filterValue={filters[column.key]}
options={
column.filter === "select" ? selectOptions(column) : null
}
canMoveLeft={reorderable && index > 0}
canMoveRight={reorderable && !isLast}
reorderable={reorderable}
canHide={
column.hideable !== false && visibleColumns.length > 1
}
onSort={(direction) =>
applySort(
direction
? [{ key: column.key, direction }]
: sorts.filter((s) => s.key !== column.key),
)
}
onFilter={(value) => setColumnFilter(column.key, value)}
onMove={(dir) => moveColumn(column.key, dir)}
onHide={() => toggleHidden(column.key)}
/>
</div>
</motion.th>
);
})}
</tr>
</thead>
<tbody>
{pageRows.length === 0 ? (
<tr>
<td
colSpan={spanAll}
className="px-4 py-12 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
{emptyState}
{clearFiltersButton}
</div>
</td>
</tr>
) : (
pageRows.map((entry) => {
const isSelected = selectedSet.has(entry.id);
const isOpen = expandedSet.has(entry.id);
const detailId = `${uid}-detail-${entry.id}`;
const name = rowLabel(entry.row);
return (
<Fragment key={entry.id}>
<motion.tr
layout={rowLayout}
transition={layoutTransition}
initial={hydrated ? { opacity: 0 } : false}
animate={{ opacity: 1 }}
data-selected={isSelected || undefined}
data-expanded={isOpen || undefined}
className={cn(
"h-12 border-border/60 border-b transition-colors hover:bg-muted/40",
"data-[selected=true]:bg-primary/[0.06] data-[expanded=true]:border-transparent",
)}
>
{selectable ? (
<td className="select-none">
<div className="flex items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
toggleRow(entry.id, checked)
}
aria-label={`Select ${name}`}
/>
</div>
</td>
) : null}
{renderExpanded ? (
<td>
<div className="flex items-center justify-center">
<button
type="button"
aria-expanded={isOpen}
aria-controls={isOpen ? detailId : undefined}
aria-label={`${isOpen ? "Collapse" : "Expand"} ${name}`}
onClick={() => toggleExpanded(entry.id)}
className="grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
>
<motion.span
aria-hidden
className="grid place-items-center"
animate={{ rotate: isOpen ? 90 : 0 }}
transition={
reduce ? { duration: 0 } : SPRING_SWAP
}
>
<ChevronRight className="size-4" />
</motion.span>
</button>
</div>
</td>
) : null}
{visibleColumns.map((column) => (
<motion.td
key={column.key}
layout={rowLayout}
transition={layoutTransition}
className={cn(
"truncate px-4 text-foreground",
alignText(column.align),
)}
>
{readCell(entry.row, column)}
</motion.td>
))}
</motion.tr>
<AnimatePresence initial={false}>
{renderExpanded && isOpen ? (
<motion.tr
key="detail"
layout={rowLayout}
transition={layoutTransition}
className="border-border/60 border-b"
>
<td colSpan={spanAll} className="p-0">
{detailPanel(entry)}
</td>
</motion.tr>
) : null}
</AnimatePresence>
</Fragment>
);
})
)}
</tbody>
</table>
</div>
)}
{paged ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-border border-t px-3 py-2">
<span className="text-muted-foreground text-xs tabular-nums">
{sortedRows.length === 0
? "0 rows"
: `${numberFormat.format(firstIndex + 1)}–${numberFormat.format(
firstIndex + pageRows.length,
)} of ${numberFormat.format(sortedRows.length)}`}
</span>
{pageCount > 1 ? (
<Pagination
total={pageCount}
page={page}
onPageChange={setPage}
siblings={0}
aria-label="Table pages"
className="[&_button]:size-8"
/>
) : null}
</div>
) : null}
<div aria-live="polite" className="sr-only">
{announcement}
</div>
</div>
);
}
/* ------------------------------------------------------------------------ */
/* Header parts */
/* ------------------------------------------------------------------------ */
function SortIndicator({
direction,
priority,
reduce,
}: {
direction: SortState["direction"] | null;
priority: number | null;
reduce: boolean;
}) {
return (
<span aria-hidden className="inline-flex shrink-0 items-center gap-0.5">
<motion.span
className="inline-flex"
initial={false}
animate={{
rotate: direction === "desc" ? 180 : 0,
opacity: direction ? 1 : 0,
y: direction || reduce ? 0 : 2,
}}
transition={reduce ? { duration: 0.12 } : SPRING_SWAP}
>
<ArrowUp className="size-3.5" />
</motion.span>
<AnimatePresence initial={false}>
{priority ? (
<motion.span
key="priority"
initial={{ opacity: 0, scale: reduce ? 1 : 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: reduce ? 1 : 0.5 }}
transition={reduce ? { duration: 0.1 } : SPRING_SWAP}
className="grid size-3.5 place-items-center rounded-full bg-primary font-semibold text-[9px] text-primary-foreground tabular-nums"
>
{priority}
</motion.span>
) : null}
</AnimatePresence>
</span>
);
}
function ColumnMenu<T>({
column,
thRefs,
position,
sortState,
filtered,
filterValue,
options,
canMoveLeft,
canMoveRight,
reorderable,
canHide,
onSort,
onFilter,
onMove,
onHide,
chip = false,
}: {
/** Render the trigger as a labelled chip anchored to itself (stacked layout) instead of a header-cell button. */
chip?: boolean;
column: DataTableColumn<T>;
thRefs: HeaderCellRefs;
position: number;
sortState: SortState | null;
filtered: boolean;
filterValue: DataTableFilterValue | undefined;
options: string[] | null;
canMoveLeft: boolean;
canMoveRight: boolean;
reorderable: boolean;
canHide: boolean;
onSort: (direction: SortState["direction"] | null) => void;
onFilter: (value: DataTableFilterValue) => void;
onMove: (dir: -1 | 1) => void;
onHide: () => void;
}) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const label = labelOf(column);
const close = useCallback(() => setOpen(false), []);
// Anchor to the whole header cell, so the panel lines up with the column.
const getAnchor = useCallback((): AnchorRect | null => {
const r = (
chip ? triggerRef.current : thRefs.current[column.key]
)?.getBoundingClientRect();
return r ? { left: r.left, right: r.right, bottom: r.bottom } : null;
}, [chip, thRefs, column.key]);
const hasSort = !!column.sortable;
const hasFilter = !!column.filter;
const selected = Array.isArray(filterValue) ? filterValue : [];
const hasAnything = hasSort || hasFilter || reorderable || canHide;
if (!hasAnything) return null;
return (
<>
{chip ? (
<button
ref={triggerRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={cn(
"inline-flex h-7 items-center gap-1 rounded-full border px-2.5 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 pointer-coarse:h-9 pointer-coarse:px-3",
filtered
? "border-primary/40 bg-primary/10 text-foreground"
: "border-border bg-background text-muted-foreground",
)}
>
{filtered ? (
<ListFilter aria-hidden className="size-3.5 text-primary" />
) : null}
{label}
<span className="sr-only">
{filtered ? " (filtered)" : ""} column options
</span>
<ChevronDown aria-hidden className="size-3.5" />
</button>
) : (
<button
ref={triggerRef}
type="button"
aria-label={`${label} column options`}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={cn(
"-translate-y-1/2 absolute top-1/2 right-1 grid size-6 place-items-center rounded-md bg-muted outline-none transition-[color,background-color,opacity] hover:bg-background hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50",
filtered || open
? "text-primary opacity-100"
: "opacity-0 group-hover/th:opacity-100 [@media(hover:none)]:opacity-100",
)}
>
{filtered ? (
<ListFilter className="size-3.5" />
) : (
<MoreHorizontal className="size-3.5" />
)}
</button>
)}
<Panel
open={open}
onClose={close}
getAnchor={getAnchor}
triggerRef={triggerRef}
label={`${label} column options`}
repositionKey={position}
>
{hasSort ? (
<>
<button
type="button"
aria-pressed={sortState?.direction === "asc"}
onClick={() => onSort("asc")}
className={PANEL_ITEM}
>
<ArrowUp />
Sort ascending
</button>
<button
type="button"
aria-pressed={sortState?.direction === "desc"}
onClick={() => onSort("desc")}
className={PANEL_ITEM}
>
<ArrowUp className="rotate-180" />
Sort descending
</button>
{sortState ? (
<button
type="button"
onClick={() => onSort(null)}
className={PANEL_ITEM}
>
<X />
Clear sort
</button>
) : null}
</>
) : null}
{hasFilter ? (
<>
{hasSort ? <div className={PANEL_DIVIDER} /> : null}
<div className={PANEL_HEADING}>Filter</div>
{column.filter === "text" ? (
<div className="px-1.5 pb-1.5">
<input
type="text"
value={typeof filterValue === "string" ? filterValue : ""}
onChange={(e) => onFilter(e.target.value)}
placeholder={`${label} contains…`}
aria-label={`Filter ${label} (contains)`}
className="h-8 w-full rounded-md border border-border bg-background px-2.5 text-sm outline-none placeholder:text-muted-foreground/70 focus-visible:border-border-strong focus-visible:ring-2 focus-visible:ring-ring/30"
/>
</div>
) : (
<div className="max-h-44 overflow-y-auto px-1 pb-1 [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]">
{(options ?? []).map((option) => (
<div
key={option}
className="rounded-lg px-1.5 py-1 hover:bg-muted"
>
<Checkbox
checked={selected.includes(option)}
onCheckedChange={(checked) =>
onFilter(
checked
? [...selected, option]
: selected.filter((o) => o !== option),
)
}
label={option}
className="w-full gap-2 text-sm [&_button]:size-4"
/>
</div>
))}
</div>
)}
{filtered ? (
<button
type="button"
onClick={() => onFilter(column.filter === "text" ? "" : [])}
className={PANEL_ITEM}
>
<X />
Clear filter
</button>
) : null}
</>
) : null}
{reorderable || canHide ? (
<>
{hasSort || hasFilter ? <div className={PANEL_DIVIDER} /> : null}
{reorderable ? (
<>
<button
type="button"
disabled={!canMoveLeft}
onClick={() => onMove(-1)}
className={PANEL_ITEM}
>
<ArrowLeft />
Move left
</button>
<button
type="button"
disabled={!canMoveRight}
onClick={() => onMove(1)}
className={PANEL_ITEM}
>
<ArrowRight />
Move right
</button>
</>
) : null}
{canHide ? (
<button
type="button"
onClick={() => {
setOpen(false);
onHide();
}}
className={PANEL_ITEM}
>
<EyeOff />
Hide column
</button>
) : null}
</>
) : null}
</Panel>
</>
);
}
function ColumnsPicker<T>({
columns,
hiddenSet,
visibleCount,
onToggle,
}: {
columns: DataTableColumn<T>[];
hiddenSet: Set<string>;
visibleCount: number;
onToggle: (key: string) => void;
}) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const close = useCallback(() => setOpen(false), []);
const getAnchor = useCallback((): AnchorRect | null => {
const r = triggerRef.current?.getBoundingClientRect();
return r ? { left: r.left, right: r.right, bottom: r.bottom } : null;
}, []);
const hiddenCount = columns.filter((c) => hiddenSet.has(c.key)).length;
return (
<>
<button
ref={triggerRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50"
>
<Columns3 className="size-3.5 text-muted-foreground" />
Columns
{hiddenCount > 0 ? (
<span className="rounded-full bg-muted px-1.5 text-[10px] text-muted-foreground tabular-nums">
{hiddenCount} hidden
</span>
) : null}
</button>
<Panel
open={open}
onClose={close}
getAnchor={getAnchor}
triggerRef={triggerRef}
label="Visible columns"
>
<div className={PANEL_HEADING}>Visible columns</div>
{columns.map((column) => {
const visible = !hiddenSet.has(column.key);
const locked =
column.hideable === false || (visible && visibleCount <= 1);
return (
<div key={column.key} className="rounded-lg px-1.5 py-1 hover:bg-muted">
<Checkbox
checked={visible}
disabled={locked}
onCheckedChange={() => onToggle(column.key)}
label={labelOf(column)}
className="w-full gap-2 text-sm [&_button]:size-4"
/>
</div>
);
})}
</Panel>
</>
);
}
"use client";
import {
type ReactNode,
type RefObject,
useId,
useLayoutEffect,
useState,
} from "react";
import { cn } from "@/lib/utils";
import type { SortState, TableColumn } from "./types";
/*
* Shared pieces of the stacked ("card list") layout both tables switch to in a
* narrow container (`mobileLayout="stack"`).
*/
/** Container width (px) below which `mobileLayout="stack"` shows cards. */
export const DEFAULT_STACK_BELOW = 560;
/**
* True once the element measures narrower than `stackBelow`. Starts false, so
* the server and the hydrating client both render the table, then switches in
* a layout effect (before the first paint after hydration) and follows the
* element's width from there. The element must not size to its content, or the
* swap could feed back into the measurement — both tables measure their
* full-width root.
*/
export function useStackedLayout(
ref: RefObject<HTMLElement | null>,
enabled: boolean,
stackBelow: number,
) {
const [stacked, setStacked] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!enabled || !el) {
setStacked(false);
return;
}
const update = (width: number) => setStacked(width < stackBelow);
update(el.getBoundingClientRect().width);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const entry = entries[entries.length - 1];
if (entry) update(entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width);
});
observer.observe(el);
return () => observer.disconnect();
}, [ref, enabled, stackBelow]);
return enabled && stacked;
}
/** Plain-text name of a column, for option labels and announcements. */
export function columnLabel<T>(column: TableColumn<T>) {
return typeof column.header === "string" ? column.header : column.key;
}
/**
* Splits visible columns into the card title (`primary` columns, or the first
* column when none opts in) and the label/value fields under it, leaving out
* `hideOnMobile` columns. A primary column is always shown.
*/
export function splitStackColumns<C extends TableColumn<never>>(columns: C[]) {
const flagged = columns.filter((c) => c.primary);
const primary = flagged.length > 0 ? flagged : columns.slice(0, 1);
const titleKeys = new Set(primary.map((c) => c.key));
const fields = columns.filter((c) => !titleKeys.has(c.key) && !c.hideOnMobile);
return { primary, fields };
}
/** Rough card height before measurement: title row + one line per field. */
export function estimateCardHeight(fieldCount: number) {
return 56 + fieldCount * 28;
}
export type StackedField = {
key: string;
label: ReactNode;
value: ReactNode;
/** The value is an input: let it take the full width instead of hugging the right edge. */
control?: boolean;
};
/**
* A card's label/value pairs as a description list, so each value is read
* with its column header.
*/
export function StackedFields({
fields,
className,
}: {
fields: StackedField[];
className?: string;
}) {
if (fields.length === 0) return null;
return (
<dl className={cn("grid gap-1.5", className)}>
{fields.map((field) => (
<div key={field.key} className="flex min-w-0 items-center gap-3">
<dt className="w-2/5 max-w-40 shrink-0 truncate text-muted-foreground text-xs">
{field.label}
</dt>
<dd
className={cn(
"min-w-0 flex-1 text-foreground",
field.control ? "pl-2" : "truncate text-right",
)}
>
{field.value}
</dd>
</div>
))}
</dl>
);
}
const SORT_NONE = "";
/**
* Compact sort control shown above the cards, where there are no headers to
* click: one native select of "column, direction" pairs.
*/
export function StackSortSelect<T>({
columns,
value,
onChange,
className,
}: {
columns: TableColumn<T>[];
value: SortState | null;
onChange: (next: SortState | null) => void;
className?: string;
}) {
const id = useId();
const sortable = columns.filter((c) => c.sortable);
if (sortable.length === 0) return null;
const current =
value && sortable.some((c) => c.key === value.key)
? `${value.direction}:${value.key}`
: SORT_NONE;
return (
<div className={cn("flex min-w-0 items-center gap-2", className)}>
<label htmlFor={id} className="shrink-0 text-muted-foreground text-xs">
Sort by
</label>
<select
id={id}
value={current}
onChange={(e) => {
const raw = e.target.value;
if (raw === SORT_NONE) return onChange(null);
const split = raw.indexOf(":");
onChange({
direction: raw.slice(0, split) === "desc" ? "desc" : "asc",
key: raw.slice(split + 1),
});
}}
className="h-8 min-w-0 max-w-full rounded-lg border border-border bg-background px-2 text-foreground text-sm outline-none transition-colors focus-visible:border-border-strong focus-visible:ring-2 focus-visible:ring-ring/30"
>
<option value={SORT_NONE}>None</option>
{sortable.map((column) => {
const label = columnLabel(column);
return [
<option key={`asc:${column.key}`} value={`asc:${column.key}`}>
{label} (ascending)
</option>,
<option key={`desc:${column.key}`} value={`desc:${column.key}`}>
{label} (descending)
</option>,
];
})}
</select>
</div>
);
}
import type { ReactNode } from "react";
export type SortDirection = "asc" | "desc";
export type SortState = {
key: string;
direction: SortDirection;
};
export type TableColumn<T> = {
/** Stable key; also the default object property read for the cell + sort value. */
key: string;
/** Header content. */
header: ReactNode;
/** Allow clicking the header to sort by this column. */
sortable?: boolean;
/** Cell text alignment. */
align?: "left" | "center" | "right";
/** Column width as a CSS length, e.g. "160px" or "20%". Omit to share remaining space equally. */
width?: string;
/** Custom cell renderer. Falls back to `row[key]`. */
cell?: (row: T) => ReactNode;
/** Render an inline text input for this column's cells (ignored when `cell` is set). */
editable?: boolean;
/** Value used for sorting. Falls back to `row[key]`. */
sortValue?: (row: T) => string | number;
/** Stacked layout (`mobileLayout="stack"`): show this cell in the card title.
* Defaults to the first column when no column sets it. */
primary?: boolean;
/** Stacked layout: leave this column out of the card's label/value pairs. */
hideOnMobile?: boolean;
};
/**
* Narrow-container layout. `"scroll"` keeps the table and scrolls it sideways;
* `"stack"` renders one card per row while the table's own width is below
* `stackBelow`.
*/
export type MobileLayout = "scroll" | "stack";
export type InsertPosition = "before" | "after";
export interface TableProps<T> {
data: T[];
columns: TableColumn<T>[];
/** Stable id per row, required for correct selection across sorts. Defaults to row index. */
getRowId?: (row: T, index: number) => string;
/** Render a leading checkbox column with select-all in the header. */
selectable?: boolean;
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
onSelectionChange?: (ids: string[]) => void;
sort?: SortState | null;
defaultSort?: SortState | null;
onSortChange?: (sort: SortState | null) => void;
/** Allow dragging the right edge of a header to resize that column. */
resizable?: boolean;
/** Minimum column width in px when resizing. */
minColumnWidth?: number;
onColumnResize?: (key: string, width: number) => void;
/** Allow dragging a header grip to reorder columns. */
reorderable?: boolean;
onColumnOrderChange?: (keys: string[]) => void;
/** Called when an `editable` cell changes. */
onCellEdit?: (rowId: string, columnKey: string, value: string) => void;
/** When set, non-sortable headers become editable inputs for the column name. */
onColumnRename?: (columnKey: string, value: string) => void;
/** Enables the row menu (Insert before / after). Receives the target index. */
onInsertRow?: (index: number, position: InsertPosition) => void;
/** Enables Delete in the row menu. */
onDeleteRow?: (rowId: string, index: number) => void;
/** Enables the column menu (Insert before / after). Receives the target column index. */
onInsertColumn?: (index: number, position: InsertPosition) => void;
/** Enables Delete in the column menu. */
onDeleteColumn?: (columnKey: string, index: number) => void;
/** Fixed row height in px — required for virtualization. */
rowHeight?: number;
/** Scroll viewport height in px. */
height?: number;
/** Rows rendered above/below the viewport. */
overscan?: number;
/** Fires when the viewport scrolls near the bottom — load the next page. */
onEndReached?: () => void;
/** Currently fetching — shows skeleton rows and pauses `onEndReached`. */
loading?: boolean;
/** How many skeleton rows to show while loading more (default 3). */
skeletonRows?: number;
emptyState?: ReactNode;
/**
* `"scroll"` (default) keeps today's sideways-scrolling table. `"stack"`
* swaps to a virtualized card list (title from `primary` columns, the rest as
* header: value pairs, editable cells stay inputs) whenever the table's own
* width is below `stackBelow`. The server always renders the table.
*/
mobileLayout?: MobileLayout;
/** Width in px under which `mobileLayout="stack"` shows cards (default 560). */
stackBelow?: number;
className?: string;
}
/** A data row paired with its stable id. */
export type TableRow<T> = { row: T; id: string };
/** Ref map from column key to its header cell, shared across the resize/reorder hooks. */
export type HeaderCellRefs = {
current: Record<string, HTMLTableCellElement | null>;
};
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useMemo,
useState,
} from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
import type { HeaderCellRefs, TableColumn } from "./types";
export function useColumnReorder<T>({
columns,
thRefs,
order: orderProp,
defaultOrder,
onColumnOrderChange,
}: {
columns: TableColumn<T>[];
thRefs: HeaderCellRefs;
/** Controlled column key order. Omit to keep the order internally. */
order?: string[];
/** Initial order for uncontrolled use; defaults to the `columns` order. */
defaultOrder?: string[];
onColumnOrderChange?: (keys: string[]) => void;
}) {
const [internalOrder, setOrder] = useState<string[]>(
() => defaultOrder ?? columns.map((c) => c.key),
);
const order = orderProp ?? internalOrder;
const [dragKey, setDragKey] = useState<string | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
// Apply the current order, tolerating columns added/removed at runtime. New
// columns are placed at their position in `columns` (after their left
// neighbor), not appended — so an inserted column lands where it was added.
const orderedColumns = useMemo(() => {
const byKey = new Map(columns.map((c) => [c.key, c]));
const resultKeys = order.filter((k) => byKey.has(k));
const present = new Set(resultKeys);
columns.forEach((column, i) => {
if (present.has(column.key)) return;
let at = resultKeys.length;
if (i === 0) {
at = 0;
} else {
const idx = resultKeys.indexOf(columns[i - 1].key);
at = idx === -1 ? i : idx + 1;
}
resultKeys.splice(at, 0, column.key);
present.add(column.key);
});
return resultKeys
.map((k) => byKey.get(k))
.filter((c): c is TableColumn<T> => c !== undefined);
}, [order, columns]);
const dropIndexFor = useCallback(
(clientX: number) => {
for (let i = 0; i < orderedColumns.length; i++) {
const rect =
thRefs.current[orderedColumns[i].key]?.getBoundingClientRect();
if (rect && clientX < rect.left + rect.width / 2) return i;
}
return orderedColumns.length;
},
[orderedColumns, thRefs],
);
/** Commit a full key order — the drop of a drag, or a keyboard move. */
const commitOrder = useCallback(
(keys: string[]) => {
if (orderProp === undefined) setOrder(keys);
onColumnOrderChange?.(keys);
},
[orderProp, onColumnOrderChange],
);
const startReorder = useCallback((key: string, e: ReactPointerEvent) => {
e.preventDefault();
e.stopPropagation();
setDragKey(key);
capturePointer(e.currentTarget, e.pointerId);
}, []);
const moveReorder = useCallback(
(e: ReactPointerEvent) => {
if (!dragKey) return;
setDropIndex(dropIndexFor(e.clientX));
},
[dragKey, dropIndexFor],
);
const endReorder = useCallback(
(e: ReactPointerEvent) => {
releasePointer(e.currentTarget, e.pointerId);
if (dragKey && dropIndex !== null) {
const keys = orderedColumns.map((c) => c.key);
const from = keys.indexOf(dragKey);
if (from !== -1) {
const without = keys.filter((_, i) => i !== from);
let to = dropIndex;
if (from < to) to--;
without.splice(to, 0, dragKey);
commitOrder(without);
}
}
setDragKey(null);
setDropIndex(null);
},
[dragKey, dropIndex, orderedColumns, commitOrder],
);
return {
orderedColumns,
dragKey,
dropIndex,
startReorder,
moveReorder,
endReorder,
commitOrder,
};
}
import type { ReactNode } from "react";
import type { TableColumn } from "./types";
export const CHECKBOX_PX = 48;
export const CHECKBOX_WIDTH = `${CHECKBOX_PX}px`;
/** Highlights the top edge of the active column's header cell. */
export const COLUMN_ACTIVE_SHADOW = "inset 0 1px 0 var(--color-primary)";
export function alignFlex(align: TableColumn<unknown>["align"]) {
if (align === "right") return "justify-end";
if (align === "center") return "justify-center";
return "justify-start";
}
export function alignText(align: TableColumn<unknown>["align"]) {
if (align === "right") return "text-right";
if (align === "center") return "text-center";
return "text-left";
}
export function readCell<T>(row: T, column: TableColumn<T>): ReactNode {
if (column.cell) return column.cell(row);
return (row as Record<string, ReactNode>)[column.key];
}
export function readSortValue<T>(
row: T,
column: TableColumn<T>,
): string | number {
if (column.sortValue) return column.sortValue(row);
return (row as Record<string, string | number>)[column.key];
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId } from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
const CHECK_PATH = "M5 13l4 4L19 7";
const INDETERMINATE_PATH = "M6 12h12";
export interface CheckboxProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
indeterminate?: boolean;
label?: string;
className?: string;
id?: string;
"aria-label"?: string;
/** Associates an external message (e.g. a form error) with the control. */
"aria-describedby"?: string;
}
export function Checkbox({
checked,
onCheckedChange,
disabled,
indeterminate,
label,
className,
id: idProp,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
}: CheckboxProps) {
const autoId = useId();
const id = idProp ?? autoId;
const reduce = useReducedMotion();
const showMark = checked || indeterminate;
const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;
return (
<label
htmlFor={id}
className={cn(
"inline-flex items-center gap-3",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
)}
>
<motion.button
id={id}
type="button"
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={
checked ? "checked" : indeterminate ? "indeterminate" : "unchecked"
}
className={cn(
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200",
TOUCH_TARGET_CLASS,
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
showMark
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50 bg-background hover:border-muted-foreground",
)}
>
<AnimatePresence initial={false}>
{showMark ? (
<motion.svg
key={indeterminate ? "indeterminate" : "checked"}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
transition={
reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }
}
aria-hidden
>
<title>{indeterminate ? "Partially selected" : "Selected"}</title>
<motion.path
d={path}
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce
? { duration: 0 }
: {
duration: indeterminate ? 0.2 : 0.3,
ease: EASE_OUT,
delay: 0.04,
}
}
/>
</motion.svg>
) : null}
</AnimatePresence>
</motion.button>
{label ? (
<span className={cn("select-none text-sm text-foreground", disabled && "opacity-60")}>
{label}
</span>
) : null}
</label>
);
}
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
AnimatePresence,
LayoutGroup,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import { useCallback, useId, useState } from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { TOUCH_TARGET_CLASS, TOUCH_TARGET_Y_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
export type PaginationVariant = "numbers" | "dots";
export type PaginationItem = number | "ellipsis";
export interface PaginationProps {
/** Total number of pages (1-based). */
total: number;
/** Controlled current page (1-based). */
page?: number;
/** Initial page for uncontrolled use. */
defaultPage?: number;
onPageChange?: (page: number) => void;
/** Pages shown on each side of the current page. */
siblings?: number;
/** Pages always shown at the start and end. */
boundaries?: number;
/** `numbers` renders page buttons; `dots` is a compact indicator row. */
variant?: PaginationVariant;
/** Most dots shown at once in the `dots` variant; the window follows the page. */
maxDots?: number;
/** Render previous / next controls. */
showControls?: boolean;
disabled?: boolean;
/** Accessible name for the `<nav>` landmark. */
"aria-label"?: string;
/** Accessible name per page button. */
getPageLabel?: (page: number) => string;
className?: string;
}
function range(start: number, end: number) {
const out: number[] = [];
for (let i = start; i <= end; i++) out.push(i);
return out;
}
/**
* Page items with ellipses. The item count stays constant once ellipses
* appear, so slots keep their position and only their numbers change.
*/
export function getPaginationItems(
total: number,
page: number,
siblings = 1,
boundaries = 1,
): PaginationItem[] {
const count = Math.max(0, Math.floor(total));
const sib = Math.max(0, Math.floor(siblings));
const bound = Math.max(1, Math.floor(boundaries));
// boundaries + siblings + current + two ellipses
const slots = sib * 2 + 3 + bound * 2;
if (count <= slots) return range(1, count);
const left = Math.max(page - sib, bound + 1);
const right = Math.min(page + sib, count - bound);
const showLeft = left > bound + 2;
const showRight = right < count - bound - 1;
if (!showLeft && showRight) {
const leftCount = sib * 2 + bound + 2;
return [
...range(1, leftCount),
"ellipsis",
...range(count - bound + 1, count),
];
}
if (showLeft && !showRight) {
const rightCount = sib * 2 + bound + 2;
return [
...range(1, bound),
"ellipsis",
...range(count - rightCount + 1, count),
];
}
return [
...range(1, bound),
"ellipsis",
...range(left, right),
"ellipsis",
...range(count - bound + 1, count),
];
}
function clamp(value: number, total: number) {
return Math.min(Math.max(1, Math.round(value)), Math.max(1, total));
}
// Numbers roll in the direction of travel when the window shifts.
const ROLL_VARIANTS: Variants = {
initial: (dir: number) => ({ opacity: 0, y: dir >= 0 ? "100%" : "-100%" }),
animate: { opacity: 1, y: "0%", transition: SPRING_SWAP },
exit: (dir: number) => ({
opacity: 0,
y: dir >= 0 ? "-100%" : "100%",
transition: { duration: 0.14, ease: EASE_OUT },
}),
};
const FADE_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.15 } },
exit: { opacity: 0, transition: { duration: 0.1 } },
};
const ITEM_CLASS =
"relative grid size-9 shrink-0 place-items-center rounded-full text-sm tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40";
export function Pagination({
total,
page,
defaultPage = 1,
onPageChange,
siblings = 1,
boundaries = 1,
variant = "numbers",
maxDots = 7,
showControls = true,
disabled = false,
"aria-label": ariaLabel = "Pagination",
getPageLabel = (p) => `Page ${p}`,
className,
}: PaginationProps) {
const reduce = useReducedMotion() ?? false;
const canHover = useHoverCapable();
const groupId = useId();
const [internal, setInternal] = useState(() => clamp(defaultPage, total));
const controlled = page !== undefined;
const current = clamp(controlled ? page : internal, total);
// Direction of the last move drives the roll. Derived during render with
// the setState-in-render pattern so it is ready on the same pass.
const [track, setTrack] = useState({ page: current, dir: 0 });
if (track.page !== current) {
setTrack({ page: current, dir: current > track.page ? 1 : -1 });
}
const direction = track.page !== current
? current > track.page ? 1 : -1
: track.dir;
const go = useCallback(
(next: number) => {
const target = clamp(next, total);
if (target === current || disabled) return;
if (!controlled) setInternal(target);
onPageChange?.(target);
},
[controlled, current, disabled, onPageChange, total],
);
const nudge = !reduce && canHover;
const rollVariants = reduce ? FADE_VARIANTS : ROLL_VARIANTS;
const pillTransition = reduce ? { duration: 0 } : SPRING_LAYOUT;
const control = (dir: -1 | 1) => {
const isPrev = dir === -1;
const Icon = isPrev ? ChevronLeft : ChevronRight;
const atEdge = isPrev ? current <= 1 : current >= total;
return (
<motion.button
type="button"
aria-label={isPrev ? "Previous page" : "Next page"}
disabled={disabled || atEdge || total < 1}
onClick={() => go(current + dir)}
initial="rest"
animate="rest"
whileHover={nudge ? "hover" : undefined}
whileTap={reduce ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
className={cn(
ITEM_CLASS,
TOUCH_TARGET_CLASS,
"text-muted-foreground hover:bg-muted hover:text-foreground",
variant === "dots" && "size-8",
)}
>
<motion.span
aria-hidden="true"
variants={{ rest: { x: 0 }, hover: { x: isPrev ? -2 : 2 } }}
transition={SPRING_PRESS}
className="grid place-items-center"
>
<Icon className="size-4" />
</motion.span>
</motion.button>
);
};
if (variant === "dots") {
const size = Math.max(3, Math.min(maxDots, total));
const start = Math.min(
Math.max(1, current - Math.floor(size / 2)),
Math.max(1, total - size + 1),
);
const dots = range(start, Math.min(total, start + size - 1));
const first = dots[0] ?? 1;
const last = dots[dots.length - 1] ?? 1;
return (
<nav aria-label={ariaLabel} className={cn("inline-flex items-center gap-1", className)}>
{showControls ? control(-1) : null}
<LayoutGroup id={groupId}>
<ul className="flex items-center gap-0.5">
<AnimatePresence initial={false} mode="popLayout">
{dots.map((p) => {
const active = p === current;
// Shrink edge dots when more pages hide beyond them.
const edge =
(p === first && first > 1) || (p === last && last < total);
return (
<motion.li
key={p}
layout={reduce ? false : "position"}
initial={{ opacity: 0, scale: reduce ? 1 : 0.4 }}
animate={{ opacity: 1, scale: edge && !reduce ? 0.6 : 1 }}
exit={{ opacity: 0, scale: reduce ? 1 : 0.4 }}
transition={reduce ? { duration: 0.12 } : SPRING_LAYOUT}
className="flex"
>
<button
type="button"
aria-label={getPageLabel(p)}
aria-current={active ? "page" : undefined}
disabled={disabled}
onClick={() => go(p)}
className={cn(
"group relative grid h-5 w-5 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:opacity-40",
TOUCH_TARGET_Y_CLASS,
)}
>
<span className="size-2 rounded-full bg-muted-foreground/35 transition-colors group-hover:bg-muted-foreground/60" />
{active ? (
<motion.span
layoutId="pagination-dot"
transition={pillTransition}
className="absolute inset-x-0 top-1.5 h-2 rounded-full bg-foreground"
/>
) : null}
</button>
</motion.li>
);
})}
</AnimatePresence>
</ul>
</LayoutGroup>
{showControls ? control(1) : null}
</nav>
);
}
const items = getPaginationItems(total, current, siblings, boundaries);
return (
<nav aria-label={ariaLabel} className={cn("inline-flex", className)}>
<ul className="flex items-center gap-1">
{showControls ? <li className="flex">{control(-1)}</li> : null}
<LayoutGroup id={groupId}>
{items.map((item, index) => {
const active = item === current;
const itemKey = String(item);
return (
// Slots are keyed by position so buttons (and their focus)
// persist while the numbers inside roll to new values.
// biome-ignore lint/suspicious/noArrayIndexKey: positional slots are the point.
<li key={index} className="relative flex">
{active ? (
<motion.span
layoutId="pagination-pill"
transition={pillTransition}
className="absolute inset-0 rounded-full bg-primary"
/>
) : null}
{item === "ellipsis" ? (
<span
aria-hidden="true"
className={cn(ITEM_CLASS, "text-muted-foreground")}
>
…
</span>
) : (
<motion.button
type="button"
aria-label={getPageLabel(item)}
aria-current={active ? "page" : undefined}
disabled={disabled}
onClick={() => go(item)}
whileTap={reduce ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
className={cn(
ITEM_CLASS,
TOUCH_TARGET_CLASS,
active
? "text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{/* The roll clips here rather than on the button, so the
button's touch hit area can overhang it. */}
<span className="relative grid size-full place-items-center overflow-hidden rounded-[inherit]">
<AnimatePresence
initial={false}
mode="popLayout"
custom={direction}
>
<motion.span
key={itemKey}
custom={direction}
variants={rollVariants}
initial="initial"
animate="animate"
exit="exit"
className="col-start-1 row-start-1"
>
{item}
</motion.span>
</AnimatePresence>
</span>
</motion.button>
)}
</li>
);
})}
</LayoutGroup>
{showControls ? <li className="flex">{control(1)}</li> : null}
</ul>
</nav>
);
}
API Reference
dataT[]—columnsDataTableColumn<T>[]—getRowId?((row: T, index: number) => string)Stable id per row — keeps selection, expansion and the reorder animation attached to the right row.
—getRowLabel?((row: T) => string)Accessible name of a row, used by its checkbox and expand button. Defaults to the first column's text.
—sort?SortState[]Sort stack, primary first. Click a header to sort by it alone; Shift+click adds a secondary sort.
—defaultSort?SortState[][]onSortChange?((sort: SortState[]) => void)—filters?DataTableFiltersPer-column filters keyed by column key.
—defaultFilters?DataTableFilters{}onFiltersChange?((filters: DataTableFilters) => void)—search?stringGlobal search across every `searchable` column.
—defaultSearch?stringonSearchChange?((search: string) => void)—showSearch?booleanRender the search field in the toolbar (default true).
truesearchPlaceholder?stringSearch…hiddenColumns?string[]Keys of hidden columns.
—defaultHiddenColumns?string[][]onHiddenColumnsChange?((keys: string[]) => void)—columnOrder?string[]Column key order.
—defaultColumnOrder?string[]—onColumnOrderChange?((keys: string[]) => void)—reorderable?booleanDrag headers by their grip, or use Move left/right in the column menu (default true).
truerenderExpanded?((row: T) => ReactNode)Renders a chevron column; the returned node fills a height-morphing detail panel under the row.
—expandedRowIds?string[]—defaultExpandedRowIds?string[][]onExpandedChange?((ids: string[]) => void)—selectable?booleanLeading checkbox column; Shift+click selects a range.
falseselectedRowIds?string[]—defaultSelectedRowIds?string[][]onSelectionChange?((ids: string[]) => void)—selectionActions?((ids: string[]) => ReactNode)Extra controls in the selected-count bar, e.g. bulk actions.
—pageSize?numberRows per page; `0` shows every row without pagination (default 10).
10page?number1-based page. Resets to 1 when search, filters or sort change.
—defaultPage?number1onPageChange?((page: number) => void)—toolbar?ReactNodeExtra toolbar content, rendered right of the search field.
—emptyState?ReactNodeNo resultsminColumnWidth?numberNarrowest a column without an explicit px width may get before the table scrolls (default 120).
120mobileLayout?"scroll" | "stack"`"scroll"` (default) keeps today's sideways-scrolling table. `"stack"` renders each row as a card (title from `primary` columns, the rest as header: value pairs, with selection, expand and a sort select plus filter chips above the list) whenever the table's own width is below `stackBelow`. The server always renders the table.
scrollstackBelow?numberWidth in px under which `mobileLayout="stack"` shows cards (default 560).
560className?string—Updated