Phase 6. 57 files: 23 vendored shadcn primitives, 30 design-system components,
4 charts. Plus `src/components/ds/props.ts`, which is types only.
Renaming these alone took typecheck from 37 to 1008, and the reason is worth
recording because it is the shape of every remaining phase.
These components had NO prop contract. No PropTypes, no validation: in the
JavaScript every prop was optional and every extra prop was spread onto the
underlying element. TypeScript infers a destructured parameter WITHOUT a default
as REQUIRED, so the moment the files became `.tsx` it invented a rule the
components never had and rejected several hundred call sites that have always
worked. That is the compiler describing its own inference, not a defect it
found.
Three mechanical fixes, each restoring a contract that already existed:
- 57 JSDoc `@type {React.ForwardRefExoticComponent<any>}` annotations become
real TypeScript annotations. Those comments were the previous authors'
deliberate compatibility types; JSDoc stops applying in a `.tsx` file, so
converting them preserves an intent that was already written down.
- 61 `React.forwardRef(...)` calls gain `<any, any>`. Without generics `ref`
infers `ForwardedRef<unknown>`, which no element's `Ref<T>` accepts - so
every primitive that forwards a ref to a `div` failed on the ref, not the
props.
- 78 component signatures take `DsProps`, a documented alias for
`Record<string, any>`. It exists so the decision is recorded once and is
greppable when someone tightens it, rather than being 78 bare `any`s with
no explanation between them. The prop NAMES are not lost: every component
still destructures them by name, which is where a reader looks.
Four files needed real types rather than compatibility ones. `ds/toast` takes
react-hot-toast's own `ToastOptions`, which narrows `position` to its
`ToastPosition` union instead of widening to `string` - the widening was what
made all six calls unassignable. `ds/Pagination`'s page range is genuinely
`(number | string)[]`, because it interleaves page numbers with '…' markers that
the renderer tests for. `ds/Field` narrows `children.props` at three reads, and
`ds/Avatar` needed the ref generic.
Two of my own automated passes were wrong and were caught rather than shipped. A
props-interface generator dropped alternating props, because non-overlapping
regex matches consume the separating comma - it made things worse (83 file
errors to 146) and was reverted wholesale. A second pass missed every
multi-line signature whose defaults contain a `)`, such as `onClose = () => {}`;
that needed a brace matcher rather than a character class.
56 of 57 files emit byte-identical JavaScript. The one exception is `ds/toast`,
where a JSDoc type CAST - `/** @type {ToastPosition} */ ('bottom-center')` -
became a real annotation, so the emitted output loses a comment and a pair of
now-redundant parentheses. The value is `"bottom-center"` either way; the
minified outputs differ only in esbuild's choice of mangled local names.
Verified: tsc 37 -> 35, set-difference showing zero introduced and two removed;
zero errors remain in any Phase 6 file; npm test 1684/1691 with the same seven
failures; lint 0 errors; build succeeds with the API origin inlined; baseline
artifacts untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
import type { DsProps } from './props';
|
|
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.
|
|
*/
|
|
/** @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,
|
|
|
|
/* 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,
|
|
}: DsProps) {
|
|
/* ── 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 min-w-[36rem] 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>
|
|
);
|
|
}
|