Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(Modal): Various bug fixes #11168

Open
wants to merge 3 commits into
base: v5
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export default [
'packages/react-core/src/helpers/Popper/thirdparty',
'packages/react-docs/patternfly-docs/generated',
'packages/react-docs/static',
'**/.cache'
'**/.cache',
'.history'
]
},
js.configs.recommended,
Expand Down
32 changes: 14 additions & 18 deletions packages/react-core/src/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ export enum ModalVariant {
}

interface ModalState {
container: HTMLElement;
ouiaStateId: string;
}

Expand All @@ -102,6 +101,7 @@ class Modal extends React.Component<ModalProps, ModalState> {
boxId = '';
labelId = '';
descriptorId = '';
backdropId = '';

static defaultProps: PickOptional<ModalProps> = {
className: '',
Expand All @@ -128,12 +128,13 @@ class Modal extends React.Component<ModalProps, ModalState> {
const boxIdNum = Modal.currentId++;
const labelIdNum = boxIdNum + 1;
const descriptorIdNum = boxIdNum + 2;
const backdropIdNum = boxIdNum + 3;
this.boxId = props.id || `pf-modal-part-${boxIdNum}`;
this.labelId = `pf-modal-part-${labelIdNum}`;
this.descriptorId = `pf-modal-part-${descriptorIdNum}`;
this.backdropId = `pf-modal-part-${backdropIdNum}`;

this.state = {
container: undefined,
ouiaStateId: getDefaultOUIAId(Modal.displayName, props.variant)
};
}
Expand All @@ -157,7 +158,7 @@ class Modal extends React.Component<ModalProps, ModalState> {
const target: HTMLElement = this.getElement(appendTo);
const bodyChildren = target.children;
for (const child of Array.from(bodyChildren)) {
if (child !== this.state.container) {
if (child.id !== this.backdropId) {
hide ? child.setAttribute('aria-hidden', '' + hide) : child.removeAttribute('aria-hidden');
}
}
Expand All @@ -175,15 +176,11 @@ class Modal extends React.Component<ModalProps, ModalState> {
header
} = 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));
this.toggleSiblingsFromScreenReaders(true);
}

if (!title && this.isEmpty(ariaLabel) && this.isEmpty(ariaLabelledby)) {
Expand All @@ -199,32 +196,31 @@ class Modal extends React.Component<ModalProps, ModalState> {
}
}

componentDidUpdate() {
componentDidUpdate(prevProps: ModalProps) {
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);
if (prevProps.isOpen !== this.props.isOpen) {
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,
Expand All @@ -242,9 +238,8 @@ class Modal extends React.Component<ModalProps, ModalState> {
elementToFocus,
...props
} = this.props;
const { container } = this.state;

if (!canUseDOM || !container) {
if (!canUseDOM || !this.getElement(appendTo)) {
return null;
}

Expand All @@ -254,6 +249,7 @@ class Modal extends React.Component<ModalProps, ModalState> {
boxId={this.boxId}
labelId={this.labelId}
descriptorId={this.descriptorId}
backdropId={this.backdropId}
title={title}
titleIconVariant={titleIconVariant}
titleLabel={titleLabel}
Expand All @@ -267,7 +263,7 @@ class Modal extends React.Component<ModalProps, ModalState> {
position={position}
elementToFocus={elementToFocus}
/>,
container
this.getElement(appendTo)
) as React.ReactElement;
}
}
Expand Down
5 changes: 4 additions & 1 deletion packages/react-core/src/components/Modal/ModalContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface ModalContentProps extends OUIAProps {
'aria-label'?: string;
/** Id to use for the modal box label. */
'aria-labelledby'?: string | null;
/** Id of the backdrop. */
backdropId?: string;
/** Accessible label applied to the modal box body. This should be used to communicate
* important information about the modal box body div element if needed, such as that it
* is scrollable.
Expand Down Expand Up @@ -117,6 +119,7 @@ export const ModalContent: React.FunctionComponent<ModalContentProps> = ({
maxWidth,
boxId,
labelId,
backdropId,
descriptorId,
disableFocusTrap = false,
hasNoBodyWrapper = false,
Expand Down Expand Up @@ -202,7 +205,7 @@ export const ModalContent: React.FunctionComponent<ModalContentProps> = ({
</ModalBox>
);
return (
<Backdrop>
<Backdrop id={backdropId}>
<FocusTrap
active={!disableFocusTrap}
focusTrapOptions={{
Expand Down
37 changes: 30 additions & 7 deletions packages/react-core/src/components/Modal/__tests__/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as React from 'react';

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';

import { css } from '../../../../../react-styles/dist/js';
import styles from '@patternfly/react-styles/css/components/Backdrop/backdrop';
Expand Down Expand Up @@ -39,12 +40,33 @@ const ModalWithSiblings = () => {
);
};

describe('Modal', () => {
test('Modal creates a container element once for div', () => {
render(<Modal {...props} />);
expect(document.createElement).toHaveBeenCalledWith('div');
});
const ModalWithAdjacentModal = () => {
const [isOpen, setIsOpen] = React.useState(true);
const [isModalMounted, setIsModalMounted] = React.useState(true);
const modalProps = { ...props, isOpen, appendTo: target, onClose: () => setIsOpen(false) };

return (
<>
<aside>Aside sibling</aside>
<article>Section sibling</article>
{isModalMounted && (
<>
<Modal {...modalProps}>
<button onClick={() => setIsModalMounted(false)}>Unmount Modal</button>
</Modal>
<Modal isOpen={false} onClose={() => {}}>
Modal closed for test
</Modal>
<Modal isOpen={false} onClose={() => {}}>
modal closed for test
</Modal>
</>
)}
</>
);
};

describe('Modal', () => {
test('modal closes with escape', async () => {
const user = userEvent.setup();

Expand Down Expand Up @@ -137,10 +159,10 @@ describe('Modal', () => {
expect(asideSibling).not.toHaveAttribute('aria-hidden');
});

test('modal removes the aria-hidden attribute from its siblings when unmounted', async () => {
test('modal siblings have the aria-hidden attribute when it has adjacent modals', async () => {
const user = userEvent.setup();

render(<ModalWithSiblings />, { container: document.body.appendChild(target) });
render(<ModalWithAdjacentModal />, { container: document.body.appendChild(target) });

const asideSibling = screen.getByRole('complementary', { hidden: true });
const articleSibling = screen.getByRole('article', { hidden: true });
Expand All @@ -154,6 +176,7 @@ describe('Modal', () => {
expect(asideSibling).not.toHaveAttribute('aria-hidden');
expect(articleSibling).not.toHaveAttribute('aria-hidden');
});

test('The modalBoxBody has no aria-label when bodyAriaLabel is not passed', () => {
const props = {
isOpen: true
Expand Down
8 changes: 8 additions & 0 deletions packages/react-core/src/components/Modal/examples/Modal.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ import formStyles from '@patternfly/react-styles/css/components/Form/form';

## Examples

### TEST PR

this example is for testing the fix testing and will be deleted before PR merges.

```ts file="./Test.tsx"

```

### 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.
Expand Down
90 changes: 90 additions & 0 deletions packages/react-core/src/components/Modal/examples/Test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// this is for testing purposed only. This file will be deleted before PR merges.

import React from 'react';
import { Modal, Button } from '@patternfly/react-core';

export const Test: React.FunctionComponent = () => {
const YourIssue1 = ({ strict }) => {
const renderCount = React.useRef(0);
renderCount.current++;

const [isOpen, setIsOpen] = React.useState(false);

React.useEffect(() => {
// this use effect causes an extra re-render in strict mode
}, [isOpen]);

return (
<div>
<Button onClick={() => setIsOpen(true)}>{strict ? 'Strict ' : ''}Open Modal</Button>
{isOpen && (
<>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<s>
Inspect the div containing the modal to see the value of aria-hidden.
<div>Render count: {renderCount.current}</div>
</s>
<p>FIXED: Root div for portal has been removed. Only siblings of modal should have aria-hidden</p>
</Modal>
</>
)}
</div>
);
};

const YourIssue2 = () => {
const [isOpen, setIsOpen] = React.useState(false);
return (
<div>
<Button onClick={() => setIsOpen(true)}>Open Modal</Button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<s>
Inspect the div containing the modal to see the value of all root siblings having no aria-hidden attribute.
</s>
<p>FIXED: Root siblings now have aria-hidden applied.</p>
</Modal>
<Modal isOpen={false} onClose={() => {}}>
This modal remains closed for this demo.
</Modal>
<Modal isOpen={false} onClose={() => {}}>
This modal remains closed for this demo.
</Modal>
</div>
);
};

return (
<div>
<p>
When a modal is open, the root and all siblings should have aria-hidden="true" except for the modal container.
</p>
<br />
<p>
<s>
When the strict mode modal opens, inspect the DOM to see that the open modal has aria-hidden="true" when it
should be "false".
</s>
</p>
<p>FIXED: root no longer has aria-hidden</p>
<p>
<s>Also, every time the modal opens a new div is appened and is never removed until the page is refreshed.</s>
</p>
<p>FIXED: the div was removed as it is not needed.</p>
<React.StrictMode>
<YourIssue1 strict />
</React.StrictMode>
<p>When the non-strict mode modal opens, inspect the DOM to see the open modal has no aria-hidden attribute.</p>
<YourIssue1 strict={undefined} />
<br />
<br />
<p>
<s>
Opening a modal while another adjacent modal is closed causes the aria-hidden attribute to be removed from the
root and all siblings.
</s>
</p>
<p>FIXED: aria hidden is no longer removed.</p>
<YourIssue2 />
</div>
);
};
Loading
Loading