diff --git a/.changeset/tidy-registry-titles.md b/.changeset/tidy-registry-titles.md deleted file mode 100644 index df800880ba8..00000000000 --- a/.changeset/tidy-registry-titles.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"shadcn": patch ---- - -Include registry item titles in `searchRegistries` results and fuzzy matching. diff --git a/apps/v4/components/announcement.tsx b/apps/v4/components/announcement.tsx index 5f3871c1e96..12ee1d3ac88 100644 --- a/apps/v4/components/announcement.tsx +++ b/apps/v4/components/announcement.tsx @@ -7,7 +7,7 @@ export function Announcement() { return ( - React Aria is now available + New Questionnaire component ) diff --git a/apps/v4/components/component-preview-tabs.tsx b/apps/v4/components/component-preview-tabs.tsx index 763c9d942a9..a2a1edacc52 100644 --- a/apps/v4/components/component-preview-tabs.tsx +++ b/apps/v4/components/component-preview-tabs.tsx @@ -235,7 +235,7 @@ function PreviewWrapper({ data-align={align} data-chromeless={chromeLessOnMobile} className={cn( - "preview relative flex h-72 w-full justify-center p-10 data-[align=center]:items-center data-[align=end]:items-end data-[align=start]:items-start data-[chromeless=true]:h-auto data-[chromeless=true]:p-0", + "preview relative flex h-72 w-full justify-center p-10 data-[align=center]:items-center data-[align=end]:items-start data-[align=start]:items-start data-[chromeless=true]:h-auto data-[chromeless=true]:p-0 sm:data-[align=end]:items-end", previewClassName )} > diff --git a/apps/v4/content/docs/changelog/2026-08-questionnaire.mdx b/apps/v4/content/docs/changelog/2026-08-questionnaire.mdx new file mode 100644 index 00000000000..272dace6014 --- /dev/null +++ b/apps/v4/content/docs/changelog/2026-08-questionnaire.mdx @@ -0,0 +1,52 @@ +--- +title: August 2026 - Questionnaire +description: A new component for building multi-step question flows with fixed, freeform, multiple, and skippable answers. +date: 2026-08-05 +--- + +Today, we're releasing [**Questionnaire**](/docs/components/base/questionnaire), +a new component for multi-step question flows. Use it for agent clarification +prompts, onboarding, surveys, intake forms, and configuration. + +Questionnaire is available for Base UI, React Aria, and Radix across all eight +styles. + + + +## Features + +- Single and multiple selection with native radios and checkboxes. +- Freeform answers alongside fixed choices. +- Explicit skipping for optional questions. +- Previous, next, submit, and custom progress controls. +- Required and custom validation. +- Controlled navigation, saved defaults, and conditional questions. +- Keyboard navigation with optional letter or number shortcuts. +- Native form serialization and server-rendered collection state. +- Standalone, Card, and Dialog composition. + +## Installation + +```bash +npx shadcn@latest add questionnaire +``` + +## @shadcn/react + +The `` component is also available as an unstyled headless primitive in `@shadcn/react`. [Read the docs](/docs/react/questionnaire) to learn more. + +
+ +
diff --git a/apps/v4/content/docs/components/aria/meta.json b/apps/v4/content/docs/components/aria/meta.json index 05577f5b4bc..9ffd5827737 100644 --- a/apps/v4/content/docs/components/aria/meta.json +++ b/apps/v4/content/docs/components/aria/meta.json @@ -43,6 +43,7 @@ "pagination", "popover", "progress", + "questionnaire", "radio-group", "resizable", "scroll-area", diff --git a/apps/v4/content/docs/components/aria/questionnaire.mdx b/apps/v4/content/docs/components/aria/questionnaire.mdx new file mode 100644 index 00000000000..af57402ec3f --- /dev/null +++ b/apps/v4/content/docs/components/aria/questionnaire.mdx @@ -0,0 +1,367 @@ +--- +title: Questionnaire +description: A multi-step questionnaire with single-choice, multiple-choice, freeform, and skippable questions. +base: aria +component: true +--- + + + +## Installation + + + + + Command + Manual + + + +```bash +npx shadcn@latest add questionnaire +``` + + + + + + + +Install the following dependency: + +```bash +npm install @shadcn/react +``` + +Copy and paste the following code into your project. + + + +Update the import paths to match your project setup. + + + + + + + +## Usage + +```tsx +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/components/ui/questionnaire" +``` + +```tsx +const items = [ + { + name: "direction", + required: true, + prompt: "What should we prototype next?", + description: "Choose a direction or write your own.", + choices: [ + { + value: "delegation", + label: "Delegation", + description: "Show how work moves to a specialist.", + }, + { + value: "questions", + label: "Question prompts", + description: "Show choices while the interface waits.", + }, + { value: "both", label: "Both together" }, + ], + input: { label: "Another answer", placeholder: "Type another answer…" }, + }, + { + name: "detail", + required: false, + prompt: "How much detail should it include?", + description: "Skip this if you are not sure yet.", + choices: [ + { value: "focused", label: "Focused" }, + { value: "complete", label: "Complete flow" }, + ], + }, +] as const +``` + +Define the collection once: pass it to `Questionnaire` for server-rendered +progress, actions, and shortcuts, then map it into the parts. + +```tsx + + + {items.map((question) => ( + + {question.prompt} + + {question.description} + + + {question.choices.map((choice) => ( + + {choice.label} + {"description" in choice ? ( + + {choice.description} + + ) : null} + + ))} + {"input" in question ? ( + + ) : null} + + + + ))} + + + + + + + +``` + +```tsx +function handleSubmit(event: React.FormEvent) { + event.preventDefault() + const answers = new FormData(event.currentTarget) + // answers.get("direction"), answers.getAll(...) for multiple items. +} +``` + +## Composition + +```text +Questionnaire +├── QuestionnaireProgress +├── QuestionnaireItem +│ ├── QuestionnaireTitle +│ ├── QuestionnaireDescription +│ ├── QuestionnaireChoices +│ │ ├── QuestionnaireChoice +│ │ └── QuestionnaireInput +│ └── QuestionnaireError +└── QuestionnaireActions + ├── QuestionnairePrevious + ├── QuestionnaireSkip + ├── QuestionnaireNext + └── QuestionnaireSubmit +``` + +Questionnaire owns the ordered items, active item, answer state, validation, +progress, and navigation. The containing page, card, dialog, or drawer owns +close and cancellation behavior, persistence, transport, and branching. + +## Server Rendering + +Pass `items` to server-render the active item, progress, actions, and answer +shortcuts. See the +[headless Questionnaire](/docs/react/questionnaire) for the complete behavior. + +## Multiple Selection + +Use `multiple` for an item that accepts more than one fixed answer. + + + +## Freeform Answer + +Compose `QuestionnaireInput` with fixed choices when the user can provide another answer. + + + +## Explicit Skip + +Add `QuestionnaireSkip` when an optional item may be intentionally left unanswered. + + + +## Shortcuts + +Assign a letter or number key to each answer with `shortcuts`. + + + +## Custom Validation + +Combine controlled navigation with an external schema such as Zod to return to an invalid item and present its error. + + + +## Controlled + +Control the active item from host state, such as returning to an invalid step. + + + +## Resume + +Restore a saved active item and default answers, then reset changes back to that saved state. + + + +## Conditional Items + +Disable items that do not apply to the user's earlier answers. + + + +## Navigation State + +Read item status to opt into disabled navigation and custom action styling. + + + +## Custom Progress + +Use the Progress render state to build a custom progress indicator. + + + +## Animated Items + +Animate the active item while keeping progress and navigation stationary. + + + +## Card + +Compose Questionnaire with Card slots while keeping the question title and description semantic. + + + +## Dialog + +Compose Questionnaire inside a Dialog while keeping cancellation and dismissal host-owned. + + + +## Accessibility + +`QuestionnaireItem` renders a `fieldset`, and `QuestionnaireTitle` renders its +`legend`. Descriptions and active errors are associated with the current item, +and invalid items and answer controls expose `aria-invalid`. + +Fixed choices preserve native radio and checkbox behavior. Progress is exposed +as a named progressbar, navigation uses real buttons, and inactive items and +actions are hidden and inert. Successful navigation focuses the newly active +item; failed validation focuses an available answer control. + +Always give `QuestionnaireInput` an accessible name with a visible label, +`aria-label`, or `aria-labelledby`. A placeholder is not a label. See the +[Questionnaire accessibility guide](/docs/react/questionnaire#accessibility) +for labeling custom compositions and the complete keyboard behavior. + +## Unstyled + +The behavior in `Questionnaire` comes from the `@shadcn/react` package. To use +it directly with your own markup and styles, see +[Questionnaire](/docs/react/questionnaire) under @shadcn/react. + +## API Reference + +The props, data attributes, and render states for every part are documented on +the [@shadcn/react Questionnaire](/docs/react/questionnaire#api-reference) page. +The styled components inherit the corresponding unstyled props. Navigation +components also accept Button `size` and `variant` props, and +`QuestionnaireActions` is a styled-only layout helper. diff --git a/apps/v4/content/docs/components/base/meta.json b/apps/v4/content/docs/components/base/meta.json index 643e76811ad..a927c0e95c1 100644 --- a/apps/v4/content/docs/components/base/meta.json +++ b/apps/v4/content/docs/components/base/meta.json @@ -46,6 +46,7 @@ "pagination", "popover", "progress", + "questionnaire", "radio-group", "resizable", "scroll-area", diff --git a/apps/v4/content/docs/components/base/questionnaire.mdx b/apps/v4/content/docs/components/base/questionnaire.mdx new file mode 100644 index 00000000000..556aca01262 --- /dev/null +++ b/apps/v4/content/docs/components/base/questionnaire.mdx @@ -0,0 +1,367 @@ +--- +title: Questionnaire +description: A multi-step questionnaire with single-choice, multiple-choice, freeform, and skippable questions. +base: base +component: true +--- + + + +## Installation + + + + + Command + Manual + + + +```bash +npx shadcn@latest add questionnaire +``` + + + + + + + +Install the following dependency: + +```bash +npm install @shadcn/react +``` + +Copy and paste the following code into your project. + + + +Update the import paths to match your project setup. + + + + + + + +## Usage + +```tsx +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/components/ui/questionnaire" +``` + +```tsx +const items = [ + { + name: "direction", + required: true, + prompt: "What should we prototype next?", + description: "Choose a direction or write your own.", + choices: [ + { + value: "delegation", + label: "Delegation", + description: "Show how work moves to a specialist.", + }, + { + value: "questions", + label: "Question prompts", + description: "Show choices while the interface waits.", + }, + { value: "both", label: "Both together" }, + ], + input: { label: "Another answer", placeholder: "Type another answer…" }, + }, + { + name: "detail", + required: false, + prompt: "How much detail should it include?", + description: "Skip this if you are not sure yet.", + choices: [ + { value: "focused", label: "Focused" }, + { value: "complete", label: "Complete flow" }, + ], + }, +] as const +``` + +Define the collection once: pass it to `Questionnaire` for server-rendered +progress, actions, and shortcuts, then map it into the parts. + +```tsx + + + {items.map((question) => ( + + {question.prompt} + + {question.description} + + + {question.choices.map((choice) => ( + + {choice.label} + {"description" in choice ? ( + + {choice.description} + + ) : null} + + ))} + {"input" in question ? ( + + ) : null} + + + + ))} + + + + + + + +``` + +```tsx +function handleSubmit(event: React.FormEvent) { + event.preventDefault() + const answers = new FormData(event.currentTarget) + // answers.get("direction"), answers.getAll(...) for multiple items. +} +``` + +## Composition + +```text +Questionnaire +├── QuestionnaireProgress +├── QuestionnaireItem +│ ├── QuestionnaireTitle +│ ├── QuestionnaireDescription +│ ├── QuestionnaireChoices +│ │ ├── QuestionnaireChoice +│ │ └── QuestionnaireInput +│ └── QuestionnaireError +└── QuestionnaireActions + ├── QuestionnairePrevious + ├── QuestionnaireSkip + ├── QuestionnaireNext + └── QuestionnaireSubmit +``` + +Questionnaire owns the ordered items, active item, answer state, validation, +progress, and navigation. The containing page, card, dialog, or drawer owns +close and cancellation behavior, persistence, transport, and branching. + +## Server Rendering + +Pass `items` to server-render the active item, progress, actions, and answer +shortcuts. See the +[headless Questionnaire](/docs/react/questionnaire) for the complete behavior. + +## Multiple Selection + +Use `multiple` for an item that accepts more than one fixed answer. + + + +## Freeform Answer + +Compose `QuestionnaireInput` with fixed choices when the user can provide another answer. + + + +## Explicit Skip + +Add `QuestionnaireSkip` when an optional item may be intentionally left unanswered. + + + +## Shortcuts + +Assign a letter or number key to each answer with `shortcuts`. + + + +## Custom Validation + +Combine controlled navigation with an external schema such as Zod to return to an invalid item and present its error. + + + +## Controlled + +Control the active item from host state, such as returning to an invalid step. + + + +## Resume + +Restore a saved active item and default answers, then reset changes back to that saved state. + + + +## Conditional Items + +Disable items that do not apply to the user's earlier answers. + + + +## Navigation State + +Read item status to opt into disabled navigation and custom action styling. + + + +## Custom Progress + +Use the Progress render state to build a custom progress indicator. + + + +## Animated Items + +Animate the active item while keeping progress and navigation stationary. + + + +## Card + +Compose Questionnaire with Card slots while keeping the question title and description semantic. + + + +## Dialog + +Compose Questionnaire inside a Dialog while keeping cancellation and dismissal host-owned. + + + +## Accessibility + +`QuestionnaireItem` renders a `fieldset`, and `QuestionnaireTitle` renders its +`legend`. Descriptions and active errors are associated with the current item, +and invalid items and answer controls expose `aria-invalid`. + +Fixed choices preserve native radio and checkbox behavior. Progress is exposed +as a named progressbar, navigation uses real buttons, and inactive items and +actions are hidden and inert. Successful navigation focuses the newly active +item; failed validation focuses an available answer control. + +Always give `QuestionnaireInput` an accessible name with a visible label, +`aria-label`, or `aria-labelledby`. A placeholder is not a label. See the +[Questionnaire accessibility guide](/docs/react/questionnaire#accessibility) +for labeling custom compositions and the complete keyboard behavior. + +## Unstyled + +The behavior in `Questionnaire` comes from the `@shadcn/react` package. To use +it directly with your own markup and styles, see +[Questionnaire](/docs/react/questionnaire) under @shadcn/react. + +## API Reference + +The props, data attributes, and render states for every part are documented on +the [@shadcn/react Questionnaire](/docs/react/questionnaire#api-reference) page. +The styled components inherit the corresponding unstyled props. Navigation +components also accept Button `size` and `variant` props, and +`QuestionnaireActions` is a styled-only layout helper. diff --git a/apps/v4/content/docs/components/radix/meta.json b/apps/v4/content/docs/components/radix/meta.json index fd3124f0553..f6b44dc2031 100644 --- a/apps/v4/content/docs/components/radix/meta.json +++ b/apps/v4/content/docs/components/radix/meta.json @@ -46,6 +46,7 @@ "pagination", "popover", "progress", + "questionnaire", "radio-group", "resizable", "scroll-area", diff --git a/apps/v4/content/docs/components/radix/questionnaire.mdx b/apps/v4/content/docs/components/radix/questionnaire.mdx new file mode 100644 index 00000000000..41c4c29e117 --- /dev/null +++ b/apps/v4/content/docs/components/radix/questionnaire.mdx @@ -0,0 +1,367 @@ +--- +title: Questionnaire +description: A multi-step questionnaire with single-choice, multiple-choice, freeform, and skippable questions. +base: radix +component: true +--- + + + +## Installation + + + + + Command + Manual + + + +```bash +npx shadcn@latest add questionnaire +``` + + + + + + + +Install the following dependency: + +```bash +npm install @shadcn/react +``` + +Copy and paste the following code into your project. + + + +Update the import paths to match your project setup. + + + + + + + +## Usage + +```tsx +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/components/ui/questionnaire" +``` + +```tsx +const items = [ + { + name: "direction", + required: true, + prompt: "What should we prototype next?", + description: "Choose a direction or write your own.", + choices: [ + { + value: "delegation", + label: "Delegation", + description: "Show how work moves to a specialist.", + }, + { + value: "questions", + label: "Question prompts", + description: "Show choices while the interface waits.", + }, + { value: "both", label: "Both together" }, + ], + input: { label: "Another answer", placeholder: "Type another answer…" }, + }, + { + name: "detail", + required: false, + prompt: "How much detail should it include?", + description: "Skip this if you are not sure yet.", + choices: [ + { value: "focused", label: "Focused" }, + { value: "complete", label: "Complete flow" }, + ], + }, +] as const +``` + +Define the collection once: pass it to `Questionnaire` for server-rendered +progress, actions, and shortcuts, then map it into the parts. + +```tsx + + + {items.map((question) => ( + + {question.prompt} + + {question.description} + + + {question.choices.map((choice) => ( + + {choice.label} + {"description" in choice ? ( + + {choice.description} + + ) : null} + + ))} + {"input" in question ? ( + + ) : null} + + + + ))} + + + + + + + +``` + +```tsx +function handleSubmit(event: React.FormEvent) { + event.preventDefault() + const answers = new FormData(event.currentTarget) + // answers.get("direction"), answers.getAll(...) for multiple items. +} +``` + +## Composition + +```text +Questionnaire +├── QuestionnaireProgress +├── QuestionnaireItem +│ ├── QuestionnaireTitle +│ ├── QuestionnaireDescription +│ ├── QuestionnaireChoices +│ │ ├── QuestionnaireChoice +│ │ └── QuestionnaireInput +│ └── QuestionnaireError +└── QuestionnaireActions + ├── QuestionnairePrevious + ├── QuestionnaireSkip + ├── QuestionnaireNext + └── QuestionnaireSubmit +``` + +Questionnaire owns the ordered items, active item, answer state, validation, +progress, and navigation. The containing page, card, dialog, or drawer owns +close and cancellation behavior, persistence, transport, and branching. + +## Server Rendering + +Pass `items` to server-render the active item, progress, actions, and answer +shortcuts. See the +[headless Questionnaire](/docs/react/questionnaire) for the complete behavior. + +## Multiple Selection + +Use `multiple` for an item that accepts more than one fixed answer. + + + +## Freeform Answer + +Compose `QuestionnaireInput` with fixed choices when the user can provide another answer. + + + +## Explicit Skip + +Add `QuestionnaireSkip` when an optional item may be intentionally left unanswered. + + + +## Shortcuts + +Assign a letter or number key to each answer with `shortcuts`. + + + +## Custom Validation + +Combine controlled navigation with an external schema such as Zod to return to an invalid item and present its error. + + + +## Controlled + +Control the active item from host state, such as returning to an invalid step. + + + +## Resume + +Restore a saved active item and default answers, then reset changes back to that saved state. + + + +## Conditional Items + +Disable items that do not apply to the user's earlier answers. + + + +## Navigation State + +Read item status to opt into disabled navigation and custom action styling. + + + +## Custom Progress + +Use the Progress render state to build a custom progress indicator. + + + +## Animated Items + +Animate the active item while keeping progress and navigation stationary. + + + +## Card + +Compose Questionnaire with Card slots while keeping the question title and description semantic. + + + +## Dialog + +Compose Questionnaire inside a Dialog while keeping cancellation and dismissal host-owned. + + + +## Accessibility + +`QuestionnaireItem` renders a `fieldset`, and `QuestionnaireTitle` renders its +`legend`. Descriptions and active errors are associated with the current item, +and invalid items and answer controls expose `aria-invalid`. + +Fixed choices preserve native radio and checkbox behavior. Progress is exposed +as a named progressbar, navigation uses real buttons, and inactive items and +actions are hidden and inert. Successful navigation focuses the newly active +item; failed validation focuses an available answer control. + +Always give `QuestionnaireInput` an accessible name with a visible label, +`aria-label`, or `aria-labelledby`. A placeholder is not a label. See the +[Questionnaire accessibility guide](/docs/react/questionnaire#accessibility) +for labeling custom compositions and the complete keyboard behavior. + +## Unstyled + +The behavior in `Questionnaire` comes from the `@shadcn/react` package. To use +it directly with your own markup and styles, see +[Questionnaire](/docs/react/questionnaire) under @shadcn/react. + +## API Reference + +The props, data attributes, and render states for every part are documented on +the [@shadcn/react Questionnaire](/docs/react/questionnaire#api-reference) page. +The styled components inherit the corresponding unstyled props. Navigation +components also accept Button `size` and `variant` props, and +`QuestionnaireActions` is a styled-only layout helper. diff --git a/apps/v4/content/docs/react/meta.json b/apps/v4/content/docs/react/meta.json index 6b5ffdd6765..f427c7fc295 100644 --- a/apps/v4/content/docs/react/meta.json +++ b/apps/v4/content/docs/react/meta.json @@ -1,4 +1,4 @@ { "title": "@shadcn/react", - "pages": ["message-scroller"] + "pages": ["message-scroller", "questionnaire"] } diff --git a/apps/v4/content/docs/react/questionnaire.mdx b/apps/v4/content/docs/react/questionnaire.mdx new file mode 100644 index 00000000000..7461ed47d42 --- /dev/null +++ b/apps/v4/content/docs/react/questionnaire.mdx @@ -0,0 +1,1107 @@ +--- +title: Questionnaire +description: Build accessible, multi-step questionnaires with single, multiple, freeform, and intentionally skipped answers. +--- + +`Questionnaire` is an unstyled form primitive for presenting one question at a +time. It manages answers, progress, validation, and navigation. + +It works well for agent clarification prompts, onboarding, surveys, intake +forms, and configuration. + +The unstyled package gives you full control over markup and styles. For the +styled version and themed examples, see +[Questionnaire](/docs/components/base/questionnaire). + +## Installation + +```bash +npm install @shadcn/react +``` + +## Import + +```tsx +import { Questionnaire } from "@shadcn/react/questionnaire" +``` + +`Questionnaire` exports its parts from one namespace. Each part accepts the +native props for its default element. + +## Anatomy + +```tsx + + + + + + + + + + + + + + + + + + + + +``` + +`Root` renders a form. Each `Item` is a fieldset, with `Title` as its legend. +`ChoiceInput` renders a native radio or checkbox. + +## Styled version + +The styled registry component uses flat component names: + +| Styled component | Unstyled part | +| ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `Questionnaire` | `Questionnaire.Root` | +| `QuestionnaireProgress` | `Questionnaire.Progress` | +| `QuestionnaireItem` | `Questionnaire.Item` | +| `QuestionnaireTitle` | `Questionnaire.Title` | +| `QuestionnaireDescription` | `Questionnaire.Description` | +| `QuestionnaireChoices` | `Questionnaire.Choices` | +| `QuestionnaireChoice` | `Questionnaire.Choice` with `ChoiceInput`, `ChoiceLabel`, and `ChoiceShortcut` | +| `QuestionnaireInput` | `Questionnaire.Input` | +| `QuestionnaireError` | `Questionnaire.Error` | +| `QuestionnaireActions` | None. Layout only; use your own container. | +| `QuestionnairePrevious`, `QuestionnaireSkip`, `QuestionnaireNext`, `QuestionnaireSubmit` | `Questionnaire.Previous`, `Questionnaire.Skip`, `Questionnaire.Next`, `Questionnaire.Submit` | + +The styled `QuestionnaireChoice` composes the input, label, shortcut, and visual +indicator for you. With the unstyled package, compose those parts yourself. + +## Basic usage + +Each `Item` is one step. Its `name` identifies the step and becomes the form +field name for its answers. `Choice.value` is the submitted answer. + + + +```tsx +const items = [ + { + name: "prototype", + required: true, + prompt: "What should we prototype next?", + description: "Choose a direction or write your own.", + choices: [ + { + value: "delegation", + label: "Delegation", + description: "Show how work moves to a specialist.", + }, + { + value: "questions", + label: "Question prompts", + description: "Show choices while the interface waits.", + }, + { value: "both", label: "Both together" }, + ], + input: { label: "Another answer", placeholder: "Type another answer…" }, + }, + { + name: "detail", + required: false, + prompt: "How much detail should it include?", + description: "Skip this if you are not sure yet.", + choices: [ + { value: "focused", label: "Focused" }, + { value: "complete", label: "Complete flow" }, + ], + }, +] as const +``` + +```tsx showLineNumbers +"use client" + +import * as React from "react" +import { Questionnaire } from "@shadcn/react/questionnaire" + +export function ProjectQuestionnaire() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + console.log({ + prototype: formData.get("prototype"), + detail: formData.get("detail"), + }) + } + + return ( + + + {items.map((question) => ( + + {question.prompt} + + {question.description} + + + {question.choices.map((choice) => ( + + + + {choice.label} + {"description" in choice ? ( + {choice.description} + ) : null} + + + + ))} + {"input" in question ? ( + + ) : null} + + + + ))} + + + + + + ) +} +``` + +Pass the same `items` collection to `Root` that you render as `Item` and +`Choice` parts. This makes item order, progress, action visibility, and answer +shortcuts available in the server-rendered HTML. + +## Multiple selection + +`multiple` turns an item's fixed choices into native checkboxes. Read the answers with +`FormData.getAll()`. Keep `multiple` in your application data and pass it to +the rendered `Item`. + + + +```tsx +const items = [ + { + name: "signals", + required: true, + multiple: true, + prompt: "What should every update include?", + description: "Select all that apply.", + choices: [ + { value: "progress", label: "Progress" }, + { value: "decisions", label: "Decisions" }, + { value: "risks", label: "Risks" }, + ], + }, +] as const +``` + +```tsx +items.map((question) => ( + + {question.prompt} + + {question.description} + + + {question.choices.map((choice) => ( + + + {choice.label} + + + ))} + + + +)) +``` + +```tsx +const signals = new FormData(form).getAll("signals").map(String) +``` + +## Freeform answers + +`Input` adds a freeform answer and renders a native text input. + + + +```tsx +const items = [ + { + name: "prototype", + required: true, + prompt: "What should we prototype next?", + choices: [ + { value: "delegation", label: "Delegation" }, + { value: "questions", label: "Question prompts" }, + ], + input: { + label: "Another prototype direction", + placeholder: "Type another direction…", + }, + }, +] as const +``` + +```tsx +items.map((question) => ( + + {question.prompt} + + {question.choices.map((choice) => ( + + + {choice.label} + + + ))} + + + + +)) +``` + +## Explicit skip + +`Skip` records that an optional item was intentionally left unanswered. Use +`onStatusChange` when your application needs to distinguish a skipped answer +from a missing one. + + + +```tsx showLineNumbers +"use client" + +import * as React from "react" +import { + Questionnaire, + type QuestionnaireItemStatus, +} from "@shadcn/react/questionnaire" + +export function PlanningQuestionnaire() { + const [timingStatus, setTimingStatus] = + React.useState("unanswered") + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + console.log({ + timing: + timingStatus === "skipped" + ? { status: "skipped" } + : { + status: "answered", + value: formData.get("timing"), + }, + }) + } + + return ( + + + + When should this be revisited? + + + Skip this if timing has not been decided. + + + + + This week + + + + + Next cycle + + + + + + + + ) +} +``` + +## Answer shortcuts + +Use `shortcuts="letters"` or `shortcuts="numbers"` to assign a key to each +enabled fixed choice, following the `items` order when it is provided. Compose +`ChoiceShortcut` wherever its hint should appear. + + + +```tsx + + + What should the agent review? + + + + Public API + + + + + Test coverage + + + + + +``` + +`"letters"` assigns `A` through `Z`; `"numbers"` assigns `1` through `9`. +Disabled choices are skipped. Selecting an answer by shortcut does not advance +to the next item. + +## Validation + +Questionnaire validates the active item before moving forward and validates all +enabled items when the form submits. + +- A required item is valid after it has an answer. +- An optional item is valid after it has an answer or is explicitly skipped. +- Disabled items and answers are ignored. + +`required` does not add visible “Required” text. Say it in the `Title` or +`Description`. + +When validation fails, Questionnaire keeps or opens the invalid item and +focuses an answer. Add `Error` to show a message. + +```tsx + + + What should the project include? (Required) + + {/* choices */} + + +``` + +`Error` remains hidden until the item is invalid. Pass children to replace its +default message. + +```tsx +Please choose a project scope. +``` + +Validation still works without `Error`. When rendered, the message is announced +to screen readers. + +For Zod or another external validator, set `Item.invalid`, render the message in +`Error`, and move `Root.item` to the first invalid item. + + + +```tsx + + + How much detail? + {/* choices */} + {errors.detail} + + +``` + +## Controlled navigation + +Pass `item` and `onItemChange` to control the active item. + + + +```tsx +const [item, setItem] = React.useState("scope") + + + + {/* question and answers */} + + + {/* question and answers */} + + + Next + Submit + +``` + +## Resume with defaults + +Restore a saved draft with `defaultItem`, `defaultChecked`, and `defaultValue`. + + + +```tsx + + + Which files are in scope? + + + + Component only + + + + + Any extra instructions? + + + + +``` + +## Conditional items + +Set `disabled` to remove an item from the current flow. Disabled items are +excluded from progress, navigation, validation, and submission. + + + +```tsx +const [runtime, setRuntime] = React.useState("local") + + + + Where should the agent run? + + setRuntime("local")} + > + + Locally + + setRuntime("remote")} + > + + + Remote environment + + + + + + {/* remote-only question */} + + +``` + +## Navigation state + +Navigation actions stay enabled by default so activating Next or Submit can +show a validation error. Use the render state when you want to disable an +action yourself. + + + +```tsx + ( + + + + + + + }> + Which files are in scope? + + }> + Choose how broadly the agent can update the workspace. + + + + + Component only + + + Complete feature directory + + + Any related workspace file + + + + + + + + + }> + How much verification is needed? + + }> + Choose the checks the agent should run before handoff. + + + + + Targeted tests + + + Package tests + + + Full workspace verification + + + + + + + + Cancel + + + + Next + Send answer + + + + + + ) +} diff --git a/apps/v4/examples/aria/questionnaire-freeform.tsx b/apps/v4/examples/aria/questionnaire-freeform.tsx new file mode 100644 index 00000000000..28ae7d8ad3b --- /dev/null +++ b/apps/v4/examples/aria/questionnaire-freeform.tsx @@ -0,0 +1,79 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/aria-nova/ui/questionnaire" + +const items = [ + { + choices: [ + { value: "incremental" }, + { value: "module" }, + { value: "rewrite" }, + ], + name: "approach", + required: true, + }, +] as const + +export function QuestionnaireFreeform() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const approach = new FormData(event.currentTarget).get("approach") + + toast("Approach selected", { + description: `Approach: ${approach ?? "None"}`, + }) + } + + return ( + + + + How should the agent approach this refactor? + + + Choose a strategy or write a more specific instruction. + + + + Make the smallest safe change + + + Refactor one module at a time + + + Replace the implementation completely + + + + + + + + Use this approach + + + ) +} diff --git a/apps/v4/examples/aria/questionnaire-multiple.tsx b/apps/v4/examples/aria/questionnaire-multiple.tsx new file mode 100644 index 00000000000..5c635f73546 --- /dev/null +++ b/apps/v4/examples/aria/questionnaire-multiple.tsx @@ -0,0 +1,78 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/aria-nova/ui/questionnaire" + +const items = [ + { + choices: [ + { value: "source" }, + { value: "tests" }, + { value: "docs" }, + { value: "history" }, + ], + name: "context", + required: true, + }, +] as const + +export function QuestionnaireMultiple() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const context = new FormData(event.currentTarget).getAll("context") + + toast("Context selected", { + description: `Context: ${context.join(", ") || "None"}`, + }) + } + + return ( + + + + What context should the agent inspect? + + + Select every source that may affect the implementation. + + + + Relevant source files + + + Existing tests + + + Architecture documentation + + + Recent commit history + + + + + + + Share context + + + ) +} diff --git a/apps/v4/examples/aria/questionnaire-navigation-state.tsx b/apps/v4/examples/aria/questionnaire-navigation-state.tsx new file mode 100644 index 00000000000..ed06d122645 --- /dev/null +++ b/apps/v4/examples/aria/questionnaire-navigation-state.tsx @@ -0,0 +1,119 @@ +"use client" + +import * as React from "react" +import type { QuestionnaireItemStatus } from "@shadcn/react/questionnaire" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/aria-nova/ui/questionnaire" + +const items = [ + { name: "permission", required: true }, + { name: "verification", required: true }, +] as const + +type ItemName = "permission" | "verification" + +export function QuestionnaireNavigationState() { + const [item, setItem] = React.useState("permission") + const [statuses, setStatuses] = React.useState< + Record + >({ + permission: "unanswered", + verification: "unanswered", + }) + const unanswered = statuses[item] === "unanswered" + + function setStatus(name: ItemName, status: QuestionnaireItemStatus) { + setStatuses((current) => ({ ...current, [name]: status })) + } + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + toast("Permissions saved", { + description: `Permission: ${formData.get("permission") ?? "None"} · Verification: ${formData.get("verification") ?? "None"}`, + }) + } + + return ( + setItem(nextItem as ItemName)} + onSubmit={handleSubmit} + > + + + setStatus("permission", status)} + > + What may the agent modify? + + Next is intentionally disabled until an answer is selected. + + + Project files + + Project files and tests + + + Files, tests, and configuration + + + + + + setStatus("verification", status)} + > + + What must pass before completion? + + + Tests + + Tests and types + + + Tests, types, and visual QA + + + + + + + + + Next + + + Save permissions + + + + ) +} diff --git a/apps/v4/examples/aria/questionnaire-progress.tsx b/apps/v4/examples/aria/questionnaire-progress.tsx new file mode 100644 index 00000000000..61c66631a05 --- /dev/null +++ b/apps/v4/examples/aria/questionnaire-progress.tsx @@ -0,0 +1,139 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/aria-nova/ui/questionnaire" + +const items = [ + { name: "scope", required: true }, + { name: "strategy", required: true }, + { name: "tests", required: true }, + { name: "delivery", required: true }, +] as const + +export function QuestionnaireProgressExample() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + toast("Pull request plan ready", { + description: `Scope: ${formData.get("scope") ?? "None"} · Commits: ${formData.get("strategy") ?? "None"} · Tests: ${formData.get("tests") ?? "None"} · Delivery: ${formData.get("delivery") ?? "None"}`, + }) + } + + return ( + + ( +
+ + + Checkpoint {state.current} of {state.total} + +
+ )} + /> + + + How large is the change? + + Small patch + + Feature-sized change + + + Cross-package change + + + + + + + + How should commits be organized? + + + + Single commit + + + Logical commits + + + Squash before review + + + + + + + Which tests should run? + + + Targeted tests + + + Package suite + + + Full workspace + + + + + + + + How should the work be delivered? + + + Patch only + + Committed locally + + + Push a review branch + + + + + + + + Next + Finish plan + +
+ ) +} diff --git a/apps/v4/examples/aria/questionnaire-resume.tsx b/apps/v4/examples/aria/questionnaire-resume.tsx new file mode 100644 index 00000000000..74eaa751639 --- /dev/null +++ b/apps/v4/examples/aria/questionnaire-resume.tsx @@ -0,0 +1,115 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { Button } from "@/styles/aria-nova/ui/button" +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/aria-nova/ui/questionnaire" + +const items = [ + { name: "change", required: true }, + { name: "verification", required: true }, + { name: "notes" }, +] as const + +export function QuestionnaireResume() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + const answers = { + change: formData.get("change"), + verification: formData.getAll("verification"), + notes: formData.get("notes"), + } + + toast("Draft updated", { + description: `Migration: ${answers.change ?? "None"} · Verification: ${answers.verification.join(", ") || "None"} · Notes: ${answers.notes || "None"}`, + }) + } + + return ( + toast("Saved answers restored")} + onSubmit={handleSubmit} + > + + + + What kind of migration is this? + + This answer was saved during the previous session. + + + + Incremental migration + + + Single cutover + + + + + + + + How should the migration be verified? + + + These checks were selected during the previous session. + + + + Run migration tests + + + Run the typecheck + + + Perform a manual smoke test + + + + + + + + Anything else the agent should remember? + + + This note was saved with the draft. + + + + + + + + Next + Update draft + + + ) +} diff --git a/apps/v4/examples/aria/questionnaire-shortcuts.tsx b/apps/v4/examples/aria/questionnaire-shortcuts.tsx new file mode 100644 index 00000000000..ce210beae14 --- /dev/null +++ b/apps/v4/examples/aria/questionnaire-shortcuts.tsx @@ -0,0 +1,96 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + NativeSelect, + NativeSelectOption, +} from "@/styles/aria-nova/ui/native-select" +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/aria-nova/ui/questionnaire" + +const items = [ + { + choices: [{ value: "inspect" }, { value: "tests" }, { value: "patch" }], + name: "action", + required: true, + }, +] as const + +type ShortcutMode = React.ComponentProps["shortcuts"] + +export function QuestionnaireShortcuts() { + const [shortcuts, setShortcuts] = React.useState("letters") + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const action = new FormData(event.currentTarget).get("action") + + toast("Next action selected", { + description: `Action: ${action ?? "None"} · Shortcuts: ${shortcuts ?? "none"}`, + }) + } + + return ( +
+ { + const value = event.target.value + setShortcuts( + value === "letters" || value === "numbers" ? value : undefined + ) + }} + > + No shortcuts + Letters + Numbers + + + + + + What should the agent do next? + + + Use the displayed shortcut or navigate with the keyboard. + + + + Inspect the implementation + + + Run the relevant tests + + + Prepare the patch + + + + + + + Confirm action + + +
+ ) +} diff --git a/apps/v4/examples/aria/questionnaire-skip.tsx b/apps/v4/examples/aria/questionnaire-skip.tsx new file mode 100644 index 00000000000..a7be2edaa27 --- /dev/null +++ b/apps/v4/examples/aria/questionnaire-skip.tsx @@ -0,0 +1,132 @@ +"use client" + +import * as React from "react" +import type { QuestionnaireItemStatus } from "@shadcn/react/questionnaire" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/aria-nova/ui/questionnaire" + +const items = [ + { name: "task", required: true }, + { name: "constraints" }, + { name: "review", required: true }, +] as const + +export function QuestionnaireSkipExample() { + const [constraintStatus, setConstraintStatus] = + React.useState("unanswered") + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + const answers = { + task: formData.get("task"), + constraints: formData.get("constraints"), + constraintStatus, + review: formData.get("review"), + } + + toast("Agent brief submitted", { + description: `Task: ${answers.task ?? "None"} · Constraints: ${ + answers.constraintStatus === "skipped" + ? "Skipped" + : (answers.constraints ?? "None") + } · Review: ${answers.review ?? "None"}`, + }) + } + + return ( + + + + + What kind of change is this? + + Choose the category that best describes the work. + + + New feature + Bug fix + Refactor + + + + + + + Are there any implementation constraints? + + + Answer if needed, or intentionally skip this question. + + + + Do not add dependencies + + + Do not change the database + + + Preserve the public API + + + + + + + + How should the work be reviewed? + + + Choose the checks the agent should complete before handoff. + + + + Run the test suite + + + Review the final diff + + + Tests and diff review + + + + + + + + + Next + Submit brief + + + ) +} diff --git a/apps/v4/examples/aria/questionnaire-validation.tsx b/apps/v4/examples/aria/questionnaire-validation.tsx new file mode 100644 index 00000000000..f9d6e09b899 --- /dev/null +++ b/apps/v4/examples/aria/questionnaire-validation.tsx @@ -0,0 +1,203 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" +import { z } from "zod" + +import { + Card, + CardAction, + CardContent, + CardFooter, + CardHeader, +} from "@/styles/aria-nova/ui/card" +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/aria-nova/ui/questionnaire" + +const items = [ + { name: "detail", required: true }, + { name: "audience", required: true }, +] as const + +const questionnaireSchema = z + .object({ + detail: z.enum(["summary", "complete"]), + audience: z.enum(["team", "public"]), + }) + .superRefine((answers, context) => { + if (answers.audience === "public" && answers.detail === "summary") { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Public answers need enough context. Choose a complete answer.", + path: ["detail"], + }) + } + }) + +type QuestionnaireItemName = keyof z.infer +type QuestionnaireErrors = Partial> + +function ValidationProgress() { + return ( + ( +
+ {state.current} / {state.total} +
+ )} + /> + ) +} + +export function QuestionnaireValidation() { + const [item, setItem] = React.useState("detail") + const [errors, setErrors] = React.useState({}) + + function clearError(name: QuestionnaireItemName) { + setErrors((currentErrors) => { + if (!currentErrors[name]) { + return currentErrors + } + + const nextErrors = { ...currentErrors } + delete nextErrors[name] + return nextErrors + }) + } + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const result = questionnaireSchema.safeParse( + Object.fromEntries(new FormData(event.currentTarget)) + ) + + if (result.success) { + setErrors({}) + toast("Agent response configured", { + description: `Detail: ${result.data.detail} · Audience: ${result.data.audience}`, + }) + return + } + + const nextErrors: QuestionnaireErrors = {} + + for (const issue of result.error.issues) { + const name = issue.path[0] + + if ((name === "detail" || name === "audience") && !nextErrors[name]) { + nextErrors[name] = issue.message + } + } + + const firstInvalidItem = result.error.issues[0]?.path[0] + + setErrors(nextErrors) + + if (firstInvalidItem === "detail" || firstInvalidItem === "audience") { + setItem(firstInvalidItem) + } + } + + return ( + + + + + + How much detail should the answer include? + + + Choose the response depth. + + + + + + + + clearError("detail")} + > + Concise summary + + clearError("detail")} + > + Complete answer + + + {errors.detail} + + + + + + Who will read the answer? + + Public answers require complete context. + + + + + + + + clearError("audience")} + > + My team + + clearError("audience")} + > + Public audience + + + {errors.audience} + + + + + + + Next + Validate answers + + + + + ) +} diff --git a/apps/v4/examples/base/questionnaire-animated.tsx b/apps/v4/examples/base/questionnaire-animated.tsx new file mode 100644 index 00000000000..aa9db672464 --- /dev/null +++ b/apps/v4/examples/base/questionnaire-animated.tsx @@ -0,0 +1,118 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/base-nova/ui/questionnaire" + +const items = [ + { name: "task", required: true }, + { name: "review", required: true }, + { name: "delivery", required: true }, +] as const + +const itemClassName = + "data-active:animate-in data-active:fade-in-0 data-active:slide-in-from-bottom-2 data-active:duration-300 motion-reduce:animate-none" + +export function QuestionnaireAnimated() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + toast("Agent workflow saved", { + description: `Task: ${formData.get("task") ?? "None"} · Review: ${formData.get("review") ?? "None"} · Delivery: ${formData.get("delivery") ?? "None"}`, + }) + } + + return ( + + + + + What should the agent do? + + Choose the task for this run. + + + + Implement the requested change + + + Debug the current behavior + + + Review the implementation + + + + + + + + How should the work be reviewed? + + + Select the verification depth. + + + + Targeted checks + + + Complete test suite + + + Tests and manual QA + + + + + + + + How should the result be delivered? + + + Choose the final handoff format. + + + + Concise summary + + + Summary and changed files + + + Detailed review handoff + + + + + + + + Next + Save workflow + + + ) +} diff --git a/apps/v4/examples/base/questionnaire-card.tsx b/apps/v4/examples/base/questionnaire-card.tsx new file mode 100644 index 00000000000..3e2d63afac0 --- /dev/null +++ b/apps/v4/examples/base/questionnaire-card.tsx @@ -0,0 +1,136 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/styles/base-nova/ui/card" +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/base-nova/ui/questionnaire" + +const items = [ + { + choices: [{ value: "fix" }, { value: "refactor" }, { value: "docs" }], + name: "task", + required: true, + }, + { + choices: [{ value: "summary" }, { value: "files" }, { value: "review" }], + name: "output", + required: true, + }, +] as const + +export function QuestionnaireCard() { + const taskTitleId = React.useId() + const outputTitleId = React.useId() + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + toast("Agent task created", { + description: `Task: ${formData.get("task") ?? "None"} · Handoff: ${formData.get("output") ?? "None"}`, + }) + } + + return ( + + + + + }> + What should the agent work on? + + }> + Choose the task that should be handled next. + + + + + + + + + Fix the failing tests + + + Refactor the data layer + + + Update the integration guide + + + + + + + + + }> + What should the final handoff include? + + }> + Pick the level of detail needed for review. + + + + + + + + + Summary only + + + Summary and changed files + + + Full review handoff + + + + + + + + + + Next + Create task + + + + + ) +} diff --git a/apps/v4/examples/base/questionnaire-conditional.tsx b/apps/v4/examples/base/questionnaire-conditional.tsx new file mode 100644 index 00000000000..29045f572c3 --- /dev/null +++ b/apps/v4/examples/base/questionnaire-conditional.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/base-nova/ui/questionnaire" + +export function QuestionnaireConditional() { + const [runtime, setRuntime] = React.useState("local") + const items = React.useMemo( + () => [ + { name: "runtime", required: true }, + { + disabled: runtime !== "cloud", + name: "environment", + required: true, + }, + { name: "approval", required: true }, + ], + [runtime] + ) + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + toast("Execution plan saved", { + description: `Runtime: ${formData.get("runtime") ?? "None"} · Environment: ${formData.get("environment") ?? "Not applicable"} · Approval: ${formData.get("approval") ?? "None"}`, + }) + } + + return ( + + + + + Where should the agent run? + + Cloud runs add an environment question to this flow. + + + setRuntime("local")} + > + Local workspace + + setRuntime("cloud")} + > + Cloud workspace + + + + + + + + Which cloud environment should it use? + + + Preview + Staging + + Isolated sandbox + + + + + + + + When should the agent request approval? + + + + Before writing files + + + Before running commands + + + Only for sensitive actions + + + + + + + + Next + Save execution plan + + + ) +} diff --git a/apps/v4/examples/base/questionnaire-controlled.tsx b/apps/v4/examples/base/questionnaire-controlled.tsx new file mode 100644 index 00000000000..698a685de0f --- /dev/null +++ b/apps/v4/examples/base/questionnaire-controlled.tsx @@ -0,0 +1,127 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/base-nova/ui/questionnaire" + +const items = [ + { name: "scope", required: true }, + { name: "checks", required: true }, + { name: "output", required: true }, +] as const + +const itemLabels: Record = { + scope: "Change scope", + checks: "Verification", + output: "Final output", +} + +export function QuestionnaireControlled() { + const [item, setItem] = React.useState("scope") + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + toast("Agent workflow configured", { + description: `Scope: ${formData.get("scope") ?? "None"} · Verification: ${formData.get("checks") ?? "None"} · Output: ${formData.get("output") ?? "None"}`, + }) + } + + return ( +
+

