{ "$schema": "https://blode.co/ui/schema/registry-item.json", "name": "ask-user-questions", "title": "Ask User Questions", "author": "Matthew Blode", "description": "A stepped questionnaire with single- or multi-select options, free-form answers, and skip.", "dependencies": ["motion"], "registryDependencies": ["button", "badge", "textarea"], "files": [ { "path": "ui/ask-user-questions.tsx", "content": "\"use client\";\n\nimport { ArrowLeftIcon, ArrowRightIcon } from \"blode-icons-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\n\nconst springs = {\n fast: { bounce: 0, duration: 0.08, type: \"spring\" as const },\n moderate: { bounce: 0.15, duration: 0.16, type: \"spring\" as const },\n slow: { bounce: 0.15, duration: 0.24, type: \"spring\" as const },\n};\n\ninterface AskUserOption {\n id: string;\n /** Bold option label. */\n title: string;\n /** Secondary descriptive text. */\n description?: string;\n}\n\ninterface AskUserQuestion {\n id: string;\n /** Question text. */\n title: string;\n options: AskUserOption[];\n /** Allow multiple selections (shows an explicit Next/Finish button). */\n multiSelect?: boolean;\n /** Reveal a textarea for a free-form answer. */\n allowOther?: boolean;\n otherPlaceholder?: string;\n /** Label for the Next button in multi-select mode. Defaults to \"Next\" / \"Finish\". */\n nextLabel?: string;\n /** Show the Skip control. Defaults to true. */\n skippable?: boolean;\n /** Description placement: inline with the title or stacked beneath it. */\n layout?: \"inline\" | \"stacked\";\n}\n\ninterface AskUserAnswer {\n questionId: string;\n selectedIds: string[];\n otherText?: string;\n skipped?: boolean;\n}\n\ntype AnswersMap = Record;\n\ninterface AskUserQuestionsProps extends Omit, \"onChange\"> {\n questions: AskUserQuestion[];\n currentIndex?: number;\n defaultCurrentIndex?: number;\n onCurrentIndexChange?: (index: number) => void;\n answers?: AnswersMap;\n defaultAnswers?: AnswersMap;\n onAnswersChange?: (answers: AnswersMap) => void;\n onComplete?: (answers: AnswersMap) => void;\n onSkip?: (questionId: string, index: number) => void;\n skipLabel?: string;\n}\n\nconst useControllable = (\n controlled: T | undefined,\n defaultValue: T,\n onChange?: (value: T) => void,\n): [T, (value: T) => void] => {\n const [uncontrolled, setUncontrolled] = React.useState(defaultValue);\n const isControlled = controlled !== undefined;\n const value = isControlled ? (controlled as T) : uncontrolled;\n const setValue = React.useCallback(\n (next: T) => {\n if (!isControlled) {\n setUncontrolled(next);\n }\n onChange?.(next);\n },\n [isControlled, onChange],\n );\n return [value, setValue];\n};\n\nconst getChipClassName = (isMulti: boolean, selected: boolean) => {\n if (isMulti) {\n return cn(\n \"rounded-full\",\n selected\n ? \"bg-foreground font-semibold text-background\"\n : \"border border-border text-muted-foreground\",\n );\n }\n return selected ? \"font-semibold text-foreground\" : \"text-muted-foreground\";\n};\n\ninterface AskUserOptionButtonProps {\n option: AskUserOption;\n index: number;\n selected: boolean;\n hovered: boolean;\n isMulti: boolean;\n layout: \"inline\" | \"stacked\";\n onSelect: () => void;\n onHover: () => void;\n}\n\nconst AskUserOptionButton = ({\n option,\n index,\n selected,\n hovered,\n isMulti,\n layout,\n onSelect,\n onHover,\n}: AskUserOptionButtonProps) => {\n const showArrow = !isMulti && hovered;\n return (\n \n {hovered && !selected && (\n \n )}\n {selected &&\n (isMulti ? (\n \n ) : (\n \n ))}\n\n \n \n {option.title}\n \n {option.description &&\n (layout === \"stacked\" ? (\n {option.description}\n ) : (\n {option.description}\n ))}\n \n\n \n \n {index + 1}\n \n \n {showArrow && (\n \n \n \n )}\n \n \n \n );\n};\n\ninterface AskUserOtherInputProps {\n placeholder?: string;\n optionCount: number;\n otherText: string;\n isMulti: boolean;\n onChangeText: (text: string) => void;\n onSubmit: () => void;\n}\n\nconst AskUserOtherInput = ({\n placeholder,\n optionCount,\n otherText,\n isMulti,\n onChangeText,\n onSubmit,\n}: AskUserOtherInputProps) => (\n 0 && \"bg-accent\",\n )}\n >\n onChangeText(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" && !e.shiftKey && !isMulti && otherText.trim()) {\n e.preventDefault();\n onSubmit();\n }\n }}\n placeholder={placeholder ?? \"Describe in your own words…\"}\n rows={1}\n value={otherText}\n />\n 0 ? \"font-semibold text-foreground\" : \"text-muted-foreground\",\n )}\n >\n {optionCount + 1}\n \n \n);\n\ninterface AskUserFooterProps {\n showBack: boolean;\n showSkip: boolean;\n showConfirm: boolean;\n skipLabel: string;\n nextLabel?: string;\n isLast: boolean;\n isMulti: boolean;\n canProceed: boolean;\n onBack: () => void;\n onSkip: () => void;\n onNext: () => void;\n}\n\nconst AskUserFooter = ({\n showBack,\n showSkip,\n showConfirm,\n skipLabel,\n nextLabel,\n isLast,\n isMulti,\n canProceed,\n onBack,\n onSkip,\n onNext,\n}: AskUserFooterProps) => (\n
\n
\n {showBack && (\n \n )}\n
\n
\n {showSkip && (\n \n )}\n {showConfirm && (\n \n )}\n
\n
\n);\n\n// ─── AskUserQuestions ───────────────────────────────────────────────────────\n// A stepped questionnaire rendered as a single card: single- or multi-select\n// options with an optional free-form answer and skip control. A morphing\n// background slides between options on hover and selection. Single-select\n// questions advance on click (an arrow overlays the number chip); multi-select\n// (or \"other\"-enabled) questions advance via the footer Next / Finish button.\nconst AskUserQuestions = ({\n questions,\n currentIndex,\n defaultCurrentIndex = 0,\n onCurrentIndexChange,\n answers,\n defaultAnswers,\n onAnswersChange,\n onComplete,\n onSkip,\n skipLabel = \"Skip\",\n className,\n ref,\n ...props\n}: AskUserQuestionsProps) => {\n const [index, setIndex] = useControllable(\n currentIndex,\n defaultCurrentIndex,\n onCurrentIndexChange,\n );\n const [answersMap, setAnswersMap] = useControllable(\n answers,\n defaultAnswers ?? {},\n onAnswersChange,\n );\n const [hoverIndex, setHoverIndex] = React.useState(null);\n\n const current = questions[index];\n\n const currentAnswer: AskUserAnswer = React.useMemo(\n () =>\n current\n ? (answersMap[current.id] ?? { questionId: current.id, selectedIds: [] })\n : { questionId: \"\", selectedIds: [] },\n [current, answersMap],\n );\n\n const commit = React.useCallback(\n (answer: AskUserAnswer): AnswersMap => {\n const next = { ...answersMap, [answer.questionId]: answer };\n setAnswersMap(next);\n return next;\n },\n [answersMap, setAnswersMap],\n );\n\n const goNext = React.useCallback(\n (nextAnswers: AnswersMap) => {\n setHoverIndex(null);\n if (index >= questions.length - 1) {\n onComplete?.(nextAnswers);\n return;\n }\n setIndex(index + 1);\n },\n [index, questions.length, onComplete, setIndex],\n );\n\n const containerRef = React.useRef(null);\n React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement);\n\n // The Cmd/Ctrl+Enter submit shortcut is attached as a native listener rather\n // than a JSX handler so the container stays a non-interactive grouping element.\n const handleShortcut = React.useCallback(\n (e: KeyboardEvent) => {\n if (!current) {\n return;\n }\n const showConfirm = Boolean(current.multiSelect || current.allowOther);\n const otherText = currentAnswer.otherText ?? \"\";\n const canProceed = currentAnswer.selectedIds.length > 0 || otherText.trim().length > 0;\n if ((e.metaKey || e.ctrlKey) && e.key === \"Enter\" && showConfirm && canProceed) {\n e.preventDefault();\n goNext(commit({ ...currentAnswer, skipped: false }));\n }\n },\n [current, currentAnswer, commit, goNext],\n );\n\n React.useEffect(() => {\n const node = containerRef.current;\n if (!node) {\n return;\n }\n const listener = (e: KeyboardEvent) => handleShortcut(e);\n node.addEventListener(\"keydown\", listener);\n return () => node.removeEventListener(\"keydown\", listener);\n }, [handleShortcut]);\n\n if (!current) {\n return null;\n }\n\n const layout = current.layout ?? \"inline\";\n const skippable = current.skippable !== false;\n const isMulti = Boolean(current.multiSelect);\n const showConfirm = Boolean(current.multiSelect || current.allowOther);\n const isLast = index === questions.length - 1;\n const otherText = currentAnswer.otherText ?? \"\";\n const canProceed = currentAnswer.selectedIds.length > 0 || otherText.trim().length > 0;\n const showBack = index > 0;\n const showSkip = skippable && questions.length > 1;\n const showFooter = showBack || showSkip || showConfirm;\n\n const selectOption = (optionId: string) => {\n if (isMulti) {\n const selected = currentAnswer.selectedIds.includes(optionId);\n const selectedIds = selected\n ? currentAnswer.selectedIds.filter((id) => id !== optionId)\n : [...currentAnswer.selectedIds, optionId];\n commit({ ...currentAnswer, selectedIds, skipped: false });\n return;\n }\n\n const answer: AskUserAnswer = {\n ...currentAnswer,\n selectedIds: [optionId],\n skipped: false,\n };\n const next = commit(answer);\n if (!current.allowOther) {\n goNext(next);\n }\n };\n\n const setOtherText = (text: string) => {\n commit({ ...currentAnswer, otherText: text, skipped: false });\n };\n\n const handleNext = () => {\n goNext(commit({ ...currentAnswer, skipped: false }));\n };\n\n const handleBack = () => {\n if (index > 0) {\n setHoverIndex(null);\n setIndex(index - 1);\n }\n };\n\n const handleSkip = () => {\n const next = commit({\n otherText: \"\",\n questionId: current.id,\n selectedIds: [],\n skipped: true,\n });\n onSkip?.(current.id, index);\n goNext(next);\n };\n\n return (\n \n {/* Header — progress sits at the top, fixed across questions. */}\n
\n Question {index + 1} of {questions.length}\n
\n\n \n \n

{current.title}

\n\n setHoverIndex(null)}\n role={isMulti ? \"group\" : \"radiogroup\"}\n >\n {current.options.map((option, i) => (\n setHoverIndex(i)}\n onSelect={() => selectOption(option.id)}\n option={option}\n selected={currentAnswer.selectedIds.includes(option.id)}\n />\n ))}\n\n {current.allowOther && (\n \n )}\n \n \n
\n\n {showFooter && (\n \n )}\n \n );\n};\n\nexport { AskUserQuestions };\nexport type { AskUserQuestionsProps, AskUserQuestion, AskUserOption, AskUserAnswer };\n", "type": "registry:ui", "target": "" } ], "type": "registry:ui" }