318 lines
12 KiB
JavaScript
318 lines
12 KiB
JavaScript
import * as React from 'react';
|
|
import { ArrowDown, ArrowUp, ChevronsUpDown, Inbox } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Surface } from '@/components/ds/Surface';
|
|
import { EmptyState } from '@/components/ds/EmptyState';
|
|
import { SkeletonTable } from '@/components/ds/Skeleton';
|
|
import { LoadingOverlay } from '@/components/ds/LoadingState';
|
|
import { Pagination } from '@/components/ds/Pagination';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { usePreferences } from '@/lib/krowHooks';
|
|
|
|
/**
|
|
* DataTable — the app's one table.
|
|
*
|
|
* Columns are declared as data:
|
|
*
|
|
* { key, header, accessor?, sortValue?, cell?, align?, width?, sortable?, hideBelow? }
|
|
*
|
|
* Sorting, selection and pagination are handled internally by default, but each
|
|
* can be lifted by passing the corresponding value + handler — so the same
|
|
* component serves a simple static list and a server-paginated view.
|
|
*
|
|
* `hideBelow: 'md'` drops a column on small screens, which is what keeps the
|
|
* table usable on mobile without a horizontal scroll for every view.
|
|
*/
|
|
export function DataTable({
|
|
columns = [],
|
|
rows = [],
|
|
/** Stable row identity. Falls back to `row.id`, then the index. */
|
|
getRowId = (row, i) => row?.id ?? i,
|
|
|
|
loading = false,
|
|
/** Dims existing rows instead of replacing them — for refetches. */
|
|
refreshing = false,
|
|
|
|
/* Sorting — omit `sort` to let the table own it */
|
|
sort: controlledSort,
|
|
onSortChange,
|
|
defaultSort,
|
|
|
|
/* Selection — pass `selectedIds` to enable */
|
|
selectedIds,
|
|
onSelectionChange,
|
|
|
|
/* Pagination — pass `pageSize` to enable */
|
|
pageSize: initialPageSize,
|
|
page: controlledPage,
|
|
onPageChange,
|
|
totalItems,
|
|
pageSizeOptions,
|
|
|
|
onRowClick,
|
|
emptyState,
|
|
/** True when filters are active, so the empty copy can say so. */
|
|
isFiltered = false,
|
|
onClearFilters,
|
|
|
|
caption,
|
|
className,
|
|
rowClassName,
|
|
stickyHeader = false,
|
|
}) {
|
|
/* ── Sorting ─────────────────────────────────────────────────────────── */
|
|
/* Row height follows the account's density preference. Only the vertical
|
|
padding changes — same columns, same type, same behaviour. */
|
|
const { compactDensity: compact } = usePreferences();
|
|
const cellPad = compact ? 'py-1.5' : 'py-3';
|
|
|
|
const [internalSort, setInternalSort] = React.useState(defaultSort ?? null);
|
|
const sort = controlledSort !== undefined ? controlledSort : internalSort;
|
|
|
|
const setSort = (next) => {
|
|
if (onSortChange) onSortChange(next);
|
|
if (controlledSort === undefined) setInternalSort(next);
|
|
};
|
|
|
|
const toggleSort = (column) => {
|
|
if (sort?.key !== column.key) return setSort({ key: column.key, direction: 'asc' });
|
|
if (sort.direction === 'asc') return setSort({ key: column.key, direction: 'desc' });
|
|
// Third click clears sorting, so the original order is reachable.
|
|
return setSort(null);
|
|
};
|
|
|
|
const valueFor = (column, row) => {
|
|
if (column.sortValue) return column.sortValue(row);
|
|
if (column.accessor) return column.accessor(row);
|
|
return row?.[column.key];
|
|
};
|
|
|
|
const sortedRows = React.useMemo(() => {
|
|
// A controlled `sort` means the parent already ordered the rows.
|
|
if (!sort || controlledSort !== undefined) return rows;
|
|
const column = columns.find((c) => c.key === sort.key);
|
|
if (!column) return rows;
|
|
|
|
const factor = sort.direction === 'desc' ? -1 : 1;
|
|
return [...rows].sort((a, b) => {
|
|
const av = valueFor(column, a);
|
|
const bv = valueFor(column, b);
|
|
if (av == null && bv == null) return 0;
|
|
if (av == null) return 1;
|
|
if (bv == null) return -1;
|
|
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * factor;
|
|
return String(av).localeCompare(String(bv)) * factor;
|
|
});
|
|
}, [rows, sort, columns, controlledSort]);
|
|
|
|
/* ── Pagination ──────────────────────────────────────────────────────── */
|
|
const paginated = initialPageSize != null;
|
|
const [internalPage, setInternalPage] = React.useState(1);
|
|
const [pageSize, setPageSize] = React.useState(initialPageSize ?? 25);
|
|
const page = controlledPage !== undefined ? controlledPage : internalPage;
|
|
|
|
const setPage = (next) => {
|
|
if (onPageChange) onPageChange(next);
|
|
if (controlledPage === undefined) setInternalPage(next);
|
|
};
|
|
|
|
// Snap back to page 1 whenever the result set changes underneath us,
|
|
// otherwise filtering to 3 rows while on page 4 shows an empty table.
|
|
const rowCount = rows.length;
|
|
React.useEffect(() => {
|
|
if (controlledPage === undefined) setInternalPage(1);
|
|
}, [rowCount, sort, controlledPage]);
|
|
|
|
const serverPaginated = totalItems != null && onPageChange != null;
|
|
const pageCount = paginated
|
|
? Math.max(1, Math.ceil((totalItems ?? sortedRows.length) / pageSize))
|
|
: 1;
|
|
|
|
const visibleRows = React.useMemo(() => {
|
|
if (!paginated || serverPaginated) return sortedRows;
|
|
const start = (page - 1) * pageSize;
|
|
return sortedRows.slice(start, start + pageSize);
|
|
}, [sortedRows, paginated, serverPaginated, page, pageSize]);
|
|
|
|
/* ── Selection ───────────────────────────────────────────────────────── */
|
|
const selectable = selectedIds !== undefined;
|
|
const selected = React.useMemo(() => new Set(selectedIds || []), [selectedIds]);
|
|
const pageIds = visibleRows.map((row, i) => getRowId(row, i));
|
|
const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selected.has(id));
|
|
const someOnPageSelected = pageIds.some((id) => selected.has(id));
|
|
|
|
const toggleAll = () => {
|
|
if (allOnPageSelected) {
|
|
onSelectionChange?.(selectedIds.filter((id) => !pageIds.includes(id)));
|
|
} else {
|
|
onSelectionChange?.([...new Set([...selectedIds, ...pageIds])]);
|
|
}
|
|
};
|
|
|
|
const toggleRow = (id) => {
|
|
onSelectionChange?.(
|
|
selected.has(id) ? selectedIds.filter((s) => s !== id) : [...selectedIds, id]
|
|
);
|
|
};
|
|
|
|
/* ── Render ──────────────────────────────────────────────────────────── */
|
|
const hideBelowClass = { sm: 'hidden sm:table-cell', md: 'hidden md:table-cell', lg: 'hidden lg:table-cell' };
|
|
const alignClass = { right: 'text-right', center: 'text-center' };
|
|
|
|
if (loading) {
|
|
return (
|
|
<Surface padding="none" radius="xl" className={cn('overflow-hidden', className)}>
|
|
<SkeletonTable rows={initialPageSize && initialPageSize < 8 ? initialPageSize : 6} columns={columns.length} />
|
|
</Surface>
|
|
);
|
|
}
|
|
|
|
if (sortedRows.length === 0) {
|
|
return (
|
|
<Surface padding="none" radius="xl" className={cn('overflow-hidden', className)}>
|
|
{emptyState || (
|
|
<EmptyState
|
|
icon={Inbox}
|
|
variant={isFiltered ? 'filtered' : 'empty'}
|
|
title={isFiltered ? 'No matching results' : 'Nothing here yet'}
|
|
description={
|
|
isFiltered
|
|
? 'No rows match the current filters. Try widening or clearing them.'
|
|
: 'Rows will appear here once there is data.'
|
|
}
|
|
action={isFiltered && onClearFilters ? { label: 'Clear filters', onClick: onClearFilters } : undefined}
|
|
/>
|
|
)}
|
|
</Surface>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn('space-y-4', className)}>
|
|
<Surface padding="none" radius="xl" className="relative overflow-hidden">
|
|
<LoadingOverlay show={refreshing} />
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse">
|
|
{caption && <caption className="sr-only">{caption}</caption>}
|
|
|
|
<thead className={cn(stickyHeader && 'sticky top-0 z-10')}>
|
|
<tr className="bg-surface-subtle border-b border-border">
|
|
{selectable && (
|
|
<th scope="col" className={cn('w-10 px-4', cellPad)}>
|
|
<Checkbox
|
|
checked={allOnPageSelected ? true : someOnPageSelected ? 'indeterminate' : false}
|
|
onCheckedChange={toggleAll}
|
|
aria-label="Select all rows on this page"
|
|
/>
|
|
</th>
|
|
)}
|
|
|
|
{columns.map((column) => {
|
|
const active = sort?.key === column.key;
|
|
const SortIcon = !active ? ChevronsUpDown : sort.direction === 'asc' ? ArrowUp : ArrowDown;
|
|
|
|
return (
|
|
<th
|
|
key={column.key}
|
|
scope="col"
|
|
style={column.width ? { width: column.width } : undefined}
|
|
aria-sort={active ? (sort.direction === 'asc' ? 'ascending' : 'descending') : undefined}
|
|
className={cn(
|
|
'px-4 text-overline uppercase text-ink-4 font-heading whitespace-nowrap',
|
|
cellPad,
|
|
column.align ? alignClass[column.align] : 'text-left',
|
|
column.hideBelow && hideBelowClass[column.hideBelow]
|
|
)}
|
|
>
|
|
{column.sortable ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleSort(column)}
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 rounded-sm transition-colors hover:text-ink-2 focus-ring-inset',
|
|
active && 'text-krow-blue'
|
|
)}
|
|
>
|
|
{column.header}
|
|
<SortIcon className="w-3 h-3" aria-hidden="true" />
|
|
</button>
|
|
) : (
|
|
column.header
|
|
)}
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody>
|
|
{visibleRows.map((row, i) => {
|
|
const id = getRowId(row, i);
|
|
const isSelected = selected.has(id);
|
|
|
|
return (
|
|
<tr
|
|
key={id}
|
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
className={cn(
|
|
'border-b border-border last:border-0 transition-colors duration-fast',
|
|
onRowClick && 'cursor-pointer',
|
|
isSelected ? 'bg-krow-blue-tint' : 'hover:bg-surface-subtle',
|
|
typeof rowClassName === 'function' ? rowClassName(row) : rowClassName
|
|
)}
|
|
>
|
|
{selectable && (
|
|
<td className={cn('px-4', cellPad)} onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox
|
|
checked={isSelected}
|
|
onCheckedChange={() => toggleRow(id)}
|
|
aria-label={`Select row ${i + 1}`}
|
|
/>
|
|
</td>
|
|
)}
|
|
|
|
{columns.map((column) => (
|
|
<td
|
|
key={column.key}
|
|
className={cn(
|
|
'px-4 text-body-sm text-ink-2 align-middle',
|
|
compact ? 'py-1.5' : 'py-3.5',
|
|
column.align ? alignClass[column.align] : 'text-left',
|
|
column.hideBelow && hideBelowClass[column.hideBelow],
|
|
column.className
|
|
)}
|
|
>
|
|
{column.cell
|
|
? column.cell(row)
|
|
: column.accessor
|
|
? column.accessor(row)
|
|
: row?.[column.key] ?? '—'}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Surface>
|
|
|
|
{paginated && (
|
|
<Pagination
|
|
page={page}
|
|
pageCount={pageCount}
|
|
onPageChange={setPage}
|
|
totalItems={totalItems ?? sortedRows.length}
|
|
pageSize={pageSize}
|
|
onPageSizeChange={(size) => {
|
|
setPageSize(size);
|
|
setPage(1);
|
|
}}
|
|
pageSizeOptions={pageSizeOptions}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|