{
  "$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<string, AskUserAnswer>;\n\ninterface AskUserQuestionsProps extends Omit<React.ComponentProps<\"div\">, \"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 = <T,>(\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    <button\n      aria-checked={selected}\n      className={cn(\n        \"relative z-10 flex items-center gap-3 rounded-full px-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[#6B97FF]\",\n        layout === \"stacked\" ? \"min-h-14 py-2\" : \"min-h-10 py-1.5\",\n      )}\n      onClick={onSelect}\n      onMouseEnter={onHover}\n      role={isMulti ? \"checkbox\" : \"radio\"}\n      type=\"button\"\n    >\n      {hovered && !selected && (\n        <motion.span\n          aria-hidden=\"true\"\n          className=\"absolute inset-0 -z-10 rounded-full bg-muted\"\n          layoutId=\"ask-user-hover\"\n          transition={springs.fast}\n        />\n      )}\n      {selected &&\n        (isMulti ? (\n          <span aria-hidden=\"true\" className=\"absolute inset-0 -z-10 rounded-full bg-accent\" />\n        ) : (\n          <motion.span\n            aria-hidden=\"true\"\n            className=\"absolute inset-0 -z-10 rounded-full bg-accent\"\n            layoutId=\"ask-user-selected\"\n            transition={springs.moderate}\n          />\n        ))}\n\n      <span\n        className={cn(\n          \"min-w-0 flex-1 text-[13px] leading-snug\",\n          layout === \"stacked\" ? \"flex flex-col gap-0.5\" : \"\",\n        )}\n      >\n        <span className={cn(\"text-foreground\", selected ? \"font-semibold\" : \"font-medium\")}>\n          {option.title}\n        </span>\n        {option.description &&\n          (layout === \"stacked\" ? (\n            <span className=\"text-muted-foreground text-xs leading-snug\">{option.description}</span>\n          ) : (\n            <span className=\"text-muted-foreground\"> {option.description}</span>\n          ))}\n      </span>\n\n      <span className=\"relative inline-flex size-7 shrink-0 items-center justify-center\">\n        <span\n          className={cn(\n            \"inline-flex size-5 items-center justify-center text-[11px] transition-opacity\",\n            getChipClassName(isMulti, selected),\n            showArrow && \"opacity-0\",\n          )}\n        >\n          {index + 1}\n        </span>\n        <AnimatePresence>\n          {showArrow && (\n            <motion.span\n              animate={{ opacity: 1, scale: 1 }}\n              aria-hidden=\"true\"\n              className=\"absolute inset-0 inline-flex items-center justify-center rounded-full bg-foreground text-background\"\n              exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.06 } }}\n              initial={{ opacity: 0, scale: 0.6 }}\n              transition={springs.fast}\n            >\n              <ArrowRightIcon className=\"size-3.5\" />\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </span>\n    </button>\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  <div\n    className={cn(\n      \"relative z-10 flex items-start gap-3 rounded-full px-3 py-2\",\n      otherText.length > 0 && \"bg-accent\",\n    )}\n  >\n    <textarea\n      aria-label={placeholder ?? \"Describe in your own words\"}\n      className=\"min-h-7 flex-1 resize-none self-center bg-transparent text-[13px] text-foreground leading-snug outline-none [field-sizing:content] placeholder:text-muted-foreground\"\n      onChange={(e) => 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    <span\n      className={cn(\n        \"inline-flex size-5 shrink-0 items-center justify-center self-center text-[11px]\",\n        otherText.length > 0 ? \"font-semibold text-foreground\" : \"text-muted-foreground\",\n      )}\n    >\n      {optionCount + 1}\n    </span>\n  </div>\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  <div className=\"flex items-center justify-between gap-2 px-4 pt-1 pb-3 sm:px-5\">\n    <div>\n      {showBack && (\n        <Button data-icon=\"inline-start\" onClick={onBack} size=\"sm\" variant=\"ghost\">\n          <ArrowLeftIcon className=\"hidden sm:block\" />\n          Back\n        </Button>\n      )}\n    </div>\n    <div className=\"flex items-center gap-2\">\n      {showSkip && (\n        <Button data-icon=\"inline-end\" onClick={onSkip} size=\"sm\" variant=\"ghost\">\n          {skipLabel}\n          <ArrowRightIcon className=\"hidden sm:block\" />\n        </Button>\n      )}\n      {showConfirm && (\n        <Button disabled={!canProceed} onClick={onNext} size=\"sm\">\n          {nextLabel ?? (isLast ? \"Finish\" : \"Next\")}\n          {isMulti && (\n            <kbd className=\"-mr-0.5 ml-0.5 inline-flex items-center gap-0.5 rounded-full bg-primary-foreground/15 px-1.5 py-px font-medium text-[10px] text-primary-foreground/90\">\n              ⌘↵\n            </kbd>\n          )}\n        </Button>\n      )}\n    </div>\n  </div>\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<number | null>(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<HTMLDivElement>(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    <div\n      className={cn(\n        \"relative w-full max-w-[520px] overflow-hidden rounded-2xl border border-border bg-card\",\n        className,\n      )}\n      data-slot=\"ask-user-questions\"\n      ref={containerRef}\n      {...props}\n    >\n      {/* Header — progress sits at the top, fixed across questions. */}\n      <div className=\"flex items-center px-4 pt-4 pb-2 text-muted-foreground text-xs tabular-figures sm:px-5 sm:pt-5\">\n        Question {index + 1} of {questions.length}\n      </div>\n\n      <AnimatePresence initial={false} mode=\"wait\">\n        <motion.div\n          animate={{ opacity: 1, y: 0 }}\n          className=\"px-4 pb-1 sm:px-5\"\n          exit={{ opacity: 0, transition: { duration: 0.1 }, y: -6 }}\n          initial={{ opacity: 0, y: 8 }}\n          key={current.id}\n          layout\n          transition={springs.moderate}\n        >\n          <h3 className=\"font-semibold text-base text-foreground leading-snug\">{current.title}</h3>\n\n          <div\n            className=\"relative mt-2 flex flex-col gap-0.5\"\n            onMouseLeave={() => setHoverIndex(null)}\n            role={isMulti ? \"group\" : \"radiogroup\"}\n          >\n            {current.options.map((option, i) => (\n              <AskUserOptionButton\n                hovered={hoverIndex === i}\n                index={i}\n                isMulti={isMulti}\n                key={option.id}\n                layout={layout}\n                onHover={() => setHoverIndex(i)}\n                onSelect={() => selectOption(option.id)}\n                option={option}\n                selected={currentAnswer.selectedIds.includes(option.id)}\n              />\n            ))}\n\n            {current.allowOther && (\n              <AskUserOtherInput\n                isMulti={isMulti}\n                onChangeText={setOtherText}\n                onSubmit={handleNext}\n                optionCount={current.options.length}\n                otherText={otherText}\n                placeholder={current.otherPlaceholder}\n              />\n            )}\n          </div>\n        </motion.div>\n      </AnimatePresence>\n\n      {showFooter && (\n        <AskUserFooter\n          canProceed={canProceed}\n          isLast={isLast}\n          isMulti={isMulti}\n          nextLabel={current.nextLabel}\n          onBack={handleBack}\n          onNext={handleNext}\n          onSkip={handleSkip}\n          showBack={showBack}\n          showConfirm={showConfirm}\n          showSkip={showSkip}\n          skipLabel={skipLabel}\n        />\n      )}\n    </div>\n  );\n};\n\nexport { AskUserQuestions };\nexport type { AskUserQuestionsProps, AskUserQuestion, AskUserOption, AskUserAnswer };\n",
      "type": "registry:ui",
      "target": ""
    }
  ],
  "type": "registry:ui"
}
