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