Initial commit
This commit is contained in:
309
src/components/ds/DataTable.jsx
Normal file
309
src/components/ds/DataTable.jsx
Normal file
@@ -0,0 +1,309 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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 ─────────────────────────────────────────────────────────── */
|
||||
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="w-10 px-4 py-3">
|
||||
<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 py-3 text-overline uppercase text-ink-4 font-heading whitespace-nowrap',
|
||||
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="px-4 py-3" 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 py-3.5 text-body-sm text-ink-2 align-middle',
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user