From d6e2097fa6ce7117d0d8d05aa15962dd3d63d0b6 Mon Sep 17 00:00:00 2001 From: Jeff Puzzo Date: Mon, 18 Jul 2022 12:15:17 -0400 Subject: [PATCH 1/8] feat(WizardComposable): Created composable spinoff of wizard w/ enhancements --- .../src/components/Wizard/examples/Wizard.md | 2 + .../WizardComposabeContext.tsx | 95 ++++++++ .../WizardComposable/WizardComposable.tsx | 179 +++++++++++++++ .../WizardComposable/WizardComposableBody.tsx | 35 +++ .../WizardComposableFooter.tsx | 59 +++++ .../WizardComposable/WizardComposableStep.tsx | 37 ++++ .../WizardComposableToggle.tsx | 206 ++++++++++++++++++ .../WizardComposable/examples/WizardBasic.tsx | 22 ++ .../examples/WizardComposable.md | 40 ++++ .../examples/WizardCustomNav.tsx | 46 ++++ .../examples/WizardKitchenSink.tsx | 155 +++++++++++++ .../hooks/useWizardFooter.tsx | 22 ++ .../src/components/WizardComposable/index.ts | 8 + .../src/components/WizardComposable/types.ts | 86 ++++++++ .../src/components/WizardComposable/utils.ts | 45 ++++ packages/react-core/src/components/index.ts | 1 + .../patternfly-docs/patternfly-docs.source.js | 2 + 17 files changed, 1040 insertions(+) create mode 100644 packages/react-core/src/components/WizardComposable/WizardComposabeContext.tsx create mode 100644 packages/react-core/src/components/WizardComposable/WizardComposable.tsx create mode 100644 packages/react-core/src/components/WizardComposable/WizardComposableBody.tsx create mode 100644 packages/react-core/src/components/WizardComposable/WizardComposableFooter.tsx create mode 100644 packages/react-core/src/components/WizardComposable/WizardComposableStep.tsx create mode 100644 packages/react-core/src/components/WizardComposable/WizardComposableToggle.tsx create mode 100644 packages/react-core/src/components/WizardComposable/examples/WizardBasic.tsx create mode 100644 packages/react-core/src/components/WizardComposable/examples/WizardComposable.md create mode 100644 packages/react-core/src/components/WizardComposable/examples/WizardCustomNav.tsx create mode 100644 packages/react-core/src/components/WizardComposable/examples/WizardKitchenSink.tsx create mode 100644 packages/react-core/src/components/WizardComposable/hooks/useWizardFooter.tsx create mode 100644 packages/react-core/src/components/WizardComposable/index.ts create mode 100644 packages/react-core/src/components/WizardComposable/types.ts create mode 100644 packages/react-core/src/components/WizardComposable/utils.ts diff --git a/packages/react-core/src/components/Wizard/examples/Wizard.md b/packages/react-core/src/components/Wizard/examples/Wizard.md index 860736de28a..9e7dfdb1a4d 100644 --- a/packages/react-core/src/components/Wizard/examples/Wizard.md +++ b/packages/react-core/src/components/Wizard/examples/Wizard.md @@ -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 composition](/components/wizard/react-composition) tab. + ## Examples ### Basic diff --git a/packages/react-core/src/components/WizardComposable/WizardComposabeContext.tsx b/packages/react-core/src/components/WizardComposable/WizardComposabeContext.tsx new file mode 100644 index 00000000000..1f0b7a18680 --- /dev/null +++ b/packages/react-core/src/components/WizardComposable/WizardComposabeContext.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { Step, SubStep } from './types'; +import { getActiveStep } from './utils'; + +export interface WizardComposableContextProps { + steps: (Step | SubStep)[]; + activeStep: Step | SubStep; + footer: React.ReactElement; + onNext(): void; + onBack(): void; + onClose(): void; + goToStepById(id: number | string): void; + goToStepByName(name: string): void; + goToStepByIndex(index: number): void; + setFooter(footer: React.ReactElement): void; +} + +export const WizardComposableContext = React.createContext({} as WizardComposableContextProps); + +interface WizardComposableContextRenderProps { + steps: (Step | SubStep)[]; + activeStep: Step | SubStep; + footer: React.ReactElement; + onNext(): void; + onBack(): void; + onClose(): void; +} + +interface WizardComposableContextProviderProps { + steps: (Step | SubStep)[]; + currentStepIndex: number; + footer: React.ReactElement; + children: React.ReactElement | ((props: WizardComposableContextRenderProps) => 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 WizardComposableContextProvider: React.FunctionComponent = ({ + 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.visited) { + setSteps(prevSteps => + prevSteps.map(step => { + if (step.id === activeStep.id) { + return { ...step, visited: true }; + } + + return step; + }) + ); + } + }, [activeStep.id, activeStep.visited]); + + return ( + + {typeof children === 'function' ? children({ activeStep, steps, footer, onNext, onBack, onClose }) : children} + + ); +}; + +export const WizardComposableContextConsumer = WizardComposableContext.Consumer; +export const useWizardContext = () => React.useContext(WizardComposableContext); diff --git a/packages/react-core/src/components/WizardComposable/WizardComposable.tsx b/packages/react-core/src/components/WizardComposable/WizardComposable.tsx new file mode 100644 index 00000000000..8b464f6b455 --- /dev/null +++ b/packages/react-core/src/components/WizardComposable/WizardComposable.tsx @@ -0,0 +1,179 @@ +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, WizardComposableContextProvider } from './WizardComposabeContext'; +import { WizardComposableStepProps } from './WizardComposableStep'; +import { WizardComposableFooter } from './WizardComposableFooter'; +import { WizardComposableToggle } from './WizardComposableToggle'; + +export interface WizardComposableProps extends React.HTMLProps { + /** Step, footer, or header child components */ + children: React.ReactElement | React.ReactElement[]; + /** 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 WizardComposable = (props: WizardComposableProps) => { + 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?.(); + } else { + 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 ( + + {children} + + ); +}; + +// eslint-disable-next-line patternfly-react/no-anonymous-functions +const WizardComposableInternal = ({ height, width, className, header, nav, ...restProps }: WizardComposableProps) => { + const { activeStep, steps, footer, onNext, onBack, onClose, goToStepByIndex } = useWizardContext(); + + const wizardFooter = isCustomWizardFooter(footer) ? ( + footer + ) : ( + + ); + + return ( +
+ {header} + +
+ ); +}; + +WizardComposable.displayName = 'WizardComposable'; diff --git a/packages/react-core/src/components/WizardComposable/WizardComposableBody.tsx b/packages/react-core/src/components/WizardComposable/WizardComposableBody.tsx new file mode 100644 index 00000000000..b44c4570316 --- /dev/null +++ b/packages/react-core/src/components/WizardComposable/WizardComposableBody.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; +import { css } from '@patternfly/react-styles'; + +export interface WizardComposableBodyProps { + 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 WizardComposableBody = ({ + children, + hasNoBodyPadding = false, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + wrapperElement: Wrapper = 'div' +}: WizardComposableBodyProps) => ( + +
{children}
+
+); + +WizardComposableBody.displayName = 'WizardComposableBody'; diff --git a/packages/react-core/src/components/WizardComposable/WizardComposableFooter.tsx b/packages/react-core/src/components/WizardComposable/WizardComposableFooter.tsx new file mode 100644 index 00000000000..3a9543c10f0 --- /dev/null +++ b/packages/react-core/src/components/WizardComposable/WizardComposableFooter.tsx @@ -0,0 +1,59 @@ +import React from 'react'; + +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; + +import { Button, ButtonVariant } from '../Button'; +import { Step, SubStep, WizardNavStepFunction } from './types'; + +export interface WizardComposableFooterProps { + /** The currently active WizardStep */ + activeStep: Step | SubStep; + /** Next button callback */ + onNext(): WizardNavStepFunction | void; + /** Back button callback */ + onBack(): WizardNavStepFunction | void; + /** Cancel link callback */ + onClose(): void; + /** Custom text for the Next button. The activeStep's nextButtonText takes precedence. */ + nextButtonText?: React.ReactNode; + /** Custom text for the Back button */ + backButtonText?: React.ReactNode; + /** Custom text for the Cancel link */ + cancelButtonText?: React.ReactNode; + /** Optional flag to disable the first step's back button */ + disableBackButton?: boolean; +} + +export const WizardComposableFooter = ({ + onNext, + onBack, + onClose, + activeStep, + disableBackButton, + nextButtonText = 'Next', + backButtonText = 'Back', + cancelButtonText = 'Cancel' +}: WizardComposableFooterProps) => ( +
+ + + {!activeStep.hideBackButton && ( + + )} + + {!activeStep.hideCancelButton && ( +
+ +
+ )} +
+); + +WizardComposableFooter.displayName = 'WizardComposableFooter'; diff --git a/packages/react-core/src/components/WizardComposable/WizardComposableStep.tsx b/packages/react-core/src/components/WizardComposable/WizardComposableStep.tsx new file mode 100644 index 00000000000..bbec3569f1b --- /dev/null +++ b/packages/react-core/src/components/WizardComposable/WizardComposableStep.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { Step } from './types'; +import { WizardComposableBody, WizardComposableBodyProps } from './WizardComposableBody'; + +export type WizardComposableStepProps = Omit & + WizardComposableBodyProps & { + /** Optional for when the Step is used as a parent to sub-steps */ + children?: React.ReactNode; + /** Flag indicating whether to use a WizardBody to wrap step contents or not */ + includeStepBody?: boolean; + /** Optional list of sub-steps */ + steps?: React.ReactElement[]; + }; + +export const WizardComposableStep = ({ + hasNoBodyPadding = false, + wrapperElement = 'div', + includeStepBody = true, + 'aria-label': ariaLabel = 'Wizard body', + 'aria-labelledby': ariaLabelledBy, + children +}: WizardComposableStepProps) => + includeStepBody ? ( + + {children} + + ) : ( + <>{children} + ); + +WizardComposableStep.displayName = 'WizardComposableStep'; diff --git a/packages/react-core/src/components/WizardComposable/WizardComposableToggle.tsx b/packages/react-core/src/components/WizardComposable/WizardComposableToggle.tsx new file mode 100644 index 00000000000..79cb93714a9 --- /dev/null +++ b/packages/react-core/src/components/WizardComposable/WizardComposableToggle.tsx @@ -0,0 +1,206 @@ +import React from 'react'; + +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; +import AngleRightIcon from '@patternfly/react-icons/dist/esm/icons/angle-right-icon'; +import CaretDownIcon from '@patternfly/react-icons/dist/esm/icons/caret-down-icon'; + +import { KeyTypes } from '../../helpers/constants'; +import { WizardNav, WizardNavItem } from '../Wizard'; +import { + Step, + SubStep, + isWizardSubStep, + isCustomWizardNav, + CustomWizardNavFunction, + DefaultWizardNavProps, + isWizardBasicStep, + isWizardParentStep +} from './types'; + +export interface WizardComposableToggleProps { + /** List of steps and/or sub-steps */ + steps: (Step | SubStep)[]; + /** The currently active WizardStep */ + activeStep: Step | SubStep; + /** The WizardFooter */ + footer: React.ReactElement; + /** Custom WizardNav or callback used to create a default WizardNav */ + nav: DefaultWizardNavProps | CustomWizardNavFunction; + /** Navigate using the step index */ + goToStepByIndex(index: number): void; + /** The button's aria-label */ + 'aria-label'?: string; + /** Flag to unmount inactive steps instead of hiding. Defaults to true */ + unmountInactiveSteps?: boolean; +} + +export const WizardComposableToggle = ({ + steps, + activeStep, + footer, + nav, + goToStepByIndex, + unmountInactiveSteps = true, + 'aria-label': ariaLabel = 'Wizard toggle' +}: WizardComposableToggleProps) => { + const [isNavOpen, setIsNavOpen] = React.useState(false); + const isActiveSubStep = isWizardSubStep(activeStep); + + const handleKeyClicks = React.useCallback( + (event: KeyboardEvent): void => { + if (isNavOpen && event.key === KeyTypes.Escape) { + setIsNavOpen(!isNavOpen); + } + }, + [isNavOpen] + ); + + // Open/close collapsable nav on keydown event + React.useEffect(() => { + const target = typeof document !== 'undefined' ? document.body : null; + target?.addEventListener('keydown', handleKeyClicks, false); + + return () => { + target?.removeEventListener('keydown', handleKeyClicks, false); + }; + }, [handleKeyClicks]); + + // Only render the active step when unmountInactiveSteps is true + const bodyContent = unmountInactiveSteps + ? activeStep.component + : steps.map(step => { + if (activeStep.name === step.name) { + return step.component; + } + + return ( +
+ {step.component} +
+ ); + }); + + const wizardNav = isCustomWizardNav(nav) + ? nav(isNavOpen, steps, activeStep, goToStepByIndex) + : React.useMemo(() => { + const props = { + isOpen: isNavOpen, + 'aria-label': nav?.ariaLabel || 'Wizard nav', + ...(nav?.ariaLabelledBy && { 'aria-labelledby': nav?.ariaLabelledBy }) + }; + + return ( + + {steps.map((step, index) => { + const stepIndex = index + 1; + const stepNavItem = step.navItem && {step.navItem}; + + if (isWizardParentStep(step)) { + let firstSubStepIndex; + let hasActiveChild = false; + + const subNavItems = step.subStepIds?.map((subStepId, index) => { + const subStep = steps.find(step => step.id === subStepId); + const subStepIndex = steps.indexOf(subStep) + 1; + + if (index === 0) { + firstSubStepIndex = subStepIndex; + } + + if (activeStep.id === subStep.id) { + hasActiveChild = true; + } + + return subStep.navItem ? ( + {subStep.navItem} + ) : ( + + ); + }); + + const hasEnabledChildren = React.Children.toArray(subNavItems).some( + child => React.isValidElement(child) && !child.props.isDisabled + ); + + return ( + stepNavItem || ( + + + {subNavItems} + + + ) + ); + } + + if (isWizardBasicStep(step)) { + return ( + stepNavItem || ( + + ) + ); + } + })} + + ); + }, [activeStep.id, goToStepByIndex, isNavOpen, nav, steps]); + + return ( + <> + +
+
+ {wizardNav} + {bodyContent} +
+ + {footer} +
+ + ); +}; + +WizardComposableToggle.displayName = 'WizardComposableToggle'; diff --git a/packages/react-core/src/components/WizardComposable/examples/WizardBasic.tsx b/packages/react-core/src/components/WizardComposable/examples/WizardBasic.tsx new file mode 100644 index 00000000000..1b17427c9a7 --- /dev/null +++ b/packages/react-core/src/components/WizardComposable/examples/WizardBasic.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { WizardComposable, WizardComposableStep } from '@patternfly/react-core'; + +export const WizardBasic: React.FunctionComponent = () => ( + + +

Step 1 content

+
+ +

Step 2 content

+
+ +

Step 3 content

+
+ +

Step 4 content

{' '} +
+ +

Review step content

+
+
+); diff --git a/packages/react-core/src/components/WizardComposable/examples/WizardComposable.md b/packages/react-core/src/components/WizardComposable/examples/WizardComposable.md new file mode 100644 index 00000000000..e94a8359a5e --- /dev/null +++ b/packages/react-core/src/components/WizardComposable/examples/WizardComposable.md @@ -0,0 +1,40 @@ +--- +id: Wizard +section: components +cssPrefix: pf-c-wizard +propComponents: + [ + 'WizardComposable', + 'WizardComposableFooter', + 'WizardComposableToggle', + 'WizardComposableStep', + 'WizardComposableBody', + ] +--- + +import { Button, WizardComposable, WizardComposableFooter, WizardComposableStep, WizardComposableBody, useWizardFooter, ModalVariant, Alert, EmptyState, EmptyStateIcon, EmptyStateBody, EmptyStateSecondaryActions, Title, Progress } from '@patternfly/react-core'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; +import ExternalLinkAltIcon from '@patternfly/react-icons/dist/esm/icons/external-link-alt-icon'; +import SlackHashIcon from '@patternfly/react-icons/dist/esm/icons/slack-hash-icon'; + +PatternFly has two implementations of a `Wizard`, where the latest `WizardComposable`. Its temporary name speaks to the improvements made to the composition of the Wizard and its ansillary components. + +The documentation for the older `Wizard` implementation can be found under the [React legacy](/components/wizard/react-legacy) tab. + +## Examples + +### Basic + +```ts file="./WizardBasic.tsx" +``` + +### Custom Nav + +```ts file="./WizardCustomNav.tsx" +``` + +### Kitchen Sink + +```ts file="./WizardKitchenSink.tsx" +``` diff --git a/packages/react-core/src/components/WizardComposable/examples/WizardCustomNav.tsx b/packages/react-core/src/components/WizardComposable/examples/WizardCustomNav.tsx new file mode 100644 index 00000000000..87c000fda0b --- /dev/null +++ b/packages/react-core/src/components/WizardComposable/examples/WizardCustomNav.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { + Step, + SubStep, + WizardComposable, + WizardComposableStep, + WizardNav, + WizardNavItem +} from '@patternfly/react-core'; + +export const WizardCustomNav: React.FunctionComponent = () => { + const nav = ( + isOpen: boolean, + steps: (Step | SubStep)[], + activeStep: Step | SubStep, + goToStepByIndex: (index: number) => void + ) => ( + + {steps.map((step, index) => ( + + ))} + + ); + + return ( + + +

