Skip to content
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
25 changes: 21 additions & 4 deletions packages/react-core/src/components/Wizard/examples/Wizard.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import SlackHashIcon from '@patternfly/react-icons/dist/esm/icons/slack-hash-ico
import FinishedStep from './FinishedStep';
import SampleForm from './SampleForm';

If you seek a wizard solution that allows for more composition, see the [React next](/components/wizard/react-next) tab.

## Examples

### Basic
Expand Down Expand Up @@ -54,7 +56,7 @@ class SimpleWizard extends React.Component {
render() {
const steps = [
{ name: 'First step', component: <p>Step 1 content</p> },
{ name: 'Second step', component: <p>Step 2 content</p>, isDisabled: true},
{ name: 'Second step', component: <p>Step 2 content</p>, isDisabled: true },
{ name: 'Third step', component: <p>Step 3 content</p> },
{ name: 'Fourth step', component: <p>Step 4 content</p>, isDisabled: true },
{ name: 'Review', component: <p>Review step content</p>, nextButtonText: 'Finish' }
Expand Down Expand Up @@ -145,9 +147,24 @@ class IncrementallyEnabledStepsWizard extends React.Component {

const steps = [
{ id: 'incrementally-enabled-1', name: 'First step', component: <p>Step 1 content</p> },
{ id: 'incrementally-enabled-2', name: 'Second step', component: <p>Step 2 content</p>, canJumpTo: stepIdReached >= 2 },
{ id: 'incrementally-enabled-3', name: 'Third step', component: <p>Step 3 content</p>, canJumpTo: stepIdReached >= 3 },
{ id: 'incrementally-enabled-4', name: 'Fourth step', component: <p>Step 4 content</p>, canJumpTo: stepIdReached >= 4 },
{
id: 'incrementally-enabled-2',
name: 'Second step',
component: <p>Step 2 content</p>,
canJumpTo: stepIdReached >= 2
},
{
id: 'incrementally-enabled-3',
name: 'Third step',
component: <p>Step 3 content</p>,
canJumpTo: stepIdReached >= 3
},
{
id: 'incrementally-enabled-4',
name: 'Fourth step',
component: <p>Step 4 content</p>,
canJumpTo: stepIdReached >= 4
},
{
id: 'incrementally-enabled-5',
name: 'Review',
Expand Down
184 changes: 184 additions & 0 deletions packages/react-core/src/next/components/Wizard/Wizard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import React from 'react';

import { css } from '@patternfly/react-styles';
import styles from '@patternfly/react-styles/css/components/Wizard/wizard';

import {
DefaultWizardFooterProps,
DefaultWizardNavProps,
isCustomWizardFooter,
isWizardParentStep,
WizardNavStepFunction,
CustomWizardNavFunction
} from './types';
import { buildSteps, normalizeNavStep } from './utils';
import { useWizardContext, WizardContextProvider } from './WizardContext';
import { WizardStepProps } from './WizardStep';
import { WizardFooter } from './WizardFooter';
import { WizardToggle } from './WizardToggle';

/**
* Wrapper for all steps and hosts state, including navigation helpers, within context.
* The WizardContext provided by default gives any child of wizard access to those resources.
*/

export interface WizardProps extends React.HTMLProps<HTMLDivElement> {
/** Step components */
children: React.ReactElement<WizardStepProps> | React.ReactElement<WizardStepProps>[];
/** Wizard header */
header?: React.ReactNode;
/** Wizard footer */
footer?: DefaultWizardFooterProps | React.ReactElement;
/** Default wizard nav props or a custom WizardNav (with callback) */
nav?: DefaultWizardNavProps | CustomWizardNavFunction;
/** The initial index the wizard is to start on (1 or higher). Defaults to 1. */
startIndex?: number;
/** Additional classes spread to the wizard */
className?: string;
/** Custom width of the wizard */
width?: number | string;
/** Custom height of the wizard */
height?: number | string;
/** Callback function when a step in the nav is clicked */
onNavByIndex?: WizardNavStepFunction;
/** Callback function after next button is clicked */
onNext?: WizardNavStepFunction;
/** Callback function after back button is clicked */
onBack?: WizardNavStepFunction;
/** Callback function to save at the end of the wizard, if not specified uses onClose */
onSave?: () => void;
/** Callback function to close the wizard */
onClose?: () => void;
}

export const Wizard = (props: WizardProps) => {
const { startIndex = 1, children, footer, onNavByIndex, onNext, onBack, onSave, onClose, ...internalProps } = props;
const [currentStepIndex, setCurrentStepIndex] = React.useState(startIndex);
const steps = buildSteps(children);

const goToStepByIndex = (index: number) => {
const lastStepIndex = steps.length;

if (index < 1) {
index = 1;
} else if (index > lastStepIndex) {
index = lastStepIndex;
}

const currStep = steps[index - 1];
const prevStep = steps[currentStepIndex - 1];
setCurrentStepIndex(index);

return onNavByIndex?.(normalizeNavStep(currStep), normalizeNavStep(prevStep));
};

const goToNextStep = () => {
// Save when on the last step, otherwise close
if (currentStepIndex >= steps.length) {
if (onSave) {
return onSave();
}

return onClose?.();
}

let currStep = steps[currentStepIndex];
let newStepIndex = currentStepIndex + 1;
const prevStep = steps[currentStepIndex - 1];

// Skip parent step and focus on the first sub-step if they exist
if (isWizardParentStep(currStep)) {
newStepIndex += 1;
currStep = steps[currentStepIndex + 1];
}

setCurrentStepIndex(newStepIndex);
return onNext?.(normalizeNavStep(currStep), normalizeNavStep(prevStep));
};

const goToPrevStep = () => {
if (steps.length < currentStepIndex) {
// Previous step was removed, just update the currentStep state
setCurrentStepIndex(steps.length);
} else {
let currStep = steps[currentStepIndex - 2];
let newStepIndex = currentStepIndex - 1;
const prevStep = steps[currentStepIndex - 1];

// // Skip parent step and focus on the step prior
if (isWizardParentStep(currStep)) {
newStepIndex -= 1;
currStep = steps[currentStepIndex - 3];
}

setCurrentStepIndex(newStepIndex);
return onBack?.(normalizeNavStep(currStep), normalizeNavStep(prevStep));
}
};

const goToStepById = (id: number | string) => {
const stepIndex = steps.findIndex(step => step.id === id) + 1;
stepIndex > 0 && setCurrentStepIndex(stepIndex);
};

const goToStepByName = (name: string) => {
const stepIndex = steps.findIndex(step => step.name === name) + 1;
stepIndex > 0 && setCurrentStepIndex(stepIndex);
};

return (
<WizardContextProvider
steps={steps}
currentStepIndex={currentStepIndex}
footer={isCustomWizardFooter(footer) && footer}
onNext={goToNextStep}
onBack={goToPrevStep}
onClose={onClose}
goToStepById={goToStepById}
goToStepByName={goToStepByName}
goToStepByIndex={goToStepByIndex}
>
<WizardInternal {...internalProps} footer={footer}>
{children}
</WizardInternal>
</WizardContextProvider>
);
};

// eslint-disable-next-line patternfly-react/no-anonymous-functions
const WizardInternal = ({ height, width, className, header, footer, nav, ...divProps }: WizardProps) => {
const { activeStep, steps, footer: customFooter, onNext, onBack, onClose, goToStepByIndex } = useWizardContext();

const wizardFooter = customFooter || (
<WizardFooter
activeStep={activeStep}
onNext={onNext}
onBack={onBack}
onClose={onClose}
disableBackButton={activeStep?.id === steps[0]?.id}
{...footer}
/>
);

return (
<div
className={css(styles.wizard, className)}
style={{
...(height ? { height } : {}),
...(width ? { width } : {})
}}
{...divProps}
>
{header}
<WizardToggle
steps={steps}
activeStep={activeStep}
footer={wizardFooter}
nav={nav}
goToStepByIndex={goToStepByIndex}
/>
</div>
);
};

Wizard.displayName = 'Wizard';
34 changes: 34 additions & 0 deletions packages/react-core/src/next/components/Wizard/WizardBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from 'react';

import styles from '@patternfly/react-styles/css/components/Wizard/wizard';
import { css } from '@patternfly/react-styles';

/**
* Used as a wrapper for WizardStep content, where the wrapping element is customizable.
*/

export interface WizardBodyProps {
children?: React.ReactNode | React.ReactNode[];
/** Set to true to remove the default body padding */
hasNoBodyPadding?: boolean;
/** An aria-label to use for the wrapper element */
'aria-label'?: string;
/** Sets the aria-labelledby attribute for the wrapper element */
'aria-labelledby'?: string;
/** Component used as the wrapping content container */
wrapperElement?: React.ElementType;
}

export const WizardBody = ({
children,
hasNoBodyPadding = false,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
wrapperElement: Wrapper = 'div'
}: WizardBodyProps) => (
<Wrapper aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} className={css(styles.wizardMain)}>
<div className={css(styles.wizardMainBody, hasNoBodyPadding && styles.modifiers.noPadding)}>{children}</div>
</Wrapper>
);

WizardBody.displayName = 'WizardBody';
104 changes: 104 additions & 0 deletions packages/react-core/src/next/components/Wizard/WizardContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import React from 'react';
import { WizardControlStep } from './types';
import { getActiveStep } from './utils';

export interface WizardContextProps {
/** List of steps */
steps: WizardControlStep[];
/** Active step */
activeStep: WizardControlStep;
/** Footer element */
footer: React.ReactElement;
/** Navigate to the next step */
onNext: () => void;
/** Navigate to the previous step */
onBack: () => void;
/** Close the wizard */
onClose: () => void;
/** Navigate to step by ID */
goToStepById: (id: number | string) => void;
/** Navigate to step by name */
goToStepByName: (name: string) => void;
/** Navigate to step by index */
goToStepByIndex: (index: number) => void;
/** Update the footer with any react element */
setFooter: (footer: React.ReactElement) => void;
}

export const WizardContext = React.createContext({} as WizardContextProps);

interface WizardContextRenderProps {
steps: WizardControlStep[];
activeStep: WizardControlStep;
footer: React.ReactElement;
onNext(): void;
onBack(): void;
onClose(): void;
}

export interface WizardContextProviderProps {
steps: WizardControlStep[];
currentStepIndex: number;
footer: React.ReactElement;
children: React.ReactElement | ((props: WizardContextRenderProps) => React.ReactElement);
onNext(): void;
onBack(): void;
onClose(): void;
goToStepById(id: number | string): void;
goToStepByName(name: string): void;
goToStepByIndex(index: number): void;
}

// eslint-disable-next-line patternfly-react/no-anonymous-functions
export const WizardContextProvider: React.FunctionComponent<WizardContextProviderProps> = ({
steps: initialSteps,
footer: initialFooter,
currentStepIndex,
children,
onNext,
onBack,
onClose,
goToStepById,
goToStepByName,
goToStepByIndex
}) => {
const [steps, setSteps] = React.useState(initialSteps);
const [footer, setFooter] = React.useState(initialFooter);
const activeStep = getActiveStep(steps, currentStepIndex);

// When the active step changes and the newly active step isn't visited, set the visited flag to true.
React.useEffect(() => {
if (activeStep && !activeStep?.visited) {
setSteps(prevSteps =>
prevSteps.map(step => {
if (step.id === activeStep.id) {
return { ...step, visited: true };
}

return step;
})
);
}
}, [activeStep]);

return (
<WizardContext.Provider
value={{
steps,
activeStep,
footer,
onNext,
onBack,
onClose,
goToStepById,
goToStepByName,
goToStepByIndex,
setFooter
}}
>
{typeof children === 'function' ? children({ activeStep, steps, footer, onNext, onBack, onClose }) : children}
</WizardContext.Provider>
);
};

export const useWizardContext = () => React.useContext(WizardContext);
Loading