{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tour",
  "title": "Tour",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-react/components/tour/tour.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { createPortal } from 'react-dom'\nimport { X } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { cn } from '@/lib/utils'\n\nexport type TourTarget = string | (() => HTMLElement | null) | HTMLElement | null\n\nexport interface TargetRect {\n  x: number\n  y: number\n  width: number\n  height: number\n}\n\nexport interface TourStep {\n  target?: TourTarget\n  title: string\n  description?: string\n  cover?: string\n  mask?: boolean\n  nextButtonText?: string\n  prevButtonText?: string\n}\n\n// ── useTourTarget ────────────────────────────────────────────────────────────\n// Measures the current step's target element (getBoundingClientRect) and keeps\n// the rect in sync on scroll / resize / element resize. `attach` is called\n// imperatively when the step or open state changes; `measure` re-reads the rect.\nfunction useTourTarget(target: TourTarget | undefined) {\n  const [rect, setRect] = React.useState<TargetRect | null>(null)\n  const elementRef = React.useRef<HTMLElement | null>(null)\n  const resizeObsRef = React.useRef<ResizeObserver | null>(null)\n  const rafRef = React.useRef(0)\n  const targetRef = React.useRef(target)\n  targetRef.current = target\n\n  const resolve = React.useCallback((): HTMLElement | null => {\n    const t = targetRef.current\n    if (!t) return null\n    if (typeof t === 'string') return document.querySelector(t) as HTMLElement | null\n    if (typeof t === 'function') return t()\n    return t\n  }, [])\n\n  const measure = React.useCallback(() => {\n    cancelAnimationFrame(rafRef.current)\n    rafRef.current = requestAnimationFrame(() => {\n      if (!elementRef.current) {\n        setRect(null)\n        return\n      }\n      const r = elementRef.current.getBoundingClientRect()\n      setRect({ x: r.left, y: r.top, width: r.width, height: r.height })\n    })\n  }, [])\n\n  const detach = React.useCallback(() => {\n    resizeObsRef.current?.disconnect()\n    resizeObsRef.current = null\n    window.removeEventListener('scroll', measure, true)\n    window.removeEventListener('resize', measure)\n    cancelAnimationFrame(rafRef.current)\n  }, [measure])\n\n  const attach = React.useCallback(() => {\n    detach()\n    elementRef.current = resolve()\n    if (!elementRef.current) {\n      setRect(null)\n      return\n    }\n    measure()\n    if (typeof ResizeObserver !== 'undefined') {\n      resizeObsRef.current = new ResizeObserver(measure)\n      resizeObsRef.current.observe(elementRef.current)\n      resizeObsRef.current.observe(document.documentElement)\n    }\n    window.addEventListener('scroll', measure, { passive: true, capture: true })\n    window.addEventListener('resize', measure, { passive: true })\n  }, [detach, measure, resolve])\n\n  React.useEffect(() => detach, [detach])\n\n  return { rect, attach, detach, measure }\n}\n\n// ── TourMask ─────────────────────────────────────────────────────────────────\ninterface TourMaskProps {\n  rect: TargetRect | null\n  zIndex: number\n  opacity?: number\n  padding?: number\n  radius?: number\n}\n\nfunction TourMask({ rect, zIndex, opacity = 0.5, padding = 4, radius = 6 }: TourMaskProps) {\n  const cutout = rect\n    ? {\n        x: rect.x - padding,\n        y: rect.y - padding,\n        w: rect.width + padding * 2,\n        h: rect.height + padding * 2,\n      }\n    : null\n\n  return (\n    <svg className=\"pointer-events-none fixed inset-0\" style={{ zIndex }} width=\"100%\" height=\"100%\" aria-hidden=\"true\">\n      <defs>\n        <mask id=\"uipkge-tour-mask\">\n          <rect width=\"100%\" height=\"100%\" fill=\"white\" />\n          {cutout && (\n            <rect x={cutout.x} y={cutout.y} width={cutout.w} height={cutout.h} rx={radius} fill=\"black\" />\n          )}\n        </mask>\n      </defs>\n      <rect\n        width=\"100%\"\n        height=\"100%\"\n        fill={`rgba(0, 0, 0, ${opacity})`}\n        mask=\"url(#uipkge-tour-mask)\"\n        className=\"pointer-events-auto\"\n        style={{ transition: 'all 200ms ease' }}\n      />\n    </svg>\n  )\n}\n\n// ── TourCard ─────────────────────────────────────────────────────────────────\ninterface TourCardProps {\n  title: string\n  description?: string\n  cover?: string\n  rect: TargetRect | null\n  total: number\n  current: number\n  prevText?: string\n  nextText?: string\n  type?: 'default' | 'primary'\n  zIndex: number\n  onPrev: () => void\n  onNext: () => void\n  onFinish: () => void\n  onSkip: () => void\n}\n\nfunction TourCard({\n  title,\n  description,\n  cover,\n  rect,\n  total,\n  current,\n  prevText = 'Previous',\n  nextText = 'Next',\n  type = 'default',\n  zIndex,\n  onPrev,\n  onNext,\n  onFinish,\n  onSkip,\n}: TourCardProps) {\n  const isLast = current === total - 1\n  const isFirst = current === 0\n\n  const cardStyle = React.useMemo<React.CSSProperties>(() => {\n    const cardWidth = 320\n    const margin = 12\n    if (!rect) {\n      return {\n        position: 'fixed',\n        top: '50%',\n        left: '50%',\n        transform: 'translate(-50%, -50%)',\n        width: `${cardWidth}px`,\n        zIndex: zIndex + 1,\n      }\n    }\n    const { x, y, width, height } = rect\n    const viewportH = typeof window !== 'undefined' ? window.innerHeight : 768\n    const viewportW = typeof window !== 'undefined' ? window.innerWidth : 1024\n    const placeBelow = y + height + margin + 200 < viewportH\n    const top = placeBelow ? y + height + margin : Math.max(8, y - margin - 200)\n    let left = x\n    if (left + cardWidth > viewportW - 8) {\n      left = viewportW - cardWidth - 8\n    }\n    if (left < 8) left = 8\n    return {\n      position: 'fixed',\n      top: `${top}px`,\n      left: `${left}px`,\n      width: `${cardWidth}px`,\n      zIndex: zIndex + 1,\n    }\n  }, [rect, zIndex])\n\n  return (\n    <div\n      role=\"dialog\"\n      aria-modal=\"true\"\n      className={cn(\n        'relative space-y-3 rounded-lg border p-4 shadow-lg',\n        type === 'primary' ? 'bg-primary text-primary-foreground border-primary' : 'bg-popover text-popover-foreground',\n      )}\n      style={cardStyle}\n    >\n      <button\n        type=\"button\"\n        className=\"hover:bg-foreground/10 focus-visible:ring-ring absolute top-2 right-2 inline-flex size-6 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none\"\n        aria-label=\"Close tour\"\n        onClick={onSkip}\n      >\n        <X className=\"size-4\" aria-hidden=\"true\" />\n      </button>\n\n      {cover && <img src={cover} alt=\"\" className=\"w-full rounded-md\" />}\n\n      <div>\n        <div className=\"pr-6 font-semibold\">{title}</div>\n        {description && <div className=\"mt-1 text-sm opacity-90\">{description}</div>}\n      </div>\n\n      <div className=\"flex items-center justify-between gap-2 pt-2\">\n        <div className=\"text-xs tabular-nums opacity-70\" aria-live=\"polite\">\n          {current + 1} / {total}\n        </div>\n        <div className=\"flex gap-2\">\n          {!isFirst && (\n            <Button size=\"sm\" variant={type === 'primary' ? 'secondary' : 'outline'} onClick={onPrev}>\n              {prevText}\n            </Button>\n          )}\n          {!isLast ? (\n            <Button size=\"sm\" variant={type === 'primary' ? 'secondary' : 'default'} onClick={onNext}>\n              {nextText}\n            </Button>\n          ) : (\n            <Button size=\"sm\" variant={type === 'primary' ? 'secondary' : 'default'} onClick={onFinish}>\n              Finish\n            </Button>\n          )}\n        </div>\n      </div>\n    </div>\n  )\n}\n\n// ── Tour ─────────────────────────────────────────────────────────────────────\nexport interface TourProps {\n  open?: boolean\n  current?: number\n  steps: TourStep[]\n  mask?: boolean\n  type?: 'default' | 'primary'\n  zIndex?: number\n  /** Controlled open updates — mirrors Vue's `update:open`. */\n  onOpenChange?: (open: boolean) => void\n  /** Controlled current-step updates — mirrors Vue's `update:current`. */\n  onCurrentChange?: (current: number) => void\n  /** Fired whenever the active step changes. */\n  onChange?: (current: number) => void\n  /** Fired when the user presses Finish on the last step. */\n  onFinish?: () => void\n  /** Fired when the user dismisses the tour (close button / Escape). */\n  onClose?: () => void\n}\n\nfunction Tour({\n  open = false,\n  current = 0,\n  steps,\n  mask = true,\n  type = 'default',\n  zIndex = 1000,\n  onOpenChange,\n  onCurrentChange,\n  onChange,\n  onFinish,\n  onClose,\n}: TourProps) {\n  const [stepIndex, setStepIndex] = React.useState(current)\n\n  // Mirror the `current` prop into local state (Vue `watch(() => props.current)`).\n  React.useEffect(() => {\n    setStepIndex(current)\n  }, [current])\n\n  const currentStep = steps[stepIndex] ?? null\n\n  const { rect, attach, measure } = useTourTarget(currentStep?.target)\n\n  // Attach + scroll the target into view on open / step change (Vue's watch\n  // on [open, stepIndex] with immediate + nextTick). The effect runs after\n  // paint, which is React's equivalent of awaiting nextTick.\n  React.useEffect(() => {\n    if (!open) return\n    attach()\n    const t = currentStep?.target\n    if (t) {\n      const el = typeof t === 'string' ? (document.querySelector(t) as HTMLElement | null) : typeof t === 'function' ? t() : t\n      el?.scrollIntoView({ behavior: 'smooth', block: 'center' })\n      const id = setTimeout(measure, 320)\n      return () => clearTimeout(id)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open, stepIndex])\n\n  const setStep = React.useCallback(\n    (i: number) => {\n      setStepIndex(i)\n      onCurrentChange?.(i)\n      onChange?.(i)\n    },\n    [onCurrentChange, onChange],\n  )\n\n  const next = React.useCallback(() => {\n    if (stepIndex < steps.length - 1) setStep(stepIndex + 1)\n  }, [stepIndex, steps.length, setStep])\n\n  const prev = React.useCallback(() => {\n    if (stepIndex > 0) setStep(stepIndex - 1)\n  }, [stepIndex, setStep])\n\n  const finish = React.useCallback(() => {\n    onFinish?.()\n    onOpenChange?.(false)\n  }, [onFinish, onOpenChange])\n\n  const skip = React.useCallback(() => {\n    onClose?.()\n    onOpenChange?.(false)\n  }, [onClose, onOpenChange])\n\n  // Escape closes the tour while open.\n  React.useEffect(() => {\n    if (typeof document === 'undefined') return\n    if (!open) return\n    function onKeydown(e: KeyboardEvent) {\n      if (e.key === 'Escape') {\n        e.preventDefault()\n        skip()\n      }\n    }\n    document.addEventListener('keydown', onKeydown)\n    return () => document.removeEventListener('keydown', onKeydown)\n  }, [open, skip])\n\n  const showMask = currentStep?.mask !== undefined ? currentStep.mask : mask\n\n  if (typeof document === 'undefined') return null\n  if (!open || !currentStep) return null\n\n  return createPortal(\n    <>\n      {showMask && <TourMask rect={rect} zIndex={zIndex} />}\n      <TourCard\n        title={currentStep.title}\n        description={currentStep.description}\n        cover={currentStep.cover}\n        rect={rect}\n        total={steps.length}\n        current={stepIndex}\n        prevText={currentStep.prevButtonText}\n        nextText={currentStep.nextButtonText}\n        type={type}\n        zIndex={zIndex}\n        onPrev={prev}\n        onNext={next}\n        onFinish={finish}\n        onSkip={skip}\n      />\n    </>,\n    document.body,\n  )\n}\n\nexport { Tour }\n",
      "type": "registry:ui",
      "target": "~/components/ui/tour/tour.tsx"
    },
    {
      "path": "packages/registry-react/components/tour/index.ts",
      "content": "export { Tour, type TourProps, type TourStep, type TourTarget, type TargetRect } from './tour'\n",
      "type": "registry:ui",
      "target": "~/components/ui/tour/index.ts"
    }
  ],
  "dependencies": [
    "lucide-react"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/react/button.json"
  ],
  "description": "Multi-step guided overlay walkthrough. Highlights a target element with a dim mask cutout and shows a card next to it. Steps support targets by selector, ref, or function; centered (no-target) steps work as modal-style intros.",
  "categories": [
    "overlay"
  ]
}