Skip to content

Commit 6a9d5b1

Browse files
authored
Add question component for choices and freeform responses (#479)
* feat: add question component * docs: expand question examples
1 parent 0c1f5e8 commit 6a9d5b1

7 files changed

Lines changed: 930 additions & 0 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
---
2+
title: Question
3+
description: A composable prompt for collecting choices, freeform text, or both from a user.
4+
path: elements/components/question
5+
---
6+
7+
The `Question` component presents a human-in-the-loop question as an immediately actionable form. Use it when an AI workflow pauses for structured input instead of hiding the prompt inside a tool details view.
8+
9+
<Preview path="question" />
10+
11+
## Installation
12+
13+
<ElementsInstaller path="question" />
14+
15+
## Usage
16+
17+
```tsx
18+
import {
19+
Question,
20+
QuestionActions,
21+
QuestionDescription,
22+
QuestionInput,
23+
QuestionOption,
24+
QuestionOptions,
25+
QuestionPrompt,
26+
QuestionSubmit,
27+
} from "@/components/ai-elements/question";
28+
29+
<Question
30+
onSubmit={async ({ selectedValues, text }) => {
31+
await respondToQuestion({ selectedValues, text });
32+
}}
33+
selectionMode="multiple"
34+
>
35+
<QuestionPrompt>What should the project include?</QuestionPrompt>
36+
<QuestionDescription>
37+
Choose any features and add details if needed.
38+
</QuestionDescription>
39+
<QuestionOptions aria-label="Project features">
40+
<QuestionOption value="authentication">Authentication</QuestionOption>
41+
<QuestionOption value="database">Database</QuestionOption>
42+
<QuestionOption value="payments">Payments</QuestionOption>
43+
</QuestionOptions>
44+
<QuestionInput
45+
aria-label="Additional requirements"
46+
placeholder="Add any other requirements…"
47+
/>
48+
<QuestionActions>
49+
<QuestionSubmit>Answer</QuestionSubmit>
50+
</QuestionActions>
51+
</Question>;
52+
```
53+
54+
Render only the parts the question supports. `QuestionSubmit` remains disabled until the user selects an option or enters non-whitespace text. The component trims freeform text before calling `onSubmit`.
55+
56+
## Examples
57+
58+
### Single select
59+
60+
Leave `selectionMode` as `"single"` when the user should choose exactly one option. Options use radio semantics, and selecting a new option replaces the previous selection.
61+
62+
<Preview path="question-single-select" />
63+
64+
### Multi-select
65+
66+
Set `selectionMode="multiple"` when the user may choose several options. Options use checkbox semantics, and `selectedValues` contains every selected value.
67+
68+
<Preview path="question-multi-select" />
69+
70+
### Freeform
71+
72+
Render `QuestionInput` without `QuestionOptions` to collect a text-only answer.
73+
74+
<Preview path="question-freeform" />
75+
76+
### Options and freeform
77+
78+
Render options and an input together when users may choose suggested answers and add context. The response can contain both `selectedValues` and `text`.
79+
80+
<Preview path="question" />
81+
82+
## Controlled state
83+
84+
Use `value` and `onValueChange` when another part of your application owns the draft response:
85+
86+
```tsx
87+
const [value, setValue] = useState({ selectedValues: [], text: "" });
88+
89+
<Question value={value} onValueChange={setValue} onSubmit={handleSubmit}>
90+
{/* question content */}
91+
</Question>;
92+
```
93+
94+
## Props
95+
96+
### `<Question />`
97+
98+
<TypeTable
99+
type={{
100+
value: {
101+
description: "The controlled question draft.",
102+
type: "QuestionValue",
103+
},
104+
defaultValue: {
105+
description:
106+
"The initial question draft when the component is uncontrolled.",
107+
type: "QuestionValue",
108+
default: '{ selectedValues: [], text: "" }',
109+
},
110+
selectionMode: {
111+
description:
112+
"Whether options behave as a single-choice radio group or multiple-choice checkboxes.",
113+
type: '"single" | "multiple"',
114+
default: '"single"',
115+
},
116+
disabled: {
117+
description: "Disables the input, options, and submit action.",
118+
type: "boolean",
119+
default: "false",
120+
},
121+
onValueChange: {
122+
description: "Called whenever the draft selection or text changes.",
123+
type: "(value: QuestionValue) => void",
124+
},
125+
onSubmit: {
126+
description:
127+
"Called with the selected values, optional trimmed text, and original form event. May return a promise for asynchronous responses.",
128+
type: "(response: QuestionResponse, event: React.FormEvent<HTMLFormElement>) => void | Promise<void>",
129+
},
130+
"...props": {
131+
description: "Any other props are spread to the form element.",
132+
type: 'React.ComponentProps<"form">',
133+
},
134+
}}
135+
/>
136+
137+
### `<QuestionPrompt />`
138+
139+
Displays the question text. Props extend `React.HTMLAttributes<HTMLParagraphElement>`.
140+
141+
### `<QuestionDescription />`
142+
143+
Displays supporting instructions. Props extend `React.HTMLAttributes<HTMLParagraphElement>`.
144+
145+
### `<QuestionOptions />`
146+
147+
Groups `QuestionOption` children and applies radio-group or checkbox-group semantics based on `selectionMode`. Props extend `React.HTMLAttributes<HTMLDivElement>`.
148+
149+
### `<QuestionOption />`
150+
151+
<TypeTable
152+
type={{
153+
value: {
154+
description:
155+
"The stable value included in selectedValues when the option is selected.",
156+
type: "string",
157+
},
158+
"...props": {
159+
description: "Any other props are spread to the shadcn/ui Button.",
160+
type: "React.ComponentProps<typeof Button>",
161+
},
162+
}}
163+
/>
164+
165+
### `<QuestionInput />`
166+
167+
A controlled textarea backed by the question draft's `text` value. Props extend `React.ComponentProps<typeof Textarea>`.
168+
169+
### `<QuestionActions />`
170+
171+
A container for the submit action or other controls. Props extend `React.HTMLAttributes<HTMLDivElement>`.
172+
173+
### `<QuestionSubmit />`
174+
175+
Submits the current response and disables itself while the response is empty or the question is disabled. Props extend `React.ComponentProps<typeof Button>`.
176+
177+
## Types
178+
179+
```ts
180+
interface QuestionValue {
181+
selectedValues: readonly string[];
182+
text: string;
183+
}
184+
185+
interface QuestionResponse {
186+
selectedValues: readonly string[];
187+
text?: string;
188+
}
189+
```
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import type { FormEvent } from "react";
2+
3+
import { render, screen } from "@testing-library/react";
4+
import { userEvent } from "@testing-library/user-event";
5+
6+
import {
7+
Question,
8+
QuestionInput,
9+
QuestionOption,
10+
QuestionOptions,
11+
QuestionPrompt,
12+
QuestionSubmit,
13+
} from "../src/question";
14+
15+
describe("question", () => {
16+
it("submits one selected option", async () => {
17+
const user = userEvent.setup();
18+
const handleSubmit = vi.fn();
19+
20+
render(
21+
<Question onSubmit={handleSubmit}>
22+
<QuestionPrompt>Where should we deploy?</QuestionPrompt>
23+
<QuestionOptions>
24+
<QuestionOption value="iad1">Washington, D.C.</QuestionOption>
25+
<QuestionOption value="sfo1">San Francisco</QuestionOption>
26+
</QuestionOptions>
27+
<QuestionSubmit />
28+
</Question>
29+
);
30+
31+
await user.click(screen.getByRole("radio", { name: "San Francisco" }));
32+
await user.click(screen.getByRole("button", { name: "Submit" }));
33+
34+
expect(handleSubmit).toHaveBeenCalledWith(
35+
{
36+
selectedValues: ["sfo1"],
37+
text: undefined,
38+
},
39+
expect.anything()
40+
);
41+
});
42+
43+
it("replaces the selected option in single selection mode", async () => {
44+
const user = userEvent.setup();
45+
46+
render(
47+
<Question>
48+
<QuestionOptions>
49+
<QuestionOption value="one">One</QuestionOption>
50+
<QuestionOption value="two">Two</QuestionOption>
51+
</QuestionOptions>
52+
</Question>
53+
);
54+
55+
const first = screen.getByRole("radio", { name: "One" });
56+
const second = screen.getByRole("radio", { name: "Two" });
57+
await user.click(first);
58+
await user.click(second);
59+
60+
expect(first).toHaveAttribute("aria-checked", "false");
61+
expect(second).toHaveAttribute("aria-checked", "true");
62+
});
63+
64+
it("submits multiple selected options", async () => {
65+
const user = userEvent.setup();
66+
const handleSubmit = vi.fn();
67+
68+
render(
69+
<Question onSubmit={handleSubmit} selectionMode="multiple">
70+
<QuestionOptions aria-label="Features">
71+
<QuestionOption value="search">Search</QuestionOption>
72+
<QuestionOption value="export">Export</QuestionOption>
73+
</QuestionOptions>
74+
<QuestionSubmit>Continue</QuestionSubmit>
75+
</Question>
76+
);
77+
78+
await user.click(screen.getByRole("checkbox", { name: "Search" }));
79+
await user.click(screen.getByRole("checkbox", { name: "Export" }));
80+
await user.click(screen.getByRole("button", { name: "Continue" }));
81+
82+
expect(handleSubmit).toHaveBeenCalledWith(
83+
{
84+
selectedValues: ["search", "export"],
85+
text: undefined,
86+
},
87+
expect.anything()
88+
);
89+
});
90+
91+
it("submits a trimmed freeform response", async () => {
92+
const user = userEvent.setup();
93+
const handleSubmit = vi.fn();
94+
95+
render(
96+
<Question onSubmit={handleSubmit}>
97+
<QuestionInput aria-label="Answer" />
98+
<QuestionSubmit />
99+
</Question>
100+
);
101+
102+
await user.type(
103+
screen.getByRole("textbox", { name: "Answer" }),
104+
" My project "
105+
);
106+
await user.click(screen.getByRole("button", { name: "Submit" }));
107+
108+
expect(handleSubmit).toHaveBeenCalledWith(
109+
{
110+
selectedValues: [],
111+
text: "My project",
112+
},
113+
expect.anything()
114+
);
115+
});
116+
117+
it("submits selected options and freeform text together", async () => {
118+
const user = userEvent.setup();
119+
const handleSubmit = vi.fn();
120+
121+
render(
122+
<Question onSubmit={handleSubmit} selectionMode="multiple">
123+
<QuestionOptions>
124+
<QuestionOption value="typescript">TypeScript</QuestionOption>
125+
</QuestionOptions>
126+
<QuestionInput aria-label="Other requirements" />
127+
<QuestionSubmit />
128+
</Question>
129+
);
130+
131+
await user.click(screen.getByRole("checkbox", { name: "TypeScript" }));
132+
await user.type(
133+
screen.getByRole("textbox", { name: "Other requirements" }),
134+
"Include tests"
135+
);
136+
await user.click(screen.getByRole("button", { name: "Submit" }));
137+
138+
expect(handleSubmit).toHaveBeenCalledWith(
139+
{
140+
selectedValues: ["typescript"],
141+
text: "Include tests",
142+
},
143+
expect.anything()
144+
);
145+
});
146+
147+
it("disables submission until a response is present", async () => {
148+
const user = userEvent.setup();
149+
150+
render(
151+
<Question>
152+
<QuestionInput aria-label="Answer" />
153+
<QuestionSubmit />
154+
</Question>
155+
);
156+
157+
const submit = screen.getByRole("button", { name: "Submit" });
158+
expect(submit).toBeDisabled();
159+
160+
await user.type(screen.getByRole("textbox", { name: "Answer" }), "Answer");
161+
expect(submit).toBeEnabled();
162+
});
163+
164+
it("passes the form event through and supports async submission", async () => {
165+
const user = userEvent.setup();
166+
const handleSubmit = vi.fn(
167+
async (_response, event: FormEvent<HTMLFormElement>) => {
168+
expect(event.currentTarget).toHaveAttribute("data-question", "example");
169+
await Promise.resolve();
170+
}
171+
);
172+
173+
render(
174+
<Question data-question="example" onSubmit={handleSubmit}>
175+
<QuestionInput aria-label="Answer" />
176+
<QuestionSubmit />
177+
</Question>
178+
);
179+
180+
await user.type(screen.getByRole("textbox", { name: "Answer" }), "Answer");
181+
await user.click(screen.getByRole("button", { name: "Submit" }));
182+
183+
expect(handleSubmit).toHaveBeenCalledOnce();
184+
});
185+
186+
it("reports value changes", async () => {
187+
const user = userEvent.setup();
188+
const handleValueChange = vi.fn();
189+
190+
render(
191+
<Question onValueChange={handleValueChange}>
192+
<QuestionOptions>
193+
<QuestionOption value="yes">Yes</QuestionOption>
194+
</QuestionOptions>
195+
</Question>
196+
);
197+
198+
await user.click(screen.getByRole("radio", { name: "Yes" }));
199+
200+
expect(handleValueChange).toHaveBeenCalledWith({
201+
selectedValues: ["yes"],
202+
text: "",
203+
});
204+
});
205+
});

0 commit comments

Comments
 (0)