"use client" /** * Exxat DS Wizard — sequential stepper (not Tabs). * * UX policy (see apps/web/docs/wizard-pattern.md): * - WIZARD_RECOMMENDED_MAX_STEPS = 6 — prefer fewer top-level chapters * - WIZARD_SCROLL_THRESHOLD = 5 — horizontal scroll + auto-scroll active step * - Horizontal overflow: HorizontalScrollControls (exxat-horizontal-scroll.mdc) * * Variants: numbered | icons | compact * Orientations: horizontal | vertical */ import * as React from "react" import { cn } from "../../lib/utils" import { Button } from "./button" import { HorizontalScrollControls, HorizontalScrollViewport, useHorizontalScrollAffordances, useHorizontalScrollItemIntoView, } from "./horizontal-scroll-controls" /** PatternFly / USWDS: prefer ≤6 top-level chapters; split long flows instead. */ export const WIZARD_RECOMMENDED_MAX_STEPS = 6 /** Above this count the nav scrolls and compact variant is recommended horizontally. */ export const WIZARD_SCROLL_THRESHOLD = 5 export interface WizardStep { id: string label: string description?: string /** Font Awesome class, e.g. `fa-user-graduate` */ icon?: string /** When set, step renders in error state (validation failed). */ error?: boolean } /** Sequential stepper states — not interchangeable with Tabs. */ export type WizardStepStatus = | "completed" | "current" | "upcoming" | "disabled" | "error" type WizardOrientation = "horizontal" | "vertical" type WizardVariant = "numbered" | "icons" | "compact" interface WizardContextValue { steps: WizardStep[] current: number orientation: WizardOrientation variant: WizardVariant linear: boolean onStepClick?: (index: number) => void } const WizardContext = React.createContext(null) function useWizard() { const ctx = React.useContext(WizardContext) if (!ctx) { throw new Error("Wizard subcomponents must be used within ") } return ctx } function resolveStepStatus( index: number, current: number, step: WizardStep, linear: boolean, ): WizardStepStatus { if (step.error && index === current) return "error" if (index < current) return "completed" if (index === current) return "current" if (linear) return "disabled" return "upcoming" } function wizardMarkerClass(status: WizardStepStatus, variant: WizardVariant) { return cn( "flex shrink-0 items-center justify-center rounded-full border-2 text-sm font-semibold transition-colors", variant === "compact" ? "size-6" : "size-9", status === "completed" && "border-brand bg-brand text-brand-foreground", status === "current" && "border-brand bg-brand-tint-subtle text-brand", status === "upcoming" && "border-border bg-muted/30 text-muted-foreground", status === "disabled" && "border-border bg-muted/30 text-muted-foreground", status === "error" && "border-destructive bg-destructive/15 text-destructive-ink", ) } function wizardLabelClass(status: WizardStepStatus) { return cn( "text-sm leading-4", status === "current" && "font-medium text-foreground", status === "completed" && "font-medium text-brand dark:text-foreground hc:text-foreground", status === "upcoming" && "text-muted-foreground", status === "disabled" && "text-muted-foreground", status === "error" && "font-medium text-destructive-ink", ) } function wizardConnectorClass(status: WizardStepStatus) { return cn( "shrink-0 rounded-full bg-border transition-colors", status === "completed" && "bg-brand", ) } function statusHint(status: WizardStepStatus): string { switch (status) { case "completed": return "completed" case "current": return "current step" case "disabled": return "not yet available" case "error": return "has errors" default: return "upcoming" } } interface WizardProps { steps: WizardStep[] current: number orientation?: WizardOrientation variant?: WizardVariant linear?: boolean onStepClick?: (index: number) => void className?: string children: React.ReactNode } function Wizard({ steps, current, orientation = "horizontal", variant = "numbered", linear = true, onStepClick, className, children, }: WizardProps) { if (process.env.NODE_ENV !== "production" && steps.length > WIZARD_RECOMMENDED_MAX_STEPS) { console.warn( `[Wizard] ${steps.length} top-level steps exceeds the recommended max of ${WIZARD_RECOMMENDED_MAX_STEPS}. ` + "Split into fewer chapters or use substeps inside each panel (PatternFly / USWDS).", ) } const value = React.useMemo( () => ({ steps, current, orientation, variant, linear, onStepClick }), [steps, current, orientation, variant, linear, onStepClick], ) return (
{children}
) } function WizardProgress({ className, formatLabel, }: { className?: string formatLabel?: (current: number, total: number) => string }) { const { steps, current } = useWizard() const total = steps.length const label = formatLabel ? formatLabel(current + 1, total) : `Step ${current + 1} of ${total}` return (

Progress: {label}

) } /** Optional guidance when the flow has many top-level steps. */ function WizardStepGuidance({ className }: { className?: string }) { const { steps } = useWizard() if (steps.length <= WIZARD_RECOMMENDED_MAX_STEPS) return null return (

Long flows work best with {WIZARD_RECOMMENDED_MAX_STEPS} or fewer top-level steps. Consider grouping fields inside each step or switching to a vertical rail with scroll.

) } function WizardNav({ className }: { className?: string }) { const { steps, current, orientation, variant, linear, onStepClick } = useWizard() const scrollRef = React.useRef(null) const activeStepRef = React.useRef(null) const { canScrollLeft, canScrollRight, overflowing, scrollPrev, scrollNext, } = useHorizontalScrollAffordances(scrollRef) useHorizontalScrollItemIntoView(scrollRef, activeStepRef, [current, steps.length]) React.useLayoutEffect(() => { if (orientation !== "vertical") return activeStepRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth", }) }, [current, orientation, steps.length]) const listContent = steps.map((step, idx) => { const status = resolveStepStatus(idx, current, step, linear) const isLast = idx === steps.length - 1 const clickable = Boolean(onStepClick) && status === "completed" const statusText = statusHint(status) const isActive = status === "current" const markerContent = status === "completed" ? (