Skip to content

Commit fc1abb7

Browse files
committed
feat(WizardComposable): Created composable spinoff of wizard w/ enhancements
1 parent 71e3dd5 commit fc1abb7

17 files changed

Lines changed: 1037 additions & 1 deletion

packages/react-core/src/components/Wizard/examples/Wizard.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import SlackHashIcon from '@patternfly/react-icons/dist/esm/icons/slack-hash-ico
1313
import FinishedStep from './FinishedStep';
1414
import SampleForm from './SampleForm';
1515

16+
If you seek a Wizard solution that allows for more composition, see the [React composition](/components/wizard/react-composition) tab.
17+
1618
## Examples
1719

1820
### Basic
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import React from 'react';
2+
import { Step, SubStep } from './types';
3+
import { getActiveStep } from './utils';
4+
5+
export interface WizardComposableContextProps {
6+
steps: (Step | SubStep)[];
7+
activeStep: Step | SubStep;
8+
footer: React.ReactElement;
9+
onNext(): void;
10+
onBack(): void;
11+
onClose(): void;
12+
goToStepById(id: number | string): void;
13+
goToStepByName(name: string): void;
14+
goToStepByIndex(index: number): void;
15+
setFooter(footer: React.ReactElement): void;
16+
}
17+
18+
export const WizardComposableContext = React.createContext({} as WizardComposableContextProps);
19+
20+
interface WizardComposableContextRenderProps {
21+
steps: (Step | SubStep)[];
22+
activeStep: Step | SubStep;
23+
footer: React.ReactElement;
24+
onNext(): void;
25+
onBack(): void;
26+
onClose(): void;
27+
}
28+
29+
interface WizardComposableContextProviderProps {
30+
steps: (Step | SubStep)[];
31+
currentStepIndex: number;
32+
footer: React.ReactElement;
33+
children: React.ReactElement | ((props: WizardComposableContextRenderProps) => React.ReactElement);
34+
onNext(): void;
35+
onBack(): void;
36+
onClose(): void;
37+
goToStepById(id: number | string): void;
38+
goToStepByName(name: string): void;
39+
goToStepByIndex(index: number): void;
40+
}
41+
42+
// eslint-disable-next-line patternfly-react/no-anonymous-functions
43+
export const WizardComposableContextProvider: React.FunctionComponent<WizardComposableContextProviderProps> = ({
44+
steps: initialSteps,
45+
footer: initialFooter,
46+
currentStepIndex,
47+
children,
48+
onNext,
49+
onBack,
50+
onClose,
51+
goToStepById,
52+
goToStepByName,
53+
goToStepByIndex
54+
}) => {
55+
const [steps, setSteps] = React.useState(initialSteps);
56+
const [footer, setFooter] = React.useState(initialFooter);
57+
const activeStep = getActiveStep(steps, currentStepIndex);
58+
59+
// When the active step changes and the newly active step isn't visited, set the visited flag to true.
60+
React.useEffect(() => {
61+
if (!activeStep.visited) {
62+
setSteps(prevSteps =>
63+
prevSteps.map(step => {
64+
if (step.id === activeStep.id) {
65+
return { ...step, visited: true };
66+
}
67+
68+
return step;
69+
})
70+
);
71+
}
72+
}, [activeStep.id, activeStep.visited]);
73+
74+
return (
75+
<WizardComposableContext.Provider
76+
value={{
77+
steps,
78+
activeStep,
79+
footer,
80+
onNext,
81+
onBack,
82+
onClose,
83+
goToStepById,
84+
goToStepByName,
85+
goToStepByIndex,
86+
setFooter
87+
}}
88+
>
89+
{typeof children === 'function' ? children({ activeStep, steps, footer, onNext, onBack, onClose }) : children}
90+
</WizardComposableContext.Provider>
91+
);
92+
};
93+
94+
export const WizardComposableContextConsumer = WizardComposableContext.Consumer;
95+
export const useWizardContext = () => React.useContext(WizardComposableContext);
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import React from 'react';
2+
3+
import { css } from '@patternfly/react-styles';
4+
import styles from '@patternfly/react-styles/css/components/Wizard/wizard';
5+
6+
import {
7+
DefaultWizardFooterProps,
8+
DefaultWizardNavProps,
9+
isCustomWizardFooter,
10+
isWizardParentStep,
11+
WizardNavStepFunction,
12+
CustomWizardNavFunction
13+
} from './types';
14+
import { buildSteps, normalizeNavStep } from './utils';
15+
import { useWizardContext, WizardComposableContextProvider } from './WizardComposabeContext';
16+
import { WizardComposableStepProps } from './WizardComposableStep';
17+
import { WizardComposableFooter } from './WizardComposableFooter';
18+
import { WizardComposableToggle } from './WizardComposableToggle';
19+
20+
export interface WizardComposableProps extends React.HTMLProps<HTMLDivElement> {
21+
/** Step, footer, or header child components */
22+
children: React.ReactElement<WizardComposableStepProps> | React.ReactElement<WizardComposableStepProps>[];
23+
/** Wizard header */
24+
header?: React.ReactNode;
25+
/** Wizard footer */
26+
footer?: DefaultWizardFooterProps | React.ReactElement;
27+
/** Default wizard nav props or a custom WizardNav (with callback) */
28+
nav?: DefaultWizardNavProps | CustomWizardNavFunction;
29+
/** The initial index the wizard is to start on (1 or higher). Defaults to 1. */
30+
startIndex?: number;
31+
/** Additional classes spread to the Wizard */
32+
className?: string;
33+
/** Custom width of the wizard */
34+
width?: number | string;
35+
/** Custom height of the wizard */
36+
height?: number | string;
37+
/** Callback function when a step in the nav is clicked */
38+
onNavByIndex?: WizardNavStepFunction;
39+
/** Callback function after Next button is clicked */
40+
onNext?: WizardNavStepFunction;
41+
/** Callback function after Back button is clicked */
42+
onBack?: WizardNavStepFunction;
43+
/** Callback function to save at the end of the wizard, if not specified uses onClose */
44+
onSave?(): void;
45+
/** Callback function to close the wizard */
46+
onClose?(): void;
47+
}
48+
49+
export const WizardComposable = (props: WizardComposableProps) => {
50+
const { startIndex = 1, children, footer, onNavByIndex, onNext, onBack, onSave, onClose, ...internalProps } = props;
51+
const [currentStepIndex, setCurrentStepIndex] = React.useState(startIndex);
52+
const steps = buildSteps(children);
53+
54+
const goToStepByIndex = (index: number) => {
55+
const lastStepIndex = steps.length;
56+
57+
if (index < 1) {
58+
index = 1;
59+
} else if (index > lastStepIndex) {
60+
index = lastStepIndex;
61+
}
62+
63+
const currStep = steps[index - 1];
64+
const prevStep = steps[currentStepIndex - 1];
65+
setCurrentStepIndex(index);
66+
67+
return onNavByIndex?.(normalizeNavStep(currStep), normalizeNavStep(prevStep));
68+
};
69+
70+
const goToNextStep = () => {
71+
// Save when on the last step, otherwise close
72+
if (currentStepIndex >= steps.length) {
73+
if (onSave) {
74+
return onSave();
75+
}
76+
77+
return onClose?.();
78+
} else {
79+
let currStep = steps[currentStepIndex];
80+
let newStepIndex = currentStepIndex + 1;
81+
const prevStep = steps[currentStepIndex - 1];
82+
83+
// Skip parent step and focus on the first sub-step if they exist
84+
if (isWizardParentStep(currStep)) {
85+
newStepIndex += 1;
86+
currStep = steps[currentStepIndex + 1];
87+
}
88+
89+
setCurrentStepIndex(newStepIndex);
90+
return onNext?.(normalizeNavStep(currStep), normalizeNavStep(prevStep));
91+
}
92+
};
93+
94+
const goToPrevStep = () => {
95+
if (steps.length < currentStepIndex) {
96+
// Previous step was removed, just update the currentStep state
97+
setCurrentStepIndex(steps.length);
98+
} else {
99+
let currStep = steps[currentStepIndex - 2];
100+
let newStepIndex = currentStepIndex - 1;
101+
const prevStep = steps[currentStepIndex - 1];
102+
103+
// // Skip parent step and focus on the step prior
104+
if (isWizardParentStep(currStep)) {
105+
newStepIndex -= 1;
106+
currStep = steps[currentStepIndex - 3];
107+
}
108+
109+
setCurrentStepIndex(newStepIndex);
110+
return onBack?.(normalizeNavStep(currStep), normalizeNavStep(prevStep));
111+
}
112+
};
113+
114+
const goToStepById = (id: number | string) => {
115+
const stepIndex = steps.findIndex(step => step.id === id) + 1;
116+
stepIndex > 0 && setCurrentStepIndex(stepIndex);
117+
};
118+
119+
const goToStepByName = (name: string) => {
120+
const stepIndex = steps.findIndex(step => step.name === name) + 1;
121+
stepIndex > 0 && setCurrentStepIndex(stepIndex);
122+
};
123+
124+
return (
125+
<WizardComposableContextProvider
126+
steps={steps}
127+
currentStepIndex={currentStepIndex}
128+
footer={isCustomWizardFooter(footer) && footer}
129+
onNext={goToNextStep}
130+
onBack={goToPrevStep}
131+
onClose={onClose}
132+
goToStepById={goToStepById}
133+
goToStepByName={goToStepByName}
134+
goToStepByIndex={goToStepByIndex}
135+
>
136+
<WizardComposableInternal {...internalProps}>{children}</WizardComposableInternal>
137+
</WizardComposableContextProvider>
138+
);
139+
};
140+
141+
// eslint-disable-next-line patternfly-react/no-anonymous-functions
142+
const WizardComposableInternal = ({ height, width, className, header, nav, ...restProps }: WizardComposableProps) => {
143+
const { activeStep, steps, footer, onNext, onBack, onClose, goToStepByIndex } = useWizardContext();
144+
145+
const wizardFooter = isCustomWizardFooter(footer) ? (
146+
footer
147+
) : (
148+
<WizardComposableFooter
149+
activeStep={activeStep}
150+
onNext={onNext}
151+
onBack={onBack}
152+
onClose={onClose}
153+
disableBackButton={activeStep.id === steps[0].id}
154+
{...footer}
155+
/>
156+
);
157+
158+
return (
159+
<div
160+
className={css(styles.wizard, className)}
161+
style={{
162+
...(height ? { height } : {}),
163+
...(width ? { width } : {})
164+
}}
165+
{...restProps}
166+
>
167+
{header}
168+
<WizardComposableToggle
169+
steps={steps}
170+
activeStep={activeStep}
171+
footer={wizardFooter}
172+
nav={nav}
173+
goToStepByIndex={goToStepByIndex}
174+
/>
175+
</div>
176+
);
177+
};
178+
179+
WizardComposable.displayName = 'WizardComposable';
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import React from 'react';
2+
3+
import styles from '@patternfly/react-styles/css/components/Wizard/wizard';
4+
import { css } from '@patternfly/react-styles';
5+
6+
export interface WizardComposableBodyProps {
7+
children?: React.ReactNode | React.ReactNode[];
8+
/** Set to true to remove the default body padding */
9+
hasNoBodyPadding?: boolean;
10+
/** An aria-label to use for the main element */
11+
'aria-label'?: string;
12+
/** Sets the aria-labelledby attribute for the main element */
13+
'aria-labelledby'?: string;
14+
/** Component used as the wrapping content container */
15+
wrapperElement?: React.ElementType;
16+
}
17+
18+
export const WizardComposableBody = ({
19+
children,
20+
hasNoBodyPadding = false,
21+
'aria-label': ariaLabel,
22+
'aria-labelledby': ariaLabelledBy,
23+
wrapperElement: Wrapper = 'div'
24+
}: WizardComposableBodyProps) => (
25+
<Wrapper aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} className={css(styles.wizardMain)}>
26+
<div className={css(styles.wizardMainBody, hasNoBodyPadding && styles.modifiers.noPadding)}>{children}</div>
27+
</Wrapper>
28+
);
29+
30+
WizardComposableBody.displayName = 'WizardComposableBody';
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from 'react';
2+
3+
import { css } from '@patternfly/react-styles';
4+
import styles from '@patternfly/react-styles/css/components/Wizard/wizard';
5+
6+
import { Button, ButtonVariant } from '../Button';
7+
import { Step, SubStep, WizardNavStepFunction } from './types';
8+
9+
export interface WizardComposableFooterProps {
10+
/** The currently active WizardStep */
11+
activeStep: Step | SubStep;
12+
/** Next button callback */
13+
onNext(): WizardNavStepFunction | void;
14+
/** Back button callback */
15+
onBack(): WizardNavStepFunction | void;
16+
/** Cancel link callback */
17+
onClose(): void;
18+
/** Custom text for the Next button. The activeStep's nextButtonText takes precedence. */
19+
nextButtonText?: React.ReactNode;
20+
/** Custom text for the Back button */
21+
backButtonText?: React.ReactNode;
22+
/** Custom text for the Cancel link */
23+
cancelButtonText?: React.ReactNode;
24+
/** Optional flag to disable the first step's back button */
25+
disableBackButton?: boolean;
26+
}
27+
28+
export const WizardComposableFooter = ({
29+
onNext,
30+
onBack,
31+
onClose,
32+
activeStep,
33+
disableBackButton,
34+
nextButtonText = 'Next',
35+
backButtonText = 'Back',
36+
cancelButtonText = 'Cancel'
37+
}: WizardComposableFooterProps) => (
38+
<footer className={css(styles.wizardFooter)}>
39+
<Button variant={ButtonVariant.primary} type="submit" onClick={onNext} isDisabled={activeStep.disableNext}>
40+
{activeStep.nextButtonText || nextButtonText}
41+
</Button>
42+
43+
{!activeStep.hideBackButton && (
44+
<Button variant={ButtonVariant.secondary} onClick={onBack} isDisabled={disableBackButton}>
45+
{backButtonText}
46+
</Button>
47+
)}
48+
49+
{!activeStep.hideCancelButton && (
50+
<div className={styles.wizardFooterCancel}>
51+
<Button variant={ButtonVariant.link} onClick={onClose}>
52+
{cancelButtonText}
53+
</Button>
54+
</div>
55+
)}
56+
</footer>
57+
);
58+
59+
WizardComposableFooter.displayName = 'WizardComposableFooter';

0 commit comments

Comments
 (0)