frontend-challenges
frontend-challenges copied to clipboard
434 - Multi-Stepper Component - react-ts
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>
}