From a8c1d3f2566a73fd2d1121d8599a8398f954e793 Mon Sep 17 00:00:00 2001
From: kurihada
Date: Fri, 20 Mar 2026 18:11:48 +0800
Subject: [PATCH] feat(ui): expand workflow-ready components
---
.changeset/fair-rules-boil.md | 5 +
apps/docs/src/components/combobox.stories.tsx | 85 +++++++
apps/docs/src/components/command.stories.tsx | 87 ++++++-
.../src/components/data-table.stories.tsx | 50 +++++
.../src/components/dropdown-menu.stories.tsx | 170 ++++++++++++--
.../src/components/empty-state.stories.tsx | 77 ++++++-
apps/docs/src/components/popover.stories.tsx | 170 +++++++++++++-
packages/ui/src/components/combobox.test.tsx | 67 +++++-
packages/ui/src/components/combobox.tsx | 73 +++++-
.../ui/src/components/combobox.variants.ts | 4 +
packages/ui/src/components/command.test.tsx | 57 +++++
packages/ui/src/components/command.tsx | 77 ++++++-
.../ui/src/components/command.variants.ts | 8 +
.../ui/src/components/data-table.test.tsx | 77 +++++++
packages/ui/src/components/data-table.tsx | 212 +++++++++++++++++-
.../ui/src/components/data-table.variants.ts | 16 +-
.../ui/src/components/dropdown-menu.test.tsx | 49 ++++
packages/ui/src/components/dropdown-menu.tsx | 135 +++++++++--
.../src/components/dropdown-menu.variants.ts | 21 +-
.../ui/src/components/empty-state.test.tsx | 48 ++++
packages/ui/src/components/empty-state.tsx | 30 ++-
.../ui/src/components/empty-state.variants.ts | 61 ++++-
packages/ui/src/components/popover.test.tsx | 22 ++
packages/ui/src/components/popover.tsx | 8 +-
.../ui/src/components/popover.variants.ts | 10 +-
packages/ui/src/lib/icons.tsx | 21 ++
registry/index.json | 7 +
27 files changed, 1562 insertions(+), 85 deletions(-)
create mode 100644 .changeset/fair-rules-boil.md
diff --git a/.changeset/fair-rules-boil.md b/.changeset/fair-rules-boil.md
new file mode 100644
index 0000000..0fb0568
--- /dev/null
+++ b/.changeset/fair-rules-boil.md
@@ -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.
diff --git a/apps/docs/src/components/combobox.stories.tsx b/apps/docs/src/components/combobox.stories.tsx
index 37fbbec..b74fe58 100644
--- a/apps/docs/src/components/combobox.stories.tsx
+++ b/apps/docs/src/components/combobox.stories.tsx
@@ -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 yesterday’s policy update."
+ },
+ {
+ value: "recent-design",
+ label: "Design review",
+ group: "Recent",
+ description: "Common pick for UI launches."
+ },
+ ...teamItems
+ ];
+
+ return (
+
+
`No team named “${query}”. Create a custom routing lane instead.`}
+ footer={
+
+
+ Need a specialist lane?
+
+
+ Create lane
+
+
+ }
+ items={items}
+ onValueChange={setValue}
+ searchPlaceholder="Search recent and suggested teams"
+ value={value}
+ />
+
+ Current routing lane:{" "}
+
+ {value || "No lane selected"}
+
+
+
+ );
+}
+
+function AsyncResultsDemo() {
+ const [searchValue, setSearchValue] = useState("");
+ const trimmedSearch = searchValue.trim().toLowerCase();
+
+ const isSearching = trimmedSearch.length > 0 && trimmedSearch.length < 3;
+
+ return (
+
+
+ `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}
+ />
+
+ This pattern is useful when the results come from an API and you need a clear
+ transition between loading, empty, and selectable states.
+
+
+ );
+}
+
function LaunchRoutingForm() {
const [submitted, setSubmitted] = useState | null>(null);
const form = useForm<{ team: string }>({
@@ -158,6 +235,14 @@ export const Controlled: Story = {
render: () =>
};
+export const RecentAndSuggested: Story = {
+ render: () =>
+};
+
+export const AsyncResults: Story = {
+ render: () =>
+};
+
export const WithForm: Story = {
render: () =>
};
diff --git a/apps/docs/src/components/command.stories.tsx b/apps/docs/src/components/command.stories.tsx
index c419a8d..af06b54 100644
--- a/apps/docs/src/components/command.stories.tsx
+++ b/apps/docs/src/components/command.stories.tsx
@@ -55,13 +55,74 @@ function InlineCommandShowcase() {
);
}
+function OperationsWorkbenchShowcase() {
+ return (
+
+
+ Tip: press Enter to run the highlighted action.
+
+
+ Manage actions
+
+
+ }
+ label="Operations workbench"
+ loop
+ >
+
+
+ No matching actions.
+
+
+ Launch review
+ R
+
+
+ Blocked queue
+ B
+
+
+
+
+
+ Open Storybook docs
+ G D
+
+
+ Compliance review locked
+ Locked
+
+
+
+
+ );
+}
+
function DialogCommandShowcase() {
const [open, setOpen] = useState(false);
return (
setOpen(true)}>Open command palette
-
+
+
+ Need a new action? Add it to the workspace command registry.
+
+
+ Manage shortcuts
+
+
+ }
+ onOpenChange={setOpen}
+ open={open}
+ title="Workspace command palette"
+ >
No commands available.
@@ -87,6 +148,22 @@ function DialogCommandShowcase() {
);
}
+function LoadingResultsShowcase() {
+ return (
+
+
+
+ No commands available.
+
+
+ );
+}
+
const meta = {
title: "Components/Command",
component: InlineCommandShowcase,
@@ -104,6 +181,14 @@ export const Playground: Story = {
render: () =>
};
+export const OperationsWorkbench: Story = {
+ render: () =>
+};
+
export const DialogPalette: Story = {
render: () =>
};
+
+export const LoadingResults: Story = {
+ render: () =>
+};
diff --git a/apps/docs/src/components/data-table.stories.tsx b/apps/docs/src/components/data-table.stories.tsx
index a9e945b..86218b0 100644
--- a/apps/docs/src/components/data-table.stories.tsx
+++ b/apps/docs/src/components/data-table.stories.tsx
@@ -128,6 +128,7 @@ const routingColumns: DataTableColumn[] = [
),
header: "Lane",
+ hideable: false,
id: "lane",
sortable: true,
width: "18rem"
@@ -141,6 +142,7 @@ const routingColumns: DataTableColumn[] = [
),
header: "Owner",
+ hideable: true,
id: "owner",
sortable: true,
width: "15rem"
@@ -166,6 +168,7 @@ const routingColumns: DataTableColumn[] = [
),
header: "Signal",
+ hideable: true,
id: "state",
sortable: true,
width: "12rem"
@@ -178,6 +181,7 @@ const routingColumns: DataTableColumn[] = [
),
header: "Routing note",
+ hideable: true,
id: "note",
width: "34rem"
}
@@ -237,6 +241,46 @@ function RoutingEmptyState({ onReset }: { onReset: () => void }) {
);
}
+function RoutingLaneDetail({ row }: { row: RoutingLaneRow }) {
+ return (
+
+
+
+
+ {row.state}
+
+
+ {row.lane}
+
+
+ {row.audience}
+
+
+
{row.note}
+
+
+
+
+
+ Owner
+
+
{row.owner}
+
{row.ownerEmail}
+
+
+
+ Next gate
+
+
{row.nextGate}
+
+ Signal score {row.signalScore}
+
+
+
+
+ );
+}
+
function DataTablePlayground() {
const [filter, setFilter] = useState("all");
const [searchValue, setSearchValue] = useState("");
@@ -288,6 +332,7 @@ function DataTablePlayground() {
}
enableSelection
getRowId={(row) => row.id}
@@ -296,6 +341,7 @@ function DataTablePlayground() {
onSortingChange={setSorting}
pageSize={3}
pageSizeOptions={[3, 5]}
+ renderRowDetails={(row) =>
}
renderRowActions={(row) => (
)}
rows={visibleRows}
+ rowDetailsDescription={(row) =>
+ `${row.lane} routing handoff for ${row.audience.toLowerCase()}`
+ }
+ rowDetailsTitle={(row) => `${row.lane} lane detail`}
searchLabel="Search routing lanes"
searchPlaceholder="Search lanes, owners, and notes"
searchValue={searchValue}
diff --git a/apps/docs/src/components/dropdown-menu.stories.tsx b/apps/docs/src/components/dropdown-menu.stories.tsx
index b2cacb7..9554c7d 100644
--- a/apps/docs/src/components/dropdown-menu.stories.tsx
+++ b/apps/docs/src/components/dropdown-menu.stories.tsx
@@ -16,6 +16,34 @@ import {
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
+function StatusIcon() {
+ return (
+
+
+
+ );
+}
+
+function LayersIcon() {
+ return (
+
+
+
+ );
+}
+
async function waitForCondition(
predicate: () => boolean,
message: string,
@@ -46,31 +74,72 @@ function ReleaseMenu({ triggerLabel = "Open menu" }: ReleaseMenuProps) {
Launch actions
-
+ }
+ shortcut="R"
+ >
Review summary
- R
-
+ }
+ shortcut="S"
+ >
Share preview
- S
-
+
Retry checks
- ⌘R
- Notify stakeholders
+
+ Notify stakeholders
+
- Staged rollout
- Global rollout
+
+ Staged rollout
+
+
+ Global rollout
+
- More actions
-
- Duplicate release
- Archive release
+
+ More actions
+
+
+
+ Duplicate release
+
+
+ Archive release
+
@@ -137,7 +206,7 @@ export const States: Story = {
docs: {
description: {
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: () => (
+
+
+
+ Release actions
+
+
+ Use richer labels when the command needs more context than a single verb.
+
+
+
+
+
+
+
+
+ Row context menu
+
+
+ Denser menus can still remain readable when every item communicates hierarchy,
+ risk, and keyboard hints clearly.
+
+
+
+
+ Open row menu
+
+
+ Task 17A · routing
+ }
+ shortcut="M"
+ >
+ Move to launch lane
+
+ }
+ shortcut="T"
+ >
+ Open thread timeline
+
+
+
+ Escalate blocker
+
+
+
+
+
+
+ )
+};
+
export const Anatomy: Story = {
render: () => (
@@ -172,7 +309,10 @@ export const Anatomy: Story = {
data-slot="label",{" "}
data-slot="separator", and{" "}
data-slot="icon" support
- grouping, dividers, and selection markers.
+ grouping, dividers, and selection markers, while{" "}
+ data-slot="description" and{" "}
+ data-slot="leading" support
+ denser contextual rows.
diff --git a/apps/docs/src/components/empty-state.stories.tsx b/apps/docs/src/components/empty-state.stories.tsx
index 7f2cabd..3bd40ff 100644
--- a/apps/docs/src/components/empty-state.stories.tsx
+++ b/apps/docs/src/components/empty-state.stories.tsx
@@ -26,27 +26,40 @@ function EmptyStateGlyph() {
}
function ReleaseEmptyState({
+ actionsLayout = "inline",
+ align = "center",
description = "Adjust the current filters or create a new release to start routing work.",
eyebrow = "No results",
+ layout = "default",
+ mediaSize = "default",
tone = "default",
title = "No matching releases"
}: {
+ actionsLayout?: "inline" | "stack";
+ align?: "center" | "start";
description?: string;
eyebrow?: string;
+ layout?: "compact" | "default" | "split";
+ mediaSize?: "compact" | "default" | "hero";
title?: string;
tone?: "default" | "subtle" | "accent";
}) {
return (
-
-
+
+
-
+
{eyebrow}
{title}
{description}
-
+
Create release
Reset filters
@@ -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: () => (
+
+
+
+ )
+};
+
+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: () => (
+
+
+
+ )
+};
+
export const Anatomy: Story = {
render: () => (
@@ -117,6 +179,13 @@ export const Anatomy: Story = {
data-tone exposes whether the
surface stays neutral, subtle, or accent-led.
+
+ layout,{" "}
+ align,{" "}
+ size, and{" "}
+ actions layout let the same
+ composition flex between dense cards and broader setup moments.
+
diff --git a/apps/docs/src/components/popover.stories.tsx b/apps/docs/src/components/popover.stories.tsx
index f370d5a..6106499 100644
--- a/apps/docs/src/components/popover.stories.tsx
+++ b/apps/docs/src/components/popover.stories.tsx
@@ -1,5 +1,6 @@
import {
Button,
+ Input,
Popover,
PopoverArrow,
PopoverClose,
@@ -61,6 +62,111 @@ function SummaryPopover({
);
}
+function InspectorPopover() {
+ return (
+
+
+ Open release inspector
+
+
+
+
+ Release inspector
+
+
+ Rollout checklist
+
+
+ Keep the surface anchored to the trigger while still supporting denser,
+ panel-like review content.
+
+
+
+
+
+ Rollout owner
+
+
+
+
+ {[
+ ["Checks", "12 passed"],
+ ["Approvals", "2 waiting"],
+ ["Scope", "10% traffic"],
+ ["Alerts", "Muted"]
+ ].map(([label, value]) => (
+
+
+ {label}
+
+
+ {value}
+
+
+ ))}
+
+
+
+
+
+ Close
+
+
+
Approve rollout
+
+
+
+
+ );
+}
+
+function InlineComposerPopover() {
+ return (
+
+
+ Assign reviewer
+
+
+
+
+ Reviewer routing
+
+
+ Lightweight form-like content can stay in a popover when the interaction is
+ single-purpose and fast to dismiss.
+
+
+
+
+
+
+ Cancel
+
+
+
Send request
+
+
+
+
+ );
+}
+
const meta = {
title: "Components/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: () => (
+
+ )
+};
+
export const Anatomy: Story = {
render: () => (
@@ -132,11 +259,12 @@ export const Anatomy: Story = {
Popover anatomy
-
+
data-slot="content" wraps
- the floating surface and exposes data-size.
+ the floating surface and exposes data-size and{" "}
+ data-padding.
data-slot="arrow" visually
@@ -170,3 +298,41 @@ export const Motion: Story = {
)
};
+
+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: () => (
+
+
+
+ Anchored release context
+
+
+ Use a richer popover when the user needs more detail than a tooltip, but not a
+ full modal interruption.
+
+
+
+
+
+
+
+ Quick assignment flow
+
+
+ Single-purpose forms can stay lightweight and local to the trigger.
+
+
+
+
+
+
+ )
+};
diff --git a/packages/ui/src/components/combobox.test.tsx b/packages/ui/src/components/combobox.test.tsx
index 16099d7..4c1e21b 100644
--- a/packages/ui/src/components/combobox.test.tsx
+++ b/packages/ui/src/components/combobox.test.tsx
@@ -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(
{
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(
+ `Create “${query}” as a new lane`}
+ footer={
+
+ Manage routing lanes
+
+ }
+ 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(
+ `Create “${query}” as a new lane`}
+ footer={Manage routing lanes }
+ 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();
+ });
+ });
});
diff --git a/packages/ui/src/components/combobox.tsx b/packages/ui/src/components/combobox.tsx
index 0016c07..563eb85 100644
--- a/packages/ui/src/components/combobox.tsx
+++ b/packages/ui/src/components/combobox.tsx
@@ -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) {
@@ -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(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(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(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 (
(function Co
value={resolvedSearchValue}
/>
- {filteredItems.length === 0 ? (
+ {loading ? (
- {emptyMessage}
+
+
+ {loadingMessage}
+
+
+ ) : filteredItems.length === 0 ? (
+
+ {renderedEmptyMessage}
) : (
(function Co
))}
)}
+ {footer ? (
+
+ {footer}
+
+ ) : null}
diff --git a/packages/ui/src/components/combobox.variants.ts b/packages/ui/src/components/combobox.variants.ts
index b3a2d6d..fc06521 100644
--- a/packages/ui/src/components/combobox.variants.ts
+++ b/packages/ui/src/components/combobox.variants.ts
@@ -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"
+]);
diff --git a/packages/ui/src/components/command.test.tsx b/packages/ui/src/components/command.test.tsx
index 083b98e..2bc1c13 100644
--- a/packages/ui/src/components/command.test.tsx
+++ b/packages/ui/src/components/command.test.tsx
@@ -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(
+ Manage commands}
+ label="Workspace palette"
+ loading
+ loadingMessage="Searching workspace…"
+ >
+
+
+ No matching items.
+
+
+ );
+
+ 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 (
+ Manage shortcuts}
+ onOpenChange={setOpen}
+ open={open}
+ title="Workspace command palette"
+ >
+
+
+
+ Recent release brief
+
+
+
+ );
+ }
+
+ render( );
+
+ 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();
+ });
+ });
});
diff --git a/packages/ui/src/components/command.tsx b/packages/ui/src/components/command.tsx
index 0f2379c..1950786 100644
--- a/packages/ui/src/components/command.tsx
+++ b/packages/ui/src/components/command.tsx
@@ -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;
+export type CommandProps = ComponentPropsWithoutRef & {
+ footer?: ReactNode;
+ loading?: boolean;
+ loadingMessage?: ReactNode;
+};
export const Command = forwardRef, CommandProps>(
- function Command({ className, ...props }, ref) {
+ function Command(
+ {
+ children,
+ className,
+ footer,
+ loading = false,
+ loadingMessage = "Loading results…",
+ ...props
+ },
+ ref
+ ) {
return (
+ >
+ {loading ? (
+
+ {loadingMessage}
+
+ ) : null}
+ {children}
+ {footer ? (
+
+ {footer}
+
+ ) : null}
+
);
}
);
@@ -57,18 +97,45 @@ export type CommandDialogProps = ComponentPropsWithoutRef
- {children}
+ {title || description ? (
+
+ {title ? {title} : null}
+ {description ? {description} : null}
+
+ ) : null}
+ {footer}
+ ) : undefined
+ }
+ loading={loading}
+ loadingMessage={loadingMessage}
+ >
+ {children}
+
);
diff --git a/packages/ui/src/components/command.variants.ts b/packages/ui/src/components/command.variants.ts
index 7a0a07f..544a00b 100644
--- a/packages/ui/src/components/command.variants.ts
+++ b/packages/ui/src/components/command.variants.ts
@@ -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"
+]);
diff --git a/packages/ui/src/components/data-table.test.tsx b/packages/ui/src/components/data-table.test.tsx
index 5494803..fc888cc 100644
--- a/packages/ui/src/components/data-table.test.tsx
+++ b/packages/ui/src/components/data-table.test.tsx
@@ -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(
+
+ );
+
+ 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( );
+
+ 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(
+ {row.note}
}
+ 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();
+ });
});
diff --git a/packages/ui/src/components/data-table.tsx b/packages/ui/src/components/data-table.tsx
index 1ede175..cbc9e10 100644
--- a/packages/ui/src/components/data-table.tsx
+++ b/packages/ui/src/components/data-table.tsx
@@ -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 = {
align?: DataTableAlignment;
cell?: (row: TData) => ReactNode;
header: ReactNode | ((column: DataTableColumnContext) => ReactNode);
+ hideable?: boolean;
id: string;
searchValue?: (row: TData) => string;
searchable?: boolean;
@@ -87,25 +106,34 @@ export type DataTableColumn = {
export type DataTableProps = Omit, "children"> & {
columns: DataTableColumn[];
+ defaultDensity?: DataTableDensity;
defaultPageIndex?: number;
defaultPageSize?: number;
defaultSearchValue?: string;
defaultSelection?: Record;
defaultSorting?: DataTableSort[];
+ defaultVisibleColumns?: Record;
+ 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) => void;
onSortingChange?: (sorting: DataTableSort[]) => void;
+ onVisibleColumnsChange?: (visibility: Record) => 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 = Omit, "child
sorting?: DataTableSort[];
tableLabel?: string;
toolbarActions?: ReactNode;
+ visibleColumns?: Record;
};
type InternalColumnMeta = {
@@ -212,25 +241,34 @@ function DataTableInner(
{
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(
sorting,
tableLabel = "Data table",
toolbarActions,
+ visibleColumns,
...props
}: DataTableProps,
ref: ForwardedRef
@@ -275,6 +314,19 @@ function DataTableInner(
defaultValue: defaultPageSize,
onChange: onPageSizeChange
});
+ const [currentDensity, setCurrentDensity] = useControllableState({
+ controlledValue: density,
+ defaultValue: defaultDensity,
+ onChange: onDensityChange
+ });
+ const [currentVisibleColumns, setCurrentVisibleColumns] = useControllableState<
+ Record
+ >({
+ controlledValue: visibleColumns,
+ defaultValue: defaultVisibleColumns,
+ onChange: onVisibleColumnsChange
+ });
+ const [detailRowId, setDetailRowId] = useState(null);
const resolvedPageSizeOptions = [...new Set([...pageSizeOptions, currentPageSize])].sort(
(left, right) => left - right
@@ -325,6 +377,34 @@ function DataTableInner(
} satisfies ColumnDef
]
: []),
+ ...(renderRowDetails
+ ? [
+ {
+ cell: ({ row }) => (
+
+ {
+ setDetailRowId(row.id);
+ }}
+ >
+ {rowDetailsLabel}
+
+
+ ),
+ enableGlobalFilter: false,
+ enableHiding: false,
+ enableSorting: false,
+ header: () => Row details ,
+ id: "__details",
+ meta: {
+ align: "end",
+ width: 108
+ } satisfies InternalColumnMeta
+ } satisfies ColumnDef
+ ]
+ : []),
...columns.map((column) => ({
accessorFn: column.accessor
? (row: TData) => getColumnAccessorValue(row, column)
@@ -334,6 +414,7 @@ function DataTableInner(
? 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) =>
typeof column.header === "function"
@@ -424,12 +505,21 @@ function DataTableInner(
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(
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(
}
}, [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 (
(
selected: selectedRows.length > 0
})}
className={cn(dataTableRootVariants(), className)}
+ data-density={currentDensity}
ref={ref}
>
{shouldRenderToolbar ? (
@@ -483,7 +598,53 @@ function DataTableInner(
) : null}
- {toolbarActions ? {toolbarActions} : null}
+
+ {toolbarActions}
+ {shouldRenderViewOptions ? (
+
+
+
+ View
+
+
+
+ Density
+ {
+ setCurrentDensity(value as DataTableDensity);
+ }}
+ >
+
+ Comfortable
+
+
+ Compact
+
+
+
+ Columns
+ {hideableColumns.map((column) => {
+ const meta = column.columnDef.meta as InternalColumnMeta | undefined;
+ const label =
+ meta?.sourceColumn ? getHeaderLabel(meta.sourceColumn) : column.id;
+
+ return (
+ {
+ column.toggleVisibility(Boolean(checked));
+ }}
+ >
+ {label}
+
+ );
+ })}
+
+
+ ) : null}
+
) : null}
@@ -533,6 +694,7 @@ function DataTableInner(
: undefined
}
scope="col"
+ density={currentDensity}
sortable={header.column.getCanSort()}
sort={sortState}
style={getColumnWidthStyle(meta?.width)}
@@ -618,6 +780,7 @@ function DataTableInner(
return (
@@ -690,6 +853,27 @@ function DataTableInner(
+
+ {renderRowDetails && detailRowOriginal ? (
+ {
+ if (!nextOpen) {
+ setDetailRowId(null);
+ }
+ }}
+ >
+
+
+ {resolvedDetailTitle}
+ {resolvedDetailDescription ? (
+ {resolvedDetailDescription}
+ ) : null}
+
+ {renderRowDetails(detailRowOriginal)}
+
+
+ ) : null}
);
}
@@ -787,12 +971,20 @@ export const DataTableHeader = forwardRef, "align"> &
VariantProps & {
+ density?: DataTableDensity;
sort?: "asc" | "desc" | false;
};
export const DataTableHeaderCell = forwardRef(
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
);
@@ -846,15 +1039,20 @@ export const DataTableRow = forwardRef(
);
export type DataTableCellProps = Omit, "align"> &
- VariantProps;
+ VariantProps & {
+ density?: DataTableDensity;
+ };
export const DataTableCell = forwardRef(
- function DataTableCell({ align = "start", className, ...props }, ref) {
+ function DataTableCell(
+ { align = "start", className, density = "comfortable", ...props },
+ ref
+ ) {
return (
);
diff --git a/packages/ui/src/components/data-table.variants.ts b/packages/ui/src/components/data-table.variants.ts
index 55a5eaf..89f0eeb 100644
--- a/packages/ui/src/components/data-table.variants.ts
+++ b/packages/ui/src/components/data-table.variants.ts
@@ -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"
}
}
);
diff --git a/packages/ui/src/components/dropdown-menu.test.tsx b/packages/ui/src/components/dropdown-menu.test.tsx
index c2c11e6..a830fd1 100644
--- a/packages/ui/src/components/dropdown-menu.test.tsx
+++ b/packages/ui/src/components/dropdown-menu.test.tsx
@@ -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(
+
+ Open menu
+
+ •}
+ shortcut="R"
+ >
+ Review summary
+
+
+
+ More actions
+
+
+
+ Archive release
+
+
+
+
+
+ );
+
+ 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();
diff --git a/packages/ui/src/components/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu.tsx
index fe07a59..01e8f28 100644
--- a/packages/ui/src/components/dropdown-menu.tsx
+++ b/packages/ui/src/components/dropdown-menu.tsx
@@ -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) {
+ return (
+ <>
+ {leading ? (
+
+ {leading}
+
+ ) : null}
+
+
+ {children}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ {shortcut ? {shortcut} : null}
+ >
+ );
+}
+
export type DropdownMenuContentProps =
ComponentPropsWithoutRef &
VariantProps;
@@ -76,13 +128,14 @@ export const DropdownMenuSubContent = forwardRef<
export type DropdownMenuItemProps =
ComponentPropsWithoutRef &
- VariantProps;
+ VariantProps &
+ DropdownMenuRichItemProps;
export const DropdownMenuItem = forwardRef<
ElementRef,
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}
- />
+ >
+
+ {children}
+
+
);
});
export type DropdownMenuCheckboxItemProps =
ComponentPropsWithoutRef &
- VariantProps;
+ VariantProps &
+ Omit;
export const DropdownMenuCheckboxItem = forwardRef<
ElementRef,
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<
- {children}
+
+ {children}
+
);
});
export type DropdownMenuRadioItemProps =
ComponentPropsWithoutRef &
- VariantProps;
+ VariantProps &
+ Omit;
export const DropdownMenuRadioItem = forwardRef<
ElementRef,
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<
- {children}
+
+ {children}
+
);
});
@@ -196,13 +286,14 @@ export const DropdownMenuSeparator = forwardRef<
export type DropdownMenuSubTriggerProps =
ComponentPropsWithoutRef &
- VariantProps;
+ VariantProps &
+ Pick;
export const DropdownMenuSubTrigger = forwardRef<
ElementRef,
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}
+
+
+ {children}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
);
diff --git a/packages/ui/src/components/dropdown-menu.variants.ts b/packages/ui/src/components/dropdown-menu.variants.ts
index dca0fad..615a5c0 100644
--- a/packages/ui/src/components/dropdown-menu.variants.ts
+++ b/packages/ui/src/components/dropdown-menu.variants.ts
@@ -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)]"
diff --git a/packages/ui/src/components/empty-state.test.tsx b/packages/ui/src/components/empty-state.test.tsx
index 7328e45..1836d65 100644
--- a/packages/ui/src/components/empty-state.test.tsx
+++ b/packages/ui/src/components/empty-state.test.tsx
@@ -56,4 +56,52 @@ describe("EmptyState", () => {
"items-start"
);
});
+
+ it("supports compact and split layout variants with slot metadata", () => {
+ render(
+ <>
+
+ Q1
+
+ No queue activity
+
+
+ Create task
+
+
+
+
+
+ Workspace
+ Invite the first operator
+
+ 42
+
+ Invite operator
+
+
+ >
+ );
+
+ 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");
+ });
});
diff --git a/packages/ui/src/components/empty-state.tsx b/packages/ui/src/components/empty-state.tsx
index 59b5955..ebafd9f 100644
--- a/packages/ui/src/components/empty-state.tsx
+++ b/packages/ui/src/components/empty-state.tsx
@@ -17,44 +17,48 @@ export type EmptyStateProps = ComponentPropsWithoutRef<"div"> &
VariantProps;
export const EmptyState = forwardRef(function EmptyState(
- { className, tone, ...props },
+ { align, className, layout, tone, ...props },
ref
) {
return (
);
});
-export type EmptyStateMediaProps = ComponentPropsWithoutRef<"div">;
+export type EmptyStateMediaProps = ComponentPropsWithoutRef<"div"> &
+ VariantProps;
export const EmptyStateMedia = forwardRef(
- function EmptyStateMedia({ className, ...props }, ref) {
+ function EmptyStateMedia({ className, size, ...props }, ref) {
return (
);
}
);
-export type EmptyStateHeaderProps = ComponentPropsWithoutRef<"div">;
+export type EmptyStateHeaderProps = ComponentPropsWithoutRef<"div"> &
+ VariantProps;
export const EmptyStateHeader = forwardRef(
- function EmptyStateHeader({ className, ...props }, ref) {
+ function EmptyStateHeader({ align, className, ...props }, ref) {
return (
);
@@ -107,15 +111,17 @@ export const EmptyStateDescription = forwardRef<
);
});
-export type EmptyStateActionsProps = ComponentPropsWithoutRef<"div">;
+export type EmptyStateActionsProps = ComponentPropsWithoutRef<"div"> &
+ VariantProps;
export const EmptyStateActions = forwardRef(
- function EmptyStateActions({ className, ...props }, ref) {
+ function EmptyStateActions({ className, layout, ...props }, ref) {
return (
);
diff --git a/packages/ui/src/components/empty-state.variants.ts b/packages/ui/src/components/empty-state.variants.ts
index 2df77f7..87c4bec 100644
--- a/packages/ui/src/components/empty-state.variants.ts
+++ b/packages/ui/src/components/empty-state.variants.ts
@@ -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"
+ }
+});
diff --git a/packages/ui/src/components/popover.test.tsx b/packages/ui/src/components/popover.test.tsx
index de32000..ec9b8f6 100644
--- a/packages/ui/src/components/popover.test.tsx
+++ b/packages/ui/src/components/popover.test.tsx
@@ -40,6 +40,28 @@ describe("Popover", () => {
});
});
+ it("supports panel-style composition with explicit padding variants", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ Open inspector
+
+ Inspector header
+
+
+
+ );
+
+ 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();
diff --git a/packages/ui/src/components/popover.tsx b/packages/ui/src/components/popover.tsx
index d7dd794..dc05114 100644
--- a/packages/ui/src/components/popover.tsx
+++ b/packages/ui/src/components/popover.tsx
@@ -19,7 +19,7 @@ export const PopoverContent = forwardRef<
ElementRef,
PopoverContentProps
>(function PopoverContent(
- { className, sideOffset = 10, size, ...props },
+ { className, padding, sideOffset = 10, size, ...props },
ref
) {
return (
@@ -27,8 +27,8 @@ export const PopoverContent = forwardRef<
@@ -44,7 +44,7 @@ export const PopoverArrow = forwardRef<
);
diff --git a/packages/ui/src/components/popover.variants.ts b/packages/ui/src/components/popover.variants.ts
index 03bd9f9..bb32ff9 100644
--- a/packages/ui/src/components/popover.variants.ts
+++ b/packages/ui/src/components/popover.variants.ts
@@ -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"
}
}
diff --git a/packages/ui/src/lib/icons.tsx b/packages/ui/src/lib/icons.tsx
index f91202b..41ec67c 100644
--- a/packages/ui/src/lib/icons.tsx
+++ b/packages/ui/src/lib/icons.tsx
@@ -131,3 +131,24 @@ export function SortUnsortedIcon({ className, ...props }: IconProps) {
);
}
+
+export function SpinnerIcon({ className, ...props }: IconProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/registry/index.json b/registry/index.json
index aa3ad1f..483e920 100644
--- a/registry/index.json
+++ b/registry/index.json
@@ -287,6 +287,9 @@
"packages/ui/src/components/checkbox.variants.ts",
"packages/ui/src/components/data-table.tsx",
"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.variants.ts",
"packages/ui/src/components/field.tsx",
@@ -295,6 +298,8 @@
"packages/ui/src/components/label.tsx",
"packages/ui/src/components/select.tsx",
"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/spinner.tsx",
"packages/ui/src/lib/cn.ts",
@@ -307,6 +312,8 @@
"name": "data-table",
"packageDependencies": {
"@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-slot": "^1.2.4",
"@tanstack/react-table": "^8.21.3",