"use client"
/**
* CoachMark — contextual onboarding / feature-discovery popover
*
* Targets elements by CSS selector, scrolls them into view, and positions
* the popover relative to the target using Radix's virtual anchor.
*
* Variants:
* • single — standalone tip anchored to a target element
* • flow — multi-step walkthrough with prev/next and step indicator
* • image — includes a hero image above the content
* • no-image — text-only (title + description)
*
* Brand-colored background with spotlight overlay on the target element.
*
* WCAG 2.1 AA:
* • Focus trapped inside while open
* • Escape dismisses
* • aria-labelledby / aria-describedby wired automatically
* • Step indicator announced via aria-live
*/
import * as React from "react"
import { createPortal } from "react-dom"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
import type { CoachMarkState } from "../../hooks/use-coach-mark"
/* ── Variant styles ─────────────────────────────────────────────────────── */
const coachMarkVariants = cva(
"z-[60] flex flex-col overflow-hidden rounded-xl bg-brand-deep text-white shadow-xl outline-none [&_h3]:text-white hc:!bg-background hc:!text-foreground hc:[&_h3]:!text-foreground hc:!border-2 hc:!border-foreground hc:!shadow-none",
{
variants: {
size: {
default: "w-[320px]",
sm: "w-[260px]",
lg: "w-[400px]",
},
},
defaultVariants: {
size: "default",
},
}
)
/* ── Sub-components ─────────────────────────────────────────────────────── */
function CoachMarkImage({
src,
alt,
}: {
src: string
alt: string
}) {
return (
)
}
/** Decorative hero block when image variant is shown without an asset (catalog / placeholder). */
export function CoachMarkImagePlaceholder() {
return (
)
}
export interface CoachMarkCatalogFrameProps {
title: string
description: string
image?: boolean
multiStep?: boolean
stepIndex?: number
stepTotal?: number
primaryLabel?: string
className?: string
}
/**
* Static coach-mark chrome for design-system catalog previews — same tokens as {@link CoachMark}.
*/
export function CoachMarkCatalogFrame({
title,
description,
image = false,
multiStep = false,
stepIndex = 0,
stepTotal = 4,
primaryLabel,
className,
}: CoachMarkCatalogFrameProps) {
const actionLabel = primaryLabel ?? (multiStep ? "Next" : "Got it")
return (
{image ?
: null}
{title}
{description}
{multiStep ? (
) : (
)}
{multiStep ? (
) : null}
)
}
function CoachMarkStepIndicator({
current,
total,
}: {
current: number
total: number
}) {
return (
)
}
/* ── Spotlight overlay — highlights the target element ──────────────────── */
function SpotlightOverlay({
rect,
maskId,
container,
}: {
rect: { x: number; y: number; width: number; height: number }
/** Unique per coach instance — multiple flows on one page must not duplicate SVG mask ids. */
maskId: string
/** When set, overlay is absolute inside this element (catalog previews) instead of full viewport. */
container?: HTMLElement | null
}) {
const padding = 6
const borderRadius = 8
const containerRect = container?.getBoundingClientRect()
const relative = Boolean(container && containerRect)
const originX = relative ? containerRect!.left : 0
const originY = relative ? containerRect!.top : 0
const x = rect.x - originX - padding
const y = rect.y - originY - padding
const w = rect.width + padding * 2
const h = rect.height + padding * 2
const maskUrl = `url(#${maskId})`
const overlay = (
{/* Semi-transparent overlay with a cutout for the target */}
{/* Highlight ring around the target */}
)
return createPortal(overlay, relative ? container! : document.body)
}
/* ── Main component ─────────────────────────────────────────────────────── */
export interface CoachMarkProps
extends VariantProps {
/** State from useCoachMark hook */
state: CoachMarkState
/** Default popover placement side (step-level side takes priority) */
side?: "top" | "bottom" | "left" | "right"
/** Default popover alignment (step-level align takes priority) */
align?: "start" | "center" | "end"
/** Offset from anchor element in px */
sideOffset?: number
/** Label for the primary (next/done) button — defaults to "Next" / "Got it" */
nextLabel?: string
/** Label for the skip button — defaults to "Skip" */
skipLabel?: string
/** Extra className for the content container */
className?: string
/** Portal spotlight (and popover) into this element — for catalog / embedded previews. */
overlayRootRef?: React.RefObject
}
export function CoachMark({
state,
side = "bottom",
align = "center",
sideOffset = 12,
nextLabel,
skipLabel = "Skip",
size,
className,
overlayRootRef,
}: CoachMarkProps) {
const spotlightMaskId = React.useId().replace(/:/g, "")
const overlayRoot = overlayRootRef?.current ?? null
const {
isOpen,
step,
currentStep,
totalSteps,
isFlow,
isFirst,
isLast,
next,
prev,
skip,
anchorRect,
} = state
if (!isOpen || !step) return null
if (!anchorRect) return null
const titleId = `coach-mark-title-${step.id}`
const descId = `coach-mark-desc-${step.id}`
const hasImage = Boolean(step.image)
const primaryLabel = nextLabel ?? (isLast ? "Got it" : "Next")
const resolvedSide = step.side ?? side
const resolvedAlign = step.align ?? align
return (
<>
{/* Spotlight overlay */}
{/* Popover with virtual anchor */}
({
x: anchorRect.x,
y: anchorRect.y,
top: anchorRect.y,
left: anchorRect.x,
bottom: anchorRect.y + anchorRect.height,
right: anchorRect.x + anchorRect.width,
width: anchorRect.width,
height: anchorRect.height,
toJSON: () => {},
}),
},
}}
/>
e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={() => skip()}
aria-labelledby={titleId}
aria-describedby={descId}
className={cn(
coachMarkVariants({ size }),
/* animations */
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2",
"data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2",
className
)}
>
{/* Image (optional) */}
{hasImage && (
)}
{/* Body */}
{/* Title + close */}
{/* Description */}
{step.description}
{/* Footer: step indicator + actions */}
{/* Left: step dots (flow only) */}
{isFlow && (
)}
{/* Right: buttons */}
{isFlow && !isLast && (
)}
{isFlow && !isFirst && (
)}
{/* Arrow */}
>
)
}