Skip to content

Sidebar #192

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

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,11 @@ 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 | ❌ | |
| sidebar | React.ReactNode | Sidebar that is shown after the header, before 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` | ❌ | |
| wrapper | React.React.ReactElement | Optional wrapper that is exclusively wrapped around the active step component. It is not wrapped around the `header`, `sidebar` and `footer` | ❌ | |
| sidebarAndStepWrapper | React.React.ReactElement | Optional wrapper that is exclusively wrapped around the sidebar and 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 | ✔️ |

#### Example
Expand All @@ -94,19 +96,27 @@ Example: pass a footer component that contains a "previous" and "next" button to
// Example: show the active step in the header
const Header = () => <p>I am the header component</p>;

// Example: show the a sidebar
const Sidebar = () => <p>I am the sidebar component</p>;

// Example: show the "previous" and "next" buttons in the footer
const Footer = () => <p>I am the footer component</p>;

// Example: Wrap steps in an `<AnimatePresence` from framer-motion
// Example: Wrap steps in an `<AnimatePresence` from framer-motion
const Wrapper = () => <AnimatePresence exitBeforeEnter />;

// Example: Wrap sidebar and steps in a Flexbox
const SidebarAndStepWrapper = () => <div class="flex" />;

const App = () => {
return (
<Wizard
<Wizard
startIndex={0}
header={<Header />}
sidebar={<Sidebar />}
footer={<Footer />}
wrapper={<Wrapper />}
sidebarAndStepWrapper={<SidebarAndStepWrapper />}
>
<Step1 />
<Step2 />
Expand Down Expand Up @@ -137,6 +147,7 @@ Used to retrieve all methods and properties related to your wizard. Make sure `W
| stepCount | number | The total number of steps of the wizard |
| isFirstStep | boolean | Indicate if the current step is the first step (aka no previous step) |
| isLastStep | boolean | Indicate if the current step is the last step (aka no next step) |
| steps | { name?: string; number?: string }[] | An array of objects for each step in the wizard. Each object has a `name` and `number` property corresponding to the step's name and number. |
| |

#### Example
Expand Down
5 changes: 3 additions & 2 deletions playground/components/asyncStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { styled } from 'goober';
import * as React from 'react';

import { useWizard } from '../../dist';
import { BaseWizardStep } from '../../dist/types';
import { useMockMutation } from '../hooks';

