feat(ui): expand workflow-ready components
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,3 +131,24 @@ export function SortUnsortedIcon({ className, ...props }: IconProps) {
|
||||
</IconFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpinnerIcon({ className, ...props }: IconProps) {
|
||||
return (
|
||||
<IconFrame className={className} {...props}>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
opacity="0.22"
|
||||
r="5.25"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
/>
|
||||
<path
|
||||
d="M8 2.75A5.25 5.25 0 0 1 13.25 8"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.75"
|
||||
/>
|
||||
</IconFrame>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user