Refactor stepper (#22)

* Refactor Stepper component

* Add stepper labels in props
This commit is contained in:
Arnaud 2024-09-20 15:35:16 +02:00 committed by GitHub
parent e81bc9ac42
commit 521b4947a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 229 additions and 67 deletions

View File

@ -1,8 +1,9 @@
import { Button } from "../Button/Button";
import "./stepper.css";
import { CSSProperties, ReactNode } from "react";
import { CSSProperties, Dispatch, ReactNode } from "react";
import { Spinner } from "../Spinner/Spinner";
import { Step } from "./Step";
import { StepperAction, StepperState } from "./useStepperReducer";
interface CustomStyleCSS extends CSSProperties {
"--codex-background"?: string;
@ -21,98 +22,117 @@ type Props = {
/**
* The current component to show.
*/
Body: ReactNode;
children: ReactNode;
// The current step to display in stepper state.
step: number;
// If it's true, the stepper will display a spinner.
// The progress is controlled by the parent component,
// to give flexibility when changing step.
progress: boolean;
style?: CustomStyleCSS;
/**
* The duration between two steps in milliseconds.
* The default is 500.
* The duration between steps
*/
duration?: number;
/**
* Callback called whenever the step is changing.
* It's working in two phase:
* "before" - allow to do before actions like updating progress state
* "end" - executed after duration time
* Dispatch function created by the useStepperReducer
*/
onChangeStep: (s: number, state: "before" | "end") => void | Promise<void>;
dispatch: Dispatch<StepperAction>;
/**
* Disable the next button.
* Default: progress == true
* State provided by useStepperReducer
*/
isNextDisable?: boolean;
state: StepperState;
style?: CustomStyleCSS;
/**
* Event called when a new step is triggered, after the loading function.
*/
onNextStep: (step: number) => void | Promise<void>;
className?: string;
/**
* Default: Back
*/
backLabel?: string;
/**
* Default: Next
*/
nextLabel?: string;
};
type Step = {
index: number;
title: string;
className: string;
};
export function Stepper({
titles,
Body,
step,
progress,
onChangeStep,
duration = 500,
isNextDisable = progress,
children,
state,
style,
dispatch,
className,
backLabel = "Back",
nextLabel = "Next",
duration = 500,
onNextStep,
}: Props) {
const onMoveStep = async (newStep: number) => {
await onChangeStep(newStep, "before");
setTimeout(() => onChangeStep(newStep, "end"), duration);
const label = state.step === titles.length - 1 ? "Finish" : "Next";
const onChangeStep = async (nextStep: number) => {
if (nextStep < 0) {
return dispatch({
type: "close",
});
}
dispatch({
type: "loading",
step: nextStep,
});
setTimeout(() => {
onNextStep(nextStep);
}, duration);
};
const label = step === titles.length - 1 ? "Finish" : "Next";
return (
<div className="stepper" style={style}>
<div className={"stepper " + className} style={style}>
<div className="stepper-steps">
{titles.map((title, index) => (
<Step
title={title}
step={index}
isActive={index === step}
isActive={index === state.step}
isLast={index === titles.length - 1}
isDone={index < step}
isDone={index < state.step}
key={title}
onClick={step > index ? () => onMoveStep(index) : undefined}
onClick={state.step > index ? () => onChangeStep(index) : undefined}
/>
))}
</div>
<div className="stepper-body">
{progress ? (
{state.progress ? (
<div className="stepper-progress">
<Spinner width={"3rem"} />
</div>
) : (
<>{Body}</>
<>{children}</>
)}
</div>
<div className="stepper-buttons">
<Button
label={step ? "Back" : "Close"}
label={backLabel}
variant="outline"
onClick={() => onMoveStep(step - 1)}
disabled={progress}
onClick={() => onChangeStep(state.step - 1)}
disabled={!state.isBackEnable}
/>
<Button
label={label}
onClick={() => onMoveStep(step + 1)}
disabled={isNextDisable}
label={nextLabel}
onClick={() => onChangeStep(state.step + 1)}
disabled={!state.isNextEnable}
/>
</div>
</div>

View File

@ -2,7 +2,6 @@
display: flex;
flex-direction: column;
background-color: var(--codex-background);
padding: 1.5rem;
border-radius: var(--codex-border-radius);
}
@ -160,6 +159,10 @@
position: absolute;
top: 5px;
}
.stepper-body {
min-width: 500px;
}
}
@media (max-width: 800px) {

View File

@ -0,0 +1,127 @@
import { Dispatch, Reducer, useReducer } from "react";
export type StepperState = {
/**
* Enable the next button.
*/
isNextEnable?: boolean;
/**
* Enable the back button.
*/
isBackEnable?: boolean;
// The current step to display in stepper state.
step: number;
// If it's true, the stepper will display a spinner.
// The progress is controlled by the parent component,
// to give flexibility when changing step.
progress: boolean;
open: boolean;
};
export type StepperAction =
| {
type: "close" | "open";
}
| {
type: "loading";
step: number;
}
| {
type: "next";
step: number;
}
| {
type: "toggle-next";
isNextEnable: boolean;
};
export type StepperBodyProps = {
dispatch: Dispatch<StepperAction>;
};
/**
* Create a reducer for the stepper.
* The storage key allows to save the step when the user
* move from a step to another.
*/
const reducer =
(steps: number) => (state: StepperState, action: StepperAction) => {
switch (action.type) {
case "close": {
return {
...state,
step: 0,
isNextDisable: true,
progress: false,
open: false,
isBackEnable: true,
};
}
case "open": {
return {
...state,
step: 0,
isNextDisable: true,
progress: false,
open: true,
isBackEnable: true,
};
}
case "loading": {
if (action.step >= steps) {
return {
...state,
step: 0,
isNextDisable: true,
progress: false,
open: false,
isBackEnable: true,
};
}
// WebStorage.set(storageKey, action.step);
return {
...state,
step: action.step,
isNextDisable: true,
progress: true,
isBackEnable: action.step != steps - 1,
};
}
case "next": {
return {
...state,
progress: false,
};
}
case "toggle-next": {
return {
...state,
isNextEnable: action.isNextEnable,
};
}
}
};
export function useStepperReducer(steps: number) {
const [state, dispatch] = useReducer<Reducer<StepperState, StepperAction>>(
reducer(steps),
{
step: 0,
isNextEnable: false,
progress: false,
open: false,
}
);
return { state, dispatch };
}

View File

@ -34,4 +34,5 @@ export { Collapse } from "./components/Collapse/Collapse";
export { Placeholder } from "./components/Placeholder/Placeholder";
export { Sheets } from "./components/Sheets/Sheets";
export { Tabs } from "./components/Tabs/Tabs";
export * from "./components/Stepper/useStepperReducer";
export { Modal } from "./components/Modal/Modal";

View File

@ -0,0 +1,3 @@
.stepper-padding {
padding: 1.5rem;
}

View File

@ -1,7 +1,9 @@
import type { Meta } from "@storybook/react";
import { Stepper } from "../src/components/Stepper/Stepper";
import React, { useState } from "react";
import { fn } from "@storybook/test";
import { useStepperReducer } from "../src/components/Stepper/useStepperReducer";
import { useEffect } from "react";
import "./Stepper.stories.css";
const meta = {
title: "Advanced/Stepper",
@ -11,43 +13,49 @@ const meta = {
},
tags: ["autodocs"],
argTypes: {},
args: { onChangeStep: fn() },
args: { onNextStep: fn() },
} satisfies Meta<typeof Stepper>;
export default meta;
type Props = {
onChangeStep: (s: number, state: "before" | "end") => void | Promise<void>;
onNextStep: (s: number) => void | Promise<void>;
};
const Template = (p: Props) => {
const [step, setStep] = useState(0);
const [progress, setProgress] = useState(false);
const { state, dispatch } = useStepperReducer(3);
useEffect(() => {
dispatch({
type: "toggle-next",
isNextEnable: true,
});
}, [dispatch]);
const titles = ["Hello world", "Hello world 2", "Hello world 3"];
const title = titles[step];
const title = titles[state.step];
const onChangeStep = (newStep: number, event: "before" | "end") => {
p.onChangeStep(newStep, event);
const onNextStep = async (step: number) => {
p.onNextStep(step);
if (event === "before") {
setProgress(true);
return;
}
setProgress(false);
setStep(newStep);
dispatch({
step,
type: "next",
});
};
return (
<div style={{ padding: "6rem" }}>
<Stepper
Body={() => React.createElement("div", {}, title)}
titles={titles}
onChangeStep={onChangeStep}
progress={progress}
step={step}
isNextDisable={progress || step === 2}
/>
state={state}
dispatch={dispatch}
onNextStep={onNextStep}
className="stepper-padding"
>
<div>{title}</div>
</Stepper>
</div>
);
};