import React from 'react'; import { Surface, cn, surfaceClasses, useEffectiveSurface, focusRing, } from '../common'; /* ───────────────────────────────────────────────────────────────────────── PixelTable — data table with striped rows + hover highlight. Upgraded (Ola 4a) additively: optional column.sortable/align/render/width, controlled sort with header-button + aria-sort, single/multi selection with checkbox column, sticky header, sticky first column, skeleton loading rows, empty state, row click handler, density scale. Legacy API (columns:{key,header,className?} + data:Record) keeps rendering exactly as before. ───────────────────────────────────────────────────────────────────────── */ export type PixelTableDensity = 'compact' | 'normal' | 'comfortable'; export type PixelTableSortDir = 'asc' | 'desc'; export type PixelTableAlign = 'left' | 'center' | 'right'; export type PixelTableSelection = 'single' | 'multi'; export interface PixelTableColumn> { /** Stable column id; also the lookup key into a row when `render` is absent. */ key: string; /** Header cell content. */ header: React.ReactNode; /** Extra class names applied to header + body cells in this column. */ className?: string; /** When true, header renders a sort button with aria-sort. Requires `onSortChange`. */ sortable?: boolean; /** Text alignment for header + body cells. */ align?: PixelTableAlign; /** Pixel width or CSS length. */ width?: number | string; /** Custom cell renderer. Overrides the `row[key]` lookup. */ render?: (row: Row, idx: number) => React.ReactNode; } export interface PixelTableSortState { key: string; dir: PixelTableSortDir; } export interface PixelTableProps> { /** Column definitions. */ columns: Array>; /** Row data. */ data: Row[]; /** Alternate-row tint. Defaults to `true`. */ striped?: boolean; /** Visual surface override. */ surface?: Surface; /** Controlled sort state. */ sort?: PixelTableSortState; /** Called when a sortable header is clicked. */ onSortChange?: (next: PixelTableSortState) => void; /** Enables a leading checkbox column with single- or multi-row selection. */ selection?: PixelTableSelection; /** Controlled list of selected row ids. */ selectedIds?: string[]; /** Called when selection changes. */ onSelectionChange?: (next: string[]) => void; /** Resolves a stable id per row. Falls back to `row.id` then to the index. */ getRowId?: (row: Row, idx: number) => string; /** Sticks the header row to the top of the scroll container. */ stickyHeader?: boolean; /** Sticks the first column to the left of the scroll container. */ stickyFirstColumn?: boolean; /** Renders skeleton rows instead of data. */ loading?: boolean; /** Rendered inside a full-width cell when `data` is empty. */ emptyState?: React.ReactNode; /** Called when a body row is clicked. Renders the row with `cursor-pointer`. */ onRowClick?: (row: Row, idx: number) => void; /** Cell padding scale. Defaults to `'normal'`. */ density?: PixelTableDensity; /** Render with surface-aware border + radius chrome. Defaults to true — tables need visible chrome. */ bordered?: boolean; } const tableCellPad: Record = { compact: 'px-3 py-1', normal: 'px-4 py-2.5', comfortable: 'px-4 py-4', }; const alignClass: Record = { left: 'text-left', center: 'text-center', right: 'text-right', }; function PixelTableSortIcons({ dir }: { dir: PixelTableSortDir | null }) { return ( ); } export function PixelTable>({ columns, data, striped = true, surface: surfaceProp, sort, onSortChange, selection, selectedIds, onSelectionChange, getRowId, stickyHeader, stickyFirstColumn, loading, emptyState, onRowClick, density = 'normal', bordered = true, }: PixelTableProps) { const surface = useEffectiveSurface(surfaceProp); const s = surfaceClasses(surface); const padCls = tableCellPad[density]; const hasSelection = selection !== undefined; // Internal selection fallback for uncontrolled mode. const [internalSelected, setInternalSelected] = React.useState([]); const effectiveSelectedIds = selectedIds ?? internalSelected; const selectedSet = React.useMemo(() => new Set(effectiveSelectedIds), [effectiveSelectedIds]); // Internal sort fallback. Active iff a sortable column exists. Headers always // render a button when col.sortable is true (no more onSortChange gate). const [internalSort, setInternalSort] = React.useState(undefined); const effectiveSort = sort ?? internalSort; const commitSelection = (next: string[]) => { if (selectedIds === undefined) setInternalSelected(next); onSelectionChange?.(next); }; const resolveRowId = React.useCallback( (row: Row, idx: number): string => { if (getRowId) return getRowId(row, idx); const r = row as unknown as Record; if (r && typeof r === 'object' && r.id != null) return String(r.id); return String(idx); }, [getRowId], ); const totalColCount = columns.length + (hasSelection ? 1 : 0); // Apply sort to rows when a sortable column is active. We sort by the raw // row[key] value (numbers compare numerically, otherwise localeCompare). // Original order preserved when no sort is active. const sortedData = React.useMemo(() => { if (!effectiveSort) return data; const col = columns.find((c) => c.key === effectiveSort.key); if (!col || !col.sortable) return data; const indexed = data.map((row, idx) => ({ row, idx })); indexed.sort((a, b) => { const av = (a.row as unknown as Record)[effectiveSort.key]; const bv = (b.row as unknown as Record)[effectiveSort.key]; let cmp = 0; if (av == null && bv == null) cmp = 0; else if (av == null) cmp = -1; else if (bv == null) cmp = 1; else if (typeof av === 'number' && typeof bv === 'number') cmp = av - bv; else cmp = String(av).localeCompare(String(bv), undefined, { numeric: true }); return effectiveSort.dir === 'asc' ? cmp : -cmp; }); return indexed.map((i) => i.row); }, [data, columns, effectiveSort]); const allRowIds = React.useMemo( () => sortedData.map((row, idx) => resolveRowId(row, idx)), [sortedData, resolveRowId], ); const allSelected = hasSelection && allRowIds.length > 0 && allRowIds.every((id) => selectedSet.has(id)); const someSelected = hasSelection && !allSelected && allRowIds.some((id) => selectedSet.has(id)); const headerCheckboxRef = React.useRef(null); React.useEffect(() => { if (headerCheckboxRef.current) { headerCheckboxRef.current.indeterminate = someSelected; } }, [someSelected]); const toggleAll = () => { if (!hasSelection || selection !== 'multi') return; commitSelection(allSelected ? [] : allRowIds); }; const toggleOne = (id: string) => { if (!hasSelection) return; if (selection === 'single') { commitSelection(selectedSet.has(id) ? [] : [id]); return; } const next = new Set(selectedSet); if (next.has(id)) next.delete(id); else next.add(id); commitSelection(Array.from(next)); }; const handleSortClick = (col: PixelTableColumn) => { if (!col.sortable) return; const isActive = effectiveSort?.key === col.key; const nextDir: PixelTableSortDir = isActive && effectiveSort?.dir === 'asc' ? 'desc' : 'asc'; const next: PixelTableSortState = { key: col.key, dir: nextDir }; if (sort === undefined) setInternalSort(next); onSortChange?.(next); }; const stickyFirstHeadCls = stickyFirstColumn ? 'sticky left-0 z-20 bg-retro-surface/80 backdrop-blur-sm' : ''; const stickyFirstBodyCls = stickyFirstColumn ? 'sticky left-0 z-10 bg-retro-bg' : ''; const renderCellContent = (col: PixelTableColumn, row: Row, idx: number): React.ReactNode => { if (col.render) return col.render(row, idx); const r = row as unknown as Record; return r?.[col.key] ?? ''; }; return (
{hasSelection && ( )} {columns.map((col, colIdx) => { const isSorted = effectiveSort?.key === col.key; const ariaSort: React.AriaAttributes['aria-sort'] = col.sortable ? (isSorted ? (effectiveSort!.dir === 'asc' ? 'ascending' : 'descending') : 'none') : undefined; const headerStyle: React.CSSProperties | undefined = col.width != null ? { width: typeof col.width === 'number' ? `${col.width}px` : col.width } : undefined; const headerStickyCls = stickyFirstColumn && !hasSelection && colIdx === 0 ? stickyFirstHeadCls : ''; return ( ); })} {loading && ( )} {loading ? ( Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: totalColCount }).map((_, j) => ( ))} )) ) : sortedData.length === 0 ? ( ) : ( sortedData.map((row, idx) => { const rowId = resolveRowId(row, idx); const isSelected = hasSelection && selectedSet.has(rowId); return ( onRowClick(row, idx) : undefined} className={cn( 'border-b border-retro-border/20 transition-colors hover:bg-retro-surface/30', striped && idx % 2 === 1 && 'bg-retro-surface/15', onRowClick && 'cursor-pointer', isSelected && 'bg-retro-surface/40', )} > {hasSelection && ( )} {columns.map((col, colIdx) => { const cellStickyCls = stickyFirstColumn && !hasSelection && colIdx === 0 ? stickyFirstBodyCls : ''; const cellStyle: React.CSSProperties | undefined = col.width != null ? { width: typeof col.width === 'number' ? `${col.width}px` : col.width } : undefined; return ( ); })} ); }) )}
{selection === 'multi' ? ( ) : ( Select )} {col.sortable ? ( ) : ( col.header )}
Loading data…
{emptyState ?? No data.}
e.stopPropagation()} > toggleOne(rowId)} className={cn('h-4 w-4', focusRing)} /> {renderCellContent(col, row, idx)}
); }