+ Current checkpoint: {itemLabels[item]} +

+ + + + + + What may the agent change? + + The host stores the active checkpoint while Questionnaire navigates. + + + + Only the target component + + + Component and related tests + + + The complete feature area + + + + + + + + Which verification level should it use? + + + + Targeted tests + + + Package tests and typecheck + + + Full workspace verification + + + + + + + + What should the agent return when finished? + + + + Concise summary + + + Summary with changed files + + + Detailed implementation handoff + + + + + + + + Next + Save workflow + + +
+ ) +} diff --git a/apps/v4/examples/base/questionnaire-demo.tsx b/apps/v4/examples/base/questionnaire-demo.tsx new file mode 100644 index 00000000000..9482a5894f3 --- /dev/null +++ b/apps/v4/examples/base/questionnaire-demo.tsx @@ -0,0 +1,142 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/base-nova/ui/questionnaire" + +const questionnaireItems = [ + { + choices: [ + { + description: "Show what the agent ran and what came back.", + label: "Tool call timeline", + value: "tool-calls", + }, + { + description: "Ask before sensitive or destructive actions.", + label: "Approval checkpoints", + value: "approvals", + }, + { + description: "Make delegated work and results easier to follow.", + label: "Sub-agent handoffs", + value: "handoffs", + }, + ], + description: "Choose a direction or describe another task.", + input: { + label: "Another agent feature", + placeholder: "Describe another feature…", + }, + name: "direction", + required: true, + title: "What should the agent build next?", + }, + { + choices: [ + { label: "Progress", value: "progress" }, + { label: "Decisions", value: "decisions" }, + { label: "Risks", value: "risks" }, + { label: "Next step", value: "next-step" }, + ], + description: "Select all that apply, or skip this question.", + multiple: true, + name: "signals", + required: false, + title: "What should every progress update include?", + }, + { + choices: [ + { label: "Start now", value: "now" }, + { label: "Next development cycle", value: "next-cycle" }, + { label: "Add it to the backlog", value: "backlog" }, + ], + description: "Choose when the agent should begin the work.", + name: "timing", + required: true, + title: "When should work begin?", + }, +] as const + +export function QuestionnaireDemo() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + const answers = { + direction: formData.get("direction"), + signals: formData.getAll("signals"), + timing: formData.get("timing"), + } + + toast("Agent plan saved", { + description: `Direction: ${answers.direction ?? "None"} · Progress signals: ${answers.signals.join(", ") || "None"} · Timing: ${answers.timing ?? "None"}`, + }) + } + + return ( + + + {questionnaireItems.map((question) => ( + + {question.title} + + {question.description} + + + {question.choices.map((choice) => ( + + {choice.label} + {"description" in choice ? ( + + {choice.description} + + ) : null} + + ))} + {"input" in question ? ( + + ) : null} + + + + ))} + + + + Next + Save plan + + + ) +} diff --git a/apps/v4/examples/base/questionnaire-dialog.tsx b/apps/v4/examples/base/questionnaire-dialog.tsx new file mode 100644 index 00000000000..71ecc845b72 --- /dev/null +++ b/apps/v4/examples/base/questionnaire-dialog.tsx @@ -0,0 +1,124 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { Button } from "@/styles/base-nova/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/styles/base-nova/ui/dialog" +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/base-nova/ui/questionnaire" + +const items = [ + { name: "scope", required: true }, + { name: "tests", required: true }, +] as const + +export function QuestionnaireDialog() { + const [open, setOpen] = React.useState(false) + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + setOpen(false) + toast("Clarification sent", { + description: `Scope: ${formData.get("scope") ?? "None"} · Verification: ${formData.get("tests") ?? "None"}`, + }) + } + + return ( + + }> + Open clarification + + + + + + + }> + Which files are in scope? + + }> + Choose how broadly the agent can update the workspace. + + + + + Component only + + + Complete feature directory + + + Any related workspace file + + + + + + + + + }> + How much verification is needed? + + }> + Choose the checks the agent should run before handoff. + + + + + Targeted tests + + + Package tests + + + Full workspace verification + + + + + + + }> + Cancel + + + + Next + Send answer + + + + + + ) +} diff --git a/apps/v4/examples/base/questionnaire-freeform.tsx b/apps/v4/examples/base/questionnaire-freeform.tsx new file mode 100644 index 00000000000..29899c34c7a --- /dev/null +++ b/apps/v4/examples/base/questionnaire-freeform.tsx @@ -0,0 +1,79 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/base-nova/ui/questionnaire" + +const items = [ + { + choices: [ + { value: "incremental" }, + { value: "module" }, + { value: "rewrite" }, + ], + name: "approach", + required: true, + }, +] as const + +export function QuestionnaireFreeform() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const approach = new FormData(event.currentTarget).get("approach") + + toast("Approach selected", { + description: `Approach: ${approach ?? "None"}`, + }) + } + + return ( + + + + How should the agent approach this refactor? + + + Choose a strategy or write a more specific instruction. + + + + Make the smallest safe change + + + Refactor one module at a time + + + Replace the implementation completely + + + + + + + + Use this approach + + + ) +} diff --git a/apps/v4/examples/base/questionnaire-multiple.tsx b/apps/v4/examples/base/questionnaire-multiple.tsx new file mode 100644 index 00000000000..b27c109fb5f --- /dev/null +++ b/apps/v4/examples/base/questionnaire-multiple.tsx @@ -0,0 +1,78 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/base-nova/ui/questionnaire" + +const items = [ + { + choices: [ + { value: "source" }, + { value: "tests" }, + { value: "docs" }, + { value: "history" }, + ], + name: "context", + required: true, + }, +] as const + +export function QuestionnaireMultiple() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const context = new FormData(event.currentTarget).getAll("context") + + toast("Context selected", { + description: `Context: ${context.join(", ") || "None"}`, + }) + } + + return ( + + + + What context should the agent inspect? + + + Select every source that may affect the implementation. + + + + Relevant source files + + + Existing tests + + + Architecture documentation + + + Recent commit history + + + + + + + Share context + + + ) +} diff --git a/apps/v4/examples/base/questionnaire-navigation-state.tsx b/apps/v4/examples/base/questionnaire-navigation-state.tsx new file mode 100644 index 00000000000..8c503ea5405 --- /dev/null +++ b/apps/v4/examples/base/questionnaire-navigation-state.tsx @@ -0,0 +1,119 @@ +"use client" + +import * as React from "react" +import type { QuestionnaireItemStatus } from "@shadcn/react/questionnaire" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/base-nova/ui/questionnaire" + +const items = [ + { name: "permission", required: true }, + { name: "verification", required: true }, +] as const + +type ItemName = "permission" | "verification" + +export function QuestionnaireNavigationState() { + const [item, setItem] = React.useState("permission") + const [statuses, setStatuses] = React.useState< + Record + >({ + permission: "unanswered", + verification: "unanswered", + }) + const unanswered = statuses[item] === "unanswered" + + function setStatus(name: ItemName, status: QuestionnaireItemStatus) { + setStatuses((current) => ({ ...current, [name]: status })) + } + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + toast("Permissions saved", { + description: `Permission: ${formData.get("permission") ?? "None"} · Verification: ${formData.get("verification") ?? "None"}`, + }) + } + + return ( + setItem(nextItem as ItemName)} + onSubmit={handleSubmit} + > + + + setStatus("permission", status)} + > + What may the agent modify? + + Next is intentionally disabled until an answer is selected. + + + Project files + + Project files and tests + + + Files, tests, and configuration + + + + + + setStatus("verification", status)} + > + + What must pass before completion? + + + Tests + + Tests and types + + + Tests, types, and visual QA + + + + + + + + + Next + + + Save permissions + + + + ) +} diff --git a/apps/v4/examples/base/questionnaire-progress.tsx b/apps/v4/examples/base/questionnaire-progress.tsx new file mode 100644 index 00000000000..19f65f77471 --- /dev/null +++ b/apps/v4/examples/base/questionnaire-progress.tsx @@ -0,0 +1,139 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/base-nova/ui/questionnaire" + +const items = [ + { name: "scope", required: true }, + { name: "strategy", required: true }, + { name: "tests", required: true }, + { name: "delivery", required: true }, +] as const + +export function QuestionnaireProgressExample() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + toast("Pull request plan ready", { + description: `Scope: ${formData.get("scope") ?? "None"} · Commits: ${formData.get("strategy") ?? "None"} · Tests: ${formData.get("tests") ?? "None"} · Delivery: ${formData.get("delivery") ?? "None"}`, + }) + } + + return ( + + ( +
+ + + Checkpoint {state.current} of {state.total} + +
+ )} + /> + + + How large is the change? + + Small patch + + Feature-sized change + + + Cross-package change + + + + + + + + How should commits be organized? + + + + Single commit + + + Logical commits + + + Squash before review + + + + + + + Which tests should run? + + + Targeted tests + + + Package suite + + + Full workspace + + + + + + + + How should the work be delivered? + + + Patch only + + Committed locally + + + Push a review branch + + + + + + + + Next + Finish plan + +
+ ) +} diff --git a/apps/v4/examples/base/questionnaire-resume.tsx b/apps/v4/examples/base/questionnaire-resume.tsx new file mode 100644 index 00000000000..ad67100cfc4 --- /dev/null +++ b/apps/v4/examples/base/questionnaire-resume.tsx @@ -0,0 +1,115 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { Button } from "@/styles/base-nova/ui/button" +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/base-nova/ui/questionnaire" + +const items = [ + { name: "change", required: true }, + { name: "verification", required: true }, + { name: "notes" }, +] as const + +export function QuestionnaireResume() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + const answers = { + change: formData.get("change"), + verification: formData.getAll("verification"), + notes: formData.get("notes"), + } + + toast("Draft updated", { + description: `Migration: ${answers.change ?? "None"} · Verification: ${answers.verification.join(", ") || "None"} · Notes: ${answers.notes || "None"}`, + }) + } + + return ( + toast("Saved answers restored")} + onSubmit={handleSubmit} + > + + + + What kind of migration is this? + + This answer was saved during the previous session. + + + + Incremental migration + + + Single cutover + + + + + + + + How should the migration be verified? + + + These checks were selected during the previous session. + + + + Run migration tests + + + Run the typecheck + + + Perform a manual smoke test + + + + + + + + Anything else the agent should remember? + + + This note was saved with the draft. + + + + + + + + Next + Update draft + + + ) +} diff --git a/apps/v4/examples/base/questionnaire-shortcuts.tsx b/apps/v4/examples/base/questionnaire-shortcuts.tsx new file mode 100644 index 00000000000..395d3239380 --- /dev/null +++ b/apps/v4/examples/base/questionnaire-shortcuts.tsx @@ -0,0 +1,96 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + NativeSelect, + NativeSelectOption, +} from "@/styles/base-nova/ui/native-select" +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/base-nova/ui/questionnaire" + +const items = [ + { + choices: [{ value: "inspect" }, { value: "tests" }, { value: "patch" }], + name: "action", + required: true, + }, +] as const + +type ShortcutMode = React.ComponentProps["shortcuts"] + +export function QuestionnaireShortcuts() { + const [shortcuts, setShortcuts] = React.useState("letters") + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const action = new FormData(event.currentTarget).get("action") + + toast("Next action selected", { + description: `Action: ${action ?? "None"} · Shortcuts: ${shortcuts ?? "none"}`, + }) + } + + return ( +
+ { + const value = event.target.value + setShortcuts( + value === "letters" || value === "numbers" ? value : undefined + ) + }} + > + No shortcuts + Letters + Numbers + + + + + + What should the agent do next? + + + Use the displayed shortcut or navigate with the keyboard. + + + + Inspect the implementation + + + Run the relevant tests + + + Prepare the patch + + + + + + + Confirm action + + +
+ ) +} diff --git a/apps/v4/examples/base/questionnaire-skip.tsx b/apps/v4/examples/base/questionnaire-skip.tsx new file mode 100644 index 00000000000..0407ae4ccf1 --- /dev/null +++ b/apps/v4/examples/base/questionnaire-skip.tsx @@ -0,0 +1,132 @@ +"use client" + +import * as React from "react" +import type { QuestionnaireItemStatus } from "@shadcn/react/questionnaire" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/base-nova/ui/questionnaire" + +const items = [ + { name: "task", required: true }, + { name: "constraints" }, + { name: "review", required: true }, +] as const + +export function QuestionnaireSkipExample() { + const [constraintStatus, setConstraintStatus] = + React.useState("unanswered") + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + const answers = { + task: formData.get("task"), + constraints: formData.get("constraints"), + constraintStatus, + review: formData.get("review"), + } + + toast("Agent brief submitted", { + description: `Task: ${answers.task ?? "None"} · Constraints: ${ + answers.constraintStatus === "skipped" + ? "Skipped" + : (answers.constraints ?? "None") + } · Review: ${answers.review ?? "None"}`, + }) + } + + return ( + + + + + What kind of change is this? + + Choose the category that best describes the work. + + + New feature + Bug fix + Refactor + + + + + + + Are there any implementation constraints? + + + Answer if needed, or intentionally skip this question. + + + + Do not add dependencies + + + Do not change the database + + + Preserve the public API + + + + + + + + How should the work be reviewed? + + + Choose the checks the agent should complete before handoff. + + + + Run the test suite + + + Review the final diff + + + Tests and diff review + + + + + + + + + Next + Submit brief + + + ) +} diff --git a/apps/v4/examples/base/questionnaire-validation.tsx b/apps/v4/examples/base/questionnaire-validation.tsx new file mode 100644 index 00000000000..eb7321cd23a --- /dev/null +++ b/apps/v4/examples/base/questionnaire-validation.tsx @@ -0,0 +1,203 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" +import { z } from "zod" + +import { + Card, + CardAction, + CardContent, + CardFooter, + CardHeader, +} from "@/styles/base-nova/ui/card" +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/base-nova/ui/questionnaire" + +const items = [ + { name: "detail", required: true }, + { name: "audience", required: true }, +] as const + +const questionnaireSchema = z + .object({ + detail: z.enum(["summary", "complete"]), + audience: z.enum(["team", "public"]), + }) + .superRefine((answers, context) => { + if (answers.audience === "public" && answers.detail === "summary") { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Public answers need enough context. Choose a complete answer.", + path: ["detail"], + }) + } + }) + +type QuestionnaireItemName = keyof z.infer +type QuestionnaireErrors = Partial> + +function ValidationProgress() { + return ( + ( +
+ {state.current} / {state.total} +
+ )} + /> + ) +} + +export function QuestionnaireValidation() { + const [item, setItem] = React.useState("detail") + const [errors, setErrors] = React.useState({}) + + function clearError(name: QuestionnaireItemName) { + setErrors((currentErrors) => { + if (!currentErrors[name]) { + return currentErrors + } + + const nextErrors = { ...currentErrors } + delete nextErrors[name] + return nextErrors + }) + } + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const result = questionnaireSchema.safeParse( + Object.fromEntries(new FormData(event.currentTarget)) + ) + + if (result.success) { + setErrors({}) + toast("Agent response configured", { + description: `Detail: ${result.data.detail} · Audience: ${result.data.audience}`, + }) + return + } + + const nextErrors: QuestionnaireErrors = {} + + for (const issue of result.error.issues) { + const name = issue.path[0] + + if ((name === "detail" || name === "audience") && !nextErrors[name]) { + nextErrors[name] = issue.message + } + } + + const firstInvalidItem = result.error.issues[0]?.path[0] + + setErrors(nextErrors) + + if (firstInvalidItem === "detail" || firstInvalidItem === "audience") { + setItem(firstInvalidItem) + } + } + + return ( + + + + + + How much detail should the answer include? + + + Choose the response depth. + + + + + + + + clearError("detail")} + > + Concise summary + + clearError("detail")} + > + Complete answer + + + {errors.detail} + + + + + + Who will read the answer? + + Public answers require complete context. + + + + + + + + clearError("audience")} + > + My team + + clearError("audience")} + > + Public audience + + + {errors.audience} + + + + + + + Next + Validate answers + + + + + ) +} diff --git a/apps/v4/examples/radix/questionnaire-animated.tsx b/apps/v4/examples/radix/questionnaire-animated.tsx new file mode 100644 index 00000000000..07a57351fc9 --- /dev/null +++ b/apps/v4/examples/radix/questionnaire-animated.tsx @@ -0,0 +1,118 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/radix-nova/ui/questionnaire" + +const items = [ + { name: "task", required: true }, + { name: "review", required: true }, + { name: "delivery", required: true }, +] as const + +const itemClassName = + "data-active:animate-in data-active:fade-in-0 data-active:slide-in-from-bottom-2 data-active:duration-300 motion-reduce:animate-none" + +export function QuestionnaireAnimated() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + toast("Agent workflow saved", { + description: `Task: ${formData.get("task") ?? "None"} · Review: ${formData.get("review") ?? "None"} · Delivery: ${formData.get("delivery") ?? "None"}`, + }) + } + + return ( + + + + + What should the agent do? + + Choose the task for this run. + + + + Implement the requested change + + + Debug the current behavior + + + Review the implementation + + + + + + + + How should the work be reviewed? + + + Select the verification depth. + + + + Targeted checks + + + Complete test suite + + + Tests and manual QA + + + + + + + + How should the result be delivered? + + + Choose the final handoff format. + + + + Concise summary + + + Summary and changed files + + + Detailed review handoff + + + + + + + + Next + Save workflow + + + ) +} diff --git a/apps/v4/examples/radix/questionnaire-card.tsx b/apps/v4/examples/radix/questionnaire-card.tsx new file mode 100644 index 00000000000..42032f65330 --- /dev/null +++ b/apps/v4/examples/radix/questionnaire-card.tsx @@ -0,0 +1,136 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/styles/radix-nova/ui/card" +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/radix-nova/ui/questionnaire" + +const items = [ + { + choices: [{ value: "fix" }, { value: "refactor" }, { value: "docs" }], + name: "task", + required: true, + }, + { + choices: [{ value: "summary" }, { value: "files" }, { value: "review" }], + name: "output", + required: true, + }, +] as const + +export function QuestionnaireCard() { + const taskTitleId = React.useId() + const outputTitleId = React.useId() + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + toast("Agent task created", { + description: `Task: ${formData.get("task") ?? "None"} · Handoff: ${formData.get("output") ?? "None"}`, + }) + } + + return ( + + + + + }> + What should the agent work on? + + }> + Choose the task that should be handled next. + + + + + + + + + Fix the failing tests + + + Refactor the data layer + + + Update the integration guide + + + + + + + + + }> + What should the final handoff include? + + }> + Pick the level of detail needed for review. + + + + + + + + + Summary only + + + Summary and changed files + + + Full review handoff + + + + + + + + + + Next + Create task + + + + + ) +} diff --git a/apps/v4/examples/radix/questionnaire-conditional.tsx b/apps/v4/examples/radix/questionnaire-conditional.tsx new file mode 100644 index 00000000000..5b7b885fac9 --- /dev/null +++ b/apps/v4/examples/radix/questionnaire-conditional.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/radix-nova/ui/questionnaire" + +export function QuestionnaireConditional() { + const [runtime, setRuntime] = React.useState("local") + const items = React.useMemo( + () => [ + { name: "runtime", required: true }, + { + disabled: runtime !== "cloud", + name: "environment", + required: true, + }, + { name: "approval", required: true }, + ], + [runtime] + ) + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + toast("Execution plan saved", { + description: `Runtime: ${formData.get("runtime") ?? "None"} · Environment: ${formData.get("environment") ?? "Not applicable"} · Approval: ${formData.get("approval") ?? "None"}`, + }) + } + + return ( + + + + + Where should the agent run? + + Cloud runs add an environment question to this flow. + + + setRuntime("local")} + > + Local workspace + + setRuntime("cloud")} + > + Cloud workspace + + + + + + + + Which cloud environment should it use? + + + Preview + Staging + + Isolated sandbox + + + + + + + + When should the agent request approval? + + + + Before writing files + + + Before running commands + + + Only for sensitive actions + + + + + + + + Next + Save execution plan + + + ) +} diff --git a/apps/v4/examples/radix/questionnaire-controlled.tsx b/apps/v4/examples/radix/questionnaire-controlled.tsx new file mode 100644 index 00000000000..f9b3a5dd263 --- /dev/null +++ b/apps/v4/examples/radix/questionnaire-controlled.tsx @@ -0,0 +1,127 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/radix-nova/ui/questionnaire" + +const items = [ + { name: "scope", required: true }, + { name: "checks", required: true }, + { name: "output", required: true }, +] as const + +const itemLabels: Record = { + scope: "Change scope", + checks: "Verification", + output: "Final output", +} + +export function QuestionnaireControlled() { + const [item, setItem] = React.useState("scope") + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + toast("Agent workflow configured", { + description: `Scope: ${formData.get("scope") ?? "None"} · Verification: ${formData.get("checks") ?? "None"} · Output: ${formData.get("output") ?? "None"}`, + }) + } + + return ( +
+

+ Current checkpoint: {itemLabels[item]} +

