feat(ui): expand workflow-ready components

This commit is contained in:
2026-03-20 18:11:48 +08:00
parent 36822f05e0
commit a8c1d3f256
27 changed files with 1562 additions and 85 deletions
+205 -7
View File
@@ -26,6 +26,16 @@ import {
import { Button } from "./button";
import { Checkbox } from "./checkbox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "./dropdown-menu";
import {
EmptyState,
EmptyStateActions,
@@ -34,6 +44,13 @@ import {
EmptyStateTitle
} from "./empty-state";
import { Input, type InputProps } from "./input";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from "./sheet";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
import { Skeleton } from "./skeleton";
import { Spinner } from "./spinner";
@@ -62,6 +79,7 @@ import {
} from "./data-table.variants";
export type DataTableAlignment = "start" | "center" | "end";
export type DataTableDensity = "comfortable" | "compact";
export type DataTableSort = {
desc?: boolean;
@@ -78,6 +96,7 @@ export type DataTableColumn<TData> = {
align?: DataTableAlignment;
cell?: (row: TData) => ReactNode;
header: ReactNode | ((column: DataTableColumnContext<TData>) => ReactNode);
hideable?: boolean;
id: string;
searchValue?: (row: TData) => string;
searchable?: boolean;
@@ -87,25 +106,34 @@ export type DataTableColumn<TData> = {
export type DataTableProps<TData> = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
columns: DataTableColumn<TData>[];
defaultDensity?: DataTableDensity;
defaultPageIndex?: number;
defaultPageSize?: number;
defaultSearchValue?: string;
defaultSelection?: Record<string, boolean>;
defaultSorting?: DataTableSort[];
defaultVisibleColumns?: Record<string, boolean>;
density?: DataTableDensity;
empty?: ReactNode;
enableSelection?: boolean;
getRowId?: (row: TData, index: number) => string;
loading?: boolean;
loadingRowCount?: number;
onDensityChange?: (density: DataTableDensity) => void;
onPageIndexChange?: (pageIndex: number) => void;
onPageSizeChange?: (pageSize: number) => void;
onSearchValueChange?: (searchValue: string) => void;
onSelectionChange?: (selection: Record<string, boolean>) => void;
onSortingChange?: (sorting: DataTableSort[]) => void;
onVisibleColumnsChange?: (visibility: Record<string, boolean>) => void;
pageIndex?: number;
pageSize?: number;
pageSizeOptions?: number[];
renderRowDetails?: (row: TData) => ReactNode;
renderRowActions?: (row: TData) => ReactNode;
rowDetailsDescription?: ReactNode | ((row: TData) => ReactNode);
rowDetailsLabel?: string;
rowDetailsTitle?: ReactNode | ((row: TData) => ReactNode);
rows: TData[];
searchLabel?: string;
searchPlaceholder?: string;
@@ -116,6 +144,7 @@ export type DataTableProps<TData> = Omit<ComponentPropsWithoutRef<"div">, "child
sorting?: DataTableSort[];
tableLabel?: string;
toolbarActions?: ReactNode;
visibleColumns?: Record<string, boolean>;
};
type InternalColumnMeta<TData> = {
@@ -212,25 +241,34 @@ function DataTableInner<TData>(
{
className,
columns,
defaultDensity = "comfortable",
defaultPageIndex = 0,
defaultPageSize = 5,
defaultSearchValue = "",
defaultSelection = {},
defaultSorting = [],
defaultVisibleColumns = {},
density,
empty,
enableSelection = false,
getRowId,
loading = false,
loadingRowCount = 5,
onDensityChange,
onPageIndexChange,
onPageSizeChange,
onSearchValueChange,
onSelectionChange,
onSortingChange,
onVisibleColumnsChange,
pageIndex,
pageSize,
pageSizeOptions = [5, 10, 20],
renderRowDetails,
renderRowActions,
rowDetailsDescription,
rowDetailsLabel = "Open details",
rowDetailsTitle,
rows,
searchLabel = "Search rows",
searchPlaceholder = "Search rows",
@@ -241,6 +279,7 @@ function DataTableInner<TData>(
sorting,
tableLabel = "Data table",
toolbarActions,
visibleColumns,
...props
}: DataTableProps<TData>,
ref: ForwardedRef<HTMLDivElement>
@@ -275,6 +314,19 @@ function DataTableInner<TData>(
defaultValue: defaultPageSize,
onChange: onPageSizeChange
});
const [currentDensity, setCurrentDensity] = useControllableState<DataTableDensity>({
controlledValue: density,
defaultValue: defaultDensity,
onChange: onDensityChange
});
const [currentVisibleColumns, setCurrentVisibleColumns] = useControllableState<
Record<string, boolean>
>({
controlledValue: visibleColumns,
defaultValue: defaultVisibleColumns,
onChange: onVisibleColumnsChange
});
const [detailRowId, setDetailRowId] = useState<string | null>(null);
const resolvedPageSizeOptions = [...new Set([...pageSizeOptions, currentPageSize])].sort(
(left, right) => left - right
@@ -325,6 +377,34 @@ function DataTableInner<TData>(
} satisfies ColumnDef<TData>
]
: []),
...(renderRowDetails
? [
{
cell: ({ row }) => (
<div {...createSlot("actions")} className="flex items-center justify-end">
<Button
size="sm"
variant="ghost"
onClick={() => {
setDetailRowId(row.id);
}}
>
{rowDetailsLabel}
</Button>
</div>
),
enableGlobalFilter: false,
enableHiding: false,
enableSorting: false,
header: () => <span className="sr-only">Row details</span>,
id: "__details",
meta: {
align: "end",
width: 108
} satisfies InternalColumnMeta<TData>
} satisfies ColumnDef<TData>
]
: []),
...columns.map((column) => ({
accessorFn: column.accessor
? (row: TData) => getColumnAccessorValue(row, column)
@@ -334,6 +414,7 @@ function DataTableInner<TData>(
? column.cell(row.original)
: stringifySearchValue(getColumnAccessorValue(row.original, column)),
enableGlobalFilter: searchableColumns.includes(column),
enableHiding: column.hideable ?? true,
enableSorting: column.sortable ?? false,
header: ({ column: tanstackColumn }: HeaderContext<TData, unknown>) =>
typeof column.header === "function"
@@ -424,12 +505,21 @@ function DataTableInner<TData>(
setCurrentSorting(nextValue.map((item) => ({ desc: item.desc, id: item.id })));
},
onColumnVisibilityChange: (updater) => {
const nextValue =
typeof updater === "function"
? updater(currentVisibleColumns)
: updater;
setCurrentVisibleColumns(nextValue);
},
state: {
globalFilter: currentSearchValue,
pagination: {
pageIndex: currentPageIndex,
pageSize: currentPageSize
} satisfies PaginationState,
columnVisibility: currentVisibleColumns,
rowSelection: currentSelection,
sorting: currentSorting as SortingState
}
@@ -437,11 +527,16 @@ function DataTableInner<TData>(
const totalColumns = table.getAllLeafColumns().length;
const filteredRowCount = table.getFilteredRowModel().rows.length;
const hideableColumns = table
.getAllLeafColumns()
.filter((column) => !column.id.startsWith("__") && column.getCanHide());
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 shouldRenderViewOptions = hideableColumns.length > 0;
const shouldRenderToolbar =
shouldRenderSearch || toolbarActions !== undefined || shouldRenderViewOptions;
const pageStart =
filteredRowCount === 0 ? 0 : currentPageIndex * currentPageSize + 1;
const pageEnd = Math.min((currentPageIndex + 1) * currentPageSize, filteredRowCount);
@@ -454,6 +549,25 @@ function DataTableInner<TData>(
}
}, [currentPageIndex, pageCount, setCurrentPageIndex]);
const detailRow = renderRowDetails
? table.getPrePaginationRowModel().rows.find((row) => row.id === detailRowId)
: undefined;
const detailRowOriginal = detailRow?.original;
const resolvedDetailTitle =
detailRowOriginal && rowDetailsTitle
? typeof rowDetailsTitle === "function"
? rowDetailsTitle(detailRowOriginal)
: rowDetailsTitle
: detailRowOriginal
? "Row details"
: null;
const resolvedDetailDescription =
detailRowOriginal && rowDetailsDescription
? typeof rowDetailsDescription === "function"
? rowDetailsDescription(detailRowOriginal)
: rowDetailsDescription
: null;
return (
<div
{...props}
@@ -464,6 +578,7 @@ function DataTableInner<TData>(
selected: selectedRows.length > 0
})}
className={cn(dataTableRootVariants(), className)}
data-density={currentDensity}
ref={ref}
>
{shouldRenderToolbar ? (
@@ -483,7 +598,53 @@ function DataTableInner<TData>(
</div>
) : null}
</div>
{toolbarActions ? <DataTableFilters>{toolbarActions}</DataTableFilters> : null}
<DataTableFilters>
{toolbarActions}
{shouldRenderViewOptions ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="subtle">
View
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Density</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={currentDensity}
onValueChange={(value) => {
setCurrentDensity(value as DataTableDensity);
}}
>
<DropdownMenuRadioItem value="comfortable">
Comfortable
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="compact">
Compact
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>Columns</DropdownMenuLabel>
{hideableColumns.map((column) => {
const meta = column.columnDef.meta as InternalColumnMeta<TData> | undefined;
const label =
meta?.sourceColumn ? getHeaderLabel(meta.sourceColumn) : column.id;
return (
<DropdownMenuCheckboxItem
checked={column.getIsVisible()}
key={column.id}
onCheckedChange={(checked) => {
column.toggleVisibility(Boolean(checked));
}}
>
{label}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</DataTableFilters>
</DataTableToolbar>
) : null}
@@ -533,6 +694,7 @@ function DataTableInner<TData>(
: undefined
}
scope="col"
density={currentDensity}
sortable={header.column.getCanSort()}
sort={sortState}
style={getColumnWidthStyle(meta?.width)}
@@ -618,6 +780,7 @@ function DataTableInner<TData>(
return (
<DataTableCell
align={align}
density={currentDensity}
key={cell.id}
style={getColumnWidthStyle(meta?.width)}
>
@@ -690,6 +853,27 @@ function DataTableInner<TData>(
</div>
</DataTablePagination>
</DataTableContent>
{renderRowDetails && detailRowOriginal ? (
<Sheet
open={detailRowId !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setDetailRowId(null);
}
}}
>
<SheetContent size="lg">
<SheetHeader>
<SheetTitle>{resolvedDetailTitle}</SheetTitle>
{resolvedDetailDescription ? (
<SheetDescription>{resolvedDetailDescription}</SheetDescription>
) : null}
</SheetHeader>
<div className="mt-6">{renderRowDetails(detailRowOriginal)}</div>
</SheetContent>
</Sheet>
) : null}
</div>
);
}
@@ -787,12 +971,20 @@ export const DataTableHeader = forwardRef<HTMLTableSectionElement, DataTableHead
export type DataTableHeaderCellProps = Omit<ComponentPropsWithoutRef<"th">, "align"> &
VariantProps<typeof dataTableHeaderCellVariants> & {
density?: DataTableDensity;
sort?: "asc" | "desc" | false;
};
export const DataTableHeaderCell = forwardRef<HTMLTableCellElement, DataTableHeaderCellProps>(
function DataTableHeaderCell(
{ align = "start", className, sort = false, sortable = false, ...props },
{
align = "start",
className,
density = "comfortable",
sort = false,
sortable = false,
...props
},
ref
) {
return (
@@ -800,9 +992,10 @@ export const DataTableHeaderCell = forwardRef<HTMLTableCellElement, DataTableHea
{...props}
{...createSlot("header")}
{...createDataAttributes({
density,
sort: sort || undefined
})}
className={cn(dataTableHeaderCellVariants({ align, sortable }), className)}
className={cn(dataTableHeaderCellVariants({ align, density, sortable }), className)}
ref={ref}
/>
);
@@ -846,15 +1039,20 @@ export const DataTableRow = forwardRef<HTMLTableRowElement, DataTableRowProps>(
);
export type DataTableCellProps = Omit<ComponentPropsWithoutRef<"td">, "align"> &
VariantProps<typeof dataTableCellVariants>;
VariantProps<typeof dataTableCellVariants> & {
density?: DataTableDensity;
};
export const DataTableCell = forwardRef<HTMLTableCellElement, DataTableCellProps>(
function DataTableCell({ align = "start", className, ...props }, ref) {
function DataTableCell(
{ align = "start", className, density = "comfortable", ...props },
ref
) {
return (
<td
{...props}
{...createSlot("cell")}
className={cn(dataTableCellVariants({ align }), className)}
className={cn(dataTableCellVariants({ align, density }), className)}
ref={ref}
/>
);