"use client"; import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { ArrowLeft, ArrowRight, Check, LogOut, X } from "lucide-react"; import { Button } from "./button"; import { BrandLoader, type BrandLoaderVariant } from "./brand-loader"; import { Card, CardContent } from "./card"; import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"; import { cn } from "./lib/utils"; import { Alert } from "./alert"; /** Interface text for Wizard chrome. Step titles and the heading come from props. */ export interface WizardLabels { /** Visible progress count, for example "Step 2 of 4". */ stepCount: (step: number, total: number) => string; /** Accessible name of the step list. */ progress: string; saveAndExit: string; /** Icon-only exit label and tooltip in the onboarding variant. */ saveAndClose: string; /** Screen-reader prefix before an earlier step title. Keep the trailing space. */ previousStepPrefix: string; /** Screen-reader prefix before a later step title when stepNavigation="all". */ stepPrefix: string; /** Screen-reader suffix after a completed step title. */ completedSuffix: string; back: string; skip: string; /** Next button text; `nextLabel` takes precedence. */ next: string; /** Next button text while busy or loading. */ saving: string; /** Title of the alert that shows the `error` message. */ errorTitle: string; /** Brand loader label; `loadingLabel` takes precedence. */ loading: string; } export const defaultWizardLabels: WizardLabels = { stepCount: (step, total) => `Step ${step} of ${total}`, progress: "Setup progress", saveAndExit: "Save and exit", saveAndClose: "Save and close", previousStepPrefix: "Go back to ", stepPrefix: "Go to ", completedSuffix: ", completed", back: "Back", skip: "Skip for now", next: "Continue", saving: "Saving…", errorTitle: "One more thing before continuing", loading: "Loading your next step", }; /** Copies only defined overrides, so an omitted or `undefined` key keeps the English default. */ function resolveWizardLabels(overrides: Partial | undefined): WizardLabels { const labels = { ...defaultWizardLabels }; if (!overrides) return labels; for (const key of Object.keys(overrides) as (keyof WizardLabels)[]) { const value = overrides[key]; if (value !== undefined) Object.assign(labels, { [key]: value }); } return labels; } export interface WizardProps { steps: readonly { id: string; title: string; mobileTitle?: string; icon?: ReactNode }[]; activeStep: number; title: string; description?: string; children: ReactNode; busy?: boolean; /** Show the theme-aware brand loader while moving between steps. Keeps form state mounted. */ loading?: boolean; loadingLabel?: string; /** Defaults to pulse for onboarding and axis for the default wizard. */ loadingVariant?: BrandLoaderVariant; /** @deprecated The brand loader starts immediately; assembly intros are no longer shown. */ loadingIntro?: boolean; error?: string; onBack: () => void; /** Show Back. Disable when earlier setup can no longer change, such as after payment. Defaults to true. */ allowBack?: boolean; onNext: () => void; onSkip?: () => void; /** Show the skip action when a handler is provided. Disable for required steps. Defaults to true. */ allowSkip?: boolean; onExit?: () => void; /** Show save-and-exit when a handler is provided. Defaults to true. The app owns route guards. */ allowExit?: boolean; nextLabel?: string; /** Native same-tab navigation for a ready exit; avoids popup APIs in webviews. */ nextHref?: string; /** Hide Next while an external action is required; errors remain visible. Defaults to true. */ showNext?: boolean; /** Compact progress keeps the current step/count without the full step list. */ progressDisplay?: "steps" | "compact"; /** Incomplete steps stay focusable so activation can explain what is missing. */ nextBlocked?: boolean; onNextBlocked?: () => void; /** Save current work and navigate to an earlier step. */ onStepSelect?: (index: number) => void; /** The onboarding variant uses connected steps and an icon-only exit. */ variant?: "default" | "onboarding"; /** Fill a height-constrained parent; only the body scrolls. */ fill?: boolean; /** On narrow (up to 760px) or coarse-touch screens, use the host document scroll with an in-flow footer. The host must also permit document scrolling. Desktop and the default layout are unchanged. */ mobileLayout?: "default" | "document"; /** Reserve 6rem plus the device safe area below footer or inline body actions on small/touch screens for browser overlays. Desktop and the default safe-area spacing are unchanged. */ mobileBottomClearance?: "safe-area" | "browser"; /** Content-sized onboarding with a progress rail; compact progress on small screens. */ layout?: "stacked" | "sidebar"; completedSteps?: readonly string[]; /** Callers enabling all steps remain responsible for navigation guards. */ stepNavigation?: "previous" | "all"; /** Localized chrome text; omitted or `undefined` keys fall back to defaultWizardLabels. */ labels?: Partial; /** Extra footer actions, rendered before Skip and Next. The default variant wraps them when the row is full. */ footerActions?: ReactNode; className?: string; } /** Controlled, resumable wizard. Persistence and validation belong to the caller. */ export function Wizard({ steps, activeStep, title, description, children, busy, loading = false, loadingLabel, loadingVariant, loadingIntro = false, error, onBack, allowBack = true, onNext, onSkip, allowSkip = true, onExit, allowExit = true, nextLabel, nextHref, showNext = true, progressDisplay = "steps", nextBlocked, onNextBlocked, onStepSelect, variant = "default", fill = false, mobileLayout = "default", mobileBottomClearance = "safe-area", layout = "stacked", completedSteps = [], stepNavigation = "previous", labels: labelOverrides, footerActions, className, }: WizardProps) { const labels = resolveWizardLabels(labelOverrides); const hasFooterActions = footerActions != null && typeof footerActions !== "boolean"; const id = useId(); const heading = useRef(null); const viewport = useRef(null); const scrollArea = useRef(null); const loader = useRef(null); const presentedStep = useRef(activeStep); const unavailable = busy || loading; const [showBusy, setShowBusy] = useState(false); useEffect(() => { if (!unavailable) { setShowBusy(false); return; } const timer = setTimeout(() => setShowBusy(true), 150); return () => clearTimeout(timer); }, [unavailable]); useEffect(() => { if (mobileLayout !== "document" || mobileBottomClearance !== "browser") return; const root = viewport.current?.closest(".ci-wizard"); const visual = window.visualViewport; const update = () => { if (!window.matchMedia("(max-width:760px), (hover:none) and (pointer:coarse)").matches) { root?.style.removeProperty("--ci-wizard-viewport-occlusion"); return; } // Native browser controls and keyboards may reduce only the visual // viewport. Pinch zoom is not browser occlusion and must not add runway. const occlusion = visual && visual.scale === 1 ? Math.max(0, window.innerHeight - visual.height - visual.offsetTop) : 0; root?.style.setProperty("--ci-wizard-viewport-occlusion", `${occlusion}px`); }; update(); window.addEventListener("resize", update); visual?.addEventListener("resize", update); visual?.addEventListener("scroll", update); return () => { window.removeEventListener("resize", update); visual?.removeEventListener("resize", update); visual?.removeEventListener("scroll", update); root?.style.removeProperty("--ci-wizard-viewport-occlusion"); }; }, [mobileLayout, mobileBottomClearance]); useEffect(() => { const documentLayout = mobileLayout === "document" && window.matchMedia("(max-width:760px), (hover:none) and (pointer:coarse)").matches; if (loading) { loader.current?.focus({ preventScroll: true }); if (documentLayout) loader.current?.scrollIntoView?.({ block: "start", inline: "nearest", behavior: "instant" }); return; } const stepChanged = presentedStep.current !== activeStep; presentedStep.current = activeStep; if (documentLayout) { // Preserve the initial page position; reveal only a newly selected step. // Focusing without scrolling avoids browser centering behind host chrome. heading.current?.focus({ preventScroll: true }); if (stepChanged) heading.current?.scrollIntoView?.({ block: "start", inline: "nearest", behavior: "instant" }); return; } scrollArea.current?.scrollTo?.({ top: 0 }); // Short mobile screens scroll the surrounding content, including progress. const content = viewport.current?.parentElement; content?.scrollTo?.({ top: 0 }); heading.current?.focus({ preventScroll: fill }); if (fill && mobileBottomClearance === "browser" && content && heading.current && getComputedStyle(content).overflowY === "auto") { // Present the new step immediately; its progress links remain above it // in the same scroll region. CSS alone determines when this layout applies. const inset = parseFloat(getComputedStyle(content).paddingTop) || 0; content.scrollTo?.({ top: Math.max(0, content.scrollTop + heading.current.getBoundingClientRect().top - content.getBoundingClientRect().top - inset) }); } }, [activeStep, fill, loading, mobileBottomClearance, mobileLayout]); return (
{labels.stepCount(activeStep + 1, steps.length)}{progressDisplay === "compact" && <> · {steps[activeStep]?.title}} {allowExit && onExit && (variant === "onboarding" ? {labels.saveAndClose} : )}
{progressDisplay === "steps" &&
    {steps.map((s, i) => (
  1. {(i < activeStep || stepNavigation === "all") && onStepSelect ? ( ) : ( {completedSteps.includes(s.id) && i !== activeStep ? : i + 1} {s.icon} )}
  2. ))}
}

{title}

{description &&

{description}

}
{children}
{loading &&
}
{(showNext || allowBack || (allowSkip && onSkip) || hasFooterActions || error) && }
); } function StepTitle({ step }: { step: WizardProps["steps"][number] }) { return {step.title} {step.mobileTitle && {step.mobileTitle}} ; }