feat: add data table and release checks

This commit is contained in:
2026-03-19 20:10:36 +08:00
parent d5e4d5ece3
commit 3f77070802
12 changed files with 2157 additions and 86 deletions
+1
View File
@@ -32,6 +32,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -0,0 +1,242 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
import { DataTable, type DataTableColumn } from "./data-table";
type ReleaseRow = {
id: string;
lane: string;
note: string;
owner: string;
risk: number;
status: string;
};
const rows: ReleaseRow[] = [
{
id: "legal",
lane: "Legal",
note: "Footnote needs one more pass before the customer note goes out.",
owner: "Ava",
risk: 3,
status: "Pending"
},
{
id: "support",
lane: "Support",
note: "Macro pack is staged and aligned with the migration narrative.",
owner: "Nia",
risk: 1,
status: "Ready"
},
{
id: "engineering",
lane: "Engineering",
note: "Canary checks are green and ready for the 10% wave.",
owner: "Mika",
risk: 2,
status: "Watching"
},
{
id: "editorial",
lane: "Editorial",
note: "Copy is locked and the public note stays staged.",
owner: "Jun",
risk: 4,
status: "Staged"
}
];
const columns: DataTableColumn<ReleaseRow>[] = [
{
accessor: "lane",
header: "Lane",
id: "lane",
sortable: true
},
{
accessor: "owner",
header: "Owner",
id: "owner",
sortable: true
},
{
accessor: "status",
header: "Status",
id: "status"
},
{
accessor: "risk",
align: "end",
header: "Risk",
id: "risk",
sortable: true
},
{
cell: (row) => row.note,
header: "Narrative",
id: "note",
searchValue: (row) => row.note
}
];
function getBodyRows() {
return screen
.getAllByRole("row")
.filter((row) => row.closest("tbody") !== null);
}
function getRenderedLanes() {
return getBodyRows().map((row) => within(row).getAllByRole("cell")[0].textContent);
}
describe("DataTable", () => {
it("renders semantic table slots, toolbar search, and row actions", () => {
render(
<DataTable
columns={columns}
renderRowActions={(row) => <Button size="sm">Open {row.lane}</Button>}
rows={rows.slice(0, 2)}
toolbarActions={<Button size="sm" variant="secondary">Create lane</Button>}
/>
);
expect(screen.getByRole("table").closest('[data-slot="root"]')).toBeInTheDocument();
expect(screen.getByRole("searchbox", { name: "Search rows" })).toHaveAttribute(
"data-slot",
"input"
);
expect(
screen.getByRole("button", { name: "Create lane" }).closest('[data-slot="actions"]')
).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: /lane/i })).toHaveAttribute(
"data-slot",
"header"
);
expect(
screen.getByRole("button", { name: "Open Legal" }).closest('[data-slot="actions"]')
).toBeInTheDocument();
});
it("sorts rows when a sortable header is activated", async () => {
const user = userEvent.setup();
render(<DataTable columns={columns} rows={rows.slice(0, 3)} />);
expect(getRenderedLanes()).toEqual(["Legal", "Support", "Engineering"]);
await user.click(within(screen.getByRole("columnheader", { name: /risk/i })).getByRole("button"));
expect(getRenderedLanes()).toEqual(["Support", "Engineering", "Legal"]);
await user.click(within(screen.getByRole("columnheader", { name: /risk/i })).getByRole("button"));
expect(getRenderedLanes()).toEqual(["Legal", "Engineering", "Support"]);
});
it("supports row selection and shows a bulk selection surface", async () => {
const user = userEvent.setup();
render(
<DataTable
columns={columns}
enableSelection
rows={rows.slice(0, 2)}
selectionActions={() => <Button size="sm">Assign owner</Button>}
/>
);
await user.click(screen.getByRole("checkbox", { name: "Select row 1" }));
expect(getBodyRows()[0]).toHaveAttribute("data-selected", "");
expect(screen.getByText("1 selected")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Assign owner" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Clear selection" }));
expect(screen.queryByText("1 selected")).not.toBeInTheDocument();
});
it("filters rows from the built-in search and falls back to the empty state", async () => {
const user = userEvent.setup();
render(<DataTable columns={columns} rows={rows.slice(0, 3)} />);
await user.type(screen.getByRole("searchbox", { name: "Search rows" }), "migration");
expect(
screen.getByText("Macro pack is staged and aligned with the migration narrative.")
).toBeInTheDocument();
expect(
screen.queryByText("Canary checks are green and ready for the 10% wave.")
).not.toBeInTheDocument();
await user.clear(screen.getByRole("searchbox", { name: "Search rows" }));
await user.type(screen.getByRole("searchbox", { name: "Search rows" }), "missing");
expect(screen.getByText("No matching rows")).toBeInTheDocument();
expect(
screen.getByText(
"Try another search, clear filters, or create a new record to repopulate this view."
)
).toBeInTheDocument();
});
it("paginates rows and updates the visible range", async () => {
const user = userEvent.setup();
render(
<DataTable
columns={columns}
defaultPageSize={2}
pageSizeOptions={[2]}
rows={rows}
/>
);
expect(screen.getByText("1-2 of 4")).toBeInTheDocument();
expect(getRenderedLanes()).toEqual(["Legal", "Support"]);
expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("3-4 of 4")).toBeInTheDocument();
expect(getRenderedLanes()).toEqual(["Engineering", "Editorial"]);
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
});
it("renders loading status without dropping the table chrome", () => {
render(<DataTable columns={columns} loading rows={rows} />);
expect(screen.getByRole("status")).toHaveTextContent("Loading rows");
expect(screen.getByRole("columnheader", { name: /lane/i })).toBeInTheDocument();
expect(screen.getByRole("table").closest('[data-slot="root"]')).toHaveAttribute(
"data-loading",
""
);
});
it("supports controlled sorting and selection callbacks", async () => {
const user = userEvent.setup();
const onSortingChange = vi.fn();
const onSelectionChange = vi.fn();
render(
<DataTable
columns={columns}
enableSelection
onSelectionChange={onSelectionChange}
onSortingChange={onSortingChange}
rows={rows.slice(0, 2)}
/>
);
await user.click(within(screen.getByRole("columnheader", { name: /lane/i })).getByRole("button"));
await user.click(screen.getByRole("checkbox", { name: "Select row 2" }));
expect(onSortingChange).toHaveBeenCalledWith([{ desc: false, id: "lane" }]);
expect(onSelectionChange).toHaveBeenCalledWith({ support: true });
});
});
+952
View File
@@ -0,0 +1,952 @@
import {
type CellContext,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type HeaderContext,
useReactTable,
type ColumnDef,
type FilterFn,
type PaginationState,
type RowSelectionState,
type SortingState
} from "@tanstack/react-table";
import {
forwardRef,
useEffect,
useState,
type ComponentPropsWithoutRef,
type CSSProperties,
type ForwardedRef,
type JSX,
type ReactNode
} from "react";
import { Button } from "./button";
import { Checkbox } from "./checkbox";
import {
EmptyState,
EmptyStateActions,
EmptyStateDescription,
EmptyStateHeader,
EmptyStateTitle
} from "./empty-state";
import { Input, type InputProps } from "./input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
import { Skeleton } from "./skeleton";
import { Spinner } from "./spinner";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
import {
dataTableBodyVariants,
dataTableCellVariants,
dataTableContentVariants,
dataTableHeaderCellVariants,
dataTableHeaderVariants,
dataTablePaginationVariants,
dataTableRootVariants,
dataTableRowVariants,
dataTableSearchContainerVariants,
dataTableSelectionBarVariants,
dataTableStatusVariants,
dataTableTableVariants,
dataTableToolbarVariants
} from "./data-table.variants";
export type DataTableAlignment = "start" | "center" | "end";
export type DataTableSort = {
desc?: boolean;
id: string;
};
export type DataTableColumnContext<TData> = {
column: DataTableColumn<TData>;
sorted: "asc" | "desc" | false;
};
export type DataTableColumn<TData> = {
accessor?: keyof TData | ((row: TData) => unknown);
align?: DataTableAlignment;
cell?: (row: TData) => ReactNode;
header: ReactNode | ((column: DataTableColumnContext<TData>) => ReactNode);
id: string;
searchValue?: (row: TData) => string;
searchable?: boolean;
sortable?: boolean;
width?: number | string;
};
export type DataTableProps<TData> = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
columns: DataTableColumn<TData>[];
defaultPageIndex?: number;
defaultPageSize?: number;
defaultSearchValue?: string;
defaultSelection?: Record<string, boolean>;
defaultSorting?: DataTableSort[];
empty?: ReactNode;
enableSelection?: boolean;
getRowId?: (row: TData, index: number) => string;
loading?: boolean;
loadingRowCount?: number;
onPageIndexChange?: (pageIndex: number) => void;
onPageSizeChange?: (pageSize: number) => void;
onSearchValueChange?: (searchValue: string) => void;
onSelectionChange?: (selection: Record<string, boolean>) => void;
onSortingChange?: (sorting: DataTableSort[]) => void;
pageIndex?: number;
pageSize?: number;
pageSizeOptions?: number[];
renderRowActions?: (row: TData) => ReactNode;
rows: TData[];
searchLabel?: string;
searchPlaceholder?: string;
searchValue?: string;
selection?: Record<string, boolean>;
selectionLabel?: ReactNode | ((rows: TData[]) => ReactNode);
selectionActions?: (rows: TData[]) => ReactNode;
sorting?: DataTableSort[];
tableLabel?: string;
toolbarActions?: ReactNode;
};
type InternalColumnMeta<TData> = {
align: DataTableAlignment;
sourceColumn?: DataTableColumn<TData>;
width?: number | string;
};
function useControllableState<T>({
controlledValue,
defaultValue,
onChange
}: {
controlledValue: T | undefined;
defaultValue: T;
onChange?: (value: T) => void;
}) {
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
const value = controlledValue ?? uncontrolledValue;
const setValue = (nextValue: T | ((currentValue: T) => T)) => {
const resolvedValue =
typeof nextValue === "function"
? (nextValue as (currentValue: T) => T)(value)
: nextValue;
if (controlledValue === undefined) {
setUncontrolledValue(resolvedValue);
}
onChange?.(resolvedValue);
};
return [value, setValue] as const;
}
function stringifySearchValue(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
if (Array.isArray(value)) {
return value.map((item) => stringifySearchValue(item)).join(" ");
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === "object") {
return Object.values(value as Record<string, unknown>)
.map((item) => stringifySearchValue(item))
.join(" ");
}
return String(value);
}
function getColumnAccessorValue<TData>(row: TData, column: DataTableColumn<TData>) {
if (typeof column.accessor === "function") {
return column.accessor(row);
}
if (typeof column.accessor === "string") {
return row[column.accessor];
}
return undefined;
}
function getColumnSearchText<TData>(row: TData, column: DataTableColumn<TData>) {
if (column.searchValue) {
return column.searchValue(row);
}
return stringifySearchValue(getColumnAccessorValue(row, column));
}
function getColumnWidthStyle(width?: number | string): CSSProperties | undefined {
if (width === undefined) {
return undefined;
}
return {
width: typeof width === "number" ? `${width}px` : width
};
}
function getHeaderLabel<TData>(column: DataTableColumn<TData>) {
return typeof column.header === "string" ? column.header : column.id;
}
function DataTableInner<TData>(
{
className,
columns,
defaultPageIndex = 0,
defaultPageSize = 5,
defaultSearchValue = "",
defaultSelection = {},
defaultSorting = [],
empty,
enableSelection = false,
getRowId,
loading = false,
loadingRowCount = 5,
onPageIndexChange,
onPageSizeChange,
onSearchValueChange,
onSelectionChange,
onSortingChange,
pageIndex,
pageSize,
pageSizeOptions = [5, 10, 20],
renderRowActions,
rows,
searchLabel = "Search rows",
searchPlaceholder = "Search rows",
searchValue,
selection,
selectionLabel,
selectionActions,
sorting,
tableLabel = "Data table",
toolbarActions,
...props
}: DataTableProps<TData>,
ref: ForwardedRef<HTMLDivElement>
) {
const selectionEnabled = enableSelection || selection !== undefined || onSelectionChange !== undefined;
const searchableColumns = columns.filter(
(column) => (column.searchable ?? false) || column.searchValue || column.accessor
);
const [currentSearchValue, setCurrentSearchValue] = useControllableState({
controlledValue: searchValue,
defaultValue: defaultSearchValue,
onChange: onSearchValueChange
});
const [currentSorting, setCurrentSorting] = useControllableState<DataTableSort[]>({
controlledValue: sorting,
defaultValue: defaultSorting,
onChange: onSortingChange
});
const [currentSelection, setCurrentSelection] = useControllableState<Record<string, boolean>>({
controlledValue: selection,
defaultValue: defaultSelection,
onChange: onSelectionChange
});
const [currentPageIndex, setCurrentPageIndex] = useControllableState({
controlledValue: pageIndex,
defaultValue: defaultPageIndex,
onChange: onPageIndexChange
});
const [currentPageSize, setCurrentPageSize] = useControllableState({
controlledValue: pageSize,
defaultValue: defaultPageSize,
onChange: onPageSizeChange
});
const resolvedPageSizeOptions = [...new Set([...pageSizeOptions, currentPageSize])].sort(
(left, right) => left - right
);
const globalFilterFn: FilterFn<TData> = (row, _columnId, filterValue) => {
const query = String(filterValue ?? "").trim().toLowerCase();
if (query.length === 0) {
return true;
}
return searchableColumns.some((column) =>
getColumnSearchText(row.original, column).toLowerCase().includes(query)
);
};
const tableColumns: ColumnDef<TData>[] = [
...(selectionEnabled
? [
{
cell: ({ row }) => (
<Checkbox
aria-label={`Select row ${row.index + 1}`}
checked={row.getIsSelected()}
onCheckedChange={(checked) => {
row.toggleSelected(Boolean(checked));
}}
/>
),
enableGlobalFilter: false,
enableHiding: false,
enableSorting: false,
header: ({ table }) => (
<Checkbox
aria-label="Select all rows"
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(checked) => {
table.toggleAllPageRowsSelected(Boolean(checked));
}}
/>
),
id: "__select",
meta: {
align: "center",
width: 56
} satisfies InternalColumnMeta<TData>
} satisfies ColumnDef<TData>
]
: []),
...columns.map((column) => ({
accessorFn: column.accessor
? (row: TData) => getColumnAccessorValue(row, column)
: undefined,
cell: ({ row }: CellContext<TData, unknown>) =>
column.cell
? column.cell(row.original)
: stringifySearchValue(getColumnAccessorValue(row.original, column)),
enableGlobalFilter: searchableColumns.includes(column),
enableSorting: column.sortable ?? false,
header: ({ column: tanstackColumn }: HeaderContext<TData, unknown>) =>
typeof column.header === "function"
? column.header({
column,
sorted: tanstackColumn.getIsSorted()
})
: column.header,
id: column.id,
meta: {
align: column.align ?? "start",
sourceColumn: column,
width: column.width
} satisfies InternalColumnMeta<TData>,
sortDescFirst: false
})),
...(renderRowActions
? [
{
cell: ({ row }) => (
<div {...createSlot("actions")} className="flex items-center justify-end">
{renderRowActions(row.original)}
</div>
),
enableGlobalFilter: false,
enableHiding: false,
enableSorting: false,
header: () => <span className="sr-only">Row actions</span>,
id: "__actions",
meta: {
align: "end",
width: 88
} satisfies InternalColumnMeta<TData>
} satisfies ColumnDef<TData>
]
: [])
];
// eslint-disable-next-line react-hooks/incompatible-library -- TanStack Table is the intentional row-model engine behind this source-owned component.
const table = useReactTable({
autoResetPageIndex: true,
columns: tableColumns,
data: rows,
enableRowSelection: selectionEnabled,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row, index) => {
if (getRowId) {
return getRowId(row, index);
}
const candidateId = (row as { id?: unknown }).id;
if (typeof candidateId === "string" || typeof candidateId === "number") {
return String(candidateId);
}
return String(index);
},
getSortedRowModel: getSortedRowModel(),
globalFilterFn,
onPaginationChange: (updater) => {
const nextValue =
typeof updater === "function"
? updater({
pageIndex: currentPageIndex,
pageSize: currentPageSize
})
: updater;
setCurrentPageIndex(nextValue.pageIndex);
setCurrentPageSize(nextValue.pageSize);
},
onRowSelectionChange: (updater) => {
const nextValue =
typeof updater === "function"
? updater(currentSelection as RowSelectionState)
: updater;
setCurrentSelection(nextValue);
},
onSortingChange: (updater) => {
const nextValue =
typeof updater === "function"
? updater(currentSorting as SortingState)
: updater;
setCurrentSorting(nextValue.map((item) => ({ desc: item.desc, id: item.id })));
},
state: {
globalFilter: currentSearchValue,
pagination: {
pageIndex: currentPageIndex,
pageSize: currentPageSize
} satisfies PaginationState,
rowSelection: currentSelection,
sorting: currentSorting as SortingState
}
});
const totalColumns = table.getAllLeafColumns().length;
const filteredRowCount = table.getFilteredRowModel().rows.length;
const selectedRows = table.getSelectedRowModel().rows.map((row) => row.original);
const pageCount = table.getPageCount();
const isEmpty = !loading && filteredRowCount === 0;
const shouldRenderSearch = searchableColumns.length > 0;
const shouldRenderToolbar = shouldRenderSearch || toolbarActions !== undefined;
const pageStart =
filteredRowCount === 0 ? 0 : currentPageIndex * currentPageSize + 1;
const pageEnd = Math.min((currentPageIndex + 1) * currentPageSize, filteredRowCount);
useEffect(() => {
const maxPageIndex = Math.max(pageCount - 1, 0);
if (currentPageIndex > maxPageIndex) {
setCurrentPageIndex(maxPageIndex);
}
}, [currentPageIndex, pageCount, setCurrentPageIndex]);
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
empty: isEmpty,
loading,
selected: selectedRows.length > 0
})}
className={cn(dataTableRootVariants(), className)}
ref={ref}
>
{shouldRenderToolbar ? (
<DataTableToolbar>
<div className="flex flex-1 flex-wrap items-center gap-3">
{shouldRenderSearch ? (
<div className={dataTableSearchContainerVariants()}>
<DataTableSearch
aria-label={searchLabel}
onChange={(event) => {
setCurrentSearchValue(event.currentTarget.value);
setCurrentPageIndex(0);
}}
placeholder={searchPlaceholder}
value={currentSearchValue}
/>
</div>
) : null}
</div>
{toolbarActions ? <DataTableFilters>{toolbarActions}</DataTableFilters> : null}
</DataTableToolbar>
) : null}
{selectionEnabled && selectedRows.length > 0 ? (
<DataTableSelectionBar>
<div className="text-sm font-medium text-[var(--color-foreground)]">
{typeof selectionLabel === "function"
? selectionLabel(selectedRows)
: selectionLabel ?? `${selectedRows.length} selected`}
</div>
<div {...createSlot("actions")} className="flex flex-wrap items-center gap-2">
{selectionActions?.(selectedRows)}
<Button
size="sm"
variant="ghost"
onClick={() => {
table.resetRowSelection();
}}
>
Clear selection
</Button>
</div>
</DataTableSelectionBar>
) : null}
<DataTableContent loading={loading}>
{loading ? <DataTableLoading className="sr-only min-h-0 px-0 py-0" /> : null}
<div className="overflow-x-auto">
<DataTableTable aria-label={tableLabel}>
<DataTableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta as InternalColumnMeta<TData> | undefined;
const sortState = header.column.getIsSorted();
const align: DataTableAlignment = meta?.align ?? "start";
return (
<DataTableHeaderCell
key={header.id}
align={align}
aria-sort={
sortState === "asc"
? "ascending"
: sortState === "desc"
? "descending"
: undefined
}
scope="col"
sortable={header.column.getCanSort()}
sort={sortState}
style={getColumnWidthStyle(meta?.width)}
>
{header.isPlaceholder ? null : header.column.getCanSort() ? (
<button
className={[
"inline-flex w-full items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5",
"outline-none transition-colors duration-200 hover:bg-[var(--color-surface)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]",
align === "end" ? "justify-end" : align === "center" ? "justify-center" : "justify-start"
].join(" ")}
onClick={header.column.getToggleSortingHandler()}
type="button"
>
<span>{flexRender(header.column.columnDef.header, header.getContext())}</span>
<span
aria-hidden="true"
className="text-xs text-[var(--color-muted-foreground)]"
>
{sortState === "asc" ? "↑" : sortState === "desc" ? "↓" : "↕"}
</span>
<span className="sr-only">
Sort by{" "}
{meta?.sourceColumn ? getHeaderLabel(meta.sourceColumn) : header.column.id}
</span>
</button>
) : (
flexRender(header.column.columnDef.header, header.getContext())
)}
</DataTableHeaderCell>
);
})}
</tr>
))}
</DataTableHeader>
<DataTableBody>
{loading
? Array.from({ length: loadingRowCount }, (_, index) => (
<DataTableRow
interactive={false}
key={`loading-row-${index}`}
>
{Array.from({ length: totalColumns }, (_, cellIndex) => (
<DataTableCell key={`loading-cell-${cellIndex}`}>
<Skeleton
className={cellIndex === totalColumns - 1 ? "ml-auto w-12" : undefined}
/>
</DataTableCell>
))}
</DataTableRow>
))
: isEmpty
? (
<tr>
<td colSpan={totalColumns}>
{empty ?? (
<DataTableEmpty
description="Try another search, clear filters, or create a new record to repopulate this view."
title="No matching rows"
/>
)}
</td>
</tr>
)
: table.getRowModel().rows.map((row) => (
<DataTableRow
data-selected={row.getIsSelected() ? "" : undefined}
key={row.id}
selected={row.getIsSelected()}
>
{row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as InternalColumnMeta<TData> | undefined;
const align: DataTableAlignment = meta?.align ?? "start";
return (
<DataTableCell
align={align}
key={cell.id}
style={getColumnWidthStyle(meta?.width)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataTableCell>
);
})}
</DataTableRow>
))}
</DataTableBody>
</DataTableTable>
</div>
<DataTablePagination>
<div className="text-sm text-[var(--color-muted-foreground)]">
{pageStart}-{pageEnd} of {filteredRowCount}
</div>
<div className="flex flex-wrap items-center gap-3">
{resolvedPageSizeOptions.length > 1 ? (
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--color-muted-foreground)]">Rows</span>
<Select
value={String(currentPageSize)}
onValueChange={(value) => {
setCurrentPageSize(Number(value));
setCurrentPageIndex(0);
}}
>
<SelectTrigger aria-label="Rows per page" className="w-[5.5rem]">
<SelectValue placeholder={String(currentPageSize)} />
</SelectTrigger>
<SelectContent>
{resolvedPageSizeOptions.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="text-sm text-[var(--color-muted-foreground)]">
Page {Math.min(currentPageIndex + 1, Math.max(pageCount, 1))} of {Math.max(pageCount, 1)}
</div>
<div className="flex items-center gap-2">
<Button
disabled={!table.getCanPreviousPage()}
size="sm"
variant="ghost"
onClick={() => {
table.previousPage();
}}
>
Previous
</Button>
<Button
disabled={!table.getCanNextPage()}
size="sm"
variant="ghost"
onClick={() => {
table.nextPage();
}}
>
Next
</Button>
</div>
</div>
</DataTablePagination>
</DataTableContent>
</div>
);
}
type DataTableComponent = <TData>(
props: DataTableProps<TData> & { ref?: ForwardedRef<HTMLDivElement> }
) => JSX.Element;
export const DataTable = forwardRef(DataTableInner) as DataTableComponent;
export type DataTableToolbarProps = ComponentPropsWithoutRef<"div">;
export const DataTableToolbar = forwardRef<HTMLDivElement, DataTableToolbarProps>(
function DataTableToolbar({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("toolbar")}
className={cn(dataTableToolbarVariants(), className)}
ref={ref}
/>
);
}
);
export type DataTableFiltersProps = ComponentPropsWithoutRef<"div">;
export const DataTableFilters = forwardRef<HTMLDivElement, DataTableFiltersProps>(
function DataTableFilters({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("actions")}
className={cn("flex flex-wrap items-center gap-2", className)}
ref={ref}
/>
);
}
);
export type DataTableSearchProps = Omit<InputProps, "size">;
export const DataTableSearch = forwardRef<HTMLInputElement, DataTableSearchProps>(
function DataTableSearch({ className, type = "search", ...props }, ref) {
return <Input {...props} className={className} ref={ref} type={type} />;
}
);
export type DataTableContentProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof dataTableContentVariants>;
export const DataTableContent = forwardRef<HTMLDivElement, DataTableContentProps>(
function DataTableContent({ className, loading, ...props }, ref) {
return (
<div
{...props}
{...createSlot("content")}
{...createDataAttributes({ loading })}
className={cn(dataTableContentVariants({ loading }), className)}
ref={ref}
/>
);
}
);
export type DataTableTableProps = ComponentPropsWithoutRef<"table">;
export const DataTableTable = forwardRef<HTMLTableElement, DataTableTableProps>(
function DataTableTable({ className, ...props }, ref) {
return (
<table
{...props}
{...createSlot("table")}
className={cn(dataTableTableVariants(), className)}
ref={ref}
/>
);
}
);
export type DataTableHeaderProps = ComponentPropsWithoutRef<"thead">;
export const DataTableHeader = forwardRef<HTMLTableSectionElement, DataTableHeaderProps>(
function DataTableHeader({ className, ...props }, ref) {
return (
<thead
{...props}
{...createSlot("header")}
className={cn(dataTableHeaderVariants(), className)}
ref={ref}
/>
);
}
);
export type DataTableHeaderCellProps = Omit<ComponentPropsWithoutRef<"th">, "align"> &
VariantProps<typeof dataTableHeaderCellVariants> & {
sort?: "asc" | "desc" | false;
};
export const DataTableHeaderCell = forwardRef<HTMLTableCellElement, DataTableHeaderCellProps>(
function DataTableHeaderCell(
{ align = "start", className, sort = false, sortable = false, ...props },
ref
) {
return (
<th
{...props}
{...createSlot("header")}
{...createDataAttributes({
sort: sort || undefined
})}
className={cn(dataTableHeaderCellVariants({ align, sortable }), className)}
ref={ref}
/>
);
}
);
export type DataTableBodyProps = ComponentPropsWithoutRef<"tbody">;
export const DataTableBody = forwardRef<HTMLTableSectionElement, DataTableBodyProps>(
function DataTableBody({ className, ...props }, ref) {
return (
<tbody
{...props}
className={cn(dataTableBodyVariants(), className)}
ref={ref}
/>
);
}
);
export type DataTableRowProps = ComponentPropsWithoutRef<"tr"> &
VariantProps<typeof dataTableRowVariants>;
export const DataTableRow = forwardRef<HTMLTableRowElement, DataTableRowProps>(
function DataTableRow(
{ className, interactive = true, selected = false, ...props },
ref
) {
return (
<tr
{...props}
{...createSlot("row")}
{...createDataAttributes({
selected
})}
className={cn(dataTableRowVariants({ interactive, selected }), className)}
ref={ref}
/>
);
}
);
export type DataTableCellProps = Omit<ComponentPropsWithoutRef<"td">, "align"> &
VariantProps<typeof dataTableCellVariants>;
export const DataTableCell = forwardRef<HTMLTableCellElement, DataTableCellProps>(
function DataTableCell({ align = "start", className, ...props }, ref) {
return (
<td
{...props}
{...createSlot("cell")}
className={cn(dataTableCellVariants({ align }), className)}
ref={ref}
/>
);
}
);
export type DataTableEmptyProps = ComponentPropsWithoutRef<"div"> & {
actions?: ReactNode;
description?: ReactNode;
title?: ReactNode;
};
export const DataTableEmpty = forwardRef<HTMLDivElement, DataTableEmptyProps>(
function DataTableEmpty(
{
actions,
className,
description = "No rows match the current search or filter state.",
title = "Nothing to show",
...props
},
ref
) {
return (
<div
{...props}
{...createSlot("empty")}
className={cn(dataTableStatusVariants(), className)}
ref={ref}
>
<EmptyState className="w-full border-none bg-transparent p-0 shadow-none">
<EmptyStateHeader>
<EmptyStateTitle>{title}</EmptyStateTitle>
<EmptyStateDescription>{description}</EmptyStateDescription>
</EmptyStateHeader>
{actions ? <EmptyStateActions>{actions}</EmptyStateActions> : null}
</EmptyState>
</div>
);
}
);
export type DataTableLoadingProps = ComponentPropsWithoutRef<"div"> & {
description?: ReactNode;
label?: ReactNode;
};
export const DataTableLoading = forwardRef<HTMLDivElement, DataTableLoadingProps>(
function DataTableLoading(
{
className,
description = "Preparing the next set of rows and controls.",
label = "Loading rows",
...props
},
ref
) {
return (
<div
{...props}
className={cn(dataTableStatusVariants(), className)}
ref={ref}
role="status"
>
<div className="grid justify-items-center gap-3 text-center">
<Spinner aria-label={String(label)} size="lg" tone="primary" />
<div className="space-y-1">
<p className="text-sm font-medium text-[var(--color-foreground)]">{label}</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
{description}
</p>
</div>
</div>
</div>
);
}
);
export type DataTablePaginationProps = ComponentPropsWithoutRef<"div">;
export const DataTablePagination = forwardRef<HTMLDivElement, DataTablePaginationProps>(
function DataTablePagination({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("pagination")}
className={cn(dataTablePaginationVariants(), className)}
ref={ref}
/>
);
}
);
export type DataTableSelectionBarProps = ComponentPropsWithoutRef<"div">;
export const DataTableSelectionBar = forwardRef<HTMLDivElement, DataTableSelectionBarProps>(
function DataTableSelectionBar({ className, ...props }, ref) {
return (
<div
{...props}
className={cn(dataTableSelectionBarVariants(), className)}
ref={ref}
/>
);
}
);
@@ -0,0 +1,119 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const dataTableRootVariants = cva("grid gap-4 text-[var(--color-foreground)]");
export const dataTableToolbarVariants = cva(
"flex flex-wrap items-center justify-between gap-3"
);
export const dataTableContentVariants = cva(
[
"overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)]",
"bg-[var(--color-card)] shadow-[var(--shadow-sm)]",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
loading: {
false: "",
true: "opacity-90"
}
},
defaultVariants: {
loading: false
}
}
);
export const dataTableTableVariants = cva("min-w-full border-collapse align-middle");
export const dataTableHeaderVariants = cva(
"bg-[color-mix(in_oklch,var(--color-surface)_74%,var(--color-card))]"
);
export const dataTableHeaderCellVariants = cva(
[
"px-4 py-3 text-sm font-medium uppercase tracking-[var(--tracking-caps)]",
"text-[var(--color-muted-foreground)]"
],
{
variants: {
align: {
start: "text-left",
center: "text-center",
end: "text-right"
},
sortable: {
false: "",
true: "select-none"
}
},
defaultVariants: {
align: "start",
sortable: false
}
}
);
export const dataTableBodyVariants = cva("");
export const dataTableRowVariants = cva(
[
"border-t border-[color-mix(in_oklch,var(--color-border)_88%,transparent)]",
"transition-colors duration-200"
],
{
variants: {
interactive: {
false: "",
true: "hover:bg-[color-mix(in_oklch,var(--color-surface)_72%,var(--color-card))]"
},
selected: {
false: "",
true: "bg-[color-mix(in_oklch,var(--color-primary)_8%,var(--color-card))]"
}
},
defaultVariants: {
interactive: true,
selected: false
}
}
);
export const dataTableCellVariants = cva(
"px-4 py-3 text-sm leading-6 text-[var(--color-card-foreground)]",
{
variants: {
align: {
start: "text-left",
center: "text-center",
end: "text-right"
}
},
defaultVariants: {
align: "start"
}
}
);
export const dataTableSearchContainerVariants = cva(
"w-full max-w-[22rem] min-w-[14rem]"
);
export const dataTablePaginationVariants = cva(
"flex flex-wrap items-center justify-between gap-3 px-4 py-3"
);
export const dataTableSelectionBarVariants = cva(
[
"flex flex-wrap items-center justify-between gap-3 rounded-[var(--radius-md)]",
"border border-[color-mix(in_oklch,var(--color-primary)_24%,var(--color-border))]",
"bg-[color-mix(in_oklch,var(--color-primary)_7%,var(--color-card))] px-4 py-3",
"shadow-[var(--shadow-xs)]"
]
);
export const dataTableStatusVariants = cva(
"grid min-h-52 place-items-center px-6 py-8"
);
+50
View File
@@ -53,6 +53,56 @@ export {
} from "./components/card.variants";
export { Checkbox, type CheckboxProps } from "./components/checkbox";
export { checkboxVariants } from "./components/checkbox.variants";
export {
DataTable,
DataTableBody,
DataTableCell,
DataTableContent,
DataTableEmpty,
DataTableFilters,
DataTableHeader,
DataTableHeaderCell,
DataTableLoading,
DataTablePagination,
DataTableRow,
DataTableSearch,
DataTableSelectionBar,
DataTableTable,
DataTableToolbar,
type DataTableAlignment,
type DataTableCellProps,
type DataTableColumn,
type DataTableColumnContext,
type DataTableContentProps,
type DataTableEmptyProps,
type DataTableFiltersProps,
type DataTableHeaderCellProps,
type DataTableHeaderProps,
type DataTableLoadingProps,
type DataTablePaginationProps,
type DataTableProps,
type DataTableRowProps,
type DataTableSearchProps,
type DataTableSelectionBarProps,
type DataTableSort,
type DataTableTableProps,
type DataTableToolbarProps
} from "./components/data-table";
export {
dataTableBodyVariants,
dataTableCellVariants,
dataTableContentVariants,
dataTableHeaderCellVariants,
dataTableHeaderVariants,
dataTablePaginationVariants,
dataTableRootVariants,
dataTableRowVariants,
dataTableSearchContainerVariants,
dataTableSelectionBarVariants,
dataTableStatusVariants,
dataTableTableVariants,
dataTableToolbarVariants
} from "./components/data-table.variants";
export {
Combobox,
type ComboboxItem,