Component
Questionnaire
Questions asked one at a time: white choice cards with the brand's radio or checkbox and a shortcut key, a typed answer, a progress count and Previous, Skip and Next pills.
npx -y github:anwarlemu/inkbox-brand-cli#release add questionnaireUsage
Use when an agent or a setup flow needs a few answers in a row (how to route calls, what to include in a summary). Pass the questions as items to Questionnaire, then render a QuestionnaireItem per question with a QuestionnaireTitle, a QuestionnaireDescription, QuestionnaireChoices of QuestionnaireChoice (add QuestionnaireInput for another answer) and a QuestionnaireError; end with QuestionnaireActions holding Previous, Skip, Next and Submit. Mark required questions; set multiple to allow several answers. onSubmit receives the native form event: read the answers with FormData.
Avoid
- More than one question on screen
- Animating between questions
- A required question without its error message
- A purple card fill for a chosen answer
Exports: Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle. This component is a client component.
Dependencies: button, input, kbd, progress, @phosphor-icons/react.
Example
"use client";import * as React from "react";import { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle,} from "@/components/ui/questionnaire";const ITEMS = [ { name: "channel", required: true, choices: [{ value: "email" }, { value: "phone" }, { value: "imessage" }] }, { name: "include", multiple: true, choices: [{ value: "sender" }, { value: "summary" }, { value: "attachments" }, { value: "next-step" }] }, { name: "when", required: true, choices: [{ value: "now" }, { value: "hourly" }, { value: "daily" }] },] as const;function Saved({ answers }: { answers: string }) { return ( <p role="status" className="rounded-control bg-success-bg px-3.5 py-2.5 text-body text-fg"> Saved. {answers} </p> );}export function Preview() { const [saved, setSaved] = React.useState<string>(); return ( <div className="mx-auto grid w-full max-w-110 gap-4"> <Questionnaire items={ITEMS} onSubmit={(event) => { event.preventDefault(); const data = new FormData(event.currentTarget); setSaved(`Channel: ${data.get("channel")}. Include: ${data.getAll("include").join(", ") || "nothing"}. When: ${data.get("when")}.`); }} > <QuestionnaireProgress /> <QuestionnaireItem name="channel" required> <QuestionnaireTitle>Where should @acme tell you about new mail?</QuestionnaireTitle> <QuestionnaireDescription>Choose a channel, or type another one.</QuestionnaireDescription> <QuestionnaireChoices> <QuestionnaireChoice value="email"> <span className="font-medium">Email</span> <QuestionnaireChoiceDescription>A note to robert@acme.com</QuestionnaireChoiceDescription> </QuestionnaireChoice> <QuestionnaireChoice value="phone"> <span className="font-medium">Phone</span> <QuestionnaireChoiceDescription>A text from the agent's number</QuestionnaireChoiceDescription> </QuestionnaireChoice> <QuestionnaireChoice value="imessage"> <span className="font-medium">iMessage</span> <QuestionnaireChoiceDescription>Through the shared iMessage line</QuestionnaireChoiceDescription> </QuestionnaireChoice> <QuestionnaireInput aria-label="Another channel" placeholder="Another channel" /> </QuestionnaireChoices> <QuestionnaireError /> </QuestionnaireItem> <QuestionnaireItem name="include" multiple> <QuestionnaireTitle>What should each note include?</QuestionnaireTitle> <QuestionnaireDescription>Choose any, or skip this question.</QuestionnaireDescription> <QuestionnaireChoices> <QuestionnaireChoice value="sender">The sender</QuestionnaireChoice> <QuestionnaireChoice value="summary">A one-line summary</QuestionnaireChoice> <QuestionnaireChoice value="attachments">Attachments</QuestionnaireChoice> <QuestionnaireChoice value="next-step">The suggested next step</QuestionnaireChoice> </QuestionnaireChoices> <QuestionnaireError /> </QuestionnaireItem> <QuestionnaireItem name="when" required> <QuestionnaireTitle>How often?</QuestionnaireTitle> <QuestionnaireDescription>Batched notes arrive once per period.</QuestionnaireDescription> <QuestionnaireChoices> <QuestionnaireChoice value="now">As each email arrives</QuestionnaireChoice> <QuestionnaireChoice value="hourly">Once an hour</QuestionnaireChoice> <QuestionnaireChoice value="daily">Once a day</QuestionnaireChoice> </QuestionnaireChoices> <QuestionnaireError /> </QuestionnaireItem> <QuestionnaireActions> <QuestionnairePrevious /> <QuestionnaireSkip /> <QuestionnaireNext /> <QuestionnaireSubmit>Save rule</QuestionnaireSubmit> </QuestionnaireActions> </Questionnaire> {saved && <Saved answers={saved} />} </div> );}const PLAN = [{ name: "line", required: true, choices: [{ value: "shared" }, { value: "dedicated" }, { value: "port" }] }] as const;export function Disabled() { return ( <Questionnaire items={PLAN} shortcuts="letters" className="mx-auto max-w-110" onSubmit={(event) => event.preventDefault()}> <QuestionnaireItem name="line" required> <QuestionnaireTitle>Which iMessage line should the agent use?</QuestionnaireTitle> <QuestionnaireDescription>No dedicated lines are available to assign right now.</QuestionnaireDescription> <QuestionnaireChoices> <QuestionnaireChoice value="shared"> <span className="font-medium">Shared</span> <QuestionnaireChoiceDescription>Assigned when someone connects</QuestionnaireChoiceDescription> </QuestionnaireChoice> <QuestionnaireChoice value="dedicated" disabled> <span className="font-medium">Dedicated</span> <QuestionnaireChoiceDescription>None available</QuestionnaireChoiceDescription> </QuestionnaireChoice> <QuestionnaireChoice value="port"> <span className="font-medium">Port a number</span> <QuestionnaireChoiceDescription>Bring an existing number</QuestionnaireChoiceDescription> </QuestionnaireChoice> </QuestionnaireChoices> <QuestionnaireError /> </QuestionnaireItem> <QuestionnaireActions> <QuestionnaireSubmit>Assign line</QuestionnaireSubmit> </QuestionnaireActions> </Questionnaire> );}Source
"use client";import * as React from "react";import { CheckIcon } from "@phosphor-icons/react/ssr";import { Button } from "@/components/ui/button";import { Input } from "@/components/ui/input";import { Kbd } from "@/components/ui/kbd";import { Progress } from "@/components/ui/progress";import { cn } from "@/lib/utils";// A short set of questions asked one at a time (extrapolated from the console, Figma 427:2715: Create Agent Identity's// choices and the Where does this agent run? picker). A choice is a 44px white card with the field edge and 8px// corners, the brand's radio (a thick pink ring when chosen) or checkbox (purple when checked) 14px in, a 14px ink label// and its number or letter key at the right; a chosen card takes the purple edge. The question is 16px medium ink with// a 14px secondary line under it; progress is a 12px muted count over a thin purple bar. Changing questions is instant:// it is driven by the keyboard as often as by the pointer. The answers submit as one native form (FormData), every// question's inputs included, so the page owns saving and what happens next.type Choice = { readonly value: string; readonly disabled?: boolean };type ItemConfig = { readonly name: string; readonly required?: boolean; readonly multiple?: boolean; readonly choices?: readonly Choice[] };type Shortcuts = "numbers" | "letters" | "none";type QuestionnaireContextValue = { order: string[]; active: string | undefined; goTo: (name: string) => void; shortcuts: Shortcuts; answers: Record<string, string[]>; other: Record<string, string>; errors: Record<string, string | undefined>; configs: React.RefObject<Map<string, ItemConfig>>; register: (config: ItemConfig) => () => void; setAnswer: (name: string, value: string, checked: boolean) => void; setOther: (name: string, text: string) => void; next: () => void; previous: () => void; skip: () => void;};const QuestionnaireContext = React.createContext<QuestionnaireContextValue | null>(null);const ItemContext = React.createContext<{ name: string; required: boolean; multiple: boolean; id: string } | null>(null);function useQuestionnaire() { const context = React.useContext(QuestionnaireContext); if (!context) throw new Error("Questionnaire parts must be inside a Questionnaire."); return context;}function useItem() { const context = React.useContext(ItemContext); if (!context) throw new Error("This part must be inside a QuestionnaireItem."); return context;}const shortcutKey = (index: number, shortcuts: Shortcuts) => (shortcuts === "none" || index > 8 ? undefined : shortcuts === "letters" ? String.fromCharCode(65 + index) : String(index + 1));/** * The questions and their answers. Pass `items` (name, required, multiple, choices) so the order, progress and shortcuts * are known on the server; without it the order is the QuestionnaireItems' order. `defaultItem` or a controlled * `item` with `onItemChange` picks the question shown. `shortcuts` puts 1 to 9 (or A to I) on the choices. */function Questionnaire({ items, defaultItem, item, onItemChange, shortcuts = "numbers", className, onSubmit, onKeyDown, children, ...props}: Omit<React.ComponentProps<"form">, "onSubmit"> & { items?: readonly ItemConfig[]; defaultItem?: string; item?: string; onItemChange?: (name: string) => void; shortcuts?: Shortcuts; onSubmit?: (event: React.FormEvent<HTMLFormElement>) => void;}) { const form = React.useRef<HTMLFormElement>(null); const configs = React.useRef(new Map<string, ItemConfig>(items?.map((i) => [i.name, i]))); const [registered, setRegistered] = React.useState<string[]>([]); const order = items ? items.map((i) => i.name) : registered; const [internal, setInternal] = React.useState(defaultItem ?? items?.[0]?.name); const active = item ?? internal ?? order[0]; const [answers, setAnswers] = React.useState<Record<string, string[]>>({}); const [other, setOtherState] = React.useState<Record<string, string>>({}); const [errors, setErrors] = React.useState<Record<string, string | undefined>>({}); const focusItem = React.useCallback((name: string) => { requestAnimationFrame(() => { const fieldset = form.current?.querySelector<HTMLElement>(`[data-slot=questionnaire-item][data-name="${CSS.escape(name)}"]`); fieldset?.querySelector<HTMLElement>("input:not(:disabled)")?.focus({ preventScroll: true }); }); }, []); const goTo = React.useCallback( (name: string) => { if (item === undefined) setInternal(name); onItemChange?.(name); focusItem(name); }, [item, onItemChange, focusItem], ); const register = React.useCallback((config: ItemConfig) => { configs.current.set(config.name, { ...configs.current.get(config.name), ...config }); setRegistered((list) => (list.includes(config.name) ? list : [...list, config.name])); return () => setRegistered((list) => list.filter((n) => n !== config.name)); }, []); const answered = React.useCallback((name: string) => (answers[name]?.length ?? 0) > 0 || (other[name] ?? "").trim() !== "", [answers, other]); const validate = React.useCallback( (name: string) => { const config = configs.current.get(name); const message = config?.required && !answered(name) ? (config.multiple ? "Choose at least one answer to continue." : "Choose an answer to continue.") : undefined; setErrors((e) => ({ ...e, [name]: message })); return !message; }, [answered], ); const setAnswer = React.useCallback((name: string, value: string, checked: boolean) => { const multiple = configs.current.get(name)?.multiple; setAnswers((a) => ({ ...a, [name]: multiple ? (checked ? [...(a[name] ?? []).filter((v) => v !== value), value] : (a[name] ?? []).filter((v) => v !== value)) : checked ? [value] : [] })); if (!multiple && checked) setOtherState((o) => ({ ...o, [name]: "" })); setErrors((e) => ({ ...e, [name]: undefined })); }, []); const setOther = React.useCallback((name: string, text: string) => { setOtherState((o) => ({ ...o, [name]: text })); if (text && !configs.current.get(name)?.multiple) setAnswers((a) => ({ ...a, [name]: [] })); setErrors((e) => ({ ...e, [name]: undefined })); }, []); const index = active ? order.indexOf(active) : -1; const next = React.useCallback(() => { if (!active || !validate(active)) return focusItem(active ?? ""); const following = order[index + 1]; if (following) goTo(following); }, [active, validate, focusItem, order, index, goTo]); const previous = React.useCallback(() => { const before = order[index - 1]; if (before) goTo(before); }, [order, index, goTo]); const skip = React.useCallback(() => { if (!active) return; setAnswers((a) => ({ ...a, [active]: [] })); setOtherState((o) => ({ ...o, [active]: "" })); setErrors((e) => ({ ...e, [active]: undefined })); const following = order[index + 1]; if (following) goTo(following); }, [active, order, index, goTo]); const value = React.useMemo<QuestionnaireContextValue>( () => ({ order, active, goTo, shortcuts, answers, other, errors, configs, register, setAnswer, setOther, next, previous, skip }), [order, active, goTo, shortcuts, answers, other, errors, register, setAnswer, setOther, next, previous, skip], ); return ( <QuestionnaireContext.Provider value={value}> <form ref={form} data-slot="questionnaire" noValidate className={cn("flex w-full min-w-0 flex-col gap-4", className)} onSubmit={(event) => { // Every required question must be answered; the first one that is not is shown with its message. const missing = order.find((name) => !validate(name)); if (missing) { event.preventDefault(); goTo(missing); return; } onSubmit?.(event); }} onKeyDown={(event) => { onKeyDown?.(event); if (event.defaultPrevented || !active || event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target as HTMLElement; const typing = target instanceof HTMLInputElement && target.type === "text"; if (event.key === "Enter" && !(target instanceof HTMLButtonElement) && !(target instanceof HTMLTextAreaElement)) { event.preventDefault(); if (index < order.length - 1) next(); else form.current?.requestSubmit(); return; } if (typing || shortcuts === "none") return; const key = event.key.toUpperCase(); const at = shortcuts === "letters" ? key.charCodeAt(0) - 65 : Number(key) - 1; if (key.length !== 1 || Number.isNaN(at) || at < 0 || at > 8) return; const choice = form.current?.querySelectorAll<HTMLInputElement>(`[data-slot=questionnaire-item][data-name="${CSS.escape(active)}"] [data-slot=questionnaire-choice-input]`)[at]; if (!choice || choice.disabled) return; event.preventDefault(); choice.focus(); setAnswer(active, choice.value, choice.type === "checkbox" ? !choice.checked : true); }} {...props} > {children} </form> </QuestionnaireContext.Provider> );}/** Where the person is: "Question 2 of 3" in 12px muted medium over a 4px purple bar. */function QuestionnaireProgress({ className, children, ...props }: React.ComponentProps<"div">) { const { order, active } = useQuestionnaire(); const at = active ? order.indexOf(active) + 1 : 0; const total = order.length; return ( <div data-slot="questionnaire-progress" className={cn("grid w-full gap-1.5", className)} {...props}> <span className="font-sans text-caption leading-tight font-medium text-fg-muted tabular-nums" aria-live="polite"> {children ?? (total ? `Question ${at} of ${total}` : " ")} </span> <Progress value={total ? (at / total) * 100 : 0} aria-label="Progress" className="h-1" /> </div> );}/** One question. Only the current one shows; the others stay in the form so their answers submit with it. */function QuestionnaireItem({ name, required = false, multiple = false, className, children, ...props }: Omit<React.ComponentProps<"fieldset">, "name"> & { name: string; required?: boolean; multiple?: boolean }) { const { active, register, errors } = useQuestionnaire(); const id = React.useId(); React.useLayoutEffect(() => register({ name, required, multiple }), [register, name, required, multiple]); const context = React.useMemo(() => ({ name, required, multiple, id }), [name, required, multiple, id]); const isActive = active === name; return ( <ItemContext.Provider value={context}> <fieldset data-slot="questionnaire-item" data-name={name} data-active={isActive || undefined} hidden={!isActive} aria-labelledby={`${id}-title`} aria-describedby={`${id}-description${errors[name] ? ` ${id}-error` : ""}`} aria-invalid={errors[name] ? true : undefined} className={cn("m-0 flex min-w-0 flex-col gap-4 border-0 p-0 outline-none", className)} {...props} > {children} </fieldset> </ItemContext.Provider> );}function QuestionnaireTitle({ className, ...props }: React.ComponentProps<"div">) { const { id } = useItem(); return <div id={`${id}-title`} data-slot="questionnaire-title" className={cn("font-sans text-body-lg leading-snug font-medium text-pretty text-fg", className)} {...props} />;}function QuestionnaireDescription({ className, ...props }: React.ComponentProps<"p">) { const { id } = useItem(); return <p id={`${id}-description`} data-slot="questionnaire-description" className={cn("-mt-3 font-sans text-body leading-body text-pretty text-fg-secondary", className)} {...props} />;}/** The choices of a question, 8px apart; each direct QuestionnaireChoice gets its shortcut in order. */function QuestionnaireChoices({ className, children, ...props }: React.ComponentProps<"div">) { const { multiple, id } = useItem(); let index = 0; return ( <div data-slot="questionnaire-choices" role={multiple ? "group" : "radiogroup"} aria-labelledby={`${id}-title`} className={cn("group/questionnaire-choices grid min-w-0 gap-2", className)} {...props}> {React.Children.map(children, (child) => (React.isValidElement<{ index?: number }>(child) && child.type === QuestionnaireChoice ? React.cloneElement(child, { index: index++ }) : child))} </div> );}function QuestionnaireChoice({ value, disabled = false, index = 0, className, children, ...props }: Omit<React.ComponentProps<"label">, "children"> & { value: string; disabled?: boolean; index?: number; children?: React.ReactNode }) { const { answers, setAnswer, shortcuts } = useQuestionnaire(); const { name, multiple } = useItem(); const checked = answers[name]?.includes(value) ?? false; const key = shortcutKey(index, shortcuts); return ( <label data-slot="questionnaire-choice" data-type={multiple ? "checkbox" : "radio"} data-checked={checked || undefined} data-disabled={disabled || undefined} className={cn( "group/questionnaire-choice relative flex min-h-11 cursor-pointer items-start gap-2.5 rounded-control border border-line-field bg-surface px-3.5 py-2.5 text-start font-sans text-body leading-snug text-fg select-none transition-[background-color,border-color] duration-150 ease-out hover:bg-subtle has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-focus-ring data-checked:border-line-brand data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 motion-reduce:transition-none", className, )} {...props} > <input data-slot="questionnaire-choice-input" type={multiple ? "checkbox" : "radio"} name={name} value={value} checked={checked} disabled={disabled} onChange={(event) => setAnswer(name, value, event.target.checked)} className="peer sr-only" /> <span aria-hidden data-slot="questionnaire-choice-indicator" className={cn( "pointer-events-none mt-px flex size-4.5 shrink-0 items-center justify-center border-[1.5px] border-line-strong bg-surface text-on-primary transition-[background-color,border-color,border-width] duration-150 ease-out motion-reduce:transition-none", multiple ? "rounded-xs group-data-checked/questionnaire-choice:border-primary group-data-checked/questionnaire-choice:bg-primary" : "rounded-pill group-data-checked/questionnaire-choice:border-4 group-data-checked/questionnaire-choice:border-pink", )} > {multiple && <CheckIcon weight="bold" className="hidden size-3.5 group-data-checked/questionnaire-choice:block" />} </span> <span data-slot="questionnaire-choice-label" className="flex min-w-0 flex-1 flex-col gap-0.5"> {children} </span> {key && ( <Kbd data-slot="questionnaire-choice-shortcut" aria-hidden className="ml-auto hidden shrink-0 sm:inline-flex"> {key} </Kbd> )} </label> );}/** A choice's second line: 14px muted. */function QuestionnaireChoiceDescription({ className, ...props }: React.ComponentProps<"span">) { return <span data-slot="questionnaire-choice-description" className={cn("text-fg-muted", className)} {...props} />;}/** A free answer typed instead of a choice (another answer); typing clears a single choice, choosing clears the text. */function QuestionnaireInput({ className, ...props }: Omit<React.ComponentProps<typeof Input>, "name" | "value" | "onChange">) { const { other, setOther } = useQuestionnaire(); const { name } = useItem(); return ( <div data-slot="questionnaire-input-wrapper" className="group/questionnaire-input relative w-full min-w-0"> <Input data-slot="questionnaire-input" type="text" name={name} value={other[name] ?? ""} onChange={(event) => setOther(name, event.target.value)} className={cn("h-11", className)} {...props} /> </div> );}/** Why the question cannot be passed yet: 12px red, read out when it appears. */function QuestionnaireError({ className, children, ...props }: React.ComponentProps<"p">) { const { errors } = useQuestionnaire(); const { name, id } = useItem(); const message = children ?? errors[name]; if (!message) return null; return ( <p id={`${id}-error`} role="alert" data-slot="questionnaire-error" className={cn("-mt-2 font-sans text-caption leading-tight text-danger", className)} {...props}> {message} </p> );}/** The buttons under the question: Previous at the left, then Skip and Next (or Submit on the last question). */function QuestionnaireActions({ className, ...props }: React.ComponentProps<"div">) { return <div data-slot="questionnaire-actions" className={cn("grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2", className)} {...props} />;}type ActionProps = Omit<React.ComponentProps<typeof Button>, "onClick" | "type"> & { onClick?: React.MouseEventHandler<HTMLButtonElement> };function QuestionnairePrevious({ children, className, variant = "secondary", onClick, ...props }: ActionProps) { const { order, active, previous } = useQuestionnaire(); const first = !active || order.indexOf(active) <= 0; return ( <Button data-slot="questionnaire-previous" variant={variant} disabled={first} className={cn("col-start-1 row-start-1 justify-self-start", first && "invisible", className)} onClick={(event) => { onClick?.(event); if (!event.defaultPrevented) previous(); }} {...props} > {children ?? "Previous"} </Button> );}function QuestionnaireSkip({ children, className, variant = "ghost", onClick, ...props }: ActionProps) { const { active, configs, order, skip } = useQuestionnaire(); const required = active ? configs.current.get(active)?.required : true; const last = !active || order.indexOf(active) === order.length - 1; if (required || last) return null; return ( <Button data-slot="questionnaire-skip" variant={variant} className={cn("col-start-2 row-start-1 justify-self-end px-4 text-fg-secondary", className)} onClick={(event) => { onClick?.(event); if (!event.defaultPrevented) skip(); }} {...props} > {children ?? "Skip"} </Button> );}function QuestionnaireNext({ children, className, variant = "default", onClick, ...props }: ActionProps) { const { order, active, next } = useQuestionnaire(); if (!active || order.indexOf(active) >= order.length - 1) return null; return ( <Button data-slot="questionnaire-next" variant={variant} className={cn("col-start-3 row-start-1 justify-self-end", className)} onClick={(event) => { onClick?.(event); if (!event.defaultPrevented) next(); }} {...props} > {children ?? "Next"} </Button> );}function QuestionnaireSubmit({ children, className, variant = "default", ...props }: Omit<React.ComponentProps<typeof Button>, "type">) { const { order, active } = useQuestionnaire(); if (!active || order.indexOf(active) !== order.length - 1) return null; return ( <Button data-slot="questionnaire-submit" type="submit" variant={variant} className={cn("col-start-3 row-start-1 justify-self-end", className)} {...props}> {children ?? "Submit"} </Button> );}export { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle,};Reference: Extrapolated from the Inkbox console (Figma 427:2715): the radio and checks in Create Agent Identity and the Where does this agent run? picker.