Questionnaire

A form shown one step at a time, with Back and Next, per-step checks and skippable steps.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add questionnaire
pnpm dlx ermineui add questionnaire
yarn dlx ermineui add questionnaire
bunx ermineui add questionnaire

That copies 4 files into src/components/ui/questionnaire/, 1 helper into src/lib/ermine/, and the 3 components it is built from. Files you already have are kept.

Use

import { Questionnaire, QuestionnaireStep } from '@/components/ui/questionnaire'
<Questionnaire v-model:step="step" submit-label="Send" @submit="send">
  <template #default="{ goTo }">
    <QuestionnaireStep title="About you" :validate="() => form.validate(['name', 'email'])">
      <!-- any fields -->
    </QuestionnaireStep>
    <QuestionnaireStep title="About your team" :skip="answers.kind !== 'team'">
      <!-- only shown to teams -->
    </QuestionnaireStep>
    <QuestionnaireStep title="Check your answers">
      <Button variant="link" @click="goTo(1)">Edit</Button>
    </QuestionnaireStep>
  </template>
</Questionnaire>

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
Questionnaire.vuesrc/components/ui/questionnaire/Questionnaire.vue
QuestionnaireStep.vuesrc/components/ui/questionnaire/QuestionnaireStep.vue
context.tssrc/components/ui/questionnaire/context.ts
index.tssrc/components/ui/questionnaire/index.ts
context.ts helpersrc/lib/ermine/context.ts

It is built from: Button, Progress, Stepper. These are added with it.

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

Questionnaire.vue 153 lines
<script setup lang="ts">
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import { Button } from '../button'
import { Progress } from '../progress'
import { Stepper } from '../stepper'
import { provideQuestionnaire, type QuestionnaireStepEntry } from './context'

const props = withDefaults(
  defineProps<{
    /** How progress is shown: a bar with "Step 2 of 4", the named steps, or nothing. */
    indicator?: 'progress' | 'stepper' | 'none'
    backLabel?: string
    nextLabel?: string
    /** Text of the button on the last step. */
    submitLabel?: string
    /** What the questionnaire is for, for screen readers. */
    label?: string
  }>(),
  { indicator: 'progress', backLabel: 'Back', nextLabel: 'Next', submitLabel: 'Submit' },
)

const emit = defineEmits<{
  /** Next was pressed on the last step, and that step's check passed. */
  submit: []
}>()

/** The step on screen, starting at 1 and counting only steps that are not skipped. */
const step = defineModel<number>('step', { default: 1 })

const root = ref<HTMLElement | null>(null)
const registered = shallowRef<QuestionnaireStepEntry[]>([])
const busy = ref(false)

const steps = computed(() => registered.value.filter((entry) => !entry.skip.value))
const index = computed(() => Math.max(0, Math.min(step.value - 1, steps.value.length - 1)))
const current = computed(() => steps.value[index.value])
const isLast = computed(() => index.value === steps.value.length - 1)

provideQuestionnaire({
  register: (entry) => {
    registered.value = [...registered.value, entry]
    return () => {
      registered.value = registered.value.filter((other) => other !== entry)
    }
  },
  currentId: computed(() => current.value?.id),
})

// If steps drop out (an answer changed and some are now skipped), stay within the ones that are left.
watch(
  () => steps.value.length,
  (count) => {
    if (count && step.value > count) step.value = count
  },
)

async function goTo(next: number) {
  step.value = Math.max(1, Math.min(next, steps.value.length))
  await nextTick()
  root.value?.querySelector<HTMLElement>(`[data-step="${current.value?.id}"] .er-questionnaire-title`)?.focus()
}

async function next() {
  if (busy.value || !current.value) return
  busy.value = true
  try {
    if ((await current.value.validate()) === false) return
    if (isLast.value) emit('submit')
    else await goTo(step.value + 1)
  } finally {
    busy.value = false
  }
}
</script>

