diff --git a/bun.lock b/bun.lock index 23d67d7..0ba94cb 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "name": "ui-components", "dependencies": { "@shikijs/transformers": "^4.2.0", + "@tanstack/react-virtual": "^3.14.4", "@vercel/analytics": "^1.4.1", "@vercel/speed-insights": "^1.1.0", "clsx": "^2.1.1", @@ -211,6 +212,10 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "postcss": "^8.5.10", "tailwindcss": "4.3.0" } }, "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.4", "", { "dependencies": { "@tanstack/virtual-core": "3.17.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZzAQP2uCDAd+9sAehqmx/DcU+B91Q4Gb0aDSM7t9bJvWDyGF9sapFNW5r1gNLsHs4wTb6ScZENJeYaHxJLiOw=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.2", "", {}, "sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], diff --git a/components/motion/checkbox.tsx b/components/motion/checkbox.tsx index e0d3565..cf9bcfa 100644 --- a/components/motion/checkbox.tsx +++ b/components/motion/checkbox.tsx @@ -16,6 +16,7 @@ export interface CheckboxProps { label?: string; className?: string; id?: string; + "aria-label"?: string; } export function Checkbox({ @@ -26,6 +27,7 @@ export function Checkbox({ label, className, id: idProp, + "aria-label": ariaLabel, }: CheckboxProps) { const autoId = useId(); const id = idProp ?? autoId; @@ -47,6 +49,7 @@ export function Checkbox({ type="button" role="checkbox" aria-checked={indeterminate ? "mixed" : checked} + aria-label={ariaLabel} disabled={disabled} onClick={() => !disabled && onCheckedChange(!checked)} whileTap={reduce || disabled ? undefined : { scale: 0.92 }} diff --git a/components/motion/table/editable-cell.tsx b/components/motion/table/editable-cell.tsx new file mode 100644 index 0000000..954f5b2 --- /dev/null +++ b/components/motion/table/editable-cell.tsx @@ -0,0 +1,22 @@ +"use client"; + +export function EditableCell({ + value, + label, + onChange, +}: { + value: string; + label: string; + onChange: (next: string) => void; +}) { + return ( + 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" + /> + ); +} diff --git a/components/motion/table/index.tsx b/components/motion/table/index.tsx new file mode 100644 index 0000000..4202dbd --- /dev/null +++ b/components/motion/table/index.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useReducedMotion } from "motion/react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Checkbox } from "@/components/motion/checkbox"; +import { cn } from "@/lib/utils"; +import { EditableCell } from "./editable-cell"; +import { RowHandle } from "./row-handle"; +import { SkeletonRows } from "./skeleton-rows"; +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 { CHECKBOX_WIDTH, alignText, readCell } from "./utils"; + +export type { + SortDirection, + SortState, + TableColumn, + TableProps, +} from "./types"; + +export function Table({ + 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", + className, +}: TableProps) { + const reduce = useReducedMotion(); + const scrollRef = useRef(null); + const thRefs: HeaderCellRefs = useRef< + Record + >({}); + + 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 } = 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, + }); + + const virtualizer = useVirtualizer({ + count: sortedRows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => rowHeight, + overscan, + }); + + 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); + + // 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 < rowHeight * 4) { + endReachedRef.current = true; + onEndReached(); + } + }, [onEndReached, loading, rowHeight]); + const [activeColumn, setActiveColumn] = useState(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 | 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>({}); + const [activeRow, setActiveRow] = useState<{ id: string; index: number } | null>( + null, + ); + const rowTimer = useRef | 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); + + return ( +
+
+ + + {selectable ? : null} + {orderedColumns.map((column) => { + const override = widths[column.key]; + const width = override ? `${override}px` : column.width; + return ( + + ); + })} + {/* Empty filler owns the leftover space — no gap, content unpinned. */} + + + + + + + {sortedRows.length === 0 ? ( + loading ? ( + + ) : ( + + + + ) + ) : ( + <> + {paddingTop > 0 ? ( + + + ) : null} + {virtualItems.map((vItem) => { + const entry = sortedRows[vItem.index]; + const isSelected = selected.has(entry.id); + return ( + { + 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 ? ( + + ) : null} + {orderedColumns.map((column) => ( + + ))} + + ); + })} + {paddingBottom > 0 ? ( + + + ) : null} + {loading ? ( + + ) : null} + + )} + +
+ {emptyState} +
+
+
+ toggleRow(entry.id)} + aria-label={`Select row ${vItem.index + 1}`} + /> +
+
+ {!column.cell && column.editable ? ( + + onCellEdit?.(entry.id, column.key, next) + } + /> + ) : ( + readCell(entry.row, column) + )} + +
+
+
+ {hasRowMenu && activeRow ? ( + activateRow(activeRow.id, activeRow.index)} + onLeave={deactivateRow} + /> + ) : null} +
+ ); +} diff --git a/components/motion/table/row-handle.tsx b/components/motion/table/row-handle.tsx new file mode 100644 index 0000000..3aba2a5 --- /dev/null +++ b/components/motion/table/row-handle.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { ArrowDownToLine, ArrowUpToLine, MoreVertical, Trash2 } from "lucide-react"; +import { useEffect } from "react"; +import { createPortal } from "react-dom"; +import { TableMenu } from "./table-menu"; + +/** 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( +
+ } + items={[ + ...(onInsertRow + ? [ + { + label: "Insert before", + icon: , + onSelect: () => onInsertRow(index, "before"), + }, + { + label: "Insert after", + icon: , + onSelect: () => onInsertRow(index, "after"), + }, + ] + : []), + ...(onDeleteRow + ? [ + { + label: "Delete row", + icon: , + destructive: true, + onSelect: () => onDeleteRow(id, index), + }, + ] + : []), + ]} + /> +
, + document.body, + ); +} diff --git a/components/motion/table/skeleton-rows.tsx b/components/motion/table/skeleton-rows.tsx new file mode 100644 index 0000000..35196e8 --- /dev/null +++ b/components/motion/table/skeleton-rows.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { TableColumn } from "./types"; +import { alignText } from "./utils"; + +export function SkeletonRows({ + count, + columns, + selectable, + rowHeight, +}: { + count: number; + columns: TableColumn[]; + selectable: boolean; + rowHeight: number; +}) { + return ( + <> + {Array.from({ length: count }, (_, r) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows + + {selectable ? : null} + {columns.map((column) => ( + +
+ + ))} + + + ))} + + ); +} diff --git a/components/motion/table/table-header.tsx b/components/motion/table/table-header.tsx new file mode 100644 index 0000000..76967a5 --- /dev/null +++ b/components/motion/table/table-header.tsx @@ -0,0 +1,333 @@ +"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 { 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 { + columns: TableColumn[]; + 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( + column: TableColumn, + index: number, + onInsertColumn?: (index: number, position: InsertPosition) => void, + onDeleteColumn?: (columnKey: string, index: number) => void, +) { + return [ + ...(onInsertColumn + ? [ + { + label: "Insert before", + icon: , + onSelect: () => onInsertColumn(index, "before"), + }, + { + label: "Insert after", + icon: , + onSelect: () => onInsertColumn(index, "after"), + }, + ] + : []), + ...(onDeleteColumn + ? [ + { + label: "Delete column", + icon: , + 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({ + column, + index, + thRefs, + onInsertColumn, + onDeleteColumn, + onEnter, + onLeave, +}: { + column: TableColumn; + 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( +
+ } + items={columnMenuItems(column, index, onInsertColumn, onDeleteColumn)} + /> +
, + document.body, + ); +} + +export function TableHeader({ + 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) { + const hasColumnMenu = !!(onInsertColumn || onDeleteColumn); + const activeIndex = columns.findIndex((c) => c.key === activeColumn); + return ( + <> + {hasColumnMenu && activeColumn && activeIndex >= 0 ? ( + onColumnActivate?.(activeColumn)} + onLeave={() => onColumnDeactivate?.()} + /> + ) : null} + + + {selectable ? ( + +
+ +
+ + ) : null} + {columns.map((column, index) => { + const active = sort?.key === column.key; + const isDragging = dragKey === column.key; + const isActive = activeColumn === column.key; + return ( + { + 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", + )} + > + + {reorderable ? ( + + ) : null} + {column.sortable ? ( + + ) : onColumnRename ? ( + + 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), + )} + /> + ) : ( + + {column.header} + + )} + + {resizable ? ( + + {open && typeof document !== "undefined" + ? createPortal( + <> +
setCoords(null)} + /> + + {items.map((item) => ( + + ))} + + , + document.body, + ) + : null} + + ); +} diff --git a/components/motion/table/types.ts b/components/motion/table/types.ts new file mode 100644 index 0000000..869a4e0 --- /dev/null +++ b/components/motion/table/types.ts @@ -0,0 +1,86 @@ +import type { ReactNode } from "react"; + +export type SortDirection = "asc" | "desc"; + +export type SortState = { + key: string; + direction: SortDirection; +}; + +export type TableColumn = { + /** 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; +}; + +export type InsertPosition = "before" | "after"; + +export interface TableProps { + data: T[]; + columns: TableColumn[]; + /** 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; + className?: string; +} + +/** A data row paired with its stable id. */ +export type TableRow = { row: T; id: string }; + +/** Ref map from column key to its header cell, shared across the resize/reorder hooks. */ +export type HeaderCellRefs = { + current: Record; +}; diff --git a/components/motion/table/use-column-reorder.ts b/components/motion/table/use-column-reorder.ts new file mode 100644 index 0000000..38de263 --- /dev/null +++ b/components/motion/table/use-column-reorder.ts @@ -0,0 +1,106 @@ +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useMemo, + useState, +} from "react"; +import type { HeaderCellRefs, TableColumn } from "./types"; + +export function useColumnReorder({ + columns, + thRefs, + onColumnOrderChange, +}: { + columns: TableColumn[]; + thRefs: HeaderCellRefs; + onColumnOrderChange?: (keys: string[]) => void; +}) { + const [order, setOrder] = useState(() => + columns.map((c) => c.key), + ); + const [dragKey, setDragKey] = useState(null); + const [dropIndex, setDropIndex] = useState(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 => 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], + ); + + const startReorder = useCallback((key: string, e: ReactPointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragKey(key); + e.currentTarget.setPointerCapture(e.pointerId); + }, []); + + const moveReorder = useCallback( + (e: ReactPointerEvent) => { + if (!dragKey) return; + setDropIndex(dropIndexFor(e.clientX)); + }, + [dragKey, dropIndexFor], + ); + + const endReorder = useCallback( + (e: ReactPointerEvent) => { + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(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); + setOrder(without); + onColumnOrderChange?.(without); + } + } + setDragKey(null); + setDropIndex(null); + }, + [dragKey, dropIndex, orderedColumns, onColumnOrderChange], + ); + + return { + orderedColumns, + dragKey, + dropIndex, + startReorder, + moveReorder, + endReorder, + }; +} diff --git a/components/motion/table/use-column-resize.ts b/components/motion/table/use-column-resize.ts new file mode 100644 index 0000000..d60bf7b --- /dev/null +++ b/components/motion/table/use-column-resize.ts @@ -0,0 +1,84 @@ +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useRef, + useState, +} from "react"; +import type { HeaderCellRefs, TableColumn } from "./types"; + +export function useColumnResize({ + orderedColumns, + thRefs, + minColumnWidth, + onColumnResize, +}: { + orderedColumns: TableColumn[]; + 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>({}); + + 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. + setWidths((prev) => { + const snapshot = { ...prev }; + 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], + }; + return snapshot; + }); + e.currentTarget.setPointerCapture(e.pointerId); + }, + [minColumnWidth, orderedColumns, thRefs], + ); + + 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; + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + if (state) { + onColumnResize?.(state.key, widths[state.key] ?? state.startWidth); + } + }, + [onColumnResize, widths], + ); + + return { widths, startResize, moveResize, endResize }; +} diff --git a/components/motion/table/use-column-sort.ts b/components/motion/table/use-column-sort.ts new file mode 100644 index 0000000..ac182cd --- /dev/null +++ b/components/motion/table/use-column-sort.ts @@ -0,0 +1,64 @@ +import { useCallback, useMemo, useState } from "react"; +import type { SortState, TableColumn, TableRow } from "./types"; +import { readSortValue } from "./utils"; + +export function useColumnSort({ + rows, + columns, + sort: sortProp, + defaultSort = null, + onSortChange, +}: { + rows: TableRow[]; + columns: TableColumn[]; + sort?: SortState | null; + defaultSort?: SortState | null; + onSortChange?: (sort: SortState | null) => void; +}) { + const [internalSort, setInternalSort] = useState( + 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 }; +} diff --git a/components/motion/table/use-row-selection.ts b/components/motion/table/use-row-selection.ts new file mode 100644 index 0000000..1739843 --- /dev/null +++ b/components/motion/table/use-row-selection.ts @@ -0,0 +1,59 @@ +import { useCallback, useMemo, useState } from "react"; +import type { TableRow } from "./types"; + +export function useRowSelection({ + sortedRows, + selectedRowIds, + defaultSelectedRowIds, + onSelectionChange, +}: { + sortedRows: TableRow[]; + selectedRowIds?: string[]; + defaultSelectedRowIds?: string[]; + onSelectionChange?: (ids: string[]) => void; +}) { + const [internalSelected, setInternalSelected] = useState>( + () => new Set(defaultSelectedRowIds), + ); + const selected = useMemo( + () => + selectedRowIds !== undefined + ? new Set(selectedRowIds) + : internalSelected, + [selectedRowIds, internalSelected], + ); + + const commit = useCallback( + (next: Set) => { + 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 }; +} diff --git a/components/motion/table/utils.ts b/components/motion/table/utils.ts new file mode 100644 index 0000000..628251f --- /dev/null +++ b/components/motion/table/utils.ts @@ -0,0 +1,33 @@ +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["align"]) { + if (align === "right") return "justify-end"; + if (align === "center") return "justify-center"; + return "justify-start"; +} + +export function alignText(align: TableColumn["align"]) { + if (align === "right") return "text-right"; + if (align === "center") return "text-center"; + return "text-left"; +} + +export function readCell(row: T, column: TableColumn): ReactNode { + if (column.cell) return column.cell(row); + return (row as Record)[column.key]; +} + +export function readSortValue( + row: T, + column: TableColumn, +): string | number { + if (column.sortValue) return column.sortValue(row); + return (row as Record)[column.key]; +} diff --git a/components/previews/index.tsx b/components/previews/index.tsx index a831ee1..9e92be8 100644 --- a/components/previews/index.tsx +++ b/components/previews/index.tsx @@ -39,6 +39,17 @@ export const previews: Record = { (m) => m.PredictionMarketPreview, ), ), + "motion/table": dynamic(() => + import("./motion/table.preview").then((m) => m.TablePreview), + ), + "motion/table-editable": dynamic(() => + import("./motion/table-editable.preview").then( + (m) => m.TableEditablePreview, + ), + ), + "motion/table-async": dynamic(() => + import("./motion/table-async.preview").then((m) => m.TableAsyncPreview), + ), "motion/bouncy-accordion": dynamic(() => import("./motion/bouncy-accordion.preview").then( (m) => m.BouncyAccordionPreview, diff --git a/components/previews/motion/table-async.preview.tsx b/components/previews/motion/table-async.preview.tsx new file mode 100644 index 0000000..befccc0 --- /dev/null +++ b/components/previews/motion/table-async.preview.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Table, type TableColumn } from "@/components/motion/table"; +import { cn } from "@/lib/utils"; + +type Person = { + id: string; + name: string; + email: string; + role: string; + status: "active" | "invited" | "suspended"; + mrr: number; +}; + +const FIRST = ["Ava", "Leo", "Mia", "Kai", "Zoe", "Eli", "Noa", "Ren", "Ivy", "Jude"]; +const LAST = ["Cole", "Frost", "Vale", "Reyes", "Okafor", "Sato", "Lund", "Marsh", "Bose", "Quinn"]; +const ROLES = ["Owner", "Admin", "Member", "Viewer"]; +const STATUSES: Person["status"][] = ["active", "invited", "suspended"]; + +const PAGE_SIZE = 20; +const MAX_PAGES = 8; + +function buildPage(page: number): Person[] { + const out: Person[] = []; + const start = page * PAGE_SIZE; + for (let n = start; n < start + PAGE_SIZE; n++) { + const first = FIRST[n % FIRST.length]; + const last = LAST[(n * 7) % LAST.length]; + out.push({ + id: String(n), + name: `${first} ${last}`, + email: `${first.toLowerCase()}.${last.toLowerCase()}${n}@beui.dev`, + role: ROLES[(n * 3) % ROLES.length], + status: STATUSES[(n * 5) % STATUSES.length], + mrr: 12 + ((n * 37) % 488), + }); + } + return out; +} + +const STATUS_STYLES: Record = { + active: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", + invited: "bg-amber-500/10 text-amber-600 dark:text-amber-400", + suspended: "bg-rose-500/10 text-rose-600 dark:text-rose-400", +}; + +export function TableAsyncPreview() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const pageRef = useRef(0); + const loadingRef = useRef(false); + + const loadMore = useCallback(() => { + if (loadingRef.current || pageRef.current >= MAX_PAGES) return; + loadingRef.current = true; + setLoading(true); + // Simulate a network request. + setTimeout(() => { + const page = pageRef.current; + setRows((prev) => [...prev, ...buildPage(page)]); + pageRef.current = page + 1; + loadingRef.current = false; + setLoading(false); + }, 700); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount + useEffect(() => { + loadMore(); + }, []); + + const columns = useMemo[]>( + () => [ + { + key: "name", + header: "Name", + cell: (r) => {r.name}, + }, + { key: "email", header: "Email", width: "220px" }, + { key: "role", header: "Role", width: "110px" }, + { + key: "status", + header: "Status", + width: "120px", + cell: (r) => ( + + {r.status} + + ), + }, + { + key: "mrr", + header: "MRR", + align: "right", + width: "100px", + cell: (r) => ${r.mrr.toLocaleString()}, + }, + ], + [], + ); + + const done = pageRef.current >= MAX_PAGES; + + return ( +
+
+
+ {rows.length.toLocaleString()} loaded + {loading ? "Loading…" : done ? "All loaded" : "Scroll for more"} +
+ row.id} + height={420} + rowHeight={52} + onEndReached={loadMore} + loading={loading} + className="rounded-2xl" + /> + + + ); +} diff --git a/components/previews/motion/table-editable.preview.tsx b/components/previews/motion/table-editable.preview.tsx new file mode 100644 index 0000000..cf7642e --- /dev/null +++ b/components/previews/motion/table-editable.preview.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { Switch } from "@/components/motion/switch"; +import { Table, type TableColumn } from "@/components/motion/table"; + +type Row = { id: string; [key: string]: string }; + +const INITIAL_ROWS: Row[] = [ + { id: "r1", name: "Ava Cole", role: "Owner", team: "Design" }, + { id: "r2", name: "Leo Frost", role: "Admin", team: "Growth" }, + { id: "r3", name: "Mia Vale", role: "Member", team: "Design" }, + { id: "r4", name: "Kai Reyes", role: "Member", team: "Platform" }, +]; + +export function TableEditablePreview() { + const [rows, setRows] = useState(INITIAL_ROWS); + const [keys, setKeys] = useState(["name", "role", "team"]); + const [labels, setLabels] = useState>({ + name: "Name", + role: "Role", + team: "Team", + }); + const [nextRow, setNextRow] = useState(5); + const [nextCol, setNextCol] = useState(1); + const [editable, setEditable] = useState(true); + + const onCellEdit = useCallback( + (rowId: string, key: string, value: string) => { + setRows((prev) => + prev.map((row) => (row.id === rowId ? { ...row, [key]: value } : row)), + ); + }, + [], + ); + + const onInsertRow = useCallback( + (index: number, position: "before" | "after") => { + const at = position === "after" ? index + 1 : index; + setRows((prev) => { + const next = [...prev]; + next.splice(at, 0, { id: `r${nextRow}` }); + return next; + }); + setNextRow((n) => n + 1); + }, + [nextRow], + ); + + const onDeleteRow = useCallback((rowId: string) => { + setRows((prev) => prev.filter((row) => row.id !== rowId)); + }, []); + + const onInsertColumn = useCallback( + (index: number, position: "before" | "after") => { + const key = `field${nextCol}`; + const at = position === "after" ? index + 1 : index; + setLabels((prev) => ({ ...prev, [key]: `Field ${nextCol}` })); + setKeys((prev) => { + const next = [...prev]; + next.splice(at, 0, key); + return next; + }); + setRows((prev) => prev.map((row) => ({ ...row, [key]: "" }))); + setNextCol((n) => n + 1); + }, + [nextCol], + ); + + const onColumnRename = useCallback((key: string, value: string) => { + setLabels((prev) => ({ ...prev, [key]: value })); + }, []); + + const onDeleteColumn = useCallback((key: string) => { + setKeys((prev) => prev.filter((k) => k !== key)); + setRows((prev) => + prev.map((row) => { + const next = { ...row }; + delete next[key]; + return next; + }), + ); + }, []); + + const columns = useMemo[]>( + () => + keys.map((key, i) => ({ + key, + header: labels[key] ?? key, + editable, + width: i === 0 ? undefined : "180px", + })), + [keys, labels, editable], + ); + + const bodyHeight = Math.min(Math.max(rows.length, 1), 6) * 48; + + return ( +
+
+

+ {editable + ? "Click a cell to edit. Use the column and row handles to insert or delete." + : "Read-only."} +

+ +
+
row.id} + rowHeight={48} + height={bodyHeight} + onCellEdit={editable ? onCellEdit : undefined} + onColumnRename={editable ? onColumnRename : undefined} + onInsertRow={editable ? onInsertRow : undefined} + onDeleteRow={editable ? onDeleteRow : undefined} + onInsertColumn={editable ? onInsertColumn : undefined} + onDeleteColumn={editable ? onDeleteColumn : undefined} + emptyState={ + + } + /> + + ); +} diff --git a/components/previews/motion/table.preview.tsx b/components/previews/motion/table.preview.tsx new file mode 100644 index 0000000..dc08497 --- /dev/null +++ b/components/previews/motion/table.preview.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Table, type TableColumn } from "@/components/motion/table"; +import { cn } from "@/lib/utils"; + +type Person = { + id: string; + name: string; + email: string; + role: string; + status: "active" | "invited" | "suspended"; + mrr: number; +}; + +const FIRST = [ + "Ava", + "Leo", + "Mia", + "Kai", + "Zoe", + "Eli", + "Noa", + "Ren", + "Ivy", + "Jude", +]; +const LAST = [ + "Cole", + "Frost", + "Vale", + "Reyes", + "Okafor", + "Sato", + "Lund", + "Marsh", + "Bose", + "Quinn", +]; +const ROLES = ["Owner", "Admin", "Member", "Viewer"]; +const STATUSES: Person["status"][] = ["active", "invited", "suspended"]; + +// Deterministic so SSR and client render the same rows (no hydration drift). +function buildPeople(count: number): Person[] { + const out: Person[] = []; + for (let i = 0; i < count; i++) { + const first = FIRST[i % FIRST.length]; + const last = LAST[(i * 7) % LAST.length]; + out.push({ + id: String(i), + name: `${first} ${last}`, + email: `${first.toLowerCase()}.${last.toLowerCase()}${i}@beui.dev`, + role: ROLES[(i * 3) % ROLES.length], + status: STATUSES[(i * 5) % STATUSES.length], + mrr: 12 + ((i * 37) % 488), + }); + } + return out; +} + +const STATUS_STYLES: Record = { + active: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", + invited: "bg-amber-500/10 text-amber-600 dark:text-amber-400", + suspended: "bg-rose-500/10 text-rose-600 dark:text-rose-400", +}; + +function StatusBadge({ status }: { status: Person["status"] }) { + return ( + + {status} + + ); +} + +export function TablePreview() { + const data = useMemo(() => buildPeople(10_000), []); + const [selected, setSelected] = useState([]); + + const columns = useMemo[]>( + () => [ + { + key: "name", + header: "Name", + sortable: true, + width: "1.4fr", + cell: (row) => {row.name}, + }, + { key: "email", header: "Email", width: "1.8fr" }, + { key: "role", header: "Role", sortable: true, width: "120px" }, + { + key: "status", + header: "Status", + width: "130px", + cell: (row) => , + }, + { + key: "mrr", + header: "MRR", + sortable: true, + align: "right", + width: "110px", + cell: (row) => ( + ${row.mrr.toLocaleString()} + ), + }, + ], + [], + ); + + return ( +
+
+
+ {data.length.toLocaleString()} rows + {selected.length > 0 ? ( + {selected.length.toLocaleString()} selected + ) : null} +
+
+ + + ); +} diff --git a/lib/registry.ts b/lib/registry.ts index 3c1536f..41cb29a 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -395,6 +395,54 @@ export const registry: CategoryEntry[] = [ badge: "new", keywords: ["slider", "range slider", "range input", "stepped slider", "ticks"], }, + { + slug: "table", + name: "Table", + description: + "Virtualized data table that stays smooth at 10k+ rows, with sortable headers, row selection, column resize and reorder, and a sticky header. Minimal, reduced-motion-safe motion.", + file: "components/motion/table/index.tsx", + badge: "new", + keywords: [ + "react data table", + "virtualized table", + "sortable table", + "table row selection", + "react table 10k rows", + "editable table react", + ], + examples: [ + { + slug: "data", + name: "Data Table", + description: + "10k virtualized rows with sortable headers, row selection, column resize and reorder.", + installSlug: "table", + file: "components/motion/table/index.tsx", + previewKey: "motion/table", + previewFile: "components/previews/motion/table.preview.tsx", + }, + { + slug: "editable", + name: "Editable Table", + description: + "Edit cells inline and insert or delete rows and columns via border handles; the table re-renders from the updated data and column defs.", + installSlug: "table-editable", + file: "components/motion/table/index.tsx", + previewKey: "motion/table-editable", + previewFile: "components/previews/motion/table-editable.preview.tsx", + }, + { + slug: "async", + name: "Async Table", + description: + "Loads pages on demand — skeleton rows on first load, then infinite scroll via onEndReached as the virtualized list nears the bottom.", + installSlug: "table-async", + file: "components/motion/table/index.tsx", + previewKey: "motion/table-async", + previewFile: "components/previews/motion/table-async.preview.tsx", + }, + ], + }, ], }, { diff --git a/package.json b/package.json index 96450b6..b5ed4cd 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@shikijs/transformers": "^4.2.0", + "@tanstack/react-virtual": "^3.14.4", "@vercel/analytics": "^1.4.1", "@vercel/speed-insights": "^1.1.0", "clsx": "^2.1.1",