+ + + + + + What may the agent change? + + The host stores the active checkpoint while Questionnaire navigates. + + + + Only the target component + + + Component and related tests + + + The complete feature area + + + + + + + + Which verification level should it use? + + + + Targeted tests + + + Package tests and typecheck + + + Full workspace verification + + + + + + + + What should the agent return when finished? + + + + Concise summary + + + Summary with changed files + + + Detailed implementation handoff + + + + + + + + Next + Save workflow + + +
+ ) +} diff --git a/apps/v4/examples/radix/questionnaire-demo.tsx b/apps/v4/examples/radix/questionnaire-demo.tsx new file mode 100644 index 00000000000..3f70b9a692e --- /dev/null +++ b/apps/v4/examples/radix/questionnaire-demo.tsx @@ -0,0 +1,150 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/radix-nova/ui/questionnaire" + +const items = [ + { + choices: [ + { value: "tool-calls" }, + { value: "approvals" }, + { value: "handoffs" }, + ], + name: "direction", + required: true, + }, + { + choices: [ + { value: "progress" }, + { value: "decisions" }, + { value: "risks" }, + { value: "next-step" }, + ], + name: "signals", + }, + { + choices: [{ value: "now" }, { value: "next-cycle" }, { value: "backlog" }], + name: "timing", + required: true, + }, +] as const + +export function QuestionnaireDemo() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + const answers = { + direction: formData.get("direction"), + signals: formData.getAll("signals"), + timing: formData.get("timing"), + } + + toast("Agent plan saved", { + description: `Direction: ${answers.direction ?? "None"} · Progress signals: ${answers.signals.join(", ") || "None"} · Timing: ${answers.timing ?? "None"}`, + }) + } + + return ( + + + + + + What should the agent build next? + + + Choose a direction or describe another task. + + + + Tool call timeline + + Show what the agent ran and what came back. + + + + Approval checkpoints + + Ask before sensitive or destructive actions. + + + + Sub-agent handoffs + + Make delegated work and results easier to follow. + + + + + + + + + + What should every progress update include? + + + Select all that apply, or skip this question. + + + Progress + Decisions + Risks + Next step + + + + + + When should work begin? + + Choose when the agent should begin the work. + + + Start now + + Next development cycle + + + Add it to the backlog + + + + + + + + + Next + Save plan + + + ) +} diff --git a/apps/v4/examples/radix/questionnaire-dialog.tsx b/apps/v4/examples/radix/questionnaire-dialog.tsx new file mode 100644 index 00000000000..d0a72ffb11c --- /dev/null +++ b/apps/v4/examples/radix/questionnaire-dialog.tsx @@ -0,0 +1,126 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { Button } from "@/styles/radix-nova/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/styles/radix-nova/ui/dialog" +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/radix-nova/ui/questionnaire" + +const items = [ + { name: "scope", required: true }, + { name: "tests", required: true }, +] as const + +export function QuestionnaireDialog() { + const [open, setOpen] = React.useState(false) + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + setOpen(false) + toast("Clarification sent", { + description: `Scope: ${formData.get("scope") ?? "None"} · Verification: ${formData.get("tests") ?? "None"}`, + }) + } + + return ( + + + + + + + + + + }> + Which files are in scope? + + }> + Choose how broadly the agent can update the workspace. + + + + + Component only + + + Complete feature directory + + + Any related workspace file + + + + + + + + + }> + How much verification is needed? + + }> + Choose the checks the agent should run before handoff. + + + + + Targeted tests + + + Package tests + + + Full workspace verification + + + + + + + + + + + + Next + Send answer + + + + + + ) +} diff --git a/apps/v4/examples/radix/questionnaire-freeform.tsx b/apps/v4/examples/radix/questionnaire-freeform.tsx new file mode 100644 index 00000000000..3eecab5cb81 --- /dev/null +++ b/apps/v4/examples/radix/questionnaire-freeform.tsx @@ -0,0 +1,79 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/radix-nova/ui/questionnaire" + +const items = [ + { + choices: [ + { value: "incremental" }, + { value: "module" }, + { value: "rewrite" }, + ], + name: "approach", + required: true, + }, +] as const + +export function QuestionnaireFreeform() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const approach = new FormData(event.currentTarget).get("approach") + + toast("Approach selected", { + description: `Approach: ${approach ?? "None"}`, + }) + } + + return ( + + + + How should the agent approach this refactor? + + + Choose a strategy or write a more specific instruction. + + + + Make the smallest safe change + + + Refactor one module at a time + + + Replace the implementation completely + + + + + + + + Use this approach + + + ) +} diff --git a/apps/v4/examples/radix/questionnaire-multiple.tsx b/apps/v4/examples/radix/questionnaire-multiple.tsx new file mode 100644 index 00000000000..a33dff070d9 --- /dev/null +++ b/apps/v4/examples/radix/questionnaire-multiple.tsx @@ -0,0 +1,78 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/radix-nova/ui/questionnaire" + +const items = [ + { + choices: [ + { value: "source" }, + { value: "tests" }, + { value: "docs" }, + { value: "history" }, + ], + name: "context", + required: true, + }, +] as const + +export function QuestionnaireMultiple() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const context = new FormData(event.currentTarget).getAll("context") + + toast("Context selected", { + description: `Context: ${context.join(", ") || "None"}`, + }) + } + + return ( + + + + What context should the agent inspect? + + + Select every source that may affect the implementation. + + + + Relevant source files + + + Existing tests + + + Architecture documentation + + + Recent commit history + + + + + + + Share context + + + ) +} diff --git a/apps/v4/examples/radix/questionnaire-navigation-state.tsx b/apps/v4/examples/radix/questionnaire-navigation-state.tsx new file mode 100644 index 00000000000..2c681d45238 --- /dev/null +++ b/apps/v4/examples/radix/questionnaire-navigation-state.tsx @@ -0,0 +1,119 @@ +"use client" + +import * as React from "react" +import type { QuestionnaireItemStatus } from "@shadcn/react/questionnaire" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/radix-nova/ui/questionnaire" + +const items = [ + { name: "permission", required: true }, + { name: "verification", required: true }, +] as const + +type ItemName = "permission" | "verification" + +export function QuestionnaireNavigationState() { + const [item, setItem] = React.useState("permission") + const [statuses, setStatuses] = React.useState< + Record + >({ + permission: "unanswered", + verification: "unanswered", + }) + const unanswered = statuses[item] === "unanswered" + + function setStatus(name: ItemName, status: QuestionnaireItemStatus) { + setStatuses((current) => ({ ...current, [name]: status })) + } + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + toast("Permissions saved", { + description: `Permission: ${formData.get("permission") ?? "None"} · Verification: ${formData.get("verification") ?? "None"}`, + }) + } + + return ( + setItem(nextItem as ItemName)} + onSubmit={handleSubmit} + > + + + setStatus("permission", status)} + > + What may the agent modify? + + Next is intentionally disabled until an answer is selected. + + + Project files + + Project files and tests + + + Files, tests, and configuration + + + + + + setStatus("verification", status)} + > + + What must pass before completion? + + + Tests + + Tests and types + + + Tests, types, and visual QA + + + + + + + + + Next + + + Save permissions + + + + ) +} diff --git a/apps/v4/examples/radix/questionnaire-progress.tsx b/apps/v4/examples/radix/questionnaire-progress.tsx new file mode 100644 index 00000000000..3fa99481781 --- /dev/null +++ b/apps/v4/examples/radix/questionnaire-progress.tsx @@ -0,0 +1,139 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/radix-nova/ui/questionnaire" + +const items = [ + { name: "scope", required: true }, + { name: "strategy", required: true }, + { name: "tests", required: true }, + { name: "delivery", required: true }, +] as const + +export function QuestionnaireProgressExample() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + + toast("Pull request plan ready", { + description: `Scope: ${formData.get("scope") ?? "None"} · Commits: ${formData.get("strategy") ?? "None"} · Tests: ${formData.get("tests") ?? "None"} · Delivery: ${formData.get("delivery") ?? "None"}`, + }) + } + + return ( + + ( +
+ + + Checkpoint {state.current} of {state.total} + +
+ )} + /> + + + How large is the change? + + Small patch + + Feature-sized change + + + Cross-package change + + + + + + + + How should commits be organized? + + + + Single commit + + + Logical commits + + + Squash before review + + + + + + + Which tests should run? + + + Targeted tests + + + Package suite + + + Full workspace + + + + + + + + How should the work be delivered? + + + Patch only + + Committed locally + + + Push a review branch + + + + + + + + Next + Finish plan + +
+ ) +} diff --git a/apps/v4/examples/radix/questionnaire-resume.tsx b/apps/v4/examples/radix/questionnaire-resume.tsx new file mode 100644 index 00000000000..b371870b3ec --- /dev/null +++ b/apps/v4/examples/radix/questionnaire-resume.tsx @@ -0,0 +1,115 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { Button } from "@/styles/radix-nova/ui/button" +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/radix-nova/ui/questionnaire" + +const items = [ + { name: "change", required: true }, + { name: "verification", required: true }, + { name: "notes" }, +] as const + +export function QuestionnaireResume() { + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + const answers = { + change: formData.get("change"), + verification: formData.getAll("verification"), + notes: formData.get("notes"), + } + + toast("Draft updated", { + description: `Migration: ${answers.change ?? "None"} · Verification: ${answers.verification.join(", ") || "None"} · Notes: ${answers.notes || "None"}`, + }) + } + + return ( + toast("Saved answers restored")} + onSubmit={handleSubmit} + > + + + + What kind of migration is this? + + This answer was saved during the previous session. + + + + Incremental migration + + + Single cutover + + + + + + + + How should the migration be verified? + + + These checks were selected during the previous session. + + + + Run migration tests + + + Run the typecheck + + + Perform a manual smoke test + + + + + + + + Anything else the agent should remember? + + + This note was saved with the draft. + + + + + + + + Next + Update draft + + + ) +} diff --git a/apps/v4/examples/radix/questionnaire-shortcuts.tsx b/apps/v4/examples/radix/questionnaire-shortcuts.tsx new file mode 100644 index 00000000000..d54ca7ddcbc --- /dev/null +++ b/apps/v4/examples/radix/questionnaire-shortcuts.tsx @@ -0,0 +1,96 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + NativeSelect, + NativeSelectOption, +} from "@/styles/radix-nova/ui/native-select" +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/radix-nova/ui/questionnaire" + +const items = [ + { + choices: [{ value: "inspect" }, { value: "tests" }, { value: "patch" }], + name: "action", + required: true, + }, +] as const + +type ShortcutMode = React.ComponentProps["shortcuts"] + +export function QuestionnaireShortcuts() { + const [shortcuts, setShortcuts] = React.useState("letters") + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const action = new FormData(event.currentTarget).get("action") + + toast("Next action selected", { + description: `Action: ${action ?? "None"} · Shortcuts: ${shortcuts ?? "none"}`, + }) + } + + return ( +
+ { + const value = event.target.value + setShortcuts( + value === "letters" || value === "numbers" ? value : undefined + ) + }} + > + No shortcuts + Letters + Numbers + + + + + + What should the agent do next? + + + Use the displayed shortcut or navigate with the keyboard. + + + + Inspect the implementation + + + Run the relevant tests + + + Prepare the patch + + + + + + + Confirm action + + +
+ ) +} diff --git a/apps/v4/examples/radix/questionnaire-skip.tsx b/apps/v4/examples/radix/questionnaire-skip.tsx new file mode 100644 index 00000000000..85fc63a6e36 --- /dev/null +++ b/apps/v4/examples/radix/questionnaire-skip.tsx @@ -0,0 +1,132 @@ +"use client" + +import * as React from "react" +import type { QuestionnaireItemStatus } from "@shadcn/react/questionnaire" +import { toast } from "sonner" + +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/radix-nova/ui/questionnaire" + +const items = [ + { name: "task", required: true }, + { name: "constraints" }, + { name: "review", required: true }, +] as const + +export function QuestionnaireSkipExample() { + const [constraintStatus, setConstraintStatus] = + React.useState("unanswered") + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + const answers = { + task: formData.get("task"), + constraints: formData.get("constraints"), + constraintStatus, + review: formData.get("review"), + } + + toast("Agent brief submitted", { + description: `Task: ${answers.task ?? "None"} · Constraints: ${ + answers.constraintStatus === "skipped" + ? "Skipped" + : (answers.constraints ?? "None") + } · Review: ${answers.review ?? "None"}`, + }) + } + + return ( + + + + + What kind of change is this? + + Choose the category that best describes the work. + + + New feature + Bug fix + Refactor + + + + + + + Are there any implementation constraints? + + + Answer if needed, or intentionally skip this question. + + + + Do not add dependencies + + + Do not change the database + + + Preserve the public API + + + + + + + + How should the work be reviewed? + + + Choose the checks the agent should complete before handoff. + + + + Run the test suite + + + Review the final diff + + + Tests and diff review + + + + + + + + + Next + Submit brief + + + ) +} diff --git a/apps/v4/examples/radix/questionnaire-validation.tsx b/apps/v4/examples/radix/questionnaire-validation.tsx new file mode 100644 index 00000000000..0a789c6d4d8 --- /dev/null +++ b/apps/v4/examples/radix/questionnaire-validation.tsx @@ -0,0 +1,203 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" +import { z } from "zod" + +import { + Card, + CardAction, + CardContent, + CardFooter, + CardHeader, +} from "@/styles/radix-nova/ui/card" +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/styles/radix-nova/ui/questionnaire" + +const items = [ + { name: "detail", required: true }, + { name: "audience", required: true }, +] as const + +const questionnaireSchema = z + .object({ + detail: z.enum(["summary", "complete"]), + audience: z.enum(["team", "public"]), + }) + .superRefine((answers, context) => { + if (answers.audience === "public" && answers.detail === "summary") { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Public answers need enough context. Choose a complete answer.", + path: ["detail"], + }) + } + }) + +type QuestionnaireItemName = keyof z.infer +type QuestionnaireErrors = Partial> + +function ValidationProgress() { + return ( + ( +
+ {state.current} / {state.total} +
+ )} + /> + ) +} + +export function QuestionnaireValidation() { + const [item, setItem] = React.useState("detail") + const [errors, setErrors] = React.useState({}) + + function clearError(name: QuestionnaireItemName) { + setErrors((currentErrors) => { + if (!currentErrors[name]) { + return currentErrors + } + + const nextErrors = { ...currentErrors } + delete nextErrors[name] + return nextErrors + }) + } + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const result = questionnaireSchema.safeParse( + Object.fromEntries(new FormData(event.currentTarget)) + ) + + if (result.success) { + setErrors({}) + toast("Agent response configured", { + description: `Detail: ${result.data.detail} · Audience: ${result.data.audience}`, + }) + return + } + + const nextErrors: QuestionnaireErrors = {} + + for (const issue of result.error.issues) { + const name = issue.path[0] + + if ((name === "detail" || name === "audience") && !nextErrors[name]) { + nextErrors[name] = issue.message + } + } + + const firstInvalidItem = result.error.issues[0]?.path[0] + + setErrors(nextErrors) + + if (firstInvalidItem === "detail" || firstInvalidItem === "audience") { + setItem(firstInvalidItem) + } + } + + return ( + + + + + + How much detail should the answer include? + + + Choose the response depth. + + + + + + + + clearError("detail")} + > + Concise summary + + clearError("detail")} + > + Complete answer + + + {errors.detail} + + + + + + Who will read the answer? + + Public answers require complete context. + + + + + + + + clearError("audience")} + > + My team + + clearError("audience")} + > + Public audience + + + {errors.audience} + + + + + + + Next + Validate answers + + + + + ) +} diff --git a/apps/v4/lib/docs.ts b/apps/v4/lib/docs.ts index 64d617b3dbb..b92ddf21846 100644 --- a/apps/v4/lib/docs.ts +++ b/apps/v4/lib/docs.ts @@ -1,22 +1,11 @@ export const PAGES_NEW = [ - "/docs/typeset", - "/docs/utils/scroll-fade", - "/docs/utils/shimmer", - "/docs/components/radix/attachment", - "/docs/components/base/attachment", - "/docs/components/radix/bubble", - "/docs/components/base/bubble", - "/docs/components/radix/message-scroller", - "/docs/components/base/message-scroller", - "/docs/components/radix/marker", - "/docs/components/base/marker", - "/docs/components/radix/message", - "/docs/components/base/message", - "/docs/components/base/toast", - "/docs/helpers/ai-sdk", - "/docs/helpers/tanstack-ai", - "/docs/react/message-scroller", + "/docs/changelog", + "/docs/changelog/2026-08-questionnaire", "/docs/registry/dynamic-search", + "/docs/components/radix/questionnaire", + "/docs/components/base/questionnaire", + "/docs/components/aria/questionnaire", + "/docs/react/questionnaire", ] export const PAGES_UPDATED = [] diff --git a/apps/v4/mdx-components.tsx b/apps/v4/mdx-components.tsx index 86df36e5de8..402f320abbe 100644 --- a/apps/v4/mdx-components.tsx +++ b/apps/v4/mdx-components.tsx @@ -175,7 +175,7 @@ export const mdxComponents = { // Typeset tables stay real tables and wrap to fit; wrap them to scroll // wide ones horizontally instead. table: (props: React.ComponentProps<"table">) => ( -
+
), diff --git a/apps/v4/package.json b/apps/v4/package.json index 48ed248e9e7..105e2ff8e0c 100644 --- a/apps/v4/package.json +++ b/apps/v4/package.json @@ -90,7 +90,7 @@ "rehype-pretty-code": "^0.14.1", "rimraf": "^6.0.1", "server-only": "^0.0.1", - "shadcn": "4.16.1", + "shadcn": "4.16.2", "shiki": "^3.23.0", "sonner": "^2.0.0", "streamdown": "^2.5.0", diff --git a/apps/v4/public/r/index.json b/apps/v4/public/r/index.json index 3f1a6ac6eb9..71cb0349bc3 100644 --- a/apps/v4/public/r/index.json +++ b/apps/v4/public/r/index.json @@ -1129,6 +1129,34 @@ } } }, + { + "name": "questionnaire", + "type": "registry:ui", + "dependencies": ["@shadcn/react"], + "registryDependencies": ["button"], + "files": [ + { + "path": "ui/questionnaire.tsx", + "type": "registry:ui" + } + ], + "meta": { + "links": { + "base": { + "docs": "https://ui.shadcn.com/docs/components/base/questionnaire", + "examples": "https://ui.shadcn.com/code/apps/v4/registry/bases/base/examples/questionnaire-example.tsx" + }, + "aria": { + "docs": "https://ui.shadcn.com/docs/components/aria/questionnaire", + "examples": "https://ui.shadcn.com/code/apps/v4/registry/bases/aria/examples/questionnaire-example.tsx" + }, + "radix": { + "docs": "https://ui.shadcn.com/docs/components/radix/questionnaire", + "examples": "https://ui.shadcn.com/code/apps/v4/registry/bases/radix/examples/questionnaire-example.tsx" + } + } + } + }, { "name": "radio-group", "type": "registry:ui", diff --git a/apps/v4/registry/__components__/aria-luma.tsx b/apps/v4/registry/__components__/aria-luma.tsx index 41f28490c01..71e74c891f8 100644 --- a/apps/v4/registry/__components__/aria-luma.tsx +++ b/apps/v4/registry/__components__/aria-luma.tsx @@ -446,6 +446,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/aria-luma/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/aria-luma/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/aria-lyra.tsx b/apps/v4/registry/__components__/aria-lyra.tsx index 9cfa9c8b670..422ba3fc8d3 100644 --- a/apps/v4/registry/__components__/aria-lyra.tsx +++ b/apps/v4/registry/__components__/aria-lyra.tsx @@ -446,6 +446,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/aria-lyra/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/aria-lyra/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/aria-maia.tsx b/apps/v4/registry/__components__/aria-maia.tsx index 07405401ea7..e20520649ff 100644 --- a/apps/v4/registry/__components__/aria-maia.tsx +++ b/apps/v4/registry/__components__/aria-maia.tsx @@ -446,6 +446,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/aria-maia/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/aria-maia/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/aria-mira.tsx b/apps/v4/registry/__components__/aria-mira.tsx index d8d477a4d4a..be587de4c5d 100644 --- a/apps/v4/registry/__components__/aria-mira.tsx +++ b/apps/v4/registry/__components__/aria-mira.tsx @@ -446,6 +446,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/aria-mira/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/aria-mira/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/aria-nova.tsx b/apps/v4/registry/__components__/aria-nova.tsx index 578673e2f95..612f5f59000 100644 --- a/apps/v4/registry/__components__/aria-nova.tsx +++ b/apps/v4/registry/__components__/aria-nova.tsx @@ -446,6 +446,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/aria-nova/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/aria-nova/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/aria-rhea.tsx b/apps/v4/registry/__components__/aria-rhea.tsx index 5ef9de995aa..4c015d6d8ab 100644 --- a/apps/v4/registry/__components__/aria-rhea.tsx +++ b/apps/v4/registry/__components__/aria-rhea.tsx @@ -446,6 +446,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/aria-rhea/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/aria-rhea/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/aria-sera.tsx b/apps/v4/registry/__components__/aria-sera.tsx index d11a19a000f..c4a6362c7d6 100644 --- a/apps/v4/registry/__components__/aria-sera.tsx +++ b/apps/v4/registry/__components__/aria-sera.tsx @@ -446,6 +446,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/aria-sera/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/aria-sera/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/aria-vega.tsx b/apps/v4/registry/__components__/aria-vega.tsx index 1c75ee10159..60a27c9b959 100644 --- a/apps/v4/registry/__components__/aria-vega.tsx +++ b/apps/v4/registry/__components__/aria-vega.tsx @@ -446,6 +446,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/aria-vega/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/aria-vega/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/base-luma.tsx b/apps/v4/registry/__components__/base-luma.tsx index c13e5779682..64d45b631e3 100644 --- a/apps/v4/registry/__components__/base-luma.tsx +++ b/apps/v4/registry/__components__/base-luma.tsx @@ -478,6 +478,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/base-luma/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/base-luma/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/base-lyra.tsx b/apps/v4/registry/__components__/base-lyra.tsx index b6595853496..9cee70661f8 100644 --- a/apps/v4/registry/__components__/base-lyra.tsx +++ b/apps/v4/registry/__components__/base-lyra.tsx @@ -478,6 +478,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/base-lyra/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/base-lyra/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/base-maia.tsx b/apps/v4/registry/__components__/base-maia.tsx index e5ace6cddd7..87b158ae51a 100644 --- a/apps/v4/registry/__components__/base-maia.tsx +++ b/apps/v4/registry/__components__/base-maia.tsx @@ -478,6 +478,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/base-maia/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/base-maia/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/base-mira.tsx b/apps/v4/registry/__components__/base-mira.tsx index e75813aea4a..b2051be19e0 100644 --- a/apps/v4/registry/__components__/base-mira.tsx +++ b/apps/v4/registry/__components__/base-mira.tsx @@ -478,6 +478,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/base-mira/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/base-mira/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/base-nova.tsx b/apps/v4/registry/__components__/base-nova.tsx index 465182155af..edeb7d554ca 100644 --- a/apps/v4/registry/__components__/base-nova.tsx +++ b/apps/v4/registry/__components__/base-nova.tsx @@ -478,6 +478,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/base-nova/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/base-nova/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/base-rhea.tsx b/apps/v4/registry/__components__/base-rhea.tsx index c8f447f0d13..f5175fed7ac 100644 --- a/apps/v4/registry/__components__/base-rhea.tsx +++ b/apps/v4/registry/__components__/base-rhea.tsx @@ -478,6 +478,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/base-rhea/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/base-rhea/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/base-sera.tsx b/apps/v4/registry/__components__/base-sera.tsx index c058aecd10b..e6f4c3bc534 100644 --- a/apps/v4/registry/__components__/base-sera.tsx +++ b/apps/v4/registry/__components__/base-sera.tsx @@ -478,6 +478,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/base-sera/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/base-sera/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/base-vega.tsx b/apps/v4/registry/__components__/base-vega.tsx index cf1c658eed0..89036373ca0 100644 --- a/apps/v4/registry/__components__/base-vega.tsx +++ b/apps/v4/registry/__components__/base-vega.tsx @@ -478,6 +478,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/base-vega/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/base-vega/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/index.tsx b/apps/v4/registry/__components__/index.tsx index e2b0ed13ae2..b4e0ef3df28 100644 --- a/apps/v4/registry/__components__/index.tsx +++ b/apps/v4/registry/__components__/index.tsx @@ -478,6 +478,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -543,6 +544,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -605,6 +607,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -671,6 +674,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -737,6 +741,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -803,6 +808,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -869,6 +875,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -935,6 +942,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -1001,6 +1009,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -1067,6 +1076,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -1129,6 +1139,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -1191,6 +1202,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -1253,6 +1265,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -1315,6 +1328,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -1377,6 +1391,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -1439,6 +1454,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -1501,6 +1517,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -1566,6 +1583,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -1631,6 +1649,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -1696,6 +1715,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -1761,6 +1781,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -1826,6 +1847,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -1891,6 +1913,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), @@ -1956,6 +1979,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", ]), diff --git a/apps/v4/registry/__components__/radix-luma.tsx b/apps/v4/registry/__components__/radix-luma.tsx index fb560606f34..0046683dfc6 100644 --- a/apps/v4/registry/__components__/radix-luma.tsx +++ b/apps/v4/registry/__components__/radix-luma.tsx @@ -470,6 +470,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/radix-luma/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/radix-luma/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/radix-lyra.tsx b/apps/v4/registry/__components__/radix-lyra.tsx index 29eea35ab5c..bef7d57e8c5 100644 --- a/apps/v4/registry/__components__/radix-lyra.tsx +++ b/apps/v4/registry/__components__/radix-lyra.tsx @@ -470,6 +470,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/radix-lyra/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/radix-lyra/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/radix-maia.tsx b/apps/v4/registry/__components__/radix-maia.tsx index 4dfb14f6a47..2eeaca57254 100644 --- a/apps/v4/registry/__components__/radix-maia.tsx +++ b/apps/v4/registry/__components__/radix-maia.tsx @@ -470,6 +470,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/radix-maia/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/radix-maia/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/radix-mira.tsx b/apps/v4/registry/__components__/radix-mira.tsx index 88a092af562..d5b9ee5997e 100644 --- a/apps/v4/registry/__components__/radix-mira.tsx +++ b/apps/v4/registry/__components__/radix-mira.tsx @@ -470,6 +470,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/radix-mira/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/radix-mira/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/radix-nova.tsx b/apps/v4/registry/__components__/radix-nova.tsx index 1937135bfe1..2264bb24488 100644 --- a/apps/v4/registry/__components__/radix-nova.tsx +++ b/apps/v4/registry/__components__/radix-nova.tsx @@ -470,6 +470,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/radix-nova/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/radix-nova/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/radix-rhea.tsx b/apps/v4/registry/__components__/radix-rhea.tsx index aa83e42150c..5d9b7a0386e 100644 --- a/apps/v4/registry/__components__/radix-rhea.tsx +++ b/apps/v4/registry/__components__/radix-rhea.tsx @@ -470,6 +470,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/radix-rhea/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/radix-rhea/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/radix-sera.tsx b/apps/v4/registry/__components__/radix-sera.tsx index dfd0a013c13..176f6a16bb1 100644 --- a/apps/v4/registry/__components__/radix-sera.tsx +++ b/apps/v4/registry/__components__/radix-sera.tsx @@ -470,6 +470,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/radix-sera/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/radix-sera/ui/marker") const exportName = diff --git a/apps/v4/registry/__components__/radix-vega.tsx b/apps/v4/registry/__components__/radix-vega.tsx index 69be1c2e322..62dc14c6eb0 100644 --- a/apps/v4/registry/__components__/radix-vega.tsx +++ b/apps/v4/registry/__components__/radix-vega.tsx @@ -470,6 +470,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/styles/radix-vega/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/styles/radix-vega/ui/marker") const exportName = diff --git a/apps/v4/registry/__index__.tsx b/apps/v4/registry/__index__.tsx index c9d0f3e75f9..7f38046dd3f 100644 --- a/apps/v4/registry/__index__.tsx +++ b/apps/v4/registry/__index__.tsx @@ -8271,6 +8271,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/base-nova/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/base/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/base/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -9604,6 +9626,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/radix-nova/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/radix/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/radix/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -10890,6 +10934,28 @@ export const Index: Record> = { }, }, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/aria-nova/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/aria/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/aria/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -12257,6 +12323,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/base-vega/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/base/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/base/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -13612,6 +13700,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/base-maia/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/base/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/base/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -14967,6 +15077,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/base-lyra/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/base/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/base/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -16322,6 +16454,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/base-mira/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/base/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/base/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -17677,6 +17831,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/base-luma/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/base/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/base/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -19032,6 +19208,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/base-sera/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/base/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/base/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -20387,6 +20585,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/base-rhea/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/base/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/base/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -21673,6 +21893,28 @@ export const Index: Record> = { }, }, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/aria-vega/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/aria/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/aria/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -22971,6 +23213,28 @@ export const Index: Record> = { }, }, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/aria-maia/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/aria/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/aria/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -24269,6 +24533,28 @@ export const Index: Record> = { }, }, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/aria-lyra/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/aria/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/aria/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -25567,6 +25853,28 @@ export const Index: Record> = { }, }, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/aria-mira/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/aria/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/aria/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -26865,6 +27173,28 @@ export const Index: Record> = { }, }, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/aria-luma/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/aria/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/aria/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -28163,6 +28493,28 @@ export const Index: Record> = { }, }, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/aria-sera/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/aria/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/aria/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -29461,6 +29813,28 @@ export const Index: Record> = { }, }, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/aria-rhea/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/aria/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/aria/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -30806,6 +31180,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/radix-vega/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/radix/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/radix/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -32139,6 +32535,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/radix-maia/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/radix/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/radix/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -33472,6 +33890,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/radix-lyra/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/radix/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/radix/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -34805,6 +35245,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/radix-mira/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/radix/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/radix/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -36138,6 +36600,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/radix-luma/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/radix/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/radix/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -37471,6 +37955,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/radix-sera/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/radix/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/radix/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -38804,6 +39310,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "styles/radix-rhea/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/radix/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/radix/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", diff --git a/apps/v4/registry/bases/__components__/aria.tsx b/apps/v4/registry/bases/__components__/aria.tsx index 60b10abccfc..23ec96c4438 100644 --- a/apps/v4/registry/bases/__components__/aria.tsx +++ b/apps/v4/registry/bases/__components__/aria.tsx @@ -446,6 +446,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/registry/bases/aria/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/registry/bases/aria/ui/marker") const exportName = @@ -970,6 +978,16 @@ export const Components: Record = { ) || "message-scroller-example" return { default: mod.default || mod[exportName] } }), + "questionnaire-example": React.lazy(async () => { + const mod = await import( + "@/registry/bases/aria/examples/questionnaire-example" + ) + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire-example" + return { default: mod.default || mod[exportName] } + }), "marker-example": React.lazy(async () => { const mod = await import("@/registry/bases/aria/examples/marker-example") const exportName = diff --git a/apps/v4/registry/bases/__components__/base.tsx b/apps/v4/registry/bases/__components__/base.tsx index 2b63eaa3216..3ecd580a029 100644 --- a/apps/v4/registry/bases/__components__/base.tsx +++ b/apps/v4/registry/bases/__components__/base.tsx @@ -478,6 +478,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/registry/bases/base/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/registry/bases/base/ui/marker") const exportName = @@ -1038,6 +1046,16 @@ export const Components: Record = { ) || "message-scroller-example" return { default: mod.default || mod[exportName] } }), + "questionnaire-example": React.lazy(async () => { + const mod = await import( + "@/registry/bases/base/examples/questionnaire-example" + ) + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire-example" + return { default: mod.default || mod[exportName] } + }), "marker-example": React.lazy(async () => { const mod = await import("@/registry/bases/base/examples/marker-example") const exportName = diff --git a/apps/v4/registry/bases/__components__/index.tsx b/apps/v4/registry/bases/__components__/index.tsx index cb56d4a7756..8d29426d6bc 100644 --- a/apps/v4/registry/bases/__components__/index.tsx +++ b/apps/v4/registry/bases/__components__/index.tsx @@ -74,6 +74,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", "accordion-example", @@ -139,6 +140,7 @@ const shards: Record< "attachment-example", "bubble-example", "message-scroller-example", + "questionnaire-example", "marker-example", "message-example", "utils", @@ -234,6 +236,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", "accordion-example", @@ -295,6 +298,7 @@ const shards: Record< "attachment-example", "bubble-example", "message-scroller-example", + "questionnaire-example", "marker-example", "message-example", "utils", @@ -393,6 +397,7 @@ const shards: Record< "attachment", "bubble", "message-scroller", + "questionnaire", "marker", "message", "accordion-example", @@ -457,6 +462,7 @@ const shards: Record< "attachment-example", "bubble-example", "message-scroller-example", + "questionnaire-example", "marker-example", "message-example", "utils", diff --git a/apps/v4/registry/bases/__components__/radix.tsx b/apps/v4/registry/bases/__components__/radix.tsx index 5db8338fec8..d9c728188ae 100644 --- a/apps/v4/registry/bases/__components__/radix.tsx +++ b/apps/v4/registry/bases/__components__/radix.tsx @@ -470,6 +470,14 @@ export const Components: Record = { ) || "message-scroller" return { default: mod.default || mod[exportName] } }), + questionnaire: React.lazy(async () => { + const mod = await import("@/registry/bases/radix/ui/questionnaire") + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire" + return { default: mod.default || mod[exportName] } + }), marker: React.lazy(async () => { const mod = await import("@/registry/bases/radix/ui/marker") const exportName = @@ -1032,6 +1040,16 @@ export const Components: Record = { ) || "message-scroller-example" return { default: mod.default || mod[exportName] } }), + "questionnaire-example": React.lazy(async () => { + const mod = await import( + "@/registry/bases/radix/examples/questionnaire-example" + ) + const exportName = + Object.keys(mod).find( + (key) => typeof mod[key] === "function" || typeof mod[key] === "object" + ) || "questionnaire-example" + return { default: mod.default || mod[exportName] } + }), "marker-example": React.lazy(async () => { const mod = await import("@/registry/bases/radix/examples/marker-example") const exportName = diff --git a/apps/v4/registry/bases/__index__.tsx b/apps/v4/registry/bases/__index__.tsx index 467ab9923de..401bb9c0a58 100644 --- a/apps/v4/registry/bases/__index__.tsx +++ b/apps/v4/registry/bases/__index__.tsx @@ -1326,6 +1326,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "registry/bases/base/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/base/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/base/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -2579,6 +2601,29 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + "questionnaire-example": { + name: "questionnaire-example", + title: "Questionnaire", + description: "", + type: "registry:example", + registryDependencies: [ + "button", + "card", + "dialog", + "example", + "questionnaire", + "sonner", + ], + files: [ + { + path: "registry/bases/base/examples/questionnaire-example.tsx", + type: "registry:example", + target: "", + }, + ], + categories: undefined, + meta: undefined, + }, "marker-example": { name: "marker-example", title: "Marker", @@ -4944,6 +4989,28 @@ export const Index: Record> = { }, }, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "registry/bases/aria/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/aria/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/aria/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -6145,6 +6212,29 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + "questionnaire-example": { + name: "questionnaire-example", + title: "Questionnaire", + description: "", + type: "registry:example", + registryDependencies: [ + "button", + "card", + "dialog", + "example", + "questionnaire", + "sonner", + ], + files: [ + { + path: "registry/bases/aria/examples/questionnaire-example.tsx", + type: "registry:example", + target: "", + }, + ], + categories: undefined, + meta: undefined, + }, "marker-example": { name: "marker-example", title: "Marker", @@ -8557,6 +8647,28 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + questionnaire: { + name: "questionnaire", + title: "undefined", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [ + { + path: "registry/bases/radix/ui/questionnaire.tsx", + type: "registry:ui", + target: "", + }, + ], + categories: undefined, + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/radix/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/radix/examples/questionnaire-example.tsx", + }, + }, + }, marker: { name: "marker", title: "undefined", @@ -9794,6 +9906,29 @@ export const Index: Record> = { categories: undefined, meta: undefined, }, + "questionnaire-example": { + name: "questionnaire-example", + title: "Questionnaire", + description: "", + type: "registry:example", + registryDependencies: [ + "button", + "card", + "dialog", + "example", + "questionnaire", + "sonner", + ], + files: [ + { + path: "registry/bases/radix/examples/questionnaire-example.tsx", + type: "registry:example", + target: "", + }, + ], + categories: undefined, + meta: undefined, + }, "marker-example": { name: "marker-example", title: "Marker", diff --git a/apps/v4/registry/bases/aria/examples/_registry.ts b/apps/v4/registry/bases/aria/examples/_registry.ts index a6abeed58d1..00782dfbb9c 100644 --- a/apps/v4/registry/bases/aria/examples/_registry.ts +++ b/apps/v4/registry/bases/aria/examples/_registry.ts @@ -916,6 +916,25 @@ export const examples: Registry["items"] = [ }, ], }, + { + name: "questionnaire-example", + title: "Questionnaire", + type: "registry:example", + registryDependencies: [ + "button", + "card", + "dialog", + "example", + "questionnaire", + "sonner", + ], + files: [ + { + path: "examples/questionnaire-example.tsx", + type: "registry:example", + }, + ], + }, { name: "marker-example", title: "Marker", diff --git a/apps/v4/registry/bases/aria/examples/questionnaire-example.tsx b/apps/v4/registry/bases/aria/examples/questionnaire-example.tsx new file mode 100644 index 00000000000..cfd24b0e07b --- /dev/null +++ b/apps/v4/registry/bases/aria/examples/questionnaire-example.tsx @@ -0,0 +1,414 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Example, + ExampleWrapper, +} from "@/registry/bases/aria/components/example" +import { Button } from "@/registry/bases/aria/ui/button" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/registry/bases/aria/ui/card" +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/registry/bases/aria/ui/dialog" +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/registry/bases/aria/ui/questionnaire" + +const questionnaireItems = [ + { + choices: [ + { value: "delegation" }, + { value: "questions" }, + { value: "both" }, + ], + name: "direction", + required: true, + }, + { + choices: [ + { value: "progress" }, + { value: "decisions" }, + { value: "risks" }, + ], + name: "signals", + }, + { + choices: [{ value: "week" }, { value: "cycle" }, { value: "later" }], + name: "timing", + required: true, + }, +] as const + +const taskItems = [ + { + choices: [ + { value: "inspect" }, + { value: "implement" }, + { value: "review" }, + ], + name: "task", + required: true, + }, +] as const + +export default function QuestionnaireExample() { + return ( + + + + + + + ) +} + +function QuestionnaireNoDescription() { + return ( + + + + + + What should the agent do next? + + + + Inspect the codebase + + + Implement the change + + + Review the result + + + + + + + + ) +} + +function QuestionnaireStandalone() { + return ( + + + + + + + + ) +} + +function QuestionnaireCard() { + return ( + + + + + + ) +} + +function QuestionnaireDialog() { + return ( + + + + + + + + Plan an agent interface + + + Answer three questions to shape the next prototype. + + ( + + Question {state.current} of {state.total} + + )} + /> + + + + + + + + + + ) +} + +function QuestionnaireCardQuestions() { + const directionTitleId = React.useId() + const signalsTitleId = React.useId() + const timingTitleId = React.useId() + + return ( + <> + + + + }> + What should we prototype next? + + }> + Choose one direction or write another answer. + + + + + + + + + Sub-agent delegation + + Show when work is delegated and what comes back. + + + + Question prompts + + Show choices while the agent waits for input. + + + + Both together + + Explore one unified interaction pattern. + + + + + + + + + + + + + + + }> + What should every progress update include? + + }> + Select all that apply, or skip this question. + + + + + + + + + Progress + + + Decisions + + Risks + + + + + + + + + + + + }> + When should this be revisited? + + }> + Choose when this should be revisited. + + + + + + + + This week + + Next cycle + + + Revisit later + + + + + + + + + + + ) +} + +function QuestionnaireQuestions() { + return ( + <> + + What should we prototype next? + + Choose one direction or write another answer. + + + + Sub-agent delegation + + Show when work is delegated and what comes back. + + + + Question prompts + + Show choices while the agent waits for input. + + + + Both together + + Explore one unified interaction pattern. + + + + + + + + + What should every progress update include? + + + Select all that apply, or skip this question. + + + Progress + Decisions + Risks + + + + + When should this be revisited? + + Choose when this should be revisited. + + + This week + Next cycle + Revisit later + + + + + ) +} + +function QuestionnaireNavigation() { + return ( + + + + Next + Save answers + + ) +} + +function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + const values = { + direction: formData.get("direction"), + signals: formData.getAll("signals"), + timing: formData.get("timing"), + } + + toast("Questionnaire submitted", { + description: `Direction: ${values.direction ?? "None"} · Progress signals: ${values.signals.join(", ") || "None"} · Timing: ${values.timing ?? "None"}`, + }) +} diff --git a/apps/v4/registry/bases/aria/ui/_registry.ts b/apps/v4/registry/bases/aria/ui/_registry.ts index 34ccb76e1ef..d05be8edf1b 100644 --- a/apps/v4/registry/bases/aria/ui/_registry.ts +++ b/apps/v4/registry/bases/aria/ui/_registry.ts @@ -1007,6 +1007,25 @@ export const ui: Registry["items"] = [ }, }, }, + { + name: "questionnaire", + type: "registry:ui", + dependencies: ["@shadcn/react"], + registryDependencies: ["button"], + files: [ + { + path: "ui/questionnaire.tsx", + type: "registry:ui", + }, + ], + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/aria/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/aria/examples/questionnaire-example.tsx", + }, + }, + }, { name: "marker", type: "registry:ui", diff --git a/apps/v4/registry/bases/aria/ui/questionnaire.tsx b/apps/v4/registry/bases/aria/ui/questionnaire.tsx new file mode 100644 index 00000000000..a493e3bb9be --- /dev/null +++ b/apps/v4/registry/bases/aria/ui/questionnaire.tsx @@ -0,0 +1,321 @@ +"use client" + +import * as React from "react" +import { Questionnaire as QuestionnairePrimitive } from "@shadcn/react/questionnaire" + +import { cn } from "@/registry/bases/aria/lib/utils" +import { buttonVariants, type Button } from "@/registry/bases/aria/ui/button" +import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder" + +function Questionnaire({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireProgress({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireChoices({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireChoice({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function QuestionnaireInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ +
+ ) +} + +function QuestionnaireError({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireActions({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function QuestionnairePrevious({ + children, + className, + size = "default", + variant = "outline", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Previous"} + + ) +} + +function QuestionnaireSkip({ + children, + className, + size = "default", + variant = "outline", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Skip"} + + ) +} + +function QuestionnaireNext({ + children, + className, + size = "default", + variant = "default", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Next"} + + ) +} + +function QuestionnaireSubmit({ + children, + className, + size = "default", + variant = "default", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Submit"} + + ) +} + +export { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +} diff --git a/apps/v4/registry/bases/base/examples/_registry.ts b/apps/v4/registry/bases/base/examples/_registry.ts index e66503fb02e..866277be003 100644 --- a/apps/v4/registry/bases/base/examples/_registry.ts +++ b/apps/v4/registry/bases/base/examples/_registry.ts @@ -964,6 +964,25 @@ export const examples: Registry["items"] = [ }, ], }, + { + name: "questionnaire-example", + title: "Questionnaire", + type: "registry:example", + registryDependencies: [ + "button", + "card", + "dialog", + "example", + "questionnaire", + "sonner", + ], + files: [ + { + path: "examples/questionnaire-example.tsx", + type: "registry:example", + }, + ], + }, { name: "marker-example", title: "Marker", diff --git a/apps/v4/registry/bases/base/examples/questionnaire-example.tsx b/apps/v4/registry/bases/base/examples/questionnaire-example.tsx new file mode 100644 index 00000000000..bc7b60ec989 --- /dev/null +++ b/apps/v4/registry/bases/base/examples/questionnaire-example.tsx @@ -0,0 +1,417 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Example, + ExampleWrapper, +} from "@/registry/bases/base/components/example" +import { Button } from "@/registry/bases/base/ui/button" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/registry/bases/base/ui/card" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/registry/bases/base/ui/dialog" +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/registry/bases/base/ui/questionnaire" + +const questionnaireItems = [ + { + choices: [ + { value: "delegation" }, + { value: "questions" }, + { value: "both" }, + ], + name: "direction", + required: true, + }, + { + choices: [ + { value: "progress" }, + { value: "decisions" }, + { value: "risks" }, + ], + name: "signals", + }, + { + choices: [{ value: "week" }, { value: "cycle" }, { value: "later" }], + name: "timing", + required: true, + }, +] as const + +const taskItems = [ + { + choices: [ + { value: "inspect" }, + { value: "implement" }, + { value: "review" }, + ], + name: "task", + required: true, + }, +] as const + +export default function QuestionnaireExample() { + return ( + + + + + + + ) +} + +function QuestionnaireNoDescription() { + return ( + + + + + + What should the agent do next? + + + + Inspect the codebase + + + Implement the change + + + Review the result + + + + + + + + ) +} + +function QuestionnaireStandalone() { + return ( + + + + + + + + ) +} + +function QuestionnaireCard() { + return ( + + + + + + ) +} + +function QuestionnaireDialog() { + return ( + + + }> + Open questionnaire + + + + + + Plan an agent interface + + + Answer three questions to shape the next prototype. + + ( + + Question {state.current} of {state.total} + + )} + /> + + + + + + + + + + ) +} + +function QuestionnaireCardQuestions() { + const directionTitleId = React.useId() + const signalsTitleId = React.useId() + const timingTitleId = React.useId() + + return ( + <> + + + + }> + What should we prototype next? + + }> + Choose one direction or write another answer. + + + + + + + + + Sub-agent delegation + + Show when work is delegated and what comes back. + + + + Question prompts + + Show choices while the agent waits for input. + + + + Both together + + Explore one unified interaction pattern. + + + + + + + + + + + + + + + }> + What should every progress update include? + + }> + Select all that apply, or skip this question. + + + + + + + + + Progress + + + Decisions + + Risks + + + + + + + + + + + + }> + When should this be revisited? + + }> + Choose when this should be revisited. + + + + + + + + This week + + Next cycle + + + Revisit later + + + + + + + + + + + ) +} + +function QuestionnaireQuestions() { + return ( + <> + + What should we prototype next? + + Choose one direction or write another answer. + + + + Sub-agent delegation + + Show when work is delegated and what comes back. + + + + Question prompts + + Show choices while the agent waits for input. + + + + Both together + + Explore one unified interaction pattern. + + + + + + + + + What should every progress update include? + + + Select all that apply, or skip this question. + + + Progress + Decisions + Risks + + + + + When should this be revisited? + + Choose when this should be revisited. + + + This week + Next cycle + Revisit later + + + + + ) +} + +function QuestionnaireNavigation() { + return ( + + + + Next + Save answers + + ) +} + +function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + const values = { + direction: formData.get("direction"), + signals: formData.getAll("signals"), + timing: formData.get("timing"), + } + + toast("Questionnaire submitted", { + description: `Direction: ${values.direction ?? "None"} · Progress signals: ${values.signals.join(", ") || "None"} · Timing: ${values.timing ?? "None"}`, + }) +} diff --git a/apps/v4/registry/bases/base/ui/_registry.ts b/apps/v4/registry/bases/base/ui/_registry.ts index feae27a71ef..80a3a705187 100644 --- a/apps/v4/registry/bases/base/ui/_registry.ts +++ b/apps/v4/registry/bases/base/ui/_registry.ts @@ -1075,6 +1075,25 @@ export default function RootLayout({ children }: { children: React.ReactNode }) }, ], }, + { + name: "questionnaire", + type: "registry:ui", + dependencies: ["@shadcn/react"], + registryDependencies: ["button"], + files: [ + { + path: "ui/questionnaire.tsx", + type: "registry:ui", + }, + ], + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/base/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/base/examples/questionnaire-example.tsx", + }, + }, + }, { name: "marker", type: "registry:ui", diff --git a/apps/v4/registry/bases/base/ui/questionnaire.tsx b/apps/v4/registry/bases/base/ui/questionnaire.tsx new file mode 100644 index 00000000000..3589c13d1a1 --- /dev/null +++ b/apps/v4/registry/bases/base/ui/questionnaire.tsx @@ -0,0 +1,321 @@ +"use client" + +import * as React from "react" +import { Questionnaire as QuestionnairePrimitive } from "@shadcn/react/questionnaire" + +import { cn } from "@/registry/bases/base/lib/utils" +import { buttonVariants, type Button } from "@/registry/bases/base/ui/button" +import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder" + +function Questionnaire({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireProgress({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireChoices({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireChoice({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function QuestionnaireInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ +
+ ) +} + +function QuestionnaireError({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireActions({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function QuestionnairePrevious({ + children, + className, + size = "default", + variant = "outline", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Previous"} + + ) +} + +function QuestionnaireSkip({ + children, + className, + size = "default", + variant = "outline", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Skip"} + + ) +} + +function QuestionnaireNext({ + children, + className, + size = "default", + variant = "default", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Next"} + + ) +} + +function QuestionnaireSubmit({ + children, + className, + size = "default", + variant = "default", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Submit"} + + ) +} + +export { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +} diff --git a/apps/v4/registry/bases/radix/examples/_registry.ts b/apps/v4/registry/bases/radix/examples/_registry.ts index bc970fa6cae..56361dd942b 100644 --- a/apps/v4/registry/bases/radix/examples/_registry.ts +++ b/apps/v4/registry/bases/radix/examples/_registry.ts @@ -952,6 +952,25 @@ export const examples: Registry["items"] = [ }, ], }, + { + name: "questionnaire-example", + title: "Questionnaire", + type: "registry:example", + registryDependencies: [ + "button", + "card", + "dialog", + "example", + "questionnaire", + "sonner", + ], + files: [ + { + path: "examples/questionnaire-example.tsx", + type: "registry:example", + }, + ], + }, { name: "marker-example", title: "Marker", diff --git a/apps/v4/registry/bases/radix/examples/questionnaire-example.tsx b/apps/v4/registry/bases/radix/examples/questionnaire-example.tsx new file mode 100644 index 00000000000..b76fcffc897 --- /dev/null +++ b/apps/v4/registry/bases/radix/examples/questionnaire-example.tsx @@ -0,0 +1,417 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { + Example, + ExampleWrapper, +} from "@/registry/bases/radix/components/example" +import { Button } from "@/registry/bases/radix/ui/button" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/registry/bases/radix/ui/card" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/registry/bases/radix/ui/dialog" +import { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +} from "@/registry/bases/radix/ui/questionnaire" + +const questionnaireItems = [ + { + choices: [ + { value: "delegation" }, + { value: "questions" }, + { value: "both" }, + ], + name: "direction", + required: true, + }, + { + choices: [ + { value: "progress" }, + { value: "decisions" }, + { value: "risks" }, + ], + name: "signals", + }, + { + choices: [{ value: "week" }, { value: "cycle" }, { value: "later" }], + name: "timing", + required: true, + }, +] as const + +const taskItems = [ + { + choices: [ + { value: "inspect" }, + { value: "implement" }, + { value: "review" }, + ], + name: "task", + required: true, + }, +] as const + +export default function QuestionnaireExample() { + return ( + + + + + + + ) +} + +function QuestionnaireNoDescription() { + return ( + + + + + + What should the agent do next? + + + + Inspect the codebase + + + Implement the change + + + Review the result + + + + + + + + ) +} + +function QuestionnaireStandalone() { + return ( + + + + + + + + ) +} + +function QuestionnaireCard() { + return ( + + + + + + ) +} + +function QuestionnaireDialog() { + return ( + + + + + + + + + + Plan an agent interface + + + Answer three questions to shape the next prototype. + + ( + + Question {state.current} of {state.total} + + )} + /> + + + + + + + + + + ) +} + +function QuestionnaireCardQuestions() { + const directionTitleId = React.useId() + const signalsTitleId = React.useId() + const timingTitleId = React.useId() + + return ( + <> + + + + }> + What should we prototype next? + + }> + Choose one direction or write another answer. + + + + + + + + + Sub-agent delegation + + Show when work is delegated and what comes back. + + + + Question prompts + + Show choices while the agent waits for input. + + + + Both together + + Explore one unified interaction pattern. + + + + + + + + + + + + + + + }> + What should every progress update include? + + }> + Select all that apply, or skip this question. + + + + + + + + + Progress + + + Decisions + + Risks + + + + + + + + + + + + }> + When should this be revisited? + + }> + Choose when this should be revisited. + + + + + + + + This week + + Next cycle + + + Revisit later + + + + + + + + + + + ) +} + +function QuestionnaireQuestions() { + return ( + <> + + What should we prototype next? + + Choose one direction or write another answer. + + + + Sub-agent delegation + + Show when work is delegated and what comes back. + + + + Question prompts + + Show choices while the agent waits for input. + + + + Both together + + Explore one unified interaction pattern. + + + + + + + + + What should every progress update include? + + + Select all that apply, or skip this question. + + + Progress + Decisions + Risks + + + + + When should this be revisited? + + Choose when this should be revisited. + + + This week + Next cycle + Revisit later + + + + + ) +} + +function QuestionnaireNavigation() { + return ( + + + + Next + Save answers + + ) +} + +function handleSubmit(event: React.FormEvent) { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + const values = { + direction: formData.get("direction"), + signals: formData.getAll("signals"), + timing: formData.get("timing"), + } + + toast("Questionnaire submitted", { + description: `Direction: ${values.direction ?? "None"} · Progress signals: ${values.signals.join(", ") || "None"} · Timing: ${values.timing ?? "None"}`, + }) +} diff --git a/apps/v4/registry/bases/radix/ui/_registry.ts b/apps/v4/registry/bases/radix/ui/_registry.ts index 0118a48e74f..834ad844cb1 100644 --- a/apps/v4/registry/bases/radix/ui/_registry.ts +++ b/apps/v4/registry/bases/radix/ui/_registry.ts @@ -1053,6 +1053,25 @@ export default function RootLayout({ children }: { children: React.ReactNode }) }, ], }, + { + name: "questionnaire", + type: "registry:ui", + dependencies: ["@shadcn/react"], + registryDependencies: ["button"], + files: [ + { + path: "ui/questionnaire.tsx", + type: "registry:ui", + }, + ], + meta: { + links: { + docs: "https://ui.shadcn.com/docs/components/radix/questionnaire", + examples: + "https://ui.shadcn.com/code/apps/v4/registry/bases/radix/examples/questionnaire-example.tsx", + }, + }, + }, { name: "marker", type: "registry:ui", diff --git a/apps/v4/registry/bases/radix/ui/questionnaire.tsx b/apps/v4/registry/bases/radix/ui/questionnaire.tsx new file mode 100644 index 00000000000..39b85c0d702 --- /dev/null +++ b/apps/v4/registry/bases/radix/ui/questionnaire.tsx @@ -0,0 +1,321 @@ +"use client" + +import * as React from "react" +import { Questionnaire as QuestionnairePrimitive } from "@shadcn/react/questionnaire" + +import { cn } from "@/registry/bases/radix/lib/utils" +import { buttonVariants, type Button } from "@/registry/bases/radix/ui/button" +import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder" + +function Questionnaire({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireProgress({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireChoices({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireChoice({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function QuestionnaireInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ +
+ ) +} + +function QuestionnaireError({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireActions({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function QuestionnairePrevious({ + children, + className, + size = "default", + variant = "outline", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Previous"} + + ) +} + +function QuestionnaireSkip({ + children, + className, + size = "default", + variant = "outline", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Skip"} + + ) +} + +function QuestionnaireNext({ + children, + className, + size = "default", + variant = "default", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Next"} + + ) +} + +function QuestionnaireSubmit({ + children, + className, + size = "default", + variant = "default", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Submit"} + + ) +} + +export { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +} diff --git a/apps/v4/registry/styles/style-luma.css b/apps/v4/registry/styles/style-luma.css index c0c7271ae2d..d284e0eb991 100644 --- a/apps/v4/registry/styles/style-luma.css +++ b/apps/v4/registry/styles/style-luma.css @@ -1590,6 +1590,71 @@ @apply group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:hover:text-foreground *:[a]:underline *:[a]:underline-offset-3; } + /* MARK: Questionnaire */ + .cn-questionnaire { + @apply gap-6; + } + + .cn-questionnaire-progress { + @apply text-xs; + } + + .cn-questionnaire-item { + @apply flex flex-col gap-5; + } + + .cn-questionnaire-title { + @apply text-base font-semibold [&:not(:has(~[data-slot=questionnaire-description]))]:mb-5; + } + + .cn-questionnaire-description { + @apply text-sm; + } + + .cn-questionnaire-choices { + @apply gap-3; + } + + .cn-questionnaire-choice { + @apply border-input bg-input/20 hover:bg-input/40 data-checked:border-primary/40 data-checked:bg-primary/10 data-invalid:border-destructive has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-ring/50 gap-3 rounded-3xl border px-4 py-3 text-sm has-[>input:focus-visible]:ring-3; + } + + .cn-questionnaire-choice-indicator { + @apply bg-input/90 group-data-checked/questionnaire-choice:bg-primary dark:group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground group-data-checked/questionnaire-choice:border-primary size-4 rounded-[5px] border-transparent; + } + + .cn-questionnaire-choice-indicator-dot { + @apply bg-primary-foreground size-2 dark:size-2.5; + } + + .cn-questionnaire-choice-indicator-check { + @apply size-3.5; + } + + .cn-questionnaire-choice-content { + @apply gap-1; + } + + .cn-questionnaire-shortcut { + @apply border-primary/10 bg-background/80 text-muted-foreground size-5 items-center justify-center rounded-full border font-mono text-[0.625rem] font-medium leading-none; + } + + .cn-questionnaire-input-wrapper { + @apply w-full; + } + + .cn-questionnaire-input { + @apply bg-input/50 border-transparent focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-3xl border px-3 py-1 text-base focus-visible:ring-3 aria-invalid:ring-3 md:text-sm; + } + + .cn-questionnaire-error { + @apply text-sm; + } + + .cn-questionnaire-actions { + @apply gap-2 sm:min-h-9; + } + /* MARK: Message Scroller */ .cn-message-scroller-content { @apply gap-8; diff --git a/apps/v4/registry/styles/style-lyra.css b/apps/v4/registry/styles/style-lyra.css index ecd03244519..5a0f06c647e 100644 --- a/apps/v4/registry/styles/style-lyra.css +++ b/apps/v4/registry/styles/style-lyra.css @@ -1569,6 +1569,71 @@ @apply group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:hover:text-foreground *:[a]:underline *:[a]:underline-offset-3; } + /* MARK: Questionnaire */ + .cn-questionnaire { + @apply gap-4; + } + + .cn-questionnaire-progress { + @apply text-xs; + } + + .cn-questionnaire-item { + @apply flex flex-col gap-4; + } + + .cn-questionnaire-title { + @apply text-sm font-medium [&:not(:has(~[data-slot=questionnaire-description]))]:mb-4; + } + + .cn-questionnaire-description { + @apply text-xs/relaxed; + } + + .cn-questionnaire-choices { + @apply gap-2; + } + + .cn-questionnaire-choice { + @apply border-input hover:bg-muted/50 data-checked:bg-muted data-checked:border-foreground/30 data-invalid:border-destructive has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-ring/50 gap-2.5 rounded-none border bg-transparent px-3 py-2.5 text-xs has-[>input:focus-visible]:ring-1; + } + + .cn-questionnaire-choice-indicator { + @apply border-input group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground group-data-checked/questionnaire-choice:border-primary size-4 rounded-none; + } + + .cn-questionnaire-choice-indicator-dot { + @apply bg-primary-foreground size-2; + } + + .cn-questionnaire-choice-indicator-check { + @apply size-3.5; + } + + .cn-questionnaire-choice-content { + @apply gap-0.5; + } + + .cn-questionnaire-shortcut { + @apply border-input bg-background text-muted-foreground size-4 items-center justify-center rounded-none border font-mono text-[0.625rem] font-medium leading-none; + } + + .cn-questionnaire-input-wrapper { + @apply w-full; + } + + .cn-questionnaire-input { + @apply dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-none border bg-transparent px-2.5 py-1 text-xs focus-visible:ring-1 aria-invalid:ring-1 md:text-xs; + } + + .cn-questionnaire-error { + @apply text-xs; + } + + .cn-questionnaire-actions { + @apply gap-1.5 sm:min-h-8; + } + /* MARK: Message Scroller */ .cn-message-scroller-content { @apply gap-6; diff --git a/apps/v4/registry/styles/style-maia.css b/apps/v4/registry/styles/style-maia.css index 62f5eb67f8e..f64b6380263 100644 --- a/apps/v4/registry/styles/style-maia.css +++ b/apps/v4/registry/styles/style-maia.css @@ -1591,6 +1591,71 @@ @apply group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:hover:text-foreground *:[a]:underline *:[a]:underline-offset-3; } + /* MARK: Questionnaire */ + .cn-questionnaire { + @apply gap-6; + } + + .cn-questionnaire-progress { + @apply text-xs; + } + + .cn-questionnaire-item { + @apply flex flex-col gap-5; + } + + .cn-questionnaire-title { + @apply text-base font-semibold [&:not(:has(~[data-slot=questionnaire-description]))]:mb-5; + } + + .cn-questionnaire-description { + @apply text-sm; + } + + .cn-questionnaire-choices { + @apply gap-3; + } + + .cn-questionnaire-choice { + @apply border-input bg-input/20 hover:bg-input/40 data-checked:border-primary/40 data-checked:bg-primary/10 data-invalid:border-destructive has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-ring/50 gap-3.5 rounded-4xl border px-4 py-3.5 text-sm has-[>input:focus-visible]:ring-[3px]; + } + + .cn-questionnaire-choice-indicator { + @apply border-input dark:bg-input/30 group-data-checked/questionnaire-choice:bg-primary dark:group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground group-data-checked/questionnaire-choice:border-primary size-4 rounded-[6px]; + } + + .cn-questionnaire-choice-indicator-dot { + @apply bg-primary-foreground size-2; + } + + .cn-questionnaire-choice-indicator-check { + @apply size-3.5; + } + + .cn-questionnaire-choice-content { + @apply gap-1; + } + + .cn-questionnaire-shortcut { + @apply border-input bg-background/80 text-muted-foreground size-5 items-center justify-center rounded-full border font-mono text-[0.625rem] font-medium leading-none; + } + + .cn-questionnaire-input-wrapper { + @apply w-full; + } + + .cn-questionnaire-input { + @apply bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-4xl border px-3 py-1 text-base focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm; + } + + .cn-questionnaire-error { + @apply text-sm; + } + + .cn-questionnaire-actions { + @apply gap-2 sm:min-h-9; + } + /* MARK: Message Scroller */ .cn-message-scroller-content { @apply gap-8; diff --git a/apps/v4/registry/styles/style-mira.css b/apps/v4/registry/styles/style-mira.css index a2750006790..bc8f08bdfd5 100644 --- a/apps/v4/registry/styles/style-mira.css +++ b/apps/v4/registry/styles/style-mira.css @@ -1596,6 +1596,71 @@ @apply group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:hover:text-foreground *:[a]:underline *:[a]:underline-offset-3; } + /* MARK: Questionnaire */ + .cn-questionnaire { + @apply gap-4; + } + + .cn-questionnaire-progress { + @apply text-[0.625rem]; + } + + .cn-questionnaire-item { + @apply flex flex-col gap-3; + } + + .cn-questionnaire-title { + @apply text-sm font-semibold [&:not(:has(~[data-slot=questionnaire-description]))]:mb-3; + } + + .cn-questionnaire-description { + @apply text-xs/relaxed; + } + + .cn-questionnaire-choices { + @apply gap-1.5; + } + + .cn-questionnaire-choice { + @apply border-input bg-input/20 hover:bg-input/40 data-checked:border-primary/40 data-checked:bg-primary/10 data-invalid:border-destructive has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-ring/30 gap-2.5 rounded-xl border px-3 py-2.5 text-xs/relaxed has-[>input:focus-visible]:ring-2; + } + + .cn-questionnaire-choice-indicator { + @apply border-input dark:bg-input/30 group-data-checked/questionnaire-choice:bg-primary dark:group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground group-data-checked/questionnaire-choice:border-primary size-4 rounded-[4px]; + } + + .cn-questionnaire-choice-indicator-dot { + @apply bg-primary-foreground size-2; + } + + .cn-questionnaire-choice-indicator-check { + @apply size-3.5; + } + + .cn-questionnaire-choice-content { + @apply gap-0.5; + } + + .cn-questionnaire-shortcut { + @apply border-input bg-background/80 text-muted-foreground size-4 items-center justify-center rounded-sm border font-mono text-[0.5625rem] font-medium leading-none; + } + + .cn-questionnaire-input-wrapper { + @apply w-full; + } + + .cn-questionnaire-input { + @apply bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 rounded-md border px-2 py-0.5 text-sm focus-visible:ring-2 aria-invalid:ring-2 md:text-xs/relaxed; + } + + .cn-questionnaire-error { + @apply text-xs/relaxed; + } + + .cn-questionnaire-actions { + @apply gap-1.5 sm:min-h-7; + } + /* MARK: Message Scroller */ .cn-message-scroller-content { @apply gap-6; diff --git a/apps/v4/registry/styles/style-nova.css b/apps/v4/registry/styles/style-nova.css index 084f66cf70c..dc9d3d807c0 100644 --- a/apps/v4/registry/styles/style-nova.css +++ b/apps/v4/registry/styles/style-nova.css @@ -1594,6 +1594,71 @@ @apply group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:hover:text-foreground *:[a]:underline *:[a]:underline-offset-3; } + /* MARK: Questionnaire */ + .cn-questionnaire { + @apply gap-4; + } + + .cn-questionnaire-progress { + @apply text-xs; + } + + .cn-questionnaire-item { + @apply flex flex-col gap-4; + } + + .cn-questionnaire-title { + @apply text-base leading-snug font-medium [&:not(:has(~[data-slot=questionnaire-description]))]:mb-4; + } + + .cn-questionnaire-description { + @apply text-muted-foreground text-sm; + } + + .cn-questionnaire-choices { + @apply gap-2; + } + + .cn-questionnaire-choice { + @apply border-input dark:bg-input/20 hover:bg-muted/50 data-checked:border-primary/40 data-checked:bg-muted dark:data-checked:bg-muted data-invalid:border-destructive has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-ring/50 gap-2.5 rounded-lg border bg-transparent px-3 py-2.5 text-sm has-[>input:focus-visible]:ring-3; + } + + .cn-questionnaire-choice-indicator { + @apply border-input dark:bg-input/30 group-data-checked/questionnaire-choice:bg-primary dark:group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground group-data-checked/questionnaire-choice:border-primary size-4 rounded-[4px]; + } + + .cn-questionnaire-choice-indicator-dot { + @apply bg-primary-foreground size-2; + } + + .cn-questionnaire-choice-indicator-check { + @apply size-3.5; + } + + .cn-questionnaire-choice-content { + @apply gap-0.5; + } + + .cn-questionnaire-shortcut { + @apply border-input bg-background text-muted-foreground size-5 items-center justify-center rounded-md border font-mono text-[0.625rem] font-medium leading-none; + } + + .cn-questionnaire-input-wrapper { + @apply w-full; + } + + .cn-questionnaire-input { + @apply dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base focus-visible:ring-3 aria-invalid:ring-3 md:text-sm; + } + + .cn-questionnaire-error { + @apply text-sm; + } + + .cn-questionnaire-actions { + @apply gap-2 sm:min-h-8; + } + /* MARK: Message Scroller */ .cn-message-scroller-content { @apply gap-6; diff --git a/apps/v4/registry/styles/style-rhea.css b/apps/v4/registry/styles/style-rhea.css index 053bb5d7cc1..10bc6922315 100644 --- a/apps/v4/registry/styles/style-rhea.css +++ b/apps/v4/registry/styles/style-rhea.css @@ -1590,6 +1590,71 @@ @apply group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:hover:text-foreground *:[a]:underline *:[a]:underline-offset-3; } + /* MARK: Questionnaire */ + .cn-questionnaire { + @apply gap-6; + } + + .cn-questionnaire-progress { + @apply text-xs; + } + + .cn-questionnaire-item { + @apply flex flex-col gap-5; + } + + .cn-questionnaire-title { + @apply text-base font-semibold [&:not(:has(~[data-slot=questionnaire-description]))]:mb-5; + } + + .cn-questionnaire-description { + @apply text-sm; + } + + .cn-questionnaire-choices { + @apply gap-3; + } + + .cn-questionnaire-choice { + @apply border-input bg-input/20 hover:bg-input/40 data-checked:border-primary/40 data-checked:bg-primary/10 data-invalid:border-destructive has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-ring/50 gap-3 rounded-2xl border px-4 py-3 text-sm has-[>input:focus-visible]:ring-3; + } + + .cn-questionnaire-choice-indicator { + @apply bg-input/90 group-data-checked/questionnaire-choice:bg-primary dark:group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground group-data-checked/questionnaire-choice:border-primary size-4 rounded-[5px] border-transparent; + } + + .cn-questionnaire-choice-indicator-dot { + @apply bg-primary-foreground size-2 dark:size-2.5; + } + + .cn-questionnaire-choice-indicator-check { + @apply size-3.5; + } + + .cn-questionnaire-choice-content { + @apply gap-1; + } + + .cn-questionnaire-shortcut { + @apply border-primary/10 bg-background/80 text-muted-foreground size-5 items-center justify-center rounded-full border font-mono text-[0.625rem] font-medium leading-none; + } + + .cn-questionnaire-input-wrapper { + @apply w-full; + } + + .cn-questionnaire-input { + @apply bg-input/50 border-transparent focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-8 rounded-2xl border px-2.5 py-1 text-base duration-200 focus-visible:ring-3 aria-invalid:ring-3 md:text-sm; + } + + .cn-questionnaire-error { + @apply text-sm; + } + + .cn-questionnaire-actions { + @apply gap-2 sm:min-h-8; + } + /* MARK: Message Scroller */ .cn-message-scroller-content { @apply gap-8; diff --git a/apps/v4/registry/styles/style-sera.css b/apps/v4/registry/styles/style-sera.css index 3d983e34f59..908bcd61458 100644 --- a/apps/v4/registry/styles/style-sera.css +++ b/apps/v4/registry/styles/style-sera.css @@ -1581,6 +1581,71 @@ @apply group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:hover:text-foreground *:[a]:underline *:[a]:underline-offset-3; } + /* MARK: Questionnaire */ + .cn-questionnaire { + @apply gap-6; + } + + .cn-questionnaire-progress { + @apply text-xs uppercase tracking-wide; + } + + .cn-questionnaire-item { + @apply flex flex-col gap-5; + } + + .cn-questionnaire-title { + @apply text-xs font-semibold uppercase tracking-wide [&:not(:has(~[data-slot=questionnaire-description]))]:mb-5; + } + + .cn-questionnaire-description { + @apply text-sm normal-case tracking-normal; + } + + .cn-questionnaire-choices { + @apply gap-3; + } + + .cn-questionnaire-choice { + @apply hover:bg-muted/50 data-checked:bg-primary/5 data-checked:border-primary/30 dark:data-checked:border-primary/20 dark:data-checked:bg-primary/10 data-invalid:border-destructive has-[>input:focus-visible]:ring-ring/30 gap-3 rounded-none border border-input bg-transparent px-4 py-4 text-sm has-[>input:focus-visible]:ring-2; + } + + .cn-questionnaire-choice-indicator { + @apply border-input group-data-checked/questionnaire-choice:border-foreground group-data-[type=checkbox]/questionnaire-choice:group-data-checked/questionnaire-choice:bg-primary group-data-[type=checkbox]/questionnaire-choice:group-data-checked/questionnaire-choice:text-primary-foreground group-data-[type=checkbox]/questionnaire-choice:group-data-checked/questionnaire-choice:border-primary size-4.5; + } + + .cn-questionnaire-choice-indicator-dot { + @apply bg-foreground size-2; + } + + .cn-questionnaire-choice-indicator-check { + @apply size-3.5; + } + + .cn-questionnaire-choice-content { + @apply gap-1; + } + + .cn-questionnaire-shortcut { + @apply border-input bg-background text-muted-foreground size-5 items-center justify-center rounded-none border font-mono text-[0.625rem] font-medium leading-none; + } + + .cn-questionnaire-input-wrapper { + @apply w-full; + } + + .cn-questionnaire-input { + @apply border-transparent border-b-input bg-transparent focus-visible:border-b-ring aria-invalid:border-b-destructive dark:aria-invalid:border-b-destructive/50 h-10 border px-0 py-1 text-base md:text-sm; + } + + .cn-questionnaire-error { + @apply text-sm; + } + + .cn-questionnaire-actions { + @apply gap-2 sm:min-h-10; + } + /* MARK: Message Scroller */ .cn-message-scroller-content { @apply gap-8; diff --git a/apps/v4/registry/styles/style-vega.css b/apps/v4/registry/styles/style-vega.css index 656709dabd6..a7f219baf64 100644 --- a/apps/v4/registry/styles/style-vega.css +++ b/apps/v4/registry/styles/style-vega.css @@ -1590,6 +1590,71 @@ @apply group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:hover:text-foreground *:[a]:underline *:[a]:underline-offset-3; } + /* MARK: Questionnaire */ + .cn-questionnaire { + @apply gap-6; + } + + .cn-questionnaire-progress { + @apply text-xs; + } + + .cn-questionnaire-item { + @apply flex flex-col gap-5; + } + + .cn-questionnaire-title { + @apply text-base font-semibold [&:not(:has(~[data-slot=questionnaire-description]))]:mb-5; + } + + .cn-questionnaire-description { + @apply text-sm; + } + + .cn-questionnaire-choices { + @apply gap-3; + } + + .cn-questionnaire-choice { + @apply border-input dark:bg-input/20 hover:bg-muted/50 data-checked:border-primary/40 data-checked:bg-muted dark:data-checked:bg-muted data-invalid:border-destructive has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-ring/50 gap-3.5 rounded-md border bg-transparent px-4 py-3.5 text-sm shadow-xs has-[>input:focus-visible]:ring-3; + } + + .cn-questionnaire-choice-indicator { + @apply border-input dark:bg-input/30 group-data-checked/questionnaire-choice:bg-primary dark:group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground group-data-checked/questionnaire-choice:border-primary size-4 rounded-[4px]; + } + + .cn-questionnaire-choice-indicator-dot { + @apply bg-primary-foreground size-2; + } + + .cn-questionnaire-choice-indicator-check { + @apply size-3.5; + } + + .cn-questionnaire-choice-content { + @apply gap-1; + } + + .cn-questionnaire-shortcut { + @apply border-input bg-background text-muted-foreground size-5 items-center justify-center rounded-md border font-mono text-[0.625rem] font-medium leading-none shadow-xs; + } + + .cn-questionnaire-input-wrapper { + @apply w-full; + } + + .cn-questionnaire-input { + @apply dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-md border bg-transparent px-2.5 py-1 text-base shadow-xs focus-visible:ring-3 aria-invalid:ring-3 md:text-sm; + } + + .cn-questionnaire-error { + @apply text-sm; + } + + .cn-questionnaire-actions { + @apply gap-2 sm:min-h-9; + } + /* MARK: Message Scroller */ .cn-message-scroller-content { @apply gap-8; diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 697c5964a32..ce7b89aeeaf 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,11 @@ # @shadcn/react +## 0.3.0 + +### Minor Changes + +- [#11414](https://github.com/shadcn-ui/ui/pull/11414) [`3e54530e31020e2277df03490a30a08e3bc1792b`](https://github.com/shadcn-ui/ui/commit/3e54530e31020e2277df03490a30a08e3bc1792b) Thanks [@shadcn](https://github.com/shadcn)! - Add the Questionnaire primitive for multi-step questions, freeform answers, validation, and keyboard navigation. + ## 0.2.1 ### Patch Changes diff --git a/packages/react/package.json b/packages/react/package.json index 9a9c1fd716c..7cf369a0a31 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@shadcn/react", - "version": "0.2.1", + "version": "0.3.0", "description": "Unstyled components for React.", "publishConfig": { "access": "public" @@ -37,6 +37,10 @@ "./message-scroller": { "types": "./dist/message-scroller/index.d.ts", "default": "./dist/message-scroller/index.js" + }, + "./questionnaire": { + "types": "./dist/questionnaire/index.d.ts", + "default": "./dist/questionnaire/index.js" } }, "scripts": { @@ -69,6 +73,7 @@ "rimraf": "^6.0.1", "tsup": "^8.5.0", "typescript": "^5.9.2", - "vite-tsconfig-paths": "^4.3.2" + "vite-tsconfig-paths": "^4.3.2", + "vitest": "^3.2.6" } } diff --git a/packages/react/src/questionnaire/README.md b/packages/react/src/questionnaire/README.md new file mode 100644 index 00000000000..50e49b4bb04 --- /dev/null +++ b/packages/react/src/questionnaire/README.md @@ -0,0 +1,98 @@ +# Questionnaire + +An unstyled, multi-step questionnaire for React. `Root` renders a native form, +and answers are read with `FormData`. + +Questionnaire supports server rendering. Pass `Root.items` to include complete +collection-derived state in the initial HTML. + +## Usage + +```tsx +const items = [ + { + name: "prototype", + required: true, + prompt: "What should we prototype next?", + description: "Choose a direction or write your own.", + choices: [ + { + value: "delegation", + label: "Delegation", + description: "Show how work moves to a specialist.", + }, + { value: "questions", label: "Question prompts" }, + ], + input: { label: "Another answer", placeholder: "Type another answer…" }, + }, + { + name: "detail", + required: false, + prompt: "How much detail?", + description: "Skip this if you are not sure yet.", + choices: [ + { value: "focused", label: "Focused" }, + { value: "complete", label: "Complete flow" }, + ], + }, +] as const +``` + +```tsx +import { Questionnaire } from "@shadcn/react/questionnaire" + +export function ProjectQuestionnaire() { + return ( + { + event.preventDefault() + const answers = new FormData(event.currentTarget) + }} + > + + {items.map((question) => ( + + {question.prompt} + + {question.description} + + + {question.choices.map((choice) => ( + + + + {choice.label} + {"description" in choice ? ( + {choice.description} + ) : null} + + + + ))} + {"input" in question ? ( + + ) : null} + + + + ))} + + + + + + ) +} +``` + +## Documentation + +Read the full docs at [ui.shadcn.com/docs/react/questionnaire](https://ui.shadcn.com/docs/react/questionnaire). diff --git a/packages/react/src/questionnaire/collection.ts b/packages/react/src/questionnaire/collection.ts new file mode 100644 index 00000000000..b4c14b13ed9 --- /dev/null +++ b/packages/react/src/questionnaire/collection.ts @@ -0,0 +1,234 @@ +import type { + ItemRegistration, + QuestionnaireItemDefinition, + QuestionnaireShortcutMode, +} from "./types" +import { getShortcutKeys } from "./utils" + +type QuestionnaireCollection = { + enabledItems: readonly QuestionnaireItemDefinition[] + itemByName: ReadonlyMap + items: readonly QuestionnaireItemDefinition[] +} + +function createQuestionnaireCollection( + items: readonly QuestionnaireItemDefinition[] | undefined +): QuestionnaireCollection | null { + if (items === undefined) { + return null + } + + return { + enabledItems: items.filter((item) => !item.disabled), + itemByName: new Map(items.map((item) => [item.name, item])), + items, + } +} + +function getInitialItemName( + collection: QuestionnaireCollection | null, + defaultItem: string | undefined +) { + if (!collection) { + return defaultItem ?? null + } + + const defaultDefinition = defaultItem + ? collection.itemByName.get(defaultItem) + : undefined + + if (defaultDefinition && !defaultDefinition.disabled) { + return defaultDefinition.name + } + + return collection.enabledItems[0]?.name ?? null +} + +function getShortcutByChoiceValue( + item: QuestionnaireItemDefinition | undefined, + shortcuts: QuestionnaireShortcutMode | null +) { + const shortcutByChoiceValue = new Map() + + if (!item || !shortcuts) { + return shortcutByChoiceValue + } + + const keys = getShortcutKeys(shortcuts) + let shortcutIndex = 0 + + for (const choice of item.choices ?? []) { + if (choice.disabled) { + continue + } + + const shortcut = keys[shortcutIndex] + + if (!shortcut) { + break + } + + shortcutByChoiceValue.set(choice.value, shortcut) + shortcutIndex += 1 + } + + return shortcutByChoiceValue +} + +function getCollectionDefinitionWarnings( + collection: QuestionnaireCollection, + defaultItem: string | undefined +) { + const warnings: string[] = [] + const itemNames = new Set() + + for (const item of collection.items) { + if (itemNames.has(item.name)) { + warnings.push(`Item name "${item.name}" is defined more than once.`) + } + + itemNames.add(item.name) + + const choiceValues = new Set() + + for (const choice of item.choices ?? []) { + if (choiceValues.has(choice.value)) { + warnings.push( + `Choice value "${choice.value}" is defined more than once in item "${item.name}".` + ) + } + + choiceValues.add(choice.value) + } + } + + if (defaultItem) { + const defaultDefinition = collection.itemByName.get(defaultItem) + + if (!defaultDefinition || defaultDefinition.disabled) { + warnings.push( + `defaultItem "${defaultItem}" does not identify an enabled item. The first enabled item will be used instead.` + ) + } + } + + return warnings +} + +function getCollectionRegistrationWarnings( + collection: QuestionnaireCollection, + registrations: readonly ItemRegistration[], + shortcuts: QuestionnaireShortcutMode | null +) { + const warnings: string[] = [] + const registrationByName = new Map( + registrations.map((registration) => [registration.name, registration]) + ) + + for (const definition of collection.items) { + const registration = registrationByName.get(definition.name) + + if (!registration) { + if (!definition.disabled) { + warnings.push( + `Item "${definition.name}" is defined but has no rendered Questionnaire.Item.` + ) + } + + continue + } + + if (registration.disabled !== Boolean(definition.disabled)) { + warnings.push( + `Item "${definition.name}" has different disabled values in Root.items and Questionnaire.Item.` + ) + } + + if (registration.required !== Boolean(definition.required)) { + warnings.push( + `Item "${definition.name}" has different required values in Root.items and Questionnaire.Item.` + ) + } + + const definedChoices = definition.choices ?? [] + const definedChoiceByValue = new Map( + definedChoices.map((choice) => [choice.value, choice]) + ) + const registeredChoiceByValue = new Map( + registration.choices.map((choice) => [choice.value, choice]) + ) + + for (const choice of definedChoices) { + const registeredChoice = registeredChoiceByValue.get(choice.value) + + if (!registeredChoice) { + warnings.push( + `Choice "${choice.value}" is defined for item "${definition.name}" but has no rendered Questionnaire.Choice.` + ) + continue + } + + if (registeredChoice.disabled !== Boolean(choice.disabled)) { + warnings.push( + `Choice "${choice.value}" in item "${definition.name}" has different disabled values in Root.items and Questionnaire.Choice.` + ) + } + } + + if (shortcuts) { + for (const choice of registration.choices) { + if (!definedChoiceByValue.has(choice.value)) { + warnings.push( + `Rendered choice "${choice.value}" in item "${definition.name}" is missing from Root.items and will not receive a shortcut.` + ) + } + } + + const definedOrder = definedChoices + .filter((choice) => !choice.disabled) + .map((choice) => choice.value) + const registeredOrder = registration.choices + .filter((choice) => !choice.disabled) + .map((choice) => choice.value) + + const sameChoices = + definedOrder.length > 0 && + definedOrder.length === registeredOrder.length && + definedOrder.every((choiceValue) => + registeredOrder.includes(choiceValue) + ) + + if ( + sameChoices && + definedOrder.some( + (choiceValue, index) => choiceValue !== registeredOrder[index] + ) + ) { + warnings.push( + `Choice order for item "${definition.name}" differs between Root.items and the rendered Questionnaire.Choice elements.` + ) + } + } + } + + for (const registration of registrations) { + if ( + !registration.disabled && + !collection.itemByName.has(registration.name) + ) { + warnings.push( + `Rendered item "${registration.name}" is missing from Root.items and is excluded from the questionnaire collection.` + ) + } + } + + return warnings +} + +export { + createQuestionnaireCollection, + getCollectionDefinitionWarnings, + getCollectionRegistrationWarnings, + getInitialItemName, + getShortcutByChoiceValue, +} diff --git a/packages/react/src/questionnaire/components.tsx b/packages/react/src/questionnaire/components.tsx new file mode 100644 index 00000000000..0b0513a6946 --- /dev/null +++ b/packages/react/src/questionnaire/components.tsx @@ -0,0 +1,551 @@ +import * as React from "react" + +import { mergeProps, useRender } from "../use-render" +import { + QuestionnaireChoiceContext, + QuestionnaireContext, + QuestionnaireItemContext, + useQuestionnaireChoiceContext, + useQuestionnaireContext, + useQuestionnaireItemContext, +} from "./context" +import type { + QuestionnaireChoiceInputProps, + QuestionnaireChoiceLabelProps, + QuestionnaireChoiceProps, + QuestionnaireChoiceShortcutProps, + QuestionnaireChoicesProps, + QuestionnaireDescriptionProps, + QuestionnaireErrorProps, + QuestionnaireInputProps, + QuestionnaireItemProps, + QuestionnaireNavigationState, + QuestionnaireNextProps, + QuestionnairePreviousProps, + QuestionnaireProgressProps, + QuestionnaireRootProps, + QuestionnaireSkipProps, + QuestionnaireSubmitProps, + QuestionnaireTitleProps, +} from "./types" +import { useQuestionnaireChoice } from "./use-questionnaire-choice" +import { useQuestionnaireInput } from "./use-questionnaire-input" +import { useQuestionnaireItem } from "./use-questionnaire-item" +import { useQuestionnaireRoot } from "./use-questionnaire-root" + +function QuestionnaireRoot({ + defaultItem, + item, + items, + noValidate = true, + onItemChange, + onReset, + onSubmit, + ref, + shortcuts, + ...props +}: QuestionnaireRootProps) { + const { context, rootProps, state } = useQuestionnaireRoot({ + defaultItem, + item, + items, + noValidate, + onItemChange, + onReset, + onSubmit, + ref, + shortcuts, + }) + const element = useRender({ + defaultTagName: "form", + props: mergeProps<"form">({ ...rootProps, noValidate }, props), + state, + }) + + return ( + + {element} + + ) +} + +function QuestionnaireProgress({ + children, + render, + ...props +}: QuestionnaireProgressProps) { + const { current, first, last, total } = useQuestionnaireContext( + "Questionnaire.Progress" + ) + const label = total ? `Question ${current} of ${total}` : undefined + + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">( + { + "aria-label": "Questionnaire progress", + "aria-live": "polite", + "aria-valuemax": total || undefined, + "aria-valuemin": total ? 1 : undefined, + "aria-valuenow": total ? current : undefined, + "aria-valuetext": label, + children: children ?? label, + role: "progressbar", + }, + props + ), + render, + state: { current, first, last, total }, + }) +} + +function QuestionnaireItem({ + "aria-describedby": ariaDescribedBy, + "aria-keyshortcuts": ariaKeyShortcuts, + children, + disabled = false, + invalid = false, + multiple = false, + name, + onStatusChange, + ref, + required = false, + ...props +}: QuestionnaireItemProps) { + const { context, itemProps, state } = useQuestionnaireItem({ + "aria-describedby": ariaDescribedBy, + "aria-keyshortcuts": ariaKeyShortcuts, + disabled, + invalid, + multiple, + name, + onStatusChange, + ref, + required, + }) + const element = useRender({ + defaultTagName: "fieldset", + props: mergeProps<"fieldset">({ ...itemProps, children }, props), + state, + stateAttributesMapping: { + active: (isActive) => ({ + "data-active": isActive ? "" : undefined, + }), + }, + }) + + return ( + + {element} + + ) +} + +function QuestionnaireTitle({ render, ...props }: QuestionnaireTitleProps) { + useQuestionnaireItemContext("Questionnaire.Title") + + return useRender({ + defaultTagName: "legend", + props, + render, + }) +} + +function QuestionnaireDescription({ + id, + render, + ...props +}: QuestionnaireDescriptionProps) { + const { registerDescription } = useQuestionnaireItemContext( + "Questionnaire.Description" + ) + const generatedId = React.useId() + const descriptionId = id ?? generatedId + + React.useLayoutEffect( + () => registerDescription(descriptionId), + [descriptionId, registerDescription] + ) + + return useRender({ + defaultTagName: "p", + props: mergeProps<"p">({ id: descriptionId }, props), + render, + }) +} + +function QuestionnaireChoices({ render, ...props }: QuestionnaireChoicesProps) { + const { shortcuts } = useQuestionnaireItemContext("Questionnaire.Choices") + + return useRender({ + defaultTagName: "div", + props, + render, + state: { shortcuts }, + }) +} + +function QuestionnaireChoice({ + checked, + children, + defaultChecked = false, + disabled = false, + onChange, + render, + value, + ...props +}: QuestionnaireChoiceProps) { + const { inputProps, state } = useQuestionnaireChoice({ + checked, + defaultChecked, + disabled, + onChange, + value, + }) + const element = useRender({ + defaultTagName: "label", + props: mergeProps<"label">({ children }, props), + render, + state, + stateAttributesMapping: { + checked: (isChecked) => ({ + "data-checked": isChecked ? "" : undefined, + "data-unchecked": isChecked ? undefined : "", + }), + }, + }) + + return ( + + {element} + + ) +} + +function QuestionnaireChoiceInput({ + render, + ...props +}: QuestionnaireChoiceInputProps) { + const { inputProps, state } = useQuestionnaireChoiceContext( + "Questionnaire.ChoiceInput" + ) + + return useRender({ + defaultTagName: "input", + props: mergeProps<"input">(inputProps, props), + render, + state, + stateAttributesMapping: { + checked: (isChecked) => ({ + "data-checked": isChecked ? "" : undefined, + "data-unchecked": isChecked ? undefined : "", + }), + }, + }) +} + +function QuestionnaireChoiceLabel({ + render, + ...props +}: QuestionnaireChoiceLabelProps) { + useQuestionnaireChoiceContext("Questionnaire.ChoiceLabel") + + return useRender({ + defaultTagName: "span", + props, + render, + }) +} + +function QuestionnaireChoiceShortcut({ + children, + render, + ...props +}: QuestionnaireChoiceShortcutProps) { + const { state } = useQuestionnaireChoiceContext( + "Questionnaire.ChoiceShortcut" + ) + const shortcutState = { shortcut: state.shortcut } + + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + "aria-hidden": true, + children: children ?? state.shortcut, + hidden: state.shortcut === null, + }, + props + ), + render, + state: shortcutState, + }) +} + +function QuestionnaireInput({ + defaultValue, + disabled = false, + onChange, + ref, + render, + type = "text", + value, + ...props +}: QuestionnaireInputProps) { + const { inputProps, state } = useQuestionnaireInput({ + defaultValue, + disabled, + onChange, + ref, + type, + value, + }) + + return useRender({ + defaultTagName: "input", + props: mergeProps<"input">(inputProps, props), + render, + state, + stateAttributesMapping: { + filled: (isFilled) => ({ + "data-empty": isFilled ? undefined : "", + "data-filled": isFilled ? "" : undefined, + }), + }, + }) +} + +function QuestionnaireError({ + children, + id, + render, + ...props +}: QuestionnaireErrorProps) { + const { invalid, registerError, required } = useQuestionnaireItemContext( + "Questionnaire.Error" + ) + const generatedId = React.useId() + const errorId = id ?? generatedId + + React.useLayoutEffect(() => registerError(errorId), [errorId, registerError]) + + return useRender({ + defaultTagName: "p", + props: mergeProps<"p">( + { + children: + children ?? + (required + ? "Choose an answer to continue." + : "Choose an answer or skip this question."), + hidden: !invalid, + id: errorId, + role: invalid ? "alert" : undefined, + }, + props + ), + render, + state: { invalid }, + }) +} + +function QuestionnairePrevious({ + children, + disabled: disabledProp = false, + onClick, + render, + tabIndex, + type = "button", + ...props +}: QuestionnairePreviousProps) { + const context = useQuestionnaireContext("Questionnaire.Previous") + const visible = context.total > 1 && !context.first + + function handleClick(event: React.MouseEvent) { + onClick?.(event) + + if (!event.defaultPrevented) { + context.goPrevious() + } + } + + return useRenderNavigationButton({ + children: children ?? "Previous", + disabled: disabledProp, + onClick: handleClick, + props, + render, + status: context.activeItemStatus, + tabIndex, + type, + visible, + }) +} + +function QuestionnaireSkip({ + children, + disabled: disabledProp = false, + onClick, + render, + tabIndex, + type = "button", + ...props +}: QuestionnaireSkipProps) { + const context = useQuestionnaireContext("Questionnaire.Skip") + const visible = context.activeItemRequired === false + + function handleClick(event: React.MouseEvent) { + onClick?.(event) + + if (!event.defaultPrevented) { + context.skipCurrent() + } + } + + return useRenderNavigationButton({ + children: children ?? "Skip", + disabled: disabledProp, + onClick: handleClick, + props, + render, + status: context.activeItemStatus, + tabIndex, + type, + visible, + }) +} + +function QuestionnaireNext({ + children, + disabled: disabledProp = false, + onClick, + render, + tabIndex, + type = "button", + ...props +}: QuestionnaireNextProps) { + const context = useQuestionnaireContext("Questionnaire.Next") + const visible = context.total > 1 && !context.last + + function handleClick(event: React.MouseEvent) { + onClick?.(event) + + if (!event.defaultPrevented) { + context.goNext() + } + } + + return useRenderNavigationButton({ + children: children ?? "Next", + disabled: disabledProp, + onClick: handleClick, + props, + render, + shortcut: "Enter", + status: context.activeItemStatus, + tabIndex, + type, + visible, + }) +} + +function QuestionnaireSubmit({ + children, + disabled: disabledProp = false, + render, + tabIndex, + type = "submit", + ...props +}: QuestionnaireSubmitProps) { + const context = useQuestionnaireContext("Questionnaire.Submit") + const visible = context.total > 0 && context.last + + return useRenderNavigationButton({ + children: children ?? "Submit", + disabled: disabledProp, + props, + render, + shortcut: "Enter", + status: context.activeItemStatus, + tabIndex, + type, + visible, + }) +} + +function useRenderNavigationButton({ + children, + disabled, + onClick, + props, + render, + shortcut, + status, + tabIndex, + type, + visible, +}: { + children: React.ReactNode + disabled: boolean + onClick?: React.MouseEventHandler + props: React.ComponentPropsWithRef<"button"> + render: QuestionnaireNextProps["render"] + shortcut?: "Enter" + status: QuestionnaireNavigationState["status"] + tabIndex: number | undefined + type: "button" | "reset" | "submit" + visible: boolean +}) { + const activeShortcut = visible && !disabled ? (shortcut ?? null) : null + const state: QuestionnaireNavigationState = { + disabled, + shortcut: activeShortcut, + status, + visible, + } + + return useRender({ + defaultTagName: "button", + props: mergeProps<"button">( + { + "aria-hidden": !visible || undefined, + "aria-keyshortcuts": activeShortcut ?? undefined, + children, + disabled, + hidden: !visible, + inert: !visible, + onClick, + tabIndex: visible ? tabIndex : -1, + type, + }, + props + ), + render, + state, + stateAttributesMapping: { + visible: (isVisible) => ({ + "data-hidden": isVisible ? undefined : "", + "data-visible": isVisible ? "" : undefined, + }), + }, + }) +} + +export { + QuestionnaireChoice, + QuestionnaireChoiceInput, + QuestionnaireChoiceLabel, + QuestionnaireChoiceShortcut, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireRoot, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +} diff --git a/packages/react/src/questionnaire/context.ts b/packages/react/src/questionnaire/context.ts new file mode 100644 index 00000000000..74233db652b --- /dev/null +++ b/packages/react/src/questionnaire/context.ts @@ -0,0 +1,59 @@ +import * as React from "react" + +import type { + QuestionnaireChoiceContextValue, + QuestionnaireContextValue, + QuestionnaireItemContextValue, +} from "./types" + +const QuestionnaireChoiceContext = + React.createContext(null) +const QuestionnaireContext = + React.createContext(null) +const QuestionnaireItemContext = + React.createContext(null) + +function useQuestionnaireContext(component: string) { + const context = React.useContext(QuestionnaireContext) + + if (!context) { + throw new Error( + `${component} must be used within a Questionnaire.Root component.` + ) + } + + return context +} + +function useQuestionnaireChoiceContext(component: string) { + const context = React.useContext(QuestionnaireChoiceContext) + + if (!context) { + throw new Error( + `${component} must be used within a Questionnaire.Choice component.` + ) + } + + return context +} + +function useQuestionnaireItemContext(component: string) { + const context = React.useContext(QuestionnaireItemContext) + + if (!context) { + throw new Error( + `${component} must be used within a Questionnaire.Item component.` + ) + } + + return context +} + +export { + QuestionnaireChoiceContext, + QuestionnaireContext, + QuestionnaireItemContext, + useQuestionnaireChoiceContext, + useQuestionnaireContext, + useQuestionnaireItemContext, +} diff --git a/packages/react/src/questionnaire/index.ts b/packages/react/src/questionnaire/index.ts new file mode 100644 index 00000000000..2c110b09bbb --- /dev/null +++ b/packages/react/src/questionnaire/index.ts @@ -0,0 +1,45 @@ +import { + QuestionnaireChoice as Choice, + QuestionnaireChoiceInput as ChoiceInput, + QuestionnaireChoiceLabel as ChoiceLabel, + QuestionnaireChoices as Choices, + QuestionnaireChoiceShortcut as ChoiceShortcut, + QuestionnaireDescription as Description, + QuestionnaireError as Error, + QuestionnaireInput as Input, + QuestionnaireItem as Item, + QuestionnaireNext as Next, + QuestionnairePrevious as Previous, + QuestionnaireProgress as Progress, + QuestionnaireRoot as Root, + QuestionnaireSkip as Skip, + QuestionnaireSubmit as Submit, + QuestionnaireTitle as Title, +} from "./components" + +export const Questionnaire = { + Root, + Progress, + Item, + Title, + Description, + Choices, + Choice, + ChoiceInput, + ChoiceLabel, + ChoiceShortcut, + Input, + Error, + Previous, + Skip, + Next, + Submit, +} + +export type { + QuestionnaireChoiceDefinition, + QuestionnaireInputType, + QuestionnaireItemDefinition, + QuestionnaireItemStatus, + QuestionnaireShortcutMode, +} from "./types" diff --git a/packages/react/src/questionnaire/questionnaire-ssr.test.tsx b/packages/react/src/questionnaire/questionnaire-ssr.test.tsx new file mode 100644 index 00000000000..85091eaa98d --- /dev/null +++ b/packages/react/src/questionnaire/questionnaire-ssr.test.tsx @@ -0,0 +1,724 @@ +// @vitest-environment jsdom + +import * as React from "react" +import { act } from "react" +import { hydrateRoot, type Root } from "react-dom/client" +import { renderToString } from "react-dom/server" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { Questionnaire } from "." +import type { QuestionnaireItemDefinition } from "./types" + +const items = [ + { + choices: [ + { value: "delegation" }, + { disabled: true, value: "automatic" }, + { value: "questions" }, + ], + name: "scope", + required: true, + }, + { + choices: [{ value: "ignored" }], + disabled: true, + name: "disabled", + }, + { + choices: [{ value: "focused" }, { value: "complete" }], + name: "detail", + }, +] as const satisfies readonly QuestionnaireItemDefinition[] + +let container: HTMLDivElement +let root: Root | null + +beforeEach(() => { + ;( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean + } + ).IS_REACT_ACT_ENVIRONMENT = true + + container = document.createElement("div") + document.body.appendChild(container) + root = null +}) + +afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + + container.remove() + vi.restoreAllMocks() +}) + +describe("Questionnaire server rendering", () => { + it("renders collection progress, active item, actions, and shortcuts", () => { + renderServerQuestionnaire() + + expect(form().dataset.current).toBe("1") + expect(form().dataset.total).toBe("2") + expect(form().hasAttribute("data-first")).toBe(true) + expect(progress().textContent).toBe("Question 1 of 2") + expect(progress().getAttribute("aria-valuenow")).toBe("1") + expect(progress().getAttribute("aria-valuemax")).toBe("2") + expect(item("scope").hasAttribute("data-active")).toBe(true) + expect(item("scope").hidden).toBe(false) + expect(item("disabled").hidden).toBe(true) + expect(item("detail").hidden).toBe(true) + expect(action("previous").hidden).toBe(true) + expect(action("skip").hidden).toBe(true) + expect(action("next").hidden).toBe(false) + expect(action("next").dataset.status).toBe("unanswered") + expect(action("submit").hidden).toBe(true) + expect(choice("delegation").dataset.shortcut).toBe("A") + expect(choiceInput("delegation").getAttribute("aria-keyshortcuts")).toBe( + "A" + ) + expect(choice("automatic").hasAttribute("data-shortcut")).toBe(false) + expect(shortcut("automatic").hidden).toBe(true) + expect(choice("questions").dataset.shortcut).toBe("B") + }) + + it("renders a requested optional item and its applicable actions", () => { + renderServerQuestionnaire({ defaultItem: "detail" }) + + expect(form().dataset.current).toBe("2") + expect(form().hasAttribute("data-last")).toBe(true) + expect(progress().textContent).toBe("Question 2 of 2") + expect(item("scope").hidden).toBe(true) + expect(item("detail").hasAttribute("data-active")).toBe(true) + expect(action("previous").hidden).toBe(false) + expect(action("skip").hidden).toBe(false) + expect(action("next").hidden).toBe(true) + expect(action("submit").hidden).toBe(false) + }) + + it("renders a controlled item from the collection", () => { + renderMarkup() + + expect(progress().textContent).toBe("Question 2 of 2") + expect(item("scope").hidden).toBe(true) + expect(item("detail").hasAttribute("data-active")).toBe(true) + }) + + it.each(["missing", "disabled"])( + "falls back from an invalid default item %s during render", + (defaultItem) => { + renderServerQuestionnaire({ defaultItem }) + + expect(progress().textContent).toBe("Question 1 of 2") + expect(item("scope").hasAttribute("data-active")).toBe(true) + expect(item("detail").hidden).toBe(true) + } + ) + + it("renders numeric shortcuts and leaves overflow choices unassigned", () => { + const choices = Array.from({ length: 10 }, (_, index) => ({ + value: `choice-${index + 1}`, + })) + const numericItems = [{ choices, name: "numeric" }] + + renderMarkup( + + + Choose a number + {choices.map((choice) => ( + + ))} + + + ) + + expect(choice("choice-1").dataset.shortcut).toBe("1") + expect(choice("choice-9").dataset.shortcut).toBe("9") + expect(choice("choice-10").hasAttribute("data-shortcut")).toBe(false) + }) + + it("renders an input-only item without fixed-choice definitions", () => { + renderMarkup( + + + + Describe the result + + + + + ) + + expect(progress().textContent).toBe("Question 1 of 1") + expect(item("input").hasAttribute("data-active")).toBe(true) + expect(query("input-control").type).toBe("text") + expect(action("submit").hidden).toBe(false) + }) + + it("renders both navigation directions for a middle item", () => { + const middleItems = [ + { name: "first" }, + { name: "middle" }, + { name: "last" }, + ] + + renderMarkup( + + + {middleItems.map((definition) => ( + + {definition.name} + + ))} + + + + + ) + + expect(progress().textContent).toBe("Question 2 of 3") + expect(action("previous").hidden).toBe(false) + expect(action("next").hidden).toBe(false) + expect(action("submit").hidden).toBe(true) + }) + + it("pins the current defaultChecked server-rendering limitation", () => { + const defaultItems = [{ choices: [{ value: "default" }], name: "answer" }] + + renderMarkup( + + + + + + ) + + expect(choiceInput("default").checked).toBe(false) + expect(choice("default").hasAttribute("data-checked")).toBe(false) + }) +}) + +describe("Questionnaire hydration", () => { + it("hydrates without changing collection-derived output", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const onRecoverableError = vi.fn() + const questionnaire = + + renderMarkup(questionnaire) + const initialMarkup = { + actions: actionsMarkup(), + progress: progress().outerHTML, + root: form().outerHTML.match(/]*>/)?.[0], + } + + await act(async () => { + root = hydrateRoot(container, questionnaire, { onRecoverableError }) + }) + + expect(onRecoverableError).not.toHaveBeenCalled() + expect(consoleWarn).not.toHaveBeenCalled() + expect(progress().outerHTML).toBe(initialMarkup.progress) + expect(actionsMarkup()).toBe(initialMarkup.actions) + expect(form().outerHTML.match(/]*>/)?.[0]).toBe(initialMarkup.root) + }) + + it("warns after falling back from an invalid default item", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const questionnaire = + + renderMarkup(questionnaire) + + await act(async () => { + root = hydrateRoot(container, questionnaire) + }) + + expect(progress().textContent).toBe("Question 1 of 2") + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining( + 'defaultItem "missing" does not identify an enabled item' + ) + ) + }) + + it("joins logical navigation to matching runtime items", async () => { + const questionnaire = + + renderMarkup(questionnaire) + + await act(async () => { + root = hydrateRoot(container, questionnaire) + }) + + await act(async () => { + choiceInput("delegation").click() + }) + + await act(async () => { + action("next").click() + }) + + expect(progress().textContent).toBe("Question 2 of 2") + expect(item("scope").hidden).toBe(true) + expect(item("detail").hasAttribute("data-active")).toBe(true) + }) + + it("keeps definition shortcut order authoritative after hydration", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const orderedItems = [ + { + choices: [{ value: "second" }, { value: "first" }], + name: "order", + }, + ] + const questionnaire = ( + + + + + + + ) + + renderMarkup(questionnaire) + + expect(choice("first").dataset.shortcut).toBe("B") + expect(choice("second").dataset.shortcut).toBe("A") + + await act(async () => { + root = hydrateRoot(container, questionnaire) + }) + + await keydown(item("order"), "A") + + expect(choiceInput("second").checked).toBe(true) + expect(choiceInput("first").checked).toBe(false) + expect(choiceInput("second").getAttribute("aria-keyshortcuts")).toContain( + "A" + ) + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining( + 'Choice order for item "order" differs between Root.items' + ) + ) + }) + + it("warns when rendered metadata differs from its definitions", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const questionnaire = ( + + + + + + ) + + renderMarkup(questionnaire) + + await act(async () => { + root = hydrateRoot(container, questionnaire) + }) + + expect(consoleWarn).toHaveBeenCalledTimes(2) + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining( + 'Item "answer" has different required values in Root.items' + ) + ) + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining( + 'Choice "fixed" in item "answer" has different disabled values' + ) + ) + }) + + it("warns about duplicate item names and choice values", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const questionnaire = ( + + + + + + ) + + renderMarkup(questionnaire) + + await act(async () => { + root = hydrateRoot(container, questionnaire) + }) + + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining('Item name "answer" is defined more than once') + ) + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining( + 'Choice value "fixed" is defined more than once in item "answer"' + ) + ) + }) + + it("does not assign post-hydration shortcuts to omitted definitions", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const questionnaire = ( + + + + + + + ) + + renderMarkup(questionnaire) + + expect(shortcut("fixed").hidden).toBe(true) + + await act(async () => { + root = hydrateRoot(container, questionnaire) + }) + + expect(shortcut("fixed").hidden).toBe(true) + expect(choiceInput("fixed").hasAttribute("aria-keyshortcuts")).toBe(false) + expect(consoleWarn).toHaveBeenCalledTimes(1) + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining( + 'Rendered choice "fixed" in item "answer" is missing from Root.items' + ) + ) + }) + + it("updates authoritative order and falls back when the active item is disabled", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const onItemChange = vi.fn() + const initialItems = [ + { name: "first" }, + { name: "second" }, + { name: "third" }, + ] + + function DynamicQuestionnaire({ + definitions, + }: { + definitions: readonly QuestionnaireItemDefinition[] + }) { + return ( + + + {definitions.map((definition) => ( + + {definition.name} + + ))} + + + + + ) + } + + const questionnaire = + renderMarkup(questionnaire) + + await act(async () => { + root = hydrateRoot(container, questionnaire) + }) + + expect(progress().textContent).toBe("Question 2 of 3") + + const reorderedItems = [ + { name: "second" }, + { name: "first" }, + { name: "third" }, + ] + + await act(async () => { + root?.render() + }) + + expect(progress().textContent).toBe("Question 1 of 3") + expect(action("previous").hidden).toBe(true) + + const disabledItems = [ + { disabled: true, name: "second" }, + { name: "first" }, + { name: "third" }, + ] + + await act(async () => { + root?.render() + }) + + expect(progress().textContent).toBe("Question 1 of 2") + expect(item("first").hasAttribute("data-active")).toBe(true) + expect(onItemChange).toHaveBeenLastCalledWith("first") + expect(consoleWarn).toHaveBeenCalledTimes(1) + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining( + 'defaultItem "second" does not identify an enabled item' + ) + ) + + const expandedItems = [ + { name: "first" }, + { name: "third" }, + { name: "fourth" }, + ] + + await act(async () => { + root?.render() + }) + + expect(progress().textContent).toBe("Question 1 of 3") + expect(action("next").hidden).toBe(false) + expect(item("fourth").hidden).toBe(true) + }) + + it("keeps a controlled item aligned with collection updates", async () => { + function ControlledQuestionnaire({ item: activeItem }: { item: string }) { + return ( + + + + Scope + + + + + + Detail + + + + + ) + } + + const questionnaire = + renderMarkup(questionnaire) + + await act(async () => { + root = hydrateRoot(container, questionnaire) + }) + + await act(async () => { + root?.render() + }) + + expect(progress().textContent).toBe("Question 2 of 2") + expect(item("scope").hidden).toBe(true) + expect(item("detail").hasAttribute("data-active")).toBe(true) + }) + + it("reports a mismatch again after it resolves and recurs", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) + + function WarningQuestionnaire({ + includeChoice, + }: { + includeChoice: boolean + }) { + const definitions = [ + { + choices: includeChoice ? [{ value: "fixed" }] : undefined, + name: "answer", + }, + ] + + return ( + + + + + + ) + } + + const questionnaire = + renderMarkup(questionnaire) + + await act(async () => { + root = hydrateRoot(container, questionnaire) + }) + + expect(consoleWarn).not.toHaveBeenCalled() + + await act(async () => { + root?.render() + }) + + expect(consoleWarn).toHaveBeenCalledTimes(1) + + await act(async () => { + root?.render() + }) + + await act(async () => { + root?.render() + }) + + expect(consoleWarn).toHaveBeenCalledTimes(2) + }) +}) + +function TestQuestionnaire({ + defaultItem, + item: controlledItem, +}: { + defaultItem?: string + item?: string +}) { + return ( + + + + + Choose the scope + + + + + + + Disabled question + + + + + Choose the detail + + + + + + + + + + ) +} + +function TestChoice({ + value, + ...props +}: Omit, "children">) { + return ( + + + {value} + + + ) +} + +function renderServerQuestionnaire({ + defaultItem, +}: { + defaultItem?: string +} = {}) { + renderMarkup() +} + +function renderMarkup(element: React.ReactNode) { + container.innerHTML = renderToString(element) +} + +function form() { + return query("root") +} + +function progress() { + return query("progress") +} + +function item(name: string) { + return query(name) +} + +function choice(value: string) { + return query(`choice-${value}`) +} + +function choiceInput(value: string) { + return query(`input-${value}`) +} + +function shortcut(value: string) { + return query(`shortcut-${value}`) +} + +function action(name: "next" | "previous" | "skip" | "submit") { + return query(name) +} + +function actionsMarkup() { + return ["previous", "skip", "next", "submit"] + .map( + (name) => + action(name as "next" | "previous" | "skip" | "submit").outerHTML + ) + .join("") +} + +function query(testId: string) { + const element = container.querySelector( + `[data-testid="${testId}"]` + ) + + if (!element) { + throw new Error(`Missing test element: ${testId}`) + } + + return element +} + +async function keydown(element: Element, key: string) { + await act(async () => { + element.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key }) + ) + }) +} diff --git a/packages/react/src/questionnaire/questionnaire.browser.test.tsx b/packages/react/src/questionnaire/questionnaire.browser.test.tsx new file mode 100644 index 00000000000..3c873dd0fcb --- /dev/null +++ b/packages/react/src/questionnaire/questionnaire.browser.test.tsx @@ -0,0 +1,1421 @@ +import * as React from "react" +import { userEvent } from "@vitest/browser/context" +import { flushSync } from "react-dom" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, expect, test, vi } from "vitest" + +import { Questionnaire, type QuestionnaireItemStatus } from "." + +;( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = false + +let root: Root | null = null +let container: HTMLDivElement | null = null + +afterEach(() => { + root?.unmount() + container?.remove() + root = null + container = null + vi.restoreAllMocks() +}) + +test("selects a composed choice through its label row", async () => { + await render( + + + Choose one + + Answer + + + + ) + + const input = choiceInput("answer") + + await userEvent.click(requiredElement('[data-testid="answer"]')) + await settle() + + expect(input.checked).toBe(true) + expect(new FormData(form()).get("answer")).toBe("answer") +}) + +test("registers a ChoiceInput whenever its composed input mounts", async () => { + function ConditionalChoiceInputQuestionnaire() { + const [showInput, setShowInput] = React.useState(false) + + return ( + + + Answer + + {showInput && ( + + )} + Conditional + + + + + + ) + } + + await render() + + const answer = requiredElement('[data-testid="answer"]') + const choice = requiredElement('[data-testid="conditional-choice"]') + const toggle = requiredElement('[data-testid="toggle-input"]') + + expect(answer.getAttribute("data-status")).toBe("unanswered") + expect(choice.hasAttribute("data-shortcut")).toBe(false) + + await userEvent.click(toggle) + await settle() + + const firstInput = requiredElement( + '[data-testid="conditional-input"]' + ) + + expect(choice.getAttribute("data-shortcut")).toBe("A") + + await userEvent.click(firstInput) + await settle() + + expect(answer.getAttribute("data-status")).toBe("answered") + + await userEvent.click(toggle) + await settle() + + expect(answer.getAttribute("data-status")).toBe("unanswered") + + await userEvent.click(toggle) + await settle() + + const secondInput = requiredElement( + '[data-testid="conditional-input"]' + ) + + expect(secondInput).not.toBe(firstInput) + expect(secondInput.checked).toBe(true) + expect(answer.getAttribute("data-status")).toBe("answered") +}) + +test("preserves a compatible selection when multiple changes", async () => { + function DynamicMultipleQuestionnaire() { + const [multiple, setMultiple] = React.useState(true) + + return ( + + + Signals + + First + + + Second + + + + + ) + } + + await render() + + await userEvent.click(choiceInput("first-signal")) + await userEvent.click(choiceInput("second-signal")) + await settle() + + expect(new FormData(form()).getAll("signals")).toEqual(["first", "second"]) + + await userEvent.click(requiredElement('[data-testid="toggle-multiple"]')) + await settle() + + expect(choiceInput("first-signal").type).toBe("radio") + expect(choiceInput("first-signal").checked).toBe(true) + expect(choiceInput("second-signal").checked).toBe(false) + expect(new FormData(form()).getAll("signals")).toEqual(["first"]) + expect( + requiredElement('[data-testid="signals"]').getAttribute("data-status") + ).toBe("answered") + + await userEvent.click(requiredElement('[data-testid="toggle-multiple"]')) + await settle() + + expect(choiceInput("first-signal").type).toBe("checkbox") + expect(choiceInput("first-signal").checked).toBe(true) + expect(choiceInput("second-signal").checked).toBe(false) +}) + +test("renders a custom title and labels its item", async () => { + await render( + + + } + > + Choose one + + Answer + + + ) + + const title = requiredElement('[data-testid="answer-title"]') + const item = requiredElement("fieldset") + + expect(title.tagName).toBe("H2") + expect(title.textContent).toBe("Choose one") + expect(item.getAttribute("aria-labelledby")).toBe("answer-title") +}) + +test("preserves native radio and checkbox keyboard behavior", async () => { + await render( + + + Choose one + + Alpha + + + Beta + + + + + + Choose several + + Gamma + + + Delta + + + + + + ) + + const alpha = choiceInput("alpha") + const beta = choiceInput("beta") + + alpha.focus() + await userEvent.keyboard("{ArrowRight}") + await settle() + + expect(alpha.checked).toBe(false) + expect(beta.checked).toBe(true) + expect(new FormData(form()).get("single")).toBe("beta") + expect( + requiredElement('[data-testid="root"] [data-active]').textContent + ).toContain("Choose one") + + await userEvent.keyboard("{ArrowDown}") + await settle() + + expect(document.activeElement).toBe( + requiredElement('[data-testid="other"]') + ) + expect(beta.checked).toBe(true) + + await userEvent.keyboard("{ArrowUp}") + await settle() + + expect(document.activeElement).toBe(beta) + expect(beta.checked).toBe(true) + + await userEvent.keyboard("{ArrowDown}") + await settle() + await userEvent.keyboard("{ArrowDown}") + await settle() + + expect(document.activeElement).toBe(alpha) + expect(alpha.checked).toBe(true) + + const other = requiredElement('[data-testid="other"]') + + expect(other.id).not.toBe("") + expect(other.hasAttribute("name")).toBe(false) + + await userEvent.type(other, "Draft") + await userEvent.keyboard("{ArrowUp}{ArrowDown}") + await settle() + + expect(document.activeElement).toBe(other) + + await userEvent.click(requiredElement('[data-testid="next"]')) + await settle() + + const gamma = choiceInput("gamma") + const delta = choiceInput("delta") + + gamma.focus() + await userEvent.keyboard(" ") + await settle() + + expect(gamma.checked).toBe(true) + expect(new FormData(form()).getAll("multiple")).toEqual(["gamma"]) + + await userEvent.keyboard("{ArrowDown}") + await settle() + + expect(document.activeElement).toBe(delta) + expect(delta.checked).toBe(false) + + await userEvent.keyboard(" ") + await settle() + + expect(new FormData(form()).getAll("multiple")).toEqual(["gamma", "delta"]) +}) + +test("moves between items with contextual horizontal arrows", async () => { + await render( + + + First question + + First answer + + + + + Second question + + + + + + + ) + + const first = requiredElement('[data-testid="first"]') + const second = requiredElement('[data-testid="second"]') + + first.focus() + await userEvent.keyboard("{ArrowRight}") + await settle() + + expect(first.hasAttribute("data-active")).toBe(true) + + await userEvent.click(choiceInput("first-answer")) + first.focus() + await userEvent.keyboard("{ArrowRight}") + await settle() + + expect(second.hasAttribute("data-active")).toBe(true) + expect(document.activeElement).toBe(second) + + const secondAnswer = requiredElement( + '[data-testid="second-answer"]' + ) + + await userEvent.keyboard("{ArrowDown}") + await settle() + + expect(document.activeElement).toBe(secondAnswer) + + await userEvent.type(secondAnswer, "Draft") + await userEvent.keyboard("{ArrowLeft}") + await settle() + + expect(second.hasAttribute("data-active")).toBe(true) + + second.focus() + await userEvent.keyboard("{ArrowLeft}") + await settle() + + expect(first.hasAttribute("data-active")).toBe(true) + expect(document.activeElement).toBe(first) +}) + +test("keeps native arrow behavior for number inputs", async () => { + await render( + + + Review rounds + + No limit + + + + + ) + + const roundCount = requiredElement( + '[data-testid="round-count"]' + ) + + await userEvent.click(roundCount) + await userEvent.keyboard("{ArrowUp}") + await settle() + + expect(document.activeElement).toBe(roundCount) +}) + +test("does not count disabled controls as answers", async () => { + function AvailabilityQuestionnaire() { + const [disabled, setDisabled] = React.useState(false) + + return ( + + + Choose answers + + Fixed + + + + + + + + ) + } + + await render() + + const answers = requiredElement('[data-testid="answers"]') + const submit = requiredElement('[data-testid="submit"]') + + expect(answers.getAttribute("data-status")).toBe("answered") + expect(new FormData(form()).getAll("answers")).toEqual(["fixed", "Custom"]) + expect(submit.disabled).toBe(false) + + await userEvent.click(requiredElement('[data-testid="toggle"]')) + await settle() + + expect(answers.getAttribute("data-status")).toBe("unanswered") + expect(new FormData(form()).getAll("answers")).toEqual([]) + expect(submit.disabled).toBe(false) + + await userEvent.click(submit) + await settle() + + expect(requiredElement('[data-testid="error"]').hidden).toBe(false) + expect(document.activeElement).toBe(answers) + + await userEvent.click(requiredElement('[data-testid="toggle"]')) + await settle() + + expect(answers.getAttribute("data-status")).toBe("answered") + expect(new FormData(form()).getAll("answers")).toEqual(["fixed", "Custom"]) + expect(submit.disabled).toBe(false) + expect(requiredElement('[data-testid="error"]').hidden).toBe(true) +}) + +test("selects answers with scoped shortcuts and confirms with Enter", async () => { + let submittedValue: FormDataEntryValue | null = null + + await render( + { + event.preventDefault() + submittedValue = new FormData(event.currentTarget).get("detail") + }} + > + + Choose one + + Alpha + + + Beta + + + + + Add detail + + Keep it brief + + + + + + + + ) + + const alpha = choiceInput("alpha") + const beta = choiceInput("beta") + const betaShortcut = requiredElement( + '[data-testid="beta"] span[data-shortcut="B"]' + ) + + expect(betaShortcut.textContent).toBe("B") + expect(betaShortcut.getAttribute("aria-hidden")).toBe("true") + + alpha.focus() + await userEvent.keyboard("b") + await settle() + + expect(beta.checked).toBe(true) + expect(document.activeElement).toBe(beta) + + await userEvent.keyboard("{Enter}") + await settle() + + const detailInput = requiredElement( + '[data-testid="detail-input"]' + ) + + expect(detailInput.hasAttribute("data-shortcut")).toBe(false) + + await userEvent.keyboard("{ArrowDown}{ArrowDown}") + await settle() + + expect(document.activeElement).toBe(detailInput) + expect(detailInput.value).toBe("") + + await userEvent.type(detailInput, "Custom detail") + await userEvent.keyboard("{Enter}") + await settle() + + expect(submittedValue).toBe("Custom detail") +}) + +test("uses definition order for shortcut activation", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await render( + + + Choose one + + First + + + Second + + + + ) + + expect(requiredElement('[data-testid="first"]').dataset.shortcut).toBe("B") + expect(requiredElement('[data-testid="second"]').dataset.shortcut).toBe("A") + + requiredElement('[data-testid="answer"]').focus() + await userEvent.keyboard("a") + await settle() + + expect(choiceInput("second").checked).toBe(true) + expect(choiceInput("first").checked).toBe(false) + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining( + 'Choice order for item "answer" differs between Root.items' + ) + ) +}) + +test("keeps vertical answer navigation in DOM order with definitions", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await render( + + + Choose one + + First + + + + Second + + + + ) + + choiceInput("first").focus() + await userEvent.keyboard("{ArrowDown}") + await settle() + + const other = requiredElement('[data-testid="other"]') + + expect(document.activeElement).toBe(other) + expect(choiceInput("first").checked).toBe(false) + + await userEvent.keyboard("{ArrowDown}") + await settle() + + expect(document.activeElement).toBe(choiceInput("second")) + expect(choiceInput("second").checked).toBe(true) + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining( + 'Choice order for item "answer" differs between Root.items' + ) + ) +}) + +test("validates, advances, and submits with Command or Control plus Enter", async () => { + let submitCount = 0 + + await render( + { + event.preventDefault() + submitCount += 1 + }} + > + + Choose one + + First answer + + + + + + + Choose another + + Second answer + + + + + + + ) + + const firstInput = requiredElement( + '[data-testid="first-input"]' + ) + + firstInput.focus() + firstInput.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Enter", + metaKey: true, + }) + ) + await settle() + + expect(requiredElement('[data-testid="first"]').hidden).toBe(false) + expect(requiredElement('[data-testid="first-error"]').hidden).toBe(false) + expect(document.activeElement).toBe(choiceInput("first-answer")) + + await userEvent.click(choiceInput("first-answer")) + firstInput.focus() + firstInput.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Enter", + metaKey: true, + }) + ) + await settle() + + const second = requiredElement('[data-testid="second"]') + + expect(second.hidden).toBe(false) + expect(document.activeElement).toBe(second) + + await userEvent.click(choiceInput("second-answer")) + second.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ctrlKey: true, + key: "Enter", + }) + ) + await settle() + + expect(submitCount).toBe(1) +}) + +test("toggles multiple answers with scoped shortcuts", async () => { + await render( + + + Choose several + + Alpha + + + Beta + + + + ) + + const alpha = choiceInput("alpha") + const beta = choiceInput("beta") + + alpha.focus() + await userEvent.keyboard("2") + await settle() + + expect(document.activeElement).toBe(beta) + expect(beta.checked).toBe(true) + expect(new FormData(form()).getAll("answers")).toEqual(["beta"]) + + await userEvent.keyboard("2") + await settle() + + expect(beta.checked).toBe(false) + expect(new FormData(form()).getAll("answers")).toEqual([]) + + await userEvent.keyboard("1") + await settle() + + expect(document.activeElement).toBe(alpha) + expect(alpha.checked).toBe(true) + expect(new FormData(form()).getAll("answers")).toEqual(["alpha"]) +}) + +test("does not implicitly submit from an unselected answer", async () => { + let submitCount = 0 + + await render( + { + event.preventDefault() + submitCount += 1 + }} + > + + Choose one + + Alpha + + + Beta + + + + + + ) + + const alpha = choiceInput("alpha") + const beta = choiceInput("beta") + const other = requiredElement('[data-testid="other"]') + + await userEvent.click(alpha) + beta.focus() + await userEvent.keyboard("{Enter}") + await settle() + + expect(submitCount).toBe(0) + + await userEvent.type(other, "Preserved draft") + await userEvent.click(alpha) + other.focus() + await userEvent.keyboard("{Enter}") + await settle() + + expect(submitCount).toBe(0) + + alpha.focus() + await userEvent.keyboard("{Enter}") + await settle() + + expect(submitCount).toBe(1) +}) + +test("keeps controlled, skipped, and native form state aligned", async () => { + function StateQuestionnaire() { + const [controlledValue] = React.useState("") + const [skippedValue, setSkippedValue] = React.useState("") + + return ( + <> + + + Controlled answer + {}} + /> + + + + + { + event.preventDefault() + setSkippedValue( + new FormData(event.currentTarget).get("skipped")?.toString() ?? + null + ) + }} + > + + Skippable answer + {}} + > + Kept + + + + + + + + {skippedValue === null ? "empty" : skippedValue} + + + ) + } + + await render() + + const controlledInput = requiredElement( + '[data-testid="controlled-input"]' + ) + + await userEvent.type(controlledInput, "Rejected") + await settle() + + expect(controlledInput.value).toBe("") + expect( + requiredElement('[data-testid="controlled-item"]').getAttribute( + "data-status" + ) + ).toBe("unanswered") + expect( + requiredElement('[data-testid="controlled-submit"]') + .disabled + ).toBe(false) + + await userEvent.click(requiredElement('[data-testid="skip"]')) + await settle() + + const controlledChoice = choiceInput("controlled-choice") + + expect(controlledChoice.checked).toBe(false) + expect(controlledChoice.id).not.toBe("") + expect(controlledChoice.hasAttribute("name")).toBe(false) + expect( + requiredElement('[data-testid="skip-item"]').getAttribute("data-status") + ).toBe("skipped") + expect(requiredElement('[data-testid="skipped-value"]').textContent).toBe( + "empty" + ) +}) + +test("returns to and blocks an externally invalid item", async () => { + function ExternallyValidatedQuestionnaire() { + const [activeItem, setActiveItem] = React.useState("first") + const [firstInvalid, setFirstInvalid] = React.useState(false) + + return ( + { + event.preventDefault() + setFirstInvalid(true) + setActiveItem("first") + }} + > + + First + + First answer + + setFirstInvalid(false)} + > + Alternative answer + + + Choose the alternative answer. + + + + + Second + + Second answer + + + + + + + ) + } + + await render() + + await userEvent.click(choiceInput("first-choice")) + await userEvent.click(requiredElement('[data-testid="next"]')) + await userEvent.click(choiceInput("second-choice")) + await userEvent.click(requiredElement('[data-testid="submit"]')) + await settle() + + const first = requiredElement('[data-testid="first"]') + const firstError = requiredElement('[data-testid="first-error"]') + + expect(first.hidden).toBe(false) + expect(first.getAttribute("data-status")).toBe("answered") + expect(first.getAttribute("aria-invalid")).toBe("true") + expect(first.getAttribute("aria-describedby")).toContain("first-error") + expect(choiceInput("first-choice").getAttribute("aria-invalid")).toBe("true") + expect(firstError.hidden).toBe(false) + expect(firstError.getAttribute("role")).toBe("alert") + expect(document.activeElement).toBe(first) + + first.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Enter", + metaKey: true, + }) + ) + await settle() + + expect(first.hidden).toBe(false) + expect(document.activeElement).toBe(choiceInput("first-choice")) + + first.focus() + await userEvent.click(requiredElement('[data-testid="next"]')) + await settle() + + expect(first.hidden).toBe(false) + expect(document.activeElement).toBe(choiceInput("first-choice")) + + await userEvent.click(choiceInput("first-alternative")) + await settle() + + expect(first.hasAttribute("aria-invalid")).toBe(false) + expect(firstError.hidden).toBe(true) + + await userEvent.click(requiredElement('[data-testid="next"]')) + await settle() + + expect(requiredElement('[data-testid="second"]').hidden).toBe(false) +}) + +test("treats an intentional skip as valid for an optional external error", async () => { + await render( + + + Optional + + Answer + + + This answer is not valid. + + + + Required + + Required answer + + + + + + + ) + + const optional = requiredElement('[data-testid="optional"]') + const optionalError = requiredElement( + '[data-testid="optional-error"]' + ) + + expect(optional.getAttribute("aria-invalid")).toBe("true") + + await userEvent.click(requiredElement('[data-testid="skip"]')) + await settle() + + expect( + requiredElement('[data-testid="required"]').hasAttribute("data-active") + ).toBe(true) + + await userEvent.click(requiredElement('[data-testid="previous"]')) + await settle() + + expect(optional.getAttribute("data-status")).toBe("skipped") + expect(optional.hasAttribute("aria-invalid")).toBe(false) + expect(optionalError.hidden).toBe(true) + + await userEvent.click(choiceInput("optional-choice")) + await settle() + + expect(optional.getAttribute("data-status")).toBe("answered") + expect(optional.getAttribute("aria-invalid")).toBe("true") + + await userEvent.click(requiredElement('[data-testid="next"]')) + await settle() + + expect(optional.hasAttribute("data-active")).toBe(true) + expect(document.activeElement).toBe(choiceInput("optional-choice")) +}) + +test("allows a freeform answer with native validation enabled", async () => { + let submittedValue: FormDataEntryValue | null = null + + await render( + { + event.preventDefault() + submittedValue = new FormData(event.currentTarget).get("answer") + }} + > + + Choose or type + + Fixed + + + + + + ) + + const fixed = choiceInput("fixed") + const other = requiredElement('[data-testid="other"]') + + expect(fixed.required).toBe(false) + + await userEvent.type(other, "Freeform") + await userEvent.click(requiredElement('[data-testid="submit"]')) + await settle() + + expect(fixed.validity.valid).toBe(true) + expect(submittedValue).toBe("Freeform") +}) + +test("validates selected native controls without validating unselected drafts", async () => { + let submittedValue: FormDataEntryValue | null = null + + await render( + { + event.preventDefault() + submittedValue = new FormData(event.currentTarget).get("contact") + }} + > + + How should we contact you? + + Use the saved address + + + + + Confirm + + Confirmed + + + + + + ) + + const contact = requiredElement('[data-testid="contact"]') + const input = requiredElement( + '[data-testid="contact-input"]' + ) + + expect(input.form).toBeNull() + + await userEvent.type(input, "not-an-email") + await userEvent.click(requiredElement('[data-testid="next"]')) + await settle() + + expect(contact.hasAttribute("data-active")).toBe(true) + expect(input.validity.valid).toBe(false) + expect(document.activeElement).toBe(input) + + await userEvent.click(choiceInput("fixed-contact")) + await settle() + + expect(input.value).toBe("not-an-email") + expect(input.hasAttribute("name")).toBe(false) + expect(input.form).toBeNull() + + await userEvent.click(requiredElement('[data-testid="next"]')) + await settle() + + expect( + requiredElement('[data-testid="confirmation"]').hasAttribute("data-active") + ).toBe(true) + + await userEvent.click(choiceInput("confirm")) + await userEvent.click(requiredElement('[data-testid="submit"]')) + await settle() + + expect(submittedValue).toBe("fixed") +}) + +test("does not confirm a freeform answer while an IME composition is active", async () => { + let submitCount = 0 + + await render( + { + event.preventDefault() + submitCount += 1 + }} + > + + Add detail + + + + + ) + + const detailInput = requiredElement( + '[data-testid="detail-input"]' + ) + + await userEvent.type(detailInput, "入力") + detailInput.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + isComposing: true, + key: "Enter", + }) + ) + await settle() + + expect(submitCount).toBe(0) +}) + +test("moves focus on validation and navigation", async () => { + await render( + + + First + + First answer + + + + + + Second + + Second answer + + + + + + + + ) + + await userEvent.click(requiredElement('[data-testid="next"]')) + await settle() + + expect(document.activeElement).toBe(choiceInput("first-answer")) + + await userEvent.click(choiceInput("first-answer")) + await userEvent.click(requiredElement('[data-testid="next"]')) + await settle() + + const second = requiredElement('[data-testid="second"]') + + expect(second.hasAttribute("data-active")).toBe(true) + expect(document.activeElement).toBe(second) + + await userEvent.click(requiredElement('[data-testid="submit"]')) + await settle() + + expect(document.activeElement).toBe(choiceInput("second-answer")) +}) + +test("submits an intentional final skip through the native form", async () => { + let status: QuestionnaireItemStatus = "unanswered" + let submittedStatus: QuestionnaireItemStatus | null = null + let submittedValues: FormDataEntryValue[] | null = null + + await render( + { + event.preventDefault() + submittedStatus = status + submittedValues = new FormData(event.currentTarget).getAll("timing") + }} + > + { + status = nextStatus + }} + > + When? + Today + + + + + + ) + + await userEvent.click(requiredElement('[data-testid="skip"]')) + await settle() + + expect(submittedStatus).toBe("skipped") + expect(submittedValues).toEqual([]) +}) + +test("preserves inactive answers and restores native defaults on reset", async () => { + await render( + + + Channel + + Email + + + + + Detail + + + + + + + + + ) + + await userEvent.click(requiredElement('[data-testid="next"]')) + await userEvent.clear(requiredElement('[data-testid="detail-input"]')) + await userEvent.type( + requiredElement('[data-testid="detail-input"]'), + "Custom detail" + ) + await userEvent.click(requiredElement('[data-testid="previous"]')) + await settle() + + expect(new FormData(form()).get("channel")).toBe("email") + expect(new FormData(form()).get("detail")).toBe("Custom detail") + + await userEvent.click(requiredElement('[data-testid="reset"]')) + await settle() + + expect(requiredElement('[data-testid="channel"]').hidden).toBe(false) + expect(requiredElement('[data-testid="detail"]').hidden).toBe(true) + expect(choiceInput("email").checked).toBe(true) + expect( + requiredElement('[data-testid="detail-input"]').value + ).toBe("Default detail") + expect( + requiredElement('[data-testid="controlled-detail-input"]') + .value + ).toBe("Controlled detail") +}) + +async function render(children: React.ReactNode) { + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + + flushSync(() => { + root!.render(children) + }) + + await settle() +} + +function TestChoice({ + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + ) +} + +function form() { + return requiredElement('[data-testid="root"]') +} + +function choiceInput(testId: string) { + return requiredElement(`[data-testid="${testId}"] input`) +} + +function requiredElement(selector: string) { + const element = container?.querySelector(selector) + + if (!element) { + throw new Error(`Missing test element: ${selector}`) + } + + return element +} + +function settle(frames = 2) { + return new Promise((resolve) => { + let remaining = frames + + function nextFrame() { + if (remaining-- <= 0) { + resolve() + return + } + + requestAnimationFrame(nextFrame) + } + + requestAnimationFrame(nextFrame) + }) +} diff --git a/packages/react/src/questionnaire/questionnaire.test.tsx b/packages/react/src/questionnaire/questionnaire.test.tsx new file mode 100644 index 00000000000..89577c84dd9 --- /dev/null +++ b/packages/react/src/questionnaire/questionnaire.test.tsx @@ -0,0 +1,2183 @@ +// @vitest-environment jsdom + +import * as React from "react" +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { Questionnaire } from "." + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + ;( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean + } + ).IS_REACT_ACT_ENVIRONMENT = true + + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => { + root.unmount() + }) + container.remove() + vi.restoreAllMocks() +}) + +describe("Questionnaire", () => { + it("owns its ordered items, progress, and navigation", async () => { + await renderQuestionnaire() + + expect(progress().textContent).toBe("Question 1 of 2") + expect(form().dataset.current).toBe("1") + expect(form().hasAttribute("data-first")).toBe(true) + expect(item("scope").hasAttribute("data-active")).toBe(true) + expect(item("detail").hidden).toBe(true) + expect(freeform("scope-input").id).not.toBe("") + expect(freeform("scope-input").hasAttribute("name")).toBe(false) + expect(previous().hasAttribute("data-hidden")).toBe(true) + expect(next().hasAttribute("data-visible")).toBe(true) + expect(next().disabled).toBe(false) + expect(next().dataset.status).toBe("unanswered") + expect(submit().hasAttribute("data-hidden")).toBe(true) + + await click(next()) + + expect(item("scope").hasAttribute("data-active")).toBe(true) + expect(item("scope").getAttribute("aria-invalid")).toBe("true") + expect(error("scope-error").hidden).toBe(false) + expect(document.activeElement).toBe(choiceInput("scope-delegation")) + + await choose("scope-delegation") + + expect(item("scope").hasAttribute("aria-invalid")).toBe(false) + expect(next().disabled).toBe(false) + expect(next().dataset.status).toBe("answered") + await click(next()) + + expect(progress().textContent).toBe("Question 2 of 2") + expect(form().hasAttribute("data-last")).toBe(true) + expect(item("scope").hidden).toBe(true) + expect(item("detail").hasAttribute("data-active")).toBe(true) + expect(previous().hasAttribute("data-visible")).toBe(true) + expect(next().hasAttribute("data-hidden")).toBe(true) + expect(submit().hasAttribute("data-visible")).toBe(true) + expect(submit().disabled).toBe(false) + expect(submit().dataset.status).toBe("unanswered") + + await click(submit()) + + expect(item("detail").getAttribute("aria-invalid")).toBe("true") + expect(error("detail-error").hidden).toBe(false) + expect(document.activeElement).toBe(choiceInput("detail-focused")) + + await choose("detail-focused") + + expect(item("detail").hasAttribute("aria-invalid")).toBe(false) + expect(submit().disabled).toBe(false) + expect(submit().dataset.status).toBe("answered") + await click(previous()) + + expect(item("scope").hasAttribute("data-active")).toBe(true) + expect(choiceInput("scope-delegation").checked).toBe(true) + }) + + it("moves between items with horizontal arrows outside radios and text entry", async () => { + await renderQuestionnaire() + + expect(item("scope").getAttribute("aria-keyshortcuts")).toBe( + "Meta+Enter Control+Enter ArrowUp ArrowDown" + ) + + await keydown(item("scope"), "ArrowRight") + + expect(item("scope").hasAttribute("data-active")).toBe(true) + + await choose("scope-delegation") + + expect(item("scope").getAttribute("aria-keyshortcuts")).toBe( + "Meta+Enter Control+Enter ArrowUp ArrowDown ArrowRight" + ) + + await keydown(choiceInput("scope-delegation"), "ArrowRight") + await keydown(item("scope"), "ArrowRight", { ctrlKey: true }) + await keydown(item("scope"), "ArrowRight", { repeat: true }) + + expect(item("scope").hasAttribute("data-active")).toBe(true) + + await keydown(item("scope"), "ArrowRight") + + expect(item("detail").hasAttribute("data-active")).toBe(true) + expect(item("detail").getAttribute("aria-keyshortcuts")).toBe( + "Meta+Enter Control+Enter ArrowUp ArrowDown ArrowLeft" + ) + expect(document.activeElement).toBe(item("detail")) + + await keydown(item("detail"), "ArrowLeft") + + expect(item("scope").hasAttribute("data-active")).toBe(true) + expect(document.activeElement).toBe(item("scope")) + + await type(freeform("scope-input"), "A custom answer") + await keydown(freeform("scope-input"), "ArrowRight") + + expect(item("scope").hasAttribute("data-active")).toBe(true) + + await keydown(next(), "ArrowRight") + + expect(item("detail").hasAttribute("data-active")).toBe(true) + }) + + it("moves vertical focus across fixed and freeform answers", async () => { + await renderQuestionnaire() + await choose("scope-questions") + await keydown(choiceInput("scope-questions"), "ArrowDown") + + expect(document.activeElement).toBe(freeform("scope-input")) + expect(choiceInput("scope-questions").checked).toBe(true) + + await keydown(freeform("scope-input"), "ArrowUp") + + expect(document.activeElement).toBe(choiceInput("scope-questions")) + expect(choiceInput("scope-questions").checked).toBe(true) + + await keydown(choiceInput("scope-questions"), "ArrowDown") + await keydown(freeform("scope-input"), "ArrowDown") + + expect(document.activeElement).toBe(choiceInput("scope-delegation")) + expect(choiceInput("scope-delegation").checked).toBe(true) + + await type(freeform("scope-input"), "A custom answer") + await keydown(freeform("scope-input"), "ArrowUp") + + expect(document.activeElement).toBe(freeform("scope-input")) + + await keydown(freeform("scope-input"), "ArrowDown") + + expect(document.activeElement).toBe(freeform("scope-input")) + + item("scope").focus() + await keydown(item("scope"), "ArrowRight") + await keydown(item("detail"), "ArrowDown") + + expect(document.activeElement).toBe(choiceInput("detail-focused")) + expect(choiceInput("detail-focused").checked).toBe(true) + + item("detail").focus() + await keydown(item("detail"), "ArrowLeft") + await keydown(item("scope"), "ArrowDown") + + expect(document.activeElement).toBe(freeform("scope-input")) + expect(freeform("scope-input").value).toBe("A custom answer") + }) + + it("moves vertical focus through multiple choices without toggling", async () => { + await act(async () => { + root.render( + + + Choose several + + First + + + Second + + + + ) + }) + + item("multiple").focus() + await keydown(item("multiple"), "ArrowDown") + + expect(document.activeElement).toBe(choiceInput("first")) + expect(choiceInput("first").checked).toBe(false) + + await keydown(choiceInput("first"), "ArrowDown") + + expect(document.activeElement).toBe(choiceInput("second")) + expect(choiceInput("second").checked).toBe(false) + + item("multiple").focus() + await keydown(item("multiple"), "ArrowUp") + + expect(document.activeElement).toBe(choiceInput("second")) + expect(choiceInput("second").checked).toBe(false) + }) + + it.each(["email", "password", "search", "tel", "text", "url"] as const)( + "moves vertically out of an empty %s input", + async (inputType) => { + await act(async () => { + root.render( + + + Enter an answer + + Fixed + + + + + ) + }) + + await keydown(freeform("answer-input"), "ArrowUp") + + expect(document.activeElement).toBe(choiceInput("fixed")) + expect(choiceInput("fixed").checked).toBe(true) + } + ) + + it.each([ + "date", + "datetime-local", + "month", + "number", + "time", + "week", + ] as const)( + "keeps vertical arrows native for an empty %s input", + async (inputType) => { + await act(async () => { + root.render( + + + Enter an answer + + Fixed + + + + + ) + }) + + const input = freeform("answer-input") + + await keydown(input, "ArrowUp") + + expect(document.activeElement).toBe(input) + + await keydown(input, "ArrowDown") + + expect(document.activeElement).toBe(input) + } + ) + + it("omits disabled answers from shortcuts, navigation, and validation focus", async () => { + await act(async () => { + root.render( + + + Choose approval + + Automatic + + + Review + + + + + + ) + }) + + expect(choice("automatic").hasAttribute("data-shortcut")).toBe(false) + expect(choice("review").dataset.shortcut).toBe("A") + expect(freeform("approval-input").hasAttribute("data-shortcut")).toBe(false) + + item("approval").focus() + await keydown(item("approval"), "ArrowDown") + + expect(document.activeElement).toBe(choiceInput("review")) + expect(choiceInput("review").checked).toBe(true) + + await act(async () => { + form().reset() + }) + + await act(async () => { + form().dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }) + ) + }) + + expect(error("approval-error").hidden).toBe(false) + expect(document.activeElement).toBe(choiceInput("review")) + }) + + it("counts only enabled answers in item status and validation", async () => { + const onStatusChange = vi.fn() + + async function renderAnswers(disabled: boolean) { + await act(async () => { + root.render( + + + Choose answers + + Fixed + + + + + + + ) + }) + } + + await renderAnswers(false) + + expect(item("answers").dataset.status).toBe("answered") + expect(new FormData(form()).getAll("answers")).toEqual(["fixed", "Custom"]) + expect(submit().disabled).toBe(false) + + await renderAnswers(true) + + expect(choiceInput("fixed-answer").checked).toBe(true) + expect(freeform("custom-answer").value).toBe("Custom") + expect(item("answers").dataset.status).toBe("unanswered") + expect(new FormData(form()).getAll("answers")).toEqual([]) + expect(submit().disabled).toBe(false) + expect(onStatusChange).toHaveBeenLastCalledWith("unanswered") + + await act(async () => { + form().dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }) + ) + }) + + expect(error("answers-error").hidden).toBe(false) + expect(document.activeElement).toBe(item("answers")) + + await renderAnswers(false) + + expect(item("answers").dataset.status).toBe("answered") + expect(new FormData(form()).getAll("answers")).toEqual(["fixed", "Custom"]) + expect(submit().disabled).toBe(false) + expect(error("answers-error").hidden).toBe(true) + expect(onStatusChange).toHaveBeenLastCalledWith("answered") + }) + + it("returns to and blocks an externally invalid item", async () => { + function ExternallyValidatedQuestionnaire() { + const [activeItem, setActiveItem] = React.useState("first") + const [firstInvalid, setFirstInvalid] = React.useState(false) + + return ( + { + event.preventDefault() + setFirstInvalid(true) + setActiveItem("first") + }} + > + + First + + First answer + + setFirstInvalid(false)} + > + Alternative answer + + + Choose the alternative answer. + + + + + Second + + Second answer + + + + + + + ) + } + + await act(async () => { + root.render() + }) + + await choose("first-choice") + await click(next()) + await choose("second-choice") + await click(submit()) + + expect(item("first").hasAttribute("data-active")).toBe(true) + expect(item("first").dataset.status).toBe("answered") + expect(item("first").getAttribute("aria-invalid")).toBe("true") + expect(item("first").getAttribute("aria-describedby")).toContain( + "first-error" + ) + expect(choiceInput("first-choice").getAttribute("aria-invalid")).toBe( + "true" + ) + expect(error("first-error").hidden).toBe(false) + expect(error("first-error").getAttribute("role")).toBe("alert") + expect(document.activeElement).toBe(item("first")) + + await keydown(item("first"), "Enter", { metaKey: true }) + + expect(item("first").hasAttribute("data-active")).toBe(true) + expect(document.activeElement).toBe(choiceInput("first-choice")) + + item("first").focus() + await click(next()) + + expect(item("first").hasAttribute("data-active")).toBe(true) + expect(document.activeElement).toBe(choiceInput("first-choice")) + + await choose("first-alternative") + + expect(item("first").hasAttribute("aria-invalid")).toBe(false) + expect(error("first-error").hidden).toBe(true) + + await click(next()) + + expect(item("second").hasAttribute("data-active")).toBe(true) + }) + + it("does not treat ArrowRight as an implicit skip", async () => { + const onStatusChange = vi.fn() + + await act(async () => { + root.render( + + + + + + + ) + }) + + await keydown(item("optional"), "ArrowRight") + + expect(item("optional").hasAttribute("data-active")).toBe(true) + expect(item("optional").dataset.status).toBe("unanswered") + expect(onStatusChange).not.toHaveBeenCalled() + + await click(skip()) + await click(previous()) + + expect(item("optional").dataset.status).toBe("skipped") + expect(item("optional").getAttribute("aria-keyshortcuts")).toBe( + "Meta+Enter Control+Enter ArrowUp ArrowDown ArrowRight" + ) + + await keydown(item("optional"), "ArrowRight") + + expect(item("next").hasAttribute("data-active")).toBe(true) + }) + + it("activates the first enabled item without reporting initialization as navigation", async () => { + const onItemChange = vi.fn() + + await act(async () => { + root.render( + + + + + + + ) + }) + + expect(item("first").hasAttribute("data-active")).toBe(true) + expect(item("disabled").hidden).toBe(true) + expect(progress().textContent).toBe("Question 1 of 2") + expect(progress().getAttribute("aria-label")).toBe("Questionnaire progress") + expect(onItemChange).not.toHaveBeenCalled() + expect(document.activeElement).toBe(document.body) + }) + + it("reports item names without navigation details", async () => { + const onItemChange = vi.fn() + + await renderQuestionnaire({ onItemChange }) + await choose("scope-delegation") + + expect(item("scope").hasAttribute("name")).toBe(false) + expect(choiceInput("scope-delegation").name).toBe("scope") + + await click(next()) + await click(previous()) + + expect(onItemChange.mock.calls).toEqual([["detail"], ["scope"]]) + }) + + it("supports controlled active-item navigation", async () => { + const onItemChange = vi.fn() + + await act(async () => { + root.render() + }) + + await choose("first-choice") + await click(next()) + + expect(item("second").hasAttribute("data-active")).toBe(true) + expect(onItemChange).toHaveBeenLastCalledWith("second") + + await click(previous()) + + expect(item("first").hasAttribute("data-active")).toBe(true) + expect(onItemChange).toHaveBeenLastCalledWith("first") + }) + + it("treats Input as a freeform answer without selecting it on focus", async () => { + await renderQuestionnaire() + const input = freeform("scope-input") + + await act(async () => { + input.focus() + }) + + expect(item("scope").dataset.status).toBe("unanswered") + expect(input.hasAttribute("name")).toBe(false) + expect(input.hasAttribute("data-empty")).toBe(true) + + await type(input, "A different direction") + + expect(item("scope").dataset.status).toBe("answered") + expect(input.name).toBe("scope") + expect(input.hasAttribute("data-filled")).toBe(true) + expect(choice("scope-delegation").hasAttribute("data-unchecked")).toBe(true) + + await choose("scope-questions") + + expect(input.value).toBe("A different direction") + expect(input.hasAttribute("name")).toBe(false) + expect(input.hasAttribute("data-filled")).toBe(true) + expect(choice("scope-questions").hasAttribute("data-checked")).toBe(true) + + await type(input, "A revised direction") + + expect(input.name).toBe("scope") + expect(choiceInput("scope-questions").checked).toBe(false) + expect(new FormData(form()).get("scope")).toBe("A revised direction") + }) + + it("supports controlled fixed and freeform answers", async () => { + await act(async () => { + root.render() + }) + + expect(item("answers").dataset.status).toBe("unanswered") + + await choose("controlled-choice") + + expect(new FormData(form()).getAll("answers")).toEqual(["fixed"]) + + await type(freeform("controlled-input"), "Custom") + + expect(new FormData(form()).getAll("answers")).toEqual(["fixed", "Custom"]) + + await choose("controlled-choice") + await type(freeform("controlled-input"), "") + + expect(item("answers").dataset.status).toBe("unanswered") + expect(new FormData(form()).getAll("answers")).toEqual([]) + }) + + it("keeps a coordinated controlled Choice and Input mutually exclusive", async () => { + await act(async () => { + root.render() + }) + + await choose("controlled-choice") + + expect(item("answer").dataset.status).toBe("answered") + expect(choice("controlled-choice").hasAttribute("data-checked")).toBe(true) + expect(choiceInput("controlled-choice").checked).toBe(true) + expect(choiceInput("controlled-choice").name).toBe("answer") + expect(freeform("controlled-input").hasAttribute("name")).toBe(false) + expect(new FormData(form()).getAll("answer")).toEqual(["fixed"]) + + await type(freeform("controlled-input"), "Custom") + + expect(item("answer").dataset.status).toBe("answered") + expect(choice("controlled-choice").hasAttribute("data-unchecked")).toBe( + true + ) + expect(choiceInput("controlled-choice").checked).toBe(false) + expect(freeform("controlled-input").name).toBe("answer") + expect(new FormData(form()).getAll("answer")).toEqual(["Custom"]) + + await choose("controlled-choice") + + expect(item("answer").dataset.status).toBe("answered") + expect(choice("controlled-choice").hasAttribute("data-checked")).toBe(true) + expect(choiceInput("controlled-choice").checked).toBe(true) + expect(choiceInput("controlled-choice").name).toBe("answer") + expect(freeform("controlled-input").value).toBe("Custom") + expect(freeform("controlled-input").hasAttribute("name")).toBe(false) + expect(new FormData(form()).getAll("answer")).toEqual(["fixed"]) + }) + + it("does not infer a controlled Input answer from a rejected edit", async () => { + const onChange = vi.fn() + + await act(async () => { + root.render( + + + Answer + + + + + ) + }) + + await type(freeform("answer-input"), "Rejected") + + expect(onChange).toHaveBeenCalledOnce() + expect(freeform("answer-input").value).toBe("") + expect(freeform("answer-input").hasAttribute("name")).toBe(false) + expect(item("answer").dataset.status).toBe("unanswered") + expect(submit().disabled).toBe(false) + }) + + it("clears controlled choices when an item is intentionally skipped", async () => { + const onStatusChange = vi.fn() + const onSubmit = vi.fn((event: React.FormEvent) => { + event.preventDefault() + }) + + await act(async () => { + root.render( + + + Answer + {}} + > + Controlled + + + + + + ) + }) + + expect(choiceInput("controlled-answer").checked).toBe(true) + expect(item("answer").dataset.status).toBe("answered") + + await click(skip()) + + expect(choiceInput("controlled-answer").checked).toBe(false) + expect(choiceInput("controlled-answer").id).not.toBe("") + expect(choiceInput("controlled-answer").hasAttribute("name")).toBe(false) + expect(item("answer").dataset.status).toBe("skipped") + expect(new FormData(form()).getAll("answer")).toEqual([]) + expect(onStatusChange).toHaveBeenLastCalledWith("skipped") + expect(onSubmit).toHaveBeenCalledOnce() + }) + + it("does not require fixed radios when an Input can answer the item", async () => { + await act(async () => { + root.render( + + + Answer + + Fixed + + + + + + ) + }) + + expect(choiceInput("fixed").required).toBe(false) + + await type(freeform("answer-input"), "Freeform") + + expect(form().checkValidity()).toBe(true) + expect(new FormData(form()).get("answer")).toBe("Freeform") + }) + + it("allows fixed and freeform answers in a multiple item", async () => { + const onSubmit = vi.fn((event: React.FormEvent) => { + event.preventDefault() + }) + + await act(async () => { + root.render( + + + Signals + + Progress + + + Risks + + + + + + ) + }) + + expect(choiceInput("progress").type).toBe("checkbox") + expect(item("signals").hasAttribute("data-multiple")).toBe(true) + expect(submit().disabled).toBe(false) + + await choose("progress") + await choose("risks") + await type(freeform("signals-input"), "Decisions") + + expect(submit().disabled).toBe(false) + expect(new FormData(form()).getAll("signals")).toEqual([ + "progress", + "risks", + "Decisions", + ]) + + await click(submit()) + + expect(onSubmit).toHaveBeenCalledOnce() + + await choose("progress") + + expect(new FormData(form()).getAll("signals")).toEqual([ + "risks", + "Decisions", + ]) + }) + + it("preserves a compatible selection when multiple changes", async () => { + function DynamicMultipleQuestionnaire() { + const [multiple, setMultiple] = React.useState(true) + + return ( + + + Signals + + First + + + Second + + + + + ) + } + + await act(async () => { + root.render() + }) + + await choose("first-signal") + await choose("second-signal") + + expect(new FormData(form()).getAll("signals")).toEqual(["first", "second"]) + + await click( + requiredElement('[data-testid="toggle-multiple"]') + ) + + expect(choiceInput("first-signal").type).toBe("radio") + expect(choiceInput("first-signal").checked).toBe(true) + expect(choiceInput("second-signal").checked).toBe(false) + expect(new FormData(form()).getAll("signals")).toEqual(["first"]) + expect(item("signals").dataset.status).toBe("answered") + + await click( + requiredElement('[data-testid="toggle-multiple"]') + ) + + expect(choiceInput("first-signal").type).toBe("checkbox") + expect(choiceInput("first-signal").checked).toBe(true) + expect(choiceInput("second-signal").checked).toBe(false) + }) + + it("records an intentional skip separately from an unanswered item", async () => { + const onTimingStatusChange = vi.fn() + + await act(async () => { + root.render( + + + + + + + + + + ) + }) + + expect(skip().hidden).toBe(true) + + await choose("plan-choice") + await click(next()) + + expect(item("timing").dataset.status).toBe("unanswered") + expect(skip().hidden).toBe(false) + expect(next().disabled).toBe(false) + + await choose("timing-choice") + + expect(item("timing").dataset.status).toBe("answered") + expect(next().disabled).toBe(false) + + await click(skip()) + + expect(item("owner").hasAttribute("data-active")).toBe(true) + expect(choiceInput("timing-choice").checked).toBe(false) + expect(onTimingStatusChange.mock.calls).toEqual([["answered"], ["skipped"]]) + + await click(previous()) + + expect(item("timing").dataset.status).toBe("skipped") + expect(next().disabled).toBe(false) + + await choose("timing-choice") + + expect(item("timing").dataset.status).toBe("answered") + expect(onTimingStatusChange).toHaveBeenLastCalledWith("answered") + }) + + it("treats an intentional skip as valid for an optional external error", async () => { + await act(async () => { + root.render( + + + Optional + + Answer + + + This answer is not valid. + + + + + + + + ) + }) + + expect(item("optional").getAttribute("aria-invalid")).toBe("true") + + await click(skip()) + + expect(item("required").hasAttribute("data-active")).toBe(true) + + await click(previous()) + + expect(item("optional").dataset.status).toBe("skipped") + expect(item("optional").hasAttribute("aria-invalid")).toBe(false) + expect(error("optional-error").hidden).toBe(true) + + await choose("optional-choice") + + expect(item("optional").dataset.status).toBe("answered") + expect(item("optional").getAttribute("aria-invalid")).toBe("true") + + await click(next()) + + expect(item("optional").hasAttribute("data-active")).toBe(true) + expect(document.activeElement).toBe(choiceInput("optional-choice")) + }) + + it("submits after skipping the final optional item", async () => { + const onStatusChange = vi.fn() + const onSubmit = vi.fn((event: React.FormEvent) => { + event.preventDefault() + }) + + await act(async () => { + root.render( + + + + + + ) + }) + + expect(skip().hidden).toBe(false) + expect(submit().disabled).toBe(false) + + await click(skip()) + + expect(item("timing").dataset.status).toBe("skipped") + expect(onStatusChange).toHaveBeenCalledWith("skipped") + expect(onSubmit).toHaveBeenCalledOnce() + }) + + it("validates unanswered items on native form submission", async () => { + const onSubmit = vi.fn() + + await renderQuestionnaire({ onSubmit }) + + expect(item("scope").getAttribute("aria-describedby")).toBe( + "scope-description" + ) + + await act(async () => { + form().dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }) + ) + }) + + expect(onSubmit).not.toHaveBeenCalled() + expect(item("scope").getAttribute("aria-invalid")).toBe("true") + expect(item("scope").getAttribute("aria-describedby")).toBe( + "scope-description scope-error-message" + ) + expect(error("scope-error").hidden).toBe(false) + expect(document.activeElement).toBe(choiceInput("scope-delegation")) + }) + + it("keeps validation active until an attempted item remains valid", async () => { + await act(async () => { + root.render( + + + Answer + + Fixed + + + + + + + ) + }) + + await act(async () => { + form().dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }) + ) + }) + + expect(error("answer-error").hidden).toBe(false) + + await type(freeform("answer-input"), " ") + + expect(error("answer-error").hidden).toBe(false) + + await choose("fixed") + + expect(error("answer-error").hidden).toBe(true) + + await choose("fixed") + + expect(error("answer-error").hidden).toBe(false) + + await type(freeform("answer-input"), "Valid") + + expect(error("answer-error").hidden).toBe(true) + + await type(freeform("answer-input"), "") + + expect(error("answer-error").hidden).toBe(false) + }) + + it("keeps live answers when default props change and uses new defaults on reset", async () => { + await act(async () => { + root.render() + }) + + await type(freeform("default-input"), "Edited") + await choose("secondary-choice") + await click(requiredElement('[data-testid="change-defaults"]')) + + expect(freeform("default-input").value).toBe("Edited") + expect(choiceInput("secondary-choice").checked).toBe(true) + expect(item("defaults").dataset.status).toBe("answered") + + await act(async () => { + form().reset() + }) + + expect(freeform("default-input").value).toBe("") + expect(choiceInput("primary-choice").checked).toBe(true) + expect(choiceInput("secondary-choice").checked).toBe(false) + expect(item("defaults").dataset.status).toBe("answered") + }) + + it("keeps Enter metadata on only the selected freeform answer", async () => { + await act(async () => { + root.render( + + + Answer + + Fixed + + + + + ) + }) + + await type(freeform("answer-input"), "Draft") + + expect(freeform("answer-input").getAttribute("aria-keyshortcuts")).toBe( + "Enter" + ) + + await choose("fixed") + + expect(freeform("answer-input").value).toBe("Draft") + expect(freeform("answer-input").hasAttribute("aria-keyshortcuts")).toBe( + false + ) + }) + + it("registers every Description and Error while they remain mounted", async () => { + await act(async () => { + root.render() + }) + + expect(item("answer").getAttribute("aria-describedby")).toBe( + "description-one description-two" + ) + + await act(async () => { + form().dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }) + ) + }) + + expect(item("answer").getAttribute("aria-describedby")).toBe( + "description-one description-two error-one error-two" + ) + + await click(requiredElement('[data-testid="toggle-details"]')) + + expect(item("answer").getAttribute("aria-describedby")).toBe( + "description-one error-one" + ) + }) + + it("resets answers, skips, validation, and the initial item", async () => { + await act(async () => { + root.render( + + + Channels + + Email + + + Chat + + + + + + + + + ) + }) + + await choose("chat") + await click(skip()) + await choose("detail-choice") + + expect(item("detail").hasAttribute("data-active")).toBe(true) + expect(choiceInput("email").checked).toBe(false) + expect(choiceInput("chat").checked).toBe(false) + + await act(async () => { + form().reset() + }) + + expect(item("channels").hasAttribute("data-active")).toBe(true) + expect(item("channels").dataset.status).toBe("answered") + expect(choiceInput("email").checked).toBe(true) + expect(choiceInput("chat").checked).toBe(false) + }) + + it("skips disabled items and registers in Strict Mode", async () => { + await act(async () => { + root.render( + + + + + + + + + + ) + }) + + expect(progress().textContent).toBe("Question 1 of 2") + + await choose("first-choice") + await click(next()) + + expect(item("last").hasAttribute("data-active")).toBe(true) + expect(item("disabled").hidden).toBe(true) + expect(progress().textContent).toBe("Question 2 of 2") + }) + + it("reconciles inserted and removed items in DOM order", async () => { + const onItemChange = vi.fn() + + await act(async () => { + root.render( + + ) + }) + + expect(progress().textContent).toBe("Question 1 of 2") + + await act(async () => { + root.render( + + ) + }) + + expect(progress().textContent).toBe("Question 1 of 3") + + await choose("first-choice") + await click(next()) + + expect(item("middle").hasAttribute("data-active")).toBe(true) + + await act(async () => { + root.render( + + ) + }) + + expect(item("first").hasAttribute("data-active")).toBe(true) + expect(progress().textContent).toBe("Question 1 of 2") + expect(onItemChange.mock.calls).toEqual([["middle"], ["first"]]) + }) + + it("reconciles inserted and removed answers in DOM order", async () => { + await act(async () => { + root.render() + }) + + expect(choice("first-answer").dataset.shortcut).toBe("A") + expect(choice("last-answer").dataset.shortcut).toBe("B") + + await act(async () => { + root.render() + }) + + expect(choice("first-answer").dataset.shortcut).toBe("A") + expect(choice("middle-answer").dataset.shortcut).toBe("B") + expect(choice("last-answer").dataset.shortcut).toBe("C") + + await act(async () => { + root.render() + }) + + expect(container.querySelector('[data-testid="middle-answer"]')).toBeNull() + expect(choice("last-answer").dataset.shortcut).toBe("B") + }) + + it.each([ + { count: 27, lastShortcut: "Z", shortcuts: "letters" as const }, + { count: 10, lastShortcut: "9", shortcuts: "numbers" as const }, + ])( + "leaves answers beyond the $shortcuts shortcut range unassigned", + async ({ count, lastShortcut, shortcuts }) => { + await act(async () => { + root.render( + + + Choose an answer + {Array.from({ length: count }, (_, index) => ( + + Answer {index + 1} + + ))} + + + ) + }) + + expect(choice(`answer-${count - 2}`).dataset.shortcut).toBe(lastShortcut) + expect(choice(`answer-${count - 1}`).hasAttribute("data-shortcut")).toBe( + false + ) + } + ) + + it("assigns letter shortcuts to enabled answers in DOM order", async () => { + const onSubmit = vi.fn((event: React.FormEvent) => { + event.preventDefault() + }) + + await act(async () => { + root.render( + + + Choose an answer + + + Disabled + + + First + + + Second + + + + + + + ) + }) + + expect(form().dataset.shortcuts).toBe("letters") + expect( + requiredElement('[data-testid="choices"]').dataset.shortcuts + ).toBe("letters") + expect(choice("disabled-answer").hasAttribute("data-shortcut")).toBe(false) + expect(choice("first-answer").dataset.shortcut).toBe("A") + expect(choice("second-answer").dataset.shortcut).toBe("B") + expect(freeform("other-answer").hasAttribute("data-shortcut")).toBe(false) + expect(choiceInput("first-answer").getAttribute("aria-keyshortcuts")).toBe( + "A" + ) + + await keydown(choiceInput("first-answer"), "b") + + expect(choiceInput("second-answer").checked).toBe(true) + expect(document.activeElement).toBe(choiceInput("second-answer")) + expect(choiceInput("second-answer").getAttribute("aria-keyshortcuts")).toBe( + "B Enter" + ) + expect(submit().dataset.shortcut).toBe("Enter") + + await keydown(choiceInput("second-answer"), "c") + + expect(document.activeElement).toBe(choiceInput("second-answer")) + expect(item("answers").dataset.status).toBe("answered") + expect(choiceInput("second-answer").checked).toBe(true) + + await keydown(freeform("other-answer"), "a") + + expect(choiceInput("first-answer").checked).toBe(false) + expect(document.activeElement).toBe(freeform("other-answer")) + + await type(freeform("other-answer"), "Draft answer") + await choose("first-answer") + await keydown(freeform("other-answer"), "Enter") + + expect(onSubmit).not.toHaveBeenCalled() + expect(new FormData(form()).get("answers")).toBe("first") + }) + + it("supports number shortcuts and confirms only from a filled answer", async () => { + const onSubmit = vi.fn((event: React.FormEvent) => { + event.preventDefault() + }) + + await act(async () => { + root.render( + + + + Explain + + + + + + ) + }) + + expect(choice("first-choice").dataset.shortcut).toBe("1") + + await keydown(choiceInput("first-choice"), "1") + + expect(choiceInput("first-choice").checked).toBe(true) + expect(next().dataset.shortcut).toBe("Enter") + + await keydown(choiceInput("first-choice"), "Enter") + + expect(item("second").hasAttribute("data-active")).toBe(true) + expect(freeform("second-input").hasAttribute("data-shortcut")).toBe(false) + + await keydown(freeform("second-input"), "Enter") + + expect(onSubmit).not.toHaveBeenCalled() + + await type(freeform("second-input"), "Enough detail") + await keydown(freeform("second-input"), "Enter") + + expect(onSubmit).toHaveBeenCalledOnce() + }) + + it("validates, advances, and submits with Command or Control plus Enter", async () => { + const onSubmit = vi.fn((event: React.FormEvent) => { + event.preventDefault() + }) + + await renderQuestionnaire({ onSubmit }) + + await keydown(freeform("scope-input"), "Enter", { metaKey: true }) + + expect(item("scope").hasAttribute("data-active")).toBe(true) + expect(item("scope").getAttribute("aria-invalid")).toBe("true") + expect(document.activeElement).toBe(choiceInput("scope-delegation")) + + await choose("scope-questions") + await keydown(freeform("scope-input"), "Enter", { metaKey: true }) + + expect(item("detail").hasAttribute("data-active")).toBe(true) + + await choose("detail-focused") + await keydown(item("detail"), "Enter", { ctrlKey: true }) + + expect(onSubmit).toHaveBeenCalledOnce() + }) + + it("does not handle modified, repeated, prevented, or composing keys", async () => { + const onSubmit = vi.fn((event: React.FormEvent) => { + event.preventDefault() + }) + let preventKeyDown = false + + await act(async () => { + root.render( + { + if (preventKeyDown) { + event.preventDefault() + } + }} + shortcuts="letters" + > + + Answer + + Choice + + + + + + ) + }) + + await keydown(choiceInput("answer-choice"), "a", { ctrlKey: true }) + await keydown(choiceInput("answer-choice"), "a", { repeat: true }) + + expect(choiceInput("answer-choice").checked).toBe(false) + + await type(freeform("answer-input"), "Composing") + await keydown(freeform("answer-input"), "Enter", { isComposing: true }) + await keydown(freeform("answer-input"), "Enter", { + metaKey: true, + repeat: true, + }) + await keydown(freeform("answer-input"), "Enter", { + metaKey: true, + shiftKey: true, + }) + + expect(onSubmit).not.toHaveBeenCalled() + + preventKeyDown = true + await keydown(freeform("answer-input"), "Enter") + + expect(onSubmit).not.toHaveBeenCalled() + }) + + it("composes fixed choice inputs, labels, and assigned shortcuts", async () => { + await act(async () => { + root.render( + + + Answer + + + + Fixed + + + + + + ) + }) + + const input = requiredElement( + '[data-testid="fixed-input"]' + ) + const label = requiredElement('[data-testid="fixed-label"]') + const shortcut = requiredElement( + '[data-testid="fixed-shortcut"]' + ) + + expect(choice("fixed").tagName).toBe("LABEL") + expect(input.type).toBe("radio") + expect(input.name).toBe("answer") + expect(input.value).toBe("fixed") + expect(label.tagName).toBe("SPAN") + expect(label.textContent).toBe("Fixed") + expect(shortcut.textContent).toBe("A") + expect(shortcut.dataset.shortcut).toBe("A") + expect(shortcut.getAttribute("aria-hidden")).toBe("true") + expect(shortcut.hidden).toBe(false) + }) + + it("registers a ChoiceInput whenever its composed input mounts", async () => { + function ConditionalChoiceInputQuestionnaire() { + const [showInput, setShowInput] = React.useState(false) + + return ( + + + Answer + + {showInput && ( + + )} + Conditional + + + + + + ) + } + + await act(async () => { + root.render() + }) + + expect(item("answer").dataset.status).toBe("unanswered") + expect(choice("conditional-choice").hasAttribute("data-shortcut")).toBe( + false + ) + + const toggle = requiredElement( + '[data-testid="toggle-input"]' + ) + + await click(toggle) + + const firstInput = requiredElement( + '[data-testid="conditional-input"]' + ) + + expect(choice("conditional-choice").dataset.shortcut).toBe("A") + + await click(firstInput) + + expect(item("answer").dataset.status).toBe("answered") + + await click(toggle) + + expect(item("answer").dataset.status).toBe("unanswered") + + await click(toggle) + + const secondInput = requiredElement( + '[data-testid="conditional-input"]' + ) + + expect(secondInput).not.toBe(firstInput) + expect(secondInput.checked).toBe(true) + expect(item("answer").dataset.status).toBe("answered") + }) + + it("supports render callbacks and emits no primitive data slots", async () => { + await act(async () => { + root.render( + + ( + + {state.current}/{state.total} + + )} + /> + + ( + + + ) +} + +function DynamicDescriptionsQuestionnaire() { + const [showAdditionalDetails, setShowAdditionalDetails] = React.useState(true) + + return ( + + + Answer + + First description + + {showAdditionalDetails ? ( + + Second description + + ) : null} + Answer + First error + {showAdditionalDetails ? ( + Second error + ) : null} + + + + + ) +} + +function DynamicQuestionnaire({ + includeMiddle, + onItemChange, +}: { + includeMiddle: boolean + onItemChange: (item: string) => void +}) { + return ( + + + + {includeMiddle ? : null} + + + + ) +} + +function DynamicAnswersQuestionnaire({ + includeMiddle, +}: { + includeMiddle: boolean +}) { + return ( + + + Choose an answer + + First + + {includeMiddle ? ( + + Middle + + ) : null} + + Last + + + + ) +} + +async function renderQuestionnaire(options: RenderQuestionnaireOptions = {}) { + await act(async () => { + root.render( + + + + + What should come next? + + Choose one or write another answer. + + + + Delegation + + + Question prompts + + + + + + + + How much detail? + + + Focused + + + Complete + + + + + + + + + + + ) + }) +} + +function TestItem({ + disabled, + name, + onStatusChange, + required, + ...props +}: React.ComponentProps) { + return ( + + {name} + + Answer + + + + ) +} + +function TestChoice({ + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + ) +} + +function form() { + return requiredElement('[data-testid="root"]') +} + +function progress() { + return requiredElement('[data-testid="progress"]') +} + +function item(value: string) { + return requiredElement(`[data-testid="${value}"]`) +} + +function choice(testId: string) { + return requiredElement(`[data-testid="${testId}"]`) +} + +function choiceInput(testId: string) { + const input = choice(testId).querySelector("input") + + if (!input) { + throw new Error(`Missing choice input: ${testId}`) + } + + return input +} + +function freeform(testId: string) { + return requiredElement(`[data-testid="${testId}"]`) +} + +function error(testId: string) { + return requiredElement(`[data-testid="${testId}"]`) +} + +function previous() { + return requiredElement('[data-testid="previous"]') +} + +function skip() { + return requiredElement('[data-testid="skip"]') +} + +function next() { + return requiredElement('[data-testid="next"]') +} + +function submit() { + return requiredElement('[data-testid="submit"]') +} + +function requiredElement(selector: string) { + const element = container.querySelector(selector) + + if (!element) { + throw new Error(`Missing test element: ${selector}`) + } + + return element +} + +async function click(element: HTMLElement) { + await act(async () => { + element.click() + }) +} + +async function choose(testId: string) { + await click(choiceInput(testId)) +} + +async function type(element: HTMLInputElement, value: string) { + await act(async () => { + element.focus() + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value" + )?.set?.call(element, value) + element.dispatchEvent(new Event("input", { bubbles: true })) + }) +} + +async function keydown( + element: HTMLElement, + key: string, + options: KeyboardEventInit = {} +) { + await act(async () => { + element.focus() + element.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key, + ...options, + }) + ) + }) +} diff --git a/packages/react/src/questionnaire/types.ts b/packages/react/src/questionnaire/types.ts new file mode 100644 index 00000000000..da3f4f64637 --- /dev/null +++ b/packages/react/src/questionnaire/types.ts @@ -0,0 +1,305 @@ +import * as React from "react" + +import type { UseRenderComponentProps } from "../use-render" + +type QuestionnaireItemStatus = "unanswered" | "answered" | "skipped" +type QuestionnaireShortcutMode = "letters" | "numbers" + +type QuestionnaireChoiceDefinition = { + disabled?: boolean + value: string +} + +type QuestionnaireItemDefinition = { + choices?: readonly QuestionnaireChoiceDefinition[] + disabled?: boolean + name: string + required?: boolean +} + +type QuestionnaireRootState = { + current: number + first: boolean + last: boolean + total: number +} + +type QuestionnaireRootProps = Omit< + React.ComponentPropsWithRef<"form">, + "defaultValue" | "value" +> & { + defaultItem?: string + item?: string + items?: readonly QuestionnaireItemDefinition[] + onItemChange?: (item: string) => void + shortcuts?: QuestionnaireShortcutMode +} + +type QuestionnaireProgressState = QuestionnaireRootState + +type QuestionnaireProgressProps = UseRenderComponentProps< + "div", + QuestionnaireProgressState +> + +type QuestionnaireItemState = { + active: boolean + disabled: boolean + invalid: boolean + multiple: boolean + required: boolean + status: QuestionnaireItemStatus +} + +type QuestionnaireItemProps = Omit< + React.ComponentPropsWithRef<"fieldset">, + "name" | "value" +> & { + invalid?: boolean + name: string + multiple?: boolean + onStatusChange?: (status: QuestionnaireItemStatus) => void + required?: boolean +} + +type QuestionnaireTitleProps = UseRenderComponentProps<"legend"> +type QuestionnaireDescriptionProps = UseRenderComponentProps<"p"> +type QuestionnaireChoicesState = { + shortcuts: QuestionnaireShortcutMode | null +} + +type QuestionnaireChoicesProps = UseRenderComponentProps< + "div", + QuestionnaireChoicesState +> +type QuestionnaireErrorProps = UseRenderComponentProps< + "p", + Pick +> + +type QuestionnaireChoiceState = { + checked: boolean + disabled: boolean + invalid: boolean + shortcut: string | null + type: "checkbox" | "radio" +} + +type QuestionnaireChoiceContextValue = { + inputProps: React.ComponentPropsWithRef<"input"> + state: QuestionnaireChoiceState +} + +type QuestionnaireChoiceProps = Omit< + UseRenderComponentProps<"label", QuestionnaireChoiceState>, + "onChange" +> & { + checked?: boolean + defaultChecked?: boolean + disabled?: boolean + onChange?: React.ChangeEventHandler + value: string +} + +type QuestionnaireChoiceInputProps = Omit< + UseRenderComponentProps<"input", QuestionnaireChoiceState>, + | "checked" + | "defaultChecked" + | "disabled" + | "name" + | "onChange" + | "required" + | "type" + | "value" +> + +type QuestionnaireChoiceLabelProps = UseRenderComponentProps<"span"> + +type QuestionnaireChoiceShortcutState = Pick< + QuestionnaireChoiceState, + "shortcut" +> + +type QuestionnaireChoiceShortcutProps = UseRenderComponentProps< + "span", + QuestionnaireChoiceShortcutState +> + +type QuestionnaireInputState = { + disabled: boolean + filled: boolean + invalid: boolean +} + +type QuestionnaireInputType = + | "date" + | "datetime-local" + | "email" + | "month" + | "number" + | "password" + | "search" + | "tel" + | "text" + | "time" + | "url" + | "week" + +type QuestionnaireInputProps = Omit< + UseRenderComponentProps<"input", QuestionnaireInputState>, + "form" | "name" | "type" +> & { + type?: QuestionnaireInputType +} + +type QuestionnaireNavigationState = { + disabled: boolean + shortcut: "Enter" | null + status: QuestionnaireItemStatus | null + visible: boolean +} + +type QuestionnairePreviousProps = UseRenderComponentProps< + "button", + QuestionnaireNavigationState +> + +type QuestionnaireSkipProps = UseRenderComponentProps< + "button", + QuestionnaireNavigationState +> + +type QuestionnaireNextProps = UseRenderComponentProps< + "button", + QuestionnaireNavigationState +> + +type QuestionnaireSubmitProps = UseRenderComponentProps< + "button", + QuestionnaireNavigationState +> + +type AnswerControlRegistration = { + disabled: boolean + element: HTMLInputElement + id: string +} & ( + | { + ownDisabled: boolean + type: "choice" + value: string + } + | { + type: "input" + } +) + +type ChoiceRegistration = { + disabled: boolean + value: string +} + +type ItemRegistration = { + choices: readonly ChoiceRegistration[] + disabled: boolean + element: HTMLFieldSetElement + focus: () => void + focusInvalid: () => void + getAnswerByElement: (element: Element) => AnswerControlRegistration | null + getAnswerByShortcut: (shortcut: string) => AnswerControlRegistration | null + moveAnswerFocus: (element: Element, direction: "next" | "previous") => boolean + name: string + required: boolean + reset: () => void + skip: () => void + status: QuestionnaireItemStatus + validate: () => boolean +} + +type PendingFocus = { + name: string + target: "invalid" | "item" +} + +type QuestionnaireContextValue = QuestionnaireRootState & { + activeItem: ItemRegistration | null + activeItemName: string | null + activeItemRequired: boolean | null + activeItemStatus: QuestionnaireItemStatus | null + domVersion: number + goNext: () => void + goPrevious: () => void + nativeValidation: boolean + itemDefinitionByName: ReadonlyMap | null + registerItem: (registration: ItemRegistration) => () => void + shortcuts: QuestionnaireShortcutMode | null + skipCurrent: () => void +} + +type QuestionnaireItemContextValue = { + active: boolean + disabled: boolean + hasInputAnswer: boolean + invalid: boolean + multiple: boolean + name: string + registerAnswerControl: (registration: AnswerControlRegistration) => () => void + registerAnswerSelection: ( + answerId: string, + defaultSelected: boolean + ) => () => void + registerDescription: (descriptionId: string) => () => void + registerError: (errorId: string) => () => void + required: boolean + resetVersion: number + selectedAnswerIds: string[] + setAnswerDefault: (answerId: string, defaultSelected: boolean) => void + setAnswerSelectionFromInteraction: ( + answerId: string, + selected: boolean + ) => void + shortcutByAnswerId: ReadonlyMap + shortcutByChoiceValue: ReadonlyMap | null + shortcuts: QuestionnaireShortcutMode | null + status: QuestionnaireItemStatus + syncControlledAnswerSelection: (answerId: string, selected: boolean) => void +} + +export type { + AnswerControlRegistration, + ChoiceRegistration, + ItemRegistration, + PendingFocus, + QuestionnaireChoiceContextValue, + QuestionnaireChoiceDefinition, + QuestionnaireChoiceInputProps, + QuestionnaireChoiceLabelProps, + QuestionnaireChoiceProps, + QuestionnaireChoiceShortcutProps, + QuestionnaireChoiceShortcutState, + QuestionnaireChoiceState, + QuestionnaireChoicesProps, + QuestionnaireChoicesState, + QuestionnaireContextValue, + QuestionnaireDescriptionProps, + QuestionnaireErrorProps, + QuestionnaireInputProps, + QuestionnaireInputState, + QuestionnaireInputType, + QuestionnaireItemContextValue, + QuestionnaireItemDefinition, + QuestionnaireItemProps, + QuestionnaireItemState, + QuestionnaireItemStatus, + QuestionnaireNavigationState, + QuestionnaireNextProps, + QuestionnairePreviousProps, + QuestionnaireProgressProps, + QuestionnaireProgressState, + QuestionnaireRootProps, + QuestionnaireRootState, + QuestionnaireShortcutMode, + QuestionnaireSkipProps, + QuestionnaireSubmitProps, + QuestionnaireTitleProps, +} diff --git a/packages/react/src/questionnaire/use-questionnaire-choice.ts b/packages/react/src/questionnaire/use-questionnaire-choice.ts new file mode 100644 index 00000000000..089e91d9def --- /dev/null +++ b/packages/react/src/questionnaire/use-questionnaire-choice.ts @@ -0,0 +1,170 @@ +import * as React from "react" + +import { useQuestionnaireItemContext } from "./context" +import type { + QuestionnaireChoiceProps, + QuestionnaireChoiceState, +} from "./types" +import { getAnswerKeyShortcuts } from "./utils" + +type UseQuestionnaireChoiceParameters = Pick< + QuestionnaireChoiceProps, + "checked" | "defaultChecked" | "disabled" | "onChange" | "value" +> + +function useQuestionnaireChoice({ + checked: controlledChecked, + defaultChecked = false, + disabled: choiceDisabled = false, + onChange, + value, +}: UseQuestionnaireChoiceParameters) { + const { + disabled: itemDisabled, + hasInputAnswer, + invalid, + multiple, + name: itemName, + registerAnswerControl, + registerAnswerSelection, + required, + resetVersion, + selectedAnswerIds, + setAnswerDefault, + setAnswerSelectionFromInteraction, + shortcutByAnswerId, + shortcutByChoiceValue, + status, + syncControlledAnswerSelection, + } = useQuestionnaireItemContext("Questionnaire.Choice") + const answerId = React.useId() + const [inputElement, setInputElement] = + React.useState(null) + const initialDefaultCheckedRef = React.useRef(defaultChecked) + const controlled = controlledChecked !== undefined + const disabled = itemDisabled || choiceDisabled + const selected = selectedAnswerIds.includes(answerId) + const checked = controlled + ? status === "skipped" + ? false + : controlledChecked + : selected + const type = multiple ? "checkbox" : "radio" + const shortcut = + shortcutByChoiceValue?.get(value) ?? + shortcutByAnswerId.get(answerId) ?? + null + + React.useLayoutEffect( + () => registerAnswerSelection(answerId, initialDefaultCheckedRef.current), + [answerId, registerAnswerSelection] + ) + React.useLayoutEffect( + () => setAnswerDefault(answerId, defaultChecked), + [answerId, defaultChecked, setAnswerDefault] + ) + + React.useLayoutEffect(() => { + if (!inputElement) { + return + } + + return registerAnswerControl({ + disabled, + element: inputElement, + id: answerId, + ownDisabled: choiceDisabled, + type: "choice", + value, + }) + }, [ + answerId, + choiceDisabled, + disabled, + inputElement, + registerAnswerControl, + value, + ]) + + React.useLayoutEffect(() => { + if (controlled) { + syncControlledAnswerSelection(answerId, controlledChecked) + } + }, [ + answerId, + controlled, + controlledChecked, + resetVersion, + syncControlledAnswerSelection, + ]) + + React.useLayoutEffect(() => { + if (!inputElement) { + return + } + + // Keep the native reset target aligned with Questionnaire's owned default, + // including controlled choices whose checked prop remains authoritative. + inputElement.defaultChecked = controlled + ? controlledChecked + : defaultChecked + + if (resetVersion > 0) { + inputElement.checked = checked + } + }, [ + checked, + controlled, + controlledChecked, + defaultChecked, + inputElement, + resetVersion, + ]) + + function handleChange(event: React.ChangeEvent) { + onChange?.(event) + + if (event.defaultPrevented) { + return + } + + if (!controlled) { + setAnswerSelectionFromInteraction(answerId, event.target.checked) + return + } + + if (status === "skipped" && controlledChecked === event.target.checked) { + setAnswerSelectionFromInteraction(answerId, controlledChecked) + } + } + + const state: QuestionnaireChoiceState = { + checked, + disabled, + invalid, + shortcut, + type, + } + + return { + inputProps: { + ref: setInputElement, + "aria-invalid": invalid || undefined, + "aria-keyshortcuts": getAnswerKeyShortcuts( + shortcut, + !disabled && checked + ), + checked, + disabled, + id: answerId, + name: status === "skipped" ? undefined : itemName, + onChange: handleChange, + required: required && !multiple && !hasInputAnswer, + type, + value, + }, + state, + } +} + +export { useQuestionnaireChoice } diff --git a/packages/react/src/questionnaire/use-questionnaire-input.ts b/packages/react/src/questionnaire/use-questionnaire-input.ts new file mode 100644 index 00000000000..e357ae8419f --- /dev/null +++ b/packages/react/src/questionnaire/use-questionnaire-input.ts @@ -0,0 +1,149 @@ +import * as React from "react" + +import { composeRefs } from "../use-render" +import { useQuestionnaireItemContext } from "./context" +import type { QuestionnaireInputProps, QuestionnaireInputState } from "./types" +import { getAnswerKeyShortcuts, hasInputValue } from "./utils" + +type UseQuestionnaireInputParameters = Pick< + QuestionnaireInputProps, + "defaultValue" | "disabled" | "onChange" | "ref" | "type" | "value" +> + +function useQuestionnaireInput({ + defaultValue, + disabled: inputDisabled = false, + onChange, + ref, + type = "text", + value: controlledValue, +}: UseQuestionnaireInputParameters) { + const { + disabled: itemDisabled, + invalid, + name: itemName, + registerAnswerControl, + registerAnswerSelection, + resetVersion, + selectedAnswerIds, + setAnswerDefault, + setAnswerSelectionFromInteraction, + syncControlledAnswerSelection, + } = useQuestionnaireItemContext("Questionnaire.Input") + const answerId = React.useId() + const inputRef = React.useRef(null) + const initialDefaultFilledRef = React.useRef(hasInputValue(defaultValue)) + const controlled = controlledValue !== undefined + const defaultFilled = hasInputValue(defaultValue) + const controlledFilled = hasInputValue(controlledValue) + const [uncontrolledFilled, setUncontrolledFilled] = + React.useState(defaultFilled) + const disabled = itemDisabled || inputDisabled + const filled = controlled ? controlledFilled : uncontrolledFilled + const selected = selectedAnswerIds.includes(answerId) + + React.useLayoutEffect( + () => registerAnswerSelection(answerId, initialDefaultFilledRef.current), + [answerId, registerAnswerSelection] + ) + React.useLayoutEffect( + () => setAnswerDefault(answerId, defaultFilled), + [defaultFilled, answerId, setAnswerDefault] + ) + + React.useLayoutEffect(() => { + const input = inputRef.current + + if (!input) { + return + } + + return registerAnswerControl({ + disabled, + element: input, + id: answerId, + type: "input", + }) + }, [disabled, answerId, registerAnswerControl]) + + React.useLayoutEffect(() => { + if (controlled) { + syncControlledAnswerSelection(answerId, controlledFilled) + return + } + + if (resetVersion > 0) { + setUncontrolledFilled(defaultFilled) + } + }, [ + controlled, + controlledFilled, + controlledValue, + defaultFilled, + answerId, + resetVersion, + syncControlledAnswerSelection, + ]) + + React.useLayoutEffect(() => { + const input = inputRef.current + + if (!input || !controlled) { + return + } + + input.defaultValue = String(controlledValue) + }, [controlled, controlledValue]) + + function handleChange(event: React.ChangeEvent) { + onChange?.(event) + + if (event.defaultPrevented) { + return + } + + const nextFilled = event.target.value.trim().length > 0 + + if (controlled) { + return + } + + setUncontrolledFilled(nextFilled) + setAnswerSelectionFromInteraction(answerId, nextFilled) + } + + const state: QuestionnaireInputState = { + disabled, + filled, + invalid, + } + const setInputRef = React.useCallback( + (element: HTMLInputElement | null) => { + inputRef.current = element + composeRefs(ref)?.(element) + }, + [ref] + ) + + return { + inputProps: { + "aria-invalid": invalid || undefined, + "aria-keyshortcuts": getAnswerKeyShortcuts( + null, + !disabled && filled && selected + ), + defaultValue: controlled ? undefined : defaultValue, + disabled, + form: selected ? undefined : "", + id: answerId, + name: selected ? itemName : undefined, + onChange: handleChange, + ref: setInputRef, + type, + value: controlled ? controlledValue : undefined, + }, + state, + } +} + +export { useQuestionnaireInput } diff --git a/packages/react/src/questionnaire/use-questionnaire-item.ts b/packages/react/src/questionnaire/use-questionnaire-item.ts new file mode 100644 index 00000000000..f7c828b6028 --- /dev/null +++ b/packages/react/src/questionnaire/use-questionnaire-item.ts @@ -0,0 +1,571 @@ +import * as React from "react" + +import { composeRefs } from "../use-render" +import { getShortcutByChoiceValue } from "./collection" +import { useQuestionnaireContext } from "./context" +import type { + AnswerControlRegistration, + QuestionnaireItemContextValue, + QuestionnaireItemProps, + QuestionnaireItemState, + QuestionnaireItemStatus, +} from "./types" +import { + compareAnswerOrder, + getShortcutKeys, + isAnswerFilled, + isEmptyNavigableInput, + isRadioTarget, + isTextEntryTarget, +} from "./utils" + +type UseQuestionnaireItemParameters = Pick< + QuestionnaireItemProps, + | "aria-describedby" + | "aria-keyshortcuts" + | "disabled" + | "invalid" + | "multiple" + | "name" + | "onStatusChange" + | "ref" + | "required" +> + +function useQuestionnaireItem({ + "aria-describedby": ariaDescribedBy, + "aria-keyshortcuts": ariaKeyShortcuts, + disabled = false, + invalid: externallyInvalid = false, + multiple = false, + name, + onStatusChange, + ref, + required = false, +}: UseQuestionnaireItemParameters) { + const { + activeItemName, + domVersion, + first, + itemDefinitionByName, + last, + nativeValidation, + registerItem, + shortcuts, + } = useQuestionnaireContext("Questionnaire.Item") + const [element, setElement] = React.useState(null) + const [answerControlRegistrations, setAnswerControlRegistrations] = + React.useState([]) + const [validationAttempted, setValidationAttempted] = React.useState(false) + const [selectedAnswerIds, setSelectedAnswerIds] = React.useState([]) + const [skipped, setSkipped] = React.useState(false) + const [resetVersion, setResetVersion] = React.useState(0) + const [descriptionIds, setDescriptionIds] = React.useState([]) + const [errorIds, setErrorIds] = React.useState([]) + const defaultSelectedAnswerIdsRef = React.useRef([]) + const multipleRef = React.useRef(multiple) + const previousMultipleRef = React.useRef(multiple) + multipleRef.current = multiple + const active = !disabled && activeItemName === name + const answerControls = React.useMemo( + () => [...answerControlRegistrations].sort(compareAnswerOrder), + [answerControlRegistrations, domVersion] + ) + const answers = React.useMemo( + () => answerControls.filter((registration) => !registration.disabled), + [answerControls] + ) + const answered = answers.some((answer) => + selectedAnswerIds.includes(answer.id) + ) + const status: QuestionnaireItemStatus = skipped + ? "skipped" + : answered + ? "answered" + : "unanswered" + const intentionallySkipped = status === "skipped" && !required + const valid = + disabled || + intentionallySkipped || + (!externallyInvalid && status === "answered") + const invalid = + !disabled && + !intentionallySkipped && + (externallyInvalid || (validationAttempted && !valid)) + const hasInputAnswer = answers.some((answer) => answer.type === "input") + const previousStatusRef = React.useRef(status) + const itemDefinition = itemDefinitionByName?.get(name) + const shortcutByChoiceValue = React.useMemo( + () => + itemDefinitionByName + ? getShortcutByChoiceValue(itemDefinition, shortcuts) + : null, + [itemDefinition, itemDefinitionByName, shortcuts] + ) + const shortcutByAnswerId = React.useMemo(() => { + if (shortcutByChoiceValue) { + return new Map() + } + + const keys = getShortcutKeys(shortcuts) + const shortcutAnswers = answers.filter((answer) => answer.type === "choice") + + return new Map( + shortcutAnswers + .slice(0, keys.length) + .map((answer, index) => [answer.id, keys[index]]) + ) + }, [answers, shortcutByChoiceValue, shortcuts]) + + const registerAnswerControl = React.useCallback( + (registration: AnswerControlRegistration) => { + setAnswerControlRegistrations((currentRegistrations) => [ + ...currentRegistrations.filter( + (currentRegistration) => + currentRegistration.element !== registration.element && + currentRegistration.id !== registration.id + ), + registration, + ]) + + return () => { + setAnswerControlRegistrations((currentRegistrations) => + currentRegistrations.filter( + (currentRegistration) => currentRegistration !== registration + ) + ) + } + }, + [] + ) + + React.useLayoutEffect(() => { + if (previousStatusRef.current === status) { + return + } + + previousStatusRef.current = status + + onStatusChange?.(status) + }, [onStatusChange, status]) + + const updateAnswerSelected = React.useCallback( + (answerId: string, selected: boolean) => { + setSelectedAnswerIds((currentAnswerIds) => { + if (!selected) { + return currentAnswerIds.filter( + (currentAnswerId) => currentAnswerId !== answerId + ) + } + + if (!multiple) { + return [answerId] + } + + return currentAnswerIds.includes(answerId) + ? currentAnswerIds + : [...currentAnswerIds, answerId] + }) + }, + [multiple] + ) + const setAnswerSelectionFromInteraction = React.useCallback( + (answerId: string, selected: boolean) => { + setSkipped(false) + updateAnswerSelected(answerId, selected) + }, + [updateAnswerSelected] + ) + const syncControlledAnswerSelection = React.useCallback( + (answerId: string, selected: boolean) => { + if (selected) { + setSkipped(false) + } + + updateAnswerSelected(answerId, selected) + }, + [updateAnswerSelected] + ) + + const registerAnswerSelection = React.useCallback( + (answerId: string, defaultSelected: boolean) => { + if (defaultSelected) { + defaultSelectedAnswerIdsRef.current = [ + ...defaultSelectedAnswerIdsRef.current.filter( + (currentAnswerId) => currentAnswerId !== answerId + ), + answerId, + ] + setSelectedAnswerIds((currentAnswerIds) => { + if (!multipleRef.current) { + return currentAnswerIds.length ? currentAnswerIds : [answerId] + } + + return currentAnswerIds.includes(answerId) + ? currentAnswerIds + : [...currentAnswerIds, answerId] + }) + } + + return () => { + defaultSelectedAnswerIdsRef.current = + defaultSelectedAnswerIdsRef.current.filter( + (currentAnswerId) => currentAnswerId !== answerId + ) + setSelectedAnswerIds((currentAnswerIds) => + currentAnswerIds.filter( + (currentAnswerId) => currentAnswerId !== answerId + ) + ) + } + }, + [] + ) + const setAnswerDefault = React.useCallback( + (answerId: string, defaultSelected: boolean) => { + if (defaultSelected) { + defaultSelectedAnswerIdsRef.current = + defaultSelectedAnswerIdsRef.current.includes(answerId) + ? defaultSelectedAnswerIdsRef.current + : [...defaultSelectedAnswerIdsRef.current, answerId] + return + } + + defaultSelectedAnswerIdsRef.current = + defaultSelectedAnswerIdsRef.current.filter( + (currentAnswerId) => currentAnswerId !== answerId + ) + }, + [] + ) + + const registerDescription = React.useCallback( + (registeredDescriptionId: string) => { + setDescriptionIds((currentDescriptionIds) => + currentDescriptionIds.includes(registeredDescriptionId) + ? currentDescriptionIds + : [...currentDescriptionIds, registeredDescriptionId] + ) + + return () => { + setDescriptionIds((currentDescriptionIds) => + currentDescriptionIds.filter( + (currentDescriptionId) => + currentDescriptionId !== registeredDescriptionId + ) + ) + } + }, + [] + ) + + const registerError = React.useCallback((registeredErrorId: string) => { + setErrorIds((currentErrorIds) => + currentErrorIds.includes(registeredErrorId) + ? currentErrorIds + : [...currentErrorIds, registeredErrorId] + ) + + return () => { + setErrorIds((currentErrorIds) => + currentErrorIds.filter( + (currentErrorId) => currentErrorId !== registeredErrorId + ) + ) + } + }, []) + + const validate = React.useCallback(() => { + setValidationAttempted(true) + + if (!valid) { + return false + } + + if (!nativeValidation) { + return true + } + + const invalidAnswer = answers.find( + (answer) => + isAnswerFilled(answer) && + answer.element.willValidate && + !answer.element.validity.valid + ) + + if (!invalidAnswer) { + return true + } + + invalidAnswer.element.focus() + invalidAnswer.element.reportValidity() + + return false + }, [answers, nativeValidation, valid]) + + const focus = React.useCallback(() => { + element?.focus() + }, [element]) + + const focusInvalid = React.useCallback(() => { + const selectedInput = element?.querySelector( + "input[data-filled][name]:not(:disabled)" + ) + const firstControl = element?.querySelector( + "input:not([type=hidden]):not(:disabled), textarea:not(:disabled)" + ) + + ;(selectedInput ?? firstControl ?? element)?.focus() + }, [element]) + + const reset = React.useCallback(() => { + setValidationAttempted(false) + setSkipped(false) + setSelectedAnswerIds( + multiple + ? [...defaultSelectedAnswerIdsRef.current] + : defaultSelectedAnswerIdsRef.current.slice(0, 1) + ) + setResetVersion((version) => version + 1) + }, [multiple]) + + const skip = React.useCallback(() => { + if (required) { + return + } + + setSelectedAnswerIds([]) + setSkipped(true) + }, [required]) + + React.useLayoutEffect(() => { + const wasMultiple = previousMultipleRef.current + previousMultipleRef.current = multiple + + if (!wasMultiple || multiple) { + return + } + + setSelectedAnswerIds((currentAnswerIds) => { + const selectedAnswer = answers.find((answer) => + currentAnswerIds.includes(answer.id) + ) + + return selectedAnswer ? [selectedAnswer.id] : [] + }) + }, [answers, multiple]) + + const getAnswerByElement = React.useCallback( + (answerElement: Element) => + answers.find((answer) => answer.element === answerElement) ?? null, + [answers] + ) + const getAnswerByShortcut = React.useCallback( + (shortcut: string) => { + if (shortcutByChoiceValue) { + const choiceValue = Array.from(shortcutByChoiceValue.entries()).find( + ([, choiceShortcut]) => choiceShortcut === shortcut + )?.[0] + + return ( + answers.find( + (answer) => answer.type === "choice" && answer.value === choiceValue + ) ?? null + ) + } + + const answerId = Array.from(shortcutByAnswerId.entries()).find( + ([, answerShortcut]) => answerShortcut === shortcut + )?.[0] + + return answers.find((answer) => answer.id === answerId) ?? null + }, + [answers, shortcutByAnswerId, shortcutByChoiceValue] + ) + const moveAnswerFocus = React.useCallback( + (currentElement: Element, direction: "next" | "previous") => { + const currentIndex = answers.findIndex( + (answer) => answer.element === currentElement + ) + const currentAnswer = + currentIndex < 0 ? null : (answers[currentIndex] ?? null) + + if ( + !answers.length || + (isTextEntryTarget(currentElement) && + !isEmptyNavigableInput(currentAnswer)) || + (currentIndex < 0 && currentElement !== element) + ) { + return false + } + + const nextAnswer = + currentIndex < 0 + ? (answers.find(isAnswerFilled) ?? + (direction === "next" ? answers[0] : answers[answers.length - 1])) + : answers[ + (currentIndex + + (direction === "next" ? 1 : -1) + + answers.length) % + answers.length + ] + + if (!nextAnswer || nextAnswer.element === currentElement) { + return false + } + + if ( + currentIndex >= 0 && + isRadioTarget(currentElement) && + isRadioTarget(nextAnswer.element) + ) { + return false + } + + nextAnswer.element.focus() + + if (nextAnswer.type === "choice" && isRadioTarget(nextAnswer.element)) { + nextAnswer.element.click() + } + + return true + }, + [answers, element] + ) + + React.useLayoutEffect(() => { + if (!element) { + return + } + + return registerItem({ + choices: answerControls.flatMap((answer) => + answer.type === "choice" + ? [{ disabled: answer.ownDisabled, value: answer.value }] + : [] + ), + disabled, + element, + focus, + focusInvalid, + getAnswerByElement, + getAnswerByShortcut, + moveAnswerFocus, + name, + required, + reset, + skip, + status, + validate, + }) + }, [ + answerControls, + disabled, + element, + focus, + focusInvalid, + getAnswerByElement, + getAnswerByShortcut, + moveAnswerFocus, + name, + registerItem, + required, + reset, + skip, + status, + validate, + ]) + + const context = React.useMemo( + () => ({ + active, + disabled, + hasInputAnswer, + invalid, + multiple, + name, + registerAnswerControl, + registerAnswerSelection, + registerDescription, + registerError, + required, + resetVersion, + selectedAnswerIds, + setAnswerDefault, + setAnswerSelectionFromInteraction, + shortcutByAnswerId, + shortcutByChoiceValue, + shortcuts, + status, + syncControlledAnswerSelection, + }), + [ + active, + disabled, + hasInputAnswer, + invalid, + multiple, + name, + registerAnswerControl, + registerAnswerSelection, + registerDescription, + registerError, + required, + resetVersion, + selectedAnswerIds, + setAnswerDefault, + setAnswerSelectionFromInteraction, + shortcutByAnswerId, + shortcutByChoiceValue, + shortcuts, + status, + syncControlledAnswerSelection, + ] + ) + const setItemRef = React.useCallback( + (nextElement: HTMLFieldSetElement | null) => { + setElement(nextElement) + composeRefs(ref)?.(nextElement) + }, + [ref] + ) + const describedBy = + [...descriptionIds, ...(invalid ? errorIds : []), ariaDescribedBy] + .filter(Boolean) + .join(" ") || undefined + const keyShortcuts = + [ + ariaKeyShortcuts, + active ? "Meta+Enter Control+Enter" : undefined, + active && answers.length ? "ArrowUp ArrowDown" : undefined, + active && !first ? "ArrowLeft" : undefined, + active && !last && status !== "unanswered" ? "ArrowRight" : undefined, + ] + .filter(Boolean) + .join(" ") || undefined + const state: QuestionnaireItemState = { + active, + disabled, + invalid, + multiple, + required, + status, + } + + return { + context, + itemProps: { + "aria-describedby": describedBy, + "aria-invalid": invalid || undefined, + "aria-keyshortcuts": keyShortcuts, + disabled, + hidden: !active, + inert: !active, + ref: setItemRef, + tabIndex: -1, + }, + state, + } +} + +export { useQuestionnaireItem } diff --git a/packages/react/src/questionnaire/use-questionnaire-root.ts b/packages/react/src/questionnaire/use-questionnaire-root.ts new file mode 100644 index 00000000000..511957c4c7e --- /dev/null +++ b/packages/react/src/questionnaire/use-questionnaire-root.ts @@ -0,0 +1,525 @@ +import * as React from "react" + +import { composeRefs } from "../use-render" +import { + createQuestionnaireCollection, + getCollectionDefinitionWarnings, + getCollectionRegistrationWarnings, + getInitialItemName, +} from "./collection" +import type { + ItemRegistration, + PendingFocus, + QuestionnaireContextValue, + QuestionnaireRootProps, + QuestionnaireRootState, +} from "./types" +import { + compareItemOrder, + getShortcutFromKey, + isAnswerFilled, + isRadioTarget, + isTextEntryTarget, +} from "./utils" + +type UseQuestionnaireRootParameters = Pick< + QuestionnaireRootProps, + | "defaultItem" + | "item" + | "items" + | "noValidate" + | "onItemChange" + | "onReset" + | "onSubmit" + | "ref" + | "shortcuts" +> + +function useQuestionnaireRoot({ + defaultItem, + item: controlledItem, + items: itemDefinitions, + noValidate, + onItemChange, + onReset, + onSubmit, + ref, + shortcuts: shortcutMode, +}: UseQuestionnaireRootParameters) { + const collection = React.useMemo( + () => createQuestionnaireCollection(itemDefinitions), + [itemDefinitions] + ) + const [registrations, setRegistrations] = React.useState( + [] + ) + const [uncontrolledItem, setUncontrolledItem] = React.useState( + () => getInitialItemName(collection, defaultItem) + ) + const [rootElement, setRootElement] = React.useState( + null + ) + const [domVersion, setDomVersion] = React.useState(0) + const pendingFocusRef = React.useRef(null) + const controlled = controlledItem !== undefined + const activeItemName = controlled ? controlledItem : uncontrolledItem + const previousActiveItemNameRef = React.useRef(activeItemName) + const nativeValidation = noValidate === false + const shortcuts = shortcutMode ?? null + + const activeWarningsRef = React.useRef(new Set()) + + React.useLayoutEffect(() => { + if (process.env.NODE_ENV === "production") { + return + } + + if (!collection || !rootElement) { + activeWarningsRef.current.clear() + return + } + + let cancelled = false + + queueMicrotask(() => { + if (cancelled) { + return + } + + const warnings = [ + ...getCollectionDefinitionWarnings(collection, defaultItem), + ...getCollectionRegistrationWarnings( + collection, + registrations, + shortcuts + ), + ] + const activeWarnings = new Set(warnings) + + activeWarnings.forEach((warning) => { + if (!activeWarningsRef.current.has(warning)) { + console.warn(`[Questionnaire] ${warning}`) + } + }) + + activeWarningsRef.current = activeWarnings + }) + + return () => { + cancelled = true + } + }, [collection, defaultItem, registrations, rootElement, shortcuts]) + + React.useLayoutEffect(() => { + if (!rootElement || typeof MutationObserver === "undefined") { + return + } + + const observer = new MutationObserver(() => { + setDomVersion((version) => version + 1) + }) + + observer.observe(rootElement, { childList: true, subtree: true }) + + return () => observer.disconnect() + }, [rootElement]) + + const runtimeItems = React.useMemo( + () => + registrations + .filter((registration) => !registration.disabled) + .sort(compareItemOrder), + [domVersion, registrations] + ) + const runtimeItemByName = React.useMemo( + () => + new Map( + runtimeItems.map((runtimeItem) => [runtimeItem.name, runtimeItem]) + ), + [runtimeItems] + ) + const logicalItems = collection?.enabledItems ?? runtimeItems + const currentIndex = logicalItems.findIndex( + (logicalItem) => logicalItem.name === activeItemName + ) + const activeItem = + currentIndex < 0 || !activeItemName + ? null + : (runtimeItemByName.get(activeItemName) ?? null) + const activeDefinition = activeItemName + ? collection?.itemByName.get(activeItemName) + : undefined + const activeItemRequired = + currentIndex < 0 + ? null + : activeDefinition + ? Boolean(activeDefinition.required) + : (activeItem?.required ?? false) + const activeItemStatus = + currentIndex < 0 + ? null + : (activeItem?.status ?? (activeItemName ? "unanswered" : null)) + const orderedRegistrations = React.useMemo( + () => + collection + ? collection.enabledItems.flatMap((definition) => { + const registration = runtimeItemByName.get(definition.name) + + return registration ? [registration] : [] + }) + : runtimeItems, + [collection, runtimeItemByName, runtimeItems] + ) + const total = logicalItems.length + const current = currentIndex < 0 ? 0 : currentIndex + 1 + const first = total > 0 && currentIndex === 0 + const last = total > 0 && currentIndex === total - 1 + + const setItem = React.useCallback( + (nextItem: string, focusTarget: PendingFocus["target"] = "item") => { + if (nextItem === activeItemName) { + return + } + + pendingFocusRef.current = { + name: nextItem, + target: focusTarget, + } + + if (!controlled) { + setUncontrolledItem(nextItem) + } + + onItemChange?.(nextItem) + }, + [activeItemName, controlled, onItemChange] + ) + + React.useLayoutEffect(() => { + if (total === 0) { + return + } + + if (currentIndex < 0) { + if (!controlled && activeItemName === null) { + setUncontrolledItem(logicalItems[0].name) + return + } + + setItem(logicalItems[0].name) + return + } + + const pendingFocus = pendingFocusRef.current + const activeItemChanged = + previousActiveItemNameRef.current !== activeItemName + + previousActiveItemNameRef.current = activeItemName + + if (!pendingFocus || pendingFocus.name !== activeItemName) { + if (controlled && activeItemChanged) { + pendingFocusRef.current = null + activeItem?.focus() + } + + return + } + + if (pendingFocus.target === "invalid") { + activeItem?.focusInvalid() + } else { + activeItem?.focus() + } + + pendingFocusRef.current = null + }, [ + activeItem, + activeItemName, + controlled, + currentIndex, + logicalItems, + setItem, + total, + ]) + + const registerItem = React.useCallback((registration: ItemRegistration) => { + setRegistrations((currentRegistrations) => [ + ...currentRegistrations.filter( + (currentRegistration) => + currentRegistration.element !== registration.element && + currentRegistration.name !== registration.name + ), + registration, + ]) + + return () => { + setRegistrations((currentRegistrations) => + currentRegistrations.filter( + (currentRegistration) => currentRegistration !== registration + ) + ) + } + }, []) + + const goPrevious = React.useCallback(() => { + if (currentIndex <= 0) { + return + } + + setItem(logicalItems[currentIndex - 1].name) + }, [currentIndex, logicalItems, setItem]) + + const goNext = React.useCallback(() => { + if (!activeItem || currentIndex >= total - 1) { + return + } + + if (!activeItem.validate()) { + activeItem.focusInvalid() + return + } + + setItem(logicalItems[currentIndex + 1].name) + }, [activeItem, currentIndex, logicalItems, setItem, total]) + + const confirmCurrent = React.useCallback(() => { + if (!activeItem) { + return + } + + if (!activeItem.validate()) { + activeItem.focusInvalid() + return + } + + if (last) { + rootElement?.requestSubmit() + return + } + + setItem(logicalItems[currentIndex + 1].name) + }, [activeItem, currentIndex, last, logicalItems, rootElement, setItem]) + + const skipCurrent = React.useCallback(() => { + if (!activeItem || activeItem.required) { + return + } + + activeItem.skip() + + if (!last) { + setItem(logicalItems[currentIndex + 1].name) + return + } + + queueMicrotask(() => { + rootElement?.requestSubmit() + }) + }, [activeItem, currentIndex, last, logicalItems, rootElement, setItem]) + + function handleReset(event: React.FormEvent) { + onReset?.(event) + + if (event.defaultPrevented) { + return + } + + for (const registration of registrations) { + registration.reset() + } + + const resetItemName = collection + ? getInitialItemName(collection, defaultItem) + : (runtimeItems.find((registration) => registration.name === defaultItem) + ?.name ?? runtimeItems[0]?.name) + + if (resetItemName) { + setItem(resetItemName) + } + } + + function handleSubmit(event: React.FormEvent) { + const firstInvalidItem = orderedRegistrations.find( + (registration) => !registration.validate() + ) + + if (firstInvalidItem) { + event.preventDefault() + setItem(firstInvalidItem.name, "invalid") + + if (firstInvalidItem.name === activeItemName) { + firstInvalidItem.focusInvalid() + pendingFocusRef.current = null + } + + return + } + + onSubmit?.(event) + } + + function handleKeyDown(event: React.KeyboardEvent) { + if ( + event.defaultPrevented || + event.nativeEvent.isComposing || + event.keyCode === 229 || + !activeItem || + !(event.target instanceof Element) + ) { + return + } + + if ( + event.key === "Enter" && + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey + ) { + event.preventDefault() + + if (!event.repeat) { + confirmCurrent() + } + + return + } + + if (event.metaKey || event.ctrlKey || event.altKey) { + return + } + + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + const moved = activeItem.moveAnswerFocus( + event.target, + event.key === "ArrowDown" ? "next" : "previous" + ) + + if (moved) { + event.preventDefault() + return + } + } + + if ( + (event.key === "ArrowLeft" || event.key === "ArrowRight") && + !isTextEntryTarget(event.target) && + !isRadioTarget(event.target) + ) { + event.preventDefault() + + if (event.repeat) { + return + } + + if (event.key === "ArrowLeft") { + goPrevious() + } else if (activeItem.status !== "unanswered") { + goNext() + } + + return + } + + if (event.key === "Enter") { + const answer = activeItem.getAnswerByElement(event.target) + + if (!answer) { + return + } + + event.preventDefault() + + if (!event.repeat && isAnswerFilled(answer)) { + confirmCurrent() + } + + return + } + + if (!shortcuts || isTextEntryTarget(event.target)) { + return + } + + const shortcut = getShortcutFromKey(event.key, shortcuts) + const answer = shortcut ? activeItem.getAnswerByShortcut(shortcut) : null + + if (!answer) { + return + } + + event.preventDefault() + + if (event.repeat) { + return + } + + answer.element.focus() + + if (answer.type === "choice") { + answer.element.click() + } + } + + const state: QuestionnaireRootState = { + current, + first, + last, + total, + } + const context = React.useMemo( + () => ({ + ...state, + activeItem, + activeItemName, + activeItemRequired, + activeItemStatus, + domVersion, + goNext, + goPrevious, + itemDefinitionByName: collection?.itemByName ?? null, + nativeValidation, + registerItem, + shortcuts, + skipCurrent, + }), + [ + activeItem, + activeItemName, + activeItemRequired, + activeItemStatus, + collection, + current, + domVersion, + first, + goNext, + goPrevious, + last, + nativeValidation, + registerItem, + shortcuts, + skipCurrent, + total, + ] + ) + const setRootRef = React.useCallback( + (element: HTMLFormElement | null) => { + setRootElement(element) + composeRefs(ref)?.(element) + }, + [ref] + ) + + return { + context, + rootProps: { + "data-shortcuts": shortcuts ?? undefined, + onKeyDown: handleKeyDown, + onReset: handleReset, + onSubmit: handleSubmit, + ref: setRootRef, + }, + state, + } +} + +export { useQuestionnaireRoot } diff --git a/packages/react/src/questionnaire/utils.ts b/packages/react/src/questionnaire/utils.ts new file mode 100644 index 00000000000..86b50aa53a1 --- /dev/null +++ b/packages/react/src/questionnaire/utils.ts @@ -0,0 +1,141 @@ +import type { + AnswerControlRegistration, + ItemRegistration, + QuestionnaireShortcutMode, +} from "./types" + +function hasInputValue(value: unknown) { + if (Array.isArray(value)) { + return value.some((item) => String(item).trim().length > 0) + } + + return ( + value !== undefined && value !== null && String(value).trim().length > 0 + ) +} + +function getShortcutKeys(shortcuts: QuestionnaireShortcutMode | null) { + if (shortcuts === "letters") { + return Array.from({ length: 26 }, (_, index) => + String.fromCharCode(65 + index) + ) + } + + if (shortcuts === "numbers") { + return Array.from({ length: 9 }, (_, index) => String(index + 1)) + } + + return [] +} + +function getShortcutFromKey(key: string, shortcuts: QuestionnaireShortcutMode) { + const normalizedKey = shortcuts === "letters" ? key.toUpperCase() : key + + return getShortcutKeys(shortcuts).includes(normalizedKey) + ? normalizedKey + : null +} + +function getAnswerKeyShortcuts(shortcut: string | null, filled: boolean) { + return ( + [shortcut, filled ? "Enter" : null].filter(Boolean).join(" ") || undefined + ) +} + +function isAnswerFilled(answer: AnswerControlRegistration) { + if (answer.type === "choice") { + return answer.element.checked + } + + return ( + answer.element.hasAttribute("name") && hasInputValue(answer.element.value) + ) +} + +function isEmptyNavigableInput(answer: AnswerControlRegistration | null) { + return ( + answer?.type === "input" && + ["email", "password", "search", "tel", "text", "url"].includes( + answer.element.type + ) && + !hasInputValue(answer.element.value) + ) +} + +function isTextEntryTarget(element: Element) { + if ( + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement + ) { + return true + } + + if (element instanceof HTMLInputElement) { + return !["button", "checkbox", "radio", "reset", "submit"].includes( + element.type + ) + } + + return element instanceof HTMLElement && element.isContentEditable +} + +function isRadioTarget(element: Element) { + return element instanceof HTMLInputElement && element.type === "radio" +} + +function compareItemOrder( + firstItem: ItemRegistration, + secondItem: ItemRegistration +) { + if (firstItem.element === secondItem.element) { + return 0 + } + + const position = firstItem.element.compareDocumentPosition(secondItem.element) + + if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + return -1 + } + + if (position & Node.DOCUMENT_POSITION_PRECEDING) { + return 1 + } + + return 0 +} + +function compareAnswerOrder( + firstAnswer: AnswerControlRegistration, + secondAnswer: AnswerControlRegistration +) { + if (firstAnswer.element === secondAnswer.element) { + return 0 + } + + const position = firstAnswer.element.compareDocumentPosition( + secondAnswer.element + ) + + if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + return -1 + } + + if (position & Node.DOCUMENT_POSITION_PRECEDING) { + return 1 + } + + return 0 +} + +export { + compareAnswerOrder, + compareItemOrder, + getAnswerKeyShortcuts, + getShortcutFromKey, + getShortcutKeys, + hasInputValue, + isAnswerFilled, + isEmptyNavigableInput, + isRadioTarget, + isTextEntryTarget, +} diff --git a/packages/react/tsup.config.ts b/packages/react/tsup.config.ts index 1f2095f5b4e..412620dfdbf 100644 --- a/packages/react/tsup.config.ts +++ b/packages/react/tsup.config.ts @@ -1,13 +1,17 @@ import { readFileSync, writeFileSync } from "fs" import { defineConfig } from "tsup" -const CLIENT_UI_ENTRIES = ["dist/message-scroller/index.js"] +const CLIENT_UI_ENTRIES = [ + "dist/message-scroller/index.js", + "dist/questionnaire/index.js", +] export default defineConfig((options) => ({ clean: !options.watch, dts: true, entry: { "message-scroller/index": "src/message-scroller/index.ts", + "questionnaire/index": "src/questionnaire/index.ts", }, format: ["esm"], sourcemap: false, diff --git a/packages/shadcn/CHANGELOG.md b/packages/shadcn/CHANGELOG.md index 090cc314b72..e0d2a63675f 100644 --- a/packages/shadcn/CHANGELOG.md +++ b/packages/shadcn/CHANGELOG.md @@ -1,5 +1,11 @@ # shadcn +## 4.16.2 + +### Patch Changes + +- [#11348](https://github.com/shadcn-ui/ui/pull/11348) [`df664e1bba86c6712bc5e08c8626590dca736089`](https://github.com/shadcn-ui/ui/commit/df664e1bba86c6712bc5e08c8626590dca736089) Thanks [@OwenKephart](https://github.com/OwenKephart)! - Include registry item titles in `searchRegistries` results and fuzzy matching. + ## 4.16.1 ### Patch Changes diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json index 48d3792a8f7..8148b8e2885 100644 --- a/packages/shadcn/package.json +++ b/packages/shadcn/package.json @@ -1,6 +1,6 @@ { "name": "shadcn", - "version": "4.16.1", + "version": "4.16.2", "description": "Add components to your apps.", "publishConfig": { "access": "public" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db0bb755de5..9c887a3dca7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,7 +328,7 @@ importers: specifier: ^0.0.1 version: 0.0.1 shadcn: - specifier: 4.16.1 + specifier: 4.16.2 version: link:../../packages/shadcn shiki: specifier: ^3.23.0 @@ -476,6 +476,9 @@ importers: vite-tsconfig-paths: specifier: ^4.3.2 version: 4.3.2(typescript@5.9.2)(vite@7.3.5(@types/node@20.19.10)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.1)) + vitest: + specifier: ^3.2.6 + version: 3.2.6(@types/debug@4.1.12)(@types/node@20.19.10)(@vitest/browser@3.2.6)(jiti@2.7.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@20.19.10)(typescript@5.9.2))(tsx@4.20.3)(yaml@2.8.1) packages/shadcn: dependencies: