Questionnaire
Multi-step questions with single, multiple, freeform and skippable answers and keyboard shortcuts.
import { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle,} from "@/components/ui/questionnaire";import { useState } from "react";
const items = [ { name: "rebalance", choices: [{ value: "drift" }, { value: "schedule" }, { value: "sign" }], }, { name: "notes" },];
export default function Demo() { const [done, setDone] = useState<string | null>(null); return ( <Questionnaire items={items} shortcuts="letters" className="w-full max-w-md rounded-2xl border border-border bg-card p-6" onSubmit={(e) => { e.preventDefault(); const data = new FormData(e.currentTarget); setDone(JSON.stringify(Object.fromEntries(data))); }} > <QuestionnaireProgress /> <QuestionnaireItem name="rebalance" required> <QuestionnaireTitle>How should the index rebalance?</QuestionnaireTitle> <QuestionnaireDescription> You can change this later in the mandate. </QuestionnaireDescription> <QuestionnaireChoices> <QuestionnaireChoice value="drift"> When any weight drifts past a limit </QuestionnaireChoice> <QuestionnaireChoice value="schedule"> On a fixed schedule </QuestionnaireChoice> <QuestionnaireChoice value="sign"> Only when I sign </QuestionnaireChoice> </QuestionnaireChoices> <QuestionnaireInput placeholder="Other…" /> </QuestionnaireItem> <QuestionnaireItem name="notes"> <QuestionnaireTitle>Any notes for the desk?</QuestionnaireTitle> <QuestionnaireInput placeholder="Add a note…" /> </QuestionnaireItem> <QuestionnaireActions> <QuestionnairePrevious /> <QuestionnaireSkip /> <QuestionnaireNext /> <QuestionnaireSubmit /> </QuestionnaireActions> {done ? ( <p className="font-mono text-xs text-muted-foreground">{done}</p> ) : null} </Questionnaire> );}<script setup lang="ts">import { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle,} from "@/components/ui/questionnaire";import { ref } from "vue";
const items = [ { name: "rebalance", choices: [{ value: "drift" }, { value: "schedule" }, { value: "sign" }] }, { name: "notes" },];const done = ref<string | null>(null);
function onSubmit(event: Event) { event.preventDefault(); const data = new FormData(event.currentTarget as HTMLFormElement); done.value = JSON.stringify(Object.fromEntries(data));}</script>
<template> <Questionnaire :items="items" shortcuts="letters" class="w-full max-w-md rounded-2xl border border-border bg-card p-6" @submit="onSubmit" > <QuestionnaireProgress /> <QuestionnaireItem name="rebalance" required> <QuestionnaireTitle>How should the index rebalance?</QuestionnaireTitle> <QuestionnaireDescription>You can change this later in the mandate.</QuestionnaireDescription> <QuestionnaireChoices> <QuestionnaireChoice value="drift">When any weight drifts past a limit</QuestionnaireChoice> <QuestionnaireChoice value="schedule">On a fixed schedule</QuestionnaireChoice> <QuestionnaireChoice value="sign">Only when I sign</QuestionnaireChoice> </QuestionnaireChoices> <QuestionnaireInput placeholder="Other…" /> </QuestionnaireItem> <QuestionnaireItem name="notes"> <QuestionnaireTitle>Any notes for the desk?</QuestionnaireTitle> <QuestionnaireInput placeholder="Add a note…" /> </QuestionnaireItem> <QuestionnaireActions> <QuestionnairePrevious /> <QuestionnaireSkip /> <QuestionnaireNext /> <QuestionnaireSubmit /> </QuestionnaireActions> <p v-if="done" class="font-mono text-xs text-muted-foreground">{{ done }}</p> </Questionnaire></template><script lang="ts"> import * as Questionnaire from "$lib/components/ui/questionnaire";
const items = [ { name: "rebalance", choices: [{ value: "drift" }, { value: "schedule" }, { value: "sign" }] }, { name: "notes" }, ]; let done = $state<string | null>(null);
function onsubmit(event: SubmitEvent) { event.preventDefault(); const data = new FormData(event.currentTarget as HTMLFormElement); done = JSON.stringify(Object.fromEntries(data)); }</script>
<Questionnaire.Root {items} shortcuts="letters" class="w-full max-w-md rounded-2xl border border-border bg-card p-6" {onsubmit}> <Questionnaire.Progress /> <Questionnaire.Item name="rebalance" required> <Questionnaire.Title>How should the index rebalance?</Questionnaire.Title> <Questionnaire.Description>You can change this later in the mandate.</Questionnaire.Description> <Questionnaire.Choices> <Questionnaire.Choice value="drift">When any weight drifts past a limit</Questionnaire.Choice> <Questionnaire.Choice value="schedule">On a fixed schedule</Questionnaire.Choice> <Questionnaire.Choice value="sign">Only when I sign</Questionnaire.Choice> </Questionnaire.Choices> <Questionnaire.Input placeholder="Other…" /> </Questionnaire.Item> <Questionnaire.Item name="notes"> <Questionnaire.Title>Any notes for the desk?</Questionnaire.Title> <Questionnaire.Input placeholder="Add a note…" /> </Questionnaire.Item> <Questionnaire.Actions> <Questionnaire.Previous /> <Questionnaire.Skip /> <Questionnaire.Next /> <Questionnaire.Submit /> </Questionnaire.Actions> {#if done} <p class="font-mono text-xs text-muted-foreground">{done}</p> {/if}</Questionnaire.Root>Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/questionnairepnpm dlx shadcn@latest add @edmi-ui/questionnaireyarn dlx shadcn@latest add @edmi-ui/questionnairebunx shadcn@latest add @edmi-ui/questionnairePick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @shadcn/reactTerminal window pnpm add @shadcn/reactTerminal window yarn add @shadcn/reactTerminal window bun add @shadcn/reactAdd the Edmi components this one builds on:
button,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/questionnairepnpm dlx shadcn-vue@latest add @edmi-ui/questionnaireyarn dlx shadcn-vue@latest add @edmi-ui/questionnairebunx shadcn-vue@latest add @edmi-ui/questionnairePick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install reka-uiTerminal window pnpm add reka-uiTerminal window yarn add reka-uiTerminal window bun add reka-uiAdd the Edmi components this one builds on:
button,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/questionnaire.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/questionnaire.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/questionnaire.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/questionnaire.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
import { Questionnaire, QuestionnaireChoice, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireInput, QuestionnaireItem, QuestionnaireProgress, QuestionnaireTitle,} from "@/components/ui/questionnaire";
<Questionnaire shortcuts="letters" className="w-full max-w-md rounded-2xl border border-border bg-card p-6"> <QuestionnaireProgress /> <QuestionnaireItem name="rebalance" required> <QuestionnaireTitle>How should the index rebalance?</QuestionnaireTitle> <QuestionnaireDescription> You can change this later in the mandate. </QuestionnaireDescription> <QuestionnaireChoices> <QuestionnaireChoice value="drift"> When any weight drifts past a limit </QuestionnaireChoice> <QuestionnaireChoice value="schedule"> On a fixed schedule </QuestionnaireChoice> <QuestionnaireChoice value="sign"> Only when I sign </QuestionnaireChoice> </QuestionnaireChoices> <QuestionnaireInput placeholder="Other…" /> </QuestionnaireItem> {/* … */}</Questionnaire><script setup lang="ts">import { Questionnaire, QuestionnaireChoice, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireInput, QuestionnaireItem, QuestionnaireProgress, QuestionnaireTitle,} from "@/components/ui/questionnaire";</script>
<template> <Questionnaire shortcuts="letters" class="w-full max-w-md rounded-2xl border border-border bg-card p-6" @submit="onSubmit" > <QuestionnaireProgress /> <QuestionnaireItem name="rebalance" required> <QuestionnaireTitle>How should the index rebalance?</QuestionnaireTitle> <QuestionnaireDescription>You can change this later in the mandate.</QuestionnaireDescription> <QuestionnaireChoices> <QuestionnaireChoice value="drift">When any weight drifts past a limit</QuestionnaireChoice> <QuestionnaireChoice value="schedule">On a fixed schedule</QuestionnaireChoice> <QuestionnaireChoice value="sign">Only when I sign</QuestionnaireChoice> </QuestionnaireChoices> <QuestionnaireInput placeholder="Other…" /> </QuestionnaireItem> <QuestionnaireItem name="notes"> <QuestionnaireTitle>Any notes for the desk?</QuestionnaireTitle> <QuestionnaireInput placeholder="Add a note…" /> </QuestionnaireItem> <!-- … --> </Questionnaire></template><script lang="ts"> import * as Questionnaire from "$lib/components/ui/questionnaire";</script>
<Questionnaire.Root shortcuts="letters" class="w-full max-w-md rounded-2xl border border-border bg-card p-6" {onsubmit}> <Questionnaire.Progress /> <Questionnaire.Item name="rebalance" required> <Questionnaire.Title>How should the index rebalance?</Questionnaire.Title> <Questionnaire.Description>You can change this later in the mandate.</Questionnaire.Description> <Questionnaire.Choices> <Questionnaire.Choice value="drift">When any weight drifts past a limit</Questionnaire.Choice> <Questionnaire.Choice value="schedule">On a fixed schedule</Questionnaire.Choice> <Questionnaire.Choice value="sign">Only when I sign</Questionnaire.Choice> </Questionnaire.Choices> <Questionnaire.Input placeholder="Other…" /> </Questionnaire.Item> <Questionnaire.Item name="notes"> <Questionnaire.Title>Any notes for the desk?</Questionnaire.Title> <Questionnaire.Input placeholder="Add a note…" /> </Questionnaire.Item> <!-- … --></Questionnaire.Root>Component source
Section titled “Component source”"use client";
import { Questionnaire as QuestionnairePrimitive } from "@shadcn/react/questionnaire";import { cn } from "cn";import * as React from "react";import { CheckIcon } from "@phosphor-icons/react";
import { type Button, buttonVariants } from "@/registry/edmi/ui/button";import { type Elevation, useElevation } from "@/registry/edmi/ui/elevation";
// ✦ choice-card depth (v4); a checked option keeps its ring.const choiceElevation = { sunken: "border-sk-bd bg-sk-bg shadow-sunken", flat: "", raised: "border-transparent shadow-raised", floating: "border-transparent shadow-floating",};const inputElevation = { sunken: "border-sk-bd bg-sk-bg shadow-sunken", flat: "", raised: "border-transparent shadow-raised", floating: "border-transparent shadow-floating",};
// ✦ `elevation` on the root flows to every choice / input.const QuestionnaireContext = React.createContext<{ elevation: Elevation | undefined;}>({ elevation: undefined });
function Questionnaire({ className, elevation, ...props}: React.ComponentProps<typeof QuestionnairePrimitive.Root> & { /** ✦ depth of every option and the text input: sunken -1, flat 0, raised +1, floating +2. */ elevation?: Elevation;}) { return ( <QuestionnaireContext.Provider value={{ elevation }}> <QuestionnairePrimitive.Root data-slot="questionnaire" className={cn("flex w-full min-w-0 flex-col gap-4", className)} {...props} /> </QuestionnaireContext.Provider> );}
function QuestionnaireProgress({ className, ...props}: React.ComponentProps<typeof QuestionnairePrimitive.Progress>) { return ( <QuestionnairePrimitive.Progress data-slot="questionnaire-progress" className={cn( "min-h-[1lh] w-fit min-w-[14ch] font-mono text-[11.5px] text-muted-foreground tabular-nums", className, )} {...props} /> );}
function QuestionnaireItem({ className, ...props}: React.ComponentProps<typeof QuestionnairePrimitive.Item>) { return ( <QuestionnairePrimitive.Item data-slot="questionnaire-item" className={cn( "flex min-w-0 flex-col gap-4 border-0 p-0 outline-none [&[hidden]]:hidden", className, )} {...props} /> );}
function QuestionnaireTitle({ className, ...props}: React.ComponentProps<typeof QuestionnairePrimitive.Title>) { return ( <QuestionnairePrimitive.Title data-slot="questionnaire-title" className={cn( "text-[17px] leading-snug font-semibold tracking-[-0.2px] text-pretty [&:not(:has(~[data-slot=questionnaire-description]))]:mb-4", className, )} {...props} /> );}
function QuestionnaireDescription({ className, ...props}: React.ComponentProps<typeof QuestionnairePrimitive.Description>) { return ( <QuestionnairePrimitive.Description data-slot="questionnaire-description" className={cn("text-sm text-pretty text-muted-foreground", className)} {...props} /> );}
function QuestionnaireChoices({ className, ...props}: React.ComponentProps<typeof QuestionnairePrimitive.Choices>) { return ( <QuestionnairePrimitive.Choices data-slot="questionnaire-choices" className={cn( "group/questionnaire-choices grid min-w-0 gap-2", className, )} {...props} /> );}
function QuestionnaireChoice({ children, className, elevation, ...props}: React.ComponentProps<typeof QuestionnairePrimitive.Choice> & { /** ✦ depth of this option; defaults to the Questionnaire level. */ elevation?: Elevation;}) { const context = React.useContext(QuestionnaireContext); const level = useElevation( elevation && elevation !== "auto" ? elevation : context.elevation, "control", ); return ( <QuestionnairePrimitive.Choice data-slot="questionnaire-choice" className={cn( "group/questionnaire-choice relative flex min-h-12 cursor-pointer items-center gap-3 rounded-xl border border-border bg-card px-3.5 py-2 text-start text-sm transition-colors outline-none select-none hover:bg-accent has-[>input:focus-visible]:outline-2 has-[>input:focus-visible]:outline-offset-2 has-[>input:focus-visible]:outline-ring data-[invalid]:border-destructive data-[checked]:border-ring data-[checked]:bg-[color-mix(in_srgb,var(--brand)_5%,var(--card))] data-[checked]:shadow-[0_0_0_1px_var(--ring)]", choiceElevation[level], "data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50", className, )} {...props} > <QuestionnairePrimitive.ChoiceInput data-slot="questionnaire-choice-input" className="absolute inset-0 z-10 size-full cursor-pointer opacity-0" /> <QuestionnairePrimitive.ChoiceShortcut data-slot="questionnaire-choice-shortcut" className="pointer-events-none hidden size-5 shrink-0 items-center justify-center rounded-md border border-input bg-card font-mono text-[10.5px] leading-none font-medium text-muted-foreground group-data-[shortcut]/questionnaire-choice:inline-flex group-data-[checked]/questionnaire-choice:border-brand-edge group-data-[checked]/questionnaire-choice:bg-brand group-data-[checked]/questionnaire-choice:text-brand-foreground" /> <QuestionnairePrimitive.ChoiceLabel data-slot="questionnaire-choice-label" className="flex min-w-0 flex-1 flex-col gap-0.5 text-sm leading-snug" > {children} </QuestionnairePrimitive.ChoiceLabel> <span aria-hidden="true" data-slot="questionnaire-choice-indicator" className="pointer-events-none relative flex size-4 shrink-0 items-center justify-center text-foreground group-data-[type=checkbox]/questionnaire-choice:size-[18px] group-data-[type=checkbox]/questionnaire-choice:rounded-[5px] group-data-[type=checkbox]/questionnaire-choice:border group-data-[type=checkbox]/questionnaire-choice:border-input group-data-[type=checkbox]/questionnaire-choice:bg-card group-data-[type=checkbox]/questionnaire-choice:group-data-[checked]/questionnaire-choice:border-primary 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" > <CheckIcon data-slot="questionnaire-choice-indicator-check" className="hidden size-3.5 group-data-[checked]/questionnaire-choice:block" /> </span> </QuestionnairePrimitive.Choice> );}
function QuestionnaireChoiceDescription({ className, ...props}: React.ComponentProps<"span">) { return ( <span data-slot="questionnaire-choice-description" className={cn("text-muted-foreground", className)} {...props} /> );}
function QuestionnaireInput({ className, elevation, ...props}: React.ComponentProps<typeof QuestionnairePrimitive.Input> & { /** ✦ depth of the text input; defaults to the Questionnaire level. */ elevation?: Elevation;}) { const context = React.useContext(QuestionnaireContext); const level = useElevation( elevation && elevation !== "auto" ? elevation : context.elevation, "field", ); return ( <div data-slot="questionnaire-input-wrapper" className="group/questionnaire-input relative w-full min-w-0" > <QuestionnairePrimitive.Input data-slot="questionnaire-input" className={cn( "h-12 w-full min-w-0 rounded-xl border border-border bg-card px-3.5 text-sm transition-colors outline-none focus-visible:border-ring focus-visible:shadow-[0_0_0_1px_var(--ring)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive", "placeholder:text-muted-foreground", inputElevation[level], className, )} {...props} /> </div> );}
function QuestionnaireError({ className, ...props}: React.ComponentProps<typeof QuestionnairePrimitive.Error>) { return ( <QuestionnairePrimitive.Error data-slot="questionnaire-error" className={cn("text-sm text-destructive-text", className)} {...props} /> );}
function QuestionnaireActions({ className, ...props}: React.ComponentProps<"div">) { return ( <div data-slot="questionnaire-actions" className={cn( "grid min-h-9 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2", className, )} {...props} /> );}
function QuestionnairePrevious({ children, className, size = "default", variant = "ghost", ...props}: React.ComponentProps<typeof QuestionnairePrimitive.Previous> & Pick<React.ComponentProps<typeof Button>, "size" | "variant">) { return ( <QuestionnairePrimitive.Previous data-slot="questionnaire-previous" data-size={size} data-variant={variant} className={cn( buttonVariants({ size, variant }), "col-start-1 row-start-1 justify-self-start [&[hidden]]:hidden", className, )} {...props} > {children ?? "Previous"} </QuestionnairePrimitive.Previous> );}
function QuestionnaireSkip({ children, className, size = "default", variant = "outline", ...props}: React.ComponentProps<typeof QuestionnairePrimitive.Skip> & Pick<React.ComponentProps<typeof Button>, "size" | "variant">) { return ( <QuestionnairePrimitive.Skip data-slot="questionnaire-skip" data-size={size} data-variant={variant} className={cn( buttonVariants({ size, variant }), "col-start-2 row-start-1 justify-self-end [&[hidden]]:hidden", className, )} {...props} > {children ?? "Skip"} </QuestionnairePrimitive.Skip> );}
function QuestionnaireNext({ children, className, size = "default", variant = "default", ...props}: React.ComponentProps<typeof QuestionnairePrimitive.Next> & Pick<React.ComponentProps<typeof Button>, "size" | "variant">) { return ( <QuestionnairePrimitive.Next data-slot="questionnaire-next" data-size={size} data-variant={variant} className={cn( buttonVariants({ size, variant }), "col-start-3 row-start-1 justify-self-end [&[hidden]]:hidden", className, )} {...props} > {children ?? "Next"} </QuestionnairePrimitive.Next> );}
function QuestionnaireSubmit({ children, className, size = "default", variant = "default", ...props}: React.ComponentProps<typeof QuestionnairePrimitive.Submit> & Pick<React.ComponentProps<typeof Button>, "size" | "variant">) { return ( <QuestionnairePrimitive.Submit data-slot="questionnaire-submit" data-size={size} data-variant={variant} className={cn( buttonVariants({ size, variant }), "col-start-3 row-start-1 justify-self-end [&[hidden]]:hidden", className, )} {...props} > {children ?? "Submit"} </QuestionnairePrimitive.Submit> );}
export { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle,};<script setup lang="ts">import type { HTMLAttributes } from "vue"import type { ItemRegistration, QuestionnaireItemDefinition, QuestionnaireShortcutMode,} from "./useQuestionnaire"import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"import { cn } from "@/registry/edmi/lib/utils"import type { Elevation } from "@/registry/edmi/ui/elevation"import { compareDocumentOrder, createQuestionnaireCollection, getInitialItemName, getShortcutFromKey, isAnswerFilled, isRadioTarget, isTextEntryTarget, provideQuestionnaireRootContext,} from "./useQuestionnaire"
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"] /** Item shown first. Ignored when `item` is provided. */ defaultItem?: string /** Controlled active item. Use with `v-model:item`. */ item?: string /** Declares the item order, and the choice order shortcuts are assigned in. */ items?: readonly QuestionnaireItemDefinition[] /** Set to `false` to run native constraint validation on answered items. */ noValidate?: boolean /** ✦ depth of every choice and input: sunken -1, flat 0, raised +1, floating +2 */ elevation?: Elevation /** Assigns a keyboard shortcut to every choice. */ shortcuts?: QuestionnaireShortcutMode}>(), { noValidate: true, elevation: undefined,})
const emits = defineEmits<{ "reset": [event: Event] "submit": [event: Event] "update:item": [item: string]}>()
interface PendingFocus { name: string target: "invalid" | "item"}
const rootElement = ref<HTMLFormElement | null>(null)const registrations = shallowRef<ItemRegistration[]>([])const domVersion = ref(0)const pendingFocus = shallowRef<PendingFocus | null>(null)
const collection = computed(() => createQuestionnaireCollection(props.items))const uncontrolledItem = ref<string | null>( getInitialItemName(collection.value, props.defaultItem),)const controlled = computed(() => props.item !== undefined)const activeItemName = computed(() => (controlled.value ? props.item! : uncontrolledItem.value))const shortcuts = computed(() => props.shortcuts ?? null)const nativeValidation = computed(() => props.noValidate === false)
let previousActiveItemName = activeItemName.value
const runtimeItems = computed(() => { // Re-sort whenever items are added to or removed from the DOM. void domVersion.value
return registrations.value .filter(registration => !registration.isDisabled()) .sort((first, second) => compareDocumentOrder(first.element, second.element))})const runtimeItemByName = computed( () => new Map(runtimeItems.value.map(runtimeItem => [runtimeItem.name, runtimeItem])),)/** `items` is authoritative when provided, so items can be declared before they render. */const logicalItems = computed<readonly { name: string }[]>( () => collection.value?.enabledItems ?? runtimeItems.value,)const currentIndex = computed( () => logicalItems.value.findIndex(logicalItem => logicalItem.name === activeItemName.value),)const activeItem = computed(() => { if (currentIndex.value < 0 || !activeItemName.value) { return null }
return runtimeItemByName.value.get(activeItemName.value) ?? null})const activeDefinition = computed(() => activeItemName.value ? collection.value?.itemByName.get(activeItemName.value) : undefined,)const activeItemRequired = computed(() => { if (currentIndex.value < 0) { return null }
return activeDefinition.value ? Boolean(activeDefinition.value.required) : (activeItem.value?.isRequired() ?? false)})const activeItemStatus = computed(() => { if (currentIndex.value < 0) { return null }
return activeItem.value?.status() ?? (activeItemName.value ? "unanswered" : null)})const orderedRegistrations = computed(() => { if (!collection.value) { return runtimeItems.value }
return collection.value.enabledItems.flatMap((definition) => { const registration = runtimeItemByName.value.get(definition.name)
return registration ? [registration] : [] })})const total = computed(() => logicalItems.value.length)const current = computed(() => (currentIndex.value < 0 ? 0 : currentIndex.value + 1))const first = computed(() => total.value > 0 && currentIndex.value === 0)const last = computed(() => total.value > 0 && currentIndex.value === total.value - 1)
function setItem(nextItem: string, focusTarget: PendingFocus["target"] = "item") { if (nextItem === activeItemName.value) { return }
pendingFocus.value = { name: nextItem, target: focusTarget }
if (!controlled.value) { uncontrolledItem.value = nextItem }
emits("update:item", nextItem)}
function registerItem(registration: ItemRegistration) { registrations.value = [ ...registrations.value.filter( current => current.element !== registration.element && current.name !== registration.name, ), registration, ]
return () => { registrations.value = registrations.value.filter(current => current !== registration) }}
function setItemAt(index: number, focusTarget: PendingFocus["target"] = "item") { const nextItem = logicalItems.value[index]
if (nextItem) { setItem(nextItem.name, focusTarget) }}
function goPrevious() { if (currentIndex.value <= 0) { return }
setItemAt(currentIndex.value - 1)}
function goNext() { if (!activeItem.value || currentIndex.value >= total.value - 1) { return }
if (!activeItem.value.validate()) { activeItem.value.focusInvalid() return }
setItemAt(currentIndex.value + 1)}
function confirmCurrent() { if (!activeItem.value) { return }
if (!activeItem.value.validate()) { activeItem.value.focusInvalid() return }
if (last.value) { rootElement.value?.requestSubmit() return }
setItemAt(currentIndex.value + 1)}
function skipCurrent() { if (!activeItem.value || activeItem.value.isRequired()) { return }
activeItem.value.skip()
if (!last.value) { setItemAt(currentIndex.value + 1) return }
queueMicrotask(() => { rootElement.value?.requestSubmit() })}
function handleReset(event: Event) { emits("reset", event)
if (event.defaultPrevented) { return }
for (const registration of registrations.value) { registration.reset() }
const resetItemName = collection.value ? getInitialItemName(collection.value, props.defaultItem) : (runtimeItems.value.find(registration => registration.name === props.defaultItem)?.name ?? runtimeItems.value[0]?.name)
if (resetItemName) { setItem(resetItemName) }}
function handleSubmit(event: Event) { const firstInvalidItem = orderedRegistrations.value.find(registration => !registration.validate())
if (firstInvalidItem) { event.preventDefault() setItem(firstInvalidItem.name, "invalid")
if (firstInvalidItem.name === activeItemName.value) { firstInvalidItem.focusInvalid() pendingFocus.value = null }
return }
emits("submit", event)}
function handleKeydown(event: KeyboardEvent) { if ( event.defaultPrevented || event.isComposing || event.keyCode === 229 || !activeItem.value || !(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.value.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.value.status() !== "unanswered") { goNext() }
return }
if (event.key === "Enter") { const answer = activeItem.value.getAnswerByElement(event.target)
if (!answer) { return }
event.preventDefault()
if (!event.repeat && isAnswerFilled(answer)) { confirmCurrent() }
return }
if (!shortcuts.value || isTextEntryTarget(event.target)) { return }
const shortcut = getShortcutFromKey(event.key, shortcuts.value) const answer = shortcut ? activeItem.value.getAnswerByShortcut(shortcut) : null
if (!answer) { return }
event.preventDefault()
if (event.repeat) { return }
answer.element.focus()
if (answer.type === "choice") { answer.element.click() }}
watch( () => [activeItemName.value, currentIndex.value, total.value, activeItem.value] as const, () => { if (total.value === 0) { return }
if (currentIndex.value < 0) { const firstItem = logicalItems.value[0]
if (!firstItem) { return }
if (!controlled.value && activeItemName.value === null) { uncontrolledItem.value = firstItem.name return }
setItem(firstItem.name) return }
const focus = pendingFocus.value const activeItemChanged = previousActiveItemName !== activeItemName.value
previousActiveItemName = activeItemName.value
if (!focus || focus.name !== activeItemName.value) { if (controlled.value && activeItemChanged) { pendingFocus.value = null activeItem.value?.focus() }
return }
if (focus.target === "invalid") { activeItem.value?.focusInvalid() } else { activeItem.value?.focus() }
pendingFocus.value = null }, { flush: "post", immediate: true },)
let observer: MutationObserver | null = null
onMounted(() => { if (!rootElement.value || typeof MutationObserver === "undefined") { return }
observer = new MutationObserver(() => { domVersion.value += 1 })
observer.observe(rootElement.value, { childList: true, subtree: true })})
onBeforeUnmount(() => { observer?.disconnect() observer = null})
provideQuestionnaireRootContext({ activeItem, activeItemName, activeItemRequired, activeItemStatus, current, domVersion, first, goNext, goPrevious, itemDefinitionByName: computed(() => collection.value?.itemByName ?? null), last, nativeValidation, elevation: computed(() => props.elevation), registerItem, shortcuts, skipCurrent, total,})</script>
<template> <form ref="rootElement" data-slot="questionnaire" :data-shortcuts="shortcuts ?? undefined" :novalidate="props.noValidate" :class="cn('flex w-full min-w-0 flex-col gap-4', props.class)" @keydown="handleKeydown" @reset="handleReset" @submit="handleSubmit" > <slot :current="current" :first="first" :last="last" :total="total" /> </form></template><script setup lang="ts">import type { PrimitiveProps } from "reka-ui"import type { HTMLAttributes } from "vue"import { Primitive } from "reka-ui"import { cn } from "@/registry/edmi/lib/utils"
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"]}>(), { as: "div",})</script>
<template> <Primitive data-slot="questionnaire-actions" :as="props.as" :as-child="props.asChild" :class="cn( 'grid min-h-9 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2', props.class, )" > <slot /> </Primitive></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { CheckIcon } from "@lucide/vue"import { computed, onBeforeUnmount, ref, useId, watch } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { useElevation } from "@/registry/edmi/ui/elevation"import { getAnswerKeyShortcuts, injectQuestionnaireItemContext, injectQuestionnaireRootContext } from "./useQuestionnaire"
const props = withDefaults(defineProps<{ /** Controlled checked state. Use with `v-model:checked`. */ checked?: boolean class?: HTMLAttributes["class"] /** Checks the choice on mount and after a native form reset. */ defaultChecked?: boolean disabled?: boolean /** Submitted as the answer of the parent item. */ value: string}>(), { // `undefined` keeps the choice uncontrolled. Without this default, Vue casts // the absent boolean prop to `false` and every choice looks controlled. checked: undefined, defaultChecked: false, disabled: false,})
const emits = defineEmits<{ "change": [event: Event] "update:checked": [checked: boolean]}>()
// ✦ choice-card depth (v4); a checked option keeps its ring.const choiceElevation = { sunken: "border-sk-bd bg-sk-bg shadow-sunken", flat: "", raised: "border-transparent shadow-raised", floating: "border-transparent shadow-floating",}
const item = injectQuestionnaireItemContext()const root = injectQuestionnaireRootContext()const level = useElevation(() => root.elevation.value, "control")
const answerId = useId()const inputElement = ref<HTMLInputElement | null>(null)const initialDefaultChecked = props.defaultChecked
const controlled = computed(() => props.checked !== undefined)const disabled = computed(() => item.disabled.value || props.disabled)const selected = computed(() => item.selectedAnswerIds.value.includes(answerId))const checked = computed(() => { if (!controlled.value) { return selected.value }
// A skipped item clears every answer, including controlled ones. return item.status.value === "skipped" ? false : props.checked!})const type = computed(() => (item.multiple.value ? "checkbox" : "radio"))const shortcut = computed(() => item.shortcutByChoiceValue.value?.get(props.value) ?? item.shortcutByAnswerId.value.get(answerId) ?? null)
function syncCheckedElement() { if (inputElement.value && inputElement.value.checked !== checked.value) { inputElement.value.checked = checked.value }}
function handleChange(event: Event) { emits("change", event)
if (event.defaultPrevented) { syncCheckedElement() return }
const nextChecked = (event.target as HTMLInputElement).checked
emits("update:checked", nextChecked)
if (!controlled.value) { item.setAnswerSelectionFromInteraction(answerId, nextChecked) return }
// Re-selecting the same controlled choice has to clear the skipped state. if (item.status.value === "skipped" && props.checked === nextChecked) { item.setAnswerSelectionFromInteraction(answerId, props.checked) }
// Checking a radio clears its siblings, so the whole group has to re-sync in // case the host keeps the previous answer. item.requestControlSync()}
const unregisterSelection = item.registerAnswerSelection(answerId, initialDefaultChecked)
let unregisterControl: (() => void) | null = null
watch([inputElement, disabled, () => props.disabled, () => props.value], ([element]) => { unregisterControl?.() unregisterControl = null
if (!element) { return }
unregisterControl = item.registerAnswerControl({ disabled: disabled.value, element, id: answerId, ownDisabled: props.disabled, type: "choice", value: props.value, })}, { flush: "post" })
watch(() => props.defaultChecked, (defaultChecked) => { item.setAnswerDefault(answerId, defaultChecked)})
watch([() => props.checked, item.resetVersion], () => { if (controlled.value) { item.syncControlledAnswerSelection(answerId, props.checked!) }}, { immediate: true })
watch(item.controlSyncVersion, syncCheckedElement, { flush: "post" })
watch([checked, inputElement, () => props.defaultChecked, item.resetVersion], () => { if (!inputElement.value) { return }
// Keep the native reset target aligned with the questionnaire owned default, // including controlled choices whose `checked` prop stays authoritative. inputElement.value.defaultChecked = controlled.value ? props.checked! : props.defaultChecked
syncCheckedElement()}, { flush: "post" })
onBeforeUnmount(() => { unregisterControl?.() unregisterControl = null unregisterSelection()})</script>
<template> <label data-slot="questionnaire-choice" :data-checked="checked ? '' : undefined" :data-disabled="disabled ? '' : undefined" :data-invalid="item.invalid.value ? '' : undefined" :data-shortcut="shortcut ?? undefined" :data-type="type" :data-unchecked="checked ? undefined : ''" :class="cn( 'group/questionnaire-choice relative flex min-h-12 cursor-pointer items-center gap-3 rounded-xl border border-border bg-card px-3.5 py-2 text-start text-sm transition-colors outline-none select-none hover:bg-accent has-[>input:focus-visible]:outline-2 has-[>input:focus-visible]:outline-offset-2 has-[>input:focus-visible]:outline-ring data-[invalid]:border-destructive data-[checked]:border-ring data-[checked]:bg-[color-mix(in_srgb,var(--brand)_5%,var(--card))] data-[checked]:shadow-[0_0_0_1px_var(--ring)]', 'data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50', choiceElevation[level], props.class, )" > <input :id="answerId" ref="inputElement" data-slot="questionnaire-choice-input" class="absolute inset-0 z-10 size-full cursor-pointer opacity-0" :aria-invalid="item.invalid.value || undefined" :aria-keyshortcuts="getAnswerKeyShortcuts(shortcut, !disabled && checked)" :checked="checked" :data-checked="checked ? '' : undefined" :data-unchecked="checked ? undefined : ''" :disabled="disabled" :name="item.status.value === 'skipped' ? undefined : item.name.value" :required="item.required.value && !item.multiple.value && !item.hasInputAnswer.value" :type="type" :value="props.value" @change="handleChange" > <span v-if="shortcut" aria-hidden="true" data-slot="questionnaire-choice-shortcut" class="pointer-events-none hidden size-5 shrink-0 items-center justify-center rounded-md border border-input bg-card font-mono text-[10.5px] leading-none font-medium text-muted-foreground group-data-[shortcut]/questionnaire-choice:inline-flex group-data-[checked]/questionnaire-choice:border-brand-edge group-data-[checked]/questionnaire-choice:bg-brand group-data-[checked]/questionnaire-choice:text-brand-foreground" > {{ shortcut }} </span> <span data-slot="questionnaire-choice-label" class="flex min-w-0 flex-1 flex-col gap-0.5 text-sm leading-snug" > <slot :checked="checked" :disabled="disabled" :shortcut="shortcut" :type="type" /> </span> <span aria-hidden="true" data-slot="questionnaire-choice-indicator" class="pointer-events-none relative flex size-4 shrink-0 items-center justify-center text-foreground group-data-[type=checkbox]/questionnaire-choice:size-[18px] group-data-[type=checkbox]/questionnaire-choice:rounded-[5px] group-data-[type=checkbox]/questionnaire-choice:border group-data-[type=checkbox]/questionnaire-choice:border-input group-data-[type=checkbox]/questionnaire-choice:bg-card group-data-[type=checkbox]/questionnaire-choice:group-data-[checked]/questionnaire-choice:border-primary 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" > <CheckIcon data-slot="questionnaire-choice-indicator-check" class="hidden size-3.5 group-data-[checked]/questionnaire-choice:block" /> </span> </label></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <span data-slot="questionnaire-choice-description" :class="cn('text-muted-foreground', props.class)" > <slot /> </span></template><script setup lang="ts">import type { PrimitiveProps } from "reka-ui"import type { HTMLAttributes } from "vue"import { Primitive } from "reka-ui"import { cn } from "@/registry/edmi/lib/utils"import { injectQuestionnaireItemContext } from "./useQuestionnaire"
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"]}>(), { as: "div",})
const item = injectQuestionnaireItemContext()</script>
<template> <Primitive data-slot="questionnaire-choices" :as="props.as" :as-child="props.asChild" :data-shortcuts="item.shortcuts.value ?? undefined" :class="cn('group/questionnaire-choices grid min-w-0 gap-2', props.class)" > <slot :shortcuts="item.shortcuts.value" /> </Primitive></template><script setup lang="ts">import type { PrimitiveProps } from "reka-ui"import type { ComponentPublicInstance, HTMLAttributes } from "vue"import { Primitive } from "reka-ui"import { onBeforeUnmount, onMounted, ref, useId } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { injectQuestionnaireItemContext } from "./useQuestionnaire"
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] id?: string}>(), { as: "p",})
const item = injectQuestionnaireItemContext()
const primitiveRef = ref<ComponentPublicInstance | null>(null)const fallbackId = props.id ?? useId()const descriptionId = ref(fallbackId)
let unregisterDescription = item.registerDescription(descriptionId.value)
onMounted(() => { // With `as-child` the rendered child can bring its own id, for example a // DialogDescription. Adopt it so both descriptions point at one element. const element = primitiveRef.value?.$el as HTMLElement | undefined const renderedId = element?.id
if (!renderedId) { if (element) { element.id = fallbackId }
return }
if (renderedId !== descriptionId.value) { unregisterDescription() descriptionId.value = renderedId unregisterDescription = item.registerDescription(renderedId) }})
onBeforeUnmount(() => unregisterDescription())</script>
<template> <Primitive v-bind="props.asChild ? {} : { id: descriptionId }" ref="primitiveRef" data-slot="questionnaire-description" :as="props.as" :as-child="props.asChild" :class="cn('text-sm text-pretty text-muted-foreground', props.class)" > <slot /> </Primitive></template><script setup lang="ts">import type { PrimitiveProps } from "reka-ui"import type { HTMLAttributes } from "vue"import { Primitive } from "reka-ui"import { computed, onBeforeUnmount, useId } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { injectQuestionnaireItemContext } from "./useQuestionnaire"
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] id?: string}>(), { as: "p",})
const item = injectQuestionnaireItemContext()
const errorId = props.id ?? useId()const unregisterError = item.registerError(errorId)
const fallback = computed(() => item.required.value ? "Choose an answer to continue." : "Choose an answer or skip this question.")
onBeforeUnmount(unregisterError)</script>
<template> <Primitive :id="errorId" data-slot="questionnaire-error" :as="props.as" :as-child="props.asChild" :data-invalid="item.invalid.value ? '' : undefined" :hidden="!item.invalid.value" :role="item.invalid.value ? 'alert' : undefined" :class="cn('text-sm text-destructive-text', props.class)" > <slot :invalid="item.invalid.value"> {{ fallback }} </slot> </Primitive></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import type { QuestionnaireInputType } from "./useQuestionnaire"import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { useElevation } from "@/registry/edmi/ui/elevation"import { getAnswerKeyShortcuts, hasInputValue, injectQuestionnaireItemContext, injectQuestionnaireRootContext,} from "./useQuestionnaire"
defineOptions({ // The wrapper is the root element, so attributes have to reach the input. inheritAttrs: false,})
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"] /** Fills the answer on mount and after a native form reset. */ defaultValue?: string | number disabled?: boolean /** Controlled value. Use with `v-model`. */ modelValue?: string | number type?: QuestionnaireInputType}>(), { disabled: false, type: "text",})
const emits = defineEmits<{ "update:modelValue": [value: string]}>()
const item = injectQuestionnaireItemContext()const root = injectQuestionnaireRootContext()const level = useElevation(() => root.elevation.value, "field")const inputElevation = { sunken: "border-sk-bd bg-sk-bg shadow-sunken", flat: "", raised: "border-transparent shadow-raised", floating: "border-transparent shadow-floating",}
const answerId = useId()const inputElement = ref<HTMLInputElement | null>(null)const initialDefaultFilled = hasInputValue(props.defaultValue)const uncontrolledValue = ref(String(props.defaultValue ?? ""))
const controlled = computed(() => props.modelValue !== undefined)const defaultFilled = computed(() => hasInputValue(props.defaultValue))const disabled = computed(() => item.disabled.value || props.disabled)// Vue re-applies `value` on every render, so the input always renders the value// the questionnaire owns instead of an undefined binding that would clear it.const value = computed(() => controlled.value ? String(props.modelValue ?? "") : uncontrolledValue.value)const filled = computed(() => hasInputValue(value.value))const selected = computed(() => item.selectedAnswerIds.value.includes(answerId))
function syncValueElement() { if (inputElement.value && inputElement.value.value !== value.value) { inputElement.value.value = value.value }}
function handleInput(event: Event) { const nextValue = (event.target as HTMLInputElement).value
emits("update:modelValue", nextValue)
if (controlled.value) { // The host owns the value, so restore whatever it kept. nextTick(syncValueElement) return }
uncontrolledValue.value = nextValue item.setAnswerSelectionFromInteraction(answerId, hasInputValue(nextValue))}
const unregisterSelection = item.registerAnswerSelection(answerId, initialDefaultFilled)
let unregisterControl: (() => void) | null = null
watch([inputElement, disabled, () => props.disabled], ([element]) => { unregisterControl?.() unregisterControl = null
if (!element) { return }
unregisterControl = item.registerAnswerControl({ disabled: disabled.value, element, id: answerId, ownDisabled: props.disabled, type: "input", value: "", })}, { flush: "post" })
watch(defaultFilled, (nextDefaultFilled) => { item.setAnswerDefault(answerId, nextDefaultFilled)})
// Watching the value as well as `filled` lets a host update clear the skipped// state even when the answer stays filled.watch([value, filled], () => { if (controlled.value) { item.syncControlledAnswerSelection(answerId, filled.value) }}, { immediate: true })
watch(item.resetVersion, () => { if (!controlled.value) { uncontrolledValue.value = String(props.defaultValue ?? "") }})
watch([value, inputElement], () => { if (!inputElement.value) { return }
// A native form reset restores `defaultValue`, so keep it in sync with the // value the questionnaire owns. inputElement.value.defaultValue = String( (controlled.value ? props.modelValue : props.defaultValue) ?? "", )}, { flush: "post" })
onBeforeUnmount(() => { unregisterControl?.() unregisterControl = null unregisterSelection()})</script>
<template> <div data-slot="questionnaire-input-wrapper" class="group/questionnaire-input relative w-full min-w-0" > <input v-bind="$attrs" :id="answerId" ref="inputElement" data-slot="questionnaire-input" :aria-invalid="item.invalid.value || undefined" :aria-keyshortcuts="getAnswerKeyShortcuts(null, !disabled && filled && selected)" :data-disabled="disabled ? '' : undefined" :data-empty="filled ? undefined : ''" :data-filled="filled ? '' : undefined" :data-invalid="item.invalid.value ? '' : undefined" :disabled="disabled" :form="selected ? undefined : ''" :name="selected ? item.name.value : undefined" :type="props.type" :value="value" :class="cn( 'h-12 w-full min-w-0 rounded-xl border border-border bg-card px-3.5 text-sm transition-colors outline-none focus-visible:border-ring focus-visible:shadow-[0_0_0_1px_var(--ring)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive', 'placeholder:text-muted-foreground', inputElevation[level], props.class, )" @input="handleInput" > </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import type { AnswerControlRegistration, QuestionnaireItemStatus } from "./useQuestionnaire"import { computed, onBeforeUnmount, ref, shallowRef, useAttrs, watch } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { compareDocumentOrder, getShortcutByChoiceValue, getShortcutKeys, injectQuestionnaireRootContext, isAnswerFilled, isEmptyNavigableInput, isRadioTarget, isTextEntryTarget, provideQuestionnaireItemContext,} from "./useQuestionnaire"
defineOptions({ inheritAttrs: false,})
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"] /** Excludes the item from the questionnaire without unmounting it. */ disabled?: boolean /** Marks the item invalid from outside, for example after schema validation. */ invalid?: boolean /** Renders choices as checkboxes and keeps every selected answer. */ multiple?: boolean /** Submitted under this name, and used to activate the item. */ name: string /** Requires an answer before the questionnaire can continue. */ required?: boolean}>(), { disabled: false, invalid: false, multiple: false, required: false,})
const emits = defineEmits<{ "update:status": [status: QuestionnaireItemStatus]}>()
const attrs = useAttrs()const root = injectQuestionnaireRootContext()
const itemElement = ref<HTMLFieldSetElement | null>(null)const answerControls = shallowRef<AnswerControlRegistration[]>([])const selectedAnswerIds = ref<string[]>([])const validationAttempted = ref(false)const skipped = ref(false)const resetVersion = ref(0)const controlSyncVersion = ref(0)const descriptionIds = ref<string[]>([])const errorIds = ref<string[]>([])const titleIds = ref<string[]>([])
let defaultSelectedAnswerIds: string[] = []
const active = computed(() => !props.disabled && root.activeItemName.value === props.name)const orderedAnswerControls = computed(() => { // Re-sort whenever answers are added to or removed from the DOM. void root.domVersion.value
return [...answerControls.value].sort((first, second) => compareDocumentOrder(first.element, second.element))})const answers = computed(() => orderedAnswerControls.value.filter(answer => !answer.disabled))const answered = computed(() => answers.value.some(answer => selectedAnswerIds.value.includes(answer.id)))const status = computed<QuestionnaireItemStatus>(() => { if (skipped.value) { return "skipped" }
return answered.value ? "answered" : "unanswered"})const intentionallySkipped = computed(() => status.value === "skipped" && !props.required)const valid = computed(() => props.disabled || intentionallySkipped.value || (!props.invalid && status.value === "answered"))const invalid = computed(() => !props.disabled && !intentionallySkipped.value && (props.invalid || (validationAttempted.value && !valid.value)))const hasInputAnswer = computed(() => answers.value.some(answer => answer.type === "input"))const itemDefinition = computed(() => root.itemDefinitionByName.value?.get(props.name))const shortcutByChoiceValue = computed(() => root.itemDefinitionByName.value ? getShortcutByChoiceValue(itemDefinition.value, root.shortcuts.value) : null)const shortcutByAnswerId = computed(() => { // Choice values from `items` take precedence, so shortcuts stay stable. if (shortcutByChoiceValue.value) { return new Map<string, string>() }
const keys = getShortcutKeys(root.shortcuts.value) const shortcutAnswers = answers.value.filter(answer => answer.type === "choice")
return new Map( shortcutAnswers .slice(0, keys.length) .flatMap((answer, index) => (keys[index] ? [[answer.id, keys[index]!] as const] : [])), )})// Only set when the title does not render as the legend, which already names// the fieldset on its own.const labelledBy = computed(() => [...titleIds.value, attrs["aria-labelledby"]].filter(Boolean).join(" ") || undefined)const describedBy = computed(() => [...descriptionIds.value, ...(invalid.value ? errorIds.value : []), attrs["aria-describedby"]] .filter(Boolean) .join(" ") || undefined)const keyShortcuts = computed(() => [ attrs["aria-keyshortcuts"], active.value ? "Meta+Enter Control+Enter" : undefined, active.value && answers.value.length ? "ArrowUp ArrowDown" : undefined, active.value && !root.first.value ? "ArrowLeft" : undefined, active.value && !root.last.value && status.value !== "unanswered" ? "ArrowRight" : undefined, ] .filter(Boolean) .join(" ") || undefined)
function updateAnswerSelected(answerId: string, selected: boolean) { if (!selected) { selectedAnswerIds.value = selectedAnswerIds.value.filter(current => current !== answerId) return }
if (!props.multiple) { selectedAnswerIds.value = [answerId] return }
if (!selectedAnswerIds.value.includes(answerId)) { selectedAnswerIds.value = [...selectedAnswerIds.value, answerId] }}
function setAnswerSelectionFromInteraction(answerId: string, selected: boolean) { skipped.value = false updateAnswerSelected(answerId, selected)}
function syncControlledAnswerSelection(answerId: string, selected: boolean) { if (selected) { skipped.value = false }
updateAnswerSelected(answerId, selected)}
function registerAnswerSelection(answerId: string, defaultSelected: boolean) { if (defaultSelected) { defaultSelectedAnswerIds = [ ...defaultSelectedAnswerIds.filter(current => current !== answerId), answerId, ]
if (!props.multiple) { if (!selectedAnswerIds.value.length) { selectedAnswerIds.value = [answerId] } } else if (!selectedAnswerIds.value.includes(answerId)) { selectedAnswerIds.value = [...selectedAnswerIds.value, answerId] } }
return () => { defaultSelectedAnswerIds = defaultSelectedAnswerIds.filter(current => current !== answerId) selectedAnswerIds.value = selectedAnswerIds.value.filter(current => current !== answerId) }}
function setAnswerDefault(answerId: string, defaultSelected: boolean) { if (defaultSelected) { if (!defaultSelectedAnswerIds.includes(answerId)) { defaultSelectedAnswerIds = [...defaultSelectedAnswerIds, answerId] }
return }
defaultSelectedAnswerIds = defaultSelectedAnswerIds.filter(current => current !== answerId)}
/** * Selecting a controlled choice clears the native checked state of the other * choices in the group, so every control re-syncs after an interaction the host * may have rejected. */function requestControlSync() { controlSyncVersion.value += 1}
function registerAnswerControl(registration: AnswerControlRegistration) { answerControls.value = [ ...answerControls.value.filter( current => current.element !== registration.element && current.id !== registration.id, ), registration, ]
return () => { answerControls.value = answerControls.value.filter(current => current !== registration) }}
function registerDescription(descriptionId: string) { if (!descriptionIds.value.includes(descriptionId)) { descriptionIds.value = [...descriptionIds.value, descriptionId] }
return () => { descriptionIds.value = descriptionIds.value.filter(current => current !== descriptionId) }}
function registerTitle(titleId: string) { if (!titleIds.value.includes(titleId)) { titleIds.value = [...titleIds.value, titleId] }
return () => { titleIds.value = titleIds.value.filter(current => current !== titleId) }}
function registerError(errorId: string) { if (!errorIds.value.includes(errorId)) { errorIds.value = [...errorIds.value, errorId] }
return () => { errorIds.value = errorIds.value.filter(current => current !== errorId) }}
function validate() { validationAttempted.value = true
if (!valid.value) { return false }
if (!root.nativeValidation.value) { return true }
const invalidAnswer = answers.value.find( answer => isAnswerFilled(answer) && answer.element.willValidate && !answer.element.validity.valid, )
if (!invalidAnswer) { return true }
invalidAnswer.element.focus() invalidAnswer.element.reportValidity()
return false}
function focus() { itemElement.value?.focus()}
function focusInvalid() { const selectedInput = itemElement.value?.querySelector<HTMLInputElement>( "input[data-filled][name]:not(:disabled)", ) const firstControl = itemElement.value?.querySelector<HTMLElement>( "input:not([type=hidden]):not(:disabled), textarea:not(:disabled)", )
;(selectedInput ?? firstControl ?? itemElement.value)?.focus()}
function reset() { validationAttempted.value = false skipped.value = false selectedAnswerIds.value = props.multiple ? [...defaultSelectedAnswerIds] : defaultSelectedAnswerIds.slice(0, 1) resetVersion.value += 1}
function skip() { if (props.required) { return }
selectedAnswerIds.value = [] skipped.value = true}
function getAnswerByElement(element: Element) { return answers.value.find(answer => answer.element === element) ?? null}
function getAnswerByShortcut(shortcut: string) { if (shortcutByChoiceValue.value) { const choiceValue = Array.from(shortcutByChoiceValue.value.entries()).find( ([, choiceShortcut]) => choiceShortcut === shortcut, )?.[0]
return ( answers.value.find(answer => answer.type === "choice" && answer.value === choiceValue) ?? null ) }
const answerId = Array.from(shortcutByAnswerId.value.entries()).find( ([, answerShortcut]) => answerShortcut === shortcut, )?.[0]
return answers.value.find(answer => answer.id === answerId) ?? null}
function moveAnswerFocus(currentElement: Element, direction: "next" | "previous") { const currentIndex = answers.value.findIndex(answer => answer.element === currentElement) const currentAnswer = currentIndex < 0 ? null : (answers.value[currentIndex] ?? null)
if ( !answers.value.length || (isTextEntryTarget(currentElement) && !isEmptyNavigableInput(currentAnswer)) || (currentIndex < 0 && currentElement !== itemElement.value) ) { return false }
const nextAnswer = currentIndex < 0 ? (answers.value.find(isAnswerFilled) ?? (direction === "next" ? answers.value[0] : answers.value[answers.value.length - 1])) : answers.value[ (currentIndex + (direction === "next" ? 1 : -1) + answers.value.length) % answers.value.length ]
if (!nextAnswer || nextAnswer.element === currentElement) { return false }
// Radio groups already move focus with the arrow keys. 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}
watch(status, (nextStatus) => { emits("update:status", nextStatus)})
watch(() => props.multiple, (multiple, wasMultiple) => { if (!wasMultiple || multiple) { return }
const selectedAnswer = answers.value.find(answer => selectedAnswerIds.value.includes(answer.id))
selectedAnswerIds.value = selectedAnswer ? [selectedAnswer.id] : []})
let unregisterItem: (() => void) | null = null
watch([itemElement, () => props.name], ([element, name]) => { unregisterItem?.() unregisterItem = null
if (!element) { return }
unregisterItem = root.registerItem({ element, focus, focusInvalid, getAnswerByElement, getAnswerByShortcut, getChoices: () => orderedAnswerControls.value.flatMap(answer => answer.type === "choice" ? [{ disabled: answer.ownDisabled, value: answer.value }] : []), isDisabled: () => props.disabled, isRequired: () => props.required, moveAnswerFocus, name, reset, skip, status: () => status.value, validate, })}, { flush: "post" })
onBeforeUnmount(() => { unregisterItem?.() unregisterItem = null})
provideQuestionnaireItemContext({ active, controlSyncVersion, disabled: computed(() => props.disabled), hasInputAnswer, invalid, multiple: computed(() => props.multiple), name: computed(() => props.name), registerAnswerControl, registerAnswerSelection, registerDescription, registerError, registerTitle, requestControlSync, required: computed(() => props.required), resetVersion, selectedAnswerIds, setAnswerDefault, setAnswerSelectionFromInteraction, shortcutByAnswerId, shortcutByChoiceValue, shortcuts: root.shortcuts, status, syncControlledAnswerSelection,})</script>
<template> <fieldset v-bind="attrs" ref="itemElement" data-slot="questionnaire-item" :aria-describedby="describedBy" :aria-invalid="invalid || undefined" :aria-keyshortcuts="keyShortcuts" :aria-labelledby="labelledBy" :data-active="active ? '' : undefined" :data-disabled="props.disabled ? '' : undefined" :data-invalid="invalid ? '' : undefined" :data-multiple="props.multiple ? '' : undefined" :data-required="props.required ? '' : undefined" :data-status="status" :disabled="props.disabled" :hidden="!active" :inert="!active" tabindex="-1" :class="cn('flex min-w-0 flex-col gap-4 border-0 p-0 outline-none [&[hidden]]:hidden', props.class)" > <slot :active="active" :invalid="invalid" :status="status" /> </fieldset></template><script setup lang="ts">import type { PrimitiveProps } from "reka-ui"import type { HTMLAttributes } from "vue"import type { ButtonVariants } from "@/registry/edmi/ui/button"import { Primitive } from "reka-ui"import { computed } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { buttonVariants } from "@/registry/edmi/ui/button"import { injectQuestionnaireRootContext } from "./useQuestionnaire"
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] disabled?: boolean size?: ButtonVariants["size"] variant?: ButtonVariants["variant"]}>(), { as: "button", disabled: false, size: "default", variant: "default",})
const emits = defineEmits<{ click: [event: MouseEvent]}>()
const root = injectQuestionnaireRootContext()
const visible = computed(() => root.total.value > 1 && !root.last.value)const shortcut = computed(() => (visible.value && !props.disabled ? "Enter" : null))
function handleClick(event: MouseEvent) { emits("click", event)
// `disabled` does not block clicks once `as` or `as-child` renders something // other than a button. if (props.disabled) { event.preventDefault() return }
if (!event.defaultPrevented) { root.goNext() }}</script>
<template> <Primitive data-slot="questionnaire-next" type="button" :aria-hidden="!visible || undefined" :aria-disabled="props.disabled || undefined" :aria-keyshortcuts="shortcut ?? undefined" :as="props.as" :as-child="props.asChild" :data-disabled="props.disabled ? '' : undefined" :data-hidden="visible ? undefined : ''" :data-shortcut="shortcut ?? undefined" :data-size="props.size" :data-status="root.activeItemStatus.value ?? undefined" :data-variant="props.variant" :data-visible="visible ? '' : undefined" :disabled="props.disabled" :hidden="!visible" :inert="!visible" :tabindex="visible ? undefined : -1" :class="cn( buttonVariants({ size: props.size, variant: props.variant }), 'col-start-3 row-start-1 justify-self-end [&[hidden]]:hidden', props.class, )" @click="handleClick" > <slot>Next</slot> </Primitive></template><script setup lang="ts">import type { PrimitiveProps } from "reka-ui"import type { HTMLAttributes } from "vue"import type { ButtonVariants } from "@/registry/edmi/ui/button"import { Primitive } from "reka-ui"import { computed } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { buttonVariants } from "@/registry/edmi/ui/button"import { injectQuestionnaireRootContext } from "./useQuestionnaire"
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] disabled?: boolean size?: ButtonVariants["size"] variant?: ButtonVariants["variant"]}>(), { as: "button", disabled: false, size: "default", variant: "ghost",})
const emits = defineEmits<{ click: [event: MouseEvent]}>()
const root = injectQuestionnaireRootContext()
const visible = computed(() => root.total.value > 1 && !root.first.value)
function handleClick(event: MouseEvent) { emits("click", event)
// `disabled` does not block clicks once `as` or `as-child` renders something // other than a button. if (props.disabled) { event.preventDefault() return }
if (!event.defaultPrevented) { root.goPrevious() }}</script>
<template> <Primitive data-slot="questionnaire-previous" type="button" :aria-hidden="!visible || undefined" :aria-disabled="props.disabled || undefined" :as="props.as" :as-child="props.asChild" :data-disabled="props.disabled ? '' : undefined" :data-hidden="visible ? undefined : ''" :data-size="props.size" :data-status="root.activeItemStatus.value ?? undefined" :data-variant="props.variant" :data-visible="visible ? '' : undefined" :disabled="props.disabled" :hidden="!visible" :inert="!visible" :tabindex="visible ? undefined : -1" :class="cn( buttonVariants({ size: props.size, variant: props.variant }), 'col-start-1 row-start-1 justify-self-start [&[hidden]]:hidden', props.class, )" @click="handleClick" > <slot>Previous</slot> </Primitive></template><script setup lang="ts">import type { PrimitiveProps } from "reka-ui"import type { HTMLAttributes } from "vue"import { Primitive } from "reka-ui"import { computed } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { injectQuestionnaireRootContext } from "./useQuestionnaire"
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"]}>(), { as: "div",})
const root = injectQuestionnaireRootContext()
const label = computed(() => root.total.value ? `Question ${root.current.value} of ${root.total.value}` : undefined)</script>
<template> <Primitive aria-label="Questionnaire progress" aria-live="polite" data-slot="questionnaire-progress" role="progressbar" :aria-valuemax="root.total.value || undefined" :aria-valuemin="root.total.value ? 1 : undefined" :aria-valuenow="root.total.value ? root.current.value : undefined" :aria-valuetext="label" :as="props.as" :as-child="props.asChild" :data-current="root.current.value" :data-first="root.first.value ? '' : undefined" :data-last="root.last.value ? '' : undefined" :data-total="root.total.value" :class="cn( 'min-h-[1lh] w-fit min-w-[14ch] font-mono text-[11.5px] text-muted-foreground tabular-nums', props.class, )" > <slot :current="root.current.value" :first="root.first.value" :last="root.last.value" :total="root.total.value" > {{ label }} </slot> </Primitive></template><script setup lang="ts">import type { PrimitiveProps } from "reka-ui"import type { HTMLAttributes } from "vue"import type { ButtonVariants } from "@/registry/edmi/ui/button"import { Primitive } from "reka-ui"import { computed } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { buttonVariants } from "@/registry/edmi/ui/button"import { injectQuestionnaireRootContext } from "./useQuestionnaire"
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] disabled?: boolean size?: ButtonVariants["size"] variant?: ButtonVariants["variant"]}>(), { as: "button", disabled: false, size: "default", variant: "outline",})
const emits = defineEmits<{ click: [event: MouseEvent]}>()
const root = injectQuestionnaireRootContext()
const visible = computed(() => root.activeItemRequired.value === false)
function handleClick(event: MouseEvent) { emits("click", event)
// `disabled` does not block clicks once `as` or `as-child` renders something // other than a button. if (props.disabled) { event.preventDefault() return }
if (!event.defaultPrevented) { root.skipCurrent() }}</script>
<template> <Primitive data-slot="questionnaire-skip" type="button" :aria-hidden="!visible || undefined" :aria-disabled="props.disabled || undefined" :as="props.as" :as-child="props.asChild" :data-disabled="props.disabled ? '' : undefined" :data-hidden="visible ? undefined : ''" :data-size="props.size" :data-status="root.activeItemStatus.value ?? undefined" :data-variant="props.variant" :data-visible="visible ? '' : undefined" :disabled="props.disabled" :hidden="!visible" :inert="!visible" :tabindex="visible ? undefined : -1" :class="cn( buttonVariants({ size: props.size, variant: props.variant }), 'col-start-2 row-start-1 justify-self-end [&[hidden]]:hidden', props.class, )" @click="handleClick" > <slot>Skip</slot> </Primitive></template><script setup lang="ts">import type { PrimitiveProps } from "reka-ui"import type { HTMLAttributes } from "vue"import type { ButtonVariants } from "@/registry/edmi/ui/button"import { Primitive } from "reka-ui"import { computed } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { buttonVariants } from "@/registry/edmi/ui/button"import { injectQuestionnaireRootContext } from "./useQuestionnaire"
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] disabled?: boolean size?: ButtonVariants["size"] variant?: ButtonVariants["variant"]}>(), { as: "button", disabled: false, size: "default", variant: "default",})
const root = injectQuestionnaireRootContext()
const visible = computed(() => root.total.value > 0 && root.last.value)const shortcut = computed(() => (visible.value && !props.disabled ? "Enter" : null))</script>
<template> <Primitive data-slot="questionnaire-submit" type="submit" :aria-hidden="!visible || undefined" :aria-disabled="props.disabled || undefined" :aria-keyshortcuts="shortcut ?? undefined" :as="props.as" :as-child="props.asChild" :data-disabled="props.disabled ? '' : undefined" :data-hidden="visible ? undefined : ''" :data-shortcut="shortcut ?? undefined" :data-size="props.size" :data-status="root.activeItemStatus.value ?? undefined" :data-variant="props.variant" :data-visible="visible ? '' : undefined" :disabled="props.disabled" :hidden="!visible" :inert="!visible" :tabindex="visible ? undefined : -1" :class="cn( buttonVariants({ size: props.size, variant: props.variant }), 'col-start-3 row-start-1 justify-self-end [&[hidden]]:hidden', props.class, )" > <slot>Submit</slot> </Primitive></template><script setup lang="ts">import type { PrimitiveProps } from "reka-ui"import type { ComponentPublicInstance, HTMLAttributes } from "vue"import { Primitive } from "reka-ui"import { onBeforeUnmount, onMounted, ref, useId } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { injectQuestionnaireItemContext } from "./useQuestionnaire"
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] id?: string}>(), { as: "legend",})
const item = injectQuestionnaireItemContext()
const primitiveRef = ref<ComponentPublicInstance | null>(null)const fallbackId = props.id ?? useId()
let unregisterTitle: (() => void) | null = null
onMounted(() => { const element = primitiveRef.value?.$el as HTMLElement | undefined
// A legend already names the fieldset. Anything else, for example a // DialogTitle rendered through `as-child`, has to name it explicitly. if (!element || element.tagName === "LEGEND") { return }
if (!element.id) { element.id = fallbackId }
unregisterTitle = item.registerTitle(element.id)})
onBeforeUnmount(() => unregisterTitle?.())</script>
<template> <Primitive v-bind="props.id ? { id: props.id } : {}" ref="primitiveRef" data-slot="questionnaire-title" :as="props.as" :as-child="props.asChild" :class="cn('text-[17px] leading-snug font-semibold tracking-[-0.2px] text-pretty [&:not(:has(~[data-slot=questionnaire-description]))]:mb-4', props.class)" > <slot /> </Primitive></template>import { createContext } from "reka-ui";import type { ComputedRef, Ref } from "vue";import type { Elevation } from "@/registry/edmi/ui/elevation";
export type QuestionnaireItemStatus = "unanswered" | "answered" | "skipped";export type QuestionnaireShortcutMode = "letters" | "numbers";
export type QuestionnaireInputType = | "date" | "datetime-local" | "email" | "month" | "number" | "password" | "search" | "tel" | "text" | "time" | "url" | "week";
export interface QuestionnaireChoiceDefinition { disabled?: boolean; value: string;}
export interface QuestionnaireItemDefinition { choices?: readonly QuestionnaireChoiceDefinition[]; disabled?: boolean; name: string; required?: boolean;}
export interface ChoiceRegistration { disabled: boolean; value: string;}
export interface AnswerControlRegistration { disabled: boolean; element: HTMLInputElement; id: string; ownDisabled: boolean; type: "choice" | "input"; value: string;}
export interface ItemRegistration { element: HTMLFieldSetElement; focus: () => void; focusInvalid: () => void; getAnswerByElement: (element: Element) => AnswerControlRegistration | null; getAnswerByShortcut: (shortcut: string) => AnswerControlRegistration | null; getChoices: () => ChoiceRegistration[]; isDisabled: () => boolean; isRequired: () => boolean; moveAnswerFocus: ( element: Element, direction: "next" | "previous", ) => boolean; name: string; reset: () => void; skip: () => void; status: () => QuestionnaireItemStatus; validate: () => boolean;}
export interface QuestionnaireRootContext { activeItem: ComputedRef<ItemRegistration | null>; activeItemName: ComputedRef<string | null>; activeItemRequired: ComputedRef<boolean | null>; activeItemStatus: ComputedRef<QuestionnaireItemStatus | null>; current: ComputedRef<number>; domVersion: Ref<number>; first: ComputedRef<boolean>; goNext: () => void; goPrevious: () => void; itemDefinitionByName: ComputedRef<Map< string, QuestionnaireItemDefinition > | null>; last: ComputedRef<boolean>; nativeValidation: ComputedRef<boolean>; /** ✦ depth of every choice / input (undefined = auto) */ elevation: ComputedRef<Elevation | undefined>; registerItem: (registration: ItemRegistration) => () => void; shortcuts: ComputedRef<QuestionnaireShortcutMode | null>; skipCurrent: () => void; total: ComputedRef<number>;}
export interface QuestionnaireItemContext { active: ComputedRef<boolean>; /** Bumped after a controlled answer interaction so every control re-syncs. */ controlSyncVersion: Ref<number>; disabled: ComputedRef<boolean>; hasInputAnswer: ComputedRef<boolean>; invalid: ComputedRef<boolean>; multiple: ComputedRef<boolean>; name: ComputedRef<string>; registerAnswerControl: ( registration: AnswerControlRegistration, ) => () => void; registerAnswerSelection: ( answerId: string, defaultSelected: boolean, ) => () => void; registerDescription: (descriptionId: string) => () => void; registerError: (errorId: string) => () => void; /** Only used when the title does not render as the fieldset legend. */ registerTitle: (titleId: string) => () => void; requestControlSync: () => void; required: ComputedRef<boolean>; resetVersion: Ref<number>; selectedAnswerIds: Ref<string[]>; setAnswerDefault: (answerId: string, defaultSelected: boolean) => void; setAnswerSelectionFromInteraction: ( answerId: string, selected: boolean, ) => void; shortcutByAnswerId: ComputedRef<Map<string, string>>; shortcutByChoiceValue: ComputedRef<Map<string, string> | null>; shortcuts: ComputedRef<QuestionnaireShortcutMode | null>; status: ComputedRef<QuestionnaireItemStatus>; syncControlledAnswerSelection: (answerId: string, selected: boolean) => void;}
export const [injectQuestionnaireRootContext, provideQuestionnaireRootContext] = createContext<QuestionnaireRootContext>("Questionnaire");
export const [injectQuestionnaireItemContext, provideQuestionnaireItemContext] = createContext<QuestionnaireItemContext>("QuestionnaireItem");
export 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 );}
export 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 [];}
export function getShortcutFromKey( key: string, shortcuts: QuestionnaireShortcutMode,) { const normalizedKey = shortcuts === "letters" ? key.toUpperCase() : key;
return getShortcutKeys(shortcuts).includes(normalizedKey) ? normalizedKey : null;}
export function getAnswerKeyShortcuts( shortcut: string | null, filled: boolean,) { return ( [shortcut, filled ? "Enter" : null].filter(Boolean).join(" ") || undefined );}
export function isAnswerFilled(answer: AnswerControlRegistration) { if (answer.type === "choice") { return answer.element.checked; }
return ( answer.element.hasAttribute("name") && hasInputValue(answer.element.value) );}
export function isEmptyNavigableInput( answer: AnswerControlRegistration | null,) { return ( answer?.type === "input" && ["email", "password", "search", "tel", "text", "url"].includes( answer.element.type, ) && !hasInputValue(answer.element.value) );}
export 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;}
export function isRadioTarget(element: Element) { return element instanceof HTMLInputElement && element.type === "radio";}
/** * Sort registrations by the position of their element in the document, so that * navigation always follows the rendered order instead of the mount order. */export function compareDocumentOrder(first: Element, second: Element) { if (first === second) { return 0; }
const position = first.compareDocumentPosition(second);
if (position & Node.DOCUMENT_POSITION_FOLLOWING) { return -1; }
if (position & Node.DOCUMENT_POSITION_PRECEDING) { return 1; }
return 0;}
export interface QuestionnaireCollection { enabledItems: QuestionnaireItemDefinition[]; itemByName: Map<string, QuestionnaireItemDefinition>; items: readonly QuestionnaireItemDefinition[];}
export 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, };}
export 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;}
/** * Map every enabled choice of an item definition to a keyboard shortcut, so * that shortcuts stay stable regardless of how choices are rendered. */export function getShortcutByChoiceValue( item: QuestionnaireItemDefinition | undefined, shortcuts: QuestionnaireShortcutMode | null,) { const shortcutByChoiceValue = new Map<string, string>();
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;}export { default as Questionnaire } from "./Questionnaire.vue";export { default as QuestionnaireActions } from "./QuestionnaireActions.vue";export { default as QuestionnaireChoice } from "./QuestionnaireChoice.vue";export { default as QuestionnaireChoiceDescription } from "./QuestionnaireChoiceDescription.vue";export { default as QuestionnaireChoices } from "./QuestionnaireChoices.vue";export { default as QuestionnaireDescription } from "./QuestionnaireDescription.vue";export { default as QuestionnaireError } from "./QuestionnaireError.vue";export { default as QuestionnaireInput } from "./QuestionnaireInput.vue";export { default as QuestionnaireItem } from "./QuestionnaireItem.vue";export { default as QuestionnaireNext } from "./QuestionnaireNext.vue";export { default as QuestionnairePrevious } from "./QuestionnairePrevious.vue";export { default as QuestionnaireProgress } from "./QuestionnaireProgress.vue";export { default as QuestionnaireSkip } from "./QuestionnaireSkip.vue";export { default as QuestionnaireSubmit } from "./QuestionnaireSubmit.vue";export { default as QuestionnaireTitle } from "./QuestionnaireTitle.vue";
export type { QuestionnaireChoiceDefinition, QuestionnaireInputType, QuestionnaireItemDefinition, QuestionnaireItemStatus, QuestionnaireShortcutMode,} from "./useQuestionnaire";
export { injectQuestionnaireItemContext, injectQuestionnaireRootContext,} from "./useQuestionnaire";<script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements";
let { ref = $bindable(null), class: className, children, ...restProps }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();</script>
<div bind:this={ref} data-slot="questionnaire-actions" class={cn( "grid min-h-9 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2", className )} {...restProps}> {@render children?.()}</div><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements";
let { ref = $bindable(null), class: className, children, ...restProps }: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();</script>
<span bind:this={ref} data-slot="questionnaire-choice-description" class={cn("text-muted-foreground", className)} {...restProps}> {@render children?.()}</span><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLLabelAttributes } from "svelte/elements"; import CheckIcon from "phosphor-svelte/lib/Check"; import { untrack, type Snippet } from "svelte"; import { type Elevation, useElevation } from "$lib/registry/ui/elevation/index.js"; import { type AnswerControlRegistration, getAnswerKeyShortcuts, getQuestionnaireItemContext, getQuestionnaireRootContext, } from "./use-questionnaire.svelte.js";
let { ref = $bindable(null), class: className, /** Controlled checked state. Use with `bind:checked`. */ checked: checkedProp = $bindable(undefined), /** Checks the choice on mount and after a native form reset. */ defaultChecked = false, disabled: disabledProp = false, /** Submitted as the answer of the parent item. */ value, /** ✦ depth of this option; defaults to the Questionnaire `elevation`. */ elevation = "auto", onchange, children, ...restProps }: Omit<WithElementRef<HTMLLabelAttributes, HTMLLabelElement>, "children" | "onchange"> & { checked?: boolean; defaultChecked?: boolean; disabled?: boolean; value: string; elevation?: Elevation; onchange?: (event: Event) => void; children?: Snippet< [{ checked: boolean; disabled: boolean; shortcut: string | null; type: "checkbox" | "radio" }] >; } = $props();
const item = getQuestionnaireItemContext(); const root = getQuestionnaireRootContext(); const level = useElevation( () => (elevation !== "auto" ? elevation : root.elevation), "control" ); const choiceElevation = { sunken: "border-sk-bd bg-sk-bg shadow-sunken", flat: "", raised: "border-transparent shadow-raised", floating: "border-transparent shadow-floating", }; const answerId = $props.id(); let inputElement = $state<HTMLInputElement | null>(null); const initialDefaultChecked = untrack(() => defaultChecked);
const controlled = $derived(checkedProp !== undefined); const disabled = $derived(item.disabled || disabledProp); const selected = $derived(item.selectedAnswerIds.includes(answerId)); const checked = $derived.by(() => { if (!controlled) return selected; // A skipped item clears every answer, including controlled ones. return item.status === "skipped" ? false : checkedProp!; }); const type = $derived(item.multiple ? "checkbox" : "radio"); const shortcut = $derived( item.shortcutByChoiceValue?.get(value) ?? item.shortcutByAnswerId.get(answerId) ?? null );
function syncCheckedElement() { if (inputElement && inputElement.checked !== checked) inputElement.checked = checked; }
function handleChange(event: Event) { onchange?.(event); if (event.defaultPrevented) { syncCheckedElement(); return; } const nextChecked = (event.target as HTMLInputElement).checked; if (!controlled) { item.setAnswerSelectionFromInteraction(answerId, nextChecked); checkedProp = undefined; return; } checkedProp = nextChecked; item.setAnswerSelectionFromInteraction(answerId, nextChecked); // Checking a radio clears its siblings, so the whole group re-syncs. item.requestControlSync(); }
$effect(() => item.registerAnswerSelection(answerId, initialDefaultChecked));
$effect(() => { const element = inputElement; if (!element) return; return item.registerAnswerControl({ disabled, element, id: answerId, ownDisabled: disabledProp, type: "choice", value, } satisfies AnswerControlRegistration); });
$effect(() => { item.setAnswerDefault(answerId, defaultChecked); });
$effect(() => { void item.resetVersion; if (controlled) item.syncControlledAnswerSelection(answerId, checkedProp!); });
$effect(() => { void item.controlSyncVersion; syncCheckedElement(); });
$effect(() => { if (!inputElement) return; // Keep the native reset target aligned with the questionnaire owned default. inputElement.defaultChecked = controlled ? checkedProp! : defaultChecked; void checked; syncCheckedElement(); });</script>
<label bind:this={ref} data-slot="questionnaire-choice" data-checked={checked ? "" : undefined} data-disabled={disabled ? "" : undefined} data-invalid={item.invalid ? "" : undefined} data-shortcut={shortcut ?? undefined} data-type={type} data-unchecked={checked ? undefined : ""} class={cn( "group/questionnaire-choice relative flex min-h-12 cursor-pointer items-center gap-3 rounded-xl border border-border bg-card px-3.5 py-2 text-start text-sm transition-colors outline-none select-none hover:bg-accent has-[>input:focus-visible]:outline-2 has-[>input:focus-visible]:outline-offset-2 has-[>input:focus-visible]:outline-ring data-[invalid]:border-destructive data-[checked]:border-ring data-[checked]:bg-[color-mix(in_srgb,var(--brand)_5%,var(--card))] data-[checked]:shadow-[0_0_0_1px_var(--ring)]", choiceElevation[level.current], "data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50", className )} {...restProps}> <input id={answerId} bind:this={inputElement} data-slot="questionnaire-choice-input" class="absolute inset-0 z-10 size-full cursor-pointer opacity-0" aria-invalid={item.invalid || undefined} aria-keyshortcuts={getAnswerKeyShortcuts(shortcut, !disabled && checked)} {checked} data-checked={checked ? "" : undefined} data-unchecked={checked ? undefined : ""} {disabled} name={item.status === "skipped" ? undefined : item.name} required={item.required && !item.multiple && !item.hasInputAnswer} {type} {value} onchange={handleChange} /> {#if shortcut} <span aria-hidden="true" data-slot="questionnaire-choice-shortcut" class="pointer-events-none hidden size-5 shrink-0 items-center justify-center rounded-md border border-input bg-card font-mono text-[10.5px] leading-none font-medium text-muted-foreground group-data-[shortcut]/questionnaire-choice:inline-flex group-data-[checked]/questionnaire-choice:border-brand group-data-[checked]/questionnaire-choice:bg-brand group-data-[checked]/questionnaire-choice:text-brand-foreground" > {shortcut} </span> {/if} <span data-slot="questionnaire-choice-label" class="flex min-w-0 flex-1 flex-col gap-0.5 text-sm leading-snug" > {@render children?.({ checked, disabled, shortcut, type })} </span> <span aria-hidden="true" data-slot="questionnaire-choice-indicator" class="pointer-events-none relative flex size-4 shrink-0 items-center justify-center text-foreground group-data-[type=checkbox]/questionnaire-choice:size-[18px] group-data-[type=checkbox]/questionnaire-choice:rounded-[5px] group-data-[type=checkbox]/questionnaire-choice:border group-data-[type=checkbox]/questionnaire-choice:border-input group-data-[type=checkbox]/questionnaire-choice:bg-card group-data-[type=checkbox]/questionnaire-choice:group-data-[checked]/questionnaire-choice:border-primary 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" > <CheckIcon data-slot="questionnaire-choice-indicator-check" class="hidden size-3.5 group-data-[checked]/questionnaire-choice:block" /> </span></label><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements"; import type { Snippet } from "svelte"; import type { QuestionnaireShortcutMode } from "./use-questionnaire.svelte.js"; import { getQuestionnaireItemContext } from "./use-questionnaire.svelte.js";
let { ref = $bindable(null), class: className, children, ...restProps }: Omit<WithElementRef<HTMLAttributes<HTMLDivElement>>, "children"> & { children?: Snippet<[{ shortcuts: QuestionnaireShortcutMode | null }]>; } = $props();
const item = getQuestionnaireItemContext();</script>
<div bind:this={ref} data-slot="questionnaire-choices" data-shortcuts={item.shortcuts ?? undefined} class={cn("group/questionnaire-choices grid min-w-0 gap-2", className)} {...restProps}> {@render children?.({ shortcuts: item.shortcuts })}</div><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements"; import { getQuestionnaireItemContext } from "./use-questionnaire.svelte.js";
let { ref = $bindable(null), class: className, id: idProp = undefined, children, ...restProps }: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
const item = getQuestionnaireItemContext(); const fallbackId = $props.id(); const id = $derived(idProp ?? fallbackId);
$effect(() => item.registerDescription(id));</script>
<p bind:this={ref} {id} data-slot="questionnaire-description" class={cn("text-sm text-pretty text-muted-foreground", className)} {...restProps}> {@render children?.()}</p><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements"; import type { Snippet } from "svelte"; import { getQuestionnaireItemContext } from "./use-questionnaire.svelte.js";
let { ref = $bindable(null), class: className, id: idProp = undefined, children, ...restProps }: Omit<WithElementRef<HTMLAttributes<HTMLParagraphElement>>, "children"> & { children?: Snippet<[{ invalid: boolean }]>; } = $props();
const item = getQuestionnaireItemContext(); const fallbackId = $props.id(); const id = $derived(idProp ?? fallbackId); const fallback = $derived( item.required ? "Choose an answer to continue." : "Choose an answer or skip this question." );
$effect(() => item.registerError(id));</script>
<p bind:this={ref} {id} data-slot="questionnaire-error" data-invalid={item.invalid ? "" : undefined} hidden={!item.invalid} role={item.invalid ? "alert" : undefined} class={cn("text-sm text-destructive-text", className)} {...restProps}> {#if children} {@render children({ invalid: item.invalid })} {:else} {fallback} {/if}</p><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLInputAttributes } from "svelte/elements"; import { untrack } from "svelte"; import { useElevation } from "$lib/registry/ui/elevation/index.js"; import { type QuestionnaireInputType, getAnswerKeyShortcuts, getQuestionnaireItemContext, getQuestionnaireRootContext, hasInputValue, } from "./use-questionnaire.svelte.js";
let { ref = $bindable(null), class: className, /** Fills the answer on mount and after a native form reset. */ defaultValue = undefined, disabled: disabledProp = false, /** Controlled value. Use with `bind:value`. */ value: valueProp = $bindable(undefined), type = "text", oninput, ...restProps }: Omit< WithElementRef<HTMLInputAttributes, HTMLInputElement>, "type" | "form" | "name" | "disabled" > & { disabled?: boolean; defaultValue?: string | number; type?: QuestionnaireInputType; } = $props();
const item = getQuestionnaireItemContext(); const root = getQuestionnaireRootContext(); const level = useElevation(() => root.elevation, "field"); const inputElevation = { sunken: "border-sk-bd bg-sk-bg shadow-sunken", flat: "", raised: "border-transparent shadow-raised", floating: "border-transparent shadow-floating", }; const answerId = $props.id(); const initialDefaultFilled = untrack(() => hasInputValue(defaultValue)); let uncontrolledValue = $state(untrack(() => String(defaultValue ?? "")));
const controlled = $derived(valueProp !== undefined); const defaultFilled = $derived(hasInputValue(defaultValue)); const disabled = $derived(item.disabled || disabledProp); const value = $derived(controlled ? String(valueProp ?? "") : uncontrolledValue); const filled = $derived(hasInputValue(value)); const selected = $derived(item.selectedAnswerIds.includes(answerId));
function handleInput(event: Event & { currentTarget: EventTarget & HTMLInputElement }) { const nextValue = event.currentTarget.value; oninput?.(event); if (controlled) valueProp = nextValue; else uncontrolledValue = nextValue; item.setAnswerSelectionFromInteraction(answerId, hasInputValue(nextValue)); }
$effect(() => item.registerAnswerSelection(answerId, initialDefaultFilled));
$effect(() => { const element = ref; if (!element) return; return item.registerAnswerControl({ disabled, element, id: answerId, ownDisabled: disabledProp, type: "input", value: "", }); });
$effect(() => { item.setAnswerDefault(answerId, defaultFilled); });
// A host update clears the skipped state even when the answer stays filled. $effect(() => { void value; if (controlled) item.syncControlledAnswerSelection(answerId, filled); });
$effect(() => { void item.resetVersion; untrack(() => { if (!controlled) uncontrolledValue = String(defaultValue ?? ""); }); });
$effect(() => { if (!ref) return; // A native form reset restores `defaultValue`, so keep it in sync with the value we own. ref.defaultValue = String((controlled ? valueProp : defaultValue) ?? ""); });</script>
<div data-slot="questionnaire-input-wrapper" class="group/questionnaire-input relative w-full min-w-0"> <input bind:this={ref} id={answerId} data-slot="questionnaire-input" aria-invalid={item.invalid || undefined} aria-keyshortcuts={getAnswerKeyShortcuts(null, !disabled && filled && selected)} data-disabled={disabled ? "" : undefined} data-empty={filled ? undefined : ""} data-filled={filled ? "" : undefined} data-invalid={item.invalid ? "" : undefined} {disabled} form={selected ? undefined : ""} name={selected ? item.name : undefined} {type} {value} class={cn( "h-12 w-full min-w-0 rounded-xl border border-border bg-card px-3.5 text-sm transition-colors outline-none focus-visible:border-ring focus-visible:shadow-[0_0_0_1px_var(--ring)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive", "placeholder:text-muted-foreground", inputElevation[level.current], className )} oninput={handleInput} {...restProps} /></div><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLFieldsetAttributes } from "svelte/elements"; import { untrack, type Snippet } from "svelte"; import { type AnswerControlRegistration, type QuestionnaireItemStatus, compareDocumentOrder, getQuestionnaireRootContext, getShortcutByChoiceValue, getShortcutKeys, isAnswerFilled, isEmptyNavigableInput, isRadioTarget, isTextEntryTarget, setQuestionnaireItemContext, } from "./use-questionnaire.svelte.js";
let { ref = $bindable(null), class: className, /** Excludes the item from the questionnaire without unmounting it. */ disabled = false, /** Marks the item invalid from outside, for example after schema validation. */ invalid: invalidProp = false, /** Renders choices as checkboxes and keeps every selected answer. */ multiple = false, /** Submitted under this name, and used to activate the item. */ name, /** Requires an answer before the questionnaire can continue. */ required = false, onStatusChange = undefined, "aria-describedby": ariaDescribedBy = undefined, "aria-keyshortcuts": ariaKeyShortcuts = undefined, "aria-labelledby": ariaLabelledBy = undefined, children, ...restProps }: Omit< WithElementRef<HTMLFieldsetAttributes, HTMLFieldSetElement>, "children" | "name" | "disabled" > & { disabled?: boolean; invalid?: boolean; multiple?: boolean; name: string; required?: boolean; onStatusChange?: (status: QuestionnaireItemStatus) => void; children?: Snippet<[{ active: boolean; invalid: boolean; status: QuestionnaireItemStatus }]>; } = $props();
const root = getQuestionnaireRootContext();
let answerControls = $state.raw<AnswerControlRegistration[]>([]); let selectedAnswerIds = $state.raw<string[]>([]); let validationAttempted = $state(false); let skipped = $state(false); let resetVersion = $state(0); let controlSyncVersion = $state(0); let descriptionIds = $state.raw<string[]>([]); let errorIds = $state.raw<string[]>([]); let titleIds = $state.raw<string[]>([]); let defaultSelectedAnswerIds: string[] = [];
const active = $derived(!disabled && root.activeItemName === name); const orderedAnswerControls = $derived.by(() => { // Re-sort whenever answers are added to or removed from the DOM. void root.domVersion; return [...answerControls].sort((a, b) => compareDocumentOrder(a.element, b.element)); }); const answers = $derived(orderedAnswerControls.filter((answer) => !answer.disabled)); const answered = $derived(answers.some((answer) => selectedAnswerIds.includes(answer.id))); const status = $derived<QuestionnaireItemStatus>( skipped ? "skipped" : answered ? "answered" : "unanswered" ); const intentionallySkipped = $derived(status === "skipped" && !required); const valid = $derived( disabled || intentionallySkipped || (!invalidProp && status === "answered") ); const invalid = $derived( !disabled && !intentionallySkipped && (invalidProp || (validationAttempted && !valid)) ); const hasInputAnswer = $derived(answers.some((answer) => answer.type === "input")); const itemDefinition = $derived(root.itemDefinitionByName?.get(name)); const shortcutByChoiceValue = $derived( root.itemDefinitionByName ? getShortcutByChoiceValue(itemDefinition, root.shortcuts) : null ); const shortcutByAnswerId = $derived.by(() => { // Choice values from `items` take precedence, so shortcuts stay stable. if (shortcutByChoiceValue) return new Map<string, string>(); const keys = getShortcutKeys(root.shortcuts); return new Map( answers .filter((answer) => answer.type === "choice") .slice(0, keys.length) .flatMap((answer, index) => (keys[index] ? [[answer.id, keys[index]!] as const] : [])) ); });
// Only set when the title does not render as the legend, which already names the fieldset. const labelledBy = $derived( [...titleIds, ariaLabelledBy].filter(Boolean).join(" ") || undefined ); const describedBy = $derived( [...descriptionIds, ...(invalid ? errorIds : []), ariaDescribedBy] .filter(Boolean) .join(" ") || undefined ); const keyShortcuts = $derived( [ ariaKeyShortcuts, active ? "Meta+Enter Control+Enter" : undefined, active && answers.length ? "ArrowUp ArrowDown" : undefined, active && !root.first ? "ArrowLeft" : undefined, active && !root.last && status !== "unanswered" ? "ArrowRight" : undefined, ] .filter(Boolean) .join(" ") || undefined );
function updateAnswerSelected(answerId: string, selected: boolean) { return untrack(() => { if (!selected) { selectedAnswerIds = selectedAnswerIds.filter((c) => c !== answerId); return; } if (!multiple) { selectedAnswerIds = [answerId]; return; } if (!selectedAnswerIds.includes(answerId)) { selectedAnswerIds = [...selectedAnswerIds, answerId]; } }); }
function setAnswerSelectionFromInteraction(answerId: string, selected: boolean) { skipped = false; updateAnswerSelected(answerId, selected); }
function syncControlledAnswerSelection(answerId: string, selected: boolean) { if (selected) skipped = false; updateAnswerSelected(answerId, selected); }
function registerAnswerSelection(answerId: string, defaultSelected: boolean) { return untrack(() => { if (defaultSelected) { defaultSelectedAnswerIds = [ ...defaultSelectedAnswerIds.filter((c) => c !== answerId), answerId, ]; if (!multiple) { if (!selectedAnswerIds.length) selectedAnswerIds = [answerId]; } else if (!selectedAnswerIds.includes(answerId)) { selectedAnswerIds = [...selectedAnswerIds, answerId]; } } return () => { defaultSelectedAnswerIds = defaultSelectedAnswerIds.filter((c) => c !== answerId); selectedAnswerIds = selectedAnswerIds.filter((c) => c !== answerId); }; }); }
function setAnswerDefault(answerId: string, defaultSelected: boolean) { return untrack(() => { if (defaultSelected) { if (!defaultSelectedAnswerIds.includes(answerId)) { defaultSelectedAnswerIds = [...defaultSelectedAnswerIds, answerId]; } return; } defaultSelectedAnswerIds = defaultSelectedAnswerIds.filter((c) => c !== answerId); }); }
/** Selecting a choice clears the native checked state of its siblings, so every control re-syncs. */ function requestControlSync() { controlSyncVersion += 1; }
function registerAnswerControl(registration: AnswerControlRegistration) { return untrack(() => { answerControls = [ ...answerControls.filter( (c) => c.element !== registration.element && c.id !== registration.id ), registration, ]; return () => { answerControls = answerControls.filter((c) => c !== registration); }; }); }
function registerId(kind: "description" | "error" | "title", id: string) { return untrack(() => { const add = (list: string[]) => (list.includes(id) ? list : [...list, id]); const remove = (list: string[]) => list.filter((c) => c !== id); if (kind === "description") descriptionIds = add(descriptionIds); else if (kind === "error") errorIds = add(errorIds); else titleIds = add(titleIds); return () => { if (kind === "description") descriptionIds = remove(descriptionIds); else if (kind === "error") errorIds = remove(errorIds); else titleIds = remove(titleIds); }; }); }
function validate() { validationAttempted = true; if (!valid) return false; if (!root.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; }
function focus() { ref?.focus(); }
function focusInvalid() { const selectedInput = ref?.querySelector<HTMLInputElement>( "input[data-filled][name]:not(:disabled)" ); const firstControl = ref?.querySelector<HTMLElement>( "input:not([type=hidden]):not(:disabled), textarea:not(:disabled)" ); (selectedInput ?? firstControl ?? ref)?.focus(); }
function reset() { validationAttempted = false; skipped = false; selectedAnswerIds = multiple ? [...defaultSelectedAnswerIds] : defaultSelectedAnswerIds.slice(0, 1); resetVersion += 1; }
function skip() { if (required) return; selectedAnswerIds = []; skipped = true; }
function getAnswerByElement(element: Element) { return answers.find((answer) => answer.element === element) ?? null; }
function getAnswerByShortcut(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; }
function moveAnswerFocus(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 !== ref) ) { 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;
// Radio groups already move focus with the arrow keys. 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; }
let previousStatus: QuestionnaireItemStatus | null = null; $effect(() => { const next = status; if (previousStatus !== null && previousStatus !== next) onStatusChange?.(next); previousStatus = next; });
let previousMultiple = untrack(() => multiple); $effect(() => { const next = multiple; if (previousMultiple && !next) { const selectedAnswer = untrack(() => answers.find((answer) => selectedAnswerIds.includes(answer.id)) ); selectedAnswerIds = selectedAnswer ? [selectedAnswer.id] : []; } previousMultiple = next; });
$effect(() => { const element = ref; const itemName = name; if (!element) return; return root.registerItem({ element, focus, focusInvalid, getAnswerByElement, getAnswerByShortcut, getChoices: () => orderedAnswerControls.flatMap((answer) => answer.type === "choice" ? [{ disabled: answer.ownDisabled, value: answer.value }] : [] ), isDisabled: () => disabled, isRequired: () => required, moveAnswerFocus, name: itemName, reset, skip, status: () => status, validate, }); });
setQuestionnaireItemContext({ get active() { return active; }, get controlSyncVersion() { return controlSyncVersion; }, get disabled() { return disabled; }, get hasInputAnswer() { return hasInputAnswer; }, get invalid() { return invalid; }, get multiple() { return multiple; }, get name() { return name; }, get required() { return required; }, get resetVersion() { return resetVersion; }, get selectedAnswerIds() { return selectedAnswerIds; }, get shortcutByAnswerId() { return shortcutByAnswerId; }, get shortcutByChoiceValue() { return shortcutByChoiceValue; }, get shortcuts() { return root.shortcuts; }, get status() { return status; }, registerAnswerControl, registerAnswerSelection, registerDescription: (id) => registerId("description", id), registerError: (id) => registerId("error", id), registerTitle: (id) => registerId("title", id), requestControlSync, setAnswerDefault, setAnswerSelectionFromInteraction, syncControlledAnswerSelection, });</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_role_supports_aria_props_implicit --><fieldset bind:this={ref} data-slot="questionnaire-item" aria-describedby={describedBy} aria-invalid={invalid || undefined} aria-keyshortcuts={keyShortcuts} aria-labelledby={labelledBy} data-active={active ? "" : undefined} data-disabled={disabled ? "" : undefined} data-invalid={invalid ? "" : undefined} data-multiple={multiple ? "" : undefined} data-required={required ? "" : undefined} data-status={status} {disabled} hidden={!active} inert={!active} tabindex={-1} class={cn("flex min-w-0 flex-col gap-4 border-0 p-0 outline-none [&[hidden]]:hidden", className)} {...restProps}> {@render children?.({ active, invalid, status })}</fieldset><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLButtonAttributes } from "svelte/elements"; import { buttonVariants, type ButtonSize, type ButtonVariant } from "$lib/registry/ui/button/index.js"; import { getQuestionnaireRootContext } from "./use-questionnaire.svelte.js";
let { ref = $bindable(null), class: className, size = "default", variant = "default", disabled = false, onclick, children, ...restProps }: WithElementRef<HTMLButtonAttributes, HTMLButtonElement> & { size?: ButtonSize; variant?: ButtonVariant; } = $props();
const root = getQuestionnaireRootContext(); const visible = $derived(root.total > 1 && !root.last); const shortcut = $derived(visible && !disabled ? "Enter" : null);
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) { onclick?.(event); if (disabled) { event.preventDefault(); return; } if (!event.defaultPrevented) root.goNext(); }</script>
<button bind:this={ref} data-slot="questionnaire-next" type="button" aria-hidden={!visible || undefined} aria-disabled={disabled || undefined} aria-keyshortcuts={shortcut ?? undefined} data-shortcut={shortcut ?? undefined} data-disabled={disabled ? "" : undefined} data-hidden={visible ? undefined : ""} data-size={size} data-status={root.activeItemStatus ?? undefined} data-variant={variant} data-visible={visible ? "" : undefined} {disabled} hidden={!visible} inert={!visible} tabindex={visible ? undefined : -1} class={cn(buttonVariants({ size, variant }), "col-start-3 row-start-1 justify-self-end [&[hidden]]:hidden", className)} onclick={handleClick} {...restProps}> {#if children} {@render children()} {:else} Next {/if}</button><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLButtonAttributes } from "svelte/elements"; import { buttonVariants, type ButtonSize, type ButtonVariant } from "$lib/registry/ui/button/index.js"; import { getQuestionnaireRootContext } from "./use-questionnaire.svelte.js";
let { ref = $bindable(null), class: className, size = "default", variant = "ghost", disabled = false, onclick, children, ...restProps }: WithElementRef<HTMLButtonAttributes, HTMLButtonElement> & { size?: ButtonSize; variant?: ButtonVariant; } = $props();
const root = getQuestionnaireRootContext(); const visible = $derived(root.total > 1 && !root.first);
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) { onclick?.(event); if (disabled) { event.preventDefault(); return; } if (!event.defaultPrevented) root.goPrevious(); }</script>
<button bind:this={ref} data-slot="questionnaire-previous" type="button" aria-hidden={!visible || undefined} aria-disabled={disabled || undefined} data-disabled={disabled ? "" : undefined} data-hidden={visible ? undefined : ""} data-size={size} data-status={root.activeItemStatus ?? undefined} data-variant={variant} data-visible={visible ? "" : undefined} {disabled} hidden={!visible} inert={!visible} tabindex={visible ? undefined : -1} class={cn(buttonVariants({ size, variant }), "col-start-1 row-start-1 justify-self-start [&[hidden]]:hidden", className)} onclick={handleClick} {...restProps}> {#if children} {@render children()} {:else} Previous {/if}</button><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements"; import type { Snippet } from "svelte"; import { getQuestionnaireRootContext } from "./use-questionnaire.svelte.js";
let { ref = $bindable(null), class: className, children, ...restProps }: Omit<WithElementRef<HTMLAttributes<HTMLDivElement>>, "children"> & { children?: Snippet<[{ current: number; first: boolean; last: boolean; total: number }]>; } = $props();
const root = getQuestionnaireRootContext(); const label = $derived(root.total ? `Question ${root.current} of ${root.total}` : undefined);</script>
<div bind:this={ref} aria-label="Questionnaire progress" aria-live="polite" data-slot="questionnaire-progress" role="progressbar" aria-valuemax={root.total || undefined} aria-valuemin={root.total ? 1 : undefined} aria-valuenow={root.total ? root.current : undefined} aria-valuetext={label} data-current={root.current} data-first={root.first ? "" : undefined} data-last={root.last ? "" : undefined} data-total={root.total} class={cn( "min-h-[1lh] w-fit min-w-[14ch] font-mono text-[11.5px] text-muted-foreground tabular-nums", className )} {...restProps}> {#if children} {@render children({ current: root.current, first: root.first, last: root.last, total: root.total, })} {:else} {label} {/if}</div><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLButtonAttributes } from "svelte/elements"; import { buttonVariants, type ButtonSize, type ButtonVariant } from "$lib/registry/ui/button/index.js"; import { getQuestionnaireRootContext } from "./use-questionnaire.svelte.js";
let { ref = $bindable(null), class: className, size = "default", variant = "outline", disabled = false, onclick, children, ...restProps }: WithElementRef<HTMLButtonAttributes, HTMLButtonElement> & { size?: ButtonSize; variant?: ButtonVariant; } = $props();
const root = getQuestionnaireRootContext(); const visible = $derived(root.activeItemRequired === false);
function handleClick(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) { onclick?.(event); if (disabled) { event.preventDefault(); return; } if (!event.defaultPrevented) root.skipCurrent(); }</script>
<button bind:this={ref} data-slot="questionnaire-skip" type="button" aria-hidden={!visible || undefined} aria-disabled={disabled || undefined} data-disabled={disabled ? "" : undefined} data-hidden={visible ? undefined : ""} data-size={size} data-status={root.activeItemStatus ?? undefined} data-variant={variant} data-visible={visible ? "" : undefined} {disabled} hidden={!visible} inert={!visible} tabindex={visible ? undefined : -1} class={cn(buttonVariants({ size, variant }), "col-start-2 row-start-1 justify-self-end [&[hidden]]:hidden", className)} onclick={handleClick} {...restProps}> {#if children} {@render children()} {:else} Skip {/if}</button><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLButtonAttributes } from "svelte/elements"; import { buttonVariants, type ButtonSize, type ButtonVariant } from "$lib/registry/ui/button/index.js"; import { getQuestionnaireRootContext } from "./use-questionnaire.svelte.js";
let { ref = $bindable(null), class: className, size = "default", variant = "default", disabled = false, onclick, children, ...restProps }: WithElementRef<HTMLButtonAttributes, HTMLButtonElement> & { size?: ButtonSize; variant?: ButtonVariant; } = $props();
const root = getQuestionnaireRootContext(); const visible = $derived(root.total > 0 && root.last); const shortcut = $derived(visible && !disabled ? "Enter" : null);</script>
<button bind:this={ref} data-slot="questionnaire-submit" type="submit" aria-hidden={!visible || undefined} aria-disabled={disabled || undefined} aria-keyshortcuts={shortcut ?? undefined} data-shortcut={shortcut ?? undefined} data-disabled={disabled ? "" : undefined} data-hidden={visible ? undefined : ""} data-size={size} data-status={root.activeItemStatus ?? undefined} data-variant={variant} data-visible={visible ? "" : undefined} {disabled} hidden={!visible} inert={!visible} tabindex={visible ? undefined : -1} class={cn(buttonVariants({ size, variant }), "col-start-3 row-start-1 justify-self-end [&[hidden]]:hidden", className)} {...restProps}> {#if children} {@render children()} {:else} Submit {/if}</button><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements";
let { ref = $bindable(null), class: className, children, ...restProps }: WithElementRef<HTMLAttributes<HTMLLegendElement>> = $props();</script>
<!-- The fieldset legend already names the item. --><legend bind:this={ref} data-slot="questionnaire-title" class={cn( "text-[17px] leading-snug font-semibold tracking-[-0.2px] text-pretty [&:not(:has(~[data-slot=questionnaire-description]))]:mb-4", className )} {...restProps}> {@render children?.()}</legend><script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLFormAttributes } from "svelte/elements"; import { onMount, untrack, type Snippet } from "svelte"; import type { Elevation } from "$lib/registry/ui/elevation/index.js"; import { type ItemRegistration, type QuestionnaireItemDefinition, type QuestionnaireItemStatus, type QuestionnaireShortcutMode, compareDocumentOrder, createQuestionnaireCollection, getInitialItemName, getShortcutFromKey, isAnswerFilled, isRadioTarget, isTextEntryTarget, setQuestionnaireRootContext, } from "./use-questionnaire.svelte.js";
let { ref = $bindable(null), class: className, /** Item shown first. Ignored when `item` is provided. */ defaultItem = undefined, /** Active item. Use with `bind:item`. */ item = $bindable(undefined), /** Declares the item order, and the choice order shortcuts are assigned in. */ items = undefined, /** Set to `false` to run native constraint validation on answered items. */ noValidate = true, /** Assigns a keyboard shortcut to every choice. */ shortcuts = undefined, /** ✦ depth of every option and input: sunken -1, flat 0, raised +1, floating +2. */ elevation = "auto", onItemChange = undefined, onsubmit, onreset, children, ...restProps }: Omit<WithElementRef<HTMLFormAttributes, HTMLFormElement>, "children" | "onsubmit" | "onreset"> & { defaultItem?: string; item?: string; items?: readonly QuestionnaireItemDefinition[]; noValidate?: boolean; shortcuts?: QuestionnaireShortcutMode; elevation?: Elevation; onItemChange?: (item: string) => void; onsubmit?: (event: SubmitEvent) => void; onreset?: (event: Event) => void; children?: Snippet<[{ current: number; first: boolean; last: boolean; total: number }]>; } = $props();
interface PendingFocus { name: string; target: "invalid" | "item"; }
let registrations = $state.raw<ItemRegistration[]>([]); let domVersion = $state(0); let pendingFocus: PendingFocus | null = null;
const collection = $derived(createQuestionnaireCollection(items)); let uncontrolledItem = $state<string | null>( untrack(() => getInitialItemName(createQuestionnaireCollection(items), defaultItem)) ); const activeItemName = $derived(item !== undefined ? item : uncontrolledItem); const resolvedShortcuts = $derived(shortcuts ?? null);
const runtimeItems = $derived.by(() => { // Re-sort whenever items are added to or removed from the DOM. void domVersion; return registrations .filter((registration) => !registration.isDisabled()) .sort((first, second) => compareDocumentOrder(first.element, second.element)); }); const runtimeItemByName = $derived(new Map(runtimeItems.map((r) => [r.name, r]))); /** `items` is authoritative when provided, so items can be declared before they render. */ const logicalItems = $derived<readonly { name: string }[]>( collection?.enabledItems ?? runtimeItems ); const currentIndex = $derived(logicalItems.findIndex((l) => l.name === activeItemName)); const activeItem = $derived( currentIndex < 0 || !activeItemName ? null : (runtimeItemByName.get(activeItemName) ?? null) ); const activeDefinition = $derived( activeItemName ? collection?.itemByName.get(activeItemName) : undefined ); const activeItemRequired = $derived.by(() => { if (currentIndex < 0) return null; return activeDefinition ? Boolean(activeDefinition.required) : (activeItem?.isRequired() ?? false); }); const activeItemStatus = $derived.by<QuestionnaireItemStatus | null>(() => { if (currentIndex < 0) return null; return activeItem?.status() ?? (activeItemName ? "unanswered" : null); }); const orderedRegistrations = $derived.by(() => { if (!collection) return runtimeItems; return collection.enabledItems.flatMap((definition) => { const registration = runtimeItemByName.get(definition.name); return registration ? [registration] : []; }); }); const total = $derived(logicalItems.length); const current = $derived(currentIndex < 0 ? 0 : currentIndex + 1); const first = $derived(total > 0 && currentIndex === 0); const last = $derived(total > 0 && currentIndex === total - 1);
function setItem(nextItem: string, focusTarget: PendingFocus["target"] = "item") { if (nextItem === activeItemName) return; pendingFocus = { name: nextItem, target: focusTarget }; uncontrolledItem = nextItem; item = nextItem; onItemChange?.(nextItem); }
function registerItem(registration: ItemRegistration) { registrations = untrack(() => [ ...registrations.filter( (r) => r.element !== registration.element && r.name !== registration.name ), registration, ]); return () => { registrations = registrations.filter((r) => r !== registration); }; }
function setItemAt(index: number, focusTarget: PendingFocus["target"] = "item") { const nextItem = logicalItems[index]; if (nextItem) setItem(nextItem.name, focusTarget); }
function goPrevious() { if (currentIndex <= 0) return; setItemAt(currentIndex - 1); }
function goNext() { if (!activeItem || currentIndex >= total - 1) return; if (!activeItem.validate()) { activeItem.focusInvalid(); return; } setItemAt(currentIndex + 1); }
function confirmCurrent() { if (!activeItem) return; if (!activeItem.validate()) { activeItem.focusInvalid(); return; } if (last) { ref?.requestSubmit(); return; } setItemAt(currentIndex + 1); }
function skipCurrent() { if (!activeItem || activeItem.isRequired()) return; activeItem.skip(); if (!last) { setItemAt(currentIndex + 1); return; } queueMicrotask(() => ref?.requestSubmit()); }
function handleReset(event: Event) { onreset?.(event); if (event.defaultPrevented) return; for (const registration of registrations) registration.reset(); const resetItemName = collection ? getInitialItemName(collection, defaultItem) : (runtimeItems.find((r) => r.name === defaultItem)?.name ?? runtimeItems[0]?.name); if (resetItemName) setItem(resetItemName); }
function handleSubmit(event: SubmitEvent) { const firstInvalidItem = orderedRegistrations.find((r) => !r.validate()); if (firstInvalidItem) { event.preventDefault(); setItem(firstInvalidItem.name, "invalid"); if (firstInvalidItem.name === activeItemName) { firstInvalidItem.focusInvalid(); pendingFocus = null; } return; } onsubmit?.(event); }
function handleKeydown(event: KeyboardEvent) { if ( event.defaultPrevented || event.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 (!resolvedShortcuts || isTextEntryTarget(event.target)) return;
const shortcut = getShortcutFromKey(event.key, resolvedShortcuts); 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(); }
let previousActiveItemName: string | null = untrack(() => activeItemName); $effect(() => { const name = activeItemName; const index = currentIndex; const count = total; const registration = activeItem; if (count === 0) return;
if (index < 0) { const firstItem = logicalItems[0]; if (!firstItem) return; if (item === undefined && name === null) { uncontrolledItem = firstItem.name; return; } setItem(firstItem.name); return; }
const focus = pendingFocus; const activeItemChanged = previousActiveItemName !== name; previousActiveItemName = name;
if (!focus || focus.name !== name) { if (item !== undefined && activeItemChanged) { pendingFocus = null; registration?.focus(); } return; }
if (focus.target === "invalid") registration?.focusInvalid(); else registration?.focus(); pendingFocus = null; });
onMount(() => { if (!ref || typeof MutationObserver === "undefined") return; const observer = new MutationObserver(() => { domVersion += 1; }); observer.observe(ref, { childList: true, subtree: true }); return () => observer.disconnect(); });
setQuestionnaireRootContext({ get elevation() { return elevation === "auto" ? undefined : elevation; }, get activeItem() { return activeItem; }, get activeItemName() { return activeItemName; }, get activeItemRequired() { return activeItemRequired; }, get activeItemStatus() { return activeItemStatus; }, get current() { return current; }, get domVersion() { return domVersion; }, get first() { return first; }, get itemDefinitionByName() { return collection?.itemByName ?? null; }, get last() { return last; }, get nativeValidation() { return noValidate === false; }, get shortcuts() { return resolvedShortcuts; }, get total() { return total; }, goNext, goPrevious, registerItem, skipCurrent, });</script>
<form bind:this={ref} data-slot="questionnaire" data-shortcuts={resolvedShortcuts ?? undefined} novalidate={noValidate} class={cn("flex w-full min-w-0 flex-col gap-4", className)} onkeydown={handleKeydown} onreset={handleReset} onsubmit={handleSubmit} {...restProps}> {@render children?.({ current, first, last, total })}</form>import { getContext, setContext } from "svelte";import type { Elevation } from "$lib/registry/ui/elevation/index.js";
export type QuestionnaireItemStatus = "unanswered" | "answered" | "skipped";export type QuestionnaireShortcutMode = "letters" | "numbers";
export type QuestionnaireInputType = | "date" | "datetime-local" | "email" | "month" | "number" | "password" | "search" | "tel" | "text" | "time" | "url" | "week";
export interface QuestionnaireChoiceDefinition { disabled?: boolean; value: string;}
export interface QuestionnaireItemDefinition { choices?: readonly QuestionnaireChoiceDefinition[]; disabled?: boolean; name: string; required?: boolean;}
export interface ChoiceRegistration { disabled: boolean; value: string;}
export interface AnswerControlRegistration { disabled: boolean; element: HTMLInputElement; id: string; ownDisabled: boolean; type: "choice" | "input"; value: string;}
export interface ItemRegistration { element: HTMLFieldSetElement; focus: () => void; focusInvalid: () => void; getAnswerByElement: (element: Element) => AnswerControlRegistration | null; getAnswerByShortcut: (shortcut: string) => AnswerControlRegistration | null; getChoices: () => ChoiceRegistration[]; isDisabled: () => boolean; isRequired: () => boolean; moveAnswerFocus: ( element: Element, direction: "next" | "previous", ) => boolean; name: string; reset: () => void; skip: () => void; status: () => QuestionnaireItemStatus; validate: () => boolean;}
/** Live values are exposed as getters, so reading them inside `$derived` / templates is reactive. */export interface QuestionnaireRootContext { readonly activeItem: ItemRegistration | null; readonly activeItemName: string | null; readonly activeItemRequired: boolean | null; readonly activeItemStatus: QuestionnaireItemStatus | null; readonly current: number; readonly domVersion: number; readonly first: boolean; readonly itemDefinitionByName: Map< string, QuestionnaireItemDefinition > | null; readonly last: boolean; readonly nativeValidation: boolean; /** ✦ depth of every option and input (undefined = auto). */ readonly elevation: Elevation | undefined; readonly shortcuts: QuestionnaireShortcutMode | null; readonly total: number; goNext: () => void; goPrevious: () => void; registerItem: (registration: ItemRegistration) => () => void; skipCurrent: () => void;}
export interface QuestionnaireItemContext { readonly active: boolean; /** Bumped after a controlled answer interaction so every control re-syncs. */ readonly controlSyncVersion: number; readonly disabled: boolean; readonly hasInputAnswer: boolean; readonly invalid: boolean; readonly multiple: boolean; readonly name: string; readonly required: boolean; readonly resetVersion: number; readonly selectedAnswerIds: string[]; readonly shortcutByAnswerId: Map<string, string>; readonly shortcutByChoiceValue: Map<string, string> | null; readonly shortcuts: QuestionnaireShortcutMode | null; readonly status: QuestionnaireItemStatus; registerAnswerControl: ( registration: AnswerControlRegistration, ) => () => void; registerAnswerSelection: ( answerId: string, defaultSelected: boolean, ) => () => void; registerDescription: (descriptionId: string) => () => void; registerError: (errorId: string) => () => void; /** Only used when the title does not render as the fieldset legend. */ registerTitle: (titleId: string) => () => void; requestControlSync: () => void; setAnswerDefault: (answerId: string, defaultSelected: boolean) => void; setAnswerSelectionFromInteraction: ( answerId: string, selected: boolean, ) => void; syncControlledAnswerSelection: (answerId: string, selected: boolean) => void;}
const ROOT_KEY = Symbol("QuestionnaireRoot");const ITEM_KEY = Symbol("QuestionnaireItem");
export function setQuestionnaireRootContext(context: QuestionnaireRootContext) { return setContext(ROOT_KEY, context);}
export function getQuestionnaireRootContext(): QuestionnaireRootContext { const context = getContext<QuestionnaireRootContext | undefined>(ROOT_KEY); if (!context) throw new Error("Questionnaire parts must be used within a Questionnaire."); return context;}
export function setQuestionnaireItemContext(context: QuestionnaireItemContext) { return setContext(ITEM_KEY, context);}
export function getQuestionnaireItemContext(): QuestionnaireItemContext { const context = getContext<QuestionnaireItemContext | undefined>(ITEM_KEY); if (!context) throw new Error( "Questionnaire answer parts must be used within a QuestionnaireItem.", ); return context;}
export 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 );}
export 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 [];}
export function getShortcutFromKey( key: string, shortcuts: QuestionnaireShortcutMode,) { const normalizedKey = shortcuts === "letters" ? key.toUpperCase() : key;
return getShortcutKeys(shortcuts).includes(normalizedKey) ? normalizedKey : null;}
export function getAnswerKeyShortcuts( shortcut: string | null, filled: boolean,) { return ( [shortcut, filled ? "Enter" : null].filter(Boolean).join(" ") || undefined );}
export function isAnswerFilled(answer: AnswerControlRegistration) { if (answer.type === "choice") { return answer.element.checked; }
return ( answer.element.hasAttribute("name") && hasInputValue(answer.element.value) );}
export function isEmptyNavigableInput( answer: AnswerControlRegistration | null,) { return ( answer?.type === "input" && ["email", "password", "search", "tel", "text", "url"].includes( answer.element.type, ) && !hasInputValue(answer.element.value) );}
export 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;}
export function isRadioTarget(element: Element) { return element instanceof HTMLInputElement && element.type === "radio";}
/** * Sort registrations by the position of their element in the document, so that * navigation always follows the rendered order instead of the mount order. */export function compareDocumentOrder(first: Element, second: Element) { if (first === second) { return 0; }
const position = first.compareDocumentPosition(second);
if (position & Node.DOCUMENT_POSITION_FOLLOWING) { return -1; }
if (position & Node.DOCUMENT_POSITION_PRECEDING) { return 1; }
return 0;}
export interface QuestionnaireCollection { enabledItems: QuestionnaireItemDefinition[]; itemByName: Map<string, QuestionnaireItemDefinition>; items: readonly QuestionnaireItemDefinition[];}
export 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, };}
export 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;}
/** * Map every enabled choice of an item definition to a keyboard shortcut, so * that shortcuts stay stable regardless of how choices are rendered. */export function getShortcutByChoiceValue( item: QuestionnaireItemDefinition | undefined, shortcuts: QuestionnaireShortcutMode | null,) { const shortcutByChoiceValue = new Map<string, string>();
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;}import Root from "./questionnaire.svelte";import Actions from "./questionnaire-actions.svelte";import Choice from "./questionnaire-choice.svelte";import ChoiceDescription from "./questionnaire-choice-description.svelte";import Choices from "./questionnaire-choices.svelte";import Description from "./questionnaire-description.svelte";import QErr from "./questionnaire-error.svelte";import Input from "./questionnaire-input.svelte";import Item from "./questionnaire-item.svelte";import Next from "./questionnaire-next.svelte";import Previous from "./questionnaire-previous.svelte";import Progress from "./questionnaire-progress.svelte";import Skip from "./questionnaire-skip.svelte";import Submit from "./questionnaire-submit.svelte";import Title from "./questionnaire-title.svelte";
export type { QuestionnaireChoiceDefinition, QuestionnaireInputType, QuestionnaireItemDefinition, QuestionnaireItemStatus, QuestionnaireShortcutMode,} from "./use-questionnaire.svelte.js";export { Actions, Actions as QuestionnaireActions, Choice, Choice as QuestionnaireChoice, ChoiceDescription, ChoiceDescription as QuestionnaireChoiceDescription, Choices, Choices as QuestionnaireChoices, Description, Description as QuestionnaireDescription, Input, Input as QuestionnaireInput, Item, Item as QuestionnaireItem, Next, Next as QuestionnaireNext, Previous, Previous as QuestionnairePrevious, Progress, Progress as QuestionnaireProgress, QErr as Error, QErr as QuestionnaireError, Root, // Root as Questionnaire, Skip, Skip as QuestionnaireSkip, Submit, Submit as QuestionnaireSubmit, Title, Title as QuestionnaireTitle,};Elevation ✦
Section titled “Elevation ✦”See the Elevation guide for the levels, roles and layered mode.
Flat by default. elevation on Questionnaire applies to every choice card and the text input: sunken (-1) wells, raised (+1) the bevel, floating (+2) bevel plus drop. A checked option keeps its ring.
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
import type { Elevation } from "@/components/ui/elevation";import { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle,} from "@/components/ui/questionnaire";import { useState } from "react";
const items = [ { name: "rebalance", choices: [{ value: "drift" }, { value: "schedule" }, { value: "sign" }], }, { name: "notes" },];
function Sample({ elevation }: { elevation: Elevation }) { const [done, setDone] = useState<string | null>(null); return ( <Questionnaire elevation={elevation} items={items} shortcuts="letters" className="w-full max-w-md rounded-2xl border border-border bg-card p-6" onSubmit={(e) => { e.preventDefault(); const data = new FormData(e.currentTarget); setDone(JSON.stringify(Object.fromEntries(data))); }} > <QuestionnaireProgress /> <QuestionnaireItem name="rebalance" required> <QuestionnaireTitle>How should the index rebalance?</QuestionnaireTitle> <QuestionnaireDescription> You can change this later in the mandate. </QuestionnaireDescription> <QuestionnaireChoices> <QuestionnaireChoice value="drift"> When any weight drifts past a limit </QuestionnaireChoice> <QuestionnaireChoice value="schedule"> On a fixed schedule </QuestionnaireChoice> <QuestionnaireChoice value="sign"> Only when I sign </QuestionnaireChoice> </QuestionnaireChoices> <QuestionnaireInput placeholder="Other…" /> </QuestionnaireItem> <QuestionnaireItem name="notes"> <QuestionnaireTitle>Any notes for the desk?</QuestionnaireTitle> <QuestionnaireInput placeholder="Add a note…" /> </QuestionnaireItem> <QuestionnaireActions> <QuestionnairePrevious /> <QuestionnaireSkip /> <QuestionnaireNext /> <QuestionnaireSubmit /> </QuestionnaireActions> {done ? ( <p className="font-mono text-xs text-muted-foreground">{done}</p> ) : null} </Questionnaire> );}
const levels = [ { value: "sunken", label: "Sunken (-1)" }, { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" },] as const;
export default function Demo() { return ( <div className="flex flex-col gap-6"> {levels.map(({ value, label }) => ( <div key={value} className="flex flex-col gap-2"> <p className="text-xs font-medium text-muted-foreground">{label}</p> <Sample elevation={value} /> </div> ))} </div> );}<script setup lang="ts">import { Questionnaire, QuestionnaireChoice, QuestionnaireChoices, QuestionnaireInput, QuestionnaireItem, QuestionnaireTitle,} from "@/components/ui/questionnaire";
const levels = [ { value: "sunken", label: "Sunken (-1)" } as const, { value: "flat", label: "Flat (0)" } as const, { value: "raised", label: "Raised (+1)" } as const, { value: "floating", label: "Floating (+2)" } as const,];</script>
<template> <div class="flex flex-col gap-6"> <div v-for="l in levels" :key="l.value" class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{{ l.label }}</p> <Questionnaire :elevation="l.value" shortcuts="letters" class="w-full max-w-md rounded-2xl border border-border bg-card p-6"> <QuestionnaireItem name="rebalance"> <QuestionnaireTitle>How should the index rebalance?</QuestionnaireTitle> <QuestionnaireChoices> <QuestionnaireChoice value="drift">When any weight drifts past a limit</QuestionnaireChoice> <QuestionnaireChoice value="schedule">On a fixed schedule</QuestionnaireChoice> </QuestionnaireChoices> <QuestionnaireInput placeholder="Other…" /> </QuestionnaireItem> </Questionnaire> </div> </div></template><script lang="ts"> import * as Questionnaire from "$lib/components/ui/questionnaire";
const items = [{ name: "rebalance", choices: [{ value: "drift" }, { value: "schedule" }, { value: "sign" }] }];
function onsubmit(event: SubmitEvent) { event.preventDefault(); }
const levels = [ { value: "sunken", label: "Sunken (-1)" } as const, { value: "flat", label: "Flat (0)" } as const, { value: "raised", label: "Raised (+1)" } as const, { value: "floating", label: "Floating (+2)" } as const, ];</script>
<div class="flex flex-col gap-6"> {#each levels as l (l.value)} <div class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{l.label}</p><div class="grid w-full max-w-3xl gap-5 md:grid-cols-2"> <Questionnaire.Root {items} elevation={l.value} shortcuts="letters" class="rounded-2xl border border-border bg-card p-5" {onsubmit} > <Questionnaire.Item name="rebalance" required> <Questionnaire.Title>How should the index rebalance?</Questionnaire.Title> <Questionnaire.Choices> <Questionnaire.Choice value="drift">On drift</Questionnaire.Choice> <Questionnaire.Choice value="schedule">On a schedule</Questionnaire.Choice> <Questionnaire.Choice value="sign">When I sign</Questionnaire.Choice> </Questionnaire.Choices> <Questionnaire.Input placeholder="Other…" /> </Questionnaire.Item> </Questionnaire.Root></div> </div> {/each}</div>| Part | Prop | Type | Default |
|---|---|---|---|
| Questionnaire | items | { name, choices?, required? }[] |
none |
| Questionnaire | shortcuts | letters | numbers |
none |
| Questionnaire | onSubmit | form event handler | none |
| QuestionnaireItem | name, multiple, required, invalid | ||
| QuestionnaireChoice | value | string |
required |
| QuestionnairePrevious, Skip, Next, Submit | size, variant | Button props | |
| Questionnaire | elevation | "auto" | "sunken" | "flat" | "raised" | "floating" |
"auto" |
Put a QuestionnaireInput inside an item next to its choices for an “Other: ____” answer: typing selects the input and clears the choices, picking a choice selects it again (the typed text is kept but no longer submitted).
Built on @shadcn/react/questionnaire.
✦ Edmi additions
Section titled “✦ Edmi additions”- Choices and the freeform input share one 48px row height. The shortcut key sits on the left (brand when selected) and the selected row gets a ring.
- Previous is a ghost button, matching the board.