Did you say...custom nav?

+
+ +

Step 2 content

+
+ +

Review step content

+
+
+ ); +}; diff --git a/packages/react-core/src/components/WizardComposable/examples/WizardKitchenSink.tsx b/packages/react-core/src/components/WizardComposable/examples/WizardKitchenSink.tsx new file mode 100644 index 00000000000..f87763d5168 --- /dev/null +++ b/packages/react-core/src/components/WizardComposable/examples/WizardKitchenSink.tsx @@ -0,0 +1,155 @@ +import React from 'react'; + +import { + WizardComposable, + WizardComposableStep, + WizardHeader, + FormGroup, + TextInput, + Drawer, + DrawerContent, + Button, + Flex, + DrawerPanelContent, + DrawerColorVariant, + DrawerHead, + DrawerActions, + DrawerCloseButton, + WizardComposableBody, + WizardComposableFooter, + useWizardContext, + WizardNavItem, + Step, + useWizardFooter +} from '@patternfly/react-core'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; + +const CustomWizardFooter = () => { + const { activeStep, onNext, onBack, onClose } = useWizardContext(); + return ; +}; + +const CustomNavItem = () => { + const { steps, activeStep, goToStepByIndex } = useWizardContext(); + const step = (steps.find(step => step.id === 'third-step') || {}) as Step; + + return ( + + ); +}; + +const StepContentWithDrawer = () => { + const [isDrawerExpanded, setIsDrawerExpanded] = React.useState(false); + + return ( + + + + drawer content + + setIsDrawerExpanded(false)} /> + + + + } + > + + + {!isDrawerExpanded && ( + + )} + + + + + + + + + ); +}; + +const StepWithCustomFooter = () => { + const { onNext: goToNextStep, onBack, onClose } = useWizardContext(); + const [isLoading, setIsLoading] = React.useState(false); + + async function onNext(goToStep: () => void) { + setIsLoading(true); + await new Promise(resolve => setTimeout(resolve, 2000)); + setIsLoading(false); + + goToStep(); + } + + const footer = React.useMemo( + () => ( +
+ + + +
+ ), + [isLoading, onBack, onClose, goToNextStep] + ); + useWizardFooter(footer); + + return <>Step 3 content w/ custom async footer; +}; + +export const WizardKitchenSink: React.FunctionComponent = () => ( + } + footer={} + nav={{ forceStepVisit: true, isExpandable: true }} + > + + + + + Substep 1 content + , + + Substep 2 content + + ]} + /> + }> + + + + Step 4 content + + + Review step content + + +); diff --git a/packages/react-core/src/components/WizardComposable/hooks/useWizardFooter.tsx b/packages/react-core/src/components/WizardComposable/hooks/useWizardFooter.tsx new file mode 100644 index 00000000000..00420bed143 --- /dev/null +++ b/packages/react-core/src/components/WizardComposable/hooks/useWizardFooter.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { useWizardContext } from '../WizardComposabeContext'; + +/** + * Set a unique footer for the wizard. stepId is only required if inactive steps are hidden instead of unmounted. + * @param footer + * @param stepId + */ +export const useWizardFooter = (footer: React.ReactElement, stepId?: string | number) => { + const { activeStep, setFooter } = useWizardContext(); + + React.useEffect(() => { + if (!stepId || activeStep.id === stepId) { + setFooter(footer); + + // Reset the footer on unmount. + return () => { + setFooter(null); + }; + } + }, [activeStep, footer, setFooter, stepId]); +}; diff --git a/packages/react-core/src/components/WizardComposable/index.ts b/packages/react-core/src/components/WizardComposable/index.ts new file mode 100644 index 00000000000..878e324cc31 --- /dev/null +++ b/packages/react-core/src/components/WizardComposable/index.ts @@ -0,0 +1,8 @@ +export * from './WizardComposable'; +export * from './WizardComposabeContext'; +export * from './WizardComposableBody'; +export * from './WizardComposableFooter'; +export * from './WizardComposableToggle'; +export * from './WizardComposableStep'; +export { useWizardFooter } from './hooks/useWizardFooter'; +export * from './types'; diff --git a/packages/react-core/src/components/WizardComposable/types.ts b/packages/react-core/src/components/WizardComposable/types.ts new file mode 100644 index 00000000000..e76569f0935 --- /dev/null +++ b/packages/react-core/src/components/WizardComposable/types.ts @@ -0,0 +1,86 @@ +import React from 'react'; +import { WizardNavItemProps, WizardNavProps } from '../Wizard'; + +export interface Step { + /** Name of the step's nav item */ + name: React.ReactNode; + /** Unique identifier */ + id: string; + /** Flag to disable the step's nav item */ + isDisabled?: boolean; + /** Flag to represent whether the step has been visited (navigated to) */ + visited?: boolean; + /** Content shown when the step's nav item is selected. When treated as a parent step, only sub-step content will be shown. */ + component?: React.ReactElement; + /** Nested step IDs */ + subStepIds?: string[]; + /** (Unused if nav is controlled) Custom WizardNavItem */ + navItem?: React.ReactElement; + /** (Unused if footer is controlled) Can change the Next button text. If nextButtonText is also set for the Wizard, this step specific one overrides it. */ + nextButtonText?: React.ReactNode; + /** (Unused if footer is controlled) The condition needed to disable the Next button */ + disableNext?: boolean; + /** (Unused if footer is controlled) True to hide the Cancel button */ + hideCancelButton?: boolean; + /** (Unused if footer is controlled) True to hide the Back button */ + hideBackButton?: boolean; +} + +export interface SubStep extends Omit { + /** Unique identifier of the parent step */ + parentId?: string | number; +} + +export type WizardNavStepData = Pick; +export type WizardNavStepFunction = (currentStep: WizardNavStepData, previousStep: WizardNavStepData) => void; + +export interface DefaultWizardNavProps { + /** Flag indicating nav items with sub steps are expandable */ + isExpandable?: boolean; + /** Aria-label for the Nav */ + ariaLabel?: string; + /** Sets aria-labelledby on nav element */ + ariaLabelledBy?: string; + /** Disable step nav items until they are visited */ + forceStepVisit?: boolean; +} + +export interface DefaultWizardFooterProps { + /** The Next button text */ + nextButtonText?: React.ReactNode; + /** The Back button text */ + backButtonText?: React.ReactNode; + /** The Cancel button text */ + cancelButtonText?: React.ReactNode; +} + +export type CustomWizardNavFunction = ( + isOpen: boolean, + steps: (Step | SubStep)[], + activeStep: Step | SubStep, + goToStepByIndex: (index: number) => void +) => React.ReactElement; + +export function isCustomWizardNav( + nav: DefaultWizardNavProps | CustomWizardNavFunction +): nav is CustomWizardNavFunction { + return typeof nav === 'function'; +} + +export function isCustomWizardFooter( + footer: DefaultWizardFooterProps | React.ReactElement +): footer is React.ReactElement { + return React.isValidElement(footer); +} + +export function isWizardBasicStep(step: Step | SubStep): step is Step { + return (step as Step).subStepIds === undefined && !isWizardSubStep(step); +} + +export function isWizardSubStep(step: Step | SubStep): step is SubStep { + return (step as SubStep).parentId !== undefined; +} + +export function isWizardParentStep(step: Step | SubStep): step is Step { + return (step as Step).subStepIds !== undefined; +} diff --git a/packages/react-core/src/components/WizardComposable/utils.ts b/packages/react-core/src/components/WizardComposable/utils.ts new file mode 100644 index 00000000000..4661afc0887 --- /dev/null +++ b/packages/react-core/src/components/WizardComposable/utils.ts @@ -0,0 +1,45 @@ +import React from 'react'; + +import { Step, SubStep, WizardNavStepData } from './types'; +import { WizardComposableStep, WizardComposableStepProps } from './WizardComposableStep'; + +/** + * Accumulate list of step & sub-step props pulled from child components + * @param children + * @returns (Step | SubStep)[] + */ +export const buildSteps = ( + children: React.ReactElement | React.ReactElement[] +) => + React.Children.toArray(children).reduce((acc: (Step | SubStep)[], child) => { + if (React.isValidElement(child) && typeof child.type !== 'string') { + if (child.type === WizardComposableStep) { + // Omit "children" and use the whole "child" (WizardStep) for the component prop. Sub-steps will do the same. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { steps: subSteps, id, children, ...stepProps } = child.props as WizardComposableStepProps; + + acc.push({ + id, + component: child, + ...stepProps, + ...(subSteps && { + subStepIds: subSteps?.map(subStep => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { children, ...subStepProps } = subStep.props; + acc.push({ ...subStepProps, component: subStep, parentId: id }); + + return subStep.props.id; + }) + }) + }); + } else { + throw new Error('Wizard only accepts children of type WizardComposableStep'); + } + } + + return acc; + }, []); + +export const normalizeNavStep = ({ id, name }: WizardNavStepData) => ({ id, name }); +export const getActiveStep = (steps: (Step | SubStep)[], currentStepIndex: number) => + steps.find((_, index) => index + 1 === currentStepIndex); diff --git a/packages/react-core/src/components/index.ts b/packages/react-core/src/components/index.ts index aa48375a5d4..eef0b8f9fbe 100644 --- a/packages/react-core/src/components/index.ts +++ b/packages/react-core/src/components/index.ts @@ -80,6 +80,7 @@ export * from './Tooltip'; export * from './NumberInput'; export * from './TreeView'; export * from './Wizard'; +export * from './WizardComposable'; export * from './DragDrop'; export * from './TextInputGroup'; export * from './Panel'; diff --git a/packages/react-docs/patternfly-docs/patternfly-docs.source.js b/packages/react-docs/patternfly-docs/patternfly-docs.source.js index 29e3bc703a5..d20f81350fc 100644 --- a/packages/react-docs/patternfly-docs/patternfly-docs.source.js +++ b/packages/react-docs/patternfly-docs/patternfly-docs.source.js @@ -41,6 +41,8 @@ module.exports = (baseSourceMD, sourceProps) => { sourceMD(path.join(reactCorePath, '/components/**/examples/*.md'), 'react'); sourceMD(path.join(reactCorePath, '/next/components/**/examples/*.md'), 'react-next'); sourceMD(path.join(reactCorePath, '/**/demos/**/*.md'), 'react-demos'); + sourceMD(path.join(reactCorePath, '/**/Wizard/examples/*.md'), 'react-legacy'); + sourceMD(path.join(reactCorePath, '/**/WizardComposable/examples/*.md'), 'react-composable'); // React-table MD sourceMD(path.join(reactTablePath, '/**/TableComposable/examples/*.md'), 'react-composable'); From 9f3ed9833ee31c9edaf3e2a65a334751e583250e Mon Sep 17 00:00:00 2001 From: Jeff Puzzo Date: Thu, 18 Aug 2022 14:53:09 -0400 Subject: [PATCH 2/8] rename all composable wizard components, move to other directory, create path alias, export as separate module --- packages/react-core/package.json | 1 - .../src/components/Wizard/examples/Wizard.md | 25 +- .../WizardComposable/WizardComposableStep.tsx | 37 --- .../WizardComposable/examples/WizardBasic.tsx | 22 -- .../examples/WizardComposable.md | 40 --- .../src/components/WizardComposable/index.ts | 8 - packages/react-core/src/components/index.ts | 1 - .../components/Wizard/Wizard.tsx} | 49 +-- .../components/Wizard/WizardBody.tsx} | 19 +- .../components/Wizard/WizardContext.tsx} | 24 +- .../components/Wizard/WizardFooter.tsx} | 22 +- .../src/next/components/Wizard/WizardStep.tsx | 23 ++ .../components/Wizard/WizardToggle.tsx} | 32 +- .../Wizard/__tests__/Wizard.test.tsx | 299 ++++++++++++++++++ .../next/components/Wizard/examples/Wizard.md | 77 +++++ .../Wizard/examples/WizardBasic.tsx | 22 ++ .../Wizard}/examples/WizardCustomNav.tsx | 26 +- .../Wizard}/examples/WizardKitchenSink.tsx | 63 ++-- .../Wizard}/hooks/useWizardFooter.tsx | 2 +- .../src/next/components/Wizard/index.ts | 8 + .../components/Wizard}/types.ts | 8 +- .../components/Wizard}/utils.ts | 18 +- .../react-core/src/next/components/index.ts | 2 +- .../patternfly-docs/patternfly-docs.source.js | 2 - packages/react-styles/README.md | 2 +- 25 files changed, 589 insertions(+), 243 deletions(-) delete mode 100644 packages/react-core/src/components/WizardComposable/WizardComposableStep.tsx delete mode 100644 packages/react-core/src/components/WizardComposable/examples/WizardBasic.tsx delete mode 100644 packages/react-core/src/components/WizardComposable/examples/WizardComposable.md delete mode 100644 packages/react-core/src/components/WizardComposable/index.ts rename packages/react-core/src/{components/WizardComposable/WizardComposable.tsx => next/components/Wizard/Wizard.tsx} (78%) rename packages/react-core/src/{components/WizardComposable/WizardComposableBody.tsx => next/components/Wizard/WizardBody.tsx} (70%) rename packages/react-core/src/{components/WizardComposable/WizardComposabeContext.tsx => next/components/Wizard/WizardContext.tsx} (73%) rename packages/react-core/src/{components/WizardComposable/WizardComposableFooter.tsx => next/components/Wizard/WizardFooter.tsx} (74%) create mode 100644 packages/react-core/src/next/components/Wizard/WizardStep.tsx rename packages/react-core/src/{components/WizardComposable/WizardComposableToggle.tsx => next/components/Wizard/WizardToggle.tsx} (88%) create mode 100644 packages/react-core/src/next/components/Wizard/__tests__/Wizard.test.tsx create mode 100644 packages/react-core/src/next/components/Wizard/examples/Wizard.md create mode 100644 packages/react-core/src/next/components/Wizard/examples/WizardBasic.tsx rename packages/react-core/src/{components/WizardComposable => next/components/Wizard}/examples/WizardCustomNav.tsx (59%) rename packages/react-core/src/{components/WizardComposable => next/components/Wizard}/examples/WizardKitchenSink.tsx (74%) rename packages/react-core/src/{components/WizardComposable => next/components/Wizard}/hooks/useWizardFooter.tsx (90%) create mode 100644 packages/react-core/src/next/components/Wizard/index.ts rename packages/react-core/src/{components/WizardComposable => next/components/Wizard}/types.ts (91%) rename packages/react-core/src/{components/WizardComposable => next/components/Wizard}/utils.ts (72%) diff --git a/packages/react-core/package.json b/packages/react-core/package.json index 8ad35ff3e0e..9147bdde2b7 100644 --- a/packages/react-core/package.json +++ b/packages/react-core/package.json @@ -3,7 +3,6 @@ "version": "4.232.13", "description": "This library provides a set of common React components for use with the PatternFly reference implementation.", "main": "dist/js/index.js", - "module": "dist/esm/index.js", "types": "dist/esm/index.d.ts", "exports": { ".": "./dist/esm/index.js", diff --git a/packages/react-core/src/components/Wizard/examples/Wizard.md b/packages/react-core/src/components/Wizard/examples/Wizard.md index 9e7dfdb1a4d..671f91caab3 100644 --- a/packages/react-core/src/components/Wizard/examples/Wizard.md +++ b/packages/react-core/src/components/Wizard/examples/Wizard.md @@ -13,7 +13,7 @@ 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 composition](/components/wizard/react-composition) tab. +If you seek a Wizard solution that allows for more composition, see the [React next](/components/wizard/react-next) tab. ## Examples @@ -56,7 +56,7 @@ class SimpleWizard extends React.Component { render() { const steps = [ { name: 'First step', component:

Step 1 content

}, - { name: 'Second step', component:

Step 2 content

, isDisabled: true}, + { name: 'Second step', component:

Step 2 content

, isDisabled: true }, { name: 'Third step', component:

Step 3 content

}, { name: 'Fourth step', component:

Step 4 content

, isDisabled: true }, { name: 'Review', component:

Review step content

, nextButtonText: 'Finish' } @@ -147,9 +147,24 @@ class IncrementallyEnabledStepsWizard extends React.Component { const steps = [ { id: 'incrementally-enabled-1', name: 'First step', component:

Step 1 content

}, - { id: 'incrementally-enabled-2', name: 'Second step', component:

Step 2 content

, canJumpTo: stepIdReached >= 2 }, - { id: 'incrementally-enabled-3', name: 'Third step', component:

Step 3 content

, canJumpTo: stepIdReached >= 3 }, - { id: 'incrementally-enabled-4', name: 'Fourth step', component:

Step 4 content

, canJumpTo: stepIdReached >= 4 }, + { + id: 'incrementally-enabled-2', + name: 'Second step', + component:

Step 2 content

, + canJumpTo: stepIdReached >= 2 + }, + { + id: 'incrementally-enabled-3', + name: 'Third step', + component:

Step 3 content

, + canJumpTo: stepIdReached >= 3 + }, + { + id: 'incrementally-enabled-4', + name: 'Fourth step', + component:

Step 4 content

, + canJumpTo: stepIdReached >= 4 + }, { id: 'incrementally-enabled-5', name: 'Review', diff --git a/packages/react-core/src/components/WizardComposable/WizardComposableStep.tsx b/packages/react-core/src/components/WizardComposable/WizardComposableStep.tsx deleted file mode 100644 index bbec3569f1b..00000000000 --- a/packages/react-core/src/components/WizardComposable/WizardComposableStep.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; - -import { Step } from './types'; -import { WizardComposableBody, WizardComposableBodyProps } from './WizardComposableBody'; - -export type WizardComposableStepProps = Omit & - WizardComposableBodyProps & { - /** Optional for when the Step is used as a parent to sub-steps */ - children?: React.ReactNode; - /** Flag indicating whether to use a WizardBody to wrap step contents or not */ - includeStepBody?: boolean; - /** Optional list of sub-steps */ - steps?: React.ReactElement[]; - }; - -export const WizardComposableStep = ({ - hasNoBodyPadding = false, - wrapperElement = 'div', - includeStepBody = true, - 'aria-label': ariaLabel = 'Wizard body', - 'aria-labelledby': ariaLabelledBy, - children -}: WizardComposableStepProps) => - includeStepBody ? ( - - {children} - - ) : ( - <>{children} - ); - -WizardComposableStep.displayName = 'WizardComposableStep'; diff --git a/packages/react-core/src/components/WizardComposable/examples/WizardBasic.tsx b/packages/react-core/src/components/WizardComposable/examples/WizardBasic.tsx deleted file mode 100644 index 1b17427c9a7..00000000000 --- a/packages/react-core/src/components/WizardComposable/examples/WizardBasic.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import { WizardComposable, WizardComposableStep } from '@patternfly/react-core'; - -export const WizardBasic: React.FunctionComponent = () => ( - - -

Step 1 content

-
- -

Step 2 content

-
- -

Step 3 content

-
- -

Step 4 content

{' '} -
- -

Review step content

-
-
-); diff --git a/packages/react-core/src/components/WizardComposable/examples/WizardComposable.md b/packages/react-core/src/components/WizardComposable/examples/WizardComposable.md deleted file mode 100644 index e94a8359a5e..00000000000 --- a/packages/react-core/src/components/WizardComposable/examples/WizardComposable.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -id: Wizard -section: components -cssPrefix: pf-c-wizard -propComponents: - [ - 'WizardComposable', - 'WizardComposableFooter', - 'WizardComposableToggle', - 'WizardComposableStep', - 'WizardComposableBody', - ] ---- - -import { Button, WizardComposable, WizardComposableFooter, WizardComposableStep, WizardComposableBody, useWizardFooter, ModalVariant, Alert, EmptyState, EmptyStateIcon, EmptyStateBody, EmptyStateSecondaryActions, Title, Progress } from '@patternfly/react-core'; -import { css } from '@patternfly/react-styles'; -import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; -import ExternalLinkAltIcon from '@patternfly/react-icons/dist/esm/icons/external-link-alt-icon'; -import SlackHashIcon from '@patternfly/react-icons/dist/esm/icons/slack-hash-icon'; - -PatternFly has two implementations of a `Wizard`, where the latest `WizardComposable`. Its temporary name speaks to the improvements made to the composition of the Wizard and its ansillary components. - -The documentation for the older `Wizard` implementation can be found under the [React legacy](/components/wizard/react-legacy) tab. - -## Examples - -### Basic - -```ts file="./WizardBasic.tsx" -``` - -### Custom Nav - -```ts file="./WizardCustomNav.tsx" -``` - -### Kitchen Sink - -```ts file="./WizardKitchenSink.tsx" -``` diff --git a/packages/react-core/src/components/WizardComposable/index.ts b/packages/react-core/src/components/WizardComposable/index.ts deleted file mode 100644 index 878e324cc31..00000000000 --- a/packages/react-core/src/components/WizardComposable/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from './WizardComposable'; -export * from './WizardComposabeContext'; -export * from './WizardComposableBody'; -export * from './WizardComposableFooter'; -export * from './WizardComposableToggle'; -export * from './WizardComposableStep'; -export { useWizardFooter } from './hooks/useWizardFooter'; -export * from './types'; diff --git a/packages/react-core/src/components/index.ts b/packages/react-core/src/components/index.ts index eef0b8f9fbe..aa48375a5d4 100644 --- a/packages/react-core/src/components/index.ts +++ b/packages/react-core/src/components/index.ts @@ -80,7 +80,6 @@ export * from './Tooltip'; export * from './NumberInput'; export * from './TreeView'; export * from './Wizard'; -export * from './WizardComposable'; export * from './DragDrop'; export * from './TextInputGroup'; export * from './Panel'; diff --git a/packages/react-core/src/components/WizardComposable/WizardComposable.tsx b/packages/react-core/src/next/components/Wizard/Wizard.tsx similarity index 78% rename from packages/react-core/src/components/WizardComposable/WizardComposable.tsx rename to packages/react-core/src/next/components/Wizard/Wizard.tsx index 8b464f6b455..cb81b3b64e8 100644 --- a/packages/react-core/src/components/WizardComposable/WizardComposable.tsx +++ b/packages/react-core/src/next/components/Wizard/Wizard.tsx @@ -12,14 +12,19 @@ import { CustomWizardNavFunction } from './types'; import { buildSteps, normalizeNavStep } from './utils'; -import { useWizardContext, WizardComposableContextProvider } from './WizardComposabeContext'; -import { WizardComposableStepProps } from './WizardComposableStep'; -import { WizardComposableFooter } from './WizardComposableFooter'; -import { WizardComposableToggle } from './WizardComposableToggle'; - -export interface WizardComposableProps extends React.HTMLProps { - /** Step, footer, or header child components */ - children: React.ReactElement | React.ReactElement[]; +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 { + /** Step components */ + children: React.ReactElement | React.ReactElement[]; /** Wizard header */ header?: React.ReactNode; /** Wizard footer */ @@ -46,7 +51,7 @@ export interface WizardComposableProps extends React.HTMLProps { onClose?(): void; } -export const WizardComposable = (props: WizardComposableProps) => { +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); @@ -122,7 +127,7 @@ export const WizardComposable = (props: WizardComposableProps) => { }; return ( - { goToStepByName={goToStepByName} goToStepByIndex={goToStepByIndex} > - {children} - + + {children} + + ); }; // eslint-disable-next-line patternfly-react/no-anonymous-functions -const WizardComposableInternal = ({ height, width, className, header, nav, ...restProps }: WizardComposableProps) => { - const { activeStep, steps, footer, onNext, onBack, onClose, goToStepByIndex } = useWizardContext(); +const WizardInternal = ({ height, width, className, header, footer, nav, ...divProps }: WizardProps) => { + const { activeStep, steps, footer: customFooter, onNext, onBack, onClose, goToStepByIndex } = useWizardContext(); - const wizardFooter = isCustomWizardFooter(footer) ? ( - footer - ) : ( - ); @@ -162,10 +167,10 @@ const WizardComposableInternal = ({ height, width, className, header, nav, ...re ...(height ? { height } : {}), ...(width ? { width } : {}) }} - {...restProps} + {...divProps} > {header} - ( - +}: WizardBodyProps) => ( +
{children}
); -WizardComposableBody.displayName = 'WizardComposableBody'; +WizardBody.displayName = 'WizardBody'; diff --git a/packages/react-core/src/components/WizardComposable/WizardComposabeContext.tsx b/packages/react-core/src/next/components/Wizard/WizardContext.tsx similarity index 73% rename from packages/react-core/src/components/WizardComposable/WizardComposabeContext.tsx rename to packages/react-core/src/next/components/Wizard/WizardContext.tsx index 1f0b7a18680..bbe8b1a758f 100644 --- a/packages/react-core/src/components/WizardComposable/WizardComposabeContext.tsx +++ b/packages/react-core/src/next/components/Wizard/WizardContext.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Step, SubStep } from './types'; import { getActiveStep } from './utils'; -export interface WizardComposableContextProps { +export interface WizardContextProps { steps: (Step | SubStep)[]; activeStep: Step | SubStep; footer: React.ReactElement; @@ -15,9 +15,9 @@ export interface WizardComposableContextProps { setFooter(footer: React.ReactElement): void; } -export const WizardComposableContext = React.createContext({} as WizardComposableContextProps); +export const WizardContext = React.createContext({} as WizardContextProps); -interface WizardComposableContextRenderProps { +interface WizardContextRenderProps { steps: (Step | SubStep)[]; activeStep: Step | SubStep; footer: React.ReactElement; @@ -26,11 +26,11 @@ interface WizardComposableContextRenderProps { onClose(): void; } -interface WizardComposableContextProviderProps { +export interface WizardContextProviderProps { steps: (Step | SubStep)[]; currentStepIndex: number; footer: React.ReactElement; - children: React.ReactElement | ((props: WizardComposableContextRenderProps) => React.ReactElement); + children: React.ReactElement | ((props: WizardContextRenderProps) => React.ReactElement); onNext(): void; onBack(): void; onClose(): void; @@ -40,7 +40,7 @@ interface WizardComposableContextProviderProps { } // eslint-disable-next-line patternfly-react/no-anonymous-functions -export const WizardComposableContextProvider: React.FunctionComponent = ({ +export const WizardContextProvider: React.FunctionComponent = ({ steps: initialSteps, footer: initialFooter, currentStepIndex, @@ -58,7 +58,7 @@ export const WizardComposableContextProvider: React.FunctionComponent { - if (!activeStep.visited) { + if (activeStep && !activeStep?.visited) { setSteps(prevSteps => prevSteps.map(step => { if (step.id === activeStep.id) { @@ -69,10 +69,10 @@ export const WizardComposableContextProvider: React.FunctionComponent {typeof children === 'function' ? children({ activeStep, steps, footer, onNext, onBack, onClose }) : children} - + ); }; -export const WizardComposableContextConsumer = WizardComposableContext.Consumer; -export const useWizardContext = () => React.useContext(WizardComposableContext); +export const WizardContextConsumer = WizardContext.Consumer; +export const useWizardContext = () => React.useContext(WizardContext); diff --git a/packages/react-core/src/components/WizardComposable/WizardComposableFooter.tsx b/packages/react-core/src/next/components/Wizard/WizardFooter.tsx similarity index 74% rename from packages/react-core/src/components/WizardComposable/WizardComposableFooter.tsx rename to packages/react-core/src/next/components/Wizard/WizardFooter.tsx index 3a9543c10f0..21ba408dbb5 100644 --- a/packages/react-core/src/components/WizardComposable/WizardComposableFooter.tsx +++ b/packages/react-core/src/next/components/Wizard/WizardFooter.tsx @@ -3,10 +3,14 @@ import React from 'react'; import { css } from '@patternfly/react-styles'; import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; -import { Button, ButtonVariant } from '../Button'; +import { Button, ButtonVariant } from '../../../components/Button'; import { Step, SubStep, WizardNavStepFunction } from './types'; -export interface WizardComposableFooterProps { +/** + * Hosts the standard structure of a footer with ties to the active step so that text for buttons can vary from step to step. + */ + +export interface WizardFooterProps { /** The currently active WizardStep */ activeStep: Step | SubStep; /** Next button callback */ @@ -25,7 +29,7 @@ export interface WizardComposableFooterProps { disableBackButton?: boolean; } -export const WizardComposableFooter = ({ +export const WizardFooter = ({ onNext, onBack, onClose, @@ -34,19 +38,19 @@ export const WizardComposableFooter = ({ nextButtonText = 'Next', backButtonText = 'Back', cancelButtonText = 'Cancel' -}: WizardComposableFooterProps) => ( +}: WizardFooterProps) => (
- - {!activeStep.hideBackButton && ( + {!activeStep?.hideBackButton && ( )} - {!activeStep.hideCancelButton && ( + {!activeStep?.hideCancelButton && (
); -WizardComposableFooter.displayName = 'WizardComposableFooter'; +WizardFooter.displayName = 'WizardFooter'; diff --git a/packages/react-core/src/next/components/Wizard/WizardStep.tsx b/packages/react-core/src/next/components/Wizard/WizardStep.tsx new file mode 100644 index 00000000000..9b9c81967f0 --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/WizardStep.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { Step } from './types'; +import { WizardBody, WizardBodyProps } from './WizardBody'; + +/** + * Used as a passthrough of step properties for Wizard and all supporting child components. + * Also acts as a wrapper for content, with an optional inclusion of WizardBody. + */ + +export interface WizardStepProps extends Omit { + /** Optional for when the Step is used as a parent to sub-steps */ + children?: React.ReactNode; + /** Props for WizardBody that wraps content by default. Can be set to null for exclusion of WizardBody. */ + body?: WizardBodyProps | null; + /** Optional list of sub-steps */ + steps?: React.ReactElement[]; +} + +export const WizardStep = ({ body, children }: WizardStepProps) => + body === undefined ? {children} : <>{children}; + +WizardStep.displayName = 'WizardStep'; diff --git a/packages/react-core/src/components/WizardComposable/WizardComposableToggle.tsx b/packages/react-core/src/next/components/Wizard/WizardToggle.tsx similarity index 88% rename from packages/react-core/src/components/WizardComposable/WizardComposableToggle.tsx rename to packages/react-core/src/next/components/Wizard/WizardToggle.tsx index 79cb93714a9..17c8b8d7744 100644 --- a/packages/react-core/src/components/WizardComposable/WizardComposableToggle.tsx +++ b/packages/react-core/src/next/components/Wizard/WizardToggle.tsx @@ -5,8 +5,8 @@ import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; import AngleRightIcon from '@patternfly/react-icons/dist/esm/icons/angle-right-icon'; import CaretDownIcon from '@patternfly/react-icons/dist/esm/icons/caret-down-icon'; -import { KeyTypes } from '../../helpers/constants'; -import { WizardNav, WizardNavItem } from '../Wizard'; +import { KeyTypes } from '../../../helpers/constants'; +import { WizardNav, WizardNavItem } from '../../../components/Wizard'; import { Step, SubStep, @@ -18,7 +18,11 @@ import { isWizardParentStep } from './types'; -export interface WizardComposableToggleProps { +/** + * Used to toggle between step content, including the body and footer. This is also where the nav and its expandability is controlled. + */ + +export interface WizardToggleProps { /** List of steps and/or sub-steps */ steps: (Step | SubStep)[]; /** The currently active WizardStep */ @@ -35,7 +39,7 @@ export interface WizardComposableToggleProps { unmountInactiveSteps?: boolean; } -export const WizardComposableToggle = ({ +export const WizardToggle = ({ steps, activeStep, footer, @@ -43,7 +47,7 @@ export const WizardComposableToggle = ({ goToStepByIndex, unmountInactiveSteps = true, 'aria-label': ariaLabel = 'Wizard toggle' -}: WizardComposableToggleProps) => { +}: WizardToggleProps) => { const [isNavOpen, setIsNavOpen] = React.useState(false); const isActiveSubStep = isWizardSubStep(activeStep); @@ -68,9 +72,9 @@ export const WizardComposableToggle = ({ // Only render the active step when unmountInactiveSteps is true const bodyContent = unmountInactiveSteps - ? activeStep.component + ? activeStep?.component : steps.map(step => { - if (activeStep.name === step.name) { + if (activeStep?.name === step.name) { return step.component; } @@ -108,7 +112,7 @@ export const WizardComposableToggle = ({ firstSubStepIndex = subStepIndex; } - if (activeStep.id === subStep.id) { + if (activeStep?.id === subStep.id) { hasActiveChild = true; } @@ -119,7 +123,7 @@ export const WizardComposableToggle = ({ key={subStep.id} id={subStep.id} content={subStep.name} - isCurrent={activeStep.id === subStep.id} + isCurrent={activeStep?.id === subStep.id} isDisabled={subStep.isDisabled || (nav?.forceStepVisit && !subStep.visited)} step={subStepIndex} onNavItemClick={goToStepByIndex} @@ -158,7 +162,7 @@ export const WizardComposableToggle = ({ key={step.id} id={step.id} content={step.name} - isCurrent={activeStep.id === step.id} + isCurrent={activeStep?.id === step.id} isDisabled={step.isDisabled || (nav?.forceStepVisit && !step.visited)} step={stepIndex} onNavItemClick={goToStepByIndex} @@ -169,7 +173,7 @@ export const WizardComposableToggle = ({ })} ); - }, [activeStep.id, goToStepByIndex, isNavOpen, nav, steps]); + }, [activeStep?.id, goToStepByIndex, isNavOpen, nav, steps]); return ( <> @@ -181,10 +185,10 @@ export const WizardComposableToggle = ({ > - {activeStep.name} + {activeStep?.name} {isActiveSubStep && - {isActiveSubStep && {activeStep.name}} + {isActiveSubStep && {activeStep?.name}} @@ -203,4 +207,4 @@ export const WizardComposableToggle = ({ ); }; -WizardComposableToggle.displayName = 'WizardComposableToggle'; +WizardToggle.displayName = 'WizardToggle'; diff --git a/packages/react-core/src/next/components/Wizard/__tests__/Wizard.test.tsx b/packages/react-core/src/next/components/Wizard/__tests__/Wizard.test.tsx new file mode 100644 index 00000000000..096c23c9c32 --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/__tests__/Wizard.test.tsx @@ -0,0 +1,299 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { WizardNav, WizardNavItem } from '../../../../components/Wizard'; +import { DefaultWizardNavProps, Step, SubStep } from '../types'; +import { DefaultWizardFooterProps, Wizard, WizardStep } from '..'; + +describe('Wizard', () => { + it('renders step when child is of type WizardStep', () => { + render( + + + Step content + + + ); + + expect(screen.getByRole('button', { name: 'Test step' })).toBeVisible(); + expect(screen.getByText('Step content')).toBeVisible(); + }); + + it('renders step when child has required props; name, id, children', () => { + const CustomStep = props =>
; + + render( + + + Custom step content + + + ); + + expect(screen.getByRole('button', { name: 'Test step' })).toBeVisible(); + expect(screen.getByText('Custom step content')).toBeVisible(); + }); + + it('renders a header when specified', () => { + render( + + + + ); + + expect(screen.getByText('Some header')).toBeVisible(); + }); + + it('renders default footer without specifying the footer prop', () => { + render( + + + + ); + + expect(screen.getByRole('button', { name: 'Next' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Back' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeVisible(); + }); + + it('renders default footer with custom props', () => { + const footer: DefaultWizardFooterProps = { + nextButtonText: <>Proceed with caution, + backButtonText: 'Turn back!', + cancelButtonText: 'Leave now!' + }; + + render( + + + + ); + + expect(screen.getByRole('button', { name: 'Proceed with caution' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Turn back!' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Leave now!' })).toBeVisible(); + }); + + it('renders custom footer', () => { + render( + Some footer}> + + + ); + + expect(screen.getByText('Some footer')).toBeVisible(); + }); + + it('renders default nav without specifying the nav prop', () => { + render( + + + + ); + + expect(screen.getByRole('navigation')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Test step' })).toBeVisible(); + }); + + it('renders default nav with custom props', () => { + const nav: DefaultWizardNavProps = { + isExpandable: true, + ariaLabel: 'Some nav label', + ariaLabelledBy: 'wizard-id', + forceStepVisit: true + }; + + render( + + ]} /> + + + ); + + const navElement = screen.getByLabelText('Some nav label'); + + expect(navElement).toBeVisible(); + expect(navElement).toHaveAttribute('aria-labelledby', 'wizard-id'); + expect(screen.getByRole('button', { name: 'Test step 1' }).parentElement).toHaveClass('pf-m-expandable'); + expect(screen.getByRole('button', { name: 'Test step 2' })).toHaveAttribute('disabled'); + }); + + it('renders custom nav', () => { + const nav = ( + isOpen: boolean, + steps: (Step | SubStep)[], + activeStep: Step | SubStep, + goToStepByIndex: (index: number) => void + ) => ( + + {steps.map((step, index) => ( + + ))} + + ); + + render( + + + + ); + + expect(screen.getByRole('navigation')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Test step' })).toBeVisible(); + }); + + it('starts at the first step as the active one by default', () => { + render( + + + Step 1 content + + + + ); + + expect(screen.getByText('Step 1 content')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Test step 1' })).toHaveClass('pf-m-current'); + }); + + it(`can start at a step that isn't the first by specifying 'startIndex'`, () => { + render( + + + + Step 2 content + + + ); + + expect(screen.getByText('Step 2 content')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Test step 2' })).toHaveClass('pf-m-current'); + }); + + it(`can use custom classNames and spread other props into the wizard's div`, () => { + render( + + + + ); + + expect(screen.getByTestId('wizard-id')).toHaveClass('some-class'); + }); + + it(`can customize the wizard's height and width`, () => { + render( + + + + ); + + const wizard = screen.getByTestId('wizard-id'); + + expect(wizard).toHaveStyle('height: 500px'); + expect(wizard).toHaveStyle('width: 500px'); + }); + + it('calls onNavByIndex on nav item click', () => { + const onNavByIndex = jest.fn(); + + render( + + + + + ); + + userEvent.click(screen.getByRole('button', { name: 'Test step 2' })); + expect(onNavByIndex).toHaveBeenCalled(); + }); + + it('calls onNext and not onSave on next button click when not on the last step', () => { + const onNext = jest.fn(); + const onSave = jest.fn(); + + render( + + + + + ); + + userEvent.click(screen.getByRole('button', { name: 'Next' })); + + expect(onNext).toHaveBeenCalled(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('calls onBack on back button click', () => { + const onBack = jest.fn(); + + render( + + + + + ); + + userEvent.click(screen.getByRole('button', { name: 'Test step 2' })); + userEvent.click(screen.getByRole('button', { name: 'Back' })); + + expect(onBack).toHaveBeenCalled(); + }); + + it('calls onSave and not onClose on next button click when on the last step', () => { + const onSave = jest.fn(); + const onClose = jest.fn(); + + render( + + + + + ); + + userEvent.click(screen.getByRole('button', { name: 'Test step 2' })); + userEvent.click(screen.getByRole('button', { name: 'Next' })); + + expect(onSave).toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('calls onClose when onSave is not specified on next button click when on the last step', () => { + const onClose = jest.fn(); + + render( + + + + + ); + + userEvent.click(screen.getByRole('button', { name: 'Test step 2' })); + userEvent.click(screen.getByRole('button', { name: 'Next' })); + + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onClose on cancel link click', () => { + const onClose = jest.fn(); + + render( + + + + ); + + userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/react-core/src/next/components/Wizard/examples/Wizard.md b/packages/react-core/src/next/components/Wizard/examples/Wizard.md new file mode 100644 index 00000000000..2c4cb962b35 --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/examples/Wizard.md @@ -0,0 +1,77 @@ +--- +id: Wizard +section: components +cssPrefix: pf-c-wizard +propComponents: ['Wizard', 'WizardFooter', 'WizardToggle', 'WizardStep', 'WizardBody'] +beta: true +--- + +import { + FormGroup, + TextInput, + Drawer, + DrawerContent, + Button, + Flex, + DrawerPanelContent, + DrawerColorVariant, + DrawerHead, + DrawerActions, + DrawerCloseButton, + WizardNavItem, + WizardNav, + WizardHeader +} from '@patternfly/react-core'; +import { + Wizard, + WizardFooter, + WizardToggle, + WizardStep, + WizardBody, + Step, + SubStep, + useWizardFooter, + WizardStepProps, + useWizardContext +} from '@patternfly/react-core/next'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; + +PatternFly has two implementations of a `Wizard`. + +This newer `Wizard` takes a more explicit and declarative approach compared to the older implementation, which can be found under the [React](/components/wizard/react) tab. + +## Composable structure + +The standard sub-component relationships are arranged as follows: + +```noLive + + + , + + ]} + /> + +``` + +## Examples + +### Basic + +```ts file="./WizardBasic.tsx" +``` + +### Custom Nav + +```ts file="./WizardCustomNav.tsx" +``` + +### Kitchen Sink + +Includes a header, custom footer, sub-steps, step content with a drawer, custom nav item, and nav prevention until step visitation. + +```ts file="./WizardKitchenSink.tsx" +``` diff --git a/packages/react-core/src/next/components/Wizard/examples/WizardBasic.tsx b/packages/react-core/src/next/components/Wizard/examples/WizardBasic.tsx new file mode 100644 index 00000000000..0739a06eb35 --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/examples/WizardBasic.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Wizard, WizardStep } from '@patternfly/react-core/next'; + +export const WizardBasic: React.FunctionComponent = () => ( + + +

Step 1 content

+
+ +

Step 2 content

+
+ +

Step 3 content

+
+ +

Step 4 content

{' '} +
+ +

Review step content

+
+
+); diff --git a/packages/react-core/src/components/WizardComposable/examples/WizardCustomNav.tsx b/packages/react-core/src/next/components/Wizard/examples/WizardCustomNav.tsx similarity index 59% rename from packages/react-core/src/components/WizardComposable/examples/WizardCustomNav.tsx rename to packages/react-core/src/next/components/Wizard/examples/WizardCustomNav.tsx index 87c000fda0b..9f26ecbeeea 100644 --- a/packages/react-core/src/components/WizardComposable/examples/WizardCustomNav.tsx +++ b/packages/react-core/src/next/components/Wizard/examples/WizardCustomNav.tsx @@ -1,12 +1,6 @@ import React from 'react'; -import { - Step, - SubStep, - WizardComposable, - WizardComposableStep, - WizardNav, - WizardNavItem -} from '@patternfly/react-core'; +import { WizardNav, WizardNavItem } from '@patternfly/react-core'; +import { Step, SubStep, Wizard, WizardStep } from '@patternfly/react-core/next'; export const WizardCustomNav: React.FunctionComponent = () => { const nav = ( @@ -31,16 +25,16 @@ export const WizardCustomNav: React.FunctionComponent = () => { ); return ( - - + +

Did you say...custom nav?

-
- + +

Step 2 content

-
- + +

Review step content

-
-
+ + ); }; diff --git a/packages/react-core/src/components/WizardComposable/examples/WizardKitchenSink.tsx b/packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx similarity index 74% rename from packages/react-core/src/components/WizardComposable/examples/WizardKitchenSink.tsx rename to packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx index f87763d5168..207875ac7db 100644 --- a/packages/react-core/src/components/WizardComposable/examples/WizardKitchenSink.tsx +++ b/packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx @@ -1,9 +1,6 @@ import React from 'react'; import { - WizardComposable, - WizardComposableStep, - WizardHeader, FormGroup, TextInput, Drawer, @@ -15,19 +12,25 @@ import { DrawerHead, DrawerActions, DrawerCloseButton, - WizardComposableBody, - WizardComposableFooter, - useWizardContext, WizardNavItem, - Step, - useWizardFooter + WizardHeader } from '@patternfly/react-core'; +import { + Step, + Wizard, + WizardStep, + WizardBody, + WizardFooter, + useWizardFooter, + WizardStepProps, + useWizardContext +} from '@patternfly/react-core/next'; import { css } from '@patternfly/react-styles'; import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; const CustomWizardFooter = () => { const { activeStep, onNext, onBack, onClose } = useWizardContext(); - return ; + return ; }; const CustomNavItem = () => { @@ -37,7 +40,7 @@ const CustomNavItem = () => { return ( Custom item} isCurrent={activeStep.id === step.id} step={steps.indexOf(step) + 1} isDisabled={step.isDisabled || !step.visited} @@ -63,7 +66,7 @@ const StepContentWithDrawer = () => { } > - + {!isDrawerExpanded && ( + )} + + {title || <> </>} + + {description && ( + + {description} + + )} +
+); +WizardHeader.displayName = 'WizardHeader'; diff --git a/packages/react-core/src/next/components/Wizard/WizardNav.tsx b/packages/react-core/src/next/components/Wizard/WizardNav.tsx new file mode 100644 index 00000000000..9cdfcb86ab2 --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/WizardNav.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; +import { css } from '@patternfly/react-styles'; + +export interface WizardNavProps { + /** children should be WizardNavItem components */ + children?: any; + /** Aria-label applied to the nav element */ + 'aria-label'?: string; + /** Sets the aria-labelledby attribute on the nav element */ + 'aria-labelledby'?: string; + /** Whether the nav is expanded */ + isOpen?: boolean; + /** True to return the inner list without the wrapping nav element */ + returnList?: boolean; +} + +export const WizardNav: React.FunctionComponent = ({ + children, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + isOpen = false, + returnList = false +}: WizardNavProps) => { + const innerList =
    {children}
; + + if (returnList) { + return innerList; + } + + return ( + + ); +}; +WizardNav.displayName = 'WizardNav'; diff --git a/packages/react-core/src/next/components/Wizard/WizardNavItem.tsx b/packages/react-core/src/next/components/Wizard/WizardNavItem.tsx new file mode 100644 index 00000000000..311f4a545bf --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/WizardNavItem.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; +import AngleRightIcon from '@patternfly/react-icons/dist/esm/icons/angle-right-icon'; + +export interface WizardNavItemProps { + /** Can nest a WizardNav component for substeps */ + children?: React.ReactNode; + /** The content to display in the nav item */ + content?: React.ReactNode; + /** Whether the nav item is the currently active item */ + isCurrent?: boolean; + /** Whether the nav item is disabled */ + isDisabled?: boolean; + /** The step passed into the onNavItemClick callback */ + step: number; + /** Callback for when the nav item is clicked */ + onNavItemClick?: (step: number) => any; + /** Component used to render WizardNavItem */ + navItemComponent?: 'button' | 'a'; + /** An optional url to use for when using an anchor component */ + href?: string; + /** Flag indicating that this NavItem has child steps and is expandable */ + isExpandable?: boolean; + /** The id for the nav item */ + id?: string | number; +} + +export const WizardNavItem: React.FunctionComponent = ({ + children = null, + content = '', + isCurrent = false, + isDisabled = false, + step, + onNavItemClick = () => undefined, + navItemComponent = 'button', + href = null, + isExpandable = false, + id, + ...rest +}: WizardNavItemProps) => { + const NavItemComponent = navItemComponent; + + const [isExpanded, setIsExpanded] = React.useState(false); + + React.useEffect(() => { + setIsExpanded(isCurrent); + }, [isCurrent]); + + if (navItemComponent === 'a' && !href && process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.error('WizardNavItem: When using an anchor, please provide an href'); + } + + const btnProps = { + disabled: isDisabled + }; + + const linkProps = { + tabIndex: isDisabled ? -1 : undefined, + href + }; + + return ( +
  • + (isExpandable ? setIsExpanded(!isExpanded || isCurrent) : onNavItemClick(step))} + className={css( + styles.wizardNavLink, + isCurrent && styles.modifiers.current, + isDisabled && styles.modifiers.disabled + )} + aria-disabled={isDisabled ? true : null} + aria-current={isCurrent && !children ? 'step' : false} + {...(isExpandable && { 'aria-expanded': isExpanded })} + > + {isExpandable ? ( + <> + {content} + + + + + + + ) : ( + content + )} + + {children} +
  • + ); +}; +WizardNavItem.displayName = 'WizardNavItem'; diff --git a/packages/react-core/src/next/components/Wizard/WizardToggle.tsx b/packages/react-core/src/next/components/Wizard/WizardToggle.tsx index 6200c7445a8..abcd79fd587 100644 --- a/packages/react-core/src/next/components/Wizard/WizardToggle.tsx +++ b/packages/react-core/src/next/components/Wizard/WizardToggle.tsx @@ -6,7 +6,7 @@ import AngleRightIcon from '@patternfly/react-icons/dist/esm/icons/angle-right-i import CaretDownIcon from '@patternfly/react-icons/dist/esm/icons/caret-down-icon'; import { KeyTypes } from '../../../helpers/constants'; -import { WizardNav, WizardNavItem } from '../../../components/Wizard'; +import { WizardNav, WizardNavItem } from '../Wizard'; import { WizardControlStep, CustomWizardNavFunction, diff --git a/packages/react-core/src/next/components/Wizard/__tests__/Wizard.test.tsx b/packages/react-core/src/next/components/Wizard/__tests__/Wizard.test.tsx index 31fef48d6d8..c9c75240783 100644 --- a/packages/react-core/src/next/components/Wizard/__tests__/Wizard.test.tsx +++ b/packages/react-core/src/next/components/Wizard/__tests__/Wizard.test.tsx @@ -3,9 +3,8 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { WizardNav, WizardNavItem } from '../../../../components/Wizard'; import { DefaultWizardNavProps, WizardControlStep, DefaultWizardFooterProps } from '../types'; -import { Wizard, WizardStep } from '..'; +import { WizardNav, WizardNavItem, Wizard, WizardStep } from '../'; describe('Wizard', () => { it('renders step when child is of type WizardStep', () => { diff --git a/packages/react-core/src/next/components/Wizard/examples/Wizard.md b/packages/react-core/src/next/components/Wizard/examples/Wizard.md index 0cb1b28f00d..951ab8f56b3 100644 --- a/packages/react-core/src/next/components/Wizard/examples/Wizard.md +++ b/packages/react-core/src/next/components/Wizard/examples/Wizard.md @@ -2,34 +2,51 @@ id: Wizard section: components cssPrefix: pf-c-wizard -propComponents: ['Wizard', 'WizardStep', 'WizardFooter', 'WizardToggle', 'WizardStep', 'WizardBody', 'WizardContextProps', 'WizardBasicStep', 'WizardParentStep', 'WizardSubStep', 'DefaultWizardNavProps', 'DefaultWizardFooterProps'] +propComponents: + [ + 'WizardProps', + 'WizardFooterProps', + 'WizardToggleProps', + 'WizardStepProps', + 'WizardBodyProps', + 'WizardHeaderProps', + 'WizardNavProps', + 'WizardNavItemProps', + 'WizardContextProps', + 'WizardBasicStep', + 'WizardParentStep', + 'WizardSubStep', + 'WizardNavItemProps', + 'DefaultWizardNavProps', + 'DefaultWizardFooterProps', + ] beta: true --- import { - FormGroup, - TextInput, - Drawer, - DrawerContent, - Button, - Flex, - DrawerPanelContent, - DrawerColorVariant, - DrawerHead, - DrawerActions, - DrawerCloseButton, - WizardNavItem, - WizardNav, - WizardHeader +FormGroup, +TextInput, +Drawer, +DrawerContent, +Button, +Flex, +DrawerPanelContent, +DrawerColorVariant, +DrawerHead, +DrawerActions, +DrawerCloseButton } from '@patternfly/react-core'; import { - Wizard, - WizardFooter, - WizardToggle, - WizardStep, - WizardBody, - useWizardFooter, - useWizardContext +Wizard, +WizardFooter, +WizardToggle, +WizardStep, +WizardBody, +useWizardFooter, +useWizardContext, +WizardNavItem, +WizardNav, +WizardHeader } from '@patternfly/react-core/next'; import { css } from '@patternfly/react-styles'; import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; @@ -45,6 +62,21 @@ PatternFly has two implementations of a `Wizard`. This newer `Wizard` takes a mo ### Custom navigation +The `Wizard`'s `nav` property can be used to build your own navigation. + +``` +/** Callback for the Wizard's 'nav' property. Returns element which replaces the Wizard's default navigation. */ +export type CustomWizardNavFunction = ( + isOpen: boolean, + steps: WizardControlStep[], + activeStep: WizardControlStep, + goToStepByIndex: (index: number) => void +) => React.ReactElement; + +/** Encompasses all step type variants that are internally controlled by the Wizard */ +type WizardControlStep = WizardBasicStep | WizardParentStep | WizardSubStep; +``` + ```ts file="./WizardCustomNav.tsx" ``` @@ -52,6 +84,16 @@ PatternFly has two implementations of a `Wizard`. This newer `Wizard` takes a mo Includes a header, custom footer, sub-steps, step content with a drawer, custom nav item, and nav prevention until step visitation. +When tapping into the `onNext`, `onBack` or `onNavByIndex` function props for `Wizard`, these callback functions will return the 'id' and 'name' of the currentStep (the currently focused step), and the previousStep (the previously focused step). + +``` +/** Callback for the Wizard's 'onNext', 'onBack', and 'onNavByIndex' properties */ +type WizardNavStepFunction = (currentStep: WizardNavStepData, previousStep: WizardNavStepData) => void; + +/** Data returned for either parameter of WizardNavStepFunction */ +type WizardNavStepData = Pick; +``` + ```ts file="./WizardKitchenSink.tsx" ``` diff --git a/packages/react-core/src/next/components/Wizard/examples/WizardCustomNav.tsx b/packages/react-core/src/next/components/Wizard/examples/WizardCustomNav.tsx index 97ffd88614b..29941787d3d 100644 --- a/packages/react-core/src/next/components/Wizard/examples/WizardCustomNav.tsx +++ b/packages/react-core/src/next/components/Wizard/examples/WizardCustomNav.tsx @@ -1,9 +1,15 @@ import React from 'react'; -import { WizardNav, WizardNavItem } from '@patternfly/react-core'; -import { Wizard, WizardStep, WizardControlStep } from '@patternfly/react-core/next'; +import { + Wizard, + WizardStep, + WizardControlStep, + CustomWizardNavFunction, + WizardNav, + WizardNavItem +} from '@patternfly/react-core/next'; export const WizardCustomNav: React.FunctionComponent = () => { - const nav = ( + const nav: CustomWizardNavFunction = ( isOpen: boolean, steps: WizardControlStep[], activeStep: WizardControlStep, diff --git a/packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx b/packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx index 303680ff30b..cd78b3434b5 100644 --- a/packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx +++ b/packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx @@ -11,9 +11,7 @@ import { DrawerColorVariant, DrawerHead, DrawerActions, - DrawerCloseButton, - WizardNavItem, - WizardHeader + DrawerCloseButton } from '@patternfly/react-core'; import { Wizard, @@ -23,7 +21,11 @@ import { WizardStepProps, WizardControlStep, useWizardFooter, - useWizardContext + useWizardContext, + WizardNavStepFunction, + WizardNavStepData, + WizardNavItem, + WizardHeader } from '@patternfly/react-core/next'; import { css } from '@patternfly/react-styles'; import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; @@ -125,36 +127,44 @@ const StepWithCustomFooter = () => { const CustomStepFour = (props: WizardStepProps) =>
    ; -export const WizardKitchenSink: React.FunctionComponent = () => ( - } - footer={} - nav={{ forceStepVisit: true, isExpandable: true }} - > - - - - - Substep 1 content - , - - Substep 2 content - - ]} - /> - }> - - - - Step 4 content - - - Review step content - - -); +export const WizardKitchenSink: React.FunctionComponent = () => { + const onNext: WizardNavStepFunction = (currentStep: WizardNavStepData, previousStep: WizardNavStepData) => { + // eslint-disable-next-line no-console + console.log('currentStep: ', currentStep, '\n previousStep: ', previousStep); + }; + + return ( + } + footer={} + nav={{ forceStepVisit: true, isExpandable: true }} + onNext={onNext} + > + + + + + Substep 1 content + , + + Substep 2 content + + ]} + /> + }> + + + + Step 4 content + + + Review step content + + + ); +}; diff --git a/packages/react-core/src/next/components/Wizard/index.ts b/packages/react-core/src/next/components/Wizard/index.ts index f5b3862ffc4..88def117af3 100644 --- a/packages/react-core/src/next/components/Wizard/index.ts +++ b/packages/react-core/src/next/components/Wizard/index.ts @@ -3,6 +3,9 @@ export * from './WizardBody'; export * from './WizardFooter'; export * from './WizardToggle'; export * from './WizardStep'; +export * from './WizardNav'; +export * from './WizardNavItem'; +export * from './WizardHeader'; export * from './hooks'; export * from './types'; export { useWizardContext } from './WizardContext'; diff --git a/packages/react-core/src/next/components/Wizard/types.tsx b/packages/react-core/src/next/components/Wizard/types.tsx index e2ac7264dbf..768fdee4f5d 100644 --- a/packages/react-core/src/next/components/Wizard/types.tsx +++ b/packages/react-core/src/next/components/Wizard/types.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { WizardNavItemProps, WizardNavProps } from '../../../components/Wizard'; +import { WizardNavProps, WizardNavItemProps } from '../Wizard'; +/** Type used to define 'basic' steps, or in other words, steps that are neither parents or children of parents. */ export interface WizardBasicStep { /** Name of the step's nav item */ name: React.ReactNode; @@ -24,20 +25,19 @@ export interface WizardBasicStep { hideBackButton?: boolean; } +/** Type used to define parent steps. */ export interface WizardParentStep extends WizardBasicStep { /** Nested step IDs */ subStepIds: string[]; } +/** Type used to define sub-steps. */ export interface WizardSubStep extends WizardBasicStep { /** Unique identifier of the parent step */ parentId: string | number; } -export type WizardControlStep = WizardBasicStep | WizardParentStep | WizardSubStep; -export type WizardNavStepData = Pick; -export type WizardNavStepFunction = (currentStep: WizardNavStepData, previousStep: WizardNavStepData) => void; - +/** Used to customize aspects of the Wizard's default navigation. */ export interface DefaultWizardNavProps { /** Flag indicating nav items with sub steps are expandable */ isExpandable?: boolean; @@ -49,6 +49,7 @@ export interface DefaultWizardNavProps { forceStepVisit?: boolean; } +/** Used to customize aspects of the Wizard's default footer. */ export interface DefaultWizardFooterProps { /** The Next button text */ nextButtonText?: React.ReactNode; @@ -58,6 +59,16 @@ export interface DefaultWizardFooterProps { cancelButtonText?: React.ReactNode; } +/** Encompasses all step type variants that are internally controlled by the Wizard. */ +export type WizardControlStep = WizardBasicStep | WizardParentStep | WizardSubStep; + +/** Callback for the Wizard's 'onNext', 'onBack', and 'onNavByIndex' properties. */ +export type WizardNavStepFunction = (currentStep: WizardNavStepData, previousStep: WizardNavStepData) => void; + +/** Data returned for either parameter of WizardNavStepFunction. */ +export type WizardNavStepData = Pick; + +/** Callback for the Wizard's 'nav' property. Returns element which replaces the Wizard's default navigation. */ export type CustomWizardNavFunction = ( isOpen: boolean, steps: WizardControlStep[], From c2319730a9e58a13e42908995311a66fde9b8a58 Mon Sep 17 00:00:00 2001 From: Jeff Puzzo Date: Tue, 23 Aug 2022 14:23:39 -0400 Subject: [PATCH 7/8] update example comment --- .../react-core/src/next/components/Wizard/examples/Wizard.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-core/src/next/components/Wizard/examples/Wizard.md b/packages/react-core/src/next/components/Wizard/examples/Wizard.md index 951ab8f56b3..d55db0d8fd9 100644 --- a/packages/react-core/src/next/components/Wizard/examples/Wizard.md +++ b/packages/react-core/src/next/components/Wizard/examples/Wizard.md @@ -84,7 +84,7 @@ type WizardControlStep = WizardBasicStep | WizardParentStep | WizardSubStep; Includes a header, custom footer, sub-steps, step content with a drawer, custom nav item, and nav prevention until step visitation. -When tapping into the `onNext`, `onBack` or `onNavByIndex` function props for `Wizard`, these callback functions will return the 'id' and 'name' of the currentStep (the currently focused step), and the previousStep (the previously focused step). +Custom operations when navigating between steps can be achieved by utilizing `onNext`, `onBack` or `onNavByIndex` properties whose callback functions return the 'id' and 'name' of the currently focused step (currentStep), and the previously focused step (previousStep). ``` /** Callback for the Wizard's 'onNext', 'onBack', and 'onNavByIndex' properties */ From 97bfc1ce84f5d5d569411ef40c0d124d5cf3674d Mon Sep 17 00:00:00 2001 From: Jeff Puzzo Date: Tue, 23 Aug 2022 15:45:28 -0400 Subject: [PATCH 8/8] update types for documentation purposes --- .../src/next/components/Wizard/Wizard.tsx | 4 ++-- .../next/components/Wizard/WizardContext.tsx | 14 +++++++------- .../src/next/components/Wizard/WizardFooter.tsx | 6 +++--- .../src/next/components/Wizard/WizardToggle.tsx | 2 +- .../next/components/Wizard/examples/Wizard.md | 17 ++++++++--------- 5 files changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/react-core/src/next/components/Wizard/Wizard.tsx b/packages/react-core/src/next/components/Wizard/Wizard.tsx index daf2b83d4ae..040a8061335 100644 --- a/packages/react-core/src/next/components/Wizard/Wizard.tsx +++ b/packages/react-core/src/next/components/Wizard/Wizard.tsx @@ -46,9 +46,9 @@ export interface WizardProps extends React.HTMLProps { /** 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; + onSave?: () => void; /** Callback function to close the wizard */ - onClose?(): void; + onClose?: () => void; } export const Wizard = (props: WizardProps) => { diff --git a/packages/react-core/src/next/components/Wizard/WizardContext.tsx b/packages/react-core/src/next/components/Wizard/WizardContext.tsx index f8082baa857..8859d1b5879 100644 --- a/packages/react-core/src/next/components/Wizard/WizardContext.tsx +++ b/packages/react-core/src/next/components/Wizard/WizardContext.tsx @@ -10,19 +10,19 @@ export interface WizardContextProps { /** Footer element */ footer: React.ReactElement; /** Navigate to the next step */ - onNext(): void; + onNext: () => void; /** Navigate to the previous step */ - onBack(): void; + onBack: () => void; /** Close the wizard */ - onClose(): void; + onClose: () => void; /** Navigate to step by ID */ - goToStepById(id: number | string): void; + goToStepById: (id: number | string) => void; /** Navigate to step by name */ - goToStepByName(name: string): void; + goToStepByName: (name: string) => void; /** Navigate to step by index */ - goToStepByIndex(index: number): void; + goToStepByIndex: (index: number) => void; /** Update the footer with any react element */ - setFooter(footer: React.ReactElement): void; + setFooter: (footer: React.ReactElement) => void; } export const WizardContext = React.createContext({} as WizardContextProps); diff --git a/packages/react-core/src/next/components/Wizard/WizardFooter.tsx b/packages/react-core/src/next/components/Wizard/WizardFooter.tsx index 40fb1aec6d6..e99371969d2 100644 --- a/packages/react-core/src/next/components/Wizard/WizardFooter.tsx +++ b/packages/react-core/src/next/components/Wizard/WizardFooter.tsx @@ -14,11 +14,11 @@ export interface WizardFooterProps { /** The currently active WizardStep */ activeStep: WizardControlStep; /** Next button callback */ - onNext(): WizardNavStepFunction | void; + onNext: () => WizardNavStepFunction | void; /** Back button callback */ - onBack(): WizardNavStepFunction | void; + onBack: () => WizardNavStepFunction | void; /** Cancel link callback */ - onClose(): void; + onClose: () => void; /** Custom text for the Next button. The activeStep's nextButtonText takes precedence. */ nextButtonText?: React.ReactNode; /** Custom text for the Back button */ diff --git a/packages/react-core/src/next/components/Wizard/WizardToggle.tsx b/packages/react-core/src/next/components/Wizard/WizardToggle.tsx index abcd79fd587..17288370e1a 100644 --- a/packages/react-core/src/next/components/Wizard/WizardToggle.tsx +++ b/packages/react-core/src/next/components/Wizard/WizardToggle.tsx @@ -31,7 +31,7 @@ export interface WizardToggleProps { /** Custom WizardNav or callback used to create a default WizardNav */ nav: DefaultWizardNavProps | CustomWizardNavFunction; /** Navigate using the step index */ - goToStepByIndex(index: number): void; + goToStepByIndex: (index: number) => void; /** The button's aria-label */ 'aria-label'?: string; /** Flag to unmount inactive steps instead of hiding. Defaults to true */ diff --git a/packages/react-core/src/next/components/Wizard/examples/Wizard.md b/packages/react-core/src/next/components/Wizard/examples/Wizard.md index d55db0d8fd9..26af49822a8 100644 --- a/packages/react-core/src/next/components/Wizard/examples/Wizard.md +++ b/packages/react-core/src/next/components/Wizard/examples/Wizard.md @@ -4,19 +4,18 @@ section: components cssPrefix: pf-c-wizard propComponents: [ - 'WizardProps', - 'WizardFooterProps', - 'WizardToggleProps', - 'WizardStepProps', - 'WizardBodyProps', - 'WizardHeaderProps', - 'WizardNavProps', - 'WizardNavItemProps', + 'Wizard', + 'WizardFooter', + 'WizardToggle', + 'WizardStep', + 'WizardBody', + 'WizardHeader', + 'WizardNav', + 'WizardNavItem', 'WizardContextProps', 'WizardBasicStep', 'WizardParentStep', 'WizardSubStep', - 'WizardNavItemProps', 'DefaultWizardNavProps', 'DefaultWizardFooterProps', ]