e12d27edd1
CI / verify (push) Successful in 3m32s
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.
244 lines
7.8 KiB
TypeScript
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());
|
|
}
|