Skip to content
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
2 changes: 1 addition & 1 deletion packages/react-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"tslib": "^2.8.1"
},
"devDependencies": {
"@patternfly/patternfly": "6.6.0-prerelease.41",
"@patternfly/patternfly": "6.6.0-prerelease.44",
"case-anything": "^3.1.2",
"css": "^3.0.0",
"fs-extra": "^11.3.3"
Expand Down
29 changes: 24 additions & 5 deletions packages/react-core/src/components/Backdrop/Backdrop.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,39 @@
import { css } from '@patternfly/react-styles';
import styles from '@patternfly/react-styles/css/components/Backdrop/backdrop';
import { useHasAnimations } from '../../helpers';

export interface BackdropProps extends React.HTMLProps<HTMLDivElement> {
/** Content rendered inside the backdrop */
children?: React.ReactNode;
/** Additional classes added to the backdrop */
className?: string;
/** Flag indicating whether animations are enabled. Animations are enabled by default. */
hasAnimations?: boolean;
/** Flag to show the backdrop when animations are enabled. Set to false while the backdrop remains mounted to play its exit transition. */
isVisible?: boolean;
}

export const Backdrop: React.FunctionComponent<BackdropProps> = ({
children = null,
className = '',
hasAnimations: hasAnimationsProp = true,
isVisible = true,
...props
}: BackdropProps) => (
<div {...props} className={css(styles.backdrop, className)}>
{children}
</div>
);
}: BackdropProps) => {
const hasAnimations = useHasAnimations(hasAnimationsProp);

return (
<div
{...props}
className={css(
styles.backdrop,
hasAnimations && styles.modifiers.animate,
hasAnimations && isVisible && styles.modifiers.show,
className
)}
>
{children}
</div>
);
};
Backdrop.displayName = 'Backdrop';
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,45 @@ test(`Renders with the ${styles.backdrop}`, () => {
expect(screen.getByText('Test')).toHaveClass(styles.backdrop);
});

