feat(ui): expand workflow-ready components
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user