Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(feat) add onIndexChange #180

Merged
merged 7 commits into from
Feb 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Example: pass a footer component that contains a "previous" and "next" button to
| startIndex | number | Indicate the wizard to start at the given step | ❌ | 0 |
| header | React.ReactNode | Header that is shown above the active step | ❌ | |
| footer | React.ReactNode | Footer that is shown below the active stepstep | ❌ | |
| onStepChange | (stepIndex) | Callback that will be invoked with the new step index when the wizard changes steps | ❌ | |
| wrapper | React.React.ReactElement | Optional wrapper that is exclusively wrapped around the active step component. It is not wrapped around the `header` and `footer` | ❌ | |
| children | React.ReactNode | Each child component will be treated as an individual step | ✔️ |

Expand Down
5 changes: 4 additions & 1 deletion playground/modules/wizard/simple/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import Section from '../../common/section';
const SimpleSection: React.FC = () => {
return (
<Section title="Simple wizard" description="mix of async and sync steps">
<Wizard footer={<Footer />}>
<Wizard
footer={<Footer />}
onStepChange={(stepIndex) => alert(`New step index is ${stepIndex}`)}
>
<AsyncStep number={1} />
<Step number={2} />
<AsyncStep number={3} />
Expand Down
5 changes: 2 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export type WizardProps = {
startIndex?: number;
/**
* Optional wrapper that is exclusively wrapped around the active step component. It is not wrapped around the `header` and `footer`
*
* @example With `framer-motion` - `<AnimatePresence />`
* ```jsx
* <Wizard wrapper={<AnimatePresence exitBeforeEnter />}>
Expand All @@ -18,6 +17,8 @@ export type WizardProps = {
* ```
*/
wrapper?: React.ReactElement;
/** Callback that will be invoked with the new step index when the wizard changes steps */
onStepChange?: (stepIndex: number) => void;
};

export type WizardValues = {
Expand All @@ -31,13 +32,11 @@ export type WizardValues = {
previousStep: () => void;
/**
* Go to the given step index
*
* @param stepIndex The step index, starts at 0
*/
goToStep: (stepIndex: number) => void;
/**
* Attach a callback that will be called when calling `nextStep()`
*
* @param handler Can be either sync or async
*/
handleStep: (handler: Handler) => void;
Expand Down
51 changes: 36 additions & 15 deletions src/wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ import { Handler, WizardProps } from './types';
import WizardContext from './wizardContext';

const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = React.memo(
({ header, footer, children, wrapper: Wrapper, startIndex = 0 }) => {
({
header,
footer,
children,
onStepChange,
wrapper: Wrapper,
startIndex = 0,
}) => {
const [activeStep, setActiveStep] = React.useState(startIndex);
const [isLoading, setIsLoading] = React.useState(false);
const hasNextStep = React.useRef(true);
Expand All @@ -16,24 +23,31 @@ const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = React.memo(
hasNextStep.current = activeStep < stepCount - 1;
hasPreviousStep.current = activeStep > 0;

const goToNextStep = React.useRef(() => {
const goToNextStep = React.useCallback(() => {
if (hasNextStep.current) {
setActiveStep((activeStep) => activeStep + 1);
const newActiveStepIndex = activeStep + 1;

setActiveStep(newActiveStepIndex);
onStepChange?.(newActiveStepIndex);
}
});
}, [activeStep, onStepChange]);

const goToPreviousStep = React.useRef(() => {
const goToPreviousStep = React.useCallback(() => {
if (hasPreviousStep.current) {
nextStepHandler.current = null;
setActiveStep((activeStep) => activeStep - 1);
const newActiveStepIndex = activeStep - 1;

setActiveStep(newActiveStepIndex);
onStepChange?.(newActiveStepIndex);
}
});
}, [activeStep, onStepChange]);

const goToStep = React.useCallback(
(stepIndex: number) => {
if (stepIndex >= 0 && stepIndex < stepCount) {
nextStepHandler.current = null;
setActiveStep(stepIndex);
onStepChange?.(stepIndex);
} else {
if (__DEV__) {
logger.log(
Expand All @@ -46,35 +60,35 @@ const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = React.memo(
}
}
},
[stepCount],
[stepCount, onStepChange],
);

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

const doNextStep = React.useRef(async () => {
const doNextStep = React.useCallback(async () => {
if (hasNextStep.current && nextStepHandler.current) {
try {
setIsLoading(true);
await nextStepHandler.current();
setIsLoading(false);
nextStepHandler.current = null;
goToNextStep.current();
goToNextStep();
} catch (error) {
setIsLoading(false);
throw error;
}
} else {
goToNextStep.current();
goToNextStep();
}
});
}, [goToNextStep]);

const wizardValue = React.useMemo(
() => ({
nextStep: doNextStep.current,
previousStep: goToPreviousStep.current,
nextStep: doNextStep,
previousStep: goToPreviousStep,
handleStep: handleStep.current,
isLoading,
activeStep,
Expand All @@ -83,7 +97,14 @@ const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = React.memo(
isLastStep: !hasNextStep.current,
goToStep,
}),
[isLoading, activeStep, stepCount, goToStep],
[
doNextStep,
goToPreviousStep,
isLoading,
activeStep,
stepCount,
goToStep,
],
);

const activeStepContent = React.useMemo(() => {
Expand Down
54 changes: 54 additions & 0 deletions test/useWizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,4 +276,58 @@ describe('useWizard', () => {
expect(result.current.isFirstStep).toBe(false);
expect(result.current.isLastStep).toBe(true);
});

describe('onStepChange()', () => {
const renderUseWizardHook = (
onStepChange: (index: number) => void,
startIndex = 0,
) => {
return renderHook(() => useWizard(), {
initialProps: {
startIndex,
onStepChange,
},
wrapper: ({ children, startIndex, onStepChange }) => (
<Wizard startIndex={startIndex} onStepChange={onStepChange}>
<p>step 1 {children}</p>
<p>step 2 {children}</p>
<p>step 3 {children}</p>
</Wizard>
),
});
};

test('should invoke onStepChange when nextStep is called', async () => {
const onStepChange = jest.fn();
const { result, waitForNextUpdate } = renderUseWizardHook(onStepChange);

result.current.nextStep();

await waitForNextUpdate();

expect(onStepChange).toHaveBeenCalledWith(1);
});

test('should invoke onStepChange when previousStep is called', async () => {
const onStepChange = jest.fn();
const { result } = renderUseWizardHook(onStepChange, 1);

act(() => {
result.current.previousStep();
});

expect(onStepChange).toHaveBeenCalledWith(0);
});

test('should invoke onStepChange when goToStep is called', async () => {
const onStepChange = jest.fn();
const { result } = renderUseWizardHook(onStepChange);

act(() => {
result.current.goToStep(1);
});

expect(onStepChange).toHaveBeenCalledWith(1);
});
});
});
Loading