Files
doormilxpress_astryx/src/components/ds/DataTable.jsx

345 lines
14 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/preferences';
/**
* 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.
*/
/** @param {any} props */
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,
selectAllPages = true,
/* 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 allRowIds = React.useMemo(
() => sortedRows.map((row, i) => getRowId(row, i)),
[sortedRows, getRowId]
);
const pageIds = visibleRows.map((row, i) => getRowId(row, i));
const targetIds = selectAllPages && !serverPaginated ? allRowIds : pageIds;
const allTargetSelected = targetIds.length > 0 && targetIds.every((id) => selected.has(id));
const someTargetSelected = targetIds.some((id) => selected.has(id));
const toggleAll = () => {
if (allTargetSelected) {
onSelectionChange?.(selectedIds.filter((id) => !targetIds.includes(id)));
} else {
onSelectionChange?.([...new Set([...selectedIds, ...targetIds])]);
}
};
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="md" 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="md" 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)}>
{/* radius="md" (12px), down from "xl".
Surface's xl resolves to rounded-2xl — calc(var(--radius) + 8px) —
which with --radius at 0.75rem is a 20px corner. On a small chip that
reads as soft; on a full-width table it curves far enough in to cut
visibly across the first and last cells of the header row.
12px is `--radius` itself, and matches radiusCard in themes/dt/tokens
— the token meant for a large boxed region, which is what a table is.
A value picked off the scale rather than an arbitrary px, so it stays
in step if the base radius is ever retuned.
Set on all three branches (loading, empty, populated) so a table does
not change shape as its data arrives. */}
<Surface padding="none" radius="md" className="relative overflow-hidden">
<LoadingOverlay show={refreshing} />
<div className="no-scrollbar 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={allTargetSelected ? true : someTargetSelected ? 'indeterminate' : false}
onCheckedChange={toggleAll}
aria-label={
selectAllPages && !serverPaginated
? 'Select all rows'
: '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-brand'
)}
>
{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-brand-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>
);
}