'use client'; import * as React from 'react'; import { cn } from '@djangocfg/ui-core/lib'; import { Button } from '@djangocfg/ui-core/components'; import { Popover, PopoverContent, PopoverAnchor, } from '@djangocfg/ui-core/components'; import { TourProvider, useTourContext } from '../context'; import type { TourProps, TourPopoverProps, TourNavigationProps, } from '../types'; export function Tour({ children, steps, onComplete, onDismiss, defaultOpen = false, }: TourProps) { const [isOpen, setIsOpen] = React.useState(defaultOpen); const [currentStep, setCurrentStep] = React.useState(0); const totalSteps = steps.length; const goToStep = React.useCallback((index: number) => { if (index >= 0 && index < steps.length) { setCurrentStep(index); } }, [steps.length]); const nextStep = React.useCallback(() => { if (currentStep < totalSteps - 1) { setCurrentStep((prev) => prev + 1); } else { setIsOpen(false); onComplete?.(); } }, [currentStep, totalSteps, onComplete]); const prevStep = React.useCallback(() => { setCurrentStep((prev) => Math.max(0, prev - 1)); }, []); const close = React.useCallback(() => { setIsOpen(false); onDismiss?.(); }, [onDismiss]); const open = React.useCallback(() => { setCurrentStep(0); setIsOpen(true); }, []); const value = React.useMemo( () => ({ isOpen, currentStep, steps, totalSteps, goToStep, nextStep, prevStep, close, open, }), [isOpen, currentStep, steps, totalSteps, goToStep, nextStep, prevStep, close, open] ); return (
{children}
); } Tour.displayName = 'Tour'; export function TourPopover({ className }: TourPopoverProps) { const { isOpen, currentStep, steps, close } = useTourContext(); const step = steps[currentStep]; const [anchorEl, setAnchorEl] = React.useState(null); React.useEffect(() => { if (!step) return; const el = document.querySelector(step.target) as HTMLElement | null; setAnchorEl(el); }, [step]); if (!isOpen || !step || !anchorEl) return null; return ( !open && close()}> ); } TourPopover.displayName = 'TourPopover'; const DEFAULT_SPOTLIGHT_PADDING = 8; interface TourSpotlightProps { className?: string; /** Pixels of breathing room around the target rectangle. Default: 8. */ padding?: number; /** Background colour of the dimmed area. Default: `rgba(0,0,0,0.6)`. */ background?: string; /** Show a ring around the highlighted target. Default: true. */ ring?: boolean; /** Close the tour when clicking outside the spotlight. Default: false. */ closeOnClick?: boolean; } /** * Spotlight overlay — full-viewport dim with a hole cut around the * current step's target element. Mount once alongside ``. */ export function TourSpotlight({ className, padding = DEFAULT_SPOTLIGHT_PADDING, background = 'rgba(0, 0, 0, 0.6)', ring = true, closeOnClick = false, }: TourSpotlightProps) { const { isOpen, currentStep, steps, close } = useTourContext(); const step = steps[currentStep]; const [rect, setRect] = React.useState(null); React.useEffect(() => { if (!isOpen || !step) { setRect(null); return; } const el = document.querySelector(step.target) as HTMLElement | null; if (!el) { setRect(null); return; } const measure = () => setRect(el.getBoundingClientRect()); measure(); const ro = new ResizeObserver(measure); ro.observe(el); window.addEventListener('resize', measure); window.addEventListener('scroll', measure, true); return () => { ro.disconnect(); window.removeEventListener('resize', measure); window.removeEventListener('scroll', measure, true); }; }, [isOpen, step]); if (!isOpen || !rect) return null; const x = Math.max(0, rect.left - padding); const y = Math.max(0, rect.top - padding); const w = rect.width + padding * 2; const h = rect.height + padding * 2; // Polygon mask: outer rectangle (viewport) minus inner rectangle (target). // Traced as a single polygon — the inner cut-out is reached via a thin // bridge along the left edge so the shape stays self-closing. const clipPath = `polygon( 0% 0%, 0% 100%, ${x}px 100%, ${x}px ${y}px, ${x + w}px ${y}px, ${x + w}px ${y + h}px, ${x}px ${y + h}px, ${x}px 100%, 100% 100%, 100% 0% )`; return ( <>