{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"wizard-steps","type":"registry:ui","title":"Wizard Steps","description":"Transition knows forward from back.","dependencies":["motion"],"categories":["navigation"],"docs":"https://www.interior.dev/docs/wizard-steps","files":[{"path":"registry/interior/wizard-steps.tsx","type":"registry:ui","target":"components/interior/wizard-steps.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\nconst EXIT_EASE = [0.4, 0, 1, 1] as const;\n\nconst RAIL = { type: \"spring\", stiffness: 520, damping: 40, mass: 0.5 } as const;\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\n\nexport type WizardDirection = 1 | -1;\n\nexport type UseWizardOptions = {\n  total: number;\n  index?: number;\n  defaultIndex?: number;\n  onIndexChange?: (index: number, direction: WizardDirection) => void;\n  onComplete?: () => void;\n};\n\nexport type UseWizardReturn = {\n  index: number;\n  direction: WizardDirection;\n  furthest: number;\n  total: number;\n  isFirst: boolean;\n  isLast: boolean;\n  next: () => void;\n  back: () => void;\n  goTo: (index: number) => void;\n};\n\nfunction clampIndex(value: number, total: number) {\n  if (total < 1) return 0;\n  return Math.max(0, Math.min(total - 1, Math.trunc(value)));\n}\n\nexport function useWizard({\n  total,\n  index,\n  defaultIndex = 0,\n  onIndexChange,\n  onComplete,\n}: UseWizardOptions): UseWizardReturn {\n  const [internal, setInternal] = useState(() => clampIndex(defaultIndex, total));\n  const current = clampIndex(index ?? internal, total);\n\n  const [seen, setSeen] = useState<{ index: number; direction: WizardDirection }>({\n    index: current,\n    direction: 1,\n  });\n  if (seen.index !== current) {\n    setSeen({ index: current, direction: current > seen.index ? 1 : -1 });\n  }\n\n  const [furthest, setFurthest] = useState(current);\n  if (furthest < current) setFurthest(current);\n\n  const emit = useRef(onIndexChange);\n  emit.current = onIndexChange;\n  const finish = useRef(onComplete);\n  finish.current = onComplete;\n\n  const controlled = index !== undefined;\n\n  const goTo = useCallback(\n    (to: number) => {\n      const target = clampIndex(to, total);\n      if (target === current) return;\n      const direction: WizardDirection = target > current ? 1 : -1;\n      if (!controlled) setInternal(target);\n      emit.current?.(target, direction);\n    },\n    [controlled, current, total],\n  );\n\n  const next = useCallback(() => {\n    if (current >= total - 1) {\n      finish.current?.();\n      return;\n    }\n    goTo(current + 1);\n  }, [current, goTo, total]);\n\n  const back = useCallback(() => goTo(current - 1), [current, goTo]);\n\n  return {\n    index: current,\n    direction: seen.direction,\n    furthest: Math.min(furthest, Math.max(total - 1, 0)),\n    total,\n    isFirst: current === 0,\n    isLast: current === total - 1,\n    next,\n    back,\n    goTo,\n  };\n}\n\nexport type WizardStep = {\n  id: string;\n  label: string;\n  content: ReactNode;\n};\n\nexport type WizardStepsProps = {\n  steps: WizardStep[];\n  index?: number;\n  defaultIndex?: number;\n  onIndexChange?: (index: number, direction: WizardDirection) => void;\n  onComplete?: () => void;\n\n  complete?: boolean;\n  height?: number;\n  backLabel?: string;\n  nextLabel?: string;\n  finishLabel?: string;\n  completeLabel?: string;\n  completeHint?: string;\n  label?: string;\n  className?: string;\n};\n\nexport function WizardSteps({\n  steps,\n  index,\n  defaultIndex = 0,\n  onIndexChange,\n  onComplete,\n  complete = false,\n  height = 184,\n  backLabel = \"Back\",\n  nextLabel = \"Next\",\n  finishLabel = \"Finish\",\n  completeLabel = \"All set\",\n  completeHint = \"Step back to change anything\",\n  label = \"Steps\",\n  className = \"\",\n}: WizardStepsProps) {\n  const wizard = useWizard({\n    total: steps.length,\n    index,\n    defaultIndex,\n    onIndexChange,\n    onComplete,\n  });\n  const reduced = useReducedMotion();\n\n  const listRef = useRef<HTMLOListElement>(null);\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const intent = useRef<\"list\" | \"panel\" | null>(null);\n\n  const { index: at, direction, furthest, total, isFirst, isLast, next, back, goTo } = wizard;\n\n  useEffect(() => {\n    const move = intent.current;\n    intent.current = null;\n    if (move === \"list\") {\n      listRef.current\n        ?.querySelector<HTMLButtonElement>('button[data-current=\"true\"]')\n        ?.focus();\n      return;\n    }\n    if (move === \"panel\") viewportRef.current?.focus({ preventScroll: true });\n  }, [at]);\n\n  const variants = useMemo(\n    () => ({\n      enter: (d: WizardDirection) => (reduced ? { opacity: 0 } : { opacity: 0, x: d * 22 }),\n      center: reduced ? { opacity: 1 } : { opacity: 1, x: 0 },\n      exit: (d: WizardDirection) =>\n        reduced\n          ? { opacity: 0, transition: { duration: 0 } }\n          : {\n              opacity: 0,\n              x: d * -22,\n              transition: { duration: 0.14, ease: EXIT_EASE },\n            },\n    }),\n    [reduced],\n  );\n\n  const panelTransition = reduced ? { duration: 0 } : CROSSFADE;\n\n  const onStepKeyDown = (e: KeyboardEvent<HTMLElement>) => {\n    let target = at;\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") target = at + 1;\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") target = at - 1;\n    else if (e.key === \"Home\") target = 0;\n    else if (e.key === \"End\") target = furthest;\n    else return;\n    e.preventDefault();\n    target = Math.min(clampIndex(target, total), furthest);\n    if (target === at) return;\n    intent.current = \"list\";\n    goTo(target);\n  };\n\n  const step = steps[at];\n  if (!step) return null;\n\n  const position = `Step ${at + 1} of ${total}: ${step.label}`;\n\n  return (\n    <div className={`w-full ${className}`}>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {position}\n      </p>\n      <span\n        aria-hidden\n        className=\"mb-2 grid select-none text-[13px] font-medium text-stone-700 dark:text-stone-200\"\n      >\n        {steps.map((s, i) => (\n          <motion.span\n            key={s.id}\n            className=\"col-start-1 row-start-1 truncate\"\n            initial={false}\n            animate={{ opacity: i === at ? 1 : 0 }}\n            transition={reduced ? { duration: 0 } : CROSSFADE}\n          >\n            {s.label}\n          </motion.span>\n        ))}\n      </span>\n      <ol\n        ref={listRef}\n        aria-label={label}\n        className=\"mb-4 flex list-none items-center gap-1 p-0\"\n      >\n        {steps.map((s, i) => {\n          const done = complete || i < at;\n          const here = !complete && i === at;\n\n          const tile = (\n            <motion.span\n              aria-hidden\n              className={`grid size-7 place-items-center rounded-[8px] border text-[11.5px] font-medium tabular-nums shadow-[0_1px_2px_rgba(28,25,23,0.06),0_4px_10px_-8px_rgba(28,25,23,0.45)] transition-colors duration-150 dark:shadow-[0_1px_6px_rgba(0,0,0,0.45)] ${\n                done\n                  ? \"border-stone-800 bg-stone-800 text-white dark:border-stone-100 dark:bg-stone-100 dark:text-stone-900\"\n                  : here\n                    ? \"border-stone-200 bg-white text-stone-700 dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-100\"\n                    : \"border-stone-200 bg-white text-stone-400 dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-500\"\n              }`}\n              initial={false}\n              animate={{ scale: here ? 1 : 0.92 }}\n              transition={reduced ? { duration: 0 } : RAIL}\n            >\n              {done ? (\n                <svg\n                  width=\"12\"\n                  height=\"12\"\n                  viewBox=\"0 0 256 256\"\n                  fill=\"none\"\n                  aria-hidden=\"true\"\n                >\n                  <polyline\n                    points=\"216 72 104 184 48 128\"\n                    stroke=\"currentColor\"\n                    strokeWidth=\"24\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                  />\n                </svg>\n              ) : (\n                i + 1\n              )}\n            </motion.span>\n          );\n\n          return (\n            <li key={s.id} className=\"flex flex-1 items-center gap-1 last:flex-none\">\n\n              {i <= furthest ? (\n                <button\n                  type=\"button\"\n                  data-current={here ? \"true\" : undefined}\n                  tabIndex={here ? 0 : -1}\n                  aria-current={here ? \"step\" : undefined}\n                  aria-label={`Step ${i + 1} of ${total}: ${s.label}`}\n                  onKeyDown={onStepKeyDown}\n                  onClick={() => {\n                    if (here) return;\n                    intent.current = \"list\";\n                    goTo(i);\n                  }}\n                  className=\"rounded-[8px] outline-none focus-visible:shadow-[0_0_0_1.5px_#4568FF] dark:focus-visible:shadow-[0_0_0_1.5px_#93B0FF]\"\n                >\n                  {tile}\n                </button>\n              ) : (\n                <span>\n                  <span className=\"sr-only\">{`Step ${i + 1} of ${total}: ${s.label}`}</span>\n                  {tile}\n                </span>\n              )}\n\n              {i < total - 1 ? (\n                <span\n                  aria-hidden\n                  className=\"relative h-[3px] flex-1 overflow-hidden rounded-[2px] bg-stone-100 shadow-[inset_0_1px_2px_rgba(28,25,23,0.07)] dark:bg-white/[0.06] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.4)]\"\n                >\n                  <motion.span\n                    className=\"absolute inset-0 origin-left rounded-[2px] bg-stone-800 dark:bg-stone-100\"\n                    initial={false}\n                    animate={{ scaleX: complete || i < at ? 1 : 0 }}\n                    transition={reduced ? { duration: 0 } : RAIL}\n                  />\n                </span>\n              ) : null}\n            </li>\n          );\n        })}\n      </ol>\n      <div\n        ref={viewportRef}\n        tabIndex={-1}\n        role=\"group\"\n        aria-label={position}\n        style={{ height }}\n        className=\"relative overflow-hidden rounded-[11px] border border-stone-200 bg-white shadow-[0_1px_2px_rgba(28,25,23,0.06),0_4px_10px_-8px_rgba(28,25,23,0.45)] outline-none transition-[border-color,box-shadow] duration-150 focus-visible:border-[#4568FF] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[0_1px_6px_rgba(0,0,0,0.45)] dark:focus-visible:border-[#93B0FF]\"\n      >\n        <AnimatePresence initial={false} custom={direction}>\n          <motion.div\n            key={complete ? \"__complete\" : step.id}\n            custom={direction}\n            variants={variants}\n            initial=\"enter\"\n            animate=\"center\"\n            exit=\"exit\"\n            transition={panelTransition}\n            style={{ scrollbarGutter: \"stable\" }}\n            className=\"absolute inset-0 overflow-y-auto overscroll-contain p-4 text-[13.5px] leading-relaxed text-stone-700 dark:text-stone-200\"\n          >\n\n            {complete ? (\n              <div className=\"flex h-full flex-col items-center justify-center gap-1.5\">\n                <p className=\"text-[13px] font-medium text-stone-700 dark:text-stone-100\">\n                  {completeLabel}\n                </p>\n                <p className=\"text-[12.5px] text-stone-400 dark:text-stone-500\">\n                  {completeHint}\n                </p>\n              </div>\n            ) : (\n              step.content\n            )}\n          </motion.div>\n        </AnimatePresence>\n      </div>\n      <div className=\"mt-3 flex h-9 items-center gap-3\">\n        <AnimatePresence initial={false}>\n          {isFirst ? null : (\n            <motion.button\n              key=\"back\"\n              type=\"button\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{\n                opacity: 0,\n                transition: reduced ? { duration: 0 } : { duration: 0.12, ease: EXIT_EASE },\n              }}\n              transition={reduced ? { duration: 0 } : { duration: 0.16, ease: EASE }}\n              onClick={() => {\n                intent.current = \"panel\";\n                back();\n              }}\n              className=\"h-9 rounded-[9px] border border-stone-200 bg-white px-3 text-[13px] font-medium text-stone-700 outline-none transition-[border-color,box-shadow] duration-150 hover:border-stone-300 focus-visible:border-[#4568FF] focus-visible:shadow-[0_1px_2px_rgba(28,25,23,0.08),0_10px_20px_-14px_rgba(69,104,255,0.6)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 dark:hover:border-white/20 dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_10px_20px_-14px_rgba(147,176,255,0.5)]\"\n            >\n              {backLabel}\n            </motion.button>\n          )}\n        </AnimatePresence>\n        <AnimatePresence initial={false}>\n          {complete ? null : (\n            <motion.button\n              key=\"advance\"\n              type=\"button\"\n              aria-label={isLast ? finishLabel : nextLabel}\n              onClick={() => {\n                if (!isLast) intent.current = \"panel\";\n                next();\n              }}\n              initial={{ opacity: 0, scale: 0.96 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{\n                opacity: 0,\n                scale: 0.96,\n                transition: reduced\n                  ? { duration: 0 }\n                  : { duration: 0.14, ease: EXIT_EASE },\n              }}\n              transition={reduced ? { duration: 0 } : CROSSFADE}\n              className=\"ml-auto grid h-9 place-items-center rounded-[9px] bg-stone-800 px-3.5 text-[13px] font-medium text-white outline-none focus-visible:shadow-[inset_0_0_0_1.5px_#93B0FF] dark:bg-stone-100 dark:text-stone-900 dark:focus-visible:shadow-[inset_0_0_0_1.5px_#4568FF]\"\n            >\n              <span aria-hidden className=\"invisible col-start-1 row-start-1\">\n                {finishLabel.length > nextLabel.length ? finishLabel : nextLabel}\n              </span>\n              <motion.span\n                aria-hidden\n                className=\"col-start-1 row-start-1\"\n                initial={false}\n                animate={{ opacity: isLast ? 0 : 1 }}\n                transition={reduced ? { duration: 0 } : CROSSFADE}\n              >\n                {nextLabel}\n              </motion.span>\n              <motion.span\n                aria-hidden\n                className=\"col-start-1 row-start-1\"\n                initial={false}\n                animate={{ opacity: isLast ? 1 : 0 }}\n                transition={reduced ? { duration: 0 } : CROSSFADE}\n              >\n                {finishLabel}\n              </motion.span>\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n"}]}