Files
pig/apps/web/src/components/DataTable.tsx
T
karti e12d27edd1
CI / verify (push) Successful in 3m32s
Polish every product workflow across desktop and mobile
Reframe each screen around the decisions compute brokers make: sellable capacity, full-cost margin, pipeline movement, contract deadlines, evidence review, staged imports, and controlled agent access. Group the shell by operating domain, strengthen mobile navigation and sheets, add responsive record treatments, and make loading, error, empty, readiness, and retry states explicit.

The visual audit exposed sortable table targets and an unnamed file input only after exercising the rendered app, so this commit also pins those accessibility decisions at their actual interaction boundaries. Manrope is self-hosted as a single Latin variable subset to keep the stronger hierarchy without shipping unused font payloads.
2026-08-13 05:34:23 -07:00

244 lines
7.8 KiB
TypeScript

import { useState } from 'react';
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type Column,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
} from '@tanstack/react-table';
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, SlidersHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
emptyMessage?: string;
filterColumn?: string;
filterPlaceholder?: string;
initialColumnVisibility?: VisibilityState;
}
export function DataTable<TData, TValue>({
columns,
data,
emptyMessage = 'No results.',
filterColumn,
filterPlaceholder = 'Filter results',
initialColumnVisibility = {},
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
initialColumnVisibility,
);
const table = useReactTable({
data,
columns,
state: { sorting, columnFilters, columnVisibility },
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
const activeFilter = filterColumn ? table.getColumn(filterColumn) : undefined;
const hideableColumns = table.getAllColumns().filter((column) => column.getCanHide());
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{activeFilter ? (
<Input
type="search"
value={(activeFilter.getFilterValue() as string | undefined) ?? ''}
onChange={(event) => activeFilter.setFilterValue(event.target.value)}
placeholder={filterPlaceholder}
aria-label={filterPlaceholder}
className="sm:max-w-xs"
/>
) : (
<span />
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="tap sm:ml-auto">
<SlidersHorizontal data-icon="inline-start" aria-hidden />
Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Visible columns</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{hideableColumns.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
checked={column.getIsVisible()}
onCheckedChange={(visible) => column.toggleVisibility(Boolean(visible))}
>
{columnLabel(column.id)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="scroll-x rounded-xl border border-border bg-surface">
<Table className="min-w-[44rem]">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() ? 'selected' : undefined}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={Math.max(table.getVisibleLeafColumns().length, 1)} className="h-28 text-center text-muted">
{emptyMessage}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted" aria-live="polite">
{table.getFilteredRowModel().rows.length} result
{table.getFilteredRowModel().rows.length === 1 ? '' : 's'} · Page{' '}
{table.getState().pagination.pageIndex + 1} of {Math.max(table.getPageCount(), 1)}
</p>
<div className="flex items-center justify-between gap-2 sm:justify-end">
<Select
value={String(table.getState().pagination.pageSize)}
onValueChange={(value) => table.setPageSize(Number(value))}
>
<SelectTrigger className="h-11 w-[7.5rem]" aria-label="Rows per page">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{[10, 20, 50].map((pageSize) => (
<SelectItem key={pageSize} value={String(pageSize)}>
{pageSize} rows
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="icon"
className="tap"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
aria-label="Previous page"
>
<ChevronLeft aria-hidden />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="tap"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
aria-label="Next page"
>
<ChevronRight aria-hidden />
</Button>
</div>
</div>
</div>
);
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
}: {
column: Column<TData, TValue>;
title: string;
}) {
if (!column.getCanSort()) return <span>{title}</span>;
const direction = column.getIsSorted();
const SortIcon = direction === 'asc' ? ArrowUp : direction === 'desc' ? ArrowDown : ArrowUpDown;
return (
<Button
type="button"
variant="ghost"
size="sm"
className="-ml-3 min-h-11"
onClick={() => column.toggleSorting(direction === 'asc')}
aria-label={`Sort by ${title}${direction ? `, currently ${direction}ending` : ''}`}
>
{title}
<SortIcon data-icon="inline-end" aria-hidden />
</Button>
);
}
function columnLabel(value: string): string {
return value
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/_/g, ' ')
.replace(/^./, (character) => character.toUpperCase());
}