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

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>
);
}