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
+5
View File
@@ -0,0 +1,5 @@
---
"@ai-ui/ui": minor
---
Expand empty states, table display controls, row detail sheets, and richer command, combobox, popover, and dropdown menu compositions for real product workflows.
@@ -62,6 +62,83 @@ function ControlledDemo() {
); );
} }
function RecentAndSuggestedDemo() {
const [value, setValue] = useState("");
const items = [
{
value: "recent-legal",
label: "Legal review",
group: "Recent",
description: "Last used in yesterdays policy update."
},
{
value: "recent-design",
label: "Design review",
group: "Recent",
description: "Common pick for UI launches."
},
...teamItems
];
return (
<div className="grid w-[420px] gap-4">
<Combobox
aria-label="Suggested routing team"
emptyMessage={(query) => `No team named “${query}”. Create a custom routing lane instead.`}
footer={
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-[var(--color-muted-foreground)]">
Need a specialist lane?
</p>
<Button size="sm" variant="ghost">
Create lane
</Button>
</div>
}
items={items}
onValueChange={setValue}
searchPlaceholder="Search recent and suggested teams"
value={value}
/>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-card)] px-4 py-3 text-sm text-[var(--color-muted-foreground)] shadow-[var(--shadow-xs)]">
Current routing lane:{" "}
<span className="font-medium text-[var(--color-foreground)]">
{value || "No lane selected"}
</span>
</div>
</div>
);
}
function AsyncResultsDemo() {
const [searchValue, setSearchValue] = useState("");
const trimmedSearch = searchValue.trim().toLowerCase();
const isSearching = trimmedSearch.length > 0 && trimmedSearch.length < 3;
return (
<div className="grid w-[420px] gap-4">
<Combobox
aria-label="Async routing search"
emptyMessage={(query) =>
`No routing lane matched “${query}”. Try a broader keyword or create a new lane.`
}
items={[...teamItems]}
loading={isSearching}
loadingMessage="Searching routing lanes…"
onSearchValueChange={setSearchValue}
searchPlaceholder="Type at least 3 characters"
searchValue={searchValue}
/>
<p className="m-0 text-sm leading-6 text-[var(--color-muted-foreground)]">
This pattern is useful when the results come from an API and you need a clear
transition between loading, empty, and selectable states.
</p>
</div>
);
}
function LaunchRoutingForm() { function LaunchRoutingForm() {
const [submitted, setSubmitted] = useState<Record<string, string> | null>(null); const [submitted, setSubmitted] = useState<Record<string, string> | null>(null);
const form = useForm<{ team: string }>({ const form = useForm<{ team: string }>({
@@ -158,6 +235,14 @@ export const Controlled: Story = {
render: () => <ControlledDemo /> render: () => <ControlledDemo />
}; };
export const RecentAndSuggested: Story = {
render: () => <RecentAndSuggestedDemo />
};
export const AsyncResults: Story = {
render: () => <AsyncResultsDemo />
};
export const WithForm: Story = { export const WithForm: Story = {
render: () => <LaunchRoutingForm /> render: () => <LaunchRoutingForm />
}; };
+86 -1
View File
@@ -55,13 +55,74 @@ function InlineCommandShowcase() {
); );
} }
function OperationsWorkbenchShowcase() {
return (
<Command
className="w-[560px]"
footer={
<div className="flex items-center justify-between gap-3">
<p className="m-0 text-xs text-[var(--color-muted-foreground)]">
Tip: press <kbd className="rounded border px-1.5 py-0.5">Enter</kbd> to run the highlighted action.
</p>
<Button size="sm" variant="ghost">
Manage actions
</Button>
</div>
}
label="Operations workbench"
loop
>
<CommandInput placeholder="Search releases, docs, and recent work" />
<CommandList>
<CommandEmpty>No matching actions.</CommandEmpty>
<CommandGroup heading="Recent">
<CommandItem keywords={["launch", "release"]} value="recent-launch-review">
Launch review
<CommandShortcut>R</CommandShortcut>
</CommandItem>
<CommandItem keywords={["blocked", "queue"]} value="recent-blocked-queue">
Blocked queue
<CommandShortcut>B</CommandShortcut>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Suggestions">
<CommandItem keywords={["docs", "stories"]} value="docs-storybook">
Open Storybook docs
<CommandShortcut>G D</CommandShortcut>
</CommandItem>
<CommandItem disabled value="compliance-review">
Compliance review locked
<CommandShortcut>Locked</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
);
}
function DialogCommandShowcase() { function DialogCommandShowcase() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
<div className="flex justify-center"> <div className="flex justify-center">
<Button onClick={() => setOpen(true)}>Open command palette</Button> <Button onClick={() => setOpen(true)}>Open command palette</Button>
<CommandDialog onOpenChange={setOpen} open={open}> <CommandDialog
description="Jump to docs, recent launches, and operational shortcuts without leaving the current view."
footer={
<div className="flex items-center justify-between gap-3">
<p className="m-0 text-xs text-[var(--color-muted-foreground)]">
Need a new action? Add it to the workspace command registry.
</p>
<Button size="sm" variant="ghost">
Manage shortcuts
</Button>
</div>
}
onOpenChange={setOpen}
open={open}
title="Workspace command palette"
>
<CommandInput placeholder="Type a command or search" /> <CommandInput placeholder="Type a command or search" />
<CommandList> <CommandList>
<CommandEmpty>No commands available.</CommandEmpty> <CommandEmpty>No commands available.</CommandEmpty>
@@ -87,6 +148,22 @@ function DialogCommandShowcase() {
); );
} }
function LoadingResultsShowcase() {
return (
<Command
className="w-[520px]"
label="Remote workspace search"
loading
loadingMessage="Searching remote workspace actions…"
>
<CommandInput placeholder="Search actions from all workspaces" />
<CommandList>
<CommandEmpty>No commands available.</CommandEmpty>
</CommandList>
</Command>
);
}
const meta = { const meta = {
title: "Components/Command", title: "Components/Command",
component: InlineCommandShowcase, component: InlineCommandShowcase,
@@ -104,6 +181,14 @@ export const Playground: Story = {
render: () => <InlineCommandShowcase /> render: () => <InlineCommandShowcase />
}; };
export const OperationsWorkbench: Story = {
render: () => <OperationsWorkbenchShowcase />
};
export const DialogPalette: Story = { export const DialogPalette: Story = {
render: () => <DialogCommandShowcase /> render: () => <DialogCommandShowcase />
}; };
export const LoadingResults: Story = {
render: () => <LoadingResultsShowcase />
};
@@ -128,6 +128,7 @@ const routingColumns: DataTableColumn<RoutingLaneRow>[] = [
</div> </div>
), ),
header: "Lane", header: "Lane",
hideable: false,
id: "lane", id: "lane",
sortable: true, sortable: true,
width: "18rem" width: "18rem"
@@ -141,6 +142,7 @@ const routingColumns: DataTableColumn<RoutingLaneRow>[] = [
</div> </div>
), ),
header: "Owner", header: "Owner",
hideable: true,
id: "owner", id: "owner",
sortable: true, sortable: true,
width: "15rem" width: "15rem"
@@ -166,6 +168,7 @@ const routingColumns: DataTableColumn<RoutingLaneRow>[] = [
</div> </div>
), ),
header: "Signal", header: "Signal",
hideable: true,
id: "state", id: "state",
sortable: true, sortable: true,
width: "12rem" width: "12rem"
@@ -178,6 +181,7 @@ const routingColumns: DataTableColumn<RoutingLaneRow>[] = [
</p> </p>
), ),
header: "Routing note", header: "Routing note",
hideable: true,
id: "note", id: "note",
width: "34rem" width: "34rem"
} }
@@ -237,6 +241,46 @@ function RoutingEmptyState({ onReset }: { onReset: () => void }) {
); );
} }
function RoutingLaneDetail({ row }: { row: RoutingLaneRow }) {
return (
<div className="grid gap-4">
<div className="grid gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-xs)]">
<div className="flex flex-wrap items-center gap-2">
<Badge size="sm" tone={getStateTone(row.state)} variant="outline">
{row.state}
</Badge>
<Badge size="sm" variant="outline">
{row.lane}
</Badge>
<Badge size="sm" variant="outline">
{row.audience}
</Badge>
</div>
<p className="text-sm leading-6 text-[var(--color-foreground)]">{row.note}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Owner
</p>
<p className="mt-2 text-sm font-medium text-[var(--color-foreground)]">{row.owner}</p>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">{row.ownerEmail}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Next gate
</p>
<p className="mt-2 text-sm font-medium text-[var(--color-foreground)]">{row.nextGate}</p>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">
Signal score {row.signalScore}
</p>
</div>
</div>
</div>
);
}
function DataTablePlayground() { function DataTablePlayground() {
const [filter, setFilter] = useState<RoutingFilter>("all"); const [filter, setFilter] = useState<RoutingFilter>("all");
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
@@ -288,6 +332,7 @@ function DataTablePlayground() {
<div className="w-full max-w-[1120px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)]"> <div className="w-full max-w-[1120px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)]">
<DataTable <DataTable
columns={routingColumns} columns={routingColumns}
defaultDensity="comfortable"
empty={<RoutingEmptyState onReset={resetView} />} empty={<RoutingEmptyState onReset={resetView} />}
enableSelection enableSelection
getRowId={(row) => row.id} getRowId={(row) => row.id}
@@ -296,6 +341,7 @@ function DataTablePlayground() {
onSortingChange={setSorting} onSortingChange={setSorting}
pageSize={3} pageSize={3}
pageSizeOptions={[3, 5]} pageSizeOptions={[3, 5]}
renderRowDetails={(row) => <RoutingLaneDetail row={row} />}
renderRowActions={(row) => ( renderRowActions={(row) => (
<Button <Button
size="sm" size="sm"
@@ -308,6 +354,10 @@ function DataTablePlayground() {
</Button> </Button>
)} )}
rows={visibleRows} rows={visibleRows}
rowDetailsDescription={(row) =>
`${row.lane} routing handoff for ${row.audience.toLowerCase()}`
}
rowDetailsTitle={(row) => `${row.lane} lane detail`}
searchLabel="Search routing lanes" searchLabel="Search routing lanes"
searchPlaceholder="Search lanes, owners, and notes" searchPlaceholder="Search lanes, owners, and notes"
searchValue={searchValue} searchValue={searchValue}
@@ -16,6 +16,34 @@ import {
} from "@ai-ui/ui"; } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
function StatusIcon() {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
<path
d="M3 8.5 6.25 11.75 13 5"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
);
}
function LayersIcon() {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
<path
d="m8 2 6 3.5L8 9 2 5.5 8 2Zm0 5 6 3.5L8 14l-6-3.5L8 7Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.3"
/>
</svg>
);
}
async function waitForCondition( async function waitForCondition(
predicate: () => boolean, predicate: () => boolean,
message: string, message: string,
@@ -46,31 +74,72 @@ function ReleaseMenu({ triggerLabel = "Open menu" }: ReleaseMenuProps) {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuLabel>Launch actions</DropdownMenuLabel> <DropdownMenuLabel>Launch actions</DropdownMenuLabel>
<DropdownMenuItem> <DropdownMenuItem
description="Open the latest rollout summary and reviewer comments."
leading={<StatusIcon />}
shortcut="R"
>
Review summary Review summary
<DropdownMenuShortcut>R</DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem
description="Copy the current branch preview and release notes."
leading={<LayersIcon />}
shortcut="S"
>
Share preview Share preview
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem disabled> <DropdownMenuItem
description="Disabled while the current verification step is still running."
disabled
shortcut="⌘R"
>
Retry checks Retry checks
<DropdownMenuShortcut>R</DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked>Notify stakeholders</DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem
checked
description="Post a summary to launch-updates after approval."
shortcut="N"
>
Notify stakeholders
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuRadioGroup value="staged"> <DropdownMenuRadioGroup value="staged">
<DropdownMenuRadioItem value="staged">Staged rollout</DropdownMenuRadioItem> <DropdownMenuRadioItem
<DropdownMenuRadioItem value="global">Global rollout</DropdownMenuRadioItem> description="Ship to 10% of traffic first."
shortcut="1"
value="staged"
>
Staged rollout
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
description="Ship globally as soon as the release is approved."
shortcut="2"
value="global"
>
Global rollout
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuSub> <DropdownMenuSub>
<DropdownMenuSubTrigger inset>More actions</DropdownMenuSubTrigger> <DropdownMenuSubTrigger
<DropdownMenuSubContent> description="Secondary actions that should stay grouped away from the primary flow."
<DropdownMenuItem>Duplicate release</DropdownMenuItem> inset
<DropdownMenuItem variant="destructive">Archive release</DropdownMenuItem> >
More actions
</DropdownMenuSubTrigger>
<DropdownMenuSubContent size="lg">
<DropdownMenuItem
description="Create a sibling release with the same routing and metadata."
>
Duplicate release
</DropdownMenuItem>
<DropdownMenuItem
description="Move the release out of the active workspace without deleting it."
variant="destructive"
>
Archive release
</DropdownMenuItem>
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
</DropdownMenuContent> </DropdownMenuContent>
@@ -137,7 +206,7 @@ export const States: Story = {
docs: { docs: {
description: { description: {
story: story:
"Open the menu to inspect the checked checkbox item, the selected radio item, a disabled action, the inset submenu trigger, and the destructive nested action." "Open the menu to inspect richer action rows with descriptions, leading icons, inline shortcuts, checked and selected states, a disabled row, an inset submenu trigger, and a destructive nested action."
} }
} }
}, },
@@ -149,6 +218,74 @@ export const States: Story = {
) )
}; };
export const ContextPanels: Story = {
parameters: {
docs: {
description: {
story:
"These examples show common product-style menu compositions: a release action menu and a denser context menu for row-level operations."
}
}
},
render: () => (
<div className="grid w-[920px] gap-4 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Release actions
</h3>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Use richer labels when the command needs more context than a single verb.
</p>
<div className="mt-6">
<ReleaseMenu triggerLabel="Open release actions" />
</div>
</div>
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Row context menu
</h3>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Denser menus can still remain readable when every item communicates hierarchy,
risk, and keyboard hints clearly.
</p>
<div className="mt-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary">Open row menu</Button>
</DropdownMenuTrigger>
<DropdownMenuContent size="xl">
<DropdownMenuLabel inset>Task 17A · routing</DropdownMenuLabel>
<DropdownMenuItem
description="Move the task into the launch lane without changing ownership."
leading={<StatusIcon />}
shortcut="M"
>
Move to launch lane
</DropdownMenuItem>
<DropdownMenuItem
description="Open the full thread timeline for the assigned reviewer."
leading={<LayersIcon />}
shortcut="T"
>
Open thread timeline
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
description="Escalate this task into the blocked queue and notify the operator."
variant="destructive"
shortcut="E"
>
Escalate blocker
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)
};
export const Anatomy: Story = { export const Anatomy: Story = {
render: () => ( render: () => (
<div className="w-[720px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]"> <div className="w-[720px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
@@ -172,7 +309,10 @@ export const Anatomy: Story = {
<code className="text-[var(--color-foreground)]">data-slot="label"</code>,{" "} <code className="text-[var(--color-foreground)]">data-slot="label"</code>,{" "}
<code className="text-[var(--color-foreground)]">data-slot="separator"</code>, and{" "} <code className="text-[var(--color-foreground)]">data-slot="separator"</code>, and{" "}
<code className="text-[var(--color-foreground)]">data-slot="icon"</code> support <code className="text-[var(--color-foreground)]">data-slot="icon"</code> support
grouping, dividers, and selection markers. grouping, dividers, and selection markers, while{" "}
<code className="text-[var(--color-foreground)]">data-slot="description"</code> and{" "}
<code className="text-[var(--color-foreground)]">data-slot="leading"</code> support
denser contextual rows.
</p> </p>
</div> </div>
</div> </div>
@@ -26,27 +26,40 @@ function EmptyStateGlyph() {
} }
function ReleaseEmptyState({ function ReleaseEmptyState({
actionsLayout = "inline",
align = "center",
description = "Adjust the current filters or create a new release to start routing work.", description = "Adjust the current filters or create a new release to start routing work.",
eyebrow = "No results", eyebrow = "No results",
layout = "default",
mediaSize = "default",
tone = "default", tone = "default",
title = "No matching releases" title = "No matching releases"
}: { }: {
actionsLayout?: "inline" | "stack";
align?: "center" | "start";
description?: string; description?: string;
eyebrow?: string; eyebrow?: string;
layout?: "compact" | "default" | "split";
mediaSize?: "compact" | "default" | "hero";
title?: string; title?: string;
tone?: "default" | "subtle" | "accent"; tone?: "default" | "subtle" | "accent";
}) { }) {
return ( return (
<EmptyState className="w-[min(100%,42rem)]" tone={tone}> <EmptyState
<EmptyStateMedia> align={align}
className="w-[min(100%,42rem)]"
layout={layout}
tone={tone}
>
<EmptyStateMedia size={mediaSize}>
<EmptyStateGlyph /> <EmptyStateGlyph />
</EmptyStateMedia> </EmptyStateMedia>
<EmptyStateHeader> <EmptyStateHeader align={align}>
<EmptyStateEyebrow>{eyebrow}</EmptyStateEyebrow> <EmptyStateEyebrow>{eyebrow}</EmptyStateEyebrow>
<EmptyStateTitle>{title}</EmptyStateTitle> <EmptyStateTitle>{title}</EmptyStateTitle>
<EmptyStateDescription>{description}</EmptyStateDescription> <EmptyStateDescription>{description}</EmptyStateDescription>
</EmptyStateHeader> </EmptyStateHeader>
<EmptyStateActions> <EmptyStateActions layout={actionsLayout}>
<Button>Create release</Button> <Button>Create release</Button>
<Button variant="ghost">Reset filters</Button> <Button variant="ghost">Reset filters</Button>
</EmptyStateActions> </EmptyStateActions>
@@ -89,6 +102,55 @@ export const Scenarios: Story = {
) )
}; };
export const CompactQueue: Story = {
parameters: {
docs: {
description: {
story:
"Use the compact layout when an empty state lives inside a dense operational card, list, or queue panel and should preserve surrounding information density."
}
}
},
render: () => (
<div className="w-[720px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<ReleaseEmptyState
actionsLayout="stack"
align="start"
description="The global queue is clear. Create a task template now or wait for the next dispatch cycle."
eyebrow="Queue clear"
layout="compact"
mediaSize="compact"
title="No blocked work right now"
tone="subtle"
/>
</div>
)
};
export const SplitWorkspace: Story = {
parameters: {
docs: {
description: {
story:
"Use the split layout when the empty state needs more narrative weight, richer actions, or a larger visual anchor inside a dashboard or setup surface."
}
}
},
render: () => (
<div className="w-[940px] rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-background)] p-6">
<ReleaseEmptyState
align="start"
description="Invite operators, connect the first runtime, and define the initial routing lane to turn this empty workspace into an active control surface."
eyebrow="Workspace setup"
layout="split"
mediaSize="hero"
title="This command center needs its first operator"
tone="accent"
/>
</div>
)
};
export const Anatomy: Story = { export const Anatomy: Story = {
render: () => ( render: () => (
<div className="w-[760px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]"> <div className="w-[760px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
@@ -117,6 +179,13 @@ export const Anatomy: Story = {
<code className="text-[var(--color-foreground)]">data-tone</code> exposes whether the <code className="text-[var(--color-foreground)]">data-tone</code> exposes whether the
surface stays neutral, subtle, or accent-led. surface stays neutral, subtle, or accent-led.
</p> </p>
<p>
<code className="text-[var(--color-foreground)]">layout</code>,{" "}
<code className="text-[var(--color-foreground)]">align</code>,{" "}
<code className="text-[var(--color-foreground)]">size</code>, and{" "}
<code className="text-[var(--color-foreground)]">actions layout</code> let the same
composition flex between dense cards and broader setup moments.
</p>
</div> </div>
</div> </div>
</div> </div>
+168 -2
View File
@@ -1,5 +1,6 @@
import { import {
Button, Button,
Input,
Popover, Popover,
PopoverArrow, PopoverArrow,
PopoverClose, PopoverClose,
@@ -61,6 +62,111 @@ function SummaryPopover({
); );
} }
function InspectorPopover() {
return (
<Popover>
<PopoverTrigger asChild>
<Button>Open release inspector</Button>
</PopoverTrigger>
<PopoverContent padding="none" size="xl" side="right" className="overflow-hidden">
<div className="border-b border-[var(--color-border)] px-5 py-4">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Release inspector
</p>
<h3 className="mt-2 text-lg font-semibold text-[var(--color-foreground)]">
Rollout checklist
</h3>
<p className="mt-1 text-sm leading-6 text-[var(--color-muted-foreground)]">
Keep the surface anchored to the trigger while still supporting denser,
panel-like review content.
</p>
</div>
<div className="grid gap-4 px-5 py-4">
<div className="grid gap-1">
<span className="text-sm font-medium text-[var(--color-foreground)]">
Rollout owner
</span>
<Input defaultValue="ops@cadence.dev" readOnly />
</div>
<div className="grid gap-3 sm:grid-cols-2">
{[
["Checks", "12 passed"],
["Approvals", "2 waiting"],
["Scope", "10% traffic"],
["Alerts", "Muted"]
].map(([label, value]) => (
<div
key={label}
className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3"
>
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
{label}
</p>
<p className="mt-2 text-sm font-medium text-[var(--color-foreground)]">
{value}
</p>
</div>
))}
</div>
</div>
<div className="flex justify-between gap-3 border-t border-[var(--color-border)] bg-[color-mix(in_oklch,var(--color-surface)_72%,white_28%)] px-5 py-4">
<PopoverClose asChild>
<Button size="sm" variant="ghost">
Close
</Button>
</PopoverClose>
<Button size="sm">Approve rollout</Button>
</div>
<PopoverArrow />
</PopoverContent>
</Popover>
);
}
function InlineComposerPopover() {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="secondary">Assign reviewer</Button>
</PopoverTrigger>
<PopoverContent side="bottom" size="md" className="grid gap-4">
<div className="space-y-1">
<h3 className="text-base font-semibold text-[var(--color-foreground)]">
Reviewer routing
</h3>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Lightweight form-like content can stay in a popover when the interaction is
single-purpose and fast to dismiss.
</p>
</div>
<div className="grid gap-3">
<div className="grid gap-1.5">
<label className="text-sm font-medium text-[var(--color-foreground)]">
Reviewer
</label>
<Input placeholder="Search reviewer" />
</div>
<div className="grid gap-1.5">
<label className="text-sm font-medium text-[var(--color-foreground)]">
Note
</label>
<Input defaultValue="Focus on launch risk and fallback copy." />
</div>
</div>
<div className="flex justify-end gap-2">
<PopoverClose asChild>
<Button size="sm" variant="ghost">
Cancel
</Button>
</PopoverClose>
<Button size="sm">Send request</Button>
</div>
<PopoverArrow />
</PopoverContent>
</Popover>
);
}
const meta = { const meta = {
title: "Components/Popover", title: "Components/Popover",
component: Popover, component: Popover,
@@ -125,6 +231,27 @@ export const Sizes: Story = {
) )
}; };
export const ComposedPanels: Story = {
parameters: {
docs: {
description: {
story:
"Use `padding=\"none\"` to compose richer inspector-style panels with custom header, content, and footer sections while keeping popover positioning and dismissal behavior."
}
}
},
render: () => (
<div className="grid w-[920px] gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<div className="rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-8">
<InspectorPopover />
</div>
<div className="rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-8">
<InlineComposerPopover />
</div>
</div>
)
};
export const Anatomy: Story = { export const Anatomy: Story = {
render: () => ( render: () => (
<div className="w-[700px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]"> <div className="w-[700px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
@@ -132,11 +259,12 @@ export const Anatomy: Story = {
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"> <p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Popover anatomy Popover anatomy
</p> </p>
<SummaryPopover triggerLabel="Preview popover structure" /> <InspectorPopover />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]"> <div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p> <p>
<code className="text-[var(--color-foreground)]">data-slot="content"</code> wraps <code className="text-[var(--color-foreground)]">data-slot="content"</code> wraps
the floating surface and exposes <code className="text-[var(--color-foreground)]">data-size</code>. the floating surface and exposes <code className="text-[var(--color-foreground)]">data-size</code> and{" "}
<code className="text-[var(--color-foreground)]">data-padding</code>.
</p> </p>
<p> <p>
<code className="text-[var(--color-foreground)]">data-slot="arrow"</code> visually <code className="text-[var(--color-foreground)]">data-slot="arrow"</code> visually
@@ -170,3 +298,41 @@ export const Motion: Story = {
</div> </div>
) )
}; };
export const ContextualWorkflows: Story = {
parameters: {
docs: {
description: {
story:
"Popover works best for anchored context, lightweight review panels, and compact forms that should stay attached to a trigger instead of escalating to dialog."
}
}
},
render: () => (
<div className="grid w-[920px] gap-4 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-lg font-semibold text-[var(--color-foreground)]">
Anchored release context
</h3>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Use a richer popover when the user needs more detail than a tooltip, but not a
full modal interruption.
</p>
<div className="mt-6">
<InspectorPopover />
</div>
</div>
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-lg font-semibold text-[var(--color-foreground)]">
Quick assignment flow
</h3>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Single-purpose forms can stay lightweight and local to the trigger.
</p>
<div className="mt-6">
<InlineComposerPopover />
</div>
</div>
</div>
)
};
+66 -1
View File
@@ -42,7 +42,7 @@ describe("Combobox", () => {
it("renders a selected value, filters options, and updates uncontrolled state", async () => { it("renders a selected value, filters options, and updates uncontrolled state", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( const loadingView = render(
<Combobox <Combobox
aria-label="Review lane" aria-label="Review lane"
defaultValue="design" defaultValue="design"
@@ -163,4 +163,69 @@ describe("Combobox", () => {
expect(trigger).toHaveAttribute("aria-describedby", expect.stringContaining(message.id)); expect(trigger).toHaveAttribute("aria-describedby", expect.stringContaining(message.id));
expect(trigger.closest("[data-slot='root']")).toHaveAttribute("data-invalid", ""); 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, useRef,
useState, useState,
type ComponentPropsWithoutRef, type ComponentPropsWithoutRef,
type KeyboardEvent type KeyboardEvent,
type ReactNode
} from "react"; } from "react";
import { import {
@@ -18,11 +19,12 @@ import {
comboboxLabelVariants, comboboxLabelVariants,
comboboxListVariants, comboboxListVariants,
comboboxSearchVariants, comboboxSearchVariants,
comboboxTriggerVariants comboboxTriggerVariants,
comboboxFooterVariants
} from "./combobox.variants"; } from "./combobox.variants";
import { cn } from "../lib/cn"; import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts"; import { createDataAttributes, createSlot } from "../lib/contracts";
import { CheckIcon, ChevronDownIcon } from "../lib/icons"; import { CheckIcon, ChevronDownIcon, SpinnerIcon } from "../lib/icons";
import { useFieldContext } from "./field"; import { useFieldContext } from "./field";
function mergeIds(...ids: Array<string | undefined>) { function mergeIds(...ids: Array<string | undefined>) {
@@ -68,9 +70,13 @@ export type ComboboxProps = Omit<
defaultOpen?: boolean; defaultOpen?: boolean;
defaultSearchValue?: string; defaultSearchValue?: string;
defaultValue?: string; defaultValue?: string;
emptyMessage?: string; emptyMessage?: ReactNode | ((query: string) => ReactNode);
filter?: (item: ComboboxItem, query: string) => boolean;
footer?: ReactNode;
invalid?: boolean; invalid?: boolean;
items: ComboboxItem[]; items: ComboboxItem[];
loading?: boolean;
loadingMessage?: ReactNode;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
onSearchValueChange?: (value: string) => void; onSearchValueChange?: (value: string) => void;
onValueChange?: (value: string) => void; onValueChange?: (value: string) => void;
@@ -89,9 +95,13 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
defaultValue, defaultValue,
disabled, disabled,
emptyMessage = "No matching results.", emptyMessage = "No matching results.",
filter,
footer,
id, id,
invalid, invalid,
items, items,
loading = false,
loadingMessage = "Searching…",
onOpenChange, onOpenChange,
onSearchValueChange, onSearchValueChange,
onValueChange, onValueChange,
@@ -137,6 +147,10 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
} }
return items.filter((item) => { return items.filter((item) => {
if (filter) {
return filter(item, query);
}
const haystack = [item.label, item.value, ...(item.keywords ?? [])] const haystack = [item.label, item.value, ...(item.keywords ?? [])]
.join(" ") .join(" ")
.toLowerCase(); .toLowerCase();
@@ -282,10 +296,45 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault(); 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); setOpenState(false);
} }
}; };
const renderedEmptyMessage =
typeof emptyMessage === "function"
? emptyMessage(resolvedSearchValue.trim())
: emptyMessage;
return ( return (
<div <div
{...createSlot("root")} {...createSlot("root")}
@@ -353,9 +402,16 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
value={resolvedSearchValue} value={resolvedSearchValue}
/> />
</div> </div>
{filteredItems.length === 0 ? ( {loading ? (
<div {...createSlot("empty")} className={comboboxEmptyVariants()}> <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>
) : ( ) : (
<div <div
@@ -428,6 +484,11 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
))} ))}
</div> </div>
)} )}
{footer ? (
<div {...createSlot("footer")} className={comboboxFooterVariants()}>
{footer}
</div>
) : null}
</PopoverPrimitive.Content> </PopoverPrimitive.Content>
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
</PopoverPrimitive.Root> </PopoverPrimitive.Root>
@@ -48,3 +48,7 @@ export const comboboxItemVariants = cva([
export const comboboxEmptyVariants = cva([ export const comboboxEmptyVariants = cva([
"px-3 py-6 text-center text-sm text-[var(--color-muted-foreground)]" "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 userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
import { import {
Command, Command,
CommandDialog, CommandDialog,
@@ -96,4 +97,60 @@ describe("Command", () => {
expect(screen.queryByPlaceholderText("Search across workspace")).not.toBeInTheDocument(); 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 { import {
commandDialogContentVariants, commandDialogContentVariants,
commandEmptyVariants, commandEmptyVariants,
commandFooterVariants,
commandGroupVariants, commandGroupVariants,
commandInputVariants, commandInputVariants,
commandInputWrapperVariants, commandInputWrapperVariants,
commandItemVariants, commandItemVariants,
commandListVariants, commandListVariants,
commandLoadingVariants,
commandSeparatorVariants, commandSeparatorVariants,
commandShortcutVariants, commandShortcutVariants,
commandVariants commandVariants
} from "./command.variants"; } from "./command.variants";
import { DialogContent } from "./dialog"; import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "./dialog";
import { cn } from "../lib/cn"; import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts"; 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>( 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 ( return (
<CommandPrimitive <CommandPrimitive
{...props} {...props}
{...createSlot("root")} {...createSlot("root")}
className={cn(commandVariants(), className)} className={cn(commandVariants(), className)}
ref={ref} 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; children?: ReactNode;
contentClassName?: string; contentClassName?: string;
commandClassName?: string; commandClassName?: string;
description?: ReactNode;
footer?: ReactNode;
loading?: boolean;
loadingMessage?: ReactNode;
title?: ReactNode;
}; };
export function CommandDialog({ export function CommandDialog({
children, children,
commandClassName, commandClassName,
contentClassName, contentClassName,
description,
footer,
loading,
loadingMessage,
title,
...props ...props
}: CommandDialogProps) { }: CommandDialogProps) {
return ( return (
<DialogPrimitive.Root {...props}> <DialogPrimitive.Root {...props}>
<DialogContent className={cn(commandDialogContentVariants(), contentClassName)}> <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> </DialogContent>
</DialogPrimitive.Root> </DialogPrimitive.Root>
); );
@@ -25,6 +25,10 @@ export const commandListVariants = cva([
"max-h-[22rem] overflow-y-auto overflow-x-hidden p-2" "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([ export const commandEmptyVariants = cva([
"py-10 text-center text-sm text-[var(--color-muted-foreground)]" "py-10 text-center text-sm text-[var(--color-muted-foreground)]"
]); ]);
@@ -51,3 +55,7 @@ export const commandSeparatorVariants = cva([
export const commandShortcutVariants = cva([ export const commandShortcutVariants = cva([
"ml-auto text-[0.7rem] uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]" "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(onSortingChange).toHaveBeenCalledWith([{ desc: false, id: "lane" }]);
expect(onSelectionChange).toHaveBeenCalledWith({ support: true }); 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 { Button } from "./button";
import { Checkbox } from "./checkbox"; import { Checkbox } from "./checkbox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "./dropdown-menu";
import { import {
EmptyState, EmptyState,
EmptyStateActions, EmptyStateActions,
@@ -34,6 +44,13 @@ import {
EmptyStateTitle EmptyStateTitle
} from "./empty-state"; } from "./empty-state";
import { Input, type InputProps } from "./input"; import { Input, type InputProps } from "./input";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from "./sheet";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
import { Skeleton } from "./skeleton"; import { Skeleton } from "./skeleton";
import { Spinner } from "./spinner"; import { Spinner } from "./spinner";
@@ -62,6 +79,7 @@ import {
} from "./data-table.variants"; } from "./data-table.variants";
export type DataTableAlignment = "start" | "center" | "end"; export type DataTableAlignment = "start" | "center" | "end";
export type DataTableDensity = "comfortable" | "compact";
export type DataTableSort = { export type DataTableSort = {
desc?: boolean; desc?: boolean;
@@ -78,6 +96,7 @@ export type DataTableColumn<TData> = {
align?: DataTableAlignment; align?: DataTableAlignment;
cell?: (row: TData) => ReactNode; cell?: (row: TData) => ReactNode;
header: ReactNode | ((column: DataTableColumnContext<TData>) => ReactNode); header: ReactNode | ((column: DataTableColumnContext<TData>) => ReactNode);
hideable?: boolean;
id: string; id: string;
searchValue?: (row: TData) => string; searchValue?: (row: TData) => string;
searchable?: boolean; searchable?: boolean;
@@ -87,25 +106,34 @@ export type DataTableColumn<TData> = {
export type DataTableProps<TData> = Omit<ComponentPropsWithoutRef<"div">, "children"> & { export type DataTableProps<TData> = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
columns: DataTableColumn<TData>[]; columns: DataTableColumn<TData>[];
defaultDensity?: DataTableDensity;
defaultPageIndex?: number; defaultPageIndex?: number;
defaultPageSize?: number; defaultPageSize?: number;
defaultSearchValue?: string; defaultSearchValue?: string;
defaultSelection?: Record<string, boolean>; defaultSelection?: Record<string, boolean>;
defaultSorting?: DataTableSort[]; defaultSorting?: DataTableSort[];
defaultVisibleColumns?: Record<string, boolean>;
density?: DataTableDensity;
empty?: ReactNode; empty?: ReactNode;
enableSelection?: boolean; enableSelection?: boolean;
getRowId?: (row: TData, index: number) => string; getRowId?: (row: TData, index: number) => string;
loading?: boolean; loading?: boolean;
loadingRowCount?: number; loadingRowCount?: number;
onDensityChange?: (density: DataTableDensity) => void;
onPageIndexChange?: (pageIndex: number) => void; onPageIndexChange?: (pageIndex: number) => void;
onPageSizeChange?: (pageSize: number) => void; onPageSizeChange?: (pageSize: number) => void;
onSearchValueChange?: (searchValue: string) => void; onSearchValueChange?: (searchValue: string) => void;
onSelectionChange?: (selection: Record<string, boolean>) => void; onSelectionChange?: (selection: Record<string, boolean>) => void;
onSortingChange?: (sorting: DataTableSort[]) => void; onSortingChange?: (sorting: DataTableSort[]) => void;
onVisibleColumnsChange?: (visibility: Record<string, boolean>) => void;
pageIndex?: number; pageIndex?: number;
pageSize?: number; pageSize?: number;
pageSizeOptions?: number[]; pageSizeOptions?: number[];
renderRowDetails?: (row: TData) => ReactNode;
renderRowActions?: (row: TData) => ReactNode; renderRowActions?: (row: TData) => ReactNode;
rowDetailsDescription?: ReactNode | ((row: TData) => ReactNode);
rowDetailsLabel?: string;
rowDetailsTitle?: ReactNode | ((row: TData) => ReactNode);
rows: TData[]; rows: TData[];
searchLabel?: string; searchLabel?: string;
searchPlaceholder?: string; searchPlaceholder?: string;
@@ -116,6 +144,7 @@ export type DataTableProps<TData> = Omit<ComponentPropsWithoutRef<"div">, "child
sorting?: DataTableSort[]; sorting?: DataTableSort[];
tableLabel?: string; tableLabel?: string;
toolbarActions?: ReactNode; toolbarActions?: ReactNode;
visibleColumns?: Record<string, boolean>;
}; };
type InternalColumnMeta<TData> = { type InternalColumnMeta<TData> = {
@@ -212,25 +241,34 @@ function DataTableInner<TData>(
{ {
className, className,
columns, columns,
defaultDensity = "comfortable",
defaultPageIndex = 0, defaultPageIndex = 0,
defaultPageSize = 5, defaultPageSize = 5,
defaultSearchValue = "", defaultSearchValue = "",
defaultSelection = {}, defaultSelection = {},
defaultSorting = [], defaultSorting = [],
defaultVisibleColumns = {},
density,
empty, empty,
enableSelection = false, enableSelection = false,
getRowId, getRowId,
loading = false, loading = false,
loadingRowCount = 5, loadingRowCount = 5,
onDensityChange,
onPageIndexChange, onPageIndexChange,
onPageSizeChange, onPageSizeChange,
onSearchValueChange, onSearchValueChange,
onSelectionChange, onSelectionChange,
onSortingChange, onSortingChange,
onVisibleColumnsChange,
pageIndex, pageIndex,
pageSize, pageSize,
pageSizeOptions = [5, 10, 20], pageSizeOptions = [5, 10, 20],
renderRowDetails,
renderRowActions, renderRowActions,
rowDetailsDescription,
rowDetailsLabel = "Open details",
rowDetailsTitle,
rows, rows,
searchLabel = "Search rows", searchLabel = "Search rows",
searchPlaceholder = "Search rows", searchPlaceholder = "Search rows",
@@ -241,6 +279,7 @@ function DataTableInner<TData>(
sorting, sorting,
tableLabel = "Data table", tableLabel = "Data table",
toolbarActions, toolbarActions,
visibleColumns,
...props ...props
}: DataTableProps<TData>, }: DataTableProps<TData>,
ref: ForwardedRef<HTMLDivElement> ref: ForwardedRef<HTMLDivElement>
@@ -275,6 +314,19 @@ function DataTableInner<TData>(
defaultValue: defaultPageSize, defaultValue: defaultPageSize,
onChange: onPageSizeChange 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( const resolvedPageSizeOptions = [...new Set([...pageSizeOptions, currentPageSize])].sort(
(left, right) => left - right (left, right) => left - right
@@ -325,6 +377,34 @@ function DataTableInner<TData>(
} satisfies ColumnDef<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) => ({ ...columns.map((column) => ({
accessorFn: column.accessor accessorFn: column.accessor
? (row: TData) => getColumnAccessorValue(row, column) ? (row: TData) => getColumnAccessorValue(row, column)
@@ -334,6 +414,7 @@ function DataTableInner<TData>(
? column.cell(row.original) ? column.cell(row.original)
: stringifySearchValue(getColumnAccessorValue(row.original, column)), : stringifySearchValue(getColumnAccessorValue(row.original, column)),
enableGlobalFilter: searchableColumns.includes(column), enableGlobalFilter: searchableColumns.includes(column),
enableHiding: column.hideable ?? true,
enableSorting: column.sortable ?? false, enableSorting: column.sortable ?? false,
header: ({ column: tanstackColumn }: HeaderContext<TData, unknown>) => header: ({ column: tanstackColumn }: HeaderContext<TData, unknown>) =>
typeof column.header === "function" typeof column.header === "function"
@@ -424,12 +505,21 @@ function DataTableInner<TData>(
setCurrentSorting(nextValue.map((item) => ({ desc: item.desc, id: item.id }))); setCurrentSorting(nextValue.map((item) => ({ desc: item.desc, id: item.id })));
}, },
onColumnVisibilityChange: (updater) => {
const nextValue =
typeof updater === "function"
? updater(currentVisibleColumns)
: updater;
setCurrentVisibleColumns(nextValue);
},
state: { state: {
globalFilter: currentSearchValue, globalFilter: currentSearchValue,
pagination: { pagination: {
pageIndex: currentPageIndex, pageIndex: currentPageIndex,
pageSize: currentPageSize pageSize: currentPageSize
} satisfies PaginationState, } satisfies PaginationState,
columnVisibility: currentVisibleColumns,
rowSelection: currentSelection, rowSelection: currentSelection,
sorting: currentSorting as SortingState sorting: currentSorting as SortingState
} }
@@ -437,11 +527,16 @@ function DataTableInner<TData>(
const totalColumns = table.getAllLeafColumns().length; const totalColumns = table.getAllLeafColumns().length;
const filteredRowCount = table.getFilteredRowModel().rows.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 selectedRows = table.getSelectedRowModel().rows.map((row) => row.original);
const pageCount = table.getPageCount(); const pageCount = table.getPageCount();
const isEmpty = !loading && filteredRowCount === 0; const isEmpty = !loading && filteredRowCount === 0;
const shouldRenderSearch = searchableColumns.length > 0; const shouldRenderSearch = searchableColumns.length > 0;
const shouldRenderToolbar = shouldRenderSearch || toolbarActions !== undefined; const shouldRenderViewOptions = hideableColumns.length > 0;
const shouldRenderToolbar =
shouldRenderSearch || toolbarActions !== undefined || shouldRenderViewOptions;
const pageStart = const pageStart =
filteredRowCount === 0 ? 0 : currentPageIndex * currentPageSize + 1; filteredRowCount === 0 ? 0 : currentPageIndex * currentPageSize + 1;
const pageEnd = Math.min((currentPageIndex + 1) * currentPageSize, filteredRowCount); const pageEnd = Math.min((currentPageIndex + 1) * currentPageSize, filteredRowCount);
@@ -454,6 +549,25 @@ function DataTableInner<TData>(
} }
}, [currentPageIndex, pageCount, setCurrentPageIndex]); }, [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 ( return (
<div <div
{...props} {...props}
@@ -464,6 +578,7 @@ function DataTableInner<TData>(
selected: selectedRows.length > 0 selected: selectedRows.length > 0
})} })}
className={cn(dataTableRootVariants(), className)} className={cn(dataTableRootVariants(), className)}
data-density={currentDensity}
ref={ref} ref={ref}
> >
{shouldRenderToolbar ? ( {shouldRenderToolbar ? (
@@ -483,7 +598,53 @@ function DataTableInner<TData>(
</div> </div>
) : null} ) : null}
</div> </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> </DataTableToolbar>
) : null} ) : null}
@@ -533,6 +694,7 @@ function DataTableInner<TData>(
: undefined : undefined
} }
scope="col" scope="col"
density={currentDensity}
sortable={header.column.getCanSort()} sortable={header.column.getCanSort()}
sort={sortState} sort={sortState}
style={getColumnWidthStyle(meta?.width)} style={getColumnWidthStyle(meta?.width)}
@@ -618,6 +780,7 @@ function DataTableInner<TData>(
return ( return (
<DataTableCell <DataTableCell
align={align} align={align}
density={currentDensity}
key={cell.id} key={cell.id}
style={getColumnWidthStyle(meta?.width)} style={getColumnWidthStyle(meta?.width)}
> >
@@ -690,6 +853,27 @@ function DataTableInner<TData>(
</div> </div>
</DataTablePagination> </DataTablePagination>
</DataTableContent> </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> </div>
); );
} }
@@ -787,12 +971,20 @@ export const DataTableHeader = forwardRef<HTMLTableSectionElement, DataTableHead
export type DataTableHeaderCellProps = Omit<ComponentPropsWithoutRef<"th">, "align"> & export type DataTableHeaderCellProps = Omit<ComponentPropsWithoutRef<"th">, "align"> &
VariantProps<typeof dataTableHeaderCellVariants> & { VariantProps<typeof dataTableHeaderCellVariants> & {
density?: DataTableDensity;
sort?: "asc" | "desc" | false; sort?: "asc" | "desc" | false;
}; };
export const DataTableHeaderCell = forwardRef<HTMLTableCellElement, DataTableHeaderCellProps>( export const DataTableHeaderCell = forwardRef<HTMLTableCellElement, DataTableHeaderCellProps>(
function DataTableHeaderCell( function DataTableHeaderCell(
{ align = "start", className, sort = false, sortable = false, ...props }, {
align = "start",
className,
density = "comfortable",
sort = false,
sortable = false,
...props
},
ref ref
) { ) {
return ( return (
@@ -800,9 +992,10 @@ export const DataTableHeaderCell = forwardRef<HTMLTableCellElement, DataTableHea
{...props} {...props}
{...createSlot("header")} {...createSlot("header")}
{...createDataAttributes({ {...createDataAttributes({
density,
sort: sort || undefined sort: sort || undefined
})} })}
className={cn(dataTableHeaderCellVariants({ align, sortable }), className)} className={cn(dataTableHeaderCellVariants({ align, density, sortable }), className)}
ref={ref} ref={ref}
/> />
); );
@@ -846,15 +1039,20 @@ export const DataTableRow = forwardRef<HTMLTableRowElement, DataTableRowProps>(
); );
export type DataTableCellProps = Omit<ComponentPropsWithoutRef<"td">, "align"> & export type DataTableCellProps = Omit<ComponentPropsWithoutRef<"td">, "align"> &
VariantProps<typeof dataTableCellVariants>; VariantProps<typeof dataTableCellVariants> & {
density?: DataTableDensity;
};
export const DataTableCell = forwardRef<HTMLTableCellElement, DataTableCellProps>( export const DataTableCell = forwardRef<HTMLTableCellElement, DataTableCellProps>(
function DataTableCell({ align = "start", className, ...props }, ref) { function DataTableCell(
{ align = "start", className, density = "comfortable", ...props },
ref
) {
return ( return (
<td <td
{...props} {...props}
{...createSlot("cell")} {...createSlot("cell")}
className={cn(dataTableCellVariants({ align }), className)} className={cn(dataTableCellVariants({ align, density }), className)}
ref={ref} ref={ref}
/> />
); );
@@ -33,7 +33,7 @@ export const dataTableHeaderVariants = cva(
export const dataTableHeaderCellVariants = 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)]" "text-[var(--color-muted-foreground)]"
], ],
{ {
@@ -43,6 +43,10 @@ export const dataTableHeaderCellVariants = cva(
center: "text-center", center: "text-center",
end: "text-right" end: "text-right"
}, },
density: {
comfortable: "py-3",
compact: "py-2.5 text-xs"
},
sortable: { sortable: {
false: "", false: "",
true: "select-none" true: "select-none"
@@ -50,6 +54,7 @@ export const dataTableHeaderCellVariants = cva(
}, },
defaultVariants: { defaultVariants: {
align: "start", align: "start",
density: "comfortable",
sortable: false sortable: false
} }
} }
@@ -81,17 +86,22 @@ export const dataTableRowVariants = cva(
); );
export const dataTableCellVariants = 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: { variants: {
align: { align: {
start: "text-left", start: "text-left",
center: "text-center", center: "text-center",
end: "text-right" end: "text-right"
},
density: {
comfortable: "py-3 text-sm leading-6",
compact: "py-2.5 text-[0.8125rem] leading-5"
} }
}, },
defaultVariants: { defaultVariants: {
align: "start" align: "start",
density: "comfortable"
} }
} }
); );
@@ -12,6 +12,9 @@ import {
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuTrigger DropdownMenuTrigger
} from "./dropdown-menu"; } from "./dropdown-menu";
@@ -58,6 +61,52 @@ describe("DropdownMenu", () => {
expect(onSelect).toHaveBeenCalledTimes(1); 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 () => { it("closes on Escape and supports controlled opening", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onOpenChange = vi.fn(); const onOpenChange = vi.fn();
+119 -10
View File
@@ -3,12 +3,18 @@ import {
forwardRef, forwardRef,
type ComponentPropsWithoutRef, type ComponentPropsWithoutRef,
type ElementRef, type ElementRef,
type HTMLAttributes type HTMLAttributes,
type PropsWithChildren,
type ReactNode
} from "react"; } from "react";
import { import {
dropdownMenuContentVariants, dropdownMenuContentVariants,
dropdownMenuItemBodyVariants,
dropdownMenuItemDescriptionVariants,
dropdownMenuItemVariants, dropdownMenuItemVariants,
dropdownMenuItemLabelVariants,
dropdownMenuItemLeadingVariants,
dropdownMenuLabelVariants, dropdownMenuLabelVariants,
dropdownMenuSeparatorVariants dropdownMenuSeparatorVariants
} from "./dropdown-menu.variants"; } from "./dropdown-menu.variants";
@@ -24,6 +30,52 @@ export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
export const DropdownMenuSub = DropdownMenuPrimitive.Sub; export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 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 = export type DropdownMenuContentProps =
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> &
VariantProps<typeof dropdownMenuContentVariants>; VariantProps<typeof dropdownMenuContentVariants>;
@@ -76,13 +128,14 @@ export const DropdownMenuSubContent = forwardRef<
export type DropdownMenuItemProps = export type DropdownMenuItemProps =
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> &
VariantProps<typeof dropdownMenuItemVariants>; VariantProps<typeof dropdownMenuItemVariants> &
DropdownMenuRichItemProps;
export const DropdownMenuItem = forwardRef< export const DropdownMenuItem = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.Item>, ElementRef<typeof DropdownMenuPrimitive.Item>,
DropdownMenuItemProps DropdownMenuItemProps
>(function DropdownMenuItem( >(function DropdownMenuItem(
{ className, inset, variant, ...props }, { children, className, description, inset, leading, shortcut, variant, ...props },
ref ref
) { ) {
return ( return (
@@ -92,19 +145,37 @@ export const DropdownMenuItem = forwardRef<
{...createDataAttributes({ inset, variant })} {...createDataAttributes({ inset, variant })}
className={cn(dropdownMenuItemVariants({ inset, variant }), className)} className={cn(dropdownMenuItemVariants({ inset, variant }), className)}
ref={ref} ref={ref}
/> >
<DropdownMenuItemContent
description={description}
leading={leading}
shortcut={shortcut}
>
{children}
</DropdownMenuItemContent>
</DropdownMenuPrimitive.Item>
); );
}); });
export type DropdownMenuCheckboxItemProps = export type DropdownMenuCheckboxItemProps =
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> &
VariantProps<typeof dropdownMenuItemVariants>; VariantProps<typeof dropdownMenuItemVariants> &
Omit<DropdownMenuRichItemProps, "leading">;
export const DropdownMenuCheckboxItem = forwardRef< export const DropdownMenuCheckboxItem = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
DropdownMenuCheckboxItemProps DropdownMenuCheckboxItemProps
>(function DropdownMenuCheckboxItem( >(function DropdownMenuCheckboxItem(
{ checked, children, className, inset = true, variant, ...props }, {
checked,
children,
className,
description,
inset = true,
shortcut,
variant,
...props
},
ref ref
) { ) {
return ( return (
@@ -124,20 +195,34 @@ export const DropdownMenuCheckboxItem = forwardRef<
<CheckIcon className="size-3" /> <CheckIcon className="size-3" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
<DropdownMenuItemContent
description={description}
shortcut={shortcut}
>
{children} {children}
</DropdownMenuItemContent>
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
); );
}); });
export type DropdownMenuRadioItemProps = export type DropdownMenuRadioItemProps =
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> &
VariantProps<typeof dropdownMenuItemVariants>; VariantProps<typeof dropdownMenuItemVariants> &
Omit<DropdownMenuRichItemProps, "leading">;
export const DropdownMenuRadioItem = forwardRef< export const DropdownMenuRadioItem = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.RadioItem>, ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
DropdownMenuRadioItemProps DropdownMenuRadioItemProps
>(function DropdownMenuRadioItem( >(function DropdownMenuRadioItem(
{ children, className, inset = true, variant, ...props }, {
children,
className,
description,
inset = true,
shortcut,
variant,
...props
},
ref ref
) { ) {
return ( return (
@@ -156,7 +241,12 @@ export const DropdownMenuRadioItem = forwardRef<
<DotIcon className="size-2.5" /> <DotIcon className="size-2.5" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
<DropdownMenuItemContent
description={description}
shortcut={shortcut}
>
{children} {children}
</DropdownMenuItemContent>
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
); );
}); });
@@ -196,13 +286,14 @@ export const DropdownMenuSeparator = forwardRef<
export type DropdownMenuSubTriggerProps = export type DropdownMenuSubTriggerProps =
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> &
VariantProps<typeof dropdownMenuItemVariants>; VariantProps<typeof dropdownMenuItemVariants> &
Pick<DropdownMenuRichItemProps, "description">;
export const DropdownMenuSubTrigger = forwardRef< export const DropdownMenuSubTrigger = forwardRef<
ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
DropdownMenuSubTriggerProps DropdownMenuSubTriggerProps
>(function DropdownMenuSubTrigger( >(function DropdownMenuSubTrigger(
{ children, className, inset, variant, ...props }, { children, className, description, inset, variant, ...props },
ref ref
) { ) {
return ( return (
@@ -212,8 +303,26 @@ export const DropdownMenuSubTrigger = forwardRef<
{...createDataAttributes({ inset, variant })} {...createDataAttributes({ inset, variant })}
className={cn(dropdownMenuItemVariants({ inset, variant }), className)} className={cn(dropdownMenuItemVariants({ inset, variant }), className)}
ref={ref} ref={ref}
>
<span
{...createSlot("body")}
className={cn(dropdownMenuItemBodyVariants())}
>
<span
{...createSlot("label")}
className={cn(dropdownMenuItemLabelVariants())}
> >
{children} {children}
</span>
{description ? (
<span
{...createSlot("description")}
className={cn(dropdownMenuItemDescriptionVariants())}
>
{description}
</span>
) : null}
</span>
<ChevronRightIcon className="ml-auto size-3 text-[var(--color-muted-foreground)]" /> <ChevronRightIcon className="ml-auto size-3 text-[var(--color-muted-foreground)]" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
); );
@@ -12,7 +12,8 @@ export const dropdownMenuContentVariants = cva(
size: { size: {
sm: "min-w-[11rem]", sm: "min-w-[11rem]",
md: "min-w-[13rem]", md: "min-w-[13rem]",
lg: "min-w-[15rem]" lg: "min-w-[15rem]",
xl: "min-w-[18rem]"
} }
}, },
defaultVariants: { defaultVariants: {
@@ -23,7 +24,7 @@ export const dropdownMenuContentVariants = cva(
export const dropdownMenuItemVariants = 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)]", "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)]", "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" "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( export const dropdownMenuLabelVariants = cva(
[ [
"px-2.5 py-2 text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]" "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" "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>; VariantProps<typeof emptyStateVariants>;
export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(function EmptyState( export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(function EmptyState(
{ className, tone, ...props }, { align, className, layout, tone, ...props },
ref ref
) { ) {
return ( return (
<div <div
{...props} {...props}
{...createSlot("root")} {...createSlot("root")}
{...createDataAttributes({ tone })} {...createDataAttributes({ align, layout, tone })}
className={cn(emptyStateVariants({ tone }), className)} className={cn(emptyStateVariants({ align, layout, tone }), className)}
ref={ref} ref={ref}
/> />
); );
}); });
export type EmptyStateMediaProps = ComponentPropsWithoutRef<"div">; export type EmptyStateMediaProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof emptyStateMediaVariants>;
export const EmptyStateMedia = forwardRef<HTMLDivElement, EmptyStateMediaProps>( export const EmptyStateMedia = forwardRef<HTMLDivElement, EmptyStateMediaProps>(
function EmptyStateMedia({ className, ...props }, ref) { function EmptyStateMedia({ className, size, ...props }, ref) {
return ( return (
<div <div
{...props} {...props}
{...createSlot("media")} {...createSlot("media")}
className={cn(emptyStateMediaVariants(), className)} {...createDataAttributes({ size })}
className={cn(emptyStateMediaVariants({ size }), className)}
ref={ref} ref={ref}
/> />
); );
} }
); );
export type EmptyStateHeaderProps = ComponentPropsWithoutRef<"div">; export type EmptyStateHeaderProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof emptyStateHeaderVariants>;
export const EmptyStateHeader = forwardRef<HTMLDivElement, EmptyStateHeaderProps>( export const EmptyStateHeader = forwardRef<HTMLDivElement, EmptyStateHeaderProps>(
function EmptyStateHeader({ className, ...props }, ref) { function EmptyStateHeader({ align, className, ...props }, ref) {
return ( return (
<div <div
{...props} {...props}
{...createSlot("header")} {...createSlot("header")}
className={cn(emptyStateHeaderVariants(), className)} {...createDataAttributes({ align })}
className={cn(emptyStateHeaderVariants({ align }), className)}
ref={ref} 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>( export const EmptyStateActions = forwardRef<HTMLDivElement, EmptyStateActionsProps>(
function EmptyStateActions({ className, ...props }, ref) { function EmptyStateActions({ className, layout, ...props }, ref) {
return ( return (
<div <div
{...props} {...props}
{...createSlot("actions")} {...createSlot("actions")}
className={cn(emptyStateActionsVariants(), className)} {...createDataAttributes({ layout })}
className={cn(emptyStateActionsVariants({ layout }), className)}
ref={ref} ref={ref}
/> />
); );
@@ -3,8 +3,8 @@ import { getMotionRecipeClassNames } from "../lib/motion";
export const emptyStateVariants = cva( 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)]", "grid gap-6 rounded-[var(--ui-card-radius)] border shadow-[var(--ui-card-default-shadow)] [border-width:var(--ui-card-border-width)]",
"justify-items-center text-center text-[var(--color-card-foreground)]", "text-[var(--color-card-foreground)]",
getMotionRecipeClassNames("transition", "ring") 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)]", "border-[var(--ui-card-subtle-border)] bg-[var(--ui-card-subtle-bg)] shadow-[var(--ui-card-subtle-shadow)]",
accent: accent:
"border-[var(--ui-card-accent-border)] bg-[var(--ui-card-accent-bg)] shadow-[var(--ui-card-accent-shadow)]" "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: { defaultVariants: {
tone: "default" tone: "default",
layout: "default",
align: "center"
} }
} }
); );
export const emptyStateMediaVariants = cva( 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))]", "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)]" "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( export const emptyStateEyebrowVariants = cva(
"text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]" "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)]" "max-w-[30rem] text-sm leading-6 text-[var(--color-muted-foreground)]"
); );
export const emptyStateActionsVariants = cva( export const emptyStateActionsVariants = cva("flex flex-wrap items-center gap-3", {
"flex flex-wrap items-center justify-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 () => { it("reports controlled open changes from the trigger", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onOpenChange = vi.fn(); const onOpenChange = vi.fn();
+4 -4
View File
@@ -19,7 +19,7 @@ export const PopoverContent = forwardRef<
ElementRef<typeof PopoverPrimitive.Content>, ElementRef<typeof PopoverPrimitive.Content>,
PopoverContentProps PopoverContentProps
>(function PopoverContent( >(function PopoverContent(
{ className, sideOffset = 10, size, ...props }, { className, padding, sideOffset = 10, size, ...props },
ref ref
) { ) {
return ( return (
@@ -27,8 +27,8 @@ export const PopoverContent = forwardRef<
<PopoverPrimitive.Content <PopoverPrimitive.Content
{...props} {...props}
{...createSlot("content")} {...createSlot("content")}
{...createDataAttributes({ size })} {...createDataAttributes({ padding, size })}
className={cn(popoverContentVariants({ size }), className)} className={cn(popoverContentVariants({ padding, size }), className)}
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
/> />
@@ -44,7 +44,7 @@ export const PopoverArrow = forwardRef<
<PopoverPrimitive.Arrow <PopoverPrimitive.Arrow
{...props} {...props}
{...createSlot("arrow")} {...createSlot("arrow")}
className={cn("fill-[var(--color-card)]", className)} className={cn("fill-[var(--ui-panel-bg)]", className)}
ref={ref} ref={ref}
/> />
); );
@@ -9,13 +9,21 @@ export const popoverContentVariants = cva(
], ],
{ {
variants: { variants: {
padding: {
none: "p-0",
sm: "p-3",
md: "p-4",
lg: "p-5"
},
size: { size: {
sm: "w-64", sm: "w-64",
md: "w-80", md: "w-80",
lg: "w-[24rem]" lg: "w-[24rem]",
xl: "w-[30rem]"
} }
}, },
defaultVariants: { defaultVariants: {
padding: "md",
size: "md" size: "md"
} }
} }
+21
View File
@@ -131,3 +131,24 @@ export function SortUnsortedIcon({ className, ...props }: IconProps) {
</IconFrame> </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>
);
}
+7
View File
@@ -287,6 +287,9 @@
"packages/ui/src/components/checkbox.variants.ts", "packages/ui/src/components/checkbox.variants.ts",
"packages/ui/src/components/data-table.tsx", "packages/ui/src/components/data-table.tsx",
"packages/ui/src/components/data-table.variants.ts", "packages/ui/src/components/data-table.variants.ts",
"packages/ui/src/components/dialog.variants.ts",
"packages/ui/src/components/dropdown-menu.tsx",
"packages/ui/src/components/dropdown-menu.variants.ts",
"packages/ui/src/components/empty-state.tsx", "packages/ui/src/components/empty-state.tsx",
"packages/ui/src/components/empty-state.variants.ts", "packages/ui/src/components/empty-state.variants.ts",
"packages/ui/src/components/field.tsx", "packages/ui/src/components/field.tsx",
@@ -295,6 +298,8 @@
"packages/ui/src/components/label.tsx", "packages/ui/src/components/label.tsx",
"packages/ui/src/components/select.tsx", "packages/ui/src/components/select.tsx",
"packages/ui/src/components/select.variants.ts", "packages/ui/src/components/select.variants.ts",
"packages/ui/src/components/sheet.tsx",
"packages/ui/src/components/sheet.variants.ts",
"packages/ui/src/components/skeleton.tsx", "packages/ui/src/components/skeleton.tsx",
"packages/ui/src/components/spinner.tsx", "packages/ui/src/components/spinner.tsx",
"packages/ui/src/lib/cn.ts", "packages/ui/src/lib/cn.ts",
@@ -307,6 +312,8 @@
"name": "data-table", "name": "data-table",
"packageDependencies": { "packageDependencies": {
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",