From 9e27175b252184af1737ba71e0b9a6bc7777f1c0 Mon Sep 17 00:00:00 2001 From: Casey Gowrie Date: Fri, 21 Aug 2026 10:12:12 -0400 Subject: [PATCH 1/2] feat: add question component --- .../content/components/(chatbot)/question.mdx | 170 +++++++++ packages/elements/__tests__/question.test.tsx | 169 +++++++++ packages/elements/src/question.tsx | 322 ++++++++++++++++++ packages/examples/src/question.tsx | 60 ++++ 4 files changed, 721 insertions(+) create mode 100644 apps/docs/content/components/(chatbot)/question.mdx create mode 100644 packages/elements/__tests__/question.test.tsx create mode 100644 packages/elements/src/question.tsx create mode 100644 packages/examples/src/question.tsx diff --git a/apps/docs/content/components/(chatbot)/question.mdx b/apps/docs/content/components/(chatbot)/question.mdx new file mode 100644 index 00000000..ab8b4895 --- /dev/null +++ b/apps/docs/content/components/(chatbot)/question.mdx @@ -0,0 +1,170 @@ +--- +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. + + + +## Installation + + + +## Usage + +```tsx +import { + Question, + QuestionActions, + QuestionDescription, + QuestionInput, + QuestionOption, + QuestionOptions, + QuestionPrompt, + QuestionSubmit, +} from "@/components/ai-elements/question"; + + { + respondToQuestion({ selectedValues, text }); + }} + selectionMode="multiple" +> + What should the project include? + + Choose any features and add details if needed. + + + Authentication + Database + Payments + + + + Answer + +; +``` + +Render only the parts the question supports: + +- **One choice:** use `QuestionOptions` and leave `selectionMode` as `"single"`. +- **Multiple choices:** use `QuestionOptions` with `selectionMode="multiple"`. +- **Freeform:** use `QuestionInput` without any options. +- **Combined:** render options and an input together. The response can contain both selected values and text. + +`QuestionSubmit` remains disabled until the user selects an option or enters non-whitespace text. The component trims freeform text before calling `onSubmit`. + +## Controlled state + +Use `value` and `onValueChange` when another part of your application owns the draft response: + +```tsx +const [value, setValue] = useState({ selectedValues: [], text: "" }); + + + {/* question content */} +; +``` + +## Props + +### `` + + void", + }, + onSubmit: { + description: + "Called with selected values and optional trimmed text when the form is submitted.", + type: "(response: QuestionResponse) => void", + }, + "...props": { + description: "Any other props are spread to the form element.", + type: 'React.ComponentProps<"form">', + }, + }} +/> + +### `` + +Displays the question text. Props extend `React.HTMLAttributes`. + +### `` + +Displays supporting instructions. Props extend `React.HTMLAttributes`. + +### `` + +Groups `QuestionOption` children and applies radio-group or checkbox-group semantics based on `selectionMode`. Props extend `React.HTMLAttributes`. + +### `` + +", + }, + }} +/> + +### `` + +A controlled textarea backed by the question draft's `text` value. Props extend `React.ComponentProps`. + +### `` + +A container for the submit action or other controls. Props extend `React.HTMLAttributes`. + +### `` + +Submits the current response and disables itself while the response is empty or the question is disabled. Props extend `React.ComponentProps`. + +## Types + +```ts +interface QuestionValue { + selectedValues: readonly string[]; + text: string; +} + +interface QuestionResponse { + selectedValues: readonly string[]; + text?: string; +} +``` diff --git a/packages/elements/__tests__/question.test.tsx b/packages/elements/__tests__/question.test.tsx new file mode 100644 index 00000000..a4252ab1 --- /dev/null +++ b/packages/elements/__tests__/question.test.tsx @@ -0,0 +1,169 @@ +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( + + Where should we deploy? + + Washington, D.C. + San Francisco + + + + ); + + await user.click(screen.getByRole("radio", { name: "San Francisco" })); + await user.click(screen.getByRole("button", { name: "Submit" })); + + expect(handleSubmit).toHaveBeenCalledWith({ + selectedValues: ["sfo1"], + text: undefined, + }); + }); + + it("replaces the selected option in single selection mode", async () => { + const user = userEvent.setup(); + + render( + + + One + Two + + + ); + + 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( + + + Search + Export + + Continue + + ); + + 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, + }); + }); + + it("submits a trimmed freeform response", async () => { + const user = userEvent.setup(); + const handleSubmit = vi.fn(); + + render( + + + + + ); + + 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", + }); + }); + + it("submits selected options and freeform text together", async () => { + const user = userEvent.setup(); + const handleSubmit = vi.fn(); + + render( + + + TypeScript + + + + + ); + + 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", + }); + }); + + it("disables submission until a response is present", async () => { + const user = userEvent.setup(); + + render( + + + + + ); + + const submit = screen.getByRole("button", { name: "Submit" }); + expect(submit).toBeDisabled(); + + await user.type(screen.getByRole("textbox", { name: "Answer" }), "Answer"); + expect(submit).toBeEnabled(); + }); + + it("reports value changes", async () => { + const user = userEvent.setup(); + const handleValueChange = vi.fn(); + + render( + + + Yes + + + ); + + await user.click(screen.getByRole("radio", { name: "Yes" })); + + expect(handleValueChange).toHaveBeenCalledWith({ + selectedValues: ["yes"], + text: "", + }); + }); +}); diff --git a/packages/elements/src/question.tsx b/packages/elements/src/question.tsx new file mode 100644 index 00000000..c9c167ef --- /dev/null +++ b/packages/elements/src/question.tsx @@ -0,0 +1,322 @@ +"use client"; + +import type { + ChangeEvent, + ComponentProps, + FormEvent, + HTMLAttributes, + MouseEvent, + ReactNode, +} from "react"; + +import { Button } from "@repo/shadcn-ui/components/ui/button"; +import { Textarea } from "@repo/shadcn-ui/components/ui/textarea"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +export interface QuestionValue { + selectedValues: readonly string[]; + text: string; +} + +export interface QuestionResponse { + selectedValues: readonly string[]; + text?: string; +} + +type SelectionMode = "multiple" | "single"; + +interface QuestionContextValue { + disabled: boolean; + selectedValues: readonly string[]; + selectionMode: SelectionMode; + setText: (text: string) => void; + text: string; + toggleValue: (value: string) => void; +} + +const QuestionContext = createContext(null); + +const useQuestion = () => { + const context = useContext(QuestionContext); + + if (!context) { + throw new Error("Question components must be used within Question"); + } + + return context; +}; + +export type QuestionProps = Omit< + ComponentProps<"form">, + "defaultValue" | "onSubmit" | "value" +> & { + defaultValue?: QuestionValue; + disabled?: boolean; + onSubmit?: (response: QuestionResponse) => void; + onValueChange?: (value: QuestionValue) => void; + selectionMode?: SelectionMode; + value?: QuestionValue; +}; + +const EMPTY_VALUE: QuestionValue = { selectedValues: [], text: "" }; + +const getSelectedValues = ( + currentValues: readonly string[], + optionValue: string, + selectionMode: SelectionMode +): readonly string[] => { + const isSelected = currentValues.includes(optionValue); + + if (selectionMode === "single") { + return isSelected ? [] : [optionValue]; + } + + if (isSelected) { + return currentValues.filter((item) => item !== optionValue); + } + + return [...currentValues, optionValue]; +}; + +export const Question = ({ + children, + className, + defaultValue = EMPTY_VALUE, + disabled = false, + onSubmit, + onValueChange, + selectionMode = "single", + value: controlledValue, + ...props +}: QuestionProps) => { + const [internalValue, setInternalValue] = useState(defaultValue); + const value = controlledValue ?? internalValue; + + const setValue = useCallback( + (nextValue: QuestionValue) => { + if (controlledValue === undefined) { + setInternalValue(nextValue); + } + onValueChange?.(nextValue); + }, + [controlledValue, onValueChange] + ); + + const setText = useCallback( + (text: string) => { + setValue({ ...value, text }); + }, + [setValue, value] + ); + + const toggleValue = useCallback( + (optionValue: string) => { + const selectedValues = getSelectedValues( + value.selectedValues, + optionValue, + selectionMode + ); + setValue({ ...value, selectedValues }); + }, + [selectionMode, setValue, value] + ); + + const contextValue = useMemo( + () => ({ + disabled, + selectedValues: value.selectedValues, + selectionMode, + setText, + text: value.text, + toggleValue, + }), + [disabled, selectionMode, setText, toggleValue, value] + ); + + const handleSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + if (disabled) { + return; + } + + const text = value.text.trim(); + if (value.selectedValues.length === 0 && text.length === 0) { + return; + } + + onSubmit?.({ + selectedValues: value.selectedValues, + text: text.length > 0 ? text : undefined, + }); + }, + [disabled, onSubmit, value] + ); + + return ( + +
+ {children} +
+
+ ); +}; + +export type QuestionPromptProps = HTMLAttributes; + +export const QuestionPrompt = ({ + className, + ...props +}: QuestionPromptProps) => ( +

+); + +export type QuestionDescriptionProps = HTMLAttributes; + +export const QuestionDescription = ({ + className, + ...props +}: QuestionDescriptionProps) => ( +

+); + +export type QuestionOptionsProps = HTMLAttributes; + +export const QuestionOptions = ({ + className, + ...props +}: QuestionOptionsProps) => { + const { selectionMode } = useQuestion(); + + return ( +

+ ); +}; + +export type QuestionOptionProps = Omit< + ComponentProps, + "value" +> & { + value: string; +}; + +export const QuestionOption = ({ + children, + className, + disabled, + onClick, + value, + variant, + ...props +}: QuestionOptionProps) => { + const question = useQuestion(); + const isSelected = question.selectedValues.includes(value); + const role = question.selectionMode === "single" ? "radio" : "checkbox"; + const handleClick = useCallback( + (event: MouseEvent) => { + question.toggleValue(value); + onClick?.(event); + }, + [onClick, question, value] + ); + + return ( + + ); +}; + +export type QuestionInputProps = Omit< + ComponentProps, + "defaultValue" | "value" +>; + +export const QuestionInput = ({ + className, + disabled, + onChange, + ...props +}: QuestionInputProps) => { + const question = useQuestion(); + const handleChange = useCallback( + (event: ChangeEvent) => { + question.setText(event.currentTarget.value); + onChange?.(event); + }, + [onChange, question] + ); + + return ( +