Questionnaire
questionnaireuiSingle multi-step question component. Pass items, shortcuts, and showProgress — variants are props, not extra files.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/questionnaire.json$npx shadcn@latest add https://uipkge.dev/r/react/questionnaire.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/questionnaire.json$bunx shadcn@latest add https://uipkge.dev/r/react/questionnaire.jsonNamed registry:
npx shadcn@latest add @uipkge-react/questionnaireInstalls to:components/ui/questionnaire/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
items | QuestionnaireItemDef[] | — | optional |
shortcuts | QuestionnaireShortcuts | — | optional |
showProgress | boolean | — | optional |
requiredMessage | string | — | optional |
value | string | — | optional |
defaultValue | string | — | optional |
onValueChange | (name: string) => void | — | optional |
answers | QuestionnaireAnswers | — | optional |
defaultAnswers | QuestionnaireAnswers | — | optional |
onAnswersChange | (answers: QuestionnaireAnswers) => void | — | optional |
onSubmit | (answers: QuestionnaireAnswers) => void | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
QuestionnaireChoiceDefinterface QuestionnaireChoiceDef {
value: string
label: string
description?: string
disabled?: boolean
}QuestionnaireItemDefinterface QuestionnaireItemDef {
name: string
prompt: string
description?: string
required?: boolean
multiple?: boolean
choices?: QuestionnaireChoiceDef[]
input?: { label?: string; placeholder?: string }
}npm dependencies
Includes
Files installed (4)
components/ui/questionnaire/questionnaire.tsx9.9 kB
'use client' import * as React from 'react' import { cn } from '@/lib/utils' import { Kbd } from '@/components/ui/kbd' import { questionnaireChoiceVariants, questionnaireInputVariants } from './questionnaire.variants' import type { QuestionnaireAnswers, QuestionnaireItemDef, QuestionnaireShortcuts, QuestionnaireValue } from './types' export interface QuestionnaireProps extends Omit<React.FormHTMLAttributes<HTMLFormElement>, 'onSubmit'> { items?: QuestionnaireItemDef[] shortcuts?: QuestionnaireShortcuts showProgress?: boolean requiredMessage?: string value?: string defaultValue?: string onValueChange?: (name: string) => void answers?: QuestionnaireAnswers defaultAnswers?: QuestionnaireAnswers onAnswersChange?: (answers: QuestionnaireAnswers) => void onSubmit?: (answers: QuestionnaireAnswers) => void } function Questionnaire({ items = [], shortcuts = false, showProgress = true, requiredMessage = 'Choose an answer to continue.', value, defaultValue, onValueChange, answers: answersProp, defaultAnswers, onAnswersChange, onSubmit, className, ...props }: QuestionnaireProps) { const [uncontrolledName, setUncontrolledName] = React.useState(defaultValue) const [uncontrolledAnswers, setUncontrolledAnswers] = React.useState<QuestionnaireAnswers>(defaultAnswers ?? {}) const [showError, setShowError] = React.useState(false) const names = items.map((i) => i.name) const answers = answersProp ?? uncontrolledAnswers const currentName = value ?? uncontrolledName ?? names[0] const activeIndex = Math.max(0, names.indexOf(currentName ?? '')) const current = items[activeIndex] const isFirst = activeIndex <= 0 const isLast = names.length === 0 || activeIndex >= names.length - 1 const progress = { current: names.length ? activeIndex + 1 : 0, total: Math.max(names.length, 1) } const setActive = (name: string) => { onValueChange?.(name) if (value === undefined) setUncontrolledName(name) setShowError(false) } const writeAnswers = (next: QuestionnaireAnswers) => { onAnswersChange?.(next) if (answersProp === undefined) setUncontrolledAnswers(next) } const isAnswered = (name: string) => { const v = answers[name] if (Array.isArray(v)) return v.length > 0 return typeof v === 'string' && v.trim().length > 0 } const setAnswer = (name: string, next: QuestionnaireValue | undefined) => { writeAnswers({ ...answers, [name]: next }) setShowError(false) } const toggleChoice = (name: string, choice: string, multiple?: boolean) => { if (multiple) { const cur = Array.isArray(answers[name]) ? [...(answers[name] as string[])] : [] setAnswer(name, cur.includes(choice) ? cur.filter((v) => v !== choice) : [...cur, choice]) return } setAnswer(name, choice) } const shortcutFor = (index: number) => { if (!shortcuts) return if (shortcuts === 'letters' && index < 26) return String.fromCharCode(65 + index) if (shortcuts === 'numbers' && index < 9) return String(index + 1) } const choiceChecked = (name: string, choice: string) => { const v = answers[name] if (Array.isArray(v)) return v.includes(choice) return v === choice } const goTo = (index: number) => { const name = names[index] if (name) setActive(name) } const goNext = () => { if (!current) return false if (current.required && !isAnswered(current.name)) { setShowError(true) return false } setShowError(false) if (isLast) { onSubmit?.(answers) return true } goTo(activeIndex + 1) return true } const skip = () => { if (!current || current.required) return setShowError(false) if (isLast) onSubmit?.(answers) else goTo(activeIndex + 1) } return ( <form data-uipkge="" data-slot="questionnaire" className={cn('flex w-full max-w-lg flex-col gap-4', className)} onSubmit={(e) => { e.preventDefault() goNext() }} onKeyDown={(e) => { if (e.defaultPrevented) return const target = e.target as HTMLElement const inField = target.closest('input, textarea, select') if (inField) { if (e.key === 'Enter' && target.tagName === 'INPUT') { e.preventDefault() goNext() } return } if (e.key === 'Enter') { e.preventDefault() goNext() return } if (!shortcuts || e.metaKey || e.ctrlKey || e.altKey || !current) return const values = current.choices?.map((c) => c.value) ?? [] let idx = -1 if (shortcuts === 'letters') idx = e.key.toLowerCase().charCodeAt(0) - 97 if (shortcuts === 'numbers') idx = Number(e.key) - 1 if (idx < 0 || idx >= values.length) return e.preventDefault() toggleChoice(current.name, values[idx], current.multiple) }} {...props} > {showProgress ? ( <div data-slot="questionnaire-progress" role="progressbar" aria-valuemin={1} aria-valuenow={progress.current} aria-valuemax={progress.total} aria-label={`Question ${progress.current} of ${progress.total}`} className="flex flex-col gap-2" > <div className="bg-muted h-1 w-full overflow-hidden rounded-full"> <div className="bg-primary h-full rounded-full transition-[width] duration-200" style={{ width: `${(progress.current / progress.total) * 100}%` }} /> </div> <p className="text-muted-foreground text-xs"> Question {progress.current} of {progress.total} </p> </div> ) : null} {current ? ( <fieldset data-slot="questionnaire-item" name={current.name} className="flex min-w-0 flex-col gap-3"> <legend data-slot="questionnaire-title" className="text-foreground text-base font-medium"> {current.prompt} </legend> {current.description ? ( <p data-slot="questionnaire-description" className="text-muted-foreground text-sm"> {current.description} </p> ) : null} {current.choices?.length ? ( <div data-slot="questionnaire-choices" role="group" className="flex flex-col gap-2"> {current.choices.map((choice, index) => ( <label key={choice.value} data-slot="questionnaire-choice" data-checked={choiceChecked(current.name, choice.value) ? '' : undefined} className={cn(questionnaireChoiceVariants(), choice.disabled && 'pointer-events-none opacity-50')} > <input className="sr-only" type={current.multiple ? 'checkbox' : 'radio'} name={current.name} value={choice.value} checked={choiceChecked(current.name, choice.value)} disabled={choice.disabled} onChange={() => toggleChoice(current.name, choice.value, current.multiple)} /> <span className="flex min-w-0 flex-1 flex-col"> <span className="font-medium">{choice.label}</span> {choice.description ? ( <span className="text-muted-foreground text-xs">{choice.description}</span> ) : null} </span> {shortcutFor(index) ? ( <Kbd data-slot="questionnaire-choice-shortcut" className="ml-auto"> {shortcutFor(index)} </Kbd> ) : null} </label> ))} </div> ) : null} {current.input ? ( <input data-slot="questionnaire-input" type="text" name={current.name} placeholder={current.input.placeholder} value={typeof answers[current.name] === 'string' ? (answers[current.name] as string) : ''} className={cn(questionnaireInputVariants())} onChange={(e) => setAnswer(current.name, e.target.value)} /> ) : null} {showError && current.required && !isAnswered(current.name) ? ( <p data-slot="questionnaire-error" role="alert" className="text-destructive text-sm"> {requiredMessage} </p> ) : null} </fieldset> ) : null} <div data-slot="questionnaire-actions" className="flex flex-wrap items-center gap-2"> <button type="button" data-slot="questionnaire-previous" className="border-border bg-background hover:bg-accent inline-flex h-9 items-center rounded-md border px-4 text-sm font-medium disabled:opacity-50" disabled={isFirst} onClick={() => { if (!isFirst) goTo(activeIndex - 1) }} > Previous </button> {current && !current.required ? ( <button type="button" data-slot="questionnaire-skip" className="hover:bg-accent inline-flex h-9 items-center rounded-md px-4 text-sm font-medium" onClick={skip} > Skip </button> ) : null} {!isLast ? ( <button type="button" data-slot="questionnaire-next" className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-9 items-center rounded-md px-4 text-sm font-medium" onClick={goNext} > Next </button> ) : ( <button type="submit" data-slot="questionnaire-submit" className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-9 items-center rounded-md px-4 text-sm font-medium" > Submit </button> )} </div> </form> ) } export { Questionnaire }components/ui/questionnaire/types.ts0.5 kB
export type QuestionnaireValue = string | string[] export type QuestionnaireAnswers = Record<string, QuestionnaireValue | undefined> export interface QuestionnaireChoiceDef { value: string label: string description?: string disabled?: boolean } export interface QuestionnaireItemDef { name: string prompt: string description?: string required?: boolean multiple?: boolean choices?: QuestionnaireChoiceDef[] input?: { label?: string; placeholder?: string } } export type QuestionnaireShortcuts = 'letters' | 'numbers' | falsecomponents/ui/questionnaire/questionnaire.variants.ts1.1 kB
import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' /** * Variant definitions live in their own file (rather than the package * `index.ts`) so consuming Vue SFCs can import without creating a circular * dependency through the index. See card.variants.ts for the canonical * example + the SSR symptom that motivated the split. */ export const questionnaireChoiceVariants = cva( 'border-border bg-card hover:bg-accent/50 has-[:focus-visible]:border-ring has-[:focus-visible]:ring-ring/50 has-[:checked]:border-primary has-[:checked]:bg-accent flex cursor-pointer items-start gap-3 rounded-lg border p-3 text-left text-sm transition-colors has-[:disabled]:pointer-events-none has-[:disabled]:opacity-50 has-[:focus-visible]:ring-[3px]', ) export const questionnaireInputVariants = cva( 'border-input bg-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 h-9 w-full rounded-md border px-3 text-sm outline-none focus-visible:ring-[3px]', ) export type QuestionnaireChoiceVariants = VariantProps<typeof questionnaireChoiceVariants>components/ui/questionnaire/index.ts0.3 kB
export { Questionnaire, type QuestionnaireProps } from './questionnaire' export { questionnaireChoiceVariants, questionnaireInputVariants } from './questionnaire.variants' export type { QuestionnaireItemDef, QuestionnaireChoiceDef, QuestionnaireAnswers, QuestionnaireValue, QuestionnaireShortcuts, } from './types'
Raw manifest:https://uipkge.dev/r/react/questionnaire.json