Files
cadence-ui/apps/docs/src/components/combobox.stories.tsx
T

263 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 RecentAndSuggestedDemo() {
const [value, setValue] = useState("");
const items = [
{
value: "recent-legal",
label: "Legal review",
group: "Recent",
description: "Last used in yesterdays 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 }>({
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: {
docs: {
description: {
component:
"Combobox is the searchable single-select surface for longer option sets, recent picks, and async lookup flows. The list should feel gently staged as results appear, with active rows gliding into place instead of snapping."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof ControlledDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Controlled: Story = {
render: () => <ControlledDemo />
};
export const RecentAndSuggested: Story = {
render: () => <RecentAndSuggestedDemo />
};
export const AsyncResults: Story = {
parameters: {
docs: {
description: {
story:
"Async comboboxes should crossfade between loading, empty, and result states. Keep the motion short and directional so the user understands the state change without losing the current context."
}
}
},
render: () => <AsyncResultsDemo />
};
export const WithForm: Story = {
render: () => <LaunchRoutingForm />
};