{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "tour",
  "title": "Tour",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/tour/tour.component.ts",
      "content": "import {\n  Component,\n  EventEmitter,\n  Input,\n  OnChanges,\n  OnDestroy,\n  Output,\n  SimpleChanges,\n  TemplateRef,\n  ViewChild,\n  ViewContainerRef,\n  booleanAttribute,\n  computed,\n  inject,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { cn } from '@/lib/utils'\nimport { UiButtonComponent } from '@/ui/button/button.component'\nimport { BodyPortal, uniqueId } from '@/ui/popper/popper'\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  finishButtonText?: string\n}\n\nconst FOCUSABLE =\n  'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'\n\nfunction prefersReducedMotion(): boolean {\n  if (typeof window === 'undefined' || !window.matchMedia) return false\n  return window.matchMedia('(prefers-reduced-motion: reduce)').matches\n}\n\nfunction resolveTarget(t: TourTarget | undefined): HTMLElement | null {\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/**\n * Angular port of UIPKGE Tour (React parity). While `open`, renders into <body> a dimming\n * SVG mask with a rounded cutout over the current step's target (plus a click-blocking\n * layer whose clip-path leaves the target clickable) and a 320px step card placed below\n * the target (above when there's no room, centered when the step has no target). The\n * card is an aria-modal dialog: focus moves into it, Tab cycles inside, Escape or the X\n * skips, and focus returns to where it was when the tour closes. The target rect tracks\n * scroll / resize. `current` mirrors into the step index; `openChange`, `currentChange`,\n * `change`, `finish` and `close` mirror React's callbacks.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-tour, [ui-tour]',\n  standalone: true,\n  imports: [UiButtonComponent],\n  // Renders nothing in place (React portals everything to <body>).\n  host: { '[attr.hidden]': '\"\"' },\n  template: `\n    <ng-template #tpl>\n      @if (currentStep(); as step) {\n        @if (showMask()) {\n          <svg\n            class=\"pointer-events-none fixed inset-0\"\n            [style.z-index]=\"zIndex\"\n            width=\"100%\"\n            height=\"100%\"\n            aria-hidden=\"true\"\n          >\n            <defs>\n              <mask [attr.id]=\"maskId\">\n                <rect width=\"100%\" height=\"100%\" fill=\"white\" />\n                @if (cutout(); as c) {\n                  <rect [attr.x]=\"c.x\" [attr.y]=\"c.y\" [attr.width]=\"c.w\" [attr.height]=\"c.h\" rx=\"6\" fill=\"black\" />\n                }\n              </mask>\n            </defs>\n            <rect\n              width=\"100%\"\n              height=\"100%\"\n              fill=\"rgba(0, 0, 0, 0.5)\"\n              [attr.mask]=\"'url(#' + maskId + ')'\"\n              [style.transition]=\"reduceMotion ? null : 'all 200ms ease'\"\n            />\n          </svg>\n          <div\n            class=\"fixed inset-0 bg-transparent\"\n            aria-hidden=\"true\"\n            [style.z-index]=\"zIndex\"\n            [style.clip-path]=\"hitClipPath()\"\n          ></div>\n        }\n        <div\n          data-slot=\"tour-card\"\n          role=\"dialog\"\n          aria-modal=\"true\"\n          [attr.aria-labelledby]=\"titleId\"\n          [attr.aria-describedby]=\"step.description ? descriptionId : null\"\n          tabindex=\"-1\"\n          [class]=\"cardClass\"\n          [style]=\"cardStyle()\"\n          (keydown)=\"onCardKeydown($event)\"\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)=\"skip()\"\n          >\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width=\"24\"\n              height=\"24\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              class=\"lucide lucide-x size-4\"\n              aria-hidden=\"true\"\n            >\n              <path d=\"M18 6 6 18\" />\n              <path d=\"m6 6 12 12\" />\n            </svg>\n          </button>\n          @if (step.cover) {\n            <img [src]=\"step.cover\" alt=\"\" class=\"w-full rounded-md\" />\n          }\n          <div>\n            <div [id]=\"titleId\" class=\"pr-6 font-semibold\">{{ step.title }}</div>\n            @if (step.description) {\n              <div [id]=\"descriptionId\" class=\"mt-1 text-sm opacity-90\">{{ step.description }}</div>\n            }\n          </div>\n          <div class=\"flex items-center justify-between gap-2 pt-2\">\n            <div class=\"text-xs tabular-nums opacity-70\" aria-live=\"polite\" aria-atomic=\"true\">\n              {{ stepIndex() + 1 }} / {{ steps.length }}\n            </div>\n            <div class=\"flex gap-2\">\n              @if (stepIndex() !== 0) {\n                <button ui-button size=\"sm\" [variant]=\"type === 'primary' ? 'secondary' : 'outline'\" (click)=\"prev()\">\n                  {{ step.prevButtonText ?? 'Previous' }}\n                </button>\n              }\n              @if (stepIndex() !== steps.length - 1) {\n                <button ui-button size=\"sm\" [variant]=\"type === 'primary' ? 'secondary' : 'default'\" (click)=\"next()\">\n                  {{ step.nextButtonText ?? 'Next' }}\n                </button>\n              } @else {\n                <button\n                  ui-button\n                  size=\"sm\"\n                  [variant]=\"type === 'primary' ? 'secondary' : 'default'\"\n                  (click)=\"finishTour()\"\n                >\n                  {{ step.finishButtonText ?? 'Finish' }}\n                </button>\n              }\n            </div>\n          </div>\n        </div>\n      }\n    </ng-template>\n  `,\n})\nexport class UiTourComponent implements OnChanges, OnDestroy {\n  @Input({ transform: booleanAttribute }) open = false\n  @Input() current = 0\n  @Input() steps: TourStep[] = []\n  @Input({ transform: booleanAttribute }) mask = true\n  @Input() type: 'default' | 'primary' = 'default'\n  @Input() zIndex = 1000\n  /** Controlled open updates (React `onOpenChange`). */\n  @Output() openChange = new EventEmitter<boolean>()\n  /** Controlled current-step updates (React `onCurrentChange`). */\n  @Output() currentChange = new EventEmitter<number>()\n  /** Fired whenever the active step changes (React `onChange`). */\n  @Output() change = new EventEmitter<number>()\n  /** Fired when the user presses Finish on the last step. */\n  @Output() finish = new EventEmitter<void>()\n  /** Fired when the user dismisses the tour (close button / Escape). */\n  @Output() close = new EventEmitter<void>()\n\n  @ViewChild('tpl', { static: true }) tpl!: TemplateRef<unknown>\n\n  readonly maskId = uniqueId('uipkge-tour-mask')\n  readonly titleId = uniqueId('tour-title')\n  readonly descriptionId = uniqueId('tour-description')\n  readonly reduceMotion = prefersReducedMotion()\n\n  readonly stepIndex = signal(0)\n  readonly rect = signal<TargetRect | null>(null)\n  readonly measuredHeight = signal(0)\n  private readonly stepsSig = signal<TourStep[]>([])\n  private readonly maskSig = signal(true)\n  private readonly zIndexSig = signal(1000)\n\n  readonly currentStep = computed(() => this.stepsSig()[this.stepIndex()] ?? null)\n  readonly showMask = computed(() => {\n    const step = this.currentStep()\n    return step?.mask !== undefined ? step.mask : this.maskSig()\n  })\n  readonly cutout = computed(() => {\n    const r = this.rect()\n    const padding = 4\n    return r ? { x: r.x - padding, y: r.y - padding, w: r.width + padding * 2, h: r.height + padding * 2 } : null\n  })\n  /** Clip-path leaves a hole over the target so pointer events reach it (the SVG mask doesn't). */\n  readonly hitClipPath = computed(() => {\n    const c = this.cutout()\n    return c\n      ? `polygon(evenodd, 0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, ${c.x}px ${c.y}px, ${c.x}px ${c.y + c.h}px, ${c.x + c.w}px ${c.y + c.h}px, ${c.x + c.w}px ${c.y}px, ${c.x}px ${c.y}px)`\n      : null\n  })\n  readonly cardStyle = computed<Record<string, string | number>>(() => {\n    const cardWidth = 320\n    const margin = 12\n    const edgePadding = 8\n    const zIndex = this.zIndexSig() + 1\n    const rect = this.rect()\n    // Prefer measured height; estimate only before first layout (cover makes card taller).\n    const cardHeight = this.measuredHeight() || (this.currentStep()?.cover ? 320 : 200)\n    if (!rect) {\n      const style: Record<string, string | number> = {\n        position: 'fixed',\n        top: '50%',\n        left: '50%',\n        transform: 'translate(-50%, -50%)',\n        width: `${cardWidth}px`,\n        'z-index': zIndex,\n      }\n      return style\n    }\n    const viewportH = window.innerHeight\n    const viewportW = window.innerWidth\n    const placeBelow = rect.y + rect.height + margin + cardHeight < viewportH\n    const top = placeBelow ? rect.y + rect.height + margin : Math.max(edgePadding, rect.y - margin - cardHeight)\n    let left = rect.x\n    if (left + cardWidth > viewportW - edgePadding) left = viewportW - cardWidth - edgePadding\n    if (left < edgePadding) left = edgePadding\n    const style: Record<string, string | number> = {\n      position: 'fixed',\n      top: `${top}px`,\n      left: `${left}px`,\n      width: `${cardWidth}px`,\n      'z-index': zIndex,\n    }\n    return style\n  })\n\n  private readonly portal = new BodyPortal(inject(ViewContainerRef))\n  private previousFocus: HTMLElement | null = null\n  private wasOpen = false\n  private targetEl: HTMLElement | null = null\n  private targetObserver: ResizeObserver | null = null\n  private cardObserver: ResizeObserver | null = null\n  private raf = 0\n  private settleTimer: ReturnType<typeof setTimeout> | null = null\n  private readonly measure = () => {\n    cancelAnimationFrame(this.raf)\n    this.raf = requestAnimationFrame(() => {\n      if (!this.targetEl) return this.rect.set(null)\n      const r = this.targetEl.getBoundingClientRect()\n      this.rect.set({ x: r.left, y: r.top, width: r.width, height: r.height })\n    })\n  }\n  private readonly onDocKeydown = (e: KeyboardEvent) => {\n    if (e.key !== 'Escape') return\n    e.preventDefault()\n    this.skip()\n  }\n\n  get cardClass(): string {\n    return cn(\n      'relative space-y-3 rounded-lg border p-4 shadow-lg outline-none',\n      this.type === 'primary'\n        ? 'bg-primary text-primary-foreground border-primary'\n        : 'bg-popover text-popover-foreground',\n    )\n  }\n\n  ngOnChanges(changes: SimpleChanges): void {\n    this.stepsSig.set(this.steps)\n    this.maskSig.set(this.mask)\n    this.zIndexSig.set(this.zIndex)\n    // Mirror the `current` input into local state (Vue `watch(() => props.current)`).\n    if (changes['current']) this.stepIndex.set(this.current)\n    if (changes['open'] || changes['current'] || changes['steps']) this.sync()\n  }\n\n  /** Attach + scroll the target into view on open / step change; tear down + restore focus on close. */\n  private sync(): void {\n    this.clearSettle()\n    if (!this.open || !this.currentStep()) {\n      this.detachTarget()\n      this.teardownPortal()\n      if (!this.open && this.wasOpen) {\n        this.previousFocus?.focus?.()\n        this.previousFocus = null\n        this.wasOpen = false\n      }\n      return\n    }\n    if (!this.wasOpen) {\n      this.previousFocus = (document.activeElement as HTMLElement | null) ?? null\n      this.wasOpen = true\n    }\n    this.attachTarget()\n    if (!this.portal.attached) {\n      const host = this.portal.attach(this.tpl)\n      document.addEventListener('keydown', this.onDocKeydown)\n      const card = host.querySelector<HTMLElement>('[data-slot=\"tour-card\"]')\n      if (card && typeof ResizeObserver !== 'undefined') {\n        this.cardObserver = new ResizeObserver(() => this.measuredHeight.set(card.getBoundingClientRect().height))\n        this.cardObserver.observe(card)\n      }\n    }\n    // Move focus into the card on open and on every step change.\n    queueMicrotask(() => this.cardEl()?.focus())\n    const el = this.targetEl\n    if (el) {\n      const reduce = prefersReducedMotion()\n      el.scrollIntoView?.({ behavior: reduce ? 'auto' : 'smooth', block: 'center' })\n      // Re-measure after smooth scroll settles.\n      this.settleTimer = setTimeout(this.measure, reduce ? 0 : 320)\n    }\n  }\n\n  private cardEl(): HTMLElement | null {\n    return document.querySelector<HTMLElement>(`[aria-labelledby=\"${this.titleId}\"]`)\n  }\n\n  private attachTarget(): void {\n    this.detachTarget()\n    this.targetEl = resolveTarget(this.currentStep()?.target)\n    if (!this.targetEl) {\n      this.rect.set(null)\n      return\n    }\n    this.measure()\n    if (typeof ResizeObserver !== 'undefined') {\n      this.targetObserver = new ResizeObserver(this.measure)\n      this.targetObserver.observe(this.targetEl)\n      this.targetObserver.observe(document.documentElement)\n    }\n    window.addEventListener('scroll', this.measure, { passive: true, capture: true })\n    window.addEventListener('resize', this.measure, { passive: true })\n  }\n\n  private detachTarget(): void {\n    this.targetObserver?.disconnect()\n    this.targetObserver = null\n    window.removeEventListener('scroll', this.measure, true)\n    window.removeEventListener('resize', this.measure)\n    cancelAnimationFrame(this.raf)\n    this.targetEl = null\n  }\n\n  private teardownPortal(): void {\n    if (!this.portal.attached) return\n    document.removeEventListener('keydown', this.onDocKeydown)\n    this.cardObserver?.disconnect()\n    this.cardObserver = null\n    this.measuredHeight.set(0)\n    this.portal.detach()\n  }\n\n  private clearSettle(): void {\n    if (this.settleTimer) clearTimeout(this.settleTimer)\n    this.settleTimer = null\n  }\n\n  private setStep(i: number): void {\n    this.stepIndex.set(i)\n    this.currentChange.emit(i)\n    this.change.emit(i)\n    this.sync()\n  }\n\n  next(): void {\n    if (this.stepIndex() < this.steps.length - 1) this.setStep(this.stepIndex() + 1)\n  }\n\n  prev(): void {\n    if (this.stepIndex() > 0) this.setStep(this.stepIndex() - 1)\n  }\n\n  finishTour(): void {\n    this.finish.emit()\n    this.openChange.emit(false)\n  }\n\n  skip(): void {\n    this.close.emit()\n    this.openChange.emit(false)\n  }\n\n  /** Keep Tab cycling inside the dialog while aria-modal is asserted. */\n  onCardKeydown(e: KeyboardEvent): void {\n    const card = e.currentTarget as HTMLElement\n    if (e.key !== 'Tab') return\n    // getClientRects over offsetParent: fixed-position descendants report a null offsetParent.\n    const list = Array.from(card.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(\n      (el) => el.getClientRects().length > 0,\n    )\n    if (list.length === 0) return\n    const first = list[0]\n    const last = list[list.length - 1]\n    if (e.shiftKey) {\n      if (document.activeElement === first || document.activeElement === card) {\n        e.preventDefault()\n        last.focus()\n      }\n    } else if (document.activeElement === last) {\n      e.preventDefault()\n      first.focus()\n    }\n  }\n\n  ngOnDestroy(): void {\n    this.clearSettle()\n    this.detachTarget()\n    this.teardownPortal()\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/tour/tour.component.ts"
    },
    {
      "path": "packages/registry-angular/components/tour/index.ts",
      "content": "export { UiTourComponent, type TourStep, type TourTarget, type TargetRect } from './tour.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/tour/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/button.json",
    "https://uipkge.dev/r/angular/popper.json"
  ],
  "description": "Multi-step guided overlay walkthrough. Highlights a target element with a dim mask cutout and shows a card next to it. Targets by selector, element or getter; per-step mask and button labels; cover images; primary type; centered (no-target) steps; Escape / close button, focus trap and focus restore.",
  "categories": [
    "overlay"
  ]
}