Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions apps/docs/content/components/(chatbot)/question.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
---
title: Question
description: A composable prompt for collecting choices, freeform text, or both from a user.
path: elements/components/question
---

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.

<Preview path="question" />

## Installation

<ElementsInstaller path="question" />

## Usage

```tsx
import {
Question,
QuestionActions,
QuestionDescription,
QuestionInput,
QuestionOption,
QuestionOptions,
QuestionPrompt,
QuestionSubmit,
} from "@/components/ai-elements/question";

<Question
onSubmit={async ({ selectedValues, text }) => {
await respondToQuestion({ selectedValues, text });
}}
selectionMode="multiple"
>
<QuestionPrompt>What should the project include?</QuestionPrompt>
<QuestionDescription>
Choose any features and add details if needed.
</QuestionDescription>
<QuestionOptions aria-label="Project features">
<QuestionOption value="authentication">Authentication</QuestionOption>
<QuestionOption value="database">Database</QuestionOption>
<QuestionOption value="payments">Payments</QuestionOption>
</QuestionOptions>
<QuestionInput
aria-label="Additional requirements"
placeholder="Add any other requirements…"
/>
<QuestionActions>
<QuestionSubmit>Answer</QuestionSubmit>
</QuestionActions>
</Question>;
```

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`.

## Examples

### Single select

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.

<Preview path="question-single-select" />

### Multi-select

Set `selectionMode="multiple"` when the user may choose several options. Options use checkbox semantics, and `selectedValues` contains every selected value.

<Preview path="question-multi-select" />

### Freeform

Render `QuestionInput` without `QuestionOptions` to collect a text-only answer.

<Preview path="question-freeform" />

### Options and freeform

Render options and an input together when users may choose suggested answers and add context. The response can contain both `selectedValues` and `text`.

<Preview path="question" />

## Controlled state

Use `value` and `onValueChange` when another part of your application owns the draft response:

```tsx
const [value, setValue] = useState({ selectedValues: [], text: "" });

<Question value={value} onValueChange={setValue} onSubmit={handleSubmit}>
{/* question content */}
</Question>;
```

## Props

### `<Question />`

<TypeTable
type={{
value: {
description: "The controlled question draft.",
type: "QuestionValue",
},
defaultValue: {
description:
"The initial question draft when the component is uncontrolled.",
type: "QuestionValue",
default: '{ selectedValues: [], text: "" }',
},
selectionMode: {
description:
"Whether options behave as a single-choice radio group or multiple-choice checkboxes.",
type: '"single" | "multiple"',
default: '"single"',
},
disabled: {
description: "Disables the input, options, and submit action.",
type: "boolean",
default: "false",
},
onValueChange: {
description: "Called whenever the draft selection or text changes.",
type: "(value: QuestionValue) => void",
},
onSubmit: {
description:
"Called with the selected values, optional trimmed text, and original form event. May return a promise for asynchronous responses.",
type: "(response: QuestionResponse, event: React.FormEvent<HTMLFormElement>) => void | Promise<void>",
},
"...props": {
description: "Any other props are spread to the form element.",
type: 'React.ComponentProps<"form">',
},
}}
/>

### `<QuestionPrompt />`

Displays the question text. Props extend `React.HTMLAttributes<HTMLParagraphElement>`.

### `<QuestionDescription />`

Displays supporting instructions. Props extend `React.HTMLAttributes<HTMLParagraphElement>`.

### `<QuestionOptions />`

Groups `QuestionOption` children and applies radio-group or checkbox-group semantics based on `selectionMode`. Props extend `React.HTMLAttributes<HTMLDivElement>`.

### `<QuestionOption />`

<TypeTable
type={{
value: {
description:
"The stable value included in selectedValues when the option is selected.",
type: "string",
},
"...props": {
description: "Any other props are spread to the shadcn/ui Button.",
type: "React.ComponentProps<typeof Button>",
},
}}
/>

### `<QuestionInput />`

A controlled textarea backed by the question draft's `text` value. Props extend `React.ComponentProps<typeof Textarea>`.

### `<QuestionActions />`

A container for the submit action or other controls. Props extend `React.HTMLAttributes<HTMLDivElement>`.

### `<QuestionSubmit />`

Submits the current response and disables itself while the response is empty or the question is disabled. Props extend `React.ComponentProps<typeof Button>`.

## Types

```ts
interface QuestionValue {
selectedValues: readonly string[];
text: string;
}

