Skip to content

Commit 1e9762f

Browse files
authored
Merge pull request #180 from pyrexfm/on-index-change
(feat) add onIndexChange
2 parents b80b72d + e717706 commit 1e9762f

File tree

5 files changed

+97
-19
lines changed

5 files changed

+97
-19
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ Example: pass a footer component that contains a "previous" and "next" button to
8484
| startIndex | number | Indicate the wizard to start at the given step || 0 |
8585
| header | React.ReactNode | Header that is shown above the active step || |
8686
| footer | React.ReactNode | Footer that is shown below the active stepstep || |
87+
| onStepChange | (stepIndex) | Callback that will be invoked with the new step index when the wizard changes steps || |
8788
| wrapper | React.React.ReactElement | Optional wrapper that is exclusively wrapped around the active step component. It is not wrapped around the `header` and `footer` || |
8889
| children | React.ReactNode | Each child component will be treated as an individual step | ✔️ |
8990

playground/modules/wizard/simple/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import Section from '../../common/section';
77
const SimpleSection: React.FC = () => {
88
return (
99
<Section title="Simple wizard" description="mix of async and sync steps">
10-
<Wizard footer={<Footer />}>
10+
<Wizard
11+
footer={<Footer />}
12+
onStepChange={(stepIndex) => alert(`New step index is ${stepIndex}`)}
13+
>
1114
<AsyncStep number={1} />
1215
<Step number={2} />
1316
<AsyncStep number={3} />

src/types.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ export type WizardProps = {
99
startIndex?: number;
1010
/**
1111
* Optional wrapper that is exclusively wrapped around the active step component. It is not wrapped around the `header` and `footer`
12-
*
1312
* @example With `framer-motion` - `<AnimatePresence />`
1413
* ```jsx
1514
* <Wizard wrapper={<AnimatePresence exitBeforeEnter />}>
@@ -18,6 +17,8 @@ export type WizardProps = {
1817
* ```
1918
*/
2019
wrapper?: React.ReactElement;
20+
/** Callback that will be invoked with the new step index when the wizard changes steps */
21+
onStepChange?: (stepIndex: number) => void;
2122
};
2223

2324
export type WizardValues = {
@@ -31,13 +32,11 @@ export type WizardValues = {
3132
previousStep: () => void;
3233
/**
3334
* Go to the given step index
34-
*
3535
* @param stepIndex The step index, starts at 0
3636
*/
3737
goToStep: (stepIndex: number) => void;
3838
/**
3939
* Attach a callback that will be called when calling `nextStep()`
40-
*
4140
* @param handler Can be either sync or async
4241
*/
4342
handleStep: (handler: Handler) => void;

src/wizard.tsx

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ import { Handler, WizardProps } from './types';
55
import WizardContext from './wizardContext';
66

77
const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = React.memo(
8-
({ header, footer, children, wrapper: Wrapper, startIndex = 0 }) => {
8+
({
9+
header,
10+
footer,
11+
children,
12+
onStepChange,
13+
wrapper: Wrapper,
14+
startIndex = 0,
15+
}) => {
916
const [activeStep, setActiveStep] = React.useState(startIndex);
1017
const [isLoading, setIsLoading] = React.useState(false);
1118
const hasNextStep = React.useRef(true);
@@ -16,24 +23,31 @@ const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = React.memo(
1623
hasNextStep.current = activeStep < stepCount - 1;
1724
hasPreviousStep.current = activeStep > 0;
1825

19-
const goToNextStep = React.useRef(() => {
26+
const goToNextStep = React.useCallback(() => {
2027
if (hasNextStep.current) {
21-
setActiveStep((activeStep) => activeStep + 1);
28+
const newActiveStepIndex = activeStep + 1;
29+
30+
setActiveStep(newActiveStepIndex);
31+
onStepChange?.(newActiveStepIndex);
2232
}
23-
});
33+
}, [activeStep, onStepChange]);
2434

25-
const goToPreviousStep = React.useRef(() => {
35+
const goToPreviousStep = React.useCallback(() => {
2636
if (hasPreviousStep.current) {
2737
nextStepHandler.current = null;
28-
setActiveStep((activeStep) => activeStep - 1);
38+
const newActiveStepIndex = activeStep - 1;
39+
40+
setActiveStep(newActiveStepIndex);
41+
onStepChange?.(newActiveStepIndex);
2942
}
30-
});
43+
}, [activeStep, onStepChange]);
3144

3245
const goToStep = React.useCallback(
3346
(stepIndex: number) => {
3447
if (stepIndex >= 0 && stepIndex < stepCount) {
3548
nextStepHandler.current = null;
3649
setActiveStep(stepIndex);
50+
onStepChange?.(stepIndex);
3751
} else {
3852
if (__DEV__) {
3953
logger.log(
@@ -46,35 +60,35 @@ const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = React.memo(
4660
}
4761
}
4862
},
49-
[stepCount],
63+
[stepCount, onStepChange],
5064
);
5165

5266
// Callback to attach the step handler
5367
const handleStep = React.useRef((handler: Handler) => {
5468
nextStepHandler.current = handler;
5569
});
5670

57-
const doNextStep = React.useRef(async () => {
71+
const doNextStep = React.useCallback(async () => {
5872
if (hasNextStep.current && nextStepHandler.current) {
5973
try {
6074
setIsLoading(true);
6175
await nextStepHandler.current();
6276
setIsLoading(false);
6377
nextStepHandler.current = null;
64-
goToNextStep.current();
78+
goToNextStep();
6579
} catch (error) {
6680
setIsLoading(false);
6781
throw error;
6882
}
6983
} else {
70-
goToNextStep.current();
84+
goToNextStep();
7185
}
72-
});
86+
}, [goToNextStep]);
7387

7488
const wizardValue = React.useMemo(
7589
() => ({
76-
nextStep: doNextStep.current,
77-
previousStep: goToPreviousStep.current,
90+
nextStep: doNextStep,
91+
previousStep: goToPreviousStep,
7892
handleStep: handleStep.current,
7993
isLoading,
8094
activeStep,
@@ -83,7 +97,14 @@ const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = React.memo(
8397
isLastStep: !hasNextStep.current,
8498
goToStep,
8599
}),
86-
[isLoading, activeStep, stepCount, goToStep],
100+
[
101+
doNextStep,
102+
goToPreviousStep,
103+
isLoading,
104+
activeStep,
105+
stepCount,
106+
goToStep,
107+
],
87108
);
88109

89110
const activeStepContent = React.useMemo(() => {

test/useWizard.test.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,4 +276,58 @@ describe('useWizard', () => {
276276
expect(result.current.isFirstStep).toBe(false);
277277
expect(result.current.isLastStep).toBe(true);
278278
});
279+
280+
describe('onStepChange()', () => {
281+
const renderUseWizardHook = (
282+
onStepChange: (index: number) => void,
283+
startIndex = 0,
284+
) => {
285+
return renderHook(() => useWizard(), {
286+
initialProps: {
287+
startIndex,
288+
onStepChange,
289+
},
290+
wrapper: ({ children, startIndex, onStepChange }) => (
291+
<Wizard startIndex={startIndex} onStepChange={onStepChange}>
292+
<p>step 1 {children}</p>
293+
<p>step 2 {children}</p>
294+
<p>step 3 {children}</p>
295+
</Wizard>
296+
),
297+
});
298+
};
299+
300+
test('should invoke onStepChange when nextStep is called', async () => {
301+
const onStepChange = jest.fn();
302+
const { result, waitForNextUpdate } = renderUseWizardHook(onStepChange);
303+
304+
result.current.nextStep();
305+
306+
await waitForNextUpdate();
307+
308+
expect(onStepChange).toHaveBeenCalledWith(1);
309+
});
310+
311+
test('should invoke onStepChange when previousStep is called', async () => {
312+
const onStepChange = jest.fn();
313+
const { result } = renderUseWizardHook(onStepChange, 1);
314+
315+
act(() => {
316+
result.current.previousStep();
317+
});
318+
319+
expect(onStepChange).toHaveBeenCalledWith(0);
320+
});
321+
322+
test('should invoke onStepChange when goToStep is called', async () => {
323+
const onStepChange = jest.fn();
324+
const { result } = renderUseWizardHook(onStepChange);
325+
326+
act(() => {
327+
result.current.goToStep(1);
328+
});
329+
330+
expect(onStepChange).toHaveBeenCalledWith(1);
331+
});
332+
});
279333
});

0 commit comments

Comments
 (0)