Skip to content

Commit 7ec2aff

Browse files
authored
feat(Modal next): Introduce a next composable Modal (#9852)
* feat(Modal next): Introduce a next composble Modal * upodate for failing tests * add integration test * updates from review * updates from Erin's comments * Updates from comments
1 parent eece332 commit 7ec2aff

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+3518
-1
lines changed
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import * as React from 'react';
2+
import * as ReactDOM from 'react-dom';
3+
import { canUseDOM, KeyTypes, PickOptional } from '../../../helpers';
4+
import { css } from '@patternfly/react-styles';
5+
import styles from '@patternfly/react-styles/css/components/Backdrop/backdrop';
6+
import { ModalContent } from './ModalContent';
7+
import { OUIAProps, getDefaultOUIAId } from '../../../helpers';
8+
9+
export interface ModalProps extends React.HTMLProps<HTMLDivElement>, OUIAProps {
10+
/** The parent container to append the modal to. Defaults to "document.body". */
11+
appendTo?: HTMLElement | (() => HTMLElement);
12+
/** Id to use for the modal box description. This should match the ModalHeader labelId or descriptorId. */
13+
'aria-describedby'?: string;
14+
/** Adds an accessible name to the modal when there is no title in the ModalHeader. */
15+
'aria-label'?: string;
16+
/** Id to use for the modal box label. This should include the ModalHeader labelId. */
17+
'aria-labelledby'?: string;
18+
/** Content rendered inside the modal. */
19+
children: React.ReactNode;
20+
/** Additional classes added to the modal. */
21+
className?: string;
22+
/** Flag to disable focus trap. */
23+
disableFocusTrap?: boolean;
24+
/** The element to focus when the modal opens. By default the first
25+
* focusable element will receive focus.
26+
*/
27+
elementToFocus?: HTMLElement | SVGElement | string;
28+
/** An id to use for the modal box container. */
29+
id?: string;
30+
/** Flag to show the modal. */
31+
isOpen?: boolean;
32+
/** Add callback for when the close button is clicked. This prop needs to be passed to render the close button */
33+
onClose?: (event: KeyboardEvent | React.MouseEvent) => void;
34+
/** Modal handles pressing of the escape key and closes the modal. If you want to handle
35+
* this yourself you can use this callback function. */
36+
onEscapePress?: (event: KeyboardEvent) => void;
37+
/** Position of the modal. By default a modal will be positioned vertically and horizontally centered. */
38+
position?: 'default' | 'top';
39+
/** Offset from alternate position. Can be any valid CSS length/percentage. */
40+
positionOffset?: string;
41+
/** Variant of the modal. */
42+
variant?: 'small' | 'medium' | 'large' | 'default';
43+
/** Default width of the modal. */
44+
width?: number | string;
45+
/** Maximum width of the modal. */
46+
maxWidth?: number | string;
47+
/** Value to overwrite the randomly generated data-ouia-component-id.*/
48+
ouiaId?: number | string;
49+
/** 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. */
50+
ouiaSafe?: boolean;
51+
}
52+
53+
export enum ModalVariant {
54+
small = 'small',
55+
medium = 'medium',
56+
large = 'large',
57+
default = 'default'
58+
}
59+
60+
interface ModalState {
61+
container: HTMLElement;
62+
ouiaStateId: string;
63+
}
64+
65+
class Modal extends React.Component<ModalProps, ModalState> {
66+
static displayName = 'Modal';
67+
static currentId = 0;
68+
boxId = '';
69+
70+
static defaultProps: PickOptional<ModalProps> = {
71+
isOpen: false,
72+
variant: 'default',
73+
appendTo: () => document.body,
74+
ouiaSafe: true,
75+
position: 'default'
76+
};
77+
78+
constructor(props: ModalProps) {
79+
super(props);
80+
const boxIdNum = Modal.currentId++;
81+
this.boxId = props.id || `pf-modal-part-${boxIdNum}`;
82+
83+
this.state = {
84+
container: undefined,
85+
ouiaStateId: getDefaultOUIAId(Modal.displayName, props.variant)
86+
};
87+
}
88+
89+
handleEscKeyClick = (event: KeyboardEvent): void => {
90+
const { onEscapePress } = this.props;
91+
if (event.key === KeyTypes.Escape && this.props.isOpen) {
92+
onEscapePress ? onEscapePress(event) : this.props.onClose?.(event);
93+
}
94+
};
95+
96+
getElement = (appendTo: HTMLElement | (() => HTMLElement)) => {
97+
if (typeof appendTo === 'function') {
98+
return appendTo();
99+
}
100+
return appendTo || document.body;
101+
};
102+
103+
toggleSiblingsFromScreenReaders = (hide: boolean) => {
104+
const { appendTo } = this.props;
105+
const target: HTMLElement = this.getElement(appendTo);
106+
const bodyChildren = target.children;
107+
for (const child of Array.from(bodyChildren)) {
108+
if (child !== this.state.container) {
109+
hide ? child.setAttribute('aria-hidden', '' + hide) : child.removeAttribute('aria-hidden');
110+
}
111+
}
112+
};
113+
114+
isEmpty = (value: string | null | undefined) => value === null || value === undefined || value === '';
115+
116+
componentDidMount() {
117+
const {
118+
appendTo,
119+
'aria-describedby': ariaDescribedby,
120+
'aria-label': ariaLabel,
121+
'aria-labelledby': ariaLabelledby
122+
} = this.props;
123+
const target: HTMLElement = this.getElement(appendTo);
124+
const container = document.createElement('div');
125+
this.setState({ container });
126+
target.appendChild(container);
127+
target.addEventListener('keydown', this.handleEscKeyClick, false);
128+
129+
if (this.props.isOpen) {
130+
target.classList.add(css(styles.backdropOpen));
131+
} else {
132+
target.classList.remove(css(styles.backdropOpen));
133+
}
134+
135+
if (!ariaDescribedby && !ariaLabel && !ariaLabelledby) {
136+
// eslint-disable-next-line no-console
137+
console.error('Modal: Specify at least one of: aria-describedby, aria-label, aria-labelledby.');
138+
}
139+
}
140+
141+
componentDidUpdate() {
142+
const { appendTo } = this.props;
143+
const target: HTMLElement = this.getElement(appendTo);
144+
if (this.props.isOpen) {
145+
target.classList.add(css(styles.backdropOpen));
146+
this.toggleSiblingsFromScreenReaders(true);
147+
} else {
148+
target.classList.remove(css(styles.backdropOpen));
149+
this.toggleSiblingsFromScreenReaders(false);
150+
}
151+
}
152+
153+
componentWillUnmount() {
154+
const { appendTo } = this.props;
155+
const target: HTMLElement = this.getElement(appendTo);
156+
if (this.state.container) {
157+
target.removeChild(this.state.container);
158+
}
159+
target.removeEventListener('keydown', this.handleEscKeyClick, false);
160+
target.classList.remove(css(styles.backdropOpen));
161+
this.toggleSiblingsFromScreenReaders(false);
162+
}
163+
164+
render() {
165+
const {
166+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
167+
appendTo,
168+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
169+
onEscapePress,
170+
'aria-labelledby': ariaLabelledby,
171+
'aria-label': ariaLabel,
172+
'aria-describedby': ariaDescribedby,
173+
ouiaId,
174+
ouiaSafe,
175+
position,
176+
elementToFocus,
177+
...props
178+
} = this.props;
179+
const { container } = this.state;
180+
181+
if (!canUseDOM || !container) {
182+
return null;
183+
}
184+
185+
return ReactDOM.createPortal(
186+
<ModalContent
187+
boxId={this.boxId}
188+
aria-label={ariaLabel}
189+
aria-describedby={ariaDescribedby}
190+
aria-labelledby={ariaLabelledby}
191+
ouiaId={ouiaId !== undefined ? ouiaId : this.state.ouiaStateId}
192+
ouiaSafe={ouiaSafe}
193+
position={position}
194+
elementToFocus={elementToFocus}
195+
{...props}
196+
/>,
197+
container
198+
) as React.ReactElement;
199+
}
200+
}
201+
202+
export { Modal };
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import * as React from 'react';
2+
import { css } from '@patternfly/react-styles';
3+
import styles from '@patternfly/react-styles/css/components/ModalBox/modal-box';
4+
5+
/** Renders content in the body of the modal */
6+
7+
export interface ModalBodyProps extends React.HTMLProps<HTMLDivElement> {
8+
/** Content rendered inside the modal body. */
9+
children?: React.ReactNode;
10+
/** Additional classes added to the modal body. */
11+
className?: string;
12+
/** Accessible label applied to the modal body. This should be used to communicate
13+
* important information about the modal body div element if needed, such as when it is scrollable.
14+
*/
15+
'aria-label'?: string;
16+
/** Accessible role applied to the modal body. This will default to "region" if the
17+
* aria-label property is passed in. Set to a more appropriate role as applicable
18+
* based on the modal content and context.
19+
*/
20+
role?: string;
21+
}
22+
23+
export const ModalBody: React.FunctionComponent<ModalBodyProps> = ({
24+
children,
25+
className,
26+
'aria-label': ariaLabel,
27+
role,
28+
...props
29+
}: ModalBodyProps) => {
30+
const defaultModalBodyRole = ariaLabel ? 'region' : undefined;
31+
return (
32+
<div
33+
aria-label={ariaLabel}
34+
role={role || defaultModalBodyRole}
35+
className={css(styles.modalBoxBody, className)}
36+
{...props}
37+
>
38+
{children}
39+
</div>
40+
);
41+
};
42+
ModalBody.displayName = 'ModalBody';
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import * as React from 'react';
2+
import { css } from '@patternfly/react-styles';
3+
import styles from '@patternfly/react-styles/css/components/ModalBox/modal-box';
4+
import topSpacer from '@patternfly/react-tokens/dist/esm/c_modal_box_m_align_top_spacer';
5+
6+
export interface ModalBoxProps extends React.HTMLProps<HTMLDivElement> {
7+
/** Id to use for the modal box description. This should match the ModalHeader labelId or descriptorId */
8+
'aria-describedby'?: string;
9+
/** Adds an accessible name to the modal when there is no title in the ModalHeader. */
10+
'aria-label'?: string;
11+
/** Id to use for the modal box label. */
12+
'aria-labelledby'?: string;
13+
/** Content rendered inside the modal box. */
14+
children: React.ReactNode;
15+
/** Additional classes added to the modal box. */
16+
className?: string;
17+
/** Position of the modal. By default a modal will be positioned vertically and horizontally centered. */
18+
position?: 'default' | 'top';
19+
/** Offset from alternate position. Can be any valid CSS length/percentage. */
20+
positionOffset?: string;
21+
/** Variant of the modal. */
22+
variant?: 'small' | 'medium' | 'large' | 'default';
23+
}
24+
25+
export const ModalBox: React.FunctionComponent<ModalBoxProps> = ({
26+
children,
27+
className,
28+
variant = 'default',
29+
position,
30+
positionOffset,
31+
'aria-labelledby': ariaLabelledby,
32+
'aria-label': ariaLabel,
33+
'aria-describedby': ariaDescribedby,
34+
style,
35+
...props
36+
}: ModalBoxProps) => {
37+
if (positionOffset) {
38+
style = style || {};
39+
(style as any)[topSpacer.name] = positionOffset;
40+
}
41+
return (
42+
<div
43+
role="dialog"
44+
aria-label={ariaLabel || null}
45+
aria-labelledby={ariaLabelledby || null}
46+
aria-describedby={ariaDescribedby}
47+
aria-modal="true"
48+
className={css(
49+
styles.modalBox,
50+
className,
51+
position === 'top' && styles.modifiers.alignTop,
52+
variant === 'large' && styles.modifiers.lg,
53+
variant === 'small' && styles.modifiers.sm,
54+
variant === 'medium' && styles.modifiers.md
55+
)}
56+
style={style}
57+
{...props}
58+
>
59+
{children}
60+
</div>
61+
);
62+
};
63+
ModalBox.displayName = 'ModalBox';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import * as React from 'react';
2+
import { css } from '@patternfly/react-styles';
3+
import styles from '@patternfly/react-styles/css/components/ModalBox/modal-box';
4+
import { Button } from '../../../components/Button';
5+
import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon';
6+
import { OUIAProps } from '../../../helpers';
7+
8+
export interface ModalBoxCloseButtonProps extends OUIAProps {
9+
/** Additional classes added to the close button. */
10+
className?: string;
11+
/** A callback for when the close button is clicked. */
12+
onClose?: (event: KeyboardEvent | React.MouseEvent) => void;
13+
/** Accessible descriptor of the close button. */
14+
'aria-label'?: string;
15+
/** Value to set the data-ouia-component-id.*/
16+
ouiaId?: number | string;
17+
}
18+
19+
export const ModalBoxCloseButton: React.FunctionComponent<ModalBoxCloseButtonProps> = ({
20+
className,
21+
onClose,
22+
'aria-label': ariaLabel = 'Close',
23+
ouiaId,
24+
...props
25+
}: ModalBoxCloseButtonProps) => (
26+
<div className={css(styles.modalBoxClose, className)}>
27+
<Button
28+
variant="plain"
29+
onClick={(event) => onClose(event)}
30+
aria-label={ariaLabel}
31+
{...(ouiaId && { ouiaId: `${ouiaId}-${ModalBoxCloseButton.displayName}` })}
32+
{...props}
33+
>
34+
<TimesIcon />
35+
</Button>
36+
</div>
37+
);
38+
ModalBoxCloseButton.displayName = 'ModalBoxCloseButton';
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import * as React from 'react';
2+
import { css } from '@patternfly/react-styles';
3+
import styles from '@patternfly/react-styles/css/components/ModalBox/modal-box';
4+
5+
export interface ModalBoxDescriptionProps {
6+
/** Content rendered inside the description. */
7+
children?: React.ReactNode;
8+
/** Additional classes added to the description. */
9+
className?: string;
10+
/** Id of the description. */
11+
id?: string;
12+
}
13+
14+
export const ModalBoxDescription: React.FunctionComponent<ModalBoxDescriptionProps> = ({
15+
children = null,
16+
className = '',
17+
id = '',
18+
...props
19+
}: ModalBoxDescriptionProps) => (
20+
<div {...props} id={id} className={css(styles.modalBoxDescription, className)}>
21+
{children}
22+
</div>
23+
);
24+
ModalBoxDescription.displayName = 'ModalBoxDescription';

0 commit comments

Comments
 (0)