263 lines
7.8 KiB
TypeScript
263 lines
7.8 KiB
TypeScript
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 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 }>({
|
||
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 />
|
||
};
|