UI Elements

Project Brief Questionnaire Dialog

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.

Free, MIT licenseOpen in Browse
"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>
  )
}

Installation

pnpm dlx shadcn add @01orbital/questionnaire-dialog-01
npx shadcn add @01orbital/questionnaire-dialog-01
yarn shadcn add @01orbital/questionnaire-dialog-01
bunx --bun shadcn add @01orbital/questionnaire-dialog-01

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/blocks/questionnaire/questionnaire-dialog-01/index.tsx, as source you own and edit.

Usage

import QuestionnaireDialog01 from "@/components/blocks/questionnaire/questionnaire-dialog-01"

export default function Example() {
  return <QuestionnaireDialog01 />
}

Dependencies

npm: cn. The CLI installs them for you.

Registry pieces it installs with it:

  • button
  • dialog
  • progress
  • questionnaire
  • section