test(`Renders with only the class ${styles.backdrop} by default`, () => {
test('Renders with animations enabled by default', () => {
render(<Backdrop>Test</Backdrop>);
expect(screen.getByText('Test')).toHaveClass(styles.backdrop, styles.modifiers.animate, styles.modifiers.show, {
exact: true
});
});

test('Renders without animation classes when animations are explicitly disabled', () => {
render(<Backdrop hasAnimations={false}>Test</Backdrop>);
expect(screen.getByText('Test')).toHaveClass(styles.backdrop, { exact: true });
});

test('Renders as visible by default when animations are enabled', () => {
render(<Backdrop hasAnimations>Test</Backdrop>);
expect(screen.getByText('Test')).toHaveClass(styles.backdrop, styles.modifiers.animate, styles.modifiers.show, {
exact: true
});
});

test('Renders as hidden when animations are enabled and isVisible is false', () => {
render(
<Backdrop hasAnimations isVisible={false}>
Test
</Backdrop>
);
expect(screen.getByText('Test')).toHaveClass(styles.backdrop, styles.modifiers.animate, { exact: true });
});

test('Renders as visible when animations are enabled and isVisible is true', () => {
render(
<Backdrop hasAnimations isVisible>
Test
</Backdrop>
);
expect(screen.getByText('Test')).toHaveClass(styles.backdrop, styles.modifiers.animate, styles.modifiers.show, {
exact: true
});
});

test('Renders with custom class name when className prop is passed', () => {
render(<Backdrop className="test-class">Test</Backdrop>);
expect(screen.getByText('Test')).toHaveClass('test-class');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
exports[`Matches the snapshot 1`] = `
<DocumentFragment>
<div
class="pf-v6-c-backdrop"
class="pf-v6-c-backdrop pf-m-animate pf-m-show"
>
Backdrop
</div>
Expand Down
5 changes: 4 additions & 1 deletion packages/react-core/src/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export interface ModalProps extends React.HTMLProps<HTMLDivElement>, OUIAProps {
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;
/** Flag indicating whether animations are enabled. Animations are enabled by default. */
hasAnimations?: boolean;
}

export enum ModalVariant {
Expand All @@ -78,7 +80,8 @@ class Modal extends Component<ModalProps, ModalState> {
variant: 'default',
appendTo: () => document.body,
ouiaSafe: true,
position: 'default'
position: 'default',
hasAnimations: true
};

constructor(props: ModalProps) {
Expand Down
8 changes: 8 additions & 0 deletions packages/react-core/src/components/Modal/ModalBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export interface ModalBoxProps extends React.HTMLProps<HTMLDivElement> {
positionOffset?: string;
/** Variant of the modal. */
variant?: 'small' | 'medium' | 'large' | 'default';
/** Flag indicating whether animations are enabled. */
hasAnimations?: boolean;
/** Flag to show the modal. */
isOpen?: boolean;
}

export const ModalBox: React.FunctionComponent<ModalBoxProps> = ({
Expand All @@ -31,6 +35,8 @@ export const ModalBox: React.FunctionComponent<ModalBoxProps> = ({
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedby,
style,
isOpen,
hasAnimations,
...props
}: ModalBoxProps) => {
if (positionOffset) {
Expand All @@ -46,6 +52,8 @@ export const ModalBox: React.FunctionComponent<ModalBoxProps> = ({
aria-modal="true"
className={css(
styles.modalBox,
hasAnimations && styles.modifiers.animate,
hasAnimations && isOpen === true && styles.modifiers.open,
className,
position === 'top' && styles.modifiers.alignTop,
variant === 'large' && styles.modifiers.lg,
Expand Down
58 changes: 54 additions & 4 deletions packages/react-core/src/components/Modal/ModalContent.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { useEffect, useState } 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 { getOUIAProps, OUIAProps, useHasAnimations } from '../../helpers';
import { Backdrop } from '../Backdrop';
import { ModalBoxCloseButton } from './ModalBoxCloseButton';
import { ModalBox } from './ModalBox';

const transitionEndFallbackDelay = 300;

export interface ModalContentProps extends OUIAProps {
/** Id to use for the modal box description. This should match the ModalHeader labelId or descriptorId. */
'aria-describedby'?: string;
Expand Down Expand Up @@ -49,6 +52,8 @@ export interface ModalContentProps extends OUIAProps {
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;
/** Flag indicating whether animations are enabled. */
hasAnimations?: boolean;
}

export const ModalContent: React.FunctionComponent<ModalContentProps> = ({
Expand All @@ -72,9 +77,30 @@ export const ModalContent: React.FunctionComponent<ModalContentProps> = ({
ouiaSafe = true,
elementToFocus,
focusTrapId,
hasAnimations: hasAnimationsProp,
...props
}: ModalContentProps) => {
if (!isOpen) {
const hasAnimations = useHasAnimations(hasAnimationsProp);
// Keeps the modal in the DOM while the close animation runs. When animations are enabled we defer
// unmounting until the backdrop's transition ends (see onTransitionEnd below) instead of removing
// it immediately when isOpen becomes false.
const [isRendered, setIsRendered] = useState(isOpen);

useEffect(() => {
if (isOpen) {
setIsRendered(true);
} else if (!isRendered) {
return;
} else if (!hasAnimations) {
setIsRendered(false);
} else {
// Ensure the modal is removed if CSS transitions are disabled or transitionend does not fire.
const transitionEndFallback = window.setTimeout(() => setIsRendered(false), transitionEndFallbackDelay);
return () => window.clearTimeout(transitionEndFallback);
}
}, [isOpen, hasAnimations, isRendered]);

if (!isRendered) {
return null;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand All @@ -91,6 +117,8 @@ export const ModalContent: React.FunctionComponent<ModalContentProps> = ({
const modalBox = (
<ModalBox
className={css(className)}
isOpen={isOpen}
hasAnimations={hasAnimations}
variant={variant}
position={position}
positionOffset={positionOffset}
Expand All @@ -113,10 +141,32 @@ export const ModalContent: React.FunctionComponent<ModalContentProps> = ({
{children}
</ModalBox>
);
let focusTrapActive = !disableFocusTrap;
if (hasAnimations) {
focusTrapActive = !disableFocusTrap && isOpen;
}

return (
<Backdrop className={css(backdropClassName)} id={backdropId}>
<Backdrop
className={css(backdropClassName)}
id={backdropId}
hasAnimations={hasAnimations}
isVisible={isOpen}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
aria-hidden={hasAnimations && !isOpen ? true : undefined}
onTransitionEnd={
hasAnimations
? (event) => {
// Only unmount once the backdrop's own closing transition finishes. Guarding on the
// target prevents bubbled transitions from child elements from triggering this early.
if (!isOpen && event.target === event.currentTarget) {
setIsRendered(false);
}
}
: undefined
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
<FocusTrap
active={!disableFocusTrap}
active={focusTrapActive}
focusTrapOptions={{
clickOutsideDeactivates: true,
tabbableOptions: { displayCheck: 'none' },
Expand Down
14 changes: 14 additions & 0 deletions packages/react-core/src/components/Modal/__tests__/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@ describe('Modal', () => {
expect(document.body).toHaveClass(css(styles.backdropOpen));
});

test('modal has animations enabled by default', () => {
render(<Modal {...props} isOpen />);
const backdrop = screen.getByText('modal content').closest(`.${styles.backdrop}`);

expect(backdrop).toHaveClass(styles.modifiers.animate, styles.modifiers.show);
});

test('modal animations can be explicitly disabled', () => {
render(<Modal {...props} isOpen hasAnimations={false} />);
const backdrop = screen.getByText('modal content').closest(`.${styles.backdrop}`);

expect(backdrop).not.toHaveClass(styles.modifiers.animate, styles.modifiers.show);
});

test('modal has no body backdropOpen class when not open', () => {
render(<Modal {...props} />);
expect(document.body).not.toHaveClass(css(styles.backdropOpen));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';

import { ModalContent } from '../ModalContent';

Expand Down Expand Up @@ -59,3 +59,77 @@ test('Modal content can add id to focus trap correctly for use with dropdowns',
'pf-v6-l-bullseye'
);
});

test('Modal content is hidden from assistive technologies during its closing animation', () => {
const { rerender } = render(
<ModalContent isOpen hasAnimations backdropId="backdropId" {...modalContentProps}>
This is a ModalBox header
</ModalContent>
);
const backdrop = document.getElementById('backdropId');

expect(backdrop).not.toHaveAttribute('aria-hidden');

rerender(
<ModalContent isOpen={false} hasAnimations backdropId="backdropId" {...modalContentProps}>
This is a ModalBox header
</ModalContent>
);
expect(backdrop).toHaveAttribute('aria-hidden', 'true');

rerender(
<ModalContent isOpen hasAnimations backdropId="backdropId" {...modalContentProps}>
This is a ModalBox header
</ModalContent>
);
expect(backdrop).not.toHaveAttribute('aria-hidden');
});

test('Modal content unmounts if its closing transition does not end', () => {
jest.useFakeTimers();
try {
const { rerender } = render(
<ModalContent isOpen hasAnimations backdropId="backdropId" {...modalContentProps}>
This is a ModalBox header
</ModalContent>
);

rerender(
<ModalContent isOpen={false} hasAnimations backdropId="backdropId" {...modalContentProps}>
This is a ModalBox header
</ModalContent>
);
expect(document.getElementById('backdropId')).toBeInTheDocument();

act(() => jest.runOnlyPendingTimers());
expect(document.getElementById('backdropId')).not.toBeInTheDocument();
} finally {
jest.useRealTimers();
}
});

test('Modal content remains mounted during close when reduced motion is preferred', () => {
const matchMedia = window.matchMedia;
window.matchMedia = jest.fn().mockReturnValue({ matches: true } as MediaQueryList);
jest.useFakeTimers();
try {
const { rerender } = render(
<ModalContent isOpen hasAnimations backdropId="backdropId" {...modalContentProps}>
This is a ModalBox header
</ModalContent>
);

rerender(
<ModalContent isOpen={false} hasAnimations backdropId="backdropId" {...modalContentProps}>
This is a ModalBox header
</ModalContent>
);
expect(document.getElementById('backdropId')).toBeInTheDocument();

act(() => jest.runOnlyPendingTimers());
expect(document.getElementById('backdropId')).not.toBeInTheDocument();
} finally {
jest.useRealTimers();
window.matchMedia = matchMedia;
}
});
24 changes: 24 additions & 0 deletions packages/react-core/src/components/Modal/examples/Modal.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,27 @@ To customize which element inside the modal receives focus when initially opened
```ts file="./ModalCustomFocus.tsx"

```

### Animated modal (hasAnimations)

To allow modals to animate as they open and close, set the `hasAnimations` property on the modal.

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

```

### Animated modal (AnimationsProvider)

To enable animations globally, wrap your application with `AnimationsProvider`. All modals within the provider will animate without needing individual `hasAnimations` props.

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

```

### Not animated modal

To explicitly disable animations, set the `hasAnimations` property to `false` on the modal.

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

```
Loading