Skip to content
edmi-ui

Questionnaire

Multi-step questions with single, multiple, freeform and skippable answers and keyboard shortcuts.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Question 1 of 2
How should the index rebalance?

You can change this later in the mandate.

Question 1 of 2
How should the index rebalance?

You can change this later in the mandate.

Question 1 of 2
How should the index rebalance?

You can change this later in the mandate.

Terminal window
npx shadcn@latest add @edmi-ui/questionnaire
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/questionnaire
Terminal window
yarn dlx shadcn@latest add @edmi-ui/questionnaire
Terminal window
bunx shadcn@latest add @edmi-ui/questionnaire

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/questionnaire
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/questionnaire
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/questionnaire
Terminal window
bunx shadcn-vue@latest add @edmi-ui/questionnaire

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/questionnaire.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/questionnaire.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/questionnaire.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/questionnaire.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

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>
registry/ui/questionnaire.tsx
"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,
};
registry/ui/questionnaire/Questionnaire.vue
<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>
registry/ui/questionnaire/QuestionnaireActions.vue
<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>
registry/ui/questionnaire/QuestionnaireChoice.vue
<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>
registry/ui/questionnaire/QuestionnaireChoiceDescription.vue
<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>
registry/ui/questionnaire/QuestionnaireChoices.vue
<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>
registry/ui/questionnaire/QuestionnaireDescription.vue
<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>
registry/ui/questionnaire/QuestionnaireError.vue
<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>
registry/ui/questionnaire/QuestionnaireInput.vue
<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>
registry/ui/questionnaire/QuestionnaireItem.vue
<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>
registry/ui/questionnaire/QuestionnaireNext.vue
<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>
registry/ui/questionnaire/QuestionnairePrevious.vue
<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>
registry/ui/questionnaire/QuestionnaireProgress.vue
<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>
registry/ui/questionnaire/QuestionnaireSkip.vue
<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>
registry/ui/questionnaire/QuestionnaireSubmit.vue
<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>
registry/ui/questionnaire/QuestionnaireTitle.vue
<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>
registry/ui/questionnaire/useQuestionnaire.ts
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;
}
registry/ui/questionnaire/index.ts
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";
src/lib/registry/ui/questionnaire/questionnaire-actions.svelte
<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>
src/lib/registry/ui/questionnaire/questionnaire-choice-description.svelte
<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>
src/lib/registry/ui/questionnaire/questionnaire-choice.svelte
<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>
src/lib/registry/ui/questionnaire/questionnaire-choices.svelte
<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>
src/lib/registry/ui/questionnaire/questionnaire-description.svelte
<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>
src/lib/registry/ui/questionnaire/questionnaire-error.svelte
<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>
src/lib/registry/ui/questionnaire/questionnaire-input.svelte
<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>
src/lib/registry/ui/questionnaire/questionnaire-item.svelte
<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>
src/lib/registry/ui/questionnaire/questionnaire-next.svelte
<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>
src/lib/registry/ui/questionnaire/questionnaire-previous.svelte
<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>
src/lib/registry/ui/questionnaire/questionnaire-progress.svelte
<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>
src/lib/registry/ui/questionnaire/questionnaire-skip.svelte
<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>
src/lib/registry/ui/questionnaire/questionnaire-submit.svelte
<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>
src/lib/registry/ui/questionnaire/questionnaire-title.svelte
<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>
src/lib/registry/ui/questionnaire/questionnaire.svelte
<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>
src/lib/registry/ui/questionnaire/use-questionnaire.svelte.ts
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;
}
src/lib/registry/ui/questionnaire/index.ts
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,
};

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.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Sunken (-1)

Question 1 of 2
How should the index rebalance?

You can change this later in the mandate.

Flat (0)

Question 1 of 2
How should the index rebalance?

You can change this later in the mandate.

Raised (+1)

Question 1 of 2
How should the index rebalance?

You can change this later in the mandate.

Floating (+2)

Question 1 of 2
How should the index rebalance?

You can change this later in the mandate.

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Sunken (-1)

How should the index rebalance?

Flat (0)

How should the index rebalance?

Raised (+1)

How should the index rebalance?

Floating (+2)

How should the index rebalance?
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.

  • 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.