diff --git a/packages/react-core/src/components/Wizard/examples/Wizard.md b/packages/react-core/src/components/Wizard/examples/Wizard.md index 860736de28a..027987b3e34 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 next](/components/wizard/react-next) tab. + ## Examples ### Basic @@ -54,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' } @@ -145,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/next/components/Wizard/Wizard.tsx b/packages/react-core/src/next/components/Wizard/Wizard.tsx new file mode 100644 index 00000000000..040a8061335 --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/Wizard.tsx @@ -0,0 +1,184 @@ +import React from 'react'; + +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; + +import { + DefaultWizardFooterProps, + DefaultWizardNavProps, + isCustomWizardFooter, + isWizardParentStep, + WizardNavStepFunction, + CustomWizardNavFunction +} from './types'; +import { buildSteps, normalizeNavStep } from './utils'; +import { useWizardContext, WizardContextProvider } from './WizardContext'; +import { WizardStepProps } from './WizardStep'; +import { WizardFooter } from './WizardFooter'; +import { WizardToggle } from './WizardToggle'; + +/** + * Wrapper for all steps and hosts state, including navigation helpers, within context. + * The WizardContext provided by default gives any child of wizard access to those resources. + */ + +export interface WizardProps extends React.HTMLProps { + /** Step 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 Wizard = (props: WizardProps) => { + const { startIndex = 1, children, footer, onNavByIndex, onNext, onBack, onSave, onClose, ...internalProps } = props; + const [currentStepIndex, setCurrentStepIndex] = React.useState(startIndex); + const steps = buildSteps(children); + + const goToStepByIndex = (index: number) => { + const lastStepIndex = steps.length; + + if (index < 1) { + index = 1; + } else if (index > lastStepIndex) { + index = lastStepIndex; + } + + const currStep = steps[index - 1]; + const prevStep = steps[currentStepIndex - 1]; + setCurrentStepIndex(index); + + return onNavByIndex?.(normalizeNavStep(currStep), normalizeNavStep(prevStep)); + }; + + const goToNextStep = () => { + // Save when on the last step, otherwise close + if (currentStepIndex >= steps.length) { + if (onSave) { + return onSave(); + } + + return onClose?.(); + } + + let currStep = steps[currentStepIndex]; + let newStepIndex = currentStepIndex + 1; + const prevStep = steps[currentStepIndex - 1]; + + // Skip parent step and focus on the first sub-step if they exist + if (isWizardParentStep(currStep)) { + newStepIndex += 1; + currStep = steps[currentStepIndex + 1]; + } + + setCurrentStepIndex(newStepIndex); + return onNext?.(normalizeNavStep(currStep), normalizeNavStep(prevStep)); + }; + + const goToPrevStep = () => { + if (steps.length < currentStepIndex) { + // Previous step was removed, just update the currentStep state + setCurrentStepIndex(steps.length); + } else { + let currStep = steps[currentStepIndex - 2]; + let newStepIndex = currentStepIndex - 1; + const prevStep = steps[currentStepIndex - 1]; + + // // Skip parent step and focus on the step prior + if (isWizardParentStep(currStep)) { + newStepIndex -= 1; + currStep = steps[currentStepIndex - 3]; + } + + setCurrentStepIndex(newStepIndex); + return onBack?.(normalizeNavStep(currStep), normalizeNavStep(prevStep)); + } + }; + + const goToStepById = (id: number | string) => { + const stepIndex = steps.findIndex(step => step.id === id) + 1; + stepIndex > 0 && setCurrentStepIndex(stepIndex); + }; + + const goToStepByName = (name: string) => { + const stepIndex = steps.findIndex(step => step.name === name) + 1; + stepIndex > 0 && setCurrentStepIndex(stepIndex); + }; + + return ( + + + {children} + + + ); +}; + +// eslint-disable-next-line patternfly-react/no-anonymous-functions +const WizardInternal = ({ height, width, className, header, footer, nav, ...divProps }: WizardProps) => { + const { activeStep, steps, footer: customFooter, onNext, onBack, onClose, goToStepByIndex } = useWizardContext(); + + const wizardFooter = customFooter || ( + + ); + + return ( +
+ {header} + +
+ ); +}; + +Wizard.displayName = 'Wizard'; diff --git a/packages/react-core/src/next/components/Wizard/WizardBody.tsx b/packages/react-core/src/next/components/Wizard/WizardBody.tsx new file mode 100644 index 00000000000..51cd195facc --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/WizardBody.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; +import { css } from '@patternfly/react-styles'; + +/** + * Used as a wrapper for WizardStep content, where the wrapping element is customizable. + */ + +export interface WizardBodyProps { + children?: React.ReactNode | React.ReactNode[]; + /** Set to true to remove the default body padding */ + hasNoBodyPadding?: boolean; + /** An aria-label to use for the wrapper element */ + 'aria-label'?: string; + /** Sets the aria-labelledby attribute for the wrapper element */ + 'aria-labelledby'?: string; + /** Component used as the wrapping content container */ + wrapperElement?: React.ElementType; +} + +export const WizardBody = ({ + children, + hasNoBodyPadding = false, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + wrapperElement: Wrapper = 'div' +}: WizardBodyProps) => ( + +
{children}
+
+); + +WizardBody.displayName = 'WizardBody'; diff --git a/packages/react-core/src/next/components/Wizard/WizardContext.tsx b/packages/react-core/src/next/components/Wizard/WizardContext.tsx new file mode 100644 index 00000000000..8859d1b5879 --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/WizardContext.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { WizardControlStep } from './types'; +import { getActiveStep } from './utils'; + +export interface WizardContextProps { + /** List of steps */ + steps: WizardControlStep[]; + /** Active step */ + activeStep: WizardControlStep; + /** Footer element */ + footer: React.ReactElement; + /** Navigate to the next step */ + onNext: () => void; + /** Navigate to the previous step */ + onBack: () => void; + /** Close the wizard */ + onClose: () => void; + /** Navigate to step by ID */ + goToStepById: (id: number | string) => void; + /** Navigate to step by name */ + goToStepByName: (name: string) => void; + /** Navigate to step by index */ + goToStepByIndex: (index: number) => void; + /** Update the footer with any react element */ + setFooter: (footer: React.ReactElement) => void; +} + +export const WizardContext = React.createContext({} as WizardContextProps); + +interface WizardContextRenderProps { + steps: WizardControlStep[]; + activeStep: WizardControlStep; + footer: React.ReactElement; + onNext(): void; + onBack(): void; + onClose(): void; +} + +export interface WizardContextProviderProps { + steps: WizardControlStep[]; + currentStepIndex: number; + footer: React.ReactElement; + children: React.ReactElement | ((props: WizardContextRenderProps) => React.ReactElement); + onNext(): void; + onBack(): void; + onClose(): void; + goToStepById(id: number | string): void; + goToStepByName(name: string): void; + goToStepByIndex(index: number): void; +} + +// eslint-disable-next-line patternfly-react/no-anonymous-functions +export const WizardContextProvider: React.FunctionComponent = ({ + steps: initialSteps, + footer: initialFooter, + currentStepIndex, + children, + onNext, + onBack, + onClose, + goToStepById, + goToStepByName, + goToStepByIndex +}) => { + const [steps, setSteps] = React.useState(initialSteps); + const [footer, setFooter] = React.useState(initialFooter); + const activeStep = getActiveStep(steps, currentStepIndex); + + // When the active step changes and the newly active step isn't visited, set the visited flag to true. + React.useEffect(() => { + if (activeStep && !activeStep?.visited) { + setSteps(prevSteps => + prevSteps.map(step => { + if (step.id === activeStep.id) { + return { ...step, visited: true }; + } + + return step; + }) + ); + } + }, [activeStep]); + + return ( + + {typeof children === 'function' ? children({ activeStep, steps, footer, onNext, onBack, onClose }) : children} + + ); +}; + +export const useWizardContext = () => React.useContext(WizardContext); diff --git a/packages/react-core/src/next/components/Wizard/WizardFooter.tsx b/packages/react-core/src/next/components/Wizard/WizardFooter.tsx new file mode 100644 index 00000000000..e99371969d2 --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/WizardFooter.tsx @@ -0,0 +1,63 @@ +import React from 'react'; + +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; + +import { Button, ButtonVariant } from '../../../components/Button'; +import { WizardControlStep, WizardNavStepFunction } from './types'; + +/** + * 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: WizardControlStep; + /** 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 WizardFooter = ({ + onNext, + onBack, + onClose, + activeStep, + disableBackButton, + nextButtonText = 'Next', + backButtonText = 'Back', + cancelButtonText = 'Cancel' +}: WizardFooterProps) => ( +
+ + + {!activeStep?.hideBackButton && ( + + )} + + {!activeStep?.hideCancelButton && ( +
+ +
+ )} +
+); + +WizardFooter.displayName = 'WizardFooter'; diff --git a/packages/react-core/src/next/components/Wizard/WizardHeader.tsx b/packages/react-core/src/next/components/Wizard/WizardHeader.tsx new file mode 100644 index 00000000000..a41307fcb4f --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/WizardHeader.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import styles from '@patternfly/react-styles/css/components/Wizard/wizard'; +import { css } from '@patternfly/react-styles'; +import { Button } from '../../../components/Button'; +import { Title } from '../../../components/Title'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +export interface WizardHeaderProps { + /** Callback function called when the X (Close) button is clicked */ + onClose?: () => void; + /** Title of the wizard */ + title: string; + /** Description of the wizard */ + description?: React.ReactNode; + /** Component type of the description */ + descriptionComponent?: 'div' | 'p'; + /** Flag indicating whether the close button should be in the header */ + hideClose?: boolean; + /** Aria-label applied to the X (Close) button */ + closeButtonAriaLabel?: string; + /** id for the title */ + titleId?: string; + /** id for the description */ + descriptionId?: string; +} + +export const WizardHeader: React.FunctionComponent = ({ + onClose = () => undefined, + title, + description, + hideClose, + closeButtonAriaLabel, + titleId, + descriptionComponent: Component = 'p', + descriptionId +}: WizardHeaderProps) => ( +
+ {!hideClose && ( + + )} + + {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/WizardStep.tsx b/packages/react-core/src/next/components/Wizard/WizardStep.tsx new file mode 100644 index 00000000000..4a0f7a65564 --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/WizardStep.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { WizardControlStep } 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/next/components/Wizard/WizardToggle.tsx b/packages/react-core/src/next/components/Wizard/WizardToggle.tsx new file mode 100644 index 00000000000..17288370e1a --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/WizardToggle.tsx @@ -0,0 +1,209 @@ +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 { + WizardControlStep, + CustomWizardNavFunction, + DefaultWizardNavProps, + isWizardBasicStep, + isWizardParentStep, + isWizardSubStep, + isCustomWizardNav +} from './types'; + +/** + * 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: WizardControlStep[]; + /** The currently active WizardStep */ + activeStep: WizardControlStep; + /** 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 WizardToggle = ({ + steps, + activeStep, + footer, + nav, + goToStepByIndex, + unmountInactiveSteps = true, + 'aria-label': ariaLabel = 'Wizard toggle' +}: WizardToggleProps) => { + 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} +
    + + ); +}; + +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..c9c75240783 --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/__tests__/Wizard.test.tsx @@ -0,0 +1,298 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { DefaultWizardNavProps, WizardControlStep, DefaultWizardFooterProps } from '../types'; +import { WizardNav, WizardNavItem, 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: WizardControlStep[], + activeStep: WizardControlStep, + 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..26af49822a8 --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/examples/Wizard.md @@ -0,0 +1,125 @@ +--- +id: Wizard +section: components +cssPrefix: pf-c-wizard +propComponents: + [ + 'Wizard', + 'WizardFooter', + 'WizardToggle', + 'WizardStep', + 'WizardBody', + 'WizardHeader', + 'WizardNav', + 'WizardNavItem', + 'WizardContextProps', + 'WizardBasicStep', + 'WizardParentStep', + 'WizardSubStep', + 'DefaultWizardNavProps', + 'DefaultWizardFooterProps', + ] +beta: true +--- + +import { +FormGroup, +TextInput, +Drawer, +DrawerContent, +Button, +Flex, +DrawerPanelContent, +DrawerColorVariant, +DrawerHead, +DrawerActions, +DrawerCloseButton +} from '@patternfly/react-core'; +import { +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'; + +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. + +## Examples + +### Basic + +```ts file="./WizardBasic.tsx" +``` + +### 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" +``` + +### Kitchen sink + +Includes a header, custom footer, sub-steps, step content with a drawer, custom nav item, and nav prevention until step visitation. + +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 */ +type WizardNavStepFunction = (currentStep: WizardNavStepData, previousStep: WizardNavStepData) => void; + +/** Data returned for either parameter of WizardNavStepFunction */ +type WizardNavStepData = Pick; +``` + +```ts file="./WizardKitchenSink.tsx" +``` + +## Hooks + +### useWizardFooter + +Used to set a unique footer for the wizard on any given step. See step 3 of [Kitchen sink](#kitchen-sink) for a live example. + +```noLive +import { useWizardFooter } from '@patternfly/react-core/next'; + +const StepContent = () => { + useWizardFooter(<>Some footer); + return <>Step content; +} +``` + +### useWizardContext + +Used to access any property of [WizardContext](#wizardcontextprops): + +```noLive +import { useWizardContext } from '@patternfly/react-core/next'; + +const StepContent = () => { + const { activeStep } = useWizardContext(); + return <>This is the active step: {activeStep}; +} +``` 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/next/components/Wizard/examples/WizardCustomNav.tsx b/packages/react-core/src/next/components/Wizard/examples/WizardCustomNav.tsx new file mode 100644 index 00000000000..29941787d3d --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/examples/WizardCustomNav.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { + Wizard, + WizardStep, + WizardControlStep, + CustomWizardNavFunction, + WizardNav, + WizardNavItem +} from '@patternfly/react-core/next'; + +export const WizardCustomNav: React.FunctionComponent = () => { + const nav: CustomWizardNavFunction = ( + isOpen: boolean, + steps: WizardControlStep[], + activeStep: WizardControlStep, + 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/next/components/Wizard/examples/WizardKitchenSink.tsx b/packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx new file mode 100644 index 00000000000..cd78b3434b5 --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/examples/WizardKitchenSink.tsx @@ -0,0 +1,170 @@ +import React from 'react'; + +import { + FormGroup, + TextInput, + Drawer, + DrawerContent, + Button, + Flex, + DrawerPanelContent, + DrawerColorVariant, + DrawerHead, + DrawerActions, + DrawerCloseButton +} from '@patternfly/react-core'; +import { + Wizard, + WizardStep, + WizardBody, + WizardFooter, + WizardStepProps, + WizardControlStep, + useWizardFooter, + 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'; + +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 WizardControlStep; + + return ( + Custom item} + isCurrent={activeStep.id === step.id} + step={steps.indexOf(step) + 1} + isDisabled={step.isDisabled || !step.visited} + onNavItemClick={goToStepByIndex} + /> + ); +}; + +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; +}; + +const CustomStepFour = (props: WizardStepProps) =>
    ; + +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/hooks/index.ts b/packages/react-core/src/next/components/Wizard/hooks/index.ts new file mode 100644 index 00000000000..c0abee9f15b --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/hooks/index.ts @@ -0,0 +1 @@ +export { useWizardFooter } from './useWizardFooter'; diff --git a/packages/react-core/src/next/components/Wizard/hooks/useWizardFooter.tsx b/packages/react-core/src/next/components/Wizard/hooks/useWizardFooter.tsx new file mode 100644 index 00000000000..7910eec9c52 --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/hooks/useWizardFooter.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { useWizardContext } from '../WizardContext'; + +/** + * 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/next/components/Wizard/index.ts b/packages/react-core/src/next/components/Wizard/index.ts new file mode 100644 index 00000000000..88def117af3 --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/index.ts @@ -0,0 +1,11 @@ +export * from './Wizard'; +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 new file mode 100644 index 00000000000..768fdee4f5d --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/types.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +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; + /** Unique identifier */ + id: string | number; + /** 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; + /** (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; +} + +/** 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; +} + +/** Used to customize aspects of the Wizard's default navigation. */ +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; +} + +/** Used to customize aspects of the Wizard's default footer. */ +export interface DefaultWizardFooterProps { + /** The Next button text */ + nextButtonText?: React.ReactNode; + /** The Back button text */ + backButtonText?: React.ReactNode; + /** The Cancel button text */ + 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[], + activeStep: WizardControlStep, + 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: WizardControlStep): step is WizardBasicStep { + return (step as WizardParentStep)?.subStepIds === undefined && !isWizardSubStep(step); +} + +export function isWizardSubStep(step: WizardControlStep): step is WizardSubStep { + return (step as WizardSubStep)?.parentId !== undefined; +} + +export function isWizardParentStep(step: WizardControlStep): step is WizardParentStep { + return (step as WizardParentStep)?.subStepIds !== undefined; +} diff --git a/packages/react-core/src/next/components/Wizard/utils.ts b/packages/react-core/src/next/components/Wizard/utils.ts new file mode 100644 index 00000000000..2ec93bb3890 --- /dev/null +++ b/packages/react-core/src/next/components/Wizard/utils.ts @@ -0,0 +1,47 @@ +import React from 'react'; + +import { WizardControlStep, WizardNavStepData } from './types'; +import { WizardStep, WizardStepProps } from './WizardStep'; + +function hasWizardStepProps(props: WizardStepProps | any): props is WizardStepProps { + return props.name !== undefined && props.id !== undefined && props.children !== undefined; +} + +/** + * Accumulate list of step & sub-step props pulled from child components + * @param children + * @returns WizardControlStep[] + */ +export const buildSteps = (children: React.ReactElement | React.ReactElement[]) => + React.Children.toArray(children).reduce((acc: WizardControlStep[], child) => { + if (React.isValidElement(child)) { + if (child.type === WizardStep || hasWizardStepProps(child.props)) { + // 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 WizardStepProps; + + 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 WizardStep'); + } + } + + return acc; + }, []); + +export const normalizeNavStep = ({ id, name }: WizardNavStepData) => ({ id, name }); +export const getActiveStep = (steps: WizardControlStep[], currentStepIndex: number) => + steps.find((_, index) => index + 1 === currentStepIndex); diff --git a/packages/react-core/src/next/components/index.ts b/packages/react-core/src/next/components/index.ts index b13bb4eb87d..4ad68566220 100644 --- a/packages/react-core/src/next/components/index.ts +++ b/packages/react-core/src/next/components/index.ts @@ -1 +1 @@ -export * from './'; +export * from './Wizard'; diff --git a/packages/react-styles/README.md b/packages/react-styles/README.md index a3a598a825d..f4af8e1df47 100644 --- a/packages/react-styles/README.md +++ b/packages/react-styles/README.md @@ -8,7 +8,7 @@ Library that provides CSS-in-JS capabilities import { css } from '@patternfly/react-styles'; import styles from './Button.css'; -const Buttton = ({ isActive, isDisabled, children }) => ( +const Button = ({ isActive, isDisabled, children }) => (