frontend-challenges icon indicating copy to clipboard operation
frontend-challenges copied to clipboard

434 - Multi-Stepper Component - react-ts

Open jsartisan opened this issue 2 months ago • 0 comments

styles.css

body {
  font-family: sans-serif;
}

h1 {
  font-size: 1.5rem;
}

span[aria-disabled="true"] {
  pointer-events: none;
  opacity: 0.5;
}

App.tsx

import React, { useState, useRef } from "react";

import { Step } from "./Step";
import { Stepper, type StepperRefType } from "./Stepper";

const App = () => {
  const stepperRef = useRef<StepperRefType>(null);
  const steps = ["Login", "Shipping", "Payment", "Review"];
  const [currentStep, setCurrentStep] = useState(0);

  return (
    <div style={{ padding: 20 }}>
      <h2>Checkout Flow</h2>

      <Stepper
        ref={stepperRef}
        currentStep={currentStep}
        setCurrentStep={setCurrentStep}
      >
        {steps.map((step, index) => (
          <Step id={index}>
            <button>{step}</button>
          </Step>
        ))}
      </Stepper>

      <div>{`You are on the ${steps[currentStep]} step`}</div>
      <button onClick={() => stepperRef?.current?.prev()}>Prev</button>
      <button onClick={() => stepperRef?.current?.next()}>Next</button>
      <button onClick={() => stepperRef?.current?.reset()}>Reset</button>
      <button onClick={() => stepperRef?.current?.goTo(2)}>
        Go to Payment
      </button>
    </div>
  );
};

export default App;

Stepper.tsx

import {
  createContext,
  useState,
  Children,
  type PropsWithChildren,
  useImperativeHandle,
  type Ref,
} from "react";

export type StepperContextType = {
  initialStep?: number;
  currentStep: number;
  setCurrentStep?: (step: number) => void;
  onStepChange?: (step: number) => void;
  totalSteps: number;
  allowSkip?: boolean;
};

export type StepperRefType = {
  next: () => unknown;
  prev: () => unknown;
  goTo: (step: number) => unknown;
  reset: () => void;
};

export const StepperContext = createContext<StepperContextType | null>(null);

export interface StepperProps
  extends Omit<StepperContextType, "totalSteps" | "currentStep">,
    PropsWithChildren {
  ref?: Ref<StepperRefType>;
  currentStep?: number;
}

export const Stepper = (props: StepperProps) => {
  const { initialStep, currentStep, setCurrentStep, children, ref, allowSkip } = props;
  const [_currentStep, _setCurrentStep] = useState<number>(() => {
    if ("currentStep" in props) return currentStep || 0;
    if ("initialStep" in props) return initialStep || 0;

    return 0;
  });
  const totalSteps = Children.count(children);

  const onStepChange = (value: number) => {
    typeof setCurrentStep === "function" && setCurrentStep(value);
    _setCurrentStep(value);

    props?.onStepChange?.(value);
  };

  const goTo = (step: number) => {
    if (step >= totalSteps) return;
    if (step < 0) return; 

    onStepChange(step);
  };
  const next = () => goTo(_currentStep + 1);
  const prev = () => goTo(_currentStep - 1);
  const reset = () => goTo(0);

  useImperativeHandle(ref, () => {
    return { next, prev, reset, goTo };
  }, [_currentStep]);

  return (
    <StepperContext
      value={{
        currentStep: _currentStep,
        setCurrentStep: onStepChange,
        initialStep,
        totalSteps,
        allowSkip
      }}
    >
      {children}
    </StepperContext>
  );
};

Step.tsx

import { type PropsWithChildren, use } from "react";
import { StepperContext } from "./Stepper";

type StepProps = {
  id:  number;
} & PropsWithChildren;

export const Step = (props: StepProps) => {
  const { children, id } = props;
  const context = use(StepperContext);

  if (!context) {
    throw new Error("Step must be used within a StepperProvider");
  }

  const { setCurrentStep, allowSkip, currentStep } = context;
  const isDisabled = (function() {
    if (allowSkip) return false;

    return currentStep < id;
  })();

  return <span aria-disabled={isDisabled} onClick={() => setCurrentStep?.(id)}>{children}</span>
}

jsartisan avatar Sep 07 '25 15:09 jsartisan