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? +

+ +
+ } + 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. +

+ + + } + 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 (
- + +

+ Need a new action? Add it to the workspace command registry. +

+ +
+ } + 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) => ( + + + 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} - + @@ -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 ( + + + + + +
+

+ 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} +

+
+ ))} +
+
+
+ + + + +
+ +
+
+ ); +} + +function InlineComposerPopover() { + return ( + + + + + +
+

+ Reviewer routing +

+

+ Lightweight form-like content can stay in a popover when the interaction is + single-purpose and fast to dismiss. +

+
+
+
+ + +
+
+ + +
+
+
+ + + + +
+ +
+
+ ); +} + 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={ + + } + 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={} + 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 }) => ( +
+ +
+ ), + 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 ? ( + + + + + + 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 + + + + + + + + + Workspace + Invite the first operator + + 42 + + + + + + ); + + 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",