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

View File

@ -2,7 +2,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: var(--codex-background); background-color: var(--codex-background);
padding: 1.5rem;
border-radius: var(--codex-border-radius); border-radius: var(--codex-border-radius);
} }
@ -160,6 +159,10 @@
position: absolute; position: absolute;
top: 5px; top: 5px;
} }
.stepper-body {
min-width: 500px;
}
} }
@media (max-width: 800px) { @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 { Placeholder } from "./components/Placeholder/Placeholder";
export { Sheets } from "./components/Sheets/Sheets"; export { Sheets } from "./components/Sheets/Sheets";
export { Tabs } from "./components/Tabs/Tabs"; export { Tabs } from "./components/Tabs/Tabs";
export * from "./components/Stepper/useStepperReducer";
export { Modal } from "./components/Modal/Modal"; 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 type { Meta } from "@storybook/react";
import { Stepper } from "../src/components/Stepper/Stepper"; import { Stepper } from "../src/components/Stepper/Stepper";
import React, { useState } from "react";
import { fn } from "@storybook/test"; import { fn } from "@storybook/test";
import { useStepperReducer } from "../src/components/Stepper/useStepperReducer";
import { useEffect } from "react";
import "./Stepper.stories.css";
const meta = { const meta = {
title: "Advanced/Stepper", title: "Advanced/Stepper",
@ -11,43 +13,49 @@ const meta = {
}, },
tags: ["autodocs"], tags: ["autodocs"],
argTypes: {}, argTypes: {},
args: { onChangeStep: fn() }, args: { onNextStep: fn() },
} satisfies Meta<typeof Stepper>; } satisfies Meta<typeof Stepper>;
export default meta; export default meta;
type Props = { type Props = {
onChangeStep: (s: number, state: "before" | "end") => void | Promise<void>; onNextStep: (s: number) => void | Promise<void>;
}; };
const Template = (p: Props) => { const Template = (p: Props) => {
const [step, setStep] = useState(0); const { state, dispatch } = useStepperReducer(3);
const [progress, setProgress] = useState(false);
useEffect(() => {
dispatch({
type: "toggle-next",
isNextEnable: true,
});
}, [dispatch]);
const titles = ["Hello world", "Hello world 2", "Hello world 3"]; 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") => { const onNextStep = async (step: number) => {
p.onChangeStep(newStep, event); p.onNextStep(step);
if (event === "before") { dispatch({
setProgress(true); step,
return; type: "next",
} });
setProgress(false);
setStep(newStep);
}; };
return ( return (
<Stepper <div style={{ padding: "6rem" }}>
Body={() => React.createElement("div", {}, title)} <Stepper
titles={titles} titles={titles}
onChangeStep={onChangeStep} state={state}
progress={progress} dispatch={dispatch}
step={step} onNextStep={onNextStep}
isNextDisable={progress || step === 2} className="stepper-padding"
/> >
<div>{title}</div>
</Stepper>
</div>
); );
}; };