feat(ui): expand workflow-ready components
This commit is contained in:
@@ -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() {
|
||||
const [submitted, setSubmitted] = useState<Record<string, string> | null>(null);
|
||||
const form = useForm<{ team: string }>({
|
||||
@@ -158,6 +235,14 @@ export const Controlled: Story = {
|
||||
render: () => <ControlledDemo />
|
||||
};
|
||||
|
||||
export const RecentAndSuggested: Story = {
|
||||
render: () => <RecentAndSuggestedDemo />
|
||||
};
|
||||
|
||||
export const AsyncResults: Story = {
|
||||
render: () => <AsyncResultsDemo />
|
||||
};
|
||||
|
||||
export const WithForm: Story = {
|
||||
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() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<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" />
|
||||
<CommandList>
|
||||
<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 = {
|
||||
title: "Components/Command",
|
||||
component: InlineCommandShowcase,
|
||||
@@ -104,6 +181,14 @@ export const Playground: Story = {
|
||||
render: () => <InlineCommandShowcase />
|
||||
};
|
||||
|
||||
export const OperationsWorkbench: Story = {
|
||||
render: () => <OperationsWorkbenchShowcase />
|
||||
};
|
||||
|
||||
export const DialogPalette: Story = {
|
||||
render: () => <DialogCommandShowcase />
|
||||
};
|
||||
|
||||
export const LoadingResults: Story = {
|
||||
render: () => <LoadingResultsShowcase />
|
||||
};
|
||||
|
||||
@@ -128,6 +128,7 @@ const routingColumns: DataTableColumn<RoutingLaneRow>[] = [
|
||||
</div>
|
||||
),
|
||||
header: "Lane",
|
||||
hideable: false,
|
||||
id: "lane",
|
||||
sortable: true,
|
||||
width: "18rem"
|
||||
@@ -141,6 +142,7 @@ const routingColumns: DataTableColumn<RoutingLaneRow>[] = [
|
||||
</div>
|
||||
),
|
||||
header: "Owner",
|
||||
hideable: true,
|
||||
id: "owner",
|
||||
sortable: true,
|
||||
width: "15rem"
|
||||
@@ -166,6 +168,7 @@ const routingColumns: DataTableColumn<RoutingLaneRow>[] = [
|
||||
</div>
|
||||
),
|
||||
header: "Signal",
|
||||
hideable: true,
|
||||
id: "state",
|
||||
sortable: true,
|
||||
width: "12rem"
|
||||
@@ -178,6 +181,7 @@ const routingColumns: DataTableColumn<RoutingLaneRow>[] = [
|
||||
</p>
|
||||
),
|
||||
header: "Routing note",
|
||||
hideable: true,
|
||||
id: "note",
|
||||
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() {
|
||||
const [filter, setFilter] = useState<RoutingFilter>("all");
|
||||
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)]">
|
||||
<DataTable
|
||||
columns={routingColumns}
|
||||
defaultDensity="comfortable"
|
||||
empty={<RoutingEmptyState onReset={resetView} />}
|
||||
enableSelection
|
||||
getRowId={(row) => row.id}
|
||||
@@ -296,6 +341,7 @@ function DataTablePlayground() {
|
||||
onSortingChange={setSorting}
|
||||
pageSize={3}
|
||||
pageSizeOptions={[3, 5]}
|
||||
renderRowDetails={(row) => <RoutingLaneDetail row={row} />}
|
||||
renderRowActions={(row) => (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -308,6 +354,10 @@ function DataTablePlayground() {
|
||||
</Button>
|
||||
)}
|
||||
rows={visibleRows}
|
||||
rowDetailsDescription={(row) =>
|
||||
`${row.lane} routing handoff for ${row.audience.toLowerCase()}`
|
||||
}
|
||||
rowDetailsTitle={(row) => `${row.lane} lane detail`}
|
||||
searchLabel="Search routing lanes"
|
||||
searchPlaceholder="Search lanes, owners, and notes"
|
||||
searchValue={searchValue}
|
||||
|
||||
@@ -16,6 +16,34 @@ import {
|
||||
} from "@ai-ui/ui";
|
||||
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(
|
||||
predicate: () => boolean,
|
||||
message: string,
|
||||
@@ -46,31 +74,72 @@ function ReleaseMenu({ triggerLabel = "Open menu" }: ReleaseMenuProps) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Launch actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
description="Open the latest rollout summary and reviewer comments."
|
||||
leading={<StatusIcon />}
|
||||
shortcut="R"
|
||||
>
|
||||
Review summary
|
||||
<DropdownMenuShortcut>R</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
description="Copy the current branch preview and release notes."
|
||||
leading={<LayersIcon />}
|
||||
shortcut="S"
|
||||
>
|
||||
Share preview
|
||||
<DropdownMenuShortcut>S</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>
|
||||
<DropdownMenuItem
|
||||
description="Disabled while the current verification step is still running."
|
||||
disabled
|
||||
shortcut="⌘R"
|
||||
>
|
||||
Retry checks
|
||||
<DropdownMenuShortcut>⌘R</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem checked>Notify stakeholders</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked
|
||||
description="Post a summary to launch-updates after approval."
|
||||
shortcut="N"
|
||||
>
|
||||
Notify stakeholders
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup value="staged">
|
||||
<DropdownMenuRadioItem value="staged">Staged rollout</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="global">Global rollout</DropdownMenuRadioItem>
|
||||
<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>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger inset>More actions</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem>Duplicate release</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive">Archive release</DropdownMenuItem>
|
||||
<DropdownMenuSubTrigger
|
||||
description="Secondary actions that should stay grouped away from the primary flow."
|
||||
inset
|
||||
>
|
||||
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>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
@@ -137,7 +206,7 @@ export const States: Story = {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Open the menu to inspect the checked checkbox item, the selected radio item, a disabled action, the inset submenu trigger, and the destructive nested action."
|
||||
"Open the menu to inspect richer action rows with descriptions, leading icons, inline shortcuts, checked and selected states, a disabled row, an inset submenu trigger, and a destructive nested action."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -149,6 +218,74 @@ export const States: Story = {
|
||||
)
|
||||
};
|
||||
|
||||
export const ContextPanels: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"These examples show common product-style menu compositions: a release action menu and a denser context menu for row-level operations."
|
||||
}
|
||||
}
|
||||
},
|
||||
render: () => (
|
||||
<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 = {
|
||||
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)]">
|
||||
@@ -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="separator"</code>, and{" "}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,27 +26,40 @@ function EmptyStateGlyph() {
|
||||
}
|
||||
|
||||
function ReleaseEmptyState({
|
||||
actionsLayout = "inline",
|
||||
align = "center",
|
||||
description = "Adjust the current filters or create a new release to start routing work.",
|
||||
eyebrow = "No results",
|
||||
layout = "default",
|
||||
mediaSize = "default",
|
||||
tone = "default",
|
||||
title = "No matching releases"
|
||||
}: {
|
||||
actionsLayout?: "inline" | "stack";
|
||||
align?: "center" | "start";
|
||||
description?: string;
|
||||
eyebrow?: string;
|
||||
layout?: "compact" | "default" | "split";
|
||||
mediaSize?: "compact" | "default" | "hero";
|
||||
title?: string;
|
||||
tone?: "default" | "subtle" | "accent";
|
||||
}) {
|
||||
return (
|
||||
<EmptyState className="w-[min(100%,42rem)]" tone={tone}>
|
||||
<EmptyStateMedia>
|
||||
<EmptyState
|
||||
align={align}
|
||||
className="w-[min(100%,42rem)]"
|
||||
layout={layout}
|
||||
tone={tone}
|
||||
>
|
||||
<EmptyStateMedia size={mediaSize}>
|
||||
<EmptyStateGlyph />
|
||||
</EmptyStateMedia>
|
||||
<EmptyStateHeader>
|
||||
<EmptyStateHeader align={align}>
|
||||
<EmptyStateEyebrow>{eyebrow}</EmptyStateEyebrow>
|
||||
<EmptyStateTitle>{title}</EmptyStateTitle>
|
||||
<EmptyStateDescription>{description}</EmptyStateDescription>
|
||||
</EmptyStateHeader>
|
||||
<EmptyStateActions>
|
||||
<EmptyStateActions layout={actionsLayout}>
|
||||
<Button>Create release</Button>
|
||||
<Button variant="ghost">Reset filters</Button>
|
||||
</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 = {
|
||||
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)]">
|
||||
@@ -117,6 +179,13 @@ export const Anatomy: Story = {
|
||||
<code className="text-[var(--color-foreground)]">data-tone</code> exposes whether the
|
||||
surface stays neutral, subtle, or accent-led.
|
||||
</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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverArrow,
|
||||
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 = {
|
||||
title: "Components/Popover",
|
||||
component: Popover,
|
||||
@@ -125,6 +231,27 @@ export const Sizes: Story = {
|
||||
)
|
||||
};
|
||||
|
||||
export const ComposedPanels: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Use `padding=\"none\"` to compose richer inspector-style panels with custom header, content, and footer sections while keeping popover positioning and dismissal behavior."
|
||||
}
|
||||
}
|
||||
},
|
||||
render: () => (
|
||||
<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 = {
|
||||
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)]">
|
||||
@@ -132,11 +259,12 @@ export const Anatomy: Story = {
|
||||
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||
Popover anatomy
|
||||
</p>
|
||||
<SummaryPopover triggerLabel="Preview popover structure" />
|
||||
<InspectorPopover />
|
||||
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
||||
<p>
|
||||
<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>
|
||||
<code className="text-[var(--color-foreground)]">data-slot="arrow"</code> visually
|
||||
@@ -170,3 +298,41 @@ export const Motion: Story = {
|
||||
</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>
|
||||
)
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user