
The project brief questionnaire inside a Dialog opened by a Start your brief button: progress, letter shortcuts and a conditional step, with the answer summary and a Done button in the same dialog.
blocks/questionnaire/questionnaire-dialog-01/index.tsx
"use client"
import { Fragment, useId, useState, type FormEvent } from "react"
import { cn } from "cn"
import {
Section,
SectionContent,
} from "@/components/editorial/section"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Progress } from "@/components/ui/progress"
import {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
} from "@/components/ui/questionnaire"
// The copy: change text here, not in the markup.
const COPY = {
open: "Start your brief",
title: "Build your project brief",
description: "A few short questions about what you're making.",
question: "Question",
previous: "Back",
skip: "Skip",
next: "Next",
submit: "Finish brief",
nameError: "Use at least three characters for your project name.",
complete: "Your brief is ready",
completeDescription: "Review your answers below, or go back to make changes.",
edit: "Edit answers",
done: "Done",
skipped: "Skipped",
}
type QuestionName =
"project" | "people" | "team-size" | "features" | "name" | "timing"
type Answers = Partial<Record<QuestionName, string[]>>
const QUESTIONS: readonly {
name: QuestionName
label: string
title: string
description: string
required: boolean
multiple?: boolean
choices: readonly { value: string; label: string; description?: string }[]
input?: { label: string; placeholder: string }
}[] = [
{
name: "project",
label: "Project",
title: "What are you making?",
description: "Choose a starting point, or describe something else.",
required: true,
choices: [
{
value: "website",
label: "A website",
description: "A home for your work, business or ideas.",
},
{
value: "store",
label: "An online store",
description: "A place to browse products and buy them.",
},
{
value: "app",
label: "An application",
description: "A tool people use to get things done.",
},
],
input: { label: "Another kind of project", placeholder: "Something else…" },
},
{
name: "people",
label: "People",
title: "Who is working on it?",
description: "Team projects get one more question about collaborators.",
required: true,
choices: [
{ value: "solo", label: "Just me" },
{ value: "team", label: "A team" },
],
},
{
name: "team-size",
label: "Team size",
title: "How many people are on your team?",
description: "Include everyone who will work on the project.",
required: true,
choices: [
{ value: "2-5", label: "2–5 people" },
{ value: "6-20", label: "6–20 people" },
{ value: "21+", label: "More than 20 people" },
],
},
{
name: "features",
label: "Priorities",
title: "What does your project need?",
description: "Choose as many as you like, or skip this for now.",
required: false,
multiple: true,
choices: [
{ value: "content", label: "Publishing and content" },
{ value: "payments", label: "Payments and checkout" },
{ value: "accounts", label: "Member accounts" },
{ value: "analytics", label: "Analytics and reporting" },
],
},
{
name: "name",
label: "Project name",
title: "What is your project called?",
description: "A working title is fine. Use at least three characters.",
required: true,
choices: [],
input: { label: "Project name", placeholder: "Give your project a name…" },
},
{
name: "timing",
label: "Timing",
title: "When would you like to get started?",
description: "Choose the timing that fits your plans.",
required: true,
choices: [
{ value: "now", label: "This week" },
{ value: "month", label: "This month" },
{ value: "exploring", label: "I’m still exploring" },
],
},
]
// Each question rises in as it becomes the active one.
const ENTER =
"data-active:animate-in data-active:fade-in-0 data-active:slide-in-from-bottom-2 data-active:duration-300 motion-reduce:data-active:animate-none"
export default function QuestionnaireDialog01() {
const id = useId()
const [item, setItem] = useState("project")
const [people, setPeople] = useState("")
const [nameError, setNameError] = useState("")
const [answers, setAnswers] = useState<Answers | null>(null)
// The team-size question only applies to a team.
const questions = QUESTIONS.map((question) => ({
...question,
disabled: question.name === "team-size" && people !== "team",
}))
const asked = questions.filter((question) => !question.disabled)
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const data = new FormData(event.currentTarget)
if (String(data.get("name") ?? "").trim().length < 3) {
setNameError(COPY.nameError)
setItem("name")
return
}
setNameError("")
setAnswers(
Object.fromEntries(
asked.map((question) => [
question.name,
data.getAll(question.name).map(String),
])
)
)
}
return (
<Section>
<SectionContent className="items-center">
<Dialog>
<DialogTrigger render={<Button size="lg" />}>
{COPY.open}
</DialogTrigger>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
<DialogHeader>
<DialogTitle>{answers ? COPY.complete : COPY.title}</DialogTitle>
<DialogDescription>
{answers ? COPY.completeDescription : COPY.description}
</DialogDescription>
</DialogHeader>
<Questionnaire
aria-label={COPY.title}
className={cn(answers && "hidden")}
item={item}
items={questions}
shortcuts="letters"
onItemChange={setItem}
onSubmit={handleSubmit}
>
<QuestionnaireProgress
render={(props, state) => (
<div {...props} className="grid gap-2">
<span className="text-xs text-muted-foreground tabular-nums">
{`${COPY.question} ${state.current} / ${state.total}`}
</span>
<Progress
aria-hidden="true"
value={
state.total ? (state.current / state.total) * 100 : 0
}
/>
</div>
)}
/>
{questions.map((question) => (
<QuestionnaireItem
key={question.name}
aria-labelledby={`${id}-${question.name}`}
aria-describedby={`${id}-${question.name}-description`}
className={ENTER}
disabled={question.disabled}
invalid={question.name === "name" && Boolean(nameError)}
multiple={question.multiple}
name={question.name}
required={question.required}
>
<QuestionnaireTitle id={`${id}-${question.name}`}>
<h3>{question.title}</h3>
</QuestionnaireTitle>
<QuestionnaireDescription
id={`${id}-${question.name}-description`}
>
{question.description}
</QuestionnaireDescription>
<QuestionnaireChoices>
{question.choices.map((choice) => (
<QuestionnaireChoice
key={choice.value}
value={choice.value}
onChange={
question.name === "people"
? () => setPeople(choice.value)
: undefined
}
>
<span>{choice.label}</span>
{choice.description && (
<QuestionnaireChoiceDescription>
{choice.description}
</QuestionnaireChoiceDescription>
)}
</QuestionnaireChoice>
))}
{question.input && (
<QuestionnaireInput
aria-label={question.input.label}
placeholder={question.input.placeholder}
onChange={
question.name === "name"
? () => setNameError("")
: undefined
}
/>
)}
</QuestionnaireChoices>
<QuestionnaireError>
{question.name === "name"
? nameError || undefined
: undefined}
</QuestionnaireError>
</QuestionnaireItem>
))}
<QuestionnaireActions>
<QuestionnairePrevious>{COPY.previous}</QuestionnairePrevious>
<QuestionnaireSkip>{COPY.skip}</QuestionnaireSkip>
<QuestionnaireNext>{COPY.next}</QuestionnaireNext>
<QuestionnaireSubmit>{COPY.submit}</QuestionnaireSubmit>
</QuestionnaireActions>
</Questionnaire>
{answers && (
<>
<dl className="grid gap-x-6 gap-y-3 text-sm sm:grid-cols-[auto_1fr]">
{asked.map((question) => (
<Fragment key={question.name}>
<dt className="text-muted-foreground">
{question.label}
</dt>
<dd>
{answers[question.name]
?.map(
(value) =>
question.choices.find(
(choice) => choice.value === value
)?.label ?? value
)
.join(", ") || COPY.skipped}
</dd>
</Fragment>
))}
</dl>
<DialogFooter>
<Button variant="outline" onClick={() => setAnswers(null)}>
{COPY.edit}
</Button>
<DialogClose render={<Button />}>{COPY.done}</DialogClose>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</SectionContent>
</Section>
)
}