<template>
  <!-- A real form, so Enter in a text field moves on just as pressing Next does. -->
  <form ref="root" class="er-questionnaire" novalidate :aria-label="label" @submit.prevent="next">
    <div v-if="indicator === 'progress' && steps.length" class="er-questionnaire-progress">
      <p>Step {{ index + 1 }} of {{ steps.length }}</p>
      <Progress :value="index + 1" :max="steps.length" aria-label="Steps completed" />
    </div>
    <Stepper
      v-else-if="indicator === 'stepper' && steps.length"
      :model-value="index + 1"
      :steps="steps.map((entry) => entry.title.value)"
      @update:model-value="goTo"
    />

    <slot :step="index + 1" :count="steps.length" :go-to="goTo" />

    <div class="er-questionnaire-footer">
      <Button v-if="index > 0" variant="outline" :disabled="busy" @click="goTo(step - 1)">{{ backLabel }}</Button>
      <Button type="submit" :disabled="busy">{{ isLast ? submitLabel : nextLabel }}</Button>
    </div>
  </form>
</template>

<style>
@layer components {
  .er-questionnaire {
    display: grid;
    gap: 1.5rem;
    width: 100%;
    min-width: 0;
  }

  .er-questionnaire-progress {
    display: grid;
    gap: 0.5rem;
  }

  .er-questionnaire-progress p {
    margin: 0;
    color: var(--er-muted-foreground);
    font-size: 0.8125rem;
    font-variant-numeric: tabular-nums;
  }

  .er-questionnaire-step {
    display: grid;
    gap: 0.5rem;
    min-width: 0;
  }

  .er-questionnaire-title {
    margin: 0;
    font-size: 1.125rem;
    font-weight: var(--er-weight-semibold);
    line-height: 1.3;
    outline: none;
  }

  .er-questionnaire-description {
    margin: 0;
    color: var(--er-muted-foreground);
    font-size: 0.875rem;
  }

  .er-questionnaire-fields {
    display: grid;
    gap: 1rem;
    margin-top: 0.75rem;
  }

  .er-questionnaire-footer {
    display: flex;
    justify-content: flex-end;
    gap: 0.5rem;
  }
}
</style>
QuestionnaireStep.vue 37 lines
<script setup lang="ts">
import { computed, onBeforeUnmount, toRef, useId } from 'vue'
import { useQuestionnaire } from './context'

const props = defineProps<{
  /** The question, or the name of this group of questions. */
  title: string
  description?: string
  /** Leave this step out, e.g. when an earlier answer makes it irrelevant. */
  skip?: boolean
  /** Checked when the reader presses Next. Return false to keep them here — show the reasons yourself. */
  validate?: () => boolean | void | Promise<boolean | void>
}>()

const questionnaire = useQuestionnaire()
const id = useId()
const current = computed(() => questionnaire.currentId.value === id)

const unregister = questionnaire.register({
  id,
  title: toRef(props, 'title'),
  skip: toRef(props, 'skip'),
  validate: () => props.validate?.(),
})
onBeforeUnmount(unregister)
</script>

<template>
  <!-- Kept in the page while hidden, so anything typed is still there when the reader comes back. -->
  <section v-show="current" class="er-questionnaire-step" :aria-labelledby="`${id}-title`" :data-step="id">
    <!-- Focus lands here when the step appears, so screen readers announce the new question. -->
    <h3 :id="`${id}-title`" class="er-questionnaire-title" tabindex="-1">{{ title }}</h3>
    <p v-if="description" class="er-questionnaire-description">{{ description }}</p>
    <div class="er-questionnaire-fields"><slot /></div>
  </section>
</template>
context.ts 21 lines
import type { Ref } from 'vue'
import { createContext } from '../../lib/context'

export interface QuestionnaireStepEntry {
  id: string
  title: Ref<string>
  /** Left out of the sequence while true — for questions that depend on an earlier answer. */
  skip: Ref<boolean>
  /** Run before moving on. Return false (or a promise of false) to stay on the step. */
  validate: () => boolean | void | Promise<boolean | void>
}

export interface QuestionnaireContext {
  /** Adds a step in the order the steps are written; returns the function that removes it. */
  register: (step: QuestionnaireStepEntry) => () => void
  /** id of the step on screen. */
  currentId: Ref<string | undefined>
}

export const [provideQuestionnaire, useQuestionnaire] = createContext<QuestionnaireContext>('Questionnaire')
index.ts 4 lines
/** Questionnaire — a form shown one step at a time, with Back and Next, per-step checks and skippable steps. */
export { default as Questionnaire } from './Questionnaire.vue'
export { default as QuestionnaireStep } from './QuestionnaireStep.vue'