ChatMultiChoiceQuestion

An assistant-message single-select question with an optional free-form fallback, modeled after Claude's AskUserQuestion tool

Installation

pnpm add @wandercom/design-system-web

Usage

20 lines
import { ChatMultiChoiceQuestion } from '@wandercom/design-system-web/ui/chat-multi-choice-question';
import { useState } from 'react';

export function Example() {
  const [value, setValue] = useState<string | null>(null);

  return (
    <ChatMultiChoiceQuestion
      question="Photos"
      description="Use your current photos, or have us curate new imagery that fits your brand."
      options={[
        { value: 'new', label: 'Use new high quality photos' },
        { value: 'existing', label: 'Use my existing photos' },
      ]}
      value={value}
      onChange={setValue}
      onSubmit={(answer) => respond(answer)}
    />
  );
}

ChatMultiChoiceQuestion is the assistant-side counterpart to a chat composer: an assistant message poses a question, the user picks one of the offered options (or, optionally, types a free-form answer), and the response is submitted back to the conversation. Selecting an option auto-submits via onSubmit — there is no dedicated submit button by default.

Examples

Default

A vertical stack of single-select option cards. The currently selected option is highlighted with a filled surface and a trailing arrow.

Loading example...
10 lines
<ChatMultiChoiceQuestion
  question="Photos"
  description="Use your current photos, or have us curate new imagery that fits your brand."
  options={[
    { value: 'new', label: 'Use new high quality photos' },
    { value: 'existing', label: 'Use my existing photos' },
  ]}
  value={value}
  onChange={setValue}
/>

With free-form answer

A free-form text input renders by default below the options, separated by a divider — wire it up with otherValue / onOtherChange. Submitting the input (Enter or the trailing arrow button) calls onSubmit with { other: string }. Pass allowUserInput={false} to hide it.

Loading example...
17 lines
<ChatMultiChoiceQuestion
  question="Photos"
  description="Use your current photos, or have us curate new imagery."
  options={[
    { value: 'new', label: 'Use new high quality photos' },
    { value: 'existing', label: 'Use my existing photos' },
  ]}
  value={value}
  onChange={setValue}
  allowUserInput
  otherValue={other}
  onOtherChange={setOther}
  onSubmit={(answer) => {
    if (typeof answer === 'string') respondWithChoice(answer);
    else respondWithText(answer.other);
  }}
/>

Composition

For full control over layout — for example, dropping the prompt because the assistant message already provides context — use the compound subcomponents directly.

23 lines
import {
  ChatMultiChoiceQuestionRoot,
  ChatMultiChoiceQuestionPrompt,
  ChatMultiChoiceQuestionOptions,
  ChatMultiChoiceQuestionOther,
  ChatMultiChoiceQuestionSubmit,
} from '@wandercom/design-system-web/ui/chat-multi-choice-question';

<ChatMultiChoiceQuestionRoot
  options={options}
  value={value}
  onChange={setValue}
  allowUserInput
  otherValue={other}
  onOtherChange={setOther}
  onSubmit={respond}
>
  <ChatMultiChoiceQuestionPrompt description="Use your current photos, or have us curate new imagery.">
    Photos
  </ChatMultiChoiceQuestionPrompt>
  <ChatMultiChoiceQuestionOptions />
  <ChatMultiChoiceQuestionOther placeholder="Tell us what you'd prefer" />
</ChatMultiChoiceQuestionRoot>;

The compound subcomponents are also exposed under the namespace export: ChatMultiChoiceQuestion.Root, .Prompt, .Options, .Other, .Submit.

Props

ChatMultiChoiceQuestion

question?:

ReactNode
Question heading rendered above the options.

description?:

ReactNode
Optional helper copy under the question.

options:

{ value: string; label: string; disabled?: boolean }[]
Choices presented to the user.

value?:

string | null
Currently selected option value.

onChange?:

(value: string) => void
Fires when the user picks an option.

allowUserInput?:

boolean
Renders a free-form 'Other' input below the options. Defaults to `true`.

otherValue?:

string
Controlled value of the free-form input.

onOtherChange?:

(value: string) => void
Fires when the free-form input changes.

onSubmit?:

(answer: string | { other: string }) => void
Fires when the user commits an answer — either by selecting an option (auto-submits) or by submitting the free-form 'Other' input.

disabled?:

boolean
Disables every option and the free-form input.

className?:

string
Additional CSS classes applied to the root element.

ChatMultiChoiceQuestion.Root

Root provides selection state and submit context to its compound subcomponents and owns the radiogroup wrapper. Use this when you need full layout control — the top-level ChatMultiChoiceQuestion covers the common case.

options:

{ value: string; label: string; disabled?: boolean }[]
Choices presented to the user.

value?:

string | null
Currently selected option value.

onChange?:

(value: string) => void
Fires when the user picks an option.

allowUserInput?:

boolean
Makes the `Other` subcomponent render its free-form input. Defaults to `true`.

otherValue?:

string
Controlled value of the free-form input.

onOtherChange?:

(value: string) => void
Fires when the free-form input changes.

onSubmit?:

(answer: string | { other: string }) => void
Fires when the user commits an answer — either by selecting an option (auto-submits) or by submitting the free-form 'Other' input.

disabled?:

boolean
Disables every option and the free-form input.

aria-label?:

string
Accessible label for the radiogroup. Used as a fallback when no `Prompt` is rendered (otherwise the prompt heading labels the group via `aria-labelledby`).

asChild?:

boolean
When true, render the immediate child as the radiogroup wrapper. ARIA, role, and class props are merged onto the child instead of the default `<div>`.

children?:

ReactNode
Compound subcomponents — `Prompt`, `Options`, `Other`, `Submit`. Rendered in document order inside the radiogroup wrapper.

className?:

string
Additional CSS classes applied to the radiogroup wrapper.

ChatMultiChoiceQuestion.Prompt

children

ReactNode
Question text rendered with the `headline-sm` heading variant.

description?:

ReactNode
Optional secondary line rendered with `body-long` and `text-secondary`.

asChild?:

boolean
Render children as the prompt root via Radix `Slot` (e.g. swap the wrapper for a custom heading element). `description` is ignored in this mode — compose it inline in your child.

className?:

string
Additional CSS classes.

ChatMultiChoiceQuestion.Options

asChild?:

boolean
When true, render the immediate child as the options wrapper. The inner option `<button>`s are not affected.

className?:

string
Additional CSS classes applied to the options wrapper.

ChatMultiChoiceQuestion.Other

placeholder?:

string
Placeholder for the free-form input. Defaults to 'Something else'.

inputAriaLabel?:

string
Accessible label for the input. Defaults to 'Other answer'.

submitAriaLabel?:

string
Accessible label for the submit button. Defaults to 'Submit answer'.

hideDivider?:

boolean
Hide the divider rendered above the input.

asChild?:

boolean
When true, render the immediate child as the wrapper. The inner divider and `ChatInputInline` are not affected.

className?:

string
Additional CSS classes applied to the form wrapper.

ChatMultiChoiceQuestion.Submit

requireAnswer?:

boolean
Disable the button until an option is selected (or 'Other' has content). Defaults to true.

children?:

ReactNode
Button label. Defaults to 'Submit'.

...ButtonProps

ComponentProps<typeof Button>
All `Button` props are forwarded.

Accessibility

  • The root renders role="radiogroup" with an aria-labelledby reference to the prompt heading when one is provided.
  • Each option is a <button role="radio"> with aria-checked. Only the currently selected option (or the first option, when no selection has been made) is in the tab sequence (tabIndex={0}); other options are tabIndex={-1} and reachable via arrow keys.
  • Keyboard interaction:
    • Arrow Down / Arrow Right — move focus to the next option (wraps).
    • Arrow Up / Arrow Left — move focus to the previous option (wraps).
    • Home / End — focus the first / last option.
    • Enter / Space — activate the focused option (native button behavior).
  • Disabled options are skipped during arrow navigation.
  • The free-form Other row reuses ChatInputInline: pressing Enter on the input submits the value, and the trailing send button is disabled while the input is empty.
  • The trailing arrow icon on the selected option is aria-hidden — selection is communicated via aria-checked.

Notes

  • Auto-submit on selection mirrors Claude's AskUserQuestion behavior. If you need an explicit submit step, drop onSubmit from Root and render ChatMultiChoiceQuestion.Submit yourself; the button reads selection state from context.
  • When allowUserInput is true and the user types a non-empty value, the submit button on the Other row activates regardless of whether a card is also selected — selecting a card still auto-submits the card value.
ChatMultiChoiceQuestion