type Props = {
number: number;
type Props = BaseWizardStep & {
content: string;
};

const MOCK = [
Expand Down
53 changes: 53 additions & 0 deletions playground/components/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { styled } from 'goober';
import * as React from 'react';

import { useWizard } from '../../dist';
import { Button } from '../modules/common';

export const Nav = styled('nav')`
display: flex;
justify-content: center;
flex-direction: column;
gap: 0;

& > ul {
list-style: none;
margin: 0;
padding: 0;
}

@media screen and (min-width: 768px) {
flex-direction: row;
gap: 1rem;

& > p {
margin: initial;
}
}
`;

const Sidebar: React.FC = () => {
const { activeStep, stepCount, goToStep, steps } = useWizard();

return (
<Nav>
{stepCount > 0 && (
<ul>
{steps.map((stepName, index) => (
<li key={index}>
<Button
label={stepName.name || `Step ${stepName.number}`}
onClick={() => goToStep(index)}
disabled={index > activeStep}
>
{stepName.name}
</Button>
</li>
))}
</ul>
)}
</Nav>
);
};

export default Sidebar;
4 changes: 2 additions & 2 deletions playground/components/step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { styled } from 'goober';
import * as React from 'react';

import { useWizard } from '../../dist';
import { BaseWizardStep } from '../../dist/types';

type Props = {
number: number;
type Props = BaseWizardStep & {
withCallback?: boolean;
};

Expand Down
22 changes: 18 additions & 4 deletions playground/modules/wizard/simple/index.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import { styled } from 'goober';
import * as React from 'react';

import { Wizard } from '../../../../dist';
import { AsyncStep, Footer, Step } from '../../../components';
import Sidebar from '../../../components/sidebar';
import Section from '../../common/section';

const Flex = styled('div')`
display: flex;
width: 100%;
gap: 1rem;

& > :nth-child(2) {
flex-grow: 1;
}
`;

const SimpleSection: React.FC = () => {
return (
<Section title="Simple wizard" description="mix of async and sync steps">
<Wizard
footer={<Footer />}
sidebar={<Sidebar />}
onStepChange={(stepIndex) => alert(`New step index is ${stepIndex}`)}
sidebarAndStepWrapper={<Flex />}
>
<AsyncStep number={1} />
<Step number={2} />
<AsyncStep number={3} />
<Step number={4} />
<AsyncStep number={1} name="Async Step 1" />
<Step number={2} name="Step 2" />
<AsyncStep number={3} name="Async Step 3" />
<Step number={4} name="Step 4" />
</Wizard>
</Section>
);
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Type re-export workaround, to stay compatible with TS 3.7 and lower
import {
BaseWizardStep as _BaseWizardStep,
Handler as _Handler,
WizardProps as _WizardProps,
WizardValues as _WizardValues,
Expand All @@ -10,5 +11,6 @@ import { default as Wizard } from './wizard';
export type WizardProps = _WizardProps;
export type WizardValues = _WizardValues;
export type Handler = _Handler;
export interface BaseWizardStep extends _BaseWizardStep {}

export { Wizard, useWizard };
23 changes: 22 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ export type Handler = (() => Promise<void>) | (() => void) | null;
export type WizardProps = {
/** Optional header that is shown above the active step */
header?: React.ReactNode;
/** Optional sidebar that is shown before the active step */
sidebar?: React.ReactNode;
/** Optional footer that is shown below the active step */
footer?: React.ReactNode;
/** Optional start index @default 0 */
startIndex?: number;
/**
* Optional wrapper that is exclusively wrapped around the active step component. It is not wrapped around the `header` and `footer`
* Optional wrapper that is exclusively wrapped around the active step component. It is not wrapped around the `header`, `sidebar` and `footer`
* @example With `framer-motion` - `<AnimatePresence />`
* ```jsx
* <Wizard wrapper={<AnimatePresence exitBeforeEnter />}>
Expand All @@ -17,10 +19,27 @@ export type WizardProps = {
* ```
*/
wrapper?: React.ReactElement;
/**
* Optional wrapper that is exclusively wrapped around the sidebar and active step component. It is not wrapped around the `header` and `footer`
* @example With `framer-motion` - `<AnimatePresence />`
* ```jsx
* <Wizard wrapper={<AnimatePresence exitBeforeEnter />}>
* ...
* </Wizard>
* ```
*/
sidebarAndStepWrapper?: React.ReactElement;
/** Callback that will be invoked with the new step index when the wizard changes steps */
onStepChange?: (stepIndex: number) => void;
};

export interface BaseWizardStep {
/** The step number */
number?: number;
/** The step name */
name?: string;
}

export type WizardValues = {
/**
* Go to the next step
Expand Down Expand Up @@ -55,6 +74,8 @@ export type WizardValues = {
isFirstStep: boolean;
/** Indicate if the current step is the last step (aka no next step) */
isLastStep: boolean;
/** The labels of each step */
steps: BaseWizardStep[];
};

/** Console log levels */
Expand Down
50 changes: 47 additions & 3 deletions src/wizard.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
import * as React from 'react';

import * as logger from './logger';
import { Handler, WizardProps } from './types';
import { BaseWizardStep, Handler, WizardProps } from './types';
import WizardContext from './wizardContext';

const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = React.memo(
({
header,
sidebar,
footer,
children,
onStepChange,
wrapper: Wrapper,
startIndex = 0,
sidebarAndStepWrapper: SidebarAndStepWrapper,
}) => {
const [activeStep, setActiveStep] = React.useState(startIndex);
const [isLoading, setIsLoading] = React.useState(false);
const hasNextStep = React.useRef(true);
const hasPreviousStep = React.useRef(false);
const nextStepHandler = React.useRef<Handler>(() => {});
const stepCount = React.Children.toArray(children).length;
const stepsArray = React.Children.toArray(children);
const steps = stepsArray
.map((child) => {
if (React.isValidElement(child)) {
const number = child.props.number;
const name = child.props.name || `Step ${number}`;

return { name, number };
}
return null;
})
.filter(Boolean) as BaseWizardStep[];

hasNextStep.current = activeStep < stepCount - 1;
hasPreviousStep.current = activeStep > 0;
Expand Down Expand Up @@ -96,6 +110,7 @@ const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = React.memo(
isFirstStep: !hasPreviousStep.current,
isLastStep: !hasNextStep.current,
goToStep,
steps,
}),
[
doNextStep,
Expand All @@ -104,6 +119,7 @@ const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = React.memo(
activeStep,
stepCount,
goToStep,
steps,
],
);

Expand All @@ -126,14 +142,19 @@ const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = React.memo(
if (header && !React.isValidElement(header)) {
logger.log('error', 'Invalid header passed to <Wizard>');
}
// Invalid sidebar element
if (sidebar && !React.isValidElement(sidebar)) {
logger.log('error', 'Invalid sidebar passed to <Wizard>');
}

// Invalid footer element
if (footer && !React.isValidElement(footer)) {
logger.log('error', 'Invalid footer passed to <Wizard>');
}
}

return reactChildren[activeStep];
}, [activeStep, children, header, footer]);
}, [activeStep, children, header, sidebar, footer]);

const enhancedActiveStepContent = React.useMemo(
() =>
Expand All @@ -143,10 +164,33 @@ const Wizard: React.FC<React.PropsWithChildren<WizardProps>> = React.memo(
[Wrapper, activeStepContent],
);

const enhancedActiveStepContentWithSidebar = React.useMemo(
() =>
SidebarAndStepWrapper ? (
React.cloneElement(SidebarAndStepWrapper, {
children: (
<>
{sidebar}
{enhancedActiveStepContent}
</>
),
})
) : (
<>
{sidebar}
{enhancedActiveStepContent}
</>
),
[SidebarAndStepWrapper, sidebar, enhancedActiveStepContent],
);

return (
<WizardContext.Provider value={wizardValue}>
{header}
{enhancedActiveStepContent}
{sidebar
? enhancedActiveStepContentWithSidebar
: enhancedActiveStepContent}

{footer}
</WizardContext.Provider>
);
Expand Down