ChatMultiOptionQuestion

A multi-select chat question with a "select all" affordance, indeterminate state, and an inline submit footer.

Installation

pnpm add @wandercom/design-system-web

Usage

19 lines
import { ChatMultiOptionQuestion } from '@wandercom/design-system-web/ui/chat-multi-option-question';

export function Example() {
  const [selected, setSelected] = useState<string[]>([]);

  return (
    <ChatMultiOptionQuestion
      description="Select all the pages you want to build."
      onChange={setSelected}
      onSubmit={(values) => respond(values)}
      options={[
        { value: 'home', label: 'Home' },
        { value: 'about', label: 'About' },
      ]}
      question="What pages do you want to show?"
      value={selected}
    />
  );
}

The high-level ChatMultiOptionQuestion composes a prompt, a "select all" row, the option list, and a footer with a live count + submit button. Drop down to the subcomponents (ChatMultiOptionQuestionRoot, ChatMultiOptionQuestionSelectAll, …) when you need a custom layout.

Default

The default question renders the full Figma layout: prompt + description, "Select all" row over a divider, the option list, a divider, and a footer with the count and submit arrow. Submit is disabled until at least one option is selected.

Loading example...
8 lines
<ChatMultiOptionQuestion
  description="Select all the pages you want to build."
  onChange={setSelected}
  onSubmit={(values) => respond(values)}
  options={pages}
  question="What pages do you want to show?"
  value={selected}
/>

Without select-all

Hide the "Select all" row when the question only has a handful of options or when toggling everything at once doesn’t make sense. The footer still renders whenever onSubmit is provided.

Loading example...
8 lines
<ChatMultiOptionQuestion
  allowSelectAll={false}
  onChange={setSelected}
  onSubmit={(values) => respond(values)}
  options={amenities}
  question="Which amenities matter most?"
  value={selected}
/>

Composed

For full control over layout — custom select-all label, removing the divider, replacing the footer — use the subcomponents directly.

Loading example...
17 lines
<ChatMultiOptionQuestionRoot
  onChange={setSelected}
  onSubmit={handleSubmit}
  options={rooms}
  value={selected}
>
  <ChatMultiOptionQuestionPrompt description="Pick every room we should photograph.">
    Which rooms are ready to shoot?
  </ChatMultiOptionQuestionPrompt>
  <div className="flex flex-col gap-2">
    <ChatMultiOptionQuestionSelectAll>All rooms in the home</ChatMultiOptionQuestionSelectAll>
    <ChatMultiOptionQuestionDivider />
    <ChatMultiOptionQuestionOptions />
    <ChatMultiOptionQuestionDivider />
    <ChatMultiOptionQuestionFooter />
  </div>
</ChatMultiOptionQuestionRoot>

Selection behavior

  • Select all is true only when every option is selected. With a partial selection, the checkbox renders an indeterminate dash and clicking it selects every option. Clicking it again with everything selected clears the selection.
  • The value array is normalized to the order of the supplied options so consumers can render summaries without re-sorting.
  • ChatMultiOptionQuestionSubmit is auto-disabled when the selection is empty. Pass disabled={false} to override, or pass showFooter={false} on the high-level component to hide the footer entirely.

Accessibility

  • The root renders as a <fieldset> and is labeled via aria-labelledby pointing at the prompt heading when a Prompt is mounted, so screen readers announce the question on entry. When no Prompt is rendered, pass aria-label on ChatMultiOptionQuestionRoot to label the group instead. To use a native <legend> instead of aria-labelledby, render ChatMultiOptionQuestionPrompt with asChild and pass a <legend> as its child.
  • Each option row is a <label> associated with the row's checkbox via htmlFor/id, so the entire row is a single tap target and screen readers announce label + state together. The checkbox renders Radix's role="checkbox" button under the hood.
  • The "select all" row uses Radix's indeterminate state and is announced as aria-checked="mixed" when partial.
  • The submit button ships with an aria-label of "Submit". Override via the aria-label prop on ChatMultiOptionQuestionSubmit or the high-level component.
  • Disabling the root via disabled on ChatMultiOptionQuestionRoot propagates disabled to every checkbox via the native <fieldset> cascade.

