Skip to content

Commit 8c27c70

Browse files
committed
feat(elements): add composable choice component
1 parent 5cb42a6 commit 8c27c70

5 files changed

Lines changed: 882 additions & 0 deletions

File tree

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
---
2+
title: Choice
3+
description: A composable component for AI-driven clarification prompts where users must choose one or more options.
4+
path: elements/components/choice
5+
---
6+
7+
The `Choice` component is designed for AI workflows where the model asks users to resolve ambiguity with structured options (timeframe, severity, scope, category, and similar constraints).
8+
9+
<Preview path="choice" />
10+
11+
## Installation
12+
13+
<ElementsInstaller path="choice" />
14+
15+
## Usage with AI SDK
16+
17+
Use `Choice` to render a client-side tool call (for example, `ask_user`) and send the selected value(s) back with [`addToolOutput`](/docs/reference/ai-sdk-ui/use-chat).
18+
19+
Add the following component to your frontend:
20+
21+
```tsx title="app/page.tsx"
22+
"use client";
23+
24+
import type { ToolUIPart, UIMessage } from "ai";
25+
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
26+
import { useChat } from "@ai-sdk/react";
27+
import {
28+
Choice,
29+
ChoiceOption,
30+
ChoiceOptions,
31+
ChoiceQuestion,
32+
ChoiceStatus,
33+
ChoiceSubmit,
34+
} from "@/components/ai-elements/choice";
35+
36+
type AskUserInput = {
37+
question: string;
38+
multi_select?: boolean;
39+
options: Array<{
40+
value: string;
41+
label: string;
42+
description?: string;
43+
}>;
44+
};
45+
46+
type AskUserOutput = string | string[];
47+
48+
type AskUserPart = ToolUIPart<{
49+
ask_user: {
50+
input: AskUserInput;
51+
output: AskUserOutput;
52+
};
53+
}>;
54+
55+
const isAskUserPart = (
56+
part: UIMessage["parts"][number]
57+
): part is AskUserPart => part.type === "tool-ask_user";
58+
59+
const Example = () => {
60+
const { messages, addToolOutput, status } = useChat({
61+
transport: new DefaultChatTransport({
62+
api: "/api/chat",
63+
}),
64+
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
65+
});
66+
67+
return (
68+
<div className="max-w-4xl mx-auto p-6 relative size-full rounded-lg border h-[600px]">
69+
<div className="flex h-full flex-col gap-3 overflow-auto">
70+
{messages.map((message) =>
71+
message.parts.map((part) => {
72+
if (!isAskUserPart(part) || part.state === "input-streaming") {
73+
return null;
74+
}
75+
76+
const multiSelect = Boolean(part.input.multi_select);
77+
const allowSubmit =
78+
part.state === "input-available" || part.state === "output-error";
79+
80+
return (
81+
<Choice
82+
className="rounded-lg border p-3"
83+
key={part.toolCallId}
84+
multiple={multiSelect}
85+
submitOnSelect={!multiSelect}
86+
onSubmit={(selection) =>
87+
addToolOutput({
88+
output: selection,
89+
tool: "ask_user",
90+
toolCallId: part.toolCallId,
91+
})
92+
}
93+
>
94+
<ChoiceQuestion>{part.input.question}</ChoiceQuestion>
95+
<ChoiceOptions>
96+
{part.input.options.map((option) => (
97+
<ChoiceOption
98+
description={option.description}
99+
disabled={!allowSubmit || status !== "ready"}
100+
key={option.value}
101+
value={option.value}
102+
>
103+
{option.label}
104+
</ChoiceOption>
105+
))}
106+
</ChoiceOptions>
107+
{multiSelect && allowSubmit ? (
108+
<ChoiceSubmit disabled={status !== "ready"} />
109+
) : null}
110+
{part.state === "output-error" ? (
111+
<ChoiceStatus status="error">
112+
{part.errorText ?? "Could not submit your selection. Try again."}
113+
</ChoiceStatus>
114+
) : null}
115+
</Choice>
116+
);
117+
})
118+
)}
119+
</div>
120+
</div>
121+
);
122+
};
123+
124+
export default Example;
125+
```
126+
127+
Add the following route to your backend:
128+
129+
```ts title="app/api/chat/route.ts"
130+
import { convertToModelMessages, stepCountIs, streamText, tool, UIMessage } from "ai";
131+
import { z } from "zod";
132+
133+
export async function POST(req: Request) {
134+
const { messages }: { messages: UIMessage[] } = await req.json();
135+
136+
const result = streamText({
137+
model: "openai/gpt-4o-mini",
138+
messages: await convertToModelMessages(messages),
139+
tools: {
140+
ask_user: tool({
141+
description: "Ask the user to choose one or more clarifying options.",
142+
inputSchema: z.object({
143+
question: z.string(),
144+
multi_select: z.boolean().default(false),
145+
options: z
146+
.array(
147+
z.object({
148+
value: z.string(),
149+
label: z.string(),
150+
description: z.string().optional(),
151+
})
152+
)
153+
.min(2)
154+
.max(6),
155+
}),
156+
}),
157+
},
158+
stopWhen: stepCountIs(5),
159+
});
160+
161+
return result.toUIMessageStreamResponse();
162+
}
163+
```
164+
165+
## Features
166+
167+
- Single-select and multi-select modes
168+
- Controlled and uncontrolled selection state
169+
- Optional auto-submit on single selection
170+
- Built-in confirm action for multi-select workflows
171+
- Keyboard and screen-reader friendly option semantics
172+
- Composable structure for custom layouts and states
173+
174+
## Examples
175+
176+
### Single Select
177+
178+
<Preview path="choice" />
179+
180+
### Multiple Select
181+
182+
<Preview path="choice-multiple" />
183+
184+
## Props
185+
186+
### `<Choice />`
187+
188+
<TypeTable
189+
type={{
190+
multiple: {
191+
description: "Enables multi-select mode when true.",
192+
type: "boolean",
193+
},
194+
disabled: {
195+
description: "Disables all child interactions.",
196+
type: "boolean",
197+
},
198+
value: {
199+
description:
200+
"Controlled selection value. Use a string for single-select or string[] for multi-select.",
201+
type: "string | string[]",
202+
},
203+
defaultValue: {
204+
description:
205+
"Initial selection for uncontrolled usage. Use a string for single-select or string[] for multi-select.",
206+
type: "string | string[]",
207+
},
208+
onValueChange: {
209+
description:
210+
"Called whenever selection changes. Returns string in single-select mode and string[] in multi-select mode.",
211+
type: "(value: string | string[] | undefined) => void",
212+
},
213+
onSubmit: {
214+
description:
215+
"Called when selection is submitted. Returns string in single-select mode and string[] in multi-select mode.",
216+
type: "(value: string | string[]) => void",
217+
},
218+
submitOnSelect: {
219+
description:
220+
"When true (default), single-select mode submits immediately after click.",
221+
type: "boolean",
222+
},
223+
"...props": {
224+
description: "Any other props are spread to the root div element.",
225+
type: "React.ComponentProps<'div'>",
226+
},
227+
}}
228+
/>
229+
230+
### `<ChoiceQuestion />`
231+
232+
<TypeTable
233+
type={{
234+
"...props": {
235+
description:
236+
"Any other props are spread to the question paragraph element.",
237+
type: "React.ComponentProps<'p'>",
238+
},
239+
}}
240+
/>
241+
242+
### `<ChoiceOptions />`
243+
244+
<TypeTable
245+
type={{
246+
"...props": {
247+
description:
248+
"Any other props are spread to the options container element.",
249+
type: "React.ComponentProps<'div'>",
250+
},
251+
}}
252+
/>
253+
254+
### `<ChoiceOption />`
255+
256+
<TypeTable
257+
type={{
258+
value: {
259+
description: "Machine-readable option value used in selection output.",
260+
type: "string",
261+
required: true,
262+
},
263+
description: {
264+
description: "Optional helper text shown beneath the option label.",
265+
type: "ReactNode",
266+
},
267+
onSelect: {
268+
description:
269+
"Called when this option is selected (after the internal toggle runs).",
270+
type: "(value: string) => void",
271+
},
272+
"...props": {
273+
description:
274+
"Any other props are spread to the underlying shadcn/ui Button component.",
275+
type: "Omit<React.ComponentProps<typeof Button>, 'value'>",
276+
},
277+
}}
278+
/>
279+
280+
### `<ChoiceSubmit />`
281+
282+
<TypeTable
283+
type={{
284+
showCount: {
285+
description:
286+
"When true (default), multi-select mode shows selected count in the label.",
287+
type: "boolean",
288+
},
289+
"...props": {
290+
description:
291+
"Any other props are spread to the underlying shadcn/ui Button component.",
292+
type: "React.ComponentProps<typeof Button>",
293+
},
294+
}}
295+
/>
296+
297+
### `<ChoiceStatus />`
298+
299+
<TypeTable
300+
type={{
301+
status: {
302+
description: "Status styling and ARIA role (`info` or `error`).",
303+
type: '"info" | "error"',
304+
},
305+
"...props": {
306+
description:
307+
"Any other props are spread to the underlying paragraph element.",
308+
type: "React.ComponentProps<'p'>",
309+
},
310+
}}
311+
/>

0 commit comments

Comments
 (0)