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
+66 -1
View File
@@ -42,7 +42,7 @@ describe("Combobox", () => {
it("renders a selected value, filters options, and updates uncontrolled state", async () => {
const user = userEvent.setup();
render(
const loadingView = render(
<Combobox
aria-label="Review lane"
defaultValue="design"
@@ -163,4 +163,69 @@ describe("Combobox", () => {
expect(trigger).toHaveAttribute("aria-describedby", expect.stringContaining(message.id));
expect(trigger.closest("[data-slot='root']")).toHaveAttribute("data-invalid", "");
});
it("supports loading, custom empty state, footer actions, and practical keyboard navigation", async () => {
const user = userEvent.setup();
const loadingView = render(
<Combobox
aria-label="Async review lane"
emptyMessage={(query) => `Create “${query}” as a new lane`}
footer={
<Button size="sm" variant="ghost">
Manage routing lanes
</Button>
}
items={reviewLaneItems}
loading
loadingMessage="Searching review lanes…"
/>
);
const trigger = screen.getByRole("combobox", { name: "Async review lane" });
await user.click(trigger);
expect(screen.getByText("Searching review lanes…")).toBeInTheDocument();
expect(screen.getByText("Manage routing lanes")).toBeInTheDocument();
loadingView.unmount();
render(
<Combobox
aria-label="Keyboard review lane"
emptyMessage={(query) => `Create “${query}” as a new lane`}
footer={<Button size="sm">Manage routing lanes</Button>}
items={reviewLaneItems}
/>
);
const keyboardTrigger = screen.getByRole("combobox", { name: "Keyboard review lane" });
await user.click(keyboardTrigger);
const searchbox = screen.getByRole("searchbox", { name: "Search options" });
await user.type(searchbox, "design");
await user.keyboard("{Escape}");
expect(searchbox).toHaveValue("");
await user.click(keyboardTrigger);
await user.keyboard("{Home}");
const firstOption = screen.getByRole("option", { name: /Editorial review/i });
expect(firstOption).toHaveAttribute("data-active", "");
await user.keyboard("{End}");
const lastOption = screen.getByRole("option", { name: /Legal review/i });
expect(lastOption).toHaveAttribute("data-active", "");
const refreshedSearchbox = screen.getByRole("searchbox", { name: "Search options" });
await user.clear(refreshedSearchbox);
await user.type(refreshedSearchbox, "security");
expect(screen.getByText("Create “security” as a new lane")).toBeInTheDocument();
await user.keyboard("{Tab}");
await waitFor(() => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
});
+67 -6
View File
@@ -7,7 +7,8 @@ import {
useRef,
useState,
type ComponentPropsWithoutRef,
type KeyboardEvent
type KeyboardEvent,
type ReactNode
} from "react";
import {
@@ -18,11 +19,12 @@ import {
comboboxLabelVariants,
comboboxListVariants,
comboboxSearchVariants,
comboboxTriggerVariants
comboboxTriggerVariants,
comboboxFooterVariants
} from "./combobox.variants";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { CheckIcon, ChevronDownIcon } from "../lib/icons";
import { CheckIcon, ChevronDownIcon, SpinnerIcon } from "../lib/icons";
import { useFieldContext } from "./field";
function mergeIds(...ids: Array<string | undefined>) {
@@ -68,9 +70,13 @@ export type ComboboxProps = Omit<
defaultOpen?: boolean;
defaultSearchValue?: string;
defaultValue?: string;
emptyMessage?: string;
emptyMessage?: ReactNode | ((query: string) => ReactNode);
filter?: (item: ComboboxItem, query: string) => boolean;
footer?: ReactNode;
invalid?: boolean;
items: ComboboxItem[];
loading?: boolean;
loadingMessage?: ReactNode;
onOpenChange?: (open: boolean) => void;
onSearchValueChange?: (value: string) => void;
onValueChange?: (value: string) => void;
@@ -89,9 +95,13 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
defaultValue,
disabled,
emptyMessage = "No matching results.",
filter,
footer,
id,
invalid,
items,
loading = false,
loadingMessage = "Searching…",
onOpenChange,
onSearchValueChange,
onValueChange,
@@ -137,6 +147,10 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
}
return items.filter((item) => {
if (filter) {
return filter(item, query);
}
const haystack = [item.label, item.value, ...(item.keywords ?? [])]
.join(" ")
.toLowerCase();
@@ -282,10 +296,45 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
if (event.key === "Escape") {
event.preventDefault();
if (resolvedSearchValue.length > 0) {
setSearchState("");
return;
}
setOpenState(false);
return;
}
if (event.key === "Home") {
event.preventDefault();
setActiveIndex(filteredItems.findIndex((item) => !item.disabled));
return;
}
if (event.key === "End") {
event.preventDefault();
const lastEnabledIndex = [...filteredItems]
.reverse()
.findIndex((item) => !item.disabled);
if (lastEnabledIndex >= 0) {
setActiveIndex(filteredItems.length - 1 - lastEnabledIndex);
}
return;
}
if (event.key === "Tab") {
setOpenState(false);
}
};
const renderedEmptyMessage =
typeof emptyMessage === "function"
? emptyMessage(resolvedSearchValue.trim())
: emptyMessage;
return (
<div
{...createSlot("root")}
@@ -353,9 +402,16 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
value={resolvedSearchValue}
/>
</div>
{filteredItems.length === 0 ? (
{loading ? (
<div {...createSlot("empty")} className={comboboxEmptyVariants()}>
{emptyMessage}
<span className="inline-flex items-center gap-2">
<SpinnerIcon className="size-4 animate-spin" />
{loadingMessage}
</span>
</div>
) : filteredItems.length === 0 ? (
<div {...createSlot("empty")} className={comboboxEmptyVariants()}>
{renderedEmptyMessage}
</div>
) : (
<div
@@ -428,6 +484,11 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
))}
</div>
)}
{footer ? (
<div {...createSlot("footer")} className={comboboxFooterVariants()}>
{footer}
</div>
) : null}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
@@ -48,3 +48,7 @@ export const comboboxItemVariants = cva([
export const comboboxEmptyVariants = cva([
"px-3 py-6 text-center text-sm text-[var(--color-muted-foreground)]"
]);
export const comboboxFooterVariants = cva([
"border-t border-[var(--ui-panel-border)] bg-[color-mix(in_oklch,var(--ui-panel-bg)_92%,var(--color-background))] px-2 py-2"
]);
@@ -4,6 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
import {
Command,
CommandDialog,
@@ -96,4 +97,60 @@ describe("Command", () => {
expect(screen.queryByPlaceholderText("Search across workspace")).not.toBeInTheDocument();
});
});
it("supports loading and footer content on the root command surface", async () => {
render(
<Command
footer={<Button size="sm">Manage commands</Button>}
label="Workspace palette"
loading
loadingMessage="Searching workspace…"
>
<CommandInput placeholder="Search workspace" />
<CommandList>
<CommandEmpty>No matching items.</CommandEmpty>
</CommandList>
</Command>
);
expect(screen.getByText("Searching workspace…")).toHaveAttribute("data-slot", "loading");
expect(screen.getByText("Manage commands")).toBeInTheDocument();
});
it("supports dialog title, description, and footer actions for a practical palette shell", async () => {
const user = userEvent.setup();
function CommandDialogExample() {
const [open, setOpen] = useState(true);
return (
<CommandDialog
description="Jump to docs, recent launches, and operational shortcuts."
footer={<Button size="sm">Manage shortcuts</Button>}
onOpenChange={setOpen}
open={open}
title="Workspace command palette"
>
<CommandInput placeholder="Search across workspace" />
<CommandList>
<CommandGroup heading="Recent">
<CommandItem value="recent-release">Recent release brief</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
);
}
render(<CommandDialogExample />);
expect(screen.getByText("Workspace command palette")).toBeInTheDocument();
expect(screen.getByText("Jump to docs, recent launches, and operational shortcuts.")).toBeInTheDocument();
expect(screen.getByText("Manage shortcuts")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Close dialog" }));
await waitFor(() => {
expect(screen.queryByText("Workspace command palette")).not.toBeInTheDocument();
});
});
});
+72 -5
View File
@@ -10,16 +10,24 @@ import {
import {
commandDialogContentVariants,
commandEmptyVariants,
commandFooterVariants,
commandGroupVariants,
commandInputVariants,
commandInputWrapperVariants,
commandItemVariants,
commandListVariants,
commandLoadingVariants,
commandSeparatorVariants,
commandShortcutVariants,
commandVariants
} from "./command.variants";
import { DialogContent } from "./dialog";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "./dialog";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts";
@@ -38,17 +46,49 @@ function SearchIcon() {
);
}
export type CommandProps = ComponentPropsWithoutRef<typeof CommandPrimitive>;
export type CommandProps = ComponentPropsWithoutRef<typeof CommandPrimitive> & {
footer?: ReactNode;
loading?: boolean;
loadingMessage?: ReactNode;
};
export const Command = forwardRef<ElementRef<typeof CommandPrimitive>, CommandProps>(
function Command({ className, ...props }, ref) {
function Command(
{
children,
className,
footer,
loading = false,
loadingMessage = "Loading results…",
...props
},
ref
) {
return (
<CommandPrimitive
{...props}
{...createSlot("root")}
className={cn(commandVariants(), className)}
ref={ref}
/>
>
{loading ? (
<div
{...createSlot("loading")}
className={commandLoadingVariants()}
>
{loadingMessage}
</div>
) : null}
{children}
{footer ? (
<div
{...createSlot("footer")}
className={commandFooterVariants()}
>
{footer}
</div>
) : null}
</CommandPrimitive>
);
}
);
@@ -57,18 +97,45 @@ export type CommandDialogProps = ComponentPropsWithoutRef<typeof DialogPrimitive
children?: ReactNode;
contentClassName?: string;
commandClassName?: string;
description?: ReactNode;
footer?: ReactNode;
loading?: boolean;
loadingMessage?: ReactNode;
title?: ReactNode;
};
export function CommandDialog({
children,
commandClassName,
contentClassName,
description,
footer,
loading,
loadingMessage,
title,
...props
}: CommandDialogProps) {
return (
<DialogPrimitive.Root {...props}>
<DialogContent className={cn(commandDialogContentVariants(), contentClassName)}>
<Command className={commandClassName}>{children}</Command>
{title || description ? (
<DialogHeader className="border-b border-[var(--ui-panel-border)] px-4 py-4">
{title ? <DialogTitle>{title}</DialogTitle> : null}
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
) : null}
<Command
className={commandClassName}
footer={
footer ? (
<DialogFooter className="p-0">{footer}</DialogFooter>
) : undefined
}
loading={loading}
loadingMessage={loadingMessage}
>
{children}
</Command>
</DialogContent>
</DialogPrimitive.Root>
);
@@ -25,6 +25,10 @@ export const commandListVariants = cva([
"max-h-[22rem] overflow-y-auto overflow-x-hidden p-2"
]);
export const commandLoadingVariants = cva([
"px-4 py-8 text-sm text-[var(--color-muted-foreground)]"
]);
export const commandEmptyVariants = cva([
"py-10 text-center text-sm text-[var(--color-muted-foreground)]"
]);
@@ -51,3 +55,7 @@ export const commandSeparatorVariants = cva([
export const commandShortcutVariants = cva([
"ml-auto text-[0.7rem] uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
]);
export const commandFooterVariants = cva([
"border-t border-[var(--ui-panel-border)] bg-[color-mix(in_oklch,var(--ui-panel-bg)_94%,var(--color-background))] px-4 py-3"
]);
@@ -239,4 +239,81 @@ describe("DataTable", () => {
expect(onSortingChange).toHaveBeenCalledWith([{ desc: false, id: "lane" }]);
expect(onSelectionChange).toHaveBeenCalledWith({ support: true });
});
it("toggles hideable columns from the built-in view menu", async () => {
const user = userEvent.setup();
render(
<DataTable
columns={[
{
accessor: "lane",
header: "Lane",
hideable: false,
id: "lane"
},
{
accessor: "owner",
header: "Owner",
hideable: true,
id: "owner"
}
]}
rows={rows.slice(0, 2)}
/>
);
expect(screen.getByRole("columnheader", { name: /owner/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "View" }));
await user.click(screen.getByRole("menuitemcheckbox", { name: "Owner" }));
expect(screen.queryByRole("columnheader", { name: /owner/i })).not.toBeInTheDocument();
expect(screen.queryByText("Ava")).not.toBeInTheDocument();
});
it("switches density from the built-in view menu", async () => {
const user = userEvent.setup();
render(<DataTable columns={columns} rows={rows.slice(0, 2)} />);
const root = screen.getByRole("table").closest('[data-slot="root"]');
expect(root).toHaveAttribute("data-density", "comfortable");
await user.click(screen.getByRole("button", { name: "View" }));
await user.click(screen.getByRole("menuitemradio", { name: "Compact" }));
expect(root).toHaveAttribute("data-density", "compact");
expect(screen.getByRole("columnheader", { name: /lane/i })).toHaveAttribute(
"data-density",
"compact"
);
});
it("opens row details inside a sheet", async () => {
const user = userEvent.setup();
render(
<DataTable
columns={columns}
renderRowDetails={(row) => <div>{row.note}</div>}
rowDetailsDescription={(row) => `${row.lane} handoff`}
rowDetailsTitle={(row) => `${row.lane} detail`}
rows={rows.slice(0, 2)}
/>
);
await user.click(screen.getAllByRole("button", { name: "Open details" })[0]);
const detailHeading = await screen.findByText("Legal detail");
const sheet = detailHeading.closest('[data-slot="content"]');
expect(detailHeading).toBeInTheDocument();
expect(screen.getByText("Legal handoff")).toBeInTheDocument();
expect(
within(sheet as HTMLElement).getByText(
"Footnote needs one more pass before the customer note goes out."
)
).toBeInTheDocument();
});
});
+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}
/>
);
@@ -33,7 +33,7 @@ export const dataTableHeaderVariants = cva(
export const dataTableHeaderCellVariants = cva(
[
"px-4 py-3 text-sm font-medium uppercase tracking-[var(--tracking-caps)]",
"px-4 text-sm font-medium uppercase tracking-[var(--tracking-caps)]",
"text-[var(--color-muted-foreground)]"
],
{
@@ -43,6 +43,10 @@ export const dataTableHeaderCellVariants = cva(
center: "text-center",
end: "text-right"
},
density: {
comfortable: "py-3",
compact: "py-2.5 text-xs"
},
sortable: {
false: "",
true: "select-none"
@@ -50,6 +54,7 @@ export const dataTableHeaderCellVariants = cva(
},
defaultVariants: {
align: "start",
density: "comfortable",
sortable: false
}
}
@@ -81,17 +86,22 @@ export const dataTableRowVariants = cva(
);
export const dataTableCellVariants = cva(
"px-4 py-3 text-sm leading-6 text-[var(--color-card-foreground)]",
"px-4 text-[var(--color-card-foreground)]",
{
variants: {
align: {
start: "text-left",
center: "text-center",
end: "text-right"
},
density: {
comfortable: "py-3 text-sm leading-6",
compact: "py-2.5 text-[0.8125rem] leading-5"
}
},
defaultVariants: {
align: "start"
align: "start",
density: "comfortable"
}
}
);
@@ -12,6 +12,9 @@ import {
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuTrigger
} from "./dropdown-menu";
@@ -58,6 +61,52 @@ describe("DropdownMenu", () => {
expect(onSelect).toHaveBeenCalledTimes(1);
});
it("renders richer row content such as leading icons, descriptions, shortcuts, and submenu descriptions", async () => {
const user = userEvent.setup();
render(
<DropdownMenu>
<DropdownMenuTrigger>Open menu</DropdownMenuTrigger>
<DropdownMenuContent size="xl">
<DropdownMenuItem
description="Inspect the latest reviewer summary."
leading={<span data-testid="leading-icon"></span>}
shortcut="R"
>
Review summary
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger description="Open more contextual actions.">
More actions
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem description="Archive this release safely.">
Archive release
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
);
await user.click(screen.getByRole("button", { name: "Open menu" }));
const menu = await screen.findByRole("menu");
expect(menu).toHaveAttribute("data-size", "xl");
expect(screen.getByTestId("leading-icon").closest('[data-slot="leading"]')).toBeInTheDocument();
expect(screen.getByText("Inspect the latest reviewer summary.")).toHaveAttribute(
"data-slot",
"description"
);
expect(screen.getByText("R")).toHaveAttribute("data-slot", "shortcut");
await user.hover(screen.getByText("More actions"));
expect(await screen.findByText("Open more contextual actions.")).toHaveAttribute(
"data-slot",
"description"
);
});
it("closes on Escape and supports controlled opening", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
+122 -13
View File
@@ -3,12 +3,18 @@ import {
forwardRef,
type ComponentPropsWithoutRef,
type ElementRef,
type HTMLAttributes
type HTMLAttributes,
type PropsWithChildren,
type ReactNode
} from "react";
import {
dropdownMenuContentVariants,
dropdownMenuItemBodyVariants,
dropdownMenuItemDescriptionVariants,
dropdownMenuItemVariants,
dropdownMenuItemLabelVariants,
dropdownMenuItemLeadingVariants,
dropdownMenuLabelVariants,
dropdownMenuSeparatorVariants
} from "./dropdown-menu.variants";
@@ -24,6 +30,52 @@ export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
type DropdownMenuRichItemProps = {
description?: ReactNode;
leading?: ReactNode;
shortcut?: ReactNode;
};
function DropdownMenuItemContent({
children,
description,
leading,
shortcut
}: PropsWithChildren<DropdownMenuRichItemProps>) {
return (
<>
{leading ? (
<span
{...createSlot("leading")}
className={cn(dropdownMenuItemLeadingVariants())}
>
{leading}
</span>
) : null}
<span
{...createSlot("body")}
className={cn(dropdownMenuItemBodyVariants())}
>
<span
{...createSlot("label")}
className={cn(dropdownMenuItemLabelVariants())}
>
{children}
</span>
{description ? (
<span
{...createSlot("description")}
className={cn(dropdownMenuItemDescriptionVariants())}
>
{description}
</span>
) : null}
</span>
{shortcut ? <DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut> : null}
</>
);
}
export type DropdownMenuContentProps =
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> &
VariantProps<typeof dropdownMenuContentVariants>;
@@ -76,13 +128,14 @@ export const DropdownMenuSubContent = forwardRef<
export type DropdownMenuItemProps =
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> &
VariantProps<typeof dropdownMenuItemVariants>;
VariantProps<typeof dropdownMenuItemVariants> &
DropdownMenuRichItemProps;
export const DropdownMenuItem = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.Item>,
DropdownMenuItemProps
>(function DropdownMenuItem(
{ className, inset, variant, ...props },
{ children, className, description, inset, leading, shortcut, variant, ...props },
ref
) {
return (
@@ -92,19 +145,37 @@ export const DropdownMenuItem = forwardRef<
{...createDataAttributes({ inset, variant })}
className={cn(dropdownMenuItemVariants({ inset, variant }), className)}
ref={ref}
/>
>
<DropdownMenuItemContent
description={description}
leading={leading}
shortcut={shortcut}
>
{children}
</DropdownMenuItemContent>
</DropdownMenuPrimitive.Item>
);
});
export type DropdownMenuCheckboxItemProps =
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> &
VariantProps<typeof dropdownMenuItemVariants>;
VariantProps<typeof dropdownMenuItemVariants> &
Omit<DropdownMenuRichItemProps, "leading">;
export const DropdownMenuCheckboxItem = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
DropdownMenuCheckboxItemProps
>(function DropdownMenuCheckboxItem(
{ checked, children, className, inset = true, variant, ...props },
{
checked,
children,
className,
description,
inset = true,
shortcut,
variant,
...props
},
ref
) {
return (
@@ -124,20 +195,34 @@ export const DropdownMenuCheckboxItem = forwardRef<
<CheckIcon className="size-3" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
<DropdownMenuItemContent
description={description}
shortcut={shortcut}
>
{children}
</DropdownMenuItemContent>
</DropdownMenuPrimitive.CheckboxItem>
);
});
export type DropdownMenuRadioItemProps =
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> &
VariantProps<typeof dropdownMenuItemVariants>;
VariantProps<typeof dropdownMenuItemVariants> &
Omit<DropdownMenuRichItemProps, "leading">;
export const DropdownMenuRadioItem = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
DropdownMenuRadioItemProps
>(function DropdownMenuRadioItem(
{ children, className, inset = true, variant, ...props },
{
children,
className,
description,
inset = true,
shortcut,
variant,
...props
},
ref
) {
return (
@@ -156,7 +241,12 @@ export const DropdownMenuRadioItem = forwardRef<
<DotIcon className="size-2.5" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
<DropdownMenuItemContent
description={description}
shortcut={shortcut}
>
{children}
</DropdownMenuItemContent>
</DropdownMenuPrimitive.RadioItem>
);
});
@@ -196,13 +286,14 @@ export const DropdownMenuSeparator = forwardRef<
export type DropdownMenuSubTriggerProps =
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> &
VariantProps<typeof dropdownMenuItemVariants>;
VariantProps<typeof dropdownMenuItemVariants> &
Pick<DropdownMenuRichItemProps, "description">;
export const DropdownMenuSubTrigger = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
DropdownMenuSubTriggerProps
>(function DropdownMenuSubTrigger(
{ children, className, inset, variant, ...props },
{ children, className, description, inset, variant, ...props },
ref
) {
return (
@@ -213,7 +304,25 @@ export const DropdownMenuSubTrigger = forwardRef<
className={cn(dropdownMenuItemVariants({ inset, variant }), className)}
ref={ref}
>
{children}
<span
{...createSlot("body")}
className={cn(dropdownMenuItemBodyVariants())}
>
<span
{...createSlot("label")}
className={cn(dropdownMenuItemLabelVariants())}
>
{children}
</span>
{description ? (
<span
{...createSlot("description")}
className={cn(dropdownMenuItemDescriptionVariants())}
>
{description}
</span>
) : null}
</span>
<ChevronRightIcon className="ml-auto size-3 text-[var(--color-muted-foreground)]" />
</DropdownMenuPrimitive.SubTrigger>
);
@@ -12,7 +12,8 @@ export const dropdownMenuContentVariants = cva(
size: {
sm: "min-w-[11rem]",
md: "min-w-[13rem]",
lg: "min-w-[15rem]"
lg: "min-w-[15rem]",
xl: "min-w-[18rem]"
}
},
defaultVariants: {
@@ -23,7 +24,7 @@ export const dropdownMenuContentVariants = cva(
export const dropdownMenuItemVariants = cva(
[
"relative flex cursor-default select-none items-center gap-2 rounded-[var(--ui-control-radius)] px-2.5 py-2 text-sm outline-none",
"relative flex min-w-0 cursor-default select-none items-center gap-2 rounded-[var(--ui-control-radius)] px-2.5 py-2 text-sm outline-none",
"text-[var(--color-foreground)] transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"focus:bg-[var(--ui-control-bg)] focus:text-[var(--color-foreground)] data-[highlighted]:bg-[var(--ui-control-bg)] data-[highlighted]:text-[var(--color-foreground)]",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-45"
@@ -47,6 +48,22 @@ export const dropdownMenuItemVariants = cva(
}
);
export const dropdownMenuItemBodyVariants = cva([
"grid min-w-0 flex-1 gap-0.5"
]);
export const dropdownMenuItemLabelVariants = cva([
"truncate text-sm font-medium text-[var(--color-foreground)]"
]);
export const dropdownMenuItemDescriptionVariants = cva([
"text-xs leading-5 text-[var(--color-muted-foreground)]"
]);
export const dropdownMenuItemLeadingVariants = cva([
"inline-flex size-4 shrink-0 items-center justify-center text-[var(--color-muted-foreground)]"
]);
export const dropdownMenuLabelVariants = cva(
[
"px-2.5 py-2 text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
@@ -56,4 +56,52 @@ describe("EmptyState", () => {
"items-start"
);
});
it("supports compact and split layout variants with slot metadata", () => {
render(
<>
<EmptyState
align="start"
data-testid="compact"
layout="compact"
tone="default"
>
<EmptyStateMedia size="compact">Q1</EmptyStateMedia>
<EmptyStateHeader align="start">
<EmptyStateTitle>No queue activity</EmptyStateTitle>
</EmptyStateHeader>
<EmptyStateActions layout="stack">
<Button size="sm">Create task</Button>
</EmptyStateActions>
</EmptyState>
<EmptyState
align="start"
data-testid="split"
layout="split"
tone="accent"
>
<EmptyStateHeader align="start">
<EmptyStateEyebrow>Workspace</EmptyStateEyebrow>
<EmptyStateTitle>Invite the first operator</EmptyStateTitle>
</EmptyStateHeader>
<EmptyStateMedia size="hero">42</EmptyStateMedia>
<EmptyStateActions layout="inline">
<Button size="sm">Invite operator</Button>
</EmptyStateActions>
</EmptyState>
</>
);
expect(screen.getByTestId("compact")).toHaveAttribute("data-layout", "compact");
expect(screen.getByTestId("compact")).toHaveAttribute("data-align", "start");
expect(screen.getByText("Q1")).toHaveAttribute("data-size", "compact");
expect(
screen.getByRole("button", { name: "Create task" }).closest('[data-slot="actions"]')
).toHaveAttribute("data-layout", "stack");
expect(screen.getByTestId("split")).toHaveAttribute("data-layout", "split");
expect(screen.getByText("Workspace")).toHaveAttribute("data-slot", "eyebrow");
expect(screen.getByText("42")).toHaveAttribute("data-size", "hero");
});
});
+18 -12
View File
@@ -17,44 +17,48 @@ export type EmptyStateProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof emptyStateVariants>;
export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(function EmptyState(
{ className, tone, ...props },
{ align, className, layout, tone, ...props },
ref
) {
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({ tone })}
className={cn(emptyStateVariants({ tone }), className)}
{...createDataAttributes({ align, layout, tone })}
className={cn(emptyStateVariants({ align, layout, tone }), className)}
ref={ref}
/>
);
});
export type EmptyStateMediaProps = ComponentPropsWithoutRef<"div">;
export type EmptyStateMediaProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof emptyStateMediaVariants>;
export const EmptyStateMedia = forwardRef<HTMLDivElement, EmptyStateMediaProps>(
function EmptyStateMedia({ className, ...props }, ref) {
function EmptyStateMedia({ className, size, ...props }, ref) {
return (
<div
{...props}
{...createSlot("media")}
className={cn(emptyStateMediaVariants(), className)}
{...createDataAttributes({ size })}
className={cn(emptyStateMediaVariants({ size }), className)}
ref={ref}
/>
);
}
);
export type EmptyStateHeaderProps = ComponentPropsWithoutRef<"div">;
export type EmptyStateHeaderProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof emptyStateHeaderVariants>;
export const EmptyStateHeader = forwardRef<HTMLDivElement, EmptyStateHeaderProps>(
function EmptyStateHeader({ className, ...props }, ref) {
function EmptyStateHeader({ align, className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(emptyStateHeaderVariants(), className)}
{...createDataAttributes({ align })}
className={cn(emptyStateHeaderVariants({ align }), className)}
ref={ref}
/>
);
@@ -107,15 +111,17 @@ export const EmptyStateDescription = forwardRef<
);
});
export type EmptyStateActionsProps = ComponentPropsWithoutRef<"div">;
export type EmptyStateActionsProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof emptyStateActionsVariants>;
export const EmptyStateActions = forwardRef<HTMLDivElement, EmptyStateActionsProps>(
function EmptyStateActions({ className, ...props }, ref) {
function EmptyStateActions({ className, layout, ...props }, ref) {
return (
<div
{...props}
{...createSlot("actions")}
className={cn(emptyStateActionsVariants(), className)}
{...createDataAttributes({ layout })}
className={cn(emptyStateActionsVariants({ layout }), className)}
ref={ref}
/>
);
@@ -3,8 +3,8 @@ import { getMotionRecipeClassNames } from "../lib/motion";
export const emptyStateVariants = cva(
[
"grid gap-6 rounded-[var(--ui-card-radius)] border p-8 shadow-[var(--ui-card-default-shadow)] sm:p-10 [border-width:var(--ui-card-border-width)]",
"justify-items-center text-center text-[var(--color-card-foreground)]",
"grid gap-6 rounded-[var(--ui-card-radius)] border shadow-[var(--ui-card-default-shadow)] [border-width:var(--ui-card-border-width)]",
"text-[var(--color-card-foreground)]",
getMotionRecipeClassNames("transition", "ring")
],
{
@@ -15,23 +15,58 @@ export const emptyStateVariants = cva(
"border-[var(--ui-card-subtle-border)] bg-[var(--ui-card-subtle-bg)] shadow-[var(--ui-card-subtle-shadow)]",
accent:
"border-[var(--ui-card-accent-border)] bg-[var(--ui-card-accent-bg)] shadow-[var(--ui-card-accent-shadow)]"
},
layout: {
default: "p-8 sm:p-10",
compact:
"gap-4 p-6 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center sm:[&>[data-slot=media]]:row-span-2 sm:[&>[data-slot=header]]:justify-items-start sm:[&>[data-slot=actions]]:col-start-2 sm:[&>[data-slot=actions]]:justify-start",
split:
"p-6 sm:p-8 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center lg:[&>[data-slot=media]]:col-start-2 lg:[&>[data-slot=media]]:row-span-2 lg:[&>[data-slot=media]]:justify-self-end lg:[&>[data-slot=header]]:col-start-1 lg:[&>[data-slot=actions]]:col-start-1 lg:[&>[data-slot=actions]]:justify-start"
},
align: {
center: "justify-items-center text-center",
start: "justify-items-start text-left"
}
},
defaultVariants: {
tone: "default"
tone: "default",
layout: "default",
align: "center"
}
}
);
export const emptyStateMediaVariants = cva(
[
"grid min-h-20 min-w-20 place-items-center rounded-[var(--ui-card-radius)] border p-4 [border-width:var(--ui-card-border-width)]",
"grid place-items-center rounded-[var(--ui-card-radius)] border [border-width:var(--ui-card-border-width)]",
"border-[var(--ui-card-subtle-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-background)_72%,white_28%),var(--ui-card-subtle-bg))]",
"text-[var(--color-foreground)] shadow-[var(--ui-card-subtle-shadow)]"
]
],
{
variants: {
size: {
compact: "min-h-16 min-w-16 p-3",
default: "min-h-20 min-w-20 p-4",
hero: "min-h-28 min-w-28 p-6"
}
},
defaultVariants: {
size: "default"
}
}
);
export const emptyStateHeaderVariants = cva("grid max-w-[34rem] gap-2 justify-items-center");
export const emptyStateHeaderVariants = cva("grid max-w-[34rem] gap-2", {
variants: {
align: {
center: "justify-items-center text-center",
start: "justify-items-start text-left"
}
},
defaultVariants: {
align: "center"
}
});
export const emptyStateEyebrowVariants = cva(
"text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
@@ -45,6 +80,14 @@ export const emptyStateDescriptionVariants = cva(
"max-w-[30rem] text-sm leading-6 text-[var(--color-muted-foreground)]"
);
export const emptyStateActionsVariants = cva(
"flex flex-wrap items-center justify-center gap-3"
);
export const emptyStateActionsVariants = cva("flex flex-wrap items-center gap-3", {
variants: {
layout: {
inline: "justify-center",
stack: "flex-col justify-start sm:flex-row"
}
},
defaultVariants: {
layout: "inline"
}
});
@@ -40,6 +40,28 @@ describe("Popover", () => {
});
});
it("supports panel-style composition with explicit padding variants", async () => {
const user = userEvent.setup();
render(
<Popover>
<PopoverTrigger>Open inspector</PopoverTrigger>
<PopoverContent padding="none" size="xl">
<div>Inspector header</div>
<PopoverArrow />
</PopoverContent>
</Popover>
);
await user.click(screen.getByRole("button", { name: "Open inspector" }));
const header = await screen.findByText("Inspector header");
const content = header.closest('[data-slot="content"]');
expect(content).toHaveAttribute("data-padding", "none");
expect(content).toHaveAttribute("data-size", "xl");
});
it("reports controlled open changes from the trigger", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
+4 -4
View File
@@ -19,7 +19,7 @@ export const PopoverContent = forwardRef<
ElementRef<typeof PopoverPrimitive.Content>,
PopoverContentProps
>(function PopoverContent(
{ className, sideOffset = 10, size, ...props },
{ className, padding, sideOffset = 10, size, ...props },
ref
) {
return (
@@ -27,8 +27,8 @@ export const PopoverContent = forwardRef<
<PopoverPrimitive.Content
{...props}
{...createSlot("content")}
{...createDataAttributes({ size })}
className={cn(popoverContentVariants({ size }), className)}
{...createDataAttributes({ padding, size })}
className={cn(popoverContentVariants({ padding, size }), className)}
ref={ref}
sideOffset={sideOffset}
/>
@@ -44,7 +44,7 @@ export const PopoverArrow = forwardRef<
<PopoverPrimitive.Arrow
{...props}
{...createSlot("arrow")}
className={cn("fill-[var(--color-card)]", className)}
className={cn("fill-[var(--ui-panel-bg)]", className)}
ref={ref}
/>
);
@@ -9,13 +9,21 @@ export const popoverContentVariants = cva(
],
{
variants: {
padding: {
none: "p-0",
sm: "p-3",
md: "p-4",
lg: "p-5"
},
size: {
sm: "w-64",
md: "w-80",
lg: "w-[24rem]"
lg: "w-[24rem]",
xl: "w-[30rem]"
}
},
defaultVariants: {
padding: "md",
size: "md"
}
}