{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tour",
  "type": "registry:ui",
  "title": "Tour",
  "description": "A Tour component for displaying a tour section.",
  "files": [
    {
      "path": "src/registry/ui/tour.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { X } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TourPlacement = \"top\" | \"bottom\" | \"left\" | \"right\";\n\nexport type TourStep = {\n  /** CSS selector of the element to highlight. If nothing matches, the card is centered. */\n  target: string;\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  placement?: TourPlacement;\n};\n\nexport type TourLabels = {\n  next: string;\n  previous: string;\n  finish: string;\n  close: string;\n  stepOf?: (current: number, total: number) => string;\n};\n\nexport type TourProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"title\" | \"children\"\n> & {\n  steps: TourStep[];\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  step?: number;\n  defaultStep?: number;\n  onStepChange?: (step: number) => void;\n  /** Called when the last step's finish button is pressed. */\n  onFinish?: () => void;\n  labels?: Partial<TourLabels>;\n};\n\nconst defaultLabels: Required<TourLabels> = {\n  next: \"Next\",\n  previous: \"Back\",\n  finish: \"Finish\",\n  close: \"Close tour\",\n  stepOf: (current, total) => `${current} of ${total}`,\n};\n\ntype Box = { top: number; left: number; width: number; height: number };\ntype Measurement = {\n  target: Box | null;\n  card: { width: number; height: number } | null;\n  viewport: { width: number; height: number };\n  /** True when the update came from scrolling/resizing and should not animate. */\n  instant: boolean;\n};\n\nconst GAP = 12;\nconst MARGIN = 12;\nconst SPOTLIGHT_PADDING = 6;\n// New step text fades in while the card glides. Opacity only, so reduced motion keeps it.\nconst STEP_FADE = { duration: 0.15, ease: [0.23, 1, 0.32, 1] } as const;\nconst OPPOSITE: Record<TourPlacement, TourPlacement> = {\n  top: \"bottom\",\n  bottom: \"top\",\n  left: \"right\",\n  right: \"left\",\n};\n\nconst subscribeNoop = () => () => {};\nconst clamp = (value: number, min: number, max: number) =>\n  Math.min(Math.max(value, min), Math.max(min, max));\n\nfunction getCardPosition(\n  target: Box | null,\n  card: { width: number; height: number },\n  viewport: { width: number; height: number },\n  preferred: TourPlacement,\n) {\n  if (!target) {\n    return {\n      x: (viewport.width - card.width) / 2,\n      y: (viewport.height - card.height) / 2,\n    };\n  }\n\n  const fits: Record<TourPlacement, boolean> = {\n    top: target.top - GAP - card.height >= MARGIN,\n    bottom:\n      target.top + target.height + GAP + card.height <=\n      viewport.height - MARGIN,\n    left: target.left - GAP - card.width >= MARGIN,\n    right:\n      target.left + target.width + GAP + card.width <= viewport.width - MARGIN,\n  };\n  const placement =\n    fits[preferred] || !fits[OPPOSITE[preferred]]\n      ? preferred\n      : OPPOSITE[preferred];\n\n  let x: number;\n  let y: number;\n  if (placement === \"top\" || placement === \"bottom\") {\n    x = target.left + target.width / 2 - card.width / 2;\n    y =\n      placement === \"top\"\n        ? target.top - GAP - card.height\n        : target.top + target.height + GAP;\n  } else {\n    y = target.top + target.height / 2 - card.height / 2;\n    x =\n      placement === \"left\"\n        ? target.left - GAP - card.width\n        : target.left + target.width + GAP;\n  }\n\n  return {\n    x: clamp(x, MARGIN, viewport.width - card.width - MARGIN),\n    y: clamp(y, MARGIN, viewport.height - card.height - MARGIN),\n  };\n}\n\nfunction getFocusable(container: HTMLElement) {\n  return Array.from(\n    container.querySelectorAll<HTMLElement>(\n      'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])',\n    ),\n  );\n}\n\nexport function Tour({\n  steps,\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  step,\n  defaultStep = 0,\n  onStepChange,\n  onFinish,\n  labels: labelsProp,\n  className,\n  onKeyDown,\n  ...props\n}: TourProps) {\n  const labels = { ...defaultLabels, ...labelsProp };\n  const titleId = useId();\n  const descriptionId = useId();\n  const reduceMotion = useReducedMotion();\n  const isClient = useSyncExternalStore(\n    subscribeNoop,\n    () => true,\n    () => false,\n  );\n  const cardRef = useRef<HTMLDivElement>(null);\n\n  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);\n  const isOpen = open ?? uncontrolledOpen;\n  const setOpen = (next: boolean) => {\n    if (open === undefined) setUncontrolledOpen(next);\n    onOpenChange?.(next);\n  };\n\n  const [uncontrolledStep, setUncontrolledStep] = useState(defaultStep);\n  // Restart from the default step every time the tour opens.\n  const [wasOpen, setWasOpen] = useState(isOpen);\n  const [measurement, setMeasurement] = useState<Measurement | null>(null);\n  if (wasOpen !== isOpen) {\n    setWasOpen(isOpen);\n    if (isOpen) setUncontrolledStep(defaultStep);\n    else setMeasurement(null);\n  }\n  const stepIndex = clamp(step ?? uncontrolledStep, 0, steps.length - 1);\n  const setStep = (next: number) => {\n    if (step === undefined) setUncontrolledStep(next);\n    onStepChange?.(next);\n  };\n\n  const current = steps[stepIndex];\n  const isFirst = stepIndex === 0;\n  const isLast = stepIndex === steps.length - 1;\n  const visible = isOpen && isClient && !!current;\n\n  // Measure target, card and viewport in a rAF so scroll/resize bursts cause one update per frame.\n  const targetSelector = current?.target;\n  useEffect(() => {\n    if (!visible || !targetSelector) return;\n\n    let frame = 0;\n    let instant = false;\n    const target = document.querySelector<HTMLElement>(targetSelector);\n\n    const measure = () => {\n      frame = 0;\n      const rect = target?.getBoundingClientRect();\n      const card = cardRef.current;\n      setMeasurement((previous) => ({\n        target: rect\n          ? {\n              top: rect.top,\n              left: rect.left,\n              width: rect.width,\n              height: rect.height,\n            }\n          : null,\n        card: card\n          ? { width: card.offsetWidth, height: card.offsetHeight }\n          : null,\n        viewport: { width: window.innerWidth, height: window.innerHeight },\n        // The first placement after opening snaps into place instead of flying in.\n        instant: instant || !previous?.card,\n      }));\n    };\n    const scheduleInstant = () => {\n      instant = true;\n      if (!frame) frame = requestAnimationFrame(measure);\n    };\n\n    if (target) {\n      const rect = target.getBoundingClientRect();\n      if (rect.top < 0 || rect.bottom > window.innerHeight) {\n        target.scrollIntoView({\n          block: \"center\",\n          behavior: reduceMotion ? \"auto\" : \"smooth\",\n        });\n      }\n    }\n    frame = requestAnimationFrame(measure);\n\n    const observer = new ResizeObserver(scheduleInstant);\n    if (target) observer.observe(target);\n    if (cardRef.current) observer.observe(cardRef.current);\n    window.addEventListener(\"resize\", scheduleInstant);\n    window.addEventListener(\"scroll\", scheduleInstant, {\n      capture: true,\n      passive: true,\n    });\n\n    return () => {\n      cancelAnimationFrame(frame);\n      observer.disconnect();\n      window.removeEventListener(\"resize\", scheduleInstant);\n      window.removeEventListener(\"scroll\", scheduleInstant, { capture: true });\n    };\n  }, [visible, targetSelector, stepIndex, reduceMotion]);\n\n  // Move focus into the card while open and give it back when the tour closes.\n  useEffect(() => {\n    if (!visible) return;\n    const previouslyFocused = document.activeElement as HTMLElement | null;\n    cardRef.current?.focus();\n    return () => previouslyFocused?.focus?.();\n  }, [visible]);\n\n  // Keep focus inside the card when the focused button disappears or becomes disabled.\n  useEffect(() => {\n    const card = cardRef.current;\n    if (visible && card && !card.contains(document.activeElement)) card.focus();\n  }, [visible, stepIndex]);\n\n  if (!visible) return null;\n\n  const goTo = (next: number) => {\n    if (next >= 0 && next < steps.length) setStep(next);\n  };\n  const finish = () => {\n    onFinish?.();\n    setOpen(false);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      setOpen(false);\n    } else if (event.key === \"ArrowRight\") {\n      event.preventDefault();\n      goTo(stepIndex + 1);\n    } else if (event.key === \"ArrowLeft\") {\n      event.preventDefault();\n      goTo(stepIndex - 1);\n    } else if (event.key === \"Tab\") {\n      const focusable = getFocusable(event.currentTarget);\n      if (focusable.length === 0) return;\n      const first = focusable[0];\n      const last = focusable[focusable.length - 1];\n      const active = document.activeElement;\n      if (\n        event.shiftKey &&\n        (active === first || active === event.currentTarget)\n      ) {\n        event.preventDefault();\n        last.focus();\n      } else if (!event.shiftKey && active === last) {\n        event.preventDefault();\n        first.focus();\n      }\n    }\n  };\n\n  const target = measurement?.target ?? null;\n  const cardSize = measurement?.card;\n  const cardPosition =\n    measurement && cardSize\n      ? getCardPosition(\n          target,\n          cardSize,\n          measurement.viewport,\n          current.placement ?? \"bottom\",\n        )\n      : null;\n  const transition =\n    reduceMotion || measurement?.instant\n      ? { duration: 0 }\n      : { type: \"spring\" as const, bounce: 0.1, duration: 0.4 };\n\n  return createPortal(\n    // Fades the whole tour in on open instead of cutting to it. Opacity only, so it stays under reduced motion.\n    // While fading it forms a stacking context, so it carries the tour's z-index itself.\n    <motion.div\n      className=\"relative z-50\"\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}\n    >\n      {/* Blocks interaction with the page; keeps focus in the card on click. */}\n      <div\n        aria-hidden\n        className={cn(\"fixed inset-0 z-50\", !target && \"bg-black/50\")}\n        onMouseDown={(event) => event.preventDefault()}\n      />\n      {target && (\n        <motion.div\n          aria-hidden\n          className=\"pointer-events-none fixed top-0 left-0 z-50 rounded-lg shadow-[0_0_0_9999px_rgb(0_0_0/0.5)] ring-2 ring-ring\"\n          initial={false}\n          // A full transform string stays on the compositor; width/height still animate for the cutout.\n          animate={{\n            transform: `translate(${target.left - SPOTLIGHT_PADDING}px, ${target.top - SPOTLIGHT_PADDING}px)`,\n            width: target.width + SPOTLIGHT_PADDING * 2,\n            height: target.height + SPOTLIGHT_PADDING * 2,\n          }}\n          transition={transition}\n        />\n      )}\n      <motion.div\n        className=\"fixed top-0 left-0 z-50\"\n        initial={false}\n        animate={\n          cardPosition\n            ? { transform: `translate(${cardPosition.x}px, ${cardPosition.y}px)`, opacity: 1 }\n            : { opacity: 0 }\n        }\n        transition={transition}\n      >\n        <div\n          ref={cardRef}\n          data-slot=\"tour\"\n          role=\"dialog\"\n          aria-modal=\"true\"\n          aria-labelledby={titleId}\n          aria-describedby={current.description ? descriptionId : undefined}\n          tabIndex={-1}\n          onKeyDown={handleKeyDown}\n          className={cn(\n            \"w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border bg-popover p-4 text-popover-foreground shadow-sm outline-none\",\n            \"focus-visible:ring-2 focus-visible:ring-ring\",\n            className,\n          )}\n          {...props}\n        >\n          <div className=\"flex items-start justify-between gap-4\">\n            <motion.h2\n              key={stepIndex}\n              id={titleId}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              transition={STEP_FADE}\n              className=\"text-base font-semibold\"\n            >\n              {current.title}\n            </motion.h2>\n            <button\n              type=\"button\"\n              aria-label={labels.close}\n              onClick={() => setOpen(false)}\n              className=\"-mt-1 -mr-1 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n            >\n              <X className=\"size-4\" aria-hidden />\n            </button>\n          </div>\n          {current.description && (\n            <motion.div\n              key={stepIndex}\n              id={descriptionId}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              transition={STEP_FADE}\n              className=\"mt-1.5 text-sm text-muted-foreground\"\n            >\n              {current.description}\n            </motion.div>\n          )}\n          <div className=\"mt-4 flex items-center gap-2\">\n            <span\n              aria-live=\"polite\"\n              className=\"mr-auto text-xs text-muted-foreground tabular-nums\"\n            >\n              {labels.stepOf(stepIndex + 1, steps.length)}\n            </span>\n            {!isFirst && (\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={() => goTo(stepIndex - 1)}\n              >\n                {labels.previous}\n              </Button>\n            )}\n            <Button\n              size=\"sm\"\n              onClick={isLast ? finish : () => goTo(stepIndex + 1)}\n            >\n              {isLast ? labels.finish : labels.next}\n            </Button>\n          </div>\n        </div>\n      </motion.div>\n    </motion.div>,\n    document.body,\n  );\n}\n"
    }
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "button"
  ]
}
