stepper.tsx raw
1 import { Slot } from "radix-ui";
2 import * as Stepperize from "@stepperize/react";
3 import { type VariantProps, cva } from "class-variance-authority";
4 import * as React from "react";
5
6 import { Button } from "src/components/ui/button";
7 import { cn } from "src/lib/utils";
8
9 const StepperContext = React.createContext<Stepper.ConfigProps | null>(null);
10
11 const useStepperProvider = (): Stepper.ConfigProps => {
12 const context = React.useContext(StepperContext);
13 if (!context) {
14 throw new Error("useStepper must be used within a StepperProvider.");
15 }
16 return context;
17 };
18
19 const defineStepper = <const Steps extends Stepperize.Step[]>(
20 ...steps: Steps
21 ): Stepper.DefineProps<Steps> => {
22 const { Scoped, useStepper, ...rest } = Stepperize.defineStepper(...steps);
23
24 const StepperContainer = ({
25 children,
26 className,
27 ...props
28 }: Omit<React.ComponentProps<"div">, "children"> & {
29 children:
30 | React.ReactNode
31 | ((props: { methods: Stepperize.Stepper<Steps> }) => React.ReactNode);
32 }) => {
33 const methods = useStepper();
34
35 return (
36 <div
37 date-component="stepper"
38 className={cn("w-full", className)}
39 {...props}
40 >
41 {typeof children === "function" ? children({ methods }) : children}
42 </div>
43 );
44 };
45
46 return {
47 ...rest,
48 useStepper,
49 Stepper: {
50 Provider: ({
51 variant = "horizontal",
52 labelOrientation = "horizontal",
53 tracking = false,
54 children,
55 className,
56 ...props
57 }) => {
58 return (
59 <StepperContext.Provider
60 value={{ variant, labelOrientation, tracking }}
61 >
62 <Scoped
63 initialStep={props.initialStep}
64 initialMetadata={props.initialMetadata}
65 >
66 <StepperContainer className={className} {...props}>
67 {children}
68 </StepperContainer>
69 </Scoped>
70 </StepperContext.Provider>
71 );
72 },
73 Navigation: ({
74 children,
75 "aria-label": ariaLabel = "Stepper Navigation",
76 ...props
77 }) => {
78 const { variant } = useStepperProvider();
79 return (
80 <nav
81 date-component="stepper-navigation"
82 aria-label={ariaLabel}
83 role="tablist"
84 {...props}
85 >
86 <ol
87 date-component="stepper-navigation-list"
88 className={classForNavigationList({ variant: variant })}
89 >
90 {children}
91 </ol>
92 </nav>
93 );
94 },
95 Step: ({ children, className, icon, ...props }) => {
96 const { variant, labelOrientation } = useStepperProvider();
97 const stepper = useStepper();
98 const currentStep = stepper.state.current.data;
99 const currentIndex = stepper.state.current.index;
100 const allSteps = stepper.state.all;
101
102 const stepIndex = stepper.lookup.getIndex(props.of);
103 const step = allSteps[stepIndex];
104
105 const isLast = stepper.lookup.getLast().id === props.of;
106 const isActive = currentStep.id === props.of;
107
108 const dataState = getStepState(currentIndex, stepIndex);
109 const childMap = useStepChildren(children);
110
111 const title = childMap.get("title");
112 const description = childMap.get("description");
113 const panel = childMap.get("panel");
114
115 if (variant === "circle") {
116 return (
117 <li
118 date-component="stepper-step"
119 className={cn(
120 "flex shrink-0 items-center gap-4 rounded-md transition-colors",
121 className
122 )}
123 >
124 <CircleStepIndicator
125 currentStep={stepIndex + 1}
126 totalSteps={allSteps.length}
127 />
128 <div
129 date-component="stepper-step-content"
130 className="flex flex-col items-start gap-1"
131 >
132 {title}
133 {description}
134 </div>
135 </li>
136 );
137 }
138
139 return (
140 <>
141 <li
142 date-component="stepper-step"
143 className={cn([
144 "group peer relative flex items-center gap-2",
145 "data-[variant=vertical]:flex-row",
146 "data-[label-orientation=vertical]:w-full",
147 "data-[label-orientation=vertical]:flex-col",
148 "data-[label-orientation=vertical]:justify-center",
149 ])}
150 data-variant={variant}
151 data-label-orientation={labelOrientation}
152 data-state={dataState}
153 data-disabled={props.disabled}
154 >
155 <Button
156 id={`step-${step.id}`}
157 date-component="stepper-step-indicator"
158 type="button"
159 role="tab"
160 tabIndex={dataState !== "inactive" ? 0 : -1}
161 className="rounded-full"
162 variant={dataState !== "inactive" ? "default" : "secondary"}
163 size="icon"
164 aria-controls={`step-panel-${props.of}`}
165 aria-current={isActive ? "step" : undefined}
166 aria-posinset={stepIndex + 1}
167 aria-setsize={allSteps.length}
168 aria-selected={isActive}
169 onKeyDown={(e) =>
170 onStepKeyDown(
171 e,
172 stepper.lookup.getNext(props.of),
173 stepper.lookup.getPrev(props.of)
174 )
175 }
176 {...props}
177 >
178 {icon ?? stepIndex + 1}
179 </Button>
180 {variant === "horizontal" && labelOrientation === "vertical" && (
181 <StepperSeparator
182 orientation="horizontal"
183 labelOrientation={labelOrientation}
184 isLast={isLast}
185 state={dataState}
186 disabled={props.disabled}
187 />
188 )}
189 <div
190 date-component="stepper-step-content"
191 className="flex flex-col items-start"
192 >
193 {title}
194 {description}
195 </div>
196 </li>
197
198 {variant === "horizontal" && labelOrientation === "horizontal" && (
199 <StepperSeparator
200 orientation="horizontal"
201 isLast={isLast}
202 state={dataState}
203 disabled={props.disabled}
204 />
205 )}
206
207 {variant === "vertical" && (
208 <div className="flex gap-4">
209 {!isLast && (
210 <div className="flex justify-center ps-[calc(var(--spacing)*4.5-1px)]">
211 <StepperSeparator
212 orientation="vertical"
213 isLast={isLast}
214 state={dataState}
215 disabled={props.disabled}
216 />
217 </div>
218 )}
219 <div className="my-3 flex-1 ps-4">{panel}</div>
220 </div>
221 )}
222 </>
223 );
224 },
225 Title,
226 Description,
227 Panel: ({ children, asChild, ...props }) => {
228 const Comp = asChild ? Slot.Root : "div";
229 const { tracking } = useStepperProvider();
230
231 return (
232 <Comp
233 date-component="stepper-step-panel"
234 ref={(node) => scrollIntoStepperPanel(node, tracking)}
235 {...props}
236 >
237 {children}
238 </Comp>
239 );
240 },
241 Controls: ({ children, className, asChild, ...props }) => {
242 const Comp = asChild ? Slot.Root : "div";
243 return (
244 <Comp
245 date-component="stepper-controls"
246 className={cn("flex justify-end gap-4", className)}
247 {...props}
248 >
249 {children}
250 </Comp>
251 );
252 },
253 },
254 };
255 };
256
257 // eslint-disable-next-line react-refresh/only-export-components
258 const Title = ({
259 children,
260 className,
261 asChild,
262 ...props
263 }: React.ComponentProps<"h4"> & { asChild?: boolean }) => {
264 const Comp = asChild ? Slot.Root : "h4";
265
266 return (
267 <Comp
268 date-component="stepper-step-title"
269 className={cn("text-base font-medium", className)}
270 {...props}
271 >
272 {children}
273 </Comp>
274 );
275 };
276
277 // eslint-disable-next-line react-refresh/only-export-components
278 const Description = ({
279 children,
280 className,
281 asChild,
282 ...props
283 }: React.ComponentProps<"p"> & { asChild?: boolean }) => {
284 const Comp = asChild ? Slot.Root : "p";
285
286 return (
287 <Comp
288 date-component="stepper-step-description"
289 className={cn("text-sm text-muted-foreground", className)}
290 {...props}
291 >
292 {children}
293 </Comp>
294 );
295 };
296
297 // eslint-disable-next-line react-refresh/only-export-components
298 const StepperSeparator = ({
299 orientation,
300 isLast,
301 labelOrientation,
302 state,
303 disabled,
304 }: {
305 isLast: boolean;
306 state: string;
307 disabled?: boolean;
308 } & VariantProps<typeof classForSeparator>) => {
309 if (isLast) {
310 return null;
311 }
312 return (
313 <div
314 date-component="stepper-separator"
315 data-orientation={orientation}
316 data-state={state}
317 data-disabled={disabled}
318 role="separator"
319 tabIndex={-1}
320 className={classForSeparator({ orientation, labelOrientation })}
321 />
322 );
323 };
324
325 // eslint-disable-next-line react-refresh/only-export-components
326 const CircleStepIndicator = ({
327 currentStep,
328 totalSteps,
329 size = 80,
330 strokeWidth = 6,
331 }: Stepper.CircleStepIndicatorProps) => {
332 const radius = (size - strokeWidth) / 2;
333 const circumference = radius * 2 * Math.PI;
334 const fillPercentage = (currentStep / totalSteps) * 100;
335 const dashOffset = circumference - (circumference * fillPercentage) / 100;
336 return (
337 <div
338 date-component="stepper-step-indicator"
339 role="progressbar"
340 aria-valuenow={currentStep}
341 aria-valuemin={1}
342 aria-valuemax={totalSteps}
343 tabIndex={-1}
344 className="relative inline-flex items-center justify-center"
345 >
346 <svg width={size} height={size}>
347 <title>Step Indicator</title>
348 <circle
349 cx={size / 2}
350 cy={size / 2}
351 r={radius}
352 fill="none"
353 stroke="currentColor"
354 strokeWidth={strokeWidth}
355 className="text-muted-foreground"
356 />
357 <circle
358 cx={size / 2}
359 cy={size / 2}
360 r={radius}
361 fill="none"
362 stroke="currentColor"
363 strokeWidth={strokeWidth}
364 strokeDasharray={circumference}
365 strokeDashoffset={dashOffset}
366 className="text-primary transition-all duration-300 ease-in-out"
367 transform={`rotate(-90 ${size / 2} ${size / 2})`}
368 />
369 </svg>
370 <div className="absolute inset-0 flex items-center justify-center">
371 <span className="text-sm font-medium" aria-live="polite">
372 {currentStep} of {totalSteps}
373 </span>
374 </div>
375 </div>
376 );
377 };
378
379 const classForNavigationList = cva("flex gap-2", {
380 variants: {
381 variant: {
382 horizontal: "flex-row items-center justify-between",
383 vertical: "flex-col",
384 circle: "flex-row items-center justify-between",
385 },
386 },
387 });
388
389 const classForSeparator = cva(
390 [
391 "bg-muted",
392 "data-[state=completed]:bg-primary data-[disabled]:opacity-50",
393 "transition-all duration-300 ease-in-out",
394 ],
395 {
396 variants: {
397 orientation: {
398 horizontal: "h-0.5 flex-1",
399 vertical: "h-full w-0.5",
400 },
401 labelOrientation: {
402 vertical:
403 "absolute left-[calc(50%+30px)] right-[calc(-50%+20px)] top-5 block shrink-0",
404 },
405 },
406 }
407 );
408
409 function scrollIntoStepperPanel(node: HTMLElement | null, tracking?: boolean) {
410 if (tracking) {
411 node?.scrollIntoView({ behavior: "smooth", block: "center" });
412 }
413 }
414
415 const useStepChildren = (children: React.ReactNode) => {
416 return React.useMemo(() => extractChildren(children), [children]);
417 };
418
419 const extractChildren = (children: React.ReactNode) => {
420 const childrenArray = React.Children.toArray(children);
421 const map = new Map<string, React.ReactNode>();
422
423 for (const child of childrenArray) {
424 if (React.isValidElement(child)) {
425 if (child.type === Title) {
426 map.set("title", child);
427 } else if (child.type === Description) {
428 map.set("description", child);
429 } else {
430 map.set("panel", child);
431 }
432 }
433 }
434
435 return map;
436 };
437
438 const onStepKeyDown = (
439 e: React.KeyboardEvent<HTMLButtonElement>,
440 nextStep: Stepperize.Step | undefined,
441 prevStep: Stepperize.Step | undefined
442 ) => {
443 const { key } = e;
444 const directions = {
445 next: ["ArrowRight", "ArrowDown"],
446 prev: ["ArrowLeft", "ArrowUp"],
447 };
448
449 if (directions.next.includes(key) || directions.prev.includes(key)) {
450 const direction = directions.next.includes(key) ? "next" : "prev";
451 const step = direction === "next" ? nextStep : prevStep;
452
453 if (!step) {
454 return;
455 }
456
457 const stepElement = document.getElementById(`step-${step.id}`);
458 if (!stepElement) {
459 return;
460 }
461
462 const isActive =
463 stepElement.parentElement?.getAttribute("data-state") !== "inactive";
464 if (isActive || direction === "prev") {
465 stepElement.focus();
466 }
467 }
468 };
469
470 const getStepState = (currentIndex: number, stepIndex: number) => {
471 if (currentIndex === stepIndex) {
472 return "active";
473 }
474 if (currentIndex > stepIndex) {
475 return "completed";
476 }
477 return "inactive";
478 };
479
480 // eslint-disable-next-line @typescript-eslint/no-namespace
481 namespace Stepper {
482 export type StepperVariant = "horizontal" | "vertical" | "circle";
483 export type StepperLabelOrientation = "horizontal" | "vertical";
484
485 export type ConfigProps = {
486 variant?: StepperVariant;
487 labelOrientation?: StepperLabelOrientation;
488 tracking?: boolean;
489 };
490
491 export type DefineProps<Steps extends Stepperize.Step[]> = Omit<
492 Stepperize.StepperReturn<Steps>,
493 "Scoped" | "Stepper"
494 > & {
495 Stepper: {
496 Provider: (
497 props: Omit<Stepperize.ScopedProps<Steps>, "children"> &
498 Omit<React.ComponentProps<"div">, "children"> &
499 Stepper.ConfigProps & {
500 children:
501 | React.ReactNode
502 | ((props: {
503 methods: Stepperize.Stepper<Steps>;
504 }) => React.ReactNode);
505 }
506 ) => React.ReactElement;
507 Navigation: (props: React.ComponentProps<"nav">) => React.ReactElement;
508 Step: (
509 props: React.ComponentProps<"button"> & {
510 of: Stepperize.Get.Id<Steps>;
511 icon?: React.ReactNode;
512 }
513 ) => React.ReactElement;
514 Title: (props: AsChildProps<"h4">) => React.ReactElement;
515 Description: (props: AsChildProps<"p">) => React.ReactElement;
516 Panel: (props: AsChildProps<"div">) => React.ReactElement;
517 Controls: (props: AsChildProps<"div">) => React.ReactElement;
518 };
519 };
520
521 export type CircleStepIndicatorProps = {
522 currentStep: number;
523 totalSteps: number;
524 size?: number;
525 strokeWidth?: number;
526 };
527 }
528
529 type AsChildProps<T extends React.ElementType> = React.ComponentProps<T> & {
530 asChild?: boolean;
531 };
532
533 export { defineStepper };
534