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