This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
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"
|
||||
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());
|
||||
}
|
||||
Reference in New Issue
Block a user