Skip to content

Commit 4d06f56

Browse files
committed
feat(modal): mount/unmount on open change
1 parent c1c00dc commit 4d06f56

File tree

5 files changed

+225
-71
lines changed

5 files changed

+225
-71
lines changed

packages/react/src/components/Modal/Modal-test.js

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const prefix = 'cds';
1818
describe('Modal', () => {
1919
it('should add extra classes that are passed via className', () => {
2020
render(
21-
<Modal data-testid="modal-1" className="custom-class">
21+
<Modal open data-testid="modal-1" className="custom-class">
2222
<p>
2323
Custom domains direct requests for your apps in this Cloud Foundry
2424
organization to a URL that you own. A custom domain can be a shared
@@ -37,7 +37,7 @@ describe('Modal', () => {
3737

3838
it('should set label if one is passed via props', () => {
3939
render(
40-
<Modal modalLabel="Account resources">
40+
<Modal open modalLabel="Account resources">
4141
<p>
4242
Custom domains direct requests for your apps in this Cloud Foundry
4343
organization to a URL that you own. A custom domain can be a shared
@@ -56,7 +56,7 @@ describe('Modal', () => {
5656

5757
it('should set modal heading if one is passed via props', () => {
5858
render(
59-
<Modal modalHeading="Add a custom domain">
59+
<Modal open modalHeading="Add a custom domain">
6060
<p>
6161
Custom domains direct requests for your apps in this Cloud Foundry
6262
organization to a URL that you own. A custom domain can be a shared
@@ -75,7 +75,7 @@ describe('Modal', () => {
7575

7676
it('should not be a passive modal by default', () => {
7777
render(
78-
<Modal data-testid="modal-2">
78+
<Modal open data-testid="modal-2">
7979
<p>
8080
Custom domains direct requests for your apps in this Cloud Foundry
8181
organization to a URL that you own. A custom domain can be a shared
@@ -94,7 +94,7 @@ describe('Modal', () => {
9494

9595
it('should be a passive modal when passiveModal is passed', () => {
9696
render(
97-
<Modal data-testid="modal-3" passiveModal>
97+
<Modal open data-testid="modal-3" passiveModal>
9898
<p>
9999
Custom domains direct requests for your apps in this Cloud Foundry
100100
organization to a URL that you own. A custom domain can be a shared
@@ -115,7 +115,7 @@ describe('Modal', () => {
115115

116116
it('should set id if one is passed via props', () => {
117117
render(
118-
<Modal id="custom-modal-id" data-testid="modal-4">
118+
<Modal open id="custom-modal-id" data-testid="modal-4">
119119
<p>
120120
Custom domains direct requests for your apps in this Cloud Foundry
121121
organization to a URL that you own. A custom domain can be a shared
@@ -137,7 +137,7 @@ describe('Modal', () => {
137137

138138
it('should not place the svg icon in the accessibility tree', () => {
139139
render(
140-
<Modal>
140+
<Modal open>
141141
<p>
142142
Custom domains direct requests for your apps in this Cloud Foundry
143143
organization to a URL that you own. A custom domain can be a shared
@@ -159,7 +159,7 @@ describe('Modal', () => {
159159

160160
it('should not make the icon tabbable', () => {
161161
render(
162-
<Modal>
162+
<Modal open>
163163
<p>
164164
Custom domains direct requests for your apps in this Cloud Foundry
165165
organization to a URL that you own. A custom domain can be a shared
@@ -181,7 +181,7 @@ describe('Modal', () => {
181181

182182
it('enables primary button by default', () => {
183183
render(
184-
<Modal primaryButtonText="Primary button text">
184+
<Modal open primaryButtonText="Primary button text">
185185
<p>
186186
Custom domains direct requests for your apps in this Cloud Foundry
187187
organization to a URL that you own. A custom domain can be a shared
@@ -200,7 +200,7 @@ describe('Modal', () => {
200200

201201
it('disables primary button is disablePrimaryButton prop is passed', () => {
202202
render(
203-
<Modal primaryButtonText="Primary button text" primaryButtonDisabled>
203+
<Modal open primaryButtonText="Primary button text" primaryButtonDisabled>
204204
<p>
205205
Custom domains direct requests for your apps in this Cloud Foundry
206206
organization to a URL that you own. A custom domain can be a shared
@@ -220,6 +220,7 @@ describe('Modal', () => {
220220
it('should set button text when passed via props', () => {
221221
render(
222222
<Modal
223+
open
223224
primaryButtonText="Primary button text"
224225
secondaryButtonText="Secondary button text">
225226
<p>
@@ -242,6 +243,7 @@ describe('Modal', () => {
242243
it('should allow you to pass a node for the primary and secondary buttons', () => {
243244
render(
244245
<Modal
246+
open
245247
primaryButtonText={<span data-testid="primary-node">testing</span>}
246248
secondaryButtonText={<span data-testid="secondary-node">testing</span>}>
247249
<p>
@@ -264,6 +266,7 @@ describe('Modal', () => {
264266
it('should support 2 secondary buttons', () => {
265267
render(
266268
<Modal
269+
open
267270
primaryButtonText="Primary button text"
268271
secondaryButtons={[
269272
{
@@ -294,7 +297,7 @@ describe('Modal', () => {
294297

295298
it('has the expected attributes when alert prop is passed', () => {
296299
render(
297-
<Modal alert>
300+
<Modal open alert>
298301
<p>
299302
Custom domains direct requests for your apps in this Cloud Foundry
300303
organization to a URL that you own. A custom domain can be a shared
@@ -315,6 +318,7 @@ describe('Modal', () => {
315318
it('renders a danger button and appropriate classes when danger prop is passed', () => {
316319
render(
317320
<Modal
321+
open
318322
danger
319323
primaryButtonText="Danger button text"
320324
data-testid="modal-5">
@@ -342,6 +346,7 @@ describe('Modal', () => {
342346
it('disables buttons when inline loading status is active', () => {
343347
render(
344348
<Modal
349+
open
345350
id="custom-modal-id"
346351
data-testid="modal-4"
347352
loadingStatus="active"
@@ -371,6 +376,7 @@ describe('Modal', () => {
371376
it('should respect decorator prop', () => {
372377
const { container } = render(
373378
<Modal
379+
open
374380
danger
375381
primaryButtonText="Danger button text"
376382
data-testid="modal-5"
@@ -385,6 +391,7 @@ describe('Modal', () => {
385391
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
386392
const { container } = render(
387393
<Modal
394+
open
388395
danger
389396
primaryButtonText="Danger button text"
390397
data-testid="modal-5"
@@ -487,23 +494,25 @@ describe('Modal', () => {
487494
Launch Modal
488495
</button>
489496
<Modal
497+
open
490498
launcherButtonRef={launcherButtonRef}
491499
primaryButtonText="Save"
492-
secondaryButtonText="Cancel">
500+
secondaryButtonText="Cancel"
501+
data-testid="modal">
493502
<p>Modal Content</p>
494503
</Modal>
495504
</>
496505
);
497506

498507
const launcherButton = screen.getByTestId('launcher-button');
508+
const saveButton = screen.getByRole('button', { name: /Save/ });
499509

500-
expect(launcherButton).not.toHaveFocus();
501-
expect(document.body).toHaveFocus();
510+
expect(saveButton).toHaveFocus();
502511

503512
jest.runAllTimers();
504513

505514
expect(launcherButton).not.toHaveFocus();
506-
expect(document.body).toHaveFocus();
515+
expect(saveButton).toHaveFocus();
507516

508517
jest.useRealTimers();
509518
});

packages/react/src/components/Modal/Modal.tsx

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import { debounce } from 'es-toolkit/compat';
3131
import useIsomorphicEffect from '../../internal/useIsomorphicEffect';
3232
import { useId } from '../../internal/useId';
3333
import { usePrefix } from '../../internal/usePrefix';
34-
import { usePreviousValue } from '../../internal/usePreviousValue';
3534
import { keys, match } from '../../internal/keyboard';
3635
import { IconButton } from '../IconButton';
3736
import { noopFn } from '../../internal/noopFn';
@@ -42,6 +41,8 @@ import { composeEventHandlers } from '../../tools/events';
4241
import deprecate from '../../prop-types/deprecate';
4342
import { unstable__Dialog as Dialog } from '../Dialog/index';
4443
import { warning } from '../../internal/warning';
44+
import { useMergedRefs } from '../../internal/useMergedRefs';
45+
import { Presence, usePresenceContext } from '../../internal/Presence';
4546

4647
export const ModalSizes = ['xs', 'sm', 'md', 'lg'] as const;
4748

@@ -234,7 +235,15 @@ export interface ModalProps extends HTMLAttributes<HTMLDivElement> {
234235
slug?: ReactNode;
235236
}
236237

237-
const Modal = React.forwardRef(function Modal(
238+
const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
239+
({ open = false, ...props }, ref) => (
240+
<Presence open={open}>
241+
<ModalInner {...props} ref={ref} />
242+
</Presence>
243+
)
244+
);
245+
246+
const ModalInner = React.forwardRef(function Modal(
238247
{
239248
'aria-label': ariaLabelProp,
240249
children,
@@ -246,7 +255,6 @@ const Modal = React.forwardRef(function Modal(
246255
passiveModal = false,
247256
secondaryButtonText,
248257
primaryButtonText,
249-
open,
250258
onRequestClose = noopFn,
251259
onRequestSubmit = noopFn,
252260
onSecondarySubmit,
@@ -273,14 +281,15 @@ const Modal = React.forwardRef(function Modal(
273281
ref: React.Ref<HTMLDivElement>
274282
) {
275283
const prefix = usePrefix();
284+
const { presenceRef, isExiting } = usePresenceContext();
276285
const button = useRef<HTMLButtonElement>(null);
277286
const secondaryButton = useRef<HTMLButtonElement>(null);
278287
const contentRef = useRef<HTMLDivElement>(null);
279288
const innerModal = useRef<HTMLDivElement>(null);
280289
const startTrap = useRef<HTMLSpanElement>(null);
281290
const endTrap = useRef<HTMLSpanElement>(null);
291+
const mergedInnerModalRefs = useMergedRefs([innerModal, presenceRef]);
282292
const [isScrollable, setIsScrollable] = useState(false);
283-
const prevOpen = usePreviousValue(open);
284293
const modalInstanceId = `modal-${useId()}`;
285294
const modalLabelId = `${prefix}--modal-header__label--${modalInstanceId}`;
286295
const modalHeadingId = `${prefix}--modal-header__heading--${modalInstanceId}`;
@@ -315,7 +324,7 @@ const Modal = React.forwardRef(function Modal(
315324

316325
evt.stopPropagation();
317326

318-
if (open && target instanceof HTMLElement) {
327+
if (target instanceof HTMLElement) {
319328
if (match(evt, keys.Escape)) {
320329
onRequestClose(evt);
321330
}
@@ -363,7 +372,6 @@ const Modal = React.forwardRef(function Modal(
363372
relatedTarget: currentActiveNode,
364373
}: React.FocusEvent<HTMLDivElement>) {
365374
if (
366-
open &&
367375
oldActiveNode instanceof HTMLElement &&
368376
currentActiveNode instanceof HTMLElement
369377
) {
@@ -389,7 +397,6 @@ const Modal = React.forwardRef(function Modal(
389397
`${prefix}--modal`,
390398
{
391399
[`${prefix}--modal-tall`]: !passiveModal,
392-
'is-visible': open,
393400
[`${prefix}--modal--danger`]: danger,
394401
[`${prefix}--modal--slug`]: slug,
395402
[`${prefix}--modal--decorator`]: decorator,
@@ -449,23 +456,20 @@ const Modal = React.forwardRef(function Modal(
449456

450457
useEffect(() => {
451458
if (!enableDialogElement) {
452-
toggleClass(
453-
document.body,
454-
`${prefix}--body--with-modal-open`,
455-
open ?? false
456-
);
459+
toggleClass(document.body, `${prefix}--body--with-modal-open`, true);
457460
}
458-
}, [open, prefix, enableDialogElement]);
461+
}, [prefix, enableDialogElement]);
459462

460463
useEffect(() => {
461-
if (!enableDialogElement && prevOpen && !open && launcherButtonRef) {
464+
if (enableDialogElement || !launcherButtonRef) return;
465+
return () => {
462466
setTimeout(() => {
463467
if ('current' in launcherButtonRef) {
464468
launcherButtonRef.current?.focus();
465469
}
466470
});
467-
}
468-
}, [open, prevOpen, launcherButtonRef, enableDialogElement]);
471+
};
472+
}, [enableDialogElement, launcherButtonRef]);
469473

470474
useEffect(() => {
471475
if (!enableDialogElement) {
@@ -495,11 +499,9 @@ const Modal = React.forwardRef(function Modal(
495499
}
496500
};
497501

498-
if (open) {
499-
focusButton(innerModal.current);
500-
}
502+
focusButton(innerModal.current);
501503
}
502-
}, [open, selectorPrimaryFocus, danger, prefix, enableDialogElement]);
504+
}, [selectorPrimaryFocus, danger, prefix, enableDialogElement]);
503505

504506
useIsomorphicEffect(() => {
505507
if (contentRef.current) {
@@ -565,9 +567,9 @@ const Modal = React.forwardRef(function Modal(
565567

566568
const modalBody = enableDialogElement ? (
567569
<Dialog
568-
open={open}
570+
open
569571
modal
570-
ref={innerModal}
572+
ref={mergedInnerModalRefs}
571573
role={isAlertDialog ? 'alertdialog' : ''}
572574
aria-describedby={isAlertDialog ? modalBodyId : ''}
573575
className={containerClasses}
@@ -674,7 +676,7 @@ const Modal = React.forwardRef(function Modal(
674676
</span>
675677
)}
676678
<div
677-
ref={innerModal}
679+
ref={mergedInnerModalRefs}
678680
role="dialog"
679681
{...alertDialogProps}
680682
className={containerClasses}
@@ -780,7 +782,8 @@ const Modal = React.forwardRef(function Modal(
780782
onBlur={!enableDialogElement ? handleBlur : () => {}}
781783
className={modalClasses}
782784
role="presentation"
783-
ref={ref}>
785+
ref={ref}
786+
data-exiting={isExiting}>
784787
{modalBody}
785788
</Layer>
786789
);

0 commit comments

Comments
 (0)