diff --git a/packages/react-core/src/next/components/Modal/Modal.tsx b/packages/react-core/src/next/components/Modal/Modal.tsx new file mode 100644 index 00000000000..073ee8805a7 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/Modal.tsx @@ -0,0 +1,202 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { canUseDOM, KeyTypes, PickOptional } from '../../../helpers'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/Backdrop/backdrop'; +import { ModalContent } from './ModalContent'; +import { OUIAProps, getDefaultOUIAId } from '../../../helpers'; + +export interface ModalProps extends React.HTMLProps, OUIAProps { + /** The parent container to append the modal to. Defaults to "document.body". */ + appendTo?: HTMLElement | (() => HTMLElement); + /** Id to use for the modal box description. This should match the ModalHeader labelId or descriptorId. */ + 'aria-describedby'?: string; + /** Adds an accessible name to the modal when there is no title in the ModalHeader. */ + 'aria-label'?: string; + /** Id to use for the modal box label. This should include the ModalHeader labelId. */ + 'aria-labelledby'?: string; + /** Content rendered inside the modal. */ + children: React.ReactNode; + /** Additional classes added to the modal. */ + className?: string; + /** Flag to disable focus trap. */ + disableFocusTrap?: boolean; + /** The element to focus when the modal opens. By default the first + * focusable element will receive focus. + */ + elementToFocus?: HTMLElement | SVGElement | string; + /** An id to use for the modal box container. */ + id?: string; + /** Flag to show the modal. */ + isOpen?: boolean; + /** Add callback for when the close button is clicked. This prop needs to be passed to render the close button */ + onClose?: (event: KeyboardEvent | React.MouseEvent) => void; + /** Modal handles pressing of the escape key and closes the modal. If you want to handle + * this yourself you can use this callback function. */ + onEscapePress?: (event: KeyboardEvent) => void; + /** Position of the modal. By default a modal will be positioned vertically and horizontally centered. */ + position?: 'default' | 'top'; + /** Offset from alternate position. Can be any valid CSS length/percentage. */ + positionOffset?: string; + /** Variant of the modal. */ + variant?: 'small' | 'medium' | 'large' | 'default'; + /** Default width of the modal. */ + width?: number | string; + /** Maximum width of the modal. */ + maxWidth?: number | string; + /** Value to overwrite the randomly generated data-ouia-component-id.*/ + ouiaId?: number | string; + /** Set the value of data-ouia-safe. Only set to true when the component is in a static state, i.e. no animations are occurring. At all other times, this value must be false. */ + ouiaSafe?: boolean; +} + +export enum ModalVariant { + small = 'small', + medium = 'medium', + large = 'large', + default = 'default' +} + +interface ModalState { + container: HTMLElement; + ouiaStateId: string; +} + +class Modal extends React.Component { + static displayName = 'Modal'; + static currentId = 0; + boxId = ''; + + static defaultProps: PickOptional = { + isOpen: false, + variant: 'default', + appendTo: () => document.body, + ouiaSafe: true, + position: 'default' + }; + + constructor(props: ModalProps) { + super(props); + const boxIdNum = Modal.currentId++; + this.boxId = props.id || `pf-modal-part-${boxIdNum}`; + + this.state = { + container: undefined, + ouiaStateId: getDefaultOUIAId(Modal.displayName, props.variant) + }; + } + + handleEscKeyClick = (event: KeyboardEvent): void => { + const { onEscapePress } = this.props; + if (event.key === KeyTypes.Escape && this.props.isOpen) { + onEscapePress ? onEscapePress(event) : this.props.onClose?.(event); + } + }; + + getElement = (appendTo: HTMLElement | (() => HTMLElement)) => { + if (typeof appendTo === 'function') { + return appendTo(); + } + return appendTo || document.body; + }; + + toggleSiblingsFromScreenReaders = (hide: boolean) => { + const { appendTo } = this.props; + const target: HTMLElement = this.getElement(appendTo); + const bodyChildren = target.children; + for (const child of Array.from(bodyChildren)) { + if (child !== this.state.container) { + hide ? child.setAttribute('aria-hidden', '' + hide) : child.removeAttribute('aria-hidden'); + } + } + }; + + isEmpty = (value: string | null | undefined) => value === null || value === undefined || value === ''; + + componentDidMount() { + const { + appendTo, + 'aria-describedby': ariaDescribedby, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby + } = this.props; + const target: HTMLElement = this.getElement(appendTo); + const container = document.createElement('div'); + this.setState({ container }); + target.appendChild(container); + target.addEventListener('keydown', this.handleEscKeyClick, false); + + if (this.props.isOpen) { + target.classList.add(css(styles.backdropOpen)); + } else { + target.classList.remove(css(styles.backdropOpen)); + } + + if (!ariaDescribedby && !ariaLabel && !ariaLabelledby) { + // eslint-disable-next-line no-console + console.error('Modal: Specify at least one of: aria-describedby, aria-label, aria-labelledby.'); + } + } + + componentDidUpdate() { + const { appendTo } = this.props; + const target: HTMLElement = this.getElement(appendTo); + if (this.props.isOpen) { + target.classList.add(css(styles.backdropOpen)); + this.toggleSiblingsFromScreenReaders(true); + } else { + target.classList.remove(css(styles.backdropOpen)); + this.toggleSiblingsFromScreenReaders(false); + } + } + + componentWillUnmount() { + const { appendTo } = this.props; + const target: HTMLElement = this.getElement(appendTo); + if (this.state.container) { + target.removeChild(this.state.container); + } + target.removeEventListener('keydown', this.handleEscKeyClick, false); + target.classList.remove(css(styles.backdropOpen)); + this.toggleSiblingsFromScreenReaders(false); + } + + render() { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + appendTo, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onEscapePress, + 'aria-labelledby': ariaLabelledby, + 'aria-label': ariaLabel, + 'aria-describedby': ariaDescribedby, + ouiaId, + ouiaSafe, + position, + elementToFocus, + ...props + } = this.props; + const { container } = this.state; + + if (!canUseDOM || !container) { + return null; + } + + return ReactDOM.createPortal( + , + container + ) as React.ReactElement; + } +} + +export { Modal }; diff --git a/packages/react-core/src/next/components/Modal/ModalBody.tsx b/packages/react-core/src/next/components/Modal/ModalBody.tsx new file mode 100644 index 00000000000..aa6689cd69b --- /dev/null +++ b/packages/react-core/src/next/components/Modal/ModalBody.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/ModalBox/modal-box'; + +/** Renders content in the body of the modal */ + +export interface ModalBodyProps extends React.HTMLProps { + /** Content rendered inside the modal body. */ + children?: React.ReactNode; + /** Additional classes added to the modal body. */ + className?: string; + /** Accessible label applied to the modal body. This should be used to communicate + * important information about the modal body div element if needed, such as when it is scrollable. + */ + 'aria-label'?: string; + /** Accessible role applied to the modal body. This will default to "region" if the + * aria-label property is passed in. Set to a more appropriate role as applicable + * based on the modal content and context. + */ + role?: string; +} + +export const ModalBody: React.FunctionComponent = ({ + children, + className, + 'aria-label': ariaLabel, + role, + ...props +}: ModalBodyProps) => { + const defaultModalBodyRole = ariaLabel ? 'region' : undefined; + return ( +
+ {children} +
+ ); +}; +ModalBody.displayName = 'ModalBody'; diff --git a/packages/react-core/src/next/components/Modal/ModalBox.tsx b/packages/react-core/src/next/components/Modal/ModalBox.tsx new file mode 100644 index 00000000000..b52a9660ece --- /dev/null +++ b/packages/react-core/src/next/components/Modal/ModalBox.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/ModalBox/modal-box'; +import topSpacer from '@patternfly/react-tokens/dist/esm/c_modal_box_m_align_top_spacer'; + +export interface ModalBoxProps extends React.HTMLProps { + /** Id to use for the modal box description. This should match the ModalHeader labelId or descriptorId */ + 'aria-describedby'?: string; + /** Adds an accessible name to the modal when there is no title in the ModalHeader. */ + 'aria-label'?: string; + /** Id to use for the modal box label. */ + 'aria-labelledby'?: string; + /** Content rendered inside the modal box. */ + children: React.ReactNode; + /** Additional classes added to the modal box. */ + className?: string; + /** Position of the modal. By default a modal will be positioned vertically and horizontally centered. */ + position?: 'default' | 'top'; + /** Offset from alternate position. Can be any valid CSS length/percentage. */ + positionOffset?: string; + /** Variant of the modal. */ + variant?: 'small' | 'medium' | 'large' | 'default'; +} + +export const ModalBox: React.FunctionComponent = ({ + children, + className, + variant = 'default', + position, + positionOffset, + 'aria-labelledby': ariaLabelledby, + 'aria-label': ariaLabel, + 'aria-describedby': ariaDescribedby, + style, + ...props +}: ModalBoxProps) => { + if (positionOffset) { + style = style || {}; + (style as any)[topSpacer.name] = positionOffset; + } + return ( +
+ {children} +
+ ); +}; +ModalBox.displayName = 'ModalBox'; diff --git a/packages/react-core/src/next/components/Modal/ModalBoxCloseButton.tsx b/packages/react-core/src/next/components/Modal/ModalBoxCloseButton.tsx new file mode 100644 index 00000000000..54cd0c04006 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/ModalBoxCloseButton.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/ModalBox/modal-box'; +import { Button } from '../../../components/Button'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; +import { OUIAProps } from '../../../helpers'; + +export interface ModalBoxCloseButtonProps extends OUIAProps { + /** Additional classes added to the close button. */ + className?: string; + /** A callback for when the close button is clicked. */ + onClose?: (event: KeyboardEvent | React.MouseEvent) => void; + /** Accessible descriptor of the close button. */ + 'aria-label'?: string; + /** Value to set the data-ouia-component-id.*/ + ouiaId?: number | string; +} + +export const ModalBoxCloseButton: React.FunctionComponent = ({ + className, + onClose, + 'aria-label': ariaLabel = 'Close', + ouiaId, + ...props +}: ModalBoxCloseButtonProps) => ( +
+ +
+); +ModalBoxCloseButton.displayName = 'ModalBoxCloseButton'; diff --git a/packages/react-core/src/next/components/Modal/ModalBoxDescription.tsx b/packages/react-core/src/next/components/Modal/ModalBoxDescription.tsx new file mode 100644 index 00000000000..1ce6385e88c --- /dev/null +++ b/packages/react-core/src/next/components/Modal/ModalBoxDescription.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/ModalBox/modal-box'; + +export interface ModalBoxDescriptionProps { + /** Content rendered inside the description. */ + children?: React.ReactNode; + /** Additional classes added to the description. */ + className?: string; + /** Id of the description. */ + id?: string; +} + +export const ModalBoxDescription: React.FunctionComponent = ({ + children = null, + className = '', + id = '', + ...props +}: ModalBoxDescriptionProps) => ( +
+ {children} +
+); +ModalBoxDescription.displayName = 'ModalBoxDescription'; diff --git a/packages/react-core/src/next/components/Modal/ModalBoxTitle.tsx b/packages/react-core/src/next/components/Modal/ModalBoxTitle.tsx new file mode 100644 index 00000000000..b7f4fcb97ea --- /dev/null +++ b/packages/react-core/src/next/components/Modal/ModalBoxTitle.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import modalStyles from '@patternfly/react-styles/css/components/ModalBox/modal-box'; +import { css } from '@patternfly/react-styles'; +import { capitalize } from '../../../helpers'; +import { Tooltip } from '../../../components/Tooltip'; +import CheckCircleIcon from '@patternfly/react-icons/dist/esm/icons/check-circle-icon'; +import ExclamationCircleIcon from '@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon'; +import ExclamationTriangleIcon from '@patternfly/react-icons/dist/esm/icons/exclamation-triangle-icon'; +import InfoCircleIcon from '@patternfly/react-icons/dist/esm/icons/info-circle-icon'; +import BellIcon from '@patternfly/react-icons/dist/esm/icons/bell-icon'; +import { useIsomorphicLayoutEffect } from '../../../helpers'; + +export const isVariantIcon = (icon: any): icon is string => + ['success', 'danger', 'warning', 'info', 'custom'].includes(icon as string); + +export interface ModalBoxTitleProps { + /** Additional classes added to the modal box title. */ + className?: string; + /** Id of the modal box title. */ + id?: string; + /** Content rendered inside the modal box title. */ + title: React.ReactNode; + /** Optional alert icon (or other) to show before the title. When the predefined alert types + * are used the default styling will be automatically applied. */ + titleIconVariant?: 'success' | 'danger' | 'warning' | 'info' | 'custom' | React.ComponentType; + /** Optional title label text for screen readers. */ + titleScreenReaderText?: string; +} + +export const ModalBoxTitle: React.FunctionComponent = ({ + className, + id, + title, + titleIconVariant, + titleScreenReaderText, + ...props +}: ModalBoxTitleProps) => { + const [hasTooltip, setHasTooltip] = React.useState(false); + const h1 = React.useRef(null); + const label = + titleScreenReaderText || + (isVariantIcon(titleIconVariant) ? `${capitalize(titleIconVariant)} alert:` : titleScreenReaderText); + const variantIcons = { + success: , + danger: , + warning: , + info: , + custom: + }; + const CustomIcon = !isVariantIcon(titleIconVariant) && titleIconVariant; + + useIsomorphicLayoutEffect(() => { + setHasTooltip(h1.current && h1.current.offsetWidth < h1.current.scrollWidth); + }, []); + + const content = ( +

+ {titleIconVariant && ( + + {isVariantIcon(titleIconVariant) ? variantIcons[titleIconVariant] : } + + )} + {label && {label}} + {title} +

+ ); + + return hasTooltip ? {content} : content; +}; +ModalBoxTitle.displayName = 'ModalBoxTitle'; diff --git a/packages/react-core/src/next/components/Modal/ModalContent.tsx b/packages/react-core/src/next/components/Modal/ModalContent.tsx new file mode 100644 index 00000000000..982356aaa42 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/ModalContent.tsx @@ -0,0 +1,131 @@ +import * as React from 'react'; +import { FocusTrap } from '../../../helpers'; +import bullsEyeStyles from '@patternfly/react-styles/css/layouts/Bullseye/bullseye'; +import { css } from '@patternfly/react-styles'; +import { getOUIAProps, OUIAProps } from '../../../helpers'; +import { Backdrop } from '../../../components/Backdrop'; +import { ModalBoxCloseButton } from './ModalBoxCloseButton'; +import { ModalBox } from './ModalBox'; + +export interface ModalContentProps extends OUIAProps { + /** Id to use for the modal box description. This should match the ModalHeader labelId or descriptorId. */ + 'aria-describedby'?: string; + /** Accessible descriptor of the modal. */ + 'aria-label'?: string; + /** Id to use for the modal box label. This should include the ModalHeader labelId. */ + 'aria-labelledby'?: string; + /** Id of the modal box container. */ + boxId: string; + /** Content rendered inside the modal. */ + children: React.ReactNode; + /** Additional classes added to the modal box. */ + className?: string; + /** Flag to disable focus trap. */ + disableFocusTrap?: boolean; + /** The element to focus when the modal opens. By default the first + * focusable element will receive focus. + */ + elementToFocus?: HTMLElement | SVGElement | string; + /** Flag to show the modal. */ + isOpen?: boolean; + /** A callback for when the close button is clicked. */ + onClose?: (event: KeyboardEvent | React.MouseEvent) => void; + /** Position of the modal. By default a modal will be positioned vertically and horizontally centered. */ + position?: 'default' | 'top'; + /** Offset from alternate position. Can be any valid CSS length/percentage. */ + positionOffset?: string; + /** Variant of the modal. */ + variant?: 'small' | 'medium' | 'large' | 'default'; + /** Default width of the modal. */ + width?: number | string; + /** Maximum width of the modal. */ + maxWidth?: number | string; + /** Value to overwrite the randomly generated data-ouia-component-id.*/ + ouiaId?: number | string; + /** Set the value of data-ouia-safe. Only set to true when the component is in a static state, i.e. no animations are occurring. At all other times, this value must be false. */ + ouiaSafe?: boolean; +} + +export const ModalContent: React.FunctionComponent = ({ + children, + className, + isOpen = false, + 'aria-label': ariaLabel, + 'aria-describedby': ariaDescribedby, + 'aria-labelledby': ariaLabelledby, + onClose, + variant = 'default', + position, + positionOffset, + width, + maxWidth, + boxId, + disableFocusTrap = false, + ouiaId, + ouiaSafe = true, + elementToFocus, + ...props +}: ModalContentProps) => { + if (!isOpen) { + return null; + } + + const ariaLabelledbyFormatted = (): string => { + const idRefList: string[] = []; + if (ariaLabel && boxId) { + idRefList.push(ariaLabel && boxId); + } + if (ariaLabelledby) { + idRefList.push(ariaLabelledby); + } + if (idRefList.length === 0) { + return undefined; + } else { + return idRefList.join(' '); + } + }; + + const modalBox = ( + + {onClose && onClose(event)} ouiaId={ouiaId} />} + {children} + + ); + return ( + + + {modalBox} + + + ); +}; +ModalContent.displayName = 'ModalContent'; diff --git a/packages/react-core/src/next/components/Modal/ModalFooter.tsx b/packages/react-core/src/next/components/Modal/ModalFooter.tsx new file mode 100644 index 00000000000..eee3e83302e --- /dev/null +++ b/packages/react-core/src/next/components/Modal/ModalFooter.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/ModalBox/modal-box'; + +/** Renders content in the footer of the modal */ + +export interface ModalFooterProps { + /** Content rendered inside the modal footer. */ + children?: React.ReactNode; + /** Additional classes added to the modal footer. */ + className?: string; +} + +export const ModalFooter: React.FunctionComponent = ({ + children, + className, + ...props +}: ModalFooterProps) => ( +
+ {children} +
+); +ModalFooter.displayName = 'ModalFooter'; diff --git a/packages/react-core/src/next/components/Modal/ModalHeader.tsx b/packages/react-core/src/next/components/Modal/ModalHeader.tsx new file mode 100644 index 00000000000..c8973d24d81 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/ModalHeader.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/ModalBox/modal-box'; +import { ModalBoxDescription } from './ModalBoxDescription'; +import { ModalBoxTitle } from './ModalBoxTitle'; + +/** Renders content in the header of the modal */ + +export interface ModalHeaderProps { + /** Custom content rendered inside the modal header. If children are supplied then the tile, tileIconVariant and titleScreenReaderText props are ignored. */ + children?: React.ReactNode; + /** Additional classes added to the modal header. */ + className?: string; + /** Description of the modal. */ + description?: React.ReactNode; + /** Id of the modal description. */ + descriptorId?: string; + /** Optional help section for the modal header. */ + help?: React.ReactNode; + /** Id of the modal title. */ + labelId?: string; + /** Content rendered inside the modal title. */ + title?: React.ReactNode; + /** Optional alert icon (or other) to show before the title. When the predefined alert types + * are used the default styling will be automatically applied. */ + titleIconVariant?: 'success' | 'danger' | 'warning' | 'info' | 'custom' | React.ComponentType; + /** Optional title label text for screen readers. */ + titleScreenReaderText?: string; +} + +export const ModalHeader: React.FunctionComponent = ({ + children, + className, + descriptorId, + description, + labelId, + title, + titleIconVariant, + titleScreenReaderText, + help, + ...props +}: ModalHeaderProps) => { + const headerContent = children ? ( + children + ) : ( + <> + + {description && {description}} + + ); + + // TODO: apply variant modifier for icon styling. Core fix needed first. similar to this: + // className={css(className, isVariantIcon(titleIconVariant) && modalStyles.modifiers[titleIconVariant])} + return ( +
+ {help && ( + <> +
{headerContent}
+
{help}
+ + )} + {!help && headerContent} +
+ ); +}; +ModalHeader.displayName = 'ModalHeader'; diff --git a/packages/react-core/src/next/components/Modal/__tests__/Modal.test.tsx b/packages/react-core/src/next/components/Modal/__tests__/Modal.test.tsx new file mode 100644 index 00000000000..7f819d0d99b --- /dev/null +++ b/packages/react-core/src/next/components/Modal/__tests__/Modal.test.tsx @@ -0,0 +1,141 @@ +import * as React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { css } from '../../../../../../react-styles/dist/js'; +import styles from '@patternfly/react-styles/css/components/Backdrop/backdrop'; + +import { Modal } from '../Modal'; +import { KeyTypes } from '../../../../helpers'; + +jest.spyOn(document, 'createElement'); +jest.spyOn(document.body, 'addEventListener'); + +const props = { + onClose: jest.fn(), + isOpen: false, + children: 'modal content' +}; + +const target = document.createElement('div'); + +const ModalWithSiblings = () => { + const [isOpen, setIsOpen] = React.useState(true); + const [isModalMounted, setIsModalMounted] = React.useState(true); + const modalProps = { ...props, isOpen, appendTo: target, onClose: () => setIsOpen(false) }; + + return ( + <> + +
Section sibling
+ {isModalMounted && ( + + + + )} + + ); +}; + +describe('Modal', () => { + test('Modal creates a container element once for div', () => { + render(); + expect(document.createElement).toHaveBeenCalledWith('div'); + }); + + test('modal closes with escape', async () => { + const user = userEvent.setup(); + + render(); + + await user.type(screen.getByLabelText("modal-div"), `{${KeyTypes.Escape}}`); + expect(props.onClose).toHaveBeenCalled(); + }); + + test('modal does not call onClose for esc key if it is not open', () => { + render(); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(props.onClose).not.toHaveBeenCalled(); + }); + + test('modal has body backdropOpen class when open', () => { + render(); + expect(document.body).toHaveClass(css(styles.backdropOpen)); + }); + + test('modal has no body backdropOpen class when not open', () => { + render(); + expect(document.body).not.toHaveClass(css(styles.backdropOpen)); + }); + + test('modal shows the close button when onClose prop is passed (true by default)', () => { + render(); + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + }); + + test('modal does not show the close button when onClose not passed', () => { + render(No close button ); + expect(screen.queryByRole('button', { name: 'Close' })).toBeNull(); + }); + + test('modal generates console error when no accessible name is provided', () => { + const props = { + onClose: jest.fn(), + isOpen: true, + children: 'modal content' + }; + const consoleErrorMock = jest.fn(); + global.console = { error: consoleErrorMock } as any; + + render(); + + expect(consoleErrorMock).toHaveBeenCalled(); + }); + + test('modal adds aria-hidden attribute to its siblings when open', () => { + render(, { container: document.body.appendChild(target) }); + + const asideSibling = screen.getByRole('complementary', { hidden: true }); + const articleSibling = screen.getByRole('article', { hidden: true }); + + expect(asideSibling).toHaveAttribute('aria-hidden'); + expect(articleSibling).toHaveAttribute('aria-hidden'); + }); + + test('modal removes the aria-hidden attribute from its siblings when closed', async () => { + const user = userEvent.setup(); + + render(, { container: document.body.appendChild(target) }); + + const asideSibling = screen.getByRole('complementary', { hidden: true }); + const articleSibling = screen.getByRole('article', { hidden: true }); + const closeButton = screen.getByRole('button', { name: 'Close' }); + + expect(articleSibling).toHaveAttribute('aria-hidden'); + expect(asideSibling).toHaveAttribute('aria-hidden'); + + await user.click(closeButton); + + expect(articleSibling).not.toHaveAttribute('aria-hidden'); + expect(asideSibling).not.toHaveAttribute('aria-hidden'); + }); + + test('modal removes the aria-hidden attribute from its siblings when unmounted', async () => { + const user = userEvent.setup(); + + render(, { container: document.body.appendChild(target) }); + + const asideSibling = screen.getByRole('complementary', { hidden: true }); + const articleSibling = screen.getByRole('article', { hidden: true }); + const unmountButton = screen.getByRole('button', { name: 'Unmount Modal' }); + + expect(asideSibling).toHaveAttribute('aria-hidden'); + expect(articleSibling).toHaveAttribute('aria-hidden'); + + await user.click(unmountButton); + + expect(asideSibling).not.toHaveAttribute('aria-hidden'); + expect(articleSibling).not.toHaveAttribute('aria-hidden'); + }); +}); diff --git a/packages/react-core/src/next/components/Modal/__tests__/ModalBody.test.tsx b/packages/react-core/src/next/components/Modal/__tests__/ModalBody.test.tsx new file mode 100644 index 00000000000..c1379a8b745 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/__tests__/ModalBody.test.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { ModalBody } from '../ModalBody'; + +describe('ModalBody tests', () => { + test('ModalBody renders', () => { + const { asFragment } = render( + + This is a Modal body + + ); + expect(asFragment()).toMatchSnapshot(); + }); + + test('The ModalBody has the expected aria-label when it is passed', () => { + const props = { + isOpen: true + }; + + render( + + This is a ModalBox + + ); + + const modalBoxBody = screen.getByText('This is a ModalBox'); + expect(modalBoxBody).toHaveAccessibleName('modal body aria label'); + }); + + test('The modalBoxBody has the expected aria role when aria-label is passed and role is not', () => { + const props = { + isOpen: true + }; + + render( + + This is a ModalBox + + ); + + const modalBoxBody = screen.getByRole('region', { name: 'modal body aria label' }); + expect(modalBoxBody).toBeInTheDocument(); + }); + + test('The modalBoxBody has the expected aria role when bodyAriaRole is passed', () => { + const props = { + isOpen: true + }; + + render( + + This is a ModalBox + + ); + + const modalBoxBody = screen.getByRole('article', { name: 'modal body aria label' }); + expect(modalBoxBody).toBeInTheDocument(); + }); +}); diff --git a/packages/react-core/src/next/components/Modal/__tests__/ModalBox.test.tsx b/packages/react-core/src/next/components/Modal/__tests__/ModalBox.test.tsx new file mode 100644 index 00000000000..9a8f8b24cad --- /dev/null +++ b/packages/react-core/src/next/components/Modal/__tests__/ModalBox.test.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { ModalBox } from '../ModalBox'; + +test('ModalBox Test', () => { + const { asFragment } = render( + + This is a ModalBox + + ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('ModalBox Test large', () => { + const { asFragment } = render( + + This is a ModalBox + + ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('ModalBox Test medium', () => { + const { asFragment } = render( + + This is a ModalBox + + ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('ModalBox Test small', () => { + const { asFragment } = render( + + This is a ModalBox + + ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('ModalBox Test top aligned', () => { + const { asFragment } = render( + + This is a ModalBox + + ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('ModalBox Test top aligned distance', () => { + const { asFragment } = render( + + This is a ModalBox + + ); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/packages/react-core/src/next/components/Modal/__tests__/ModalBoxCloseButton.test.tsx b/packages/react-core/src/next/components/Modal/__tests__/ModalBoxCloseButton.test.tsx new file mode 100644 index 00000000000..13339dfd28f --- /dev/null +++ b/packages/react-core/src/next/components/Modal/__tests__/ModalBoxCloseButton.test.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ModalBoxCloseButton } from '../ModalBoxCloseButton'; + +describe('ModalBoxCloseButton', () => { + test('onClose called when clicked', async () => { + const onClose = jest.fn(); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button')); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-core/src/next/components/Modal/__tests__/ModalBoxDescription.test.tsx b/packages/react-core/src/next/components/Modal/__tests__/ModalBoxDescription.test.tsx new file mode 100644 index 00000000000..8aca48ac935 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/__tests__/ModalBoxDescription.test.tsx @@ -0,0 +1,8 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { ModalBoxDescription } from '../ModalBoxDescription'; + +test('ModalBoxDescription Test', () => { + const { asFragment } = render(This is a ModalBox Description); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/packages/react-core/src/next/components/Modal/__tests__/ModalBoxHeader.test.tsx b/packages/react-core/src/next/components/Modal/__tests__/ModalBoxHeader.test.tsx new file mode 100644 index 00000000000..d3da940a1f7 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/__tests__/ModalBoxHeader.test.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { ModalHeader } from '../ModalHeader'; + +test('ModalHeader Test', () => { + const { asFragment } = render(This is a ModalBox header); + expect(asFragment()).toMatchSnapshot(); +}); + +test('ModalHeader help renders', () => { + const { asFragment } = render(test}>This is a ModalBox header); + expect(asFragment()).toMatchSnapshot(); +}); + +test('Modal Test with custom header', () => { + const header = TEST; + + const { asFragment } = render( + + {header} + + ); + expect(asFragment()).toMatchSnapshot(); +}); \ No newline at end of file diff --git a/packages/react-core/src/next/components/Modal/__tests__/ModalBoxTitle.test.tsx b/packages/react-core/src/next/components/Modal/__tests__/ModalBoxTitle.test.tsx new file mode 100644 index 00000000000..fdda1eac2f8 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/__tests__/ModalBoxTitle.test.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { ModalBoxTitle } from '../ModalBoxTitle'; +import BullhornIcon from '@patternfly/react-icons/dist/esm/icons/bullhorn-icon'; + +test('ModalBoxTitle alert variant', () => { + const { asFragment } = render( + + ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('ModalBoxTitle info variant', () => { + const { asFragment } = render( + + ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('ModalBoxTitle danger variant', () => { + const { asFragment } = render( + + ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('ModalBoxTitle custom variant', () => { + const { asFragment } = render( + + ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('ModalBoxTitle success variant', () => { + const { asFragment } = render( + + ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('ModalBoxTitle custom icon variant', () => { + const { asFragment } = render( + + ); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/packages/react-core/src/next/components/Modal/__tests__/ModalContent.test.tsx b/packages/react-core/src/next/components/Modal/__tests__/ModalContent.test.tsx new file mode 100644 index 00000000000..ff5fc0aef21 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/__tests__/ModalContent.test.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { ModalContent } from '../ModalContent'; + +const modalContentProps = { + boxId: 'boxId', + labelId: 'labelId', + descriptorId: 'descriptorId', + disableFocusTrap: true +}; +test('Modal Content Test only body', () => { + const { asFragment } = render( + + This is a ModalBox header + + ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('Modal Content Test isOpen', () => { + const { asFragment } = render( + + This is a ModalBox header + + ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('Modal Content Test description', () => { + const { asFragment } = render( + + This is a ModalBox header + + ); + expect(asFragment()).toMatchSnapshot(); +}); + +test('Modal Content Test with onclose', () => { + const { asFragment } = render( + undefined} + isOpen + {...modalContentProps} + > + This is a ModalBox header + + ); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/packages/react-core/src/next/components/Modal/__tests__/ModalFooter.test.tsx b/packages/react-core/src/next/components/Modal/__tests__/ModalFooter.test.tsx new file mode 100644 index 00000000000..2e0c256b907 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/__tests__/ModalFooter.test.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { ModalFooter } from '../ModalFooter'; + +test('ModalFooter Test', () => { + const { asFragment } = render( + This is a ModalBox Footer + ); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalBody.test.tsx.snap b/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalBody.test.tsx.snap new file mode 100644 index 00000000000..b977403512b --- /dev/null +++ b/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalBody.test.tsx.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ModalBody tests ModalBody renders 1`] = ` + +
+ This is a Modal body +
+
+`; diff --git a/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalBox.test.tsx.snap b/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalBox.test.tsx.snap new file mode 100644 index 00000000000..24d8d7a60f9 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalBox.test.tsx.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ModalBox Test 1`] = ` + + + +`; + +exports[`ModalBox Test large 1`] = ` + + + +`; + +exports[`ModalBox Test medium 1`] = ` + + + +`; + +exports[`ModalBox Test small 1`] = ` + + + +`; + +exports[`ModalBox Test top aligned 1`] = ` + + + +`; + +exports[`ModalBox Test top aligned distance 1`] = ` + + + +`; diff --git a/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalBoxDescription.test.tsx.snap b/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalBoxDescription.test.tsx.snap new file mode 100644 index 00000000000..f313d198539 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalBoxDescription.test.tsx.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ModalBoxDescription Test 1`] = ` + +
+ This is a ModalBox Description +
+
+`; diff --git a/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalBoxHeader.test.tsx.snap b/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalBoxHeader.test.tsx.snap new file mode 100644 index 00000000000..98edf535b86 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalBoxHeader.test.tsx.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Modal Test with custom header 1`] = ` + +
+ + TEST + +
+
+`; + +exports[`ModalHeader Test 1`] = ` + +
+ This is a ModalBox header +
+
+`; + +exports[`ModalHeader help renders 1`] = ` + +
+
+ This is a ModalBox header +
+
+
+ test +
+
+
+
+`; diff --git a/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalBoxTitle.test.tsx.snap b/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalBoxTitle.test.tsx.snap new file mode 100644 index 00000000000..112406099e3 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalBoxTitle.test.tsx.snap @@ -0,0 +1,218 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ModalBoxTitle alert variant 1`] = ` + +

+ + + + + Warning alert: + + + Test Modal Box warning + +

+
+`; + +exports[`ModalBoxTitle custom icon variant 1`] = ` + +

+ + + + + Test Modal Box custom + +

+
+`; + +exports[`ModalBoxTitle custom variant 1`] = ` + +

+ + + + + Custom alert: + + + Test Modal Box warning + +

+
+`; + +exports[`ModalBoxTitle danger variant 1`] = ` + +

+ + + + + Danger alert: + + + Test Modal Box danger + +

+
+`; + +exports[`ModalBoxTitle info variant 1`] = ` + +

+ + + + + Info alert: + + + Test Modal Box info + +

+
+`; + +exports[`ModalBoxTitle success variant 1`] = ` + +

+ + + + + Success alert: + + + Test Modal Box success + +

+
+`; diff --git a/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalContent.test.tsx.snap b/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalContent.test.tsx.snap new file mode 100644 index 00000000000..5456c013268 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalContent.test.tsx.snap @@ -0,0 +1,128 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Modal Content Test description 1`] = ` + +
+
+ +
+
+
+`; + +exports[`Modal Content Test isOpen 1`] = ` + +
+
+ +
+
+
+`; + +exports[`Modal Content Test only body 1`] = ` + +
+
+ +
+
+
+`; + +exports[`Modal Content Test with onclose 1`] = ` + +
+
+ +
+
+
+`; diff --git a/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalFooter.test.tsx.snap b/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalFooter.test.tsx.snap new file mode 100644 index 00000000000..0e85b548067 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/__tests__/__snapshots__/ModalFooter.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ModalFooter Test 1`] = ` + +
+ This is a ModalBox Footer +
+
+`; diff --git a/packages/react-core/src/next/components/Modal/examples/Modal.md b/packages/react-core/src/next/components/Modal/examples/Modal.md new file mode 100644 index 00000000000..c5b75813e96 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/examples/Modal.md @@ -0,0 +1,148 @@ +--- +id: Modal +section: components +cssPrefix: pf-v5-c-modal-box +propComponents: ['Modal', 'ModalBody', 'ModalHeader', 'ModalFooter'] +ouia: true +beta: true +--- + +import WarningTriangleIcon from '@patternfly/react-icons/dist/esm/icons/warning-triangle-icon'; +import CaretDownIcon from '@patternfly/react-icons/dist/esm/icons/caret-down-icon'; +import BullhornIcon from '@patternfly/react-icons/dist/esm/icons/bullhorn-icon'; +import HelpIcon from '@patternfly/react-icons/dist/esm/icons/help-icon'; +import formStyles from '@patternfly/react-styles/css/components/Form/form'; + +## Examples + +### Basic modals + +Basic modals give users the option to either confirm or cancel an action. + +To flag an open modal, use the `isOpen` property. To execute a callback when a modal is closed, use the `onClose` property. + +A modal must have a ``, containing the main content of the modal. The `` and `` components are not required, but are typically used to display the modal title and any button actions, respectively. + +```ts file="./ModalBasic.tsx" + +``` + +### Scrollable modals + +To enable keyboard-accessible scrolling of a modal’s content, pass `tabIndex={0}` to the ``. + +```ts file="ModalWithOverflowingContent.tsx" + +``` + +### With a static description + +To provide additional information about a modal, use the `description` property. Descriptions are static and do not scroll with other modal content. + +```ts file="./ModalWithDescription.tsx" + +``` + +### Top aligned + +To override a modal's default center alignment, use the `position` property. In this example, `position` is set to "top", which moves the modal to the top of the screen. + +```ts file="./ModalTopAligned.tsx" + +``` + +### Modal sizes + +To adjust the size of a modal, use the `variant` property. Modal variants include "small", "medium", "large", and "default". + +In the following example, you can display each modal size option. To launch a modal with a specific size, first select the respective radio button, followed by the "Show modal" button. + +```ts file="./ModalSize.tsx" + +``` + +### Custom width + +To choose a specific width for a modal, use the `width` property. The following example has a `width` of "50%". + +```ts file="./ModalCustomWidth.tsx" + +``` + +### Custom header + +To add a custom header to a modal, your custom content must be passed as a child of the `` component. Do not pass the `title` property to `` when using a custom header. + +```ts file="./ModalCustomHeader.tsx" + +``` + +### No header or footer + +To exclusively present information in a modal, remove the header and/or footer. + +When a modal has no header or footer, make sure to add an `aria-label` explicitly stating this, so that those using assistive technologies can understand this context. + +```ts file="./ModalNoHeaderFooter.tsx" + +``` + +### Title icon + +To add an icon before a modal’s title, use the `titleIconVariant` property, which can be set to a "success", "danger", "warning", or "info" variant. The following example uses a "warning" variant. + +```ts file="./ModalTitleIcon.tsx" + +``` + +### Custom title icon + +To add a custom icon before a modal’s title, set `titleIconVariant` to an imported custom icon. The following example imports and uses a bullhorn icon. + +```ts file="./ModalCustomTitleIcon.tsx" + +``` + +### With wizard + +To guide users through a series of steps in a modal, you can add a [wizard](/components/wizard) to a modal. To configure the ``, pass an array that contains a “name” and “component” value for each step into the `steps` property. + +```ts file="./ModalWithWizard.tsx" + +``` + +### With dropdown + +To present a menu of actions or links to a user, you can add a [dropdown](/components/menus/dropdown) to a modal. + +To allow the dropdown to visually break out of the modal container, set the `menuAppendTo` property to “parent”. Handle the modal’s closing behavior by listening to the `onEscapePress` callback on the `` component. This allows the "escape" key to collapse the dropdown without closing the entire modal. + +```ts file="./ModalWithDropdown.tsx" + +``` + +### With help + +To help simplify and explain complex models, add a help [popover](/components/popover). Only place a help icon at the modal level if its information applies to all content in the modal. If the help popover is specific to a particular modal section, place the help icon beside that section instead. + +```ts file="./ModalWithHelp.tsx" + +``` + +### With form + +To collect user input within a modal, you can add a [form](/components/forms/form). + +To enable form submission from a button in the modal's footer (outside of the `
`), set the button's `form` property equal to the form's id. + +```ts file="ModalWithForm.tsx" + +``` + +### Custom focus + +To customize which element inside the modal receives focus when initially opened, use the `elementToFocus` property`. + +```ts file="./ModalCustomFocus.tsx" + +``` diff --git a/packages/react-core/src/next/components/Modal/examples/ModalBasic.tsx b/packages/react-core/src/next/components/Modal/examples/ModalBasic.tsx new file mode 100644 index 00000000000..9b325381132 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/examples/ModalBasic.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Button } from '@patternfly/react-core'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@patternfly/react-core/next'; + +export const ModalBasic: React.FunctionComponent = () => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + setIsModalOpen(!isModalOpen); + }; + + return ( + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + + ); +}; diff --git a/packages/react-core/src/next/components/Modal/examples/ModalCustomFocus.tsx b/packages/react-core/src/next/components/Modal/examples/ModalCustomFocus.tsx new file mode 100644 index 00000000000..83c63796eb7 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/examples/ModalCustomFocus.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Button } from '@patternfly/react-core'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@patternfly/react-core/next'; + +export const ModalCustomFocus: React.FunctionComponent = () => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + setIsModalOpen(!isModalOpen); + }; + + return ( + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + + ); +}; diff --git a/packages/react-core/src/next/components/Modal/examples/ModalCustomHeader.tsx b/packages/react-core/src/next/components/Modal/examples/ModalCustomHeader.tsx new file mode 100644 index 00000000000..0236a1e6105 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/examples/ModalCustomHeader.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Button, Title, TitleSizes, TextContent, Flex } from '@patternfly/react-core'; +import { Modal, ModalBody, ModalFooter, ModalHeader, ModalVariant } from '@patternfly/react-core/next'; + +import WarningTriangleIcon from '@patternfly/react-icons/dist/esm/icons/warning-triangle-icon'; + +export const ModalCustomHeaderFooter: React.FunctionComponent = () => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + setIsModalOpen(!isModalOpen); + }; + + return ( + + + + + + + Custom header modal + +

Add custom content to the header by not passing the titles prop the modal box header component.

+
+
+ + + When static text describing the modal is available outside of the modal header, it can be given an ID that + is then passed in as the modal's aria-describedby value. + +
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +
+ + + <Flex spaceItems={{ default: 'spaceItemsSm' }}> + <WarningTriangleIcon /> + <span>Custom modal footer.</span> + </Flex> + + +
+
+ ); +}; diff --git a/packages/react-core/src/next/components/Modal/examples/ModalCustomTitleIcon.tsx b/packages/react-core/src/next/components/Modal/examples/ModalCustomTitleIcon.tsx new file mode 100644 index 00000000000..c70c38ac052 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/examples/ModalCustomTitleIcon.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Button } from '@patternfly/react-core'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@patternfly/react-core/next'; + +import BullhornIcon from '@patternfly/react-icons/dist/esm/icons/bullhorn-icon'; + +export const ModalCustomTitleIcon: React.FunctionComponent = () => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + setIsModalOpen(!isModalOpen); + }; + + return ( + + + + + + + When static text describing the modal is available outside of the modal header, it can be given an ID that + is then passed in as the modal's aria-describedby value. + +
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +
+ + + + +
+
+ ); +}; diff --git a/packages/react-core/src/next/components/Modal/examples/ModalCustomWidth.tsx b/packages/react-core/src/next/components/Modal/examples/ModalCustomWidth.tsx new file mode 100644 index 00000000000..b9cc3bdb9ad --- /dev/null +++ b/packages/react-core/src/next/components/Modal/examples/ModalCustomWidth.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Button } from '@patternfly/react-core'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@patternfly/react-core/next'; + +export const ModalCustomWidth: React.FunctionComponent = () => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + setIsModalOpen(!isModalOpen); + }; + + return ( + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + + ); +}; diff --git a/packages/react-core/src/next/components/Modal/examples/ModalNoHeaderFooter.tsx b/packages/react-core/src/next/components/Modal/examples/ModalNoHeaderFooter.tsx new file mode 100644 index 00000000000..17d07538cf1 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/examples/ModalNoHeaderFooter.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Button } from '@patternfly/react-core'; +import { Modal, ModalBody, ModalVariant } from '@patternfly/react-core/next'; + +export const ModalNoHeaderFooter: React.FunctionComponent = () => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + setIsModalOpen(!isModalOpen); + }; + + return ( + + + + + + When static text describing the modal is available outside of the modal header, it can be given an ID that + is then passed in as the modal's aria-describedby value. + +
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +
+
+
+ ); +}; diff --git a/packages/react-core/src/next/components/Modal/examples/ModalSize.tsx b/packages/react-core/src/next/components/Modal/examples/ModalSize.tsx new file mode 100644 index 00000000000..faf5196ba93 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/examples/ModalSize.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Button, Radio } from '@patternfly/react-core'; +import { Modal, ModalBody, ModalFooter, ModalHeader, ModalVariant } from '@patternfly/react-core/next'; + +export const ModalSize: React.FunctionComponent = () => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + const [selectedVariant, setSelectedVariant] = React.useState(ModalVariant.small); + + const capitalize = (input: string) => input[0].toUpperCase() + input.substring(1); + const formatSizeVariantName = (variant: string) => capitalize(variant); + + const variantOptions = [ModalVariant.small, ModalVariant.medium, ModalVariant.large]; + + const renderSizeOptions = variantOptions.map((variant) => ( + setSelectedVariant(variant)} + key={formatSizeVariantName(variant)} + name="Variant options" + /> + )); + + const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + setIsModalOpen(!isModalOpen); + }; + + return ( + + {renderSizeOptions} +
+ + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + +
+ ); +}; diff --git a/packages/react-core/src/next/components/Modal/examples/ModalTitleIcon.tsx b/packages/react-core/src/next/components/Modal/examples/ModalTitleIcon.tsx new file mode 100644 index 00000000000..6c96a5c6ed6 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/examples/ModalTitleIcon.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Button } from '@patternfly/react-core'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@patternfly/react-core/next'; + +export const ModalTitleIcon: React.FunctionComponent = () => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + setIsModalOpen(!isModalOpen); + }; + + return ( + + + + + + + When static text describing the modal is available outside of the modal header, it can be given an ID that + is then passed in as the modal's aria-describedby value. + +
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +
+ + + + +
+
+ ); +}; diff --git a/packages/react-core/src/next/components/Modal/examples/ModalTopAligned.tsx b/packages/react-core/src/next/components/Modal/examples/ModalTopAligned.tsx new file mode 100644 index 00000000000..551f36bc9a6 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/examples/ModalTopAligned.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Button } from '@patternfly/react-core'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@patternfly/react-core/next'; + +export const ModalTopAligned: React.FunctionComponent = () => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + setIsModalOpen(!isModalOpen); + }; + + return ( + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + + ); +}; diff --git a/packages/react-core/src/next/components/Modal/examples/ModalWithDescription.tsx b/packages/react-core/src/next/components/Modal/examples/ModalWithDescription.tsx new file mode 100644 index 00000000000..5de229d938a --- /dev/null +++ b/packages/react-core/src/next/components/Modal/examples/ModalWithDescription.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Button } from '@patternfly/react-core'; +import { Modal, ModalBody, ModalHeader, ModalFooter, ModalVariant } from '@patternfly/react-core/next'; + +export const ModalWithDescription: React.FunctionComponent = () => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + setIsModalOpen(!isModalOpen); + }; + + return ( + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Quis eleifend quam adipiscing vitae proin sagittis nisl rhoncus. Semper auctor neque vitae + tempus. Diam donec adipiscing tristique risus. Augue eget arcu dictum varius duis. Ut enim blandit volutpat + maecenas volutpat blandit aliquam. Sit amet mauris commodo quis imperdiet massa tincidunt. Habitant morbi + tristique senectus et netus. Fames ac turpis egestas sed tempus urna. Neque laoreet suspendisse interdum + consectetur libero id. Volutpat lacus laoreet non curabitur gravida arcu ac tortor. Porta nibh venenatis cras + sed felis eget velit. Nullam non nisi est sit amet facilisis. Nunc mi ipsum faucibus vitae. Lorem sed risus + ultricies tristique nulla aliquet enim tortor at. Egestas sed tempus urna et pharetra pharetra massa massa + ultricies. Lacinia quis vel eros donec ac odio tempor orci. Malesuada fames ac turpis egestas integer eget + aliquet. +
+
+ Neque aliquam vestibulum morbi blandit cursus risus at ultrices. Molestie at elementum eu facilisis sed odio + morbi. Elit pellentesque habitant morbi tristique. Consequat nisl vel pretium lectus quam id leo in vitae. + Quis varius quam quisque id diam vel quam elementum. Viverra nam libero justo laoreet sit amet cursus. + Sollicitudin tempor id eu nisl nunc. Orci nulla pellentesque dignissim enim sit amet venenatis. Dignissim enim + sit amet venenatis urna cursus eget. Iaculis at erat pellentesque adipiscing commodo elit. Faucibus pulvinar + elementum integer enim neque volutpat. Nullam vehicula ipsum a arcu cursus vitae congue mauris. Nunc mattis + enim ut tellus elementum sagittis vitae. Blandit cursus risus at ultrices. Tellus mauris a diam maecenas sed + enim. Non diam phasellus vestibulum lorem sed risus ultricies tristique nulla. +
+
+ Nulla pharetra diam sit amet nisl suscipit adipiscing. Ac tortor vitae purus faucibus ornare suspendisse sed + nisi. Sed felis eget velit aliquet sagittis id consectetur purus. Tincidunt tortor aliquam nulla facilisi cras + fermentum. Volutpat est velit egestas dui id ornare arcu odio. Pharetra magna ac placerat vestibulum. Ultrices + sagittis orci a scelerisque purus semper eget duis at. Nisi est sit amet facilisis magna etiam tempor orci eu. + Convallis tellus id interdum velit. Facilisis sed odio morbi quis commodo odio aenean sed. +
+
+ Eu scelerisque felis imperdiet proin fermentum leo vel orci porta. Facilisi etiam dignissim diam quis enim + lobortis scelerisque fermentum. Eleifend donec pretium vulputate sapien nec sagittis aliquam malesuada. Magna + etiam tempor orci eu lobortis elementum. Quis auctor elit sed vulputate mi sit. Eleifend quam adipiscing vitae + proin sagittis nisl rhoncus mattis rhoncus. Erat velit scelerisque in dictum non. Sit amet nulla facilisi + morbi tempus iaculis urna. Enim ut tellus elementum sagittis vitae et leo duis ut. Lectus arcu bibendum at + varius vel pharetra vel turpis. Morbi tristique senectus et netus et. Eget aliquet nibh praesent tristique + magna sit amet purus gravida. Nisl purus in mollis nunc sed id semper risus. Id neque aliquam vestibulum + morbi. Mauris a diam maecenas sed enim ut sem. Egestas tellus rutrum tellus pellentesque. +
+ + + + +
+
+ ); +}; diff --git a/packages/react-core/src/next/components/Modal/examples/ModalWithDropdown.tsx b/packages/react-core/src/next/components/Modal/examples/ModalWithDropdown.tsx new file mode 100644 index 00000000000..53be83c889d --- /dev/null +++ b/packages/react-core/src/next/components/Modal/examples/ModalWithDropdown.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { Button, Dropdown, DropdownList, DropdownItem, MenuToggle, MenuToggleElement } from '@patternfly/react-core'; + +import { Modal, ModalBody, ModalHeader, ModalFooter, ModalVariant } from '@patternfly/react-core/next'; + +export const ModalWithDropdown: React.FunctionComponent = () => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + const [isDropdownOpen, setIsDropdownOpen] = React.useState(false); + + const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + setIsModalOpen(!isModalOpen); + setIsDropdownOpen(false); + }; + + const handleDropdownToggle = () => { + setIsDropdownOpen(!isDropdownOpen); + }; + + const onSelect = () => { + setIsDropdownOpen(!isDropdownOpen); + onFocus(); + }; + + const onFocus = () => { + const element = document.getElementById('modal-dropdown-toggle'); + (element as HTMLElement).focus(); + }; + + const onEscapePress = (event: KeyboardEvent) => { + if (isDropdownOpen) { + setIsDropdownOpen(!isDropdownOpen); + onFocus(); + } else { + handleModalToggle(event); + } + }; + + return ( + + + + + +
+ Set the dropdown menuAppendTo prop to parent in order to allow the dropdown menu + break out of the modal container. You'll also want to handle closing of the modal yourself, by listening to + the onEscapePress callback on the Modal component, so you can close the Dropdown first if + it's open without closing the entire modal. +
+
+
+ setIsDropdownOpen(isOpen)} + toggle={(toggleRef: React.Ref) => ( + + Dropdown + + )} + > + + + Action + + ev.preventDefault()} + > + Link + + + Disabled Action + + + Disabled Link + + + +
+
+ + + + +
+
+ ); +}; diff --git a/packages/react-core/src/next/components/Modal/examples/ModalWithForm.tsx b/packages/react-core/src/next/components/Modal/examples/ModalWithForm.tsx new file mode 100644 index 00000000000..cf91e7ffca6 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/examples/ModalWithForm.tsx @@ -0,0 +1,211 @@ +import React from 'react'; +import { Button, Form, FormGroup, Popover, TextInput } from '@patternfly/react-core'; +import { Modal, ModalBody, ModalFooter, ModalHeader, ModalVariant } from '@patternfly/react-core/next'; +import HelpIcon from '@patternfly/react-icons/dist/esm/icons/help-icon'; +import formStyles from '@patternfly/react-styles/css/components/Form/form'; + +export const ModalWithForm: React.FunctionComponent = () => { + const [isModalOpen, setModalOpen] = React.useState(false); + const [nameValue, setNameValue] = React.useState(''); + const [emailValue, setEmailValue] = React.useState(''); + const [addressValue, setAddressValue] = React.useState(''); + + const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + setModalOpen(!isModalOpen); + }; + + const handleNameInputChange = (_event, value: string) => { + setNameValue(value); + }; + + const handleEmailInputChange = (_event, value: string) => { + setEmailValue(value); + }; + const handleAddressInputChange = (_event, value: string) => { + setAddressValue(value); + }; + + return ( + + + + + + + + The + + name + + of a + + Person + + + } + bodyContent={ +
+ Often composed of + + givenName + + and + + familyName + + . +
+ } + > + + + } + isRequired + fieldId="modal-with-form-form-name" + > + +
+ + The + + e-mail + + of a + + person + + + } + bodyContent={ +
+ Valid + + e-mail + + address. +
+ } + > + + + } + isRequired + fieldId="modal-with-form-form-email" + > + +
+ + The + + adress + + of a + + person + + + } + bodyContent={ + + } + > + + + } + isRequired + fieldId="modal-with-form-form-address" + > + + + +
+ + + + +
+
+ ); +}; diff --git a/packages/react-core/src/next/components/Modal/examples/ModalWithHelp.tsx b/packages/react-core/src/next/components/Modal/examples/ModalWithHelp.tsx new file mode 100644 index 00000000000..c9952f2ab2f --- /dev/null +++ b/packages/react-core/src/next/components/Modal/examples/ModalWithHelp.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Button, Popover } from '@patternfly/react-core'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@patternfly/react-core/next'; +import HelpIcon from '@patternfly/react-icons/dist/esm/icons/help-icon'; + +export const ModalWithHelp: React.FunctionComponent = () => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + setIsModalOpen(!isModalOpen); + }; + + return ( + + + + Help Popover} + bodyContent={ +
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam id feugiat augue, nec fringilla + turpis. +
+ } + footerContent="Popover Footer" + > + + + } + /> + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + +
+
+ ); +}; diff --git a/packages/react-core/src/next/components/Modal/examples/ModalWithOverflowingContent.tsx b/packages/react-core/src/next/components/Modal/examples/ModalWithOverflowingContent.tsx new file mode 100644 index 00000000000..2a2808b34cd --- /dev/null +++ b/packages/react-core/src/next/components/Modal/examples/ModalWithOverflowingContent.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Button } from '@patternfly/react-core'; +import { Modal, ModalBody, ModalHeader, ModalFooter, ModalVariant } from '@patternfly/react-core/next'; + +export const ModalWithOverflowingContent: React.FunctionComponent = () => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + setIsModalOpen((prevIsModalOpen) => !prevIsModalOpen); + }; + + return ( + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Quis eleifend quam adipiscing vitae proin sagittis nisl rhoncus. Semper auctor neque vitae + tempus. Diam donec adipiscing tristique risus. Augue eget arcu dictum varius duis. Ut enim blandit volutpat + maecenas volutpat blandit aliquam. Sit amet mauris commodo quis imperdiet massa tincidunt. Habitant morbi + tristique senectus et netus. Fames ac turpis egestas sed tempus urna. Neque laoreet suspendisse interdum + consectetur libero id. Volutpat lacus laoreet non curabitur gravida arcu ac tortor. Porta nibh venenatis cras + sed felis eget velit. Nullam non nisi est sit amet facilisis. Nunc mi ipsum faucibus vitae. Lorem sed risus + ultricies tristique nulla aliquet enim tortor at. Egestas sed tempus urna et pharetra pharetra massa massa + ultricies. Lacinia quis vel eros donec ac odio tempor orci. Malesuada fames ac turpis egestas integer eget + aliquet. +
+
+ Neque aliquam vestibulum morbi blandit cursus risus at ultrices. Molestie at elementum eu facilisis sed odio + morbi. Elit pellentesque habitant morbi tristique. Consequat nisl vel pretium lectus quam id leo in vitae. + Quis varius quam quisque id diam vel quam elementum. Viverra nam libero justo laoreet sit amet cursus. + Sollicitudin tempor id eu nisl nunc. Orci nulla pellentesque dignissim enim sit amet venenatis. Dignissim enim + sit amet venenatis urna cursus eget. Iaculis at erat pellentesque adipiscing commodo elit. Faucibus pulvinar + elementum integer enim neque volutpat. Nullam vehicula ipsum a arcu cursus vitae congue mauris. Nunc mattis + enim ut tellus elementum sagittis vitae. Blandit cursus risus at ultrices. Tellus mauris a diam maecenas sed + enim. Non diam phasellus vestibulum lorem sed risus ultricies tristique nulla. +
+
+ Nulla pharetra diam sit amet nisl suscipit adipiscing. Ac tortor vitae purus faucibus ornare suspendisse sed + nisi. Sed felis eget velit aliquet sagittis id consectetur purus. Tincidunt tortor aliquam nulla facilisi cras + fermentum. Volutpat est velit egestas dui id ornare arcu odio. Pharetra magna ac placerat vestibulum. Ultrices + sagittis orci a scelerisque purus semper eget duis at. Nisi est sit amet facilisis magna etiam tempor orci eu. + Convallis tellus id interdum velit. Facilisis sed odio morbi quis commodo odio aenean sed. +
+
+ Eu scelerisque felis imperdiet proin fermentum leo vel orci porta. Facilisi etiam dignissim diam quis enim + lobortis scelerisque fermentum. Eleifend donec pretium vulputate sapien nec sagittis aliquam malesuada. Magna + etiam tempor orci eu lobortis elementum. Quis auctor elit sed vulputate mi sit. Eleifend quam adipiscing vitae + proin sagittis nisl rhoncus mattis rhoncus. Erat velit scelerisque in dictum non. Sit amet nulla facilisi + morbi tempus iaculis urna. Enim ut tellus elementum sagittis vitae et leo duis ut. Lectus arcu bibendum at + varius vel pharetra vel turpis. Morbi tristique senectus et netus et. Eget aliquet nibh praesent tristique + magna sit amet purus gravida. Nisl purus in mollis nunc sed id semper risus. Id neque aliquam vestibulum + morbi. Mauris a diam maecenas sed enim ut sem. Egestas tellus rutrum tellus pellentesque. +
+ + + + +
+
+ ); +}; diff --git a/packages/react-core/src/next/components/Modal/examples/ModalWithWizard.tsx b/packages/react-core/src/next/components/Modal/examples/ModalWithWizard.tsx new file mode 100644 index 00000000000..f98c2410970 --- /dev/null +++ b/packages/react-core/src/next/components/Modal/examples/ModalWithWizard.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Button, Wizard, WizardHeader, WizardStep } from '@patternfly/react-core'; +import { Modal, ModalVariant } from '@patternfly/react-core/next'; + +export const ModalWithWizard: React.FunctionComponent = () => { + const [isModalOpen, setIsModalOpen] = React.useState(false); + + const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + setIsModalOpen((prevIsModalOpen) => !prevIsModalOpen); + }; + + const handleWizardToggle = () => { + setIsModalOpen((prevIsModalOpen) => !prevIsModalOpen); + }; + + const numberedSteps = [1, 2, 3, 4].map((stepNumber) => ( + + {`Step ${stepNumber}`} + + )); + + return ( + + + + + } + onClose={handleWizardToggle} + > + {numberedSteps} + + Review step + + + + + ); +}; diff --git a/packages/react-core/src/next/components/Modal/index.ts b/packages/react-core/src/next/components/Modal/index.ts new file mode 100644 index 00000000000..14ff043038d --- /dev/null +++ b/packages/react-core/src/next/components/Modal/index.ts @@ -0,0 +1,4 @@ +export * from './Modal'; +export * from './ModalBody'; +export * from './ModalHeader'; +export * from './ModalFooter'; diff --git a/packages/react-core/src/next/components/index.ts b/packages/react-core/src/next/components/index.ts index b13bb4eb87d..cb89ee17889 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 './Modal'; diff --git a/packages/react-integration/cypress/integration/modalnext.spec.ts b/packages/react-integration/cypress/integration/modalnext.spec.ts new file mode 100644 index 00000000000..55627209328 --- /dev/null +++ b/packages/react-integration/cypress/integration/modalnext.spec.ts @@ -0,0 +1,106 @@ +describe('Modal Test', () => { + it('Navigate to Modal next section', () => { + cy.visit('http://localhost:3000/modal-next-demo-nav-link'); + }); + + it('Verify Half Width Modal', () => { + cy.get('#showHalfWidthModalButton').then((modalButton: JQuery) => { + cy.wrap(modalButton).click(); + + cy.get('.pf-v5-c-page').then((page: JQuery) => { + cy.get('.pf-v5-c-modal-box') + .then(() => { + cy.get('.pf-v5-c-modal-box').should('have.css', 'width', `${page.width() / 2}px`); + cy.get('.pf-v5-c-modal-box .pf-v5-c-button[aria-label="Close"]').then((closeButton) => { + cy.wrap(closeButton).click(); + cy.get('.pf-v5-c-modal-box').should('not.exist'); + }); + }) + .then(() => { + cy.wrap(modalButton).click(); + cy.get('.pf-v5-c-modal-box').should('exist'); + cy.get('body').type('{esc}'); + cy.get('.pf-v5-c-modal-box').should('not.exist'); + }); + }); + }); + }); + + it('Verify Custom Escape Press Modal', () => { + cy.get('#showCustomEscapeModalButton.customEscapePressed').should('not.exist'); + cy.get('#showCustomEscapeModalButton').then((modalButton: JQuery) => { + cy.wrap(modalButton).click(); + cy.get('.pf-v5-c-modal-box').should('exist'); + cy.get('.pf-v5-c-modal-box') + .then(() => { + cy.get('.pf-v5-c-modal-box .pf-v5-c-button[aria-label="Close"]').then((closeButton) => { + cy.wrap(closeButton).click(); + cy.get('.pf-v5-c-modal-box').should('not.exist'); + cy.get('#showCustomEscapeModalButton.customEscapePressed').should('not.exist'); + }); + }) + .then(() => { + cy.wrap(modalButton).click(); + cy.get('.pf-v5-c-modal-box').should('exist'); + cy.get('body').type('{esc}'); + cy.get('.pf-v5-c-modal-box').should('not.exist'); + cy.get('#showCustomEscapeModalButton.customEscapePressed').should('exist'); + }); + }); + }); + + it('Verify focustrap for basic modal', () => { + cy.get('#tabstop-test').focus(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + cy.tab().click(); // click first btn to open first modal + cy.focused().should('have.attr', 'aria-label', 'Close'); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + cy.tab(); + cy.focused().should('have.attr', 'data-id', 'modal-01-confirm-btn'); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + cy.tab(); + cy.focused().should('have.attr', 'data-id', 'modal-01-cancel-btn'); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + cy.tab(); + cy.focused().should('have.attr', 'aria-label', 'Close'); + cy.focused().click(); + }); + + it('Verify escape key closes modal', () => { + cy.get('#tabstop-test').focus(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + cy.tab().tab().click(); // open second modal + + cy.get('.pf-v5-c-modal-box').should('exist'); + // press escape key + cy.get('body').type('{esc}'); + cy.get('.pf-v5-c-modal-box').should('not.exist'); + }); + + it('Verify first focusable element receives focus by default', () => { + cy.get('#showDefaultModalButton').click(); + cy.get('.pf-v5-c-modal-box__close > .pf-v5-c-button.pf-m-plain').should('have.focus'); + cy.get('.pf-v5-c-modal-box__close > .pf-v5-c-button.pf-m-plain').click(); + }); + + it('Verify custom element receives focus', () => { + cy.get('#showCustomFocusModalButton').click(); + cy.get('#modal-custom-focus-confirm-button').should('have.focus'); + cy.get('#modal-custom-focus-cancel-button').click(); + }); + + it("Verify the same id doesn't appear multiple times", () => { + cy.get('#showDescriptionModalButton').click(); + + cy.get('body').find('div#test-modal-id').should('have.length', 1); + + cy.get('.pf-v5-c-modal-box__close > .pf-v5-c-button.pf-m-plain').click(); + }); +}); diff --git a/packages/react-integration/demo-app-ts/src/Demos.ts b/packages/react-integration/demo-app-ts/src/Demos.ts index 638f4410a72..07d7536bd54 100644 --- a/packages/react-integration/demo-app-ts/src/Demos.ts +++ b/packages/react-integration/demo-app-ts/src/Demos.ts @@ -242,6 +242,11 @@ export const Demos: DemoInterface[] = [ name: 'Modal Demo', componentType: Examples.ModalDemo }, + { + id: 'modal-next-demo', + name: 'Modal Next Demo', + componentType: Examples.ModalNextDemo + }, { id: 'nav-demo', name: 'Nav Demo', diff --git a/packages/react-integration/demo-app-ts/src/components/demos/ModalNextDemo/ModalNextDemo.tsx b/packages/react-integration/demo-app-ts/src/components/demos/ModalNextDemo/ModalNextDemo.tsx new file mode 100644 index 00000000000..30fa02affc4 --- /dev/null +++ b/packages/react-integration/demo-app-ts/src/components/demos/ModalNextDemo/ModalNextDemo.tsx @@ -0,0 +1,622 @@ +import React from 'react'; +import { Button, Title, TitleSizes } from '@patternfly/react-core'; +import { Modal, ModalHeader, ModalBody, ModalFooter, ModalVariant } from '@patternfly/react-core/next'; +import WarningTriangleIcon from '@patternfly/react-icons/dist/esm/icons/warning-triangle-icon'; +import spacing from '@patternfly/react-styles/css/utilities/Spacing/spacing'; + +interface ModalDemoState { + isModalOpen: boolean; + isModalDescriptionOpen: boolean; + isHelpModalOpen: boolean; + isSmallModalOpen: boolean; + isMediumModalOpen: boolean; + isLargeModalOpen: boolean; + isHalfWidthModalOpen: boolean; + isCustomHeaderFooterModalOpen: boolean; + isNoHeaderModalOpen: boolean; + isModalCustomEscapeOpen: boolean; + isModalAlertVariantOpen: boolean; + customEscapePressed: boolean; + isCustomFocusModalOpen: boolean; +} + +// eslint-disable-next-line patternfly-react/no-anonymous-functions +export class ModalNextDemo extends React.Component, ModalDemoState> { + static displayName = 'ModalDemo'; + + state = { + isModalOpen: false, + isModalDescriptionOpen: false, + isHelpModalOpen: false, + isSmallModalOpen: false, + isMediumModalOpen: false, + isLargeModalOpen: false, + isHalfWidthModalOpen: false, + isCustomHeaderFooterModalOpen: false, + isNoHeaderModalOpen: false, + isModalCustomEscapeOpen: false, + isModalAlertVariantOpen: false, + customEscapePressed: false, + isCustomFocusModalOpen: false + }; + + handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => { + this.setState(({ isModalOpen }) => ({ + isModalOpen: !isModalOpen + })); + }; + + handleModalDescriptionToggle = () => { + this.setState(({ isModalDescriptionOpen }) => ({ + isModalDescriptionOpen: !isModalDescriptionOpen + })); + }; + + handleSmallModalToggle = () => { + this.setState(({ isSmallModalOpen }) => ({ + isSmallModalOpen: !isSmallModalOpen + })); + }; + + handleHelpModalToggle = () => { + this.setState(({ isHelpModalOpen }) => ({ + isHelpModalOpen: !isHelpModalOpen + })); + }; + + handleMediumModalToggle = () => { + this.setState(({ isMediumModalOpen }) => ({ + isMediumModalOpen: !isMediumModalOpen + })); + }; + + handleLargeModalToggle = () => { + this.setState(({ isLargeModalOpen }) => ({ + isLargeModalOpen: !isLargeModalOpen + })); + }; + + handleHalfWidthModalToggle = () => { + this.setState(({ isHalfWidthModalOpen }) => ({ + isHalfWidthModalOpen: !isHalfWidthModalOpen + })); + }; + + handleCustomHeaderFooterModalToggle = () => { + this.setState(({ isCustomHeaderFooterModalOpen }) => ({ + isCustomHeaderFooterModalOpen: !isCustomHeaderFooterModalOpen + })); + }; + + handleNoHeaderModalToggle = () => { + this.setState(({ isNoHeaderModalOpen }) => ({ + isNoHeaderModalOpen: !isNoHeaderModalOpen + })); + }; + + handleModalCustomEscapeToggle = (event?: any, customEscapePressed?: boolean) => { + this.setState(({ isModalCustomEscapeOpen }) => ({ + isModalCustomEscapeOpen: !isModalCustomEscapeOpen, + customEscapePressed + })); + }; + + handleModalAlertVariantToggle = (event?: any, customEscapePressed?: boolean) => { + this.setState(({ isModalAlertVariantOpen }) => ({ + isModalAlertVariantOpen: !isModalAlertVariantOpen, + customEscapePressed + })); + }; + + handleCustomFocusModalToggle = () => { + this.setState(({ isCustomFocusModalOpen }) => ({ + isCustomFocusModalOpen: !isCustomFocusModalOpen + })); + }; + + componentDidMount() { + window.scrollTo(0, 0); + } + + renderModal() { + const { isModalOpen } = this.state; + + return ( + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + ); + } + + renderModalWithDescription() { + const { isModalDescriptionOpen } = this.state; + + return ( + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + ); + } + + renderSmallModal() { + const { isSmallModalOpen } = this.state; + + return ( + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + ); + } + + renderMediumModal() { + const { isMediumModalOpen } = this.state; + + return ( + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + ); + } + + renderLargeModal() { + const { isLargeModalOpen } = this.state; + + return ( + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + ); + } + + renderHalfWidthModal() { + const { isHalfWidthModalOpen } = this.state; + + return ( + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + ); + } + + renderCustomHeaderFooterModal() { + const { isCustomHeaderFooterModalOpen } = this.state; + + return ( + + + + Custom Modal Header/Footer + +

+ Allows for custom content in the header and/or footer by passing components. +

+
+ + + When static text describing the modal is available, it can be wrapped with an ID referring to the modal's + aria-describedby value. + +
+
+ Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. + Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. +
+ + + <WarningTriangleIcon /> + <span className={spacing.plSm}>Custom modal footer.</span> + + +
+ ); + } + + renderNoHeaderModal() { + const { isNoHeaderModalOpen } = this.state; + + return ( + + + + When static text describing the modal is available, it can be wrapped with an ID referring to the modal's + aria-describedby value. + {' '} + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. + Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + + + + + + ); + } + + renderModalWithCustomEscape() { + const { isModalCustomEscapeOpen } = this.state; + + return ( + this.handleModalCustomEscapeToggle(event, true)} + aria-labelledby="custom-escape-modal-title" + > + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + ); + } + + renderModalWithAlertVariant() { + const { isModalAlertVariantOpen } = this.state; + + return ( + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + ); + } + + renderHelpModal() { + const { isHelpModalOpen } = this.state; + + return ( + + Help} /> + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + ); + } + + renderCustomFocusModal() { + const { isCustomFocusModalOpen } = this.state; + + return ( + + Help} + /> + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla + pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. + + + + + + + ); + } + + render() { + const buttonStyle = { + marginRight: 20, + marginBottom: 20 + }; + + return ( + +
+ + + + + + + + + + + + +
+ {this.renderModal()} + {this.renderSmallModal()} + {this.renderMediumModal()} + {this.renderLargeModal()} + {this.renderHalfWidthModal()} + {this.renderCustomHeaderFooterModal()} + {this.renderNoHeaderModal()} + {this.renderModalWithDescription()} + {this.renderModalWithCustomEscape()} + {this.renderModalWithAlertVariant()} + {this.renderHelpModal()} + {this.renderCustomFocusModal()} +
+ ); + } +} diff --git a/packages/react-integration/demo-app-ts/src/components/demos/index.ts b/packages/react-integration/demo-app-ts/src/components/demos/index.ts index 2eadd9a9609..c879328ac09 100644 --- a/packages/react-integration/demo-app-ts/src/components/demos/index.ts +++ b/packages/react-integration/demo-app-ts/src/components/demos/index.ts @@ -44,6 +44,7 @@ export * from './MastheadDemo/MastheadDemo'; export * from './MenuDemo/MenuDemo'; export * from './MenuDemo/MenuDrilldownDemo'; export * from './ModalDemo/ModalDemo'; +export * from './ModalNextDemo/ModalNextDemo'; export * from './NavDemo/NavDemo'; export * from './NotificationBadgeDemo/NotificationBadgeDemo'; export * from './NotificationDrawerDemo/NotificationDrawerBasicDemo';