Ran the official @tailwindcss/upgrade tool: - tailwind.config.ts → @theme directive in globals.css - @tailwind base/components/utilities → @import 'tailwindcss' - postcss.config switched from tailwindcss + autoprefixer to @tailwindcss/postcss (autoprefixer baked in) - focus-visible:outline-none → focus-visible:outline-hidden (the v3 utility was a footgun — outline still showed in forced-colors mode) Reverted the migration tool's over-zealous variant="outline" → variant="outline-solid" rename on CVA prop values; that rename was meant for the Tailwind `outline:` utility, not our Button/Badge component variants. Swapped tailwindcss-animate (v3-style JS plugin) for tw-animate-css (v4-native @import). Same utility surface (animate-spin, animate-in, etc.), one fewer JS plugin in the bundle. Fixed the upgrade tool's malformed dark variant (@custom-variant dark (&:is(class *)) — `class` was being parsed as a tag) to canonical &:where(.dark, .dark *). Verified: tsc 0 errors, eslint 0 errors (16 pre-existing warnings), vitest 1315/1315, next build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
467 lines
18 KiB
TypeScript
467 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo, useRef, useState } from 'react';
|
|
import {
|
|
flexRender,
|
|
getCoreRowModel,
|
|
useReactTable,
|
|
type ColumnDef,
|
|
type Row,
|
|
type RowSelectionState,
|
|
type VisibilityState,
|
|
} from '@tanstack/react-table';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import { ArrowDown, ArrowUp, ArrowUpDown, Loader2 } from 'lucide-react';
|
|
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export interface DataTablePagination {
|
|
page: number;
|
|
pageSize: number;
|
|
total: number;
|
|
totalPages: number;
|
|
}
|
|
|
|
export interface BulkAction {
|
|
label: string;
|
|
icon?: React.ElementType;
|
|
variant?: 'default' | 'destructive';
|
|
onClick: (selectedIds: string[]) => void;
|
|
}
|
|
|
|
interface DataTableProps<TData> {
|
|
columns: ColumnDef<TData, unknown>[];
|
|
data: TData[];
|
|
pagination?: DataTablePagination;
|
|
onPaginationChange?: (page: number, pageSize: number) => void;
|
|
sort?: { field: string; direction: 'asc' | 'desc' };
|
|
onSortChange?: (field: string, direction: 'asc' | 'desc') => void;
|
|
rowSelection?: RowSelectionState;
|
|
onRowSelectionChange?: (selection: RowSelectionState) => void;
|
|
bulkActions?: BulkAction[];
|
|
emptyState?: React.ReactNode;
|
|
isLoading?: boolean;
|
|
getRowId?: (row: TData) => string;
|
|
onRowClick?: (row: TData) => void;
|
|
/**
|
|
* Optional row class hook — return a string of Tailwind utilities
|
|
* applied to the `<TableRow>`. Use for visual grouping (e.g. tinting
|
|
* berths by mooring letter so the kanban-like grid reads at a glance).
|
|
*/
|
|
getRowClassName?: (row: TData) => string | undefined;
|
|
/**
|
|
* Mobile card renderer. When provided, the table is hidden below `lg:`
|
|
* and replaced with a vertical list of cards built from this callback.
|
|
* The same TanStack `table` instance powers both views, so pagination,
|
|
* sort, and selection stay in sync across the breakpoint.
|
|
*/
|
|
cardRender?: (row: Row<TData>) => React.ReactNode;
|
|
/**
|
|
* Optional grouping key for the mobile card list. When set, consecutive
|
|
* rows that share the same returned key are visually grouped under a
|
|
* header showing the key. Rendered only on mobile (next to cardRender);
|
|
* the desktop table is unaffected. Useful for berths-by-area,
|
|
* documents-by-folder, etc. — pre-sort the data on the same key so
|
|
* adjacent rows already share groups.
|
|
*/
|
|
mobileGroupBy?: (row: TData) => string | null | undefined;
|
|
/**
|
|
* Per-column visibility map. Keys are column IDs, values mean
|
|
* "currently visible". Columns absent from the map are visible by
|
|
* default — newly-added columns surface for existing users without
|
|
* needing a preferences migration.
|
|
*/
|
|
columnVisibility?: VisibilityState;
|
|
/**
|
|
* Opt-in row virtualization. Only renders rows in the viewport (plus a
|
|
* small overscan), so a 5000-row client-export list stays at 60 fps.
|
|
* The virtualizer uses the surrounding scroll container — set
|
|
* `virtualHeightPx` (default 600) so we know the visible area at mount.
|
|
*
|
|
* Off by default because most CRM tables are server-paginated to 20-50
|
|
* rows where virtualization is pure overhead. Pass `virtual` only for
|
|
* tables that legitimately hold hundreds-to-thousands of rows in memory.
|
|
*
|
|
* Constraints:
|
|
* - Pagination is incompatible with `virtual` (virtualize ALL rows or
|
|
* paginate — pick one). When both are passed, pagination wins and
|
|
* virtualization is silently disabled.
|
|
* - Mobile card view is unaffected; virtualization only applies to the
|
|
* desktop `<Table>` rendering at lg: and up.
|
|
*/
|
|
virtual?: boolean;
|
|
/** Container pixel height when `virtual` is on. Defaults to 600. */
|
|
virtualHeightPx?: number;
|
|
/** Pixel height of an average row (used for virtualizer sizing). Defaults
|
|
* to 48, which matches the Tailwind h-12 cell height shadcn Table uses. */
|
|
virtualRowHeightPx?: number;
|
|
}
|
|
|
|
export function DataTable<TData>({
|
|
columns,
|
|
data,
|
|
pagination,
|
|
onPaginationChange,
|
|
sort,
|
|
onSortChange,
|
|
rowSelection: externalSelection,
|
|
onRowSelectionChange,
|
|
bulkActions,
|
|
emptyState,
|
|
isLoading,
|
|
getRowId,
|
|
onRowClick,
|
|
getRowClassName,
|
|
cardRender,
|
|
mobileGroupBy,
|
|
columnVisibility,
|
|
virtual,
|
|
virtualHeightPx = 600,
|
|
virtualRowHeightPx = 48,
|
|
}: DataTableProps<TData>) {
|
|
const [internalSelection, setInternalSelection] = useState<RowSelectionState>({});
|
|
const rowSelectionState = externalSelection ?? internalSelection;
|
|
const setRowSelection = onRowSelectionChange ?? setInternalSelection;
|
|
|
|
// Memoize the assembled columns array. perf-test-auditor HIGH H2:
|
|
// TanStack docs explicitly warn that rebuilding `columns` on every
|
|
// render resets the table's internal state (sort, filter, sizing).
|
|
// Re-derive only when the source columns or bulkActions presence
|
|
// actually change.
|
|
const hasBulkActions = Boolean(bulkActions && bulkActions.length > 0);
|
|
const allColumns = useMemo<ColumnDef<TData, unknown>[]>(() => {
|
|
const cols: ColumnDef<TData, unknown>[] = [];
|
|
if (hasBulkActions) {
|
|
cols.push({
|
|
id: 'select',
|
|
header: ({ table }) => (
|
|
<Checkbox
|
|
checked={
|
|
table.getIsAllPageRowsSelected() ||
|
|
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
|
}
|
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
|
aria-label="Select all"
|
|
className="translate-y-[2px]"
|
|
/>
|
|
),
|
|
cell: ({ row }) => (
|
|
<Checkbox
|
|
checked={row.getIsSelected()}
|
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
|
aria-label="Select row"
|
|
className="translate-y-[2px]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
),
|
|
enableSorting: false,
|
|
size: 40,
|
|
});
|
|
}
|
|
cols.push(...columns);
|
|
return cols;
|
|
}, [columns, hasBulkActions]);
|
|
|
|
const table = useReactTable({
|
|
data,
|
|
columns: allColumns,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
manualPagination: true,
|
|
manualSorting: true,
|
|
rowCount: pagination?.total ?? data.length,
|
|
state: {
|
|
rowSelection: rowSelectionState,
|
|
pagination: pagination
|
|
? { pageIndex: pagination.page - 1, pageSize: pagination.pageSize }
|
|
: undefined,
|
|
columnVisibility,
|
|
},
|
|
onRowSelectionChange: (updater) => {
|
|
const newSelection = typeof updater === 'function' ? updater(rowSelectionState) : updater;
|
|
setRowSelection(newSelection);
|
|
},
|
|
getRowId: getRowId as (row: TData, index: number) => string,
|
|
enableRowSelection: !!bulkActions?.length,
|
|
});
|
|
|
|
const selectedIds = Object.keys(rowSelectionState).filter((k) => rowSelectionState[k]);
|
|
|
|
function handleSort(columnId: string) {
|
|
if (!onSortChange) return;
|
|
if (sort?.field === columnId) {
|
|
onSortChange(columnId, sort.direction === 'asc' ? 'desc' : 'asc');
|
|
} else {
|
|
onSortChange(columnId, 'asc');
|
|
}
|
|
}
|
|
|
|
function getSortIcon(columnId: string) {
|
|
if (sort?.field !== columnId) return <ArrowUpDown className="ml-1 h-3.5 w-3.5" />;
|
|
return sort.direction === 'asc' ? (
|
|
<ArrowUp className="ml-1 h-3.5 w-3.5" />
|
|
) : (
|
|
<ArrowDown className="ml-1 h-3.5 w-3.5" />
|
|
);
|
|
}
|
|
|
|
const rows = table.getRowModel().rows;
|
|
|
|
// Virtualization gate — pagination wins over virtual (you can't do both).
|
|
const virtualEnabled = Boolean(virtual) && !pagination;
|
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const virtualizer = useVirtualizer({
|
|
count: virtualEnabled ? rows.length : 0,
|
|
getScrollElement: () => scrollContainerRef.current,
|
|
estimateSize: () => virtualRowHeightPx,
|
|
overscan: 8,
|
|
});
|
|
const virtualRows = virtualEnabled ? virtualizer.getVirtualItems() : [];
|
|
const virtualPaddingTop = virtualRows[0]?.start ?? 0;
|
|
const virtualPaddingBottom = virtualEnabled
|
|
? virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0)
|
|
: 0;
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div
|
|
ref={virtualEnabled ? scrollContainerRef : undefined}
|
|
style={virtualEnabled ? { height: virtualHeightPx, overflow: 'auto' } : undefined}
|
|
className={cn('rounded-md border overflow-x-auto', cardRender && 'hidden lg:block')}
|
|
>
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10 bg-muted/50">
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<TableRow key={headerGroup.id}>
|
|
{headerGroup.headers.map((header) => (
|
|
<TableHead
|
|
key={header.id}
|
|
style={{ width: header.getSize() !== 150 ? header.getSize() : undefined }}
|
|
className={cn(
|
|
header.column.getCanSort() && onSortChange && 'cursor-pointer select-none',
|
|
)}
|
|
onClick={() => {
|
|
if (
|
|
header.column.getCanSort() &&
|
|
onSortChange &&
|
|
header.column.id !== 'select'
|
|
) {
|
|
handleSort(header.column.id);
|
|
}
|
|
}}
|
|
>
|
|
<div className="flex items-center">
|
|
{header.isPlaceholder
|
|
? null
|
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
{header.column.getCanSort() &&
|
|
onSortChange &&
|
|
header.column.id !== 'select' &&
|
|
header.column.id !== 'actions' &&
|
|
getSortIcon(header.column.id)}
|
|
</div>
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableHeader>
|
|
<TableBody>
|
|
{isLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={allColumns.length} className="h-40 text-center">
|
|
<div className="flex items-center justify-center gap-2">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
<span className="text-muted-foreground">Loading...</span>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : table.getRowModel().rows.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={allColumns.length} className="h-40">
|
|
{emptyState ?? (
|
|
<div className="text-center text-muted-foreground">No results.</div>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : virtualEnabled ? (
|
|
<>
|
|
{virtualPaddingTop > 0 ? (
|
|
<TableRow style={{ height: virtualPaddingTop }}>
|
|
<TableCell colSpan={allColumns.length} className="p-0" />
|
|
</TableRow>
|
|
) : null}
|
|
{virtualRows.map((vRow) => {
|
|
const row = rows[vRow.index]!;
|
|
return (
|
|
<TableRow
|
|
key={row.id}
|
|
data-state={row.getIsSelected() && 'selected'}
|
|
className={cn(
|
|
onRowClick && 'cursor-pointer',
|
|
getRowClassName?.(row.original),
|
|
)}
|
|
onClick={() => onRowClick?.(row.original)}
|
|
>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<TableCell key={cell.id}>
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
);
|
|
})}
|
|
{virtualPaddingBottom > 0 ? (
|
|
<TableRow style={{ height: virtualPaddingBottom }}>
|
|
<TableCell colSpan={allColumns.length} className="p-0" />
|
|
</TableRow>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
rows.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
data-state={row.getIsSelected() && 'selected'}
|
|
className={cn(onRowClick && 'cursor-pointer', getRowClassName?.(row.original))}
|
|
onClick={() => onRowClick?.(row.original)}
|
|
>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<TableCell key={cell.id}>
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* Mobile card list */}
|
|
{cardRender && (
|
|
<ul className="lg:hidden flex flex-col gap-2">
|
|
{isLoading ? (
|
|
<li className="rounded-md border bg-card p-6 text-center">
|
|
<Loader2 className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
|
|
</li>
|
|
) : rows.length === 0 ? (
|
|
<li className="rounded-md border bg-card p-6 text-center text-sm text-muted-foreground">
|
|
{emptyState ?? 'No results.'}
|
|
</li>
|
|
) : (
|
|
(() => {
|
|
// Walk rows once, emitting a section header <li> every time
|
|
// the groupBy key changes. Keeps the existing flex-col gap-2
|
|
// rhythm; the header sits above the first card of each group
|
|
// with a faint top divider for visual rest between blocks.
|
|
let lastGroup: string | null | undefined;
|
|
const nodes: React.ReactNode[] = [];
|
|
rows.forEach((row, i) => {
|
|
const group = mobileGroupBy ? mobileGroupBy(row.original) : undefined;
|
|
if (mobileGroupBy && group !== lastGroup) {
|
|
nodes.push(
|
|
<li key={`__group_${group ?? '_none'}_${i}`} className="px-1 pt-3">
|
|
<div className="flex items-center gap-3 text-base font-bold tracking-tight text-foreground">
|
|
<span>{group ?? 'Other'}</span>
|
|
<span aria-hidden className="h-px flex-1 bg-border" />
|
|
</div>
|
|
</li>,
|
|
);
|
|
lastGroup = group;
|
|
}
|
|
nodes.push(<li key={row.id}>{cardRender(row)}</li>);
|
|
});
|
|
return nodes;
|
|
})()
|
|
)}
|
|
</ul>
|
|
)}
|
|
|
|
{/* Pagination — render whenever pagination is defined so the
|
|
page-size selector is reachable even on single-page tables.
|
|
Prev/Next group only renders when there's actually more than
|
|
one page. */}
|
|
{pagination && (
|
|
<div className="flex items-center justify-between px-2 gap-3 flex-wrap">
|
|
<div className="text-sm text-muted-foreground">
|
|
{selectedIds.length > 0
|
|
? `${selectedIds.length} of ${pagination.total} row(s) selected`
|
|
: `${pagination.total} row(s) total`}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{/* Page-size selector — "All" maps to the validator's
|
|
max(1000) cap. If a port has more than 1000 active rows
|
|
the user paginates; we don't quietly drop rows. */}
|
|
<label className="text-sm text-muted-foreground inline-flex items-center gap-1.5">
|
|
Show
|
|
<select
|
|
value={String(pagination.pageSize)}
|
|
onChange={(e) => {
|
|
const next = e.target.value === 'all' ? 1000 : Number(e.target.value);
|
|
onPaginationChange?.(1, next);
|
|
}}
|
|
className="h-8 rounded-md border border-input bg-background px-2 text-sm focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
|
|
>
|
|
<option value="25">25</option>
|
|
<option value="50">50</option>
|
|
<option value="100">100</option>
|
|
<option value="250">250</option>
|
|
<option value="all">All</option>
|
|
</select>
|
|
</label>
|
|
{pagination.totalPages > 1 && (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={pagination.page <= 1}
|
|
onClick={() => onPaginationChange?.(pagination.page - 1, pagination.pageSize)}
|
|
>
|
|
Previous
|
|
</Button>
|
|
<span className="text-sm text-muted-foreground">
|
|
Page {pagination.page} of {pagination.totalPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={pagination.page >= pagination.totalPages}
|
|
onClick={() => onPaginationChange?.(pagination.page + 1, pagination.pageSize)}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Bulk actions bar */}
|
|
{bulkActions && selectedIds.length > 0 && (
|
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-lg border bg-background px-4 py-3 shadow-lg animate-in slide-in-from-bottom-4">
|
|
<span className="text-sm font-medium">{selectedIds.length} selected</span>
|
|
{bulkActions.map((action) => (
|
|
<Button
|
|
key={action.label}
|
|
variant={action.variant ?? 'default'}
|
|
size="sm"
|
|
onClick={() => action.onClick(selectedIds)}
|
|
>
|
|
{action.icon && <action.icon className="mr-1.5 h-4 w-4" />}
|
|
{action.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|