feat: add data table and release checks
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user