interface QuestionResponse {
selectedValues: readonly string[];
text?: string;
}
```
205 changes: 205 additions & 0 deletions packages/elements/__tests__/question.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import type { FormEvent } from "react";

import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";

import {
Question,
QuestionInput,
QuestionOption,
QuestionOptions,
QuestionPrompt,
QuestionSubmit,
} from "../src/question";

describe("question", () => {
it("submits one selected option", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();

render(
<Question onSubmit={handleSubmit}>
<QuestionPrompt>Where should we deploy?</QuestionPrompt>
<QuestionOptions>
<QuestionOption value="iad1">Washington, D.C.</QuestionOption>
<QuestionOption value="sfo1">San Francisco</QuestionOption>
</QuestionOptions>
<QuestionSubmit />
</Question>
);

await user.click(screen.getByRole("radio", { name: "San Francisco" }));
await user.click(screen.getByRole("button", { name: "Submit" }));

expect(handleSubmit).toHaveBeenCalledWith(
{
selectedValues: ["sfo1"],
text: undefined,
},
expect.anything()
);
});

it("replaces the selected option in single selection mode", async () => {
const user = userEvent.setup();

render(
<Question>
<QuestionOptions>
<QuestionOption value="one">One</QuestionOption>
<QuestionOption value="two">Two</QuestionOption>
</QuestionOptions>
</Question>
);

const first = screen.getByRole("radio", { name: "One" });
const second = screen.getByRole("radio", { name: "Two" });
await user.click(first);
await user.click(second);

expect(first).toHaveAttribute("aria-checked", "false");
expect(second).toHaveAttribute("aria-checked", "true");
});

it("submits multiple selected options", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();

render(
<Question onSubmit={handleSubmit} selectionMode="multiple">
<QuestionOptions aria-label="Features">
<QuestionOption value="search">Search</QuestionOption>
<QuestionOption value="export">Export</QuestionOption>
</QuestionOptions>
<QuestionSubmit>Continue</QuestionSubmit>
</Question>
);

await user.click(screen.getByRole("checkbox", { name: "Search" }));
await user.click(screen.getByRole("checkbox", { name: "Export" }));
await user.click(screen.getByRole("button", { name: "Continue" }));

expect(handleSubmit).toHaveBeenCalledWith(
{
selectedValues: ["search", "export"],
text: undefined,
},
expect.anything()
);
});

it("submits a trimmed freeform response", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();

render(
<Question onSubmit={handleSubmit}>
<QuestionInput aria-label="Answer" />
<QuestionSubmit />
</Question>
);

await user.type(
screen.getByRole("textbox", { name: "Answer" }),
" My project "
);
await user.click(screen.getByRole("button", { name: "Submit" }));

expect(handleSubmit).toHaveBeenCalledWith(
{
selectedValues: [],
text: "My project",
},
expect.anything()
);
});

it("submits selected options and freeform text together", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();

render(
<Question onSubmit={handleSubmit} selectionMode="multiple">
<QuestionOptions>
<QuestionOption value="typescript">TypeScript</QuestionOption>
</QuestionOptions>
<QuestionInput aria-label="Other requirements" />
<QuestionSubmit />
</Question>
);

await user.click(screen.getByRole("checkbox", { name: "TypeScript" }));
await user.type(
screen.getByRole("textbox", { name: "Other requirements" }),
"Include tests"
);
await user.click(screen.getByRole("button", { name: "Submit" }));

expect(handleSubmit).toHaveBeenCalledWith(
{
selectedValues: ["typescript"],
text: "Include tests",
},
expect.anything()
);
});

it("disables submission until a response is present", async () => {
const user = userEvent.setup();

render(
<Question>
<QuestionInput aria-label="Answer" />
<QuestionSubmit />
</Question>
);

const submit = screen.getByRole("button", { name: "Submit" });
expect(submit).toBeDisabled();

await user.type(screen.getByRole("textbox", { name: "Answer" }), "Answer");
expect(submit).toBeEnabled();
});

it("passes the form event through and supports async submission", async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn(
async (_response, event: FormEvent<HTMLFormElement>) => {
expect(event.currentTarget).toHaveAttribute("data-question", "example");
await Promise.resolve();
}
);

render(
<Question data-question="example" onSubmit={handleSubmit}>
<QuestionInput aria-label="Answer" />
<QuestionSubmit />
</Question>
);

await user.type(screen.getByRole("textbox", { name: "Answer" }), "Answer");
await user.click(screen.getByRole("button", { name: "Submit" }));

expect(handleSubmit).toHaveBeenCalledOnce();
});

it("reports value changes", async () => {
const user = userEvent.setup();
const handleValueChange = vi.fn();

render(
<Question onValueChange={handleValueChange}>
<QuestionOptions>
<QuestionOption value="yes">Yes</QuestionOption>
</QuestionOptions>
</Question>
);

await user.click(screen.getByRole("radio", { name: "Yes" }));

expect(handleValueChange).toHaveBeenCalledWith({
selectedValues: ["yes"],
text: "",
});
});
});
Loading