feat: add command and combobox components

This commit is contained in:
2026-03-19 18:16:50 +08:00
parent b7d17383bf
commit 71ebb010b9
12 changed files with 1318 additions and 0 deletions
@@ -0,0 +1,163 @@
import {
Button,
Combobox,
Form,
FormControl,
FormDescription,
FormItem,
FormLabel,
FormMessage
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
import { Controller, useForm } from "react-hook-form";
import { useState } from "react";
const teamItems = [
{
value: "design",
label: "Design",
group: "Primary teams",
description: "Owns interface quality and review workflows.",
keywords: ["ux", "ui", "visual"]
},
{
value: "engineering",
label: "Engineering",
group: "Primary teams",
description: "Implements and verifies rollout mechanics.",
keywords: ["dev", "build", "api"]
},
{
value: "legal",
label: "Legal",
group: "Specialist teams",
description: "Checks policy, compliance, and contractual risk.",
keywords: ["policy", "compliance"]
},
{
value: "ops",
label: "Operations",
group: "Specialist teams",
description: "Coordinates timing, communications, and monitoring.",
keywords: ["launch", "support"]
}
] as const;
function ControlledDemo() {
const [value, setValue] = useState("design");
return (
<div className="grid w-[380px] gap-4">
<Combobox
aria-label="Routing team"
items={[...teamItems]}
onValueChange={setValue}
searchPlaceholder="Search 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 value: <span className="font-medium text-[var(--color-foreground)]">{value}</span>
</div>
</div>
);
}
function LaunchRoutingForm() {
const [submitted, setSubmitted] = useState<Record<string, string> | null>(null);
const form = useForm<{ team: string }>({
defaultValues: {
team: ""
}
});
return (
<Form {...form}>
<form
className="grid w-[560px] gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]"
noValidate
onSubmit={form.handleSubmit((values) => {
setSubmitted(values);
})}
>
<div className="space-y-2">
<h2 className="m-0 text-xl font-semibold tracking-[var(--tracking-tight)]">
Launch routing
</h2>
<p className="m-0 text-sm leading-6 text-[var(--color-muted-foreground)]">
Combobox can live inside <code>FormControl</code> and surface RHF validation state.
</p>
</div>
<Controller
control={form.control}
name="team"
rules={{
required: "Choose a routing team before submitting."
}}
render={({ field }) => (
<FormItem name="team">
<FormLabel>Routing team</FormLabel>
<FormControl>
<Combobox
aria-label="Routing team"
items={[...teamItems]}
onValueChange={field.onChange}
searchPlaceholder="Search teams"
value={field.value}
/>
</FormControl>
<FormDescription>
The chosen team becomes the primary owner for approvals and notifications.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="ghost"
onClick={() => {
form.reset();
setSubmitted(null);
}}
>
Reset
</Button>
<Button type="submit">Save routing</Button>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
<p className="m-0 text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Submitted payload
</p>
<pre className="m-0 mt-3 overflow-x-auto text-sm leading-6 text-[var(--color-foreground)]">
<code>{submitted ? JSON.stringify(submitted, null, 2) : "Submit the form to inspect values."}</code>
</pre>
</div>
</form>
</Form>
);
}
const meta = {
title: "Components/Combobox",
component: ControlledDemo,
parameters: {
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof ControlledDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Controlled: Story = {
render: () => <ControlledDemo />
};
export const WithForm: Story = {
render: () => <LaunchRoutingForm />
};
@@ -0,0 +1,109 @@
import { useState } from "react";
import {
Button,
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
const inlineItems = [
{
heading: "Navigation",
items: [
{ label: "Open docs", shortcut: "G D", value: "open-docs" },
{ label: "Go to releases", shortcut: "G R", value: "go-releases" }
]
},
{
heading: "Actions",
items: [
{ label: "Publish update", shortcut: "P U", value: "publish-update" },
{ label: "Invite reviewer", shortcut: "I R", value: "invite-reviewer" }
]
}
];
function InlineCommandShowcase() {
return (
<Command className="w-[520px]">
<CommandInput placeholder="Search across the workspace" />
<CommandList>
<CommandEmpty>No matching actions.</CommandEmpty>
{inlineItems.map((group, index) => (
<div key={group.heading}>
<CommandGroup heading={group.heading}>
{group.items.map((item) => (
<CommandItem key={item.value} value={item.value}>
{item.label}
<CommandShortcut>{item.shortcut}</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
{index < inlineItems.length - 1 ? <CommandSeparator /> : null}
</div>
))}
</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}>
<CommandInput placeholder="Type a command or search" />
<CommandList>
<CommandEmpty>No commands available.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem value="launch-checklist">Launch checklist</CommandItem>
<CommandItem value="rollout-audit">Rollout audit</CommandItem>
<CommandItem value="brand-theme">Brand theme tokens</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="People">
<CommandItem value="jordan-lee">
Jordan Lee
<CommandShortcut>@</CommandShortcut>
</CommandItem>
<CommandItem value="avery-carter">
Avery Carter
<CommandShortcut>@</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
</div>
);
}
const meta = {
title: "Components/Command",
component: InlineCommandShowcase,
parameters: {
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof InlineCommandShowcase>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => <InlineCommandShowcase />
};
export const DialogPalette: Story = {
render: () => <DialogCommandShowcase />
};