{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "tour",
  "title": "Tour",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-vue/components/tour/Tour.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, nextTick, ref, watch } from 'vue'\nimport TourMask from './TourMask.vue'\nimport TourCard from './TourCard.vue'\nimport { useTourTarget } from './use-tour-target'\nimport type { TourStep } from '.'\n\nconst props = withDefaults(\n  defineProps<{\n    open?: boolean\n    current?: number\n    steps: TourStep[]\n    mask?: boolean\n    type?: 'default' | 'primary'\n    zIndex?: number\n  }>(),\n  {\n    open: false,\n    current: 0,\n    mask: true,\n    type: 'default',\n    zIndex: 1000,\n  },\n)\n\nconst emits = defineEmits<{\n  (e: 'update:open', v: boolean): void\n  (e: 'update:current', v: number): void\n  (e: 'change', v: number): void\n  (e: 'finish'): void\n  (e: 'close'): void\n}>()\n\nconst stepIndex = ref(props.current)\nwatch(\n  () => props.current,\n  (v) => (stepIndex.value = v),\n)\n\nconst currentStep = computed<TourStep | null>(() => props.steps[stepIndex.value] ?? null)\n\nconst targetRef = computed(() => currentStep.value?.target)\nconst { rect, attach, measure } = useTourTarget(targetRef)\n\nwatch(\n  [() => props.open, stepIndex],\n  async ([open]) => {\n    if (!open) return\n    await nextTick()\n    attach()\n    const t = currentStep.value?.target\n    if (t) {\n      const el =\n        typeof t === 'string' ? (document.querySelector(t) as HTMLElement | null) : typeof t === 'function' ? t() : t\n      el?.scrollIntoView({ behavior: 'smooth', block: 'center' })\n      setTimeout(measure, 320)\n    }\n  },\n  { immediate: true },\n)\n\nfunction setStep(i: number) {\n  stepIndex.value = i\n  emits('update:current', i)\n  emits('change', i)\n}\n\nfunction next() {\n  if (stepIndex.value < props.steps.length - 1) setStep(stepIndex.value + 1)\n}\n\nfunction prev() {\n  if (stepIndex.value > 0) setStep(stepIndex.value - 1)\n}\n\nfunction finish() {\n  emits('finish')\n  emits('update:open', false)\n}\n\nfunction skip() {\n  emits('close')\n  emits('update:open', false)\n}\n\nfunction onKeydown(e: KeyboardEvent) {\n  if (!props.open) return\n  if (e.key === 'Escape') {\n    e.preventDefault()\n    skip()\n  }\n}\n\nwatch(\n  () => props.open,\n  (v) => {\n    if (typeof document === 'undefined') return\n    if (v) document.addEventListener('keydown', onKeydown)\n    else document.removeEventListener('keydown', onKeydown)\n  },\n)\n\nconst showMask = computed(() => {\n  const stepMask = currentStep.value?.mask\n  if (stepMask !== undefined) return stepMask\n  return props.mask\n})\n</script>\n\n<template>\n  <Teleport to=\"body\">\n    <template v-if=\"open && currentStep\">\n      <TourMask v-if=\"showMask\" :rect=\"rect\" :z-index=\"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        :prev-text=\"currentStep.prevButtonText\"\n        :next-text=\"currentStep.nextButtonText\"\n        :type=\"type\"\n        :z-index=\"zIndex\"\n        @prev=\"prev\"\n        @next=\"next\"\n        @finish=\"finish\"\n        @skip=\"skip\"\n      />\n    </template>\n  </Teleport>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/tour/Tour.vue"
    },
    {
      "path": "packages/registry-vue/components/tour/TourMask.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport type { TargetRect } from './use-tour-target'\n\nconst props = defineProps<{\n  rect: TargetRect | null\n  zIndex: number\n  opacity?: number\n  padding?: number\n  radius?: number\n}>()\n\nconst opacity = computed(() => props.opacity ?? 0.5)\nconst padding = computed(() => props.padding ?? 4)\nconst radius = computed(() => props.radius ?? 6)\n\nconst cutout = computed(() => {\n  const r = props.rect\n  if (!r) return null\n  return {\n    x: r.x - padding.value,\n    y: r.y - padding.value,\n    w: r.width + padding.value * 2,\n    h: r.height + padding.value * 2,\n  }\n})\n</script>\n\n<template>\n  <svg class=\"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        <rect\n          v-if=\"cutout\"\n          :x=\"cutout.x\"\n          :y=\"cutout.y\"\n          :width=\"cutout.w\"\n          :height=\"cutout.h\"\n          :rx=\"radius\"\n          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      class=\"pointer-events-auto\"\n      :style=\"{ transition: 'all 200ms ease' }\"\n    />\n  </svg>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/tour/TourMask.vue"
    },
    {
      "path": "packages/registry-vue/components/tour/TourCard.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { X } from 'lucide-vue-next'\nimport { Button } from '@/components/ui/button'\nimport { cn } from '@/lib/utils'\nimport type { TargetRect } from './use-tour-target'\n\nconst props = withDefaults(\n  defineProps<{\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  }>(),\n  {\n    prevText: 'Previous',\n    nextText: 'Next',\n    type: 'default',\n  },\n)\n\ndefineEmits<{\n  (e: 'prev'): void\n  (e: 'next'): void\n  (e: 'finish'): void\n  (e: 'skip'): void\n}>()\n\nconst isLast = computed(() => props.current === props.total - 1)\nconst isFirst = computed(() => props.current === 0)\n\nconst cardStyle = computed(() => {\n  const cardWidth = 320\n  const margin = 12\n  if (!props.rect) {\n    return {\n      position: 'fixed' as const,\n      top: '50%',\n      left: '50%',\n      transform: 'translate(-50%, -50%)',\n      width: `${cardWidth}px`,\n      zIndex: props.zIndex + 1,\n    }\n  }\n  const { x, y, width, height } = props.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' as const,\n    top: `${top}px`,\n    left: `${left}px`,\n    width: `${cardWidth}px`,\n    zIndex: props.zIndex + 1,\n  }\n})\n</script>\n\n<template>\n  <div\n    role=\"dialog\"\n    aria-modal=\"true\"\n    :class=\"\n      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    \"\n    :style=\"cardStyle\"\n  >\n    <button\n      type=\"button\"\n      class=\"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      @click=\"$emit('skip')\"\n    >\n      <X class=\"size-4\" aria-hidden=\"true\" />\n    </button>\n\n    <img v-if=\"cover\" :src=\"cover\" alt=\"\" class=\"w-full rounded-md\" />\n\n    <div>\n      <div class=\"pr-6 font-semibold\">{{ title }}</div>\n      <div v-if=\"description\" class=\"mt-1 text-sm opacity-90\">{{ description }}</div>\n    </div>\n\n    <div class=\"flex items-center justify-between gap-2 pt-2\">\n      <div class=\"text-xs tabular-nums opacity-70\" aria-live=\"polite\">{{ current + 1 }} / {{ total }}</div>\n      <div class=\"flex gap-2\">\n        <Button\n          v-if=\"!isFirst\"\n          size=\"sm\"\n          :variant=\"type === 'primary' ? 'secondary' : 'outline'\"\n          @click=\"$emit('prev')\"\n        >\n          {{ prevText }}\n        </Button>\n        <Button v-if=\"!isLast\" size=\"sm\" :variant=\"type === 'primary' ? 'secondary' : 'default'\" @click=\"$emit('next')\">\n          {{ nextText }}\n        </Button>\n        <Button v-else size=\"sm\" :variant=\"type === 'primary' ? 'secondary' : 'default'\" @click=\"$emit('finish')\">\n          Finish\n        </Button>\n      </div>\n    </div>\n  </div>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/tour/TourCard.vue"
    },
    {
      "path": "packages/registry-vue/components/tour/use-tour-target.ts",
      "content": "import { onBeforeUnmount, ref, watch, type Ref } from 'vue'\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 function useTourTarget(target: Ref<TourTarget | undefined>) {\n  const rect = ref<TargetRect | null>(null)\n  const element = ref<HTMLElement | null>(null)\n\n  let resizeObs: ResizeObserver | null = null\n  let raf = 0\n\n  function resolve(): HTMLElement | null {\n    const t = target.value\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  function measure() {\n    cancelAnimationFrame(raf)\n    raf = requestAnimationFrame(() => {\n      if (!element.value) {\n        rect.value = null\n        return\n      }\n      const r = element.value.getBoundingClientRect()\n      rect.value = { x: r.left, y: r.top, width: r.width, height: r.height }\n    })\n  }\n\n  function attach() {\n    detach()\n    element.value = resolve()\n    if (!element.value) {\n      rect.value = null\n      return\n    }\n    measure()\n    if (typeof ResizeObserver !== 'undefined') {\n      resizeObs = new ResizeObserver(measure)\n      resizeObs.observe(element.value)\n      resizeObs.observe(document.documentElement)\n    }\n    window.addEventListener('scroll', measure, { passive: true, capture: true })\n    window.addEventListener('resize', measure, { passive: true })\n  }\n\n  function detach() {\n    resizeObs?.disconnect()\n    resizeObs = null\n    window.removeEventListener('scroll', measure, true)\n    window.removeEventListener('resize', measure)\n    cancelAnimationFrame(raf)\n  }\n\n  watch(target, attach, { immediate: false })\n\n  onBeforeUnmount(detach)\n\n  return { element, rect, attach, detach, measure }\n}\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/tour/use-tour-target.ts"
    },
    {
      "path": "packages/registry-vue/components/tour/index.ts",
      "content": "import type { TourTarget } from './use-tour-target'\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\nexport type { TourTarget } from './use-tour-target'\nexport { default as Tour } from './Tour.vue'\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/tour/index.ts"
    }
  ],
  "dependencies": [
    "lucide-vue-next"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/vue/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"
  ]
}