Props

ChatMultiOptionQuestion / ChatMultiOptionQuestionRoot

ChatMultiOptionQuestion accepts everything ChatMultiOptionQuestionRoot does, plus question, description, allowSelectAll, and showFooter which drive the default composition.

options

ChatMultiOptionQuestionItem[]
List of selectable options. Each item needs a stable `value` and a `label`.

value

string[]
Controlled list of selected values.

onChange

(value: string[]) => void
Called whenever the selection changes. Receives the new selection in the order of `options`.

onSubmit?:

(value: string[]) => void
Called when the submit button is pressed. Determines whether the default footer renders.

question

ReactNode
(ChatMultiOptionQuestion only) Question rendered as the heading of the prompt.

description?:

ReactNode
(ChatMultiOptionQuestion only) Optional secondary line under the question.

allowSelectAll?:

boolean
(ChatMultiOptionQuestion only) Render the "select all" row above the options. Defaults to `true`.

selectAllLabel?:

ReactNode
Label rendered next to the "select all" checkbox. Defaults to "Select all".

showFooter?:

boolean
(ChatMultiOptionQuestion only) Show the count + submit footer. Defaults to `true` when `onSubmit` is provided.

disabled?:

boolean
Disables every checkbox and the submit button via the native `<fieldset>` cascade.

className?:

string
Additional CSS classes applied to the root.

ChatMultiOptionQuestionPrompt

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

className?:

string
Additional CSS classes to apply.

ChatMultiOptionQuestionOptions

Inherits all <div> props.

children?:

(item: ChatMultiOptionQuestionItem, index: number) => ReactNode
Optional render override. When omitted, renders a `ChatMultiOptionQuestionOption` for each item.

ChatMultiOptionQuestionOption

Inherits all <label> props except htmlFor and onChange (managed by the root).

value

string
Value of the option this row represents. Must match an item in `options`.

children

ReactNode
Label rendered next to the checkbox.

disabled?:

boolean
Disable this single row. Falls back to the root `disabled` flag.

ChatMultiOptionQuestionSelectAll

Inherits all <label> props except htmlFor and onChange.

children?:

ReactNode
Override the label. Defaults to the root `selectAllLabel` ("Select all").

className?:

string
Additional CSS classes to apply.

ChatMultiOptionQuestionDivider

Inherits all <div> props. Renders a 1px horizontal rule with aria-hidden="true".

asChild?:

boolean
Render children as the divider root via Radix `Slot` (e.g. swap the wrapper for `<hr>`).

className?:

string
Additional CSS classes to apply.

ChatMultiOptionQuestionFooter

children?:

ReactNode
Override the footer contents. Defaults to `ChatMultiOptionQuestionCount` on the left and `ChatMultiOptionQuestionSubmit` on the right.

asChild?:

boolean
Render children as the footer root via Radix `Slot` (e.g. swap the wrapper for a semantic `<footer>`). Skips the default count + submit layout.

className?:

string
Additional CSS classes to apply.

ChatMultiOptionQuestionCount

format?:

(count: number) => ReactNode
Custom render for the count string. Defaults to "{n} selected".

asChild?:

boolean
Render children as the count root via Radix `Slot`. Skips the default count formatting.

className?:

string
Additional CSS classes to apply.

ChatMultiOptionQuestionSubmit

Inherits the Button API except size, variant, type, asChild, and children, which are fixed by the component.

icon?:

ReactNode
Override the icon. Defaults to a right arrow.

aria-label?:

string
Accessible label. Defaults to "Submit".

disabled?:

boolean
Override the auto-derived disabled state (which is true when the selection is empty).

className?:

string
Additional CSS classes to apply.

Types

4 lines
type ChatMultiOptionQuestionItem = {
  value: string;
  label: ReactNode;
};
ChatMultiOptionQuestion