1import { CheckIcon } from "lucide-react";
2import type { ComponentProps, ReactNode } from "react";
3
4import { cn } from "../../lib/utils";
5import { AnimatedHeight } from "../AnimatedHeight";
6import { DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./dialog";
7import { ScrollArea } from "./scroll-area";
8
9/** Compose a wizard from its header, panel, and footer; flow logic stays with the caller. */
10export function WizardPopup({
11 children,
12 size = "default",
13 ...props
14}: Omit<ComponentProps<typeof DialogPopup>, "className" | "style"> & {
15 readonly size?: "default" | "wide";
16}) {
17 return (
18 <DialogPopup {...props} className={size === "wide" ? "max-w-3xl" : "max-w-xl"}>
19 <ScrollArea className="*:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4">
20 <div className="flex min-h-0 flex-col">{children}</div>
21 </ScrollArea>
22 </DialogPopup>
23 );
24}
25
26export function WizardHeader({
27 title,
28 description,
29 identity,
30 children,
31}: {
32 readonly title: ReactNode;
33 readonly description?: ReactNode;
34 /** Optional branding shown in place of the visible title. The title remains accessible. */
35 readonly identity?: ReactNode;
36 readonly children?: ReactNode;
37}) {
38 return (
39 <DialogHeader>
40 <DialogTitle className={identity ? "sr-only" : undefined}>{title}</DialogTitle>
41 {identity}
42 {description ? <DialogDescription>{description}</DialogDescription> : null}
43 {children}
44 </DialogHeader>
45 );
46}
47
48export function WizardFooter({
49 children,
50 leading,
51}: {
52 readonly children: ReactNode;
53 readonly leading?: ReactNode;
54}) {
55 return (
56 <DialogFooter variant="bare" className={leading ? "sm:justify-between" : undefined}>
57 {leading}
58 {leading ? (
59 <div className="flex flex-col-reverse gap-2 sm:flex-row">{children}</div>
60 ) : (
61 children
62 )}
63 </DialogFooter>
64 );
65}
66
67export function WizardSteps({
68 steps,
69 currentStep,
70 summaries,
71 showSummaries = false,
72 onStepChange,
73 isStepDisabled,
74}: {
75 readonly steps: readonly string[];
76 readonly currentStep: number;
77 readonly summaries?: readonly (string | null)[];
78 readonly showSummaries?: boolean;
79 readonly isStepDisabled?: (step: number) => boolean;
80 readonly onStepChange?: (step: number) => void;
81}) {
82 const Step = onStepChange ? "button" : "div";
83 return (
84 <ol
85 className="grid auto-cols-fr grid-flow-col gap-1 rounded-xl bg-background p-1 ring-1 ring-black/5 dark:bg-white/4 dark:ring-white/5"
86 role="list"
87 aria-label="Setup progress"
88 >
89 {steps.map((step, index) => (
90 <li key={step} className="min-w-0">
91 <Step
92 {...(onStepChange
93 ? { type: "button" as const, disabled: isStepDisabled?.(index) }
94 : {})}
95 className={cn(
96 "flex w-full min-w-0 items-center gap-2 rounded-lg px-2.5 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring max-sm:justify-center max-sm:px-2",
97 onStepChange &&
98 "cursor-pointer hover:bg-card disabled:cursor-default disabled:hover:bg-transparent",
99 index === currentStep &&
100 "bg-card text-foreground shadow-xs ring-1 ring-inset ring-black/5 hover:bg-card dark:shadow-none dark:ring-white/5",
101 )}
102 aria-current={index === currentStep ? "step" : undefined}
103 aria-label={`${step}, step ${index + 1}${index < currentStep && summaries?.[index] ? `, ${summaries?.[index]}` : ""}`}
104 onClick={onStepChange ? () => onStepChange(index) : undefined}
105 >
106 <span
107 className={cn(
108 "grid size-5 shrink-0 place-items-center rounded-full text-sm font-medium ring-1",
109 index < currentStep
110 ? "bg-primary text-primary-foreground ring-primary"
111 : index === currentStep
112 ? "bg-primary/10 text-primary ring-primary/30"
113 : "bg-card text-muted-foreground ring-black/10 dark:bg-white/5 dark:ring-white/10",
114 )}
115 aria-hidden
116 >
117 {index < currentStep ? <CheckIcon className="size-4 shrink-0" /> : index + 1}
118 </span>
119 <span
120 className={cn(
121 "min-w-0 truncate text-sm font-medium max-sm:hidden",
122 index === currentStep ? "text-foreground" : "text-muted-foreground",
123 )}
124 >
125 {step}
126 {showSummaries && index < currentStep && summaries?.[index]
127 ? `: ${summaries[index]}`
128 : null}
129 </span>
130 </Step>
131 </li>
132 ))}
133 </ol>
134 );
135}
136
137export function WizardPanel({
138 children,
139 holdHeight = false,
140 className,
141}: {
142 readonly children: ReactNode;
143 readonly holdHeight?: boolean;
144 readonly className?: string;
145}) {
146 return (
147 <div
148 data-slot="dialog-panel"
149 className={cn(
150 "min-w-0 space-y-4 bg-background/80 px-6 py-5 ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5",
151 className,
152 )}
153 >
154 <AnimatedHeight holdHeight={holdHeight}>{children}</AnimatedHeight>
155 </div>
156 );
157}