## Sortable Table — Data Rows travel to their new order. Docs: https://www.interior.dev/docs/sortable-table Reference: https://www.interior.dev/reference/sortable-table License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/sortable-table.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { SortableTable, type SortState, } from "@/components/interior/sortable-table"; type Deploy = { id: string; service: string; duration: number; status: string; rank: number; }; const DEPLOYS: Deploy[] = [ { id: "d1", service: "checkout-api", duration: 184, status: "passed", rank: 0 }, { id: "d2", service: "web", duration: 62, status: "failed", rank: 2 }, { id: "d3", service: "search-index", duration: 431, status: "passed", rank: 0 }, ]; export function DeployTable() { const [sort, setSort] = useState({ columnId: "duration", direction: "desc", }); return ( d.id} getRowLabel={(d) => d.service} sort={sort} onSortChange={setSort} markable maxHeight={320} columns={[ { id: "service", header: "Service", value: (d) => d.service }, { id: "duration", header: "Build", width: "88px", align: "end", numeric: true, value: (d) => d.duration, cell: (d) => `${d.duration}s`, }, { id: "status", header: "Status", width: "92px", align: "end", value: (d) => d.rank, cell: (d) => d.status, }, ]} /> ); } ``` ### Props - `rows` (`T[]`) The data, in its original order. That order is preserved and stays reachable. - `columns` (`SortableColumn[]`) Column id, header, optional fixed width, alignment, a value accessor used for sorting and a cell renderer used for display. - `getRowId` (`(row: T) => string`) Stable identity per row. This is what lets a row keep its element across a reorder instead of being recycled into a different one. - `label` (`string`) Accessible name for the table. - `rowHeight` (`number`) — default: `44` Fixed row height in pixels. Rows are positioned by transform against this, so the table's height never changes when the order does. - `maxHeight` (`number | undefined`) Caps the body and scrolls inside it. Without it the body is exactly rows.length * rowHeight. - `sort` (`SortState | null | undefined`) Controlled sort. Pass null for unsorted; omit the prop entirely to let the table own it. - `defaultSort` (`SortState | null`) — default: `null` Initial sort when uncontrolled. - `onSortChange` (`(next: SortState | null) => void`) Fires on every header activation, including the third click that returns to the original order. - `markable` (`boolean`) — default: `false` Adds a leading follow toggle so one row can be marked and watched across a reorder. - `onMarkChange` (`(id: string | null) => void`) Reports the marked row id, or null when it is cleared. - `getRowLabel` (`(row: T) => string`) Accessible name for a row's follow button. Falls back to the first column's value. - `className` (`string`) — default: `""` Appended last, so any of the container classes can be overridden. ### Behavior notes - Sorting a table normally teleports every row and the one you were reading is gone; here each row keeps its element and travels to its new index, so it can be followed rather than found again. - Rows are placed by transform inside a body whose height is reserved from the row count, so a reorder relayouts nothing and the table never grows, shrinks or shifts the columns under the cursor. - Sort again mid-flight and the springs resume from where the rows currently are instead of restarting from the order they were leaving. - DOM order always matches visual order, so a screen reader reads the sorted table, and the result is announced once as a single sentence naming the column, the direction and the row count instead of once per row. - A third activation of the same header restores the order the rows arrived in, a state most tables make unreachable, and sorting is stable on the original index with empty values held last in both directions. - Comparison runs through a fixed Intl.Collator and no clock or random source is touched during render, so the server and the client agree on the markup; under prefers-reduced-motion the rows are placed instantly and the announcement still fires. ### Source (`components/interior/sortable-table.tsx`) ```tsx "use client"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { motion, useReducedMotion } from "motion/react"; const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const; const SMALL = { type: "spring", stiffness: 700, damping: 46, mass: 0.5 } as const; const EASE = [0.23, 1, 0.32, 1] as const; const LEAVE = [0.4, 0, 1, 1] as const; const HIDE = { duration: 0.12, ease: LEAVE } as const; const SHOW = { duration: 0.25, ease: EASE } as const; const STEP = 0.018; const STEP_CAP = 8; const SETTLE_MS = 380; export type SortDirection = "asc" | "desc"; export type SortState = { columnId: string; direction: SortDirection }; export type SortableColumn = { id: string; header: string; width?: string; align?: "start" | "end"; numeric?: boolean; sortable?: boolean; value?: (row: T) => string | number | null | undefined; cell?: (row: T) => ReactNode; }; export type OrderedRow = { id: string; row: T; index: number }; export type UseSortableRowsOptions = { rows: T[]; getRowId: (row: T) => string; getValue: (row: T, columnId: string) => string | number | null | undefined; sort?: SortState | null; defaultSort?: SortState | null; onSortChange?: (next: SortState | null) => void; restoreOriginal?: boolean; }; export function useSortableRows({ rows, getRowId, getValue, sort, defaultSort = null, onSortChange, restoreOriginal = true, }: UseSortableRowsOptions) { const [internal, setInternal] = useState(defaultSort); const controlled = sort !== undefined; const current = controlled ? sort : internal; const collator = useMemo( () => new Intl.Collator("en", { numeric: true, sensitivity: "base" }), [], ); const ordered = useMemo[]>(() => { const base = rows.map((row, i) => ({ id: getRowId(row), row, i })); if (current) { const dir = current.direction === "asc" ? 1 : -1; base.sort((x, y) => { const a = getValue(x.row, current.columnId); const b = getValue(y.row, current.columnId); const emptyA = a === null || a === undefined || a === ""; const emptyB = b === null || b === undefined || b === ""; if (emptyA || emptyB) { if (emptyA && emptyB) return x.i - y.i; return emptyA ? 1 : -1; } const d = typeof a === "number" && typeof b === "number" ? a - b : collator.compare(String(a), String(b)); return d === 0 ? x.i - y.i : d * dir; }); } return base.map(({ id, row }, index) => ({ id, row, index })); }, [rows, current, getRowId, getValue, collator]); const toggle = useCallback( (columnId: string) => { const next: SortState | null = !current || current.columnId !== columnId ? { columnId, direction: "asc" } : current.direction === "asc" ? { columnId, direction: "desc" } : restoreOriginal ? null : { columnId, direction: "asc" }; if (!controlled) setInternal(next); onSortChange?.(next); }, [current, controlled, onSortChange, restoreOriginal], ); const ariaSort = useCallback( (columnId: string): "ascending" | "descending" | "none" => current?.columnId === columnId ? current.direction === "asc" ? "ascending" : "descending" : "none", [current], ); return { sort: current, ordered, toggle, ariaSort }; } export type SortableTableProps = { rows: T[]; columns: SortableColumn[]; getRowId: (row: T) => string; label: string; rowHeight?: number; maxHeight?: number; sort?: SortState | null; defaultSort?: SortState | null; onSortChange?: (next: SortState | null) => void; markable?: boolean; onMarkChange?: (id: string | null) => void; getRowLabel?: (row: T) => string; className?: string; }; export function SortableTable({ rows, columns, getRowId, label, rowHeight = 44, maxHeight, sort, defaultSort = null, onSortChange, markable = false, onMarkChange, getRowLabel, className = "", }: SortableTableProps) { const reduced = useReducedMotion(); const [marked, setMarked] = useState(null); const [touched, setTouched] = useState(false); const [moving, setMoving] = useState(false); const settleTimer = useRef | null>(null); useEffect( () => () => { if (settleTimer.current) clearTimeout(settleTimer.current); }, [], ); const getValue = useCallback( (row: T, columnId: string) => { const column = columns.find((c) => c.id === columnId); return column?.value ? column.value(row) : null; }, [columns], ); const { sort: current, ordered, toggle, ariaSort } = useSortableRows({ rows, getRowId, getValue, sort, defaultSort, onSortChange, }); const template = useMemo( () => (markable ? "28px " : "") + columns.map((c) => c.width ?? "minmax(0, 1fr)").join(" "), [columns, markable], ); const onToggle = (columnId: string) => { setTouched(true); toggle(columnId); if (reduced) return; setMoving(true); if (settleTimer.current) clearTimeout(settleTimer.current); settleTimer.current = setTimeout(() => setMoving(false), SETTLE_MS); }; const onMark = (id: string) => { const next = marked === id ? null : id; setMarked(next); onMarkChange?.(next); }; const nameOf = (row: T) => getRowLabel?.(row) ?? String(columns[0]?.value?.(row) ?? getRowId(row)); const activeHeader = columns.find((c) => c.id === current?.columnId)?.header; const message = !touched ? "" : current && activeHeader ? `Sorted by ${activeHeader}, ${ current.direction === "asc" ? "ascending" : "descending" }. ${rows.length} rows.` : `Original order restored. ${rows.length} rows.`; return (
{markable && (
Follow
)} {columns.map((column) => { const state = ariaSort(column.id); const active = state !== "none"; const end = column.align === "end"; return (
{column.sortable === false ? ( {column.header} ) : ( )}
); })}
{rows.length === 0 && (
No rows
)} {ordered.map(({ id, row, index }) => { const isMarked = markable && marked === id; return ( {markable && (
)} {columns.map((column, c) => { const raw = column.value?.(row); const content = column.cell ? column.cell(row) : raw === null || raw === undefined || raw === "" ? "—" : String(raw); return (
{content}
); })}
); })} {Array.from({ length: Math.max(0, rows.length - 1) }, (_, i) => (
))}
{message}
); } ```