Skip to content

Commit 9167f42

Browse files
committed
WIP: Continue revert on Modal
1 parent 2153f74 commit 9167f42

File tree

2 files changed

+227
-52
lines changed

2 files changed

+227
-52
lines changed

code/core/src/components/components/Modal/Modal.stories.tsx

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,164 @@ export const OnInteractOutsidePreventDefault = meta.story({
304304
},
305305
});
306306

307-
// TODO what when dismissOutside is disabled
307+
export const OnInteractOutsideDismissDisabled = meta.story({
308+
name: 'OnInteractOutside - dismiss disabled (deprecated)',
309+
args: {
310+
children: <SampleModalContent />,
311+
dismissOnClickOutside: false,
312+
onInteractOutside: fn(),
313+
},
314+
render: (args) => {
315+
const [isOpen, setOpen] = useState(false);
316+
317+
return (
318+
<>
319+
<Modal {...args} open={isOpen} onOpenChange={setOpen} />
320+
<Button ariaLabel={false} onClick={() => setOpen(true)}>
321+
Open Modal
322+
</Button>
323+
<Button ariaLabel={false} style={{ marginLeft: '1rem' }}>
324+
Outside Button
325+
</Button>
326+
</>
327+
);
328+
},
329+
play: async ({ args, canvas, step }) => {
330+
await step('Open modal', async () => {
331+
const trigger = canvas.getByText('Open Modal');
332+
await userEvent.click(trigger);
333+
await waitFor(() => {
334+
expect(screen.queryByText('Sample Modal')).toBeInTheDocument();
335+
});
336+
});
337+
338+
await step('Click outside to close, nothing should happen', async () => {
339+
const outsideButton = canvas.getByText('Outside Button');
340+
await userEvent.click(outsideButton);
341+
expect(args.onInteractOutside).not.toHaveBeenCalled();
342+
// Wait a bit to ensure the modal close animation would've had time to play.
343+
await new Promise((r) => setTimeout(r, 300));
344+
await waitFor(() => {
345+
expect(screen.queryByText('Sample Modal')).toBeInTheDocument();
346+
});
347+
});
348+
},
349+
});
350+
351+
export const OnEscapeKeyDown = meta.story({
352+
name: 'OnEscapeKeyDown (deprecated)',
353+
args: {
354+
children: <SampleModalContent />,
355+
onEscapeKeyDown: fn(),
356+
},
357+
render: (args) => {
358+
const [isOpen, setOpen] = useState(false);
359+
360+
return (
361+
<>
362+
<Modal {...args} open={isOpen} onOpenChange={setOpen} />
363+
<Button ariaLabel={false} onClick={() => setOpen(true)}>
364+
Open Modal
365+
</Button>
366+
</>
367+
);
368+
},
369+
play: async ({ args, canvas, step }) => {
370+
await step('Open modal', async () => {
371+
const trigger = canvas.getByText('Open Modal');
372+
await userEvent.click(trigger);
373+
await waitFor(() => {
374+
expect(screen.queryByText('Sample Modal')).toBeInTheDocument();
375+
});
376+
});
377+
378+
await step('Close modal with Escape key', async () => {
379+
await userEvent.keyboard('{Escape}');
380+
expect(args.onEscapeKeyDown).toHaveBeenCalled();
381+
await waitFor(() => {
382+
expect(screen.queryByText('Sample Modal')).not.toBeInTheDocument();
383+
});
384+
});
385+
},
386+
});
387+
388+
export const OnEscapeKeyDownPreventDefault = meta.story({
389+
name: 'OnEscapeKeyDown - e.preventDefault (deprecated)',
390+
args: {
391+
children: <SampleModalContent />,
392+
onEscapeKeyDown: (e) => e.preventDefault(),
393+
},
394+
render: (args) => {
395+
const [isOpen, setOpen] = useState(false);
396+
397+
return (
398+
<>
399+
<Modal {...args} open={isOpen} onOpenChange={setOpen} />
400+
<Button ariaLabel={false} onClick={() => setOpen(true)}>
401+
Open Modal
402+
</Button>
403+
</>
404+
);
405+
},
406+
play: async ({ canvas, step }) => {
407+
await step('Open modal', async () => {
408+
const trigger = canvas.getByText('Open Modal');
409+
await userEvent.click(trigger);
410+
await waitFor(() => {
411+
expect(screen.queryByText('Sample Modal')).toBeInTheDocument();
412+
});
413+
});
414+
415+
await step('Click outside to close but modal stays open', async () => {
416+
await userEvent.keyboard('{Escape}');
417+
// Wait a bit to ensure the modal close animation would've had time to play.
418+
await new Promise((r) => setTimeout(r, 300));
419+
await waitFor(() => {
420+
expect(screen.queryByText('Sample Modal')).toBeInTheDocument();
421+
});
422+
});
423+
},
424+
});
425+
426+
export const OnEscapeKeyDownEscDisabled = meta.story({
427+
name: 'OnEscapeKeyDown - dismiss disabled (deprecated)',
428+
args: {
429+
children: <SampleModalContent />,
430+
dismissOnEscape: false,
431+
onEscapeKeyDown: fn(),
432+
},
433+
render: (args) => {
434+
const [isOpen, setOpen] = useState(false);
435+
436+
return (
437+
<>
438+
<Modal {...args} open={isOpen} onOpenChange={setOpen} />
439+
<Button ariaLabel={false} onClick={() => setOpen(true)}>
440+
Open Modal
441+
</Button>
442+
</>
443+
);
444+
},
445+
play: async ({ args, canvas, step }) => {
446+
await step('Open modal', async () => {
447+
const trigger = canvas.getByText('Open Modal');
448+
await userEvent.click(trigger);
449+
await waitFor(() => {
450+
expect(screen.queryByText('Sample Modal')).toBeInTheDocument();
451+
});
452+
});
453+
454+
await step('Click outside to close, nothing should happen', async () => {
455+
await userEvent.keyboard('{Escape}');
456+
expect(args.onEscapeKeyDown).not.toHaveBeenCalled();
457+
// Wait a bit to ensure the modal close animation would've had time to play.
458+
await new Promise((r) => setTimeout(r, 300));
459+
await waitFor(() => {
460+
expect(screen.queryByText('Sample Modal')).toBeInTheDocument();
461+
});
462+
});
463+
},
464+
});
308465

309466
const ModalWithTrigger = ({
310467
triggerText,

code/core/src/components/components/Modal/Modal.tsx

Lines changed: 69 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import React, { type HTMLAttributes, createContext, useEffect, useState } from 'react';
1+
import React, { type HTMLAttributes, createContext, useEffect, useRef, useState } from 'react';
22

33
import { deprecate, logger } from 'storybook/internal/client-logger';
44
import type { DecoratorFunction } from 'storybook/internal/csf';
55

6-
import { useKeyboard } from '@react-aria/interactions';
7-
import { UNSAFE_PortalProvider } from '@react-aria/overlays';
8-
import { Dialog } from 'react-aria-components/patched-dist/Dialog';
9-
import { ModalOverlay, Modal as ModalUpstream } from 'react-aria-components/patched-dist/Modal';
6+
import { FocusScope } from '@react-aria/focus';
7+
import { Overlay, UNSAFE_PortalProvider, useModalOverlay } from '@react-aria/overlays';
8+
import { mergeProps } from '@react-aria/utils';
9+
import { useOverlayTriggerState } from '@react-stately/overlays';
10+
import type { KeyboardEvent as RAKeyboardEvent } from '@react-types/shared';
1011
import { useTransitionState } from 'react-transition-state';
1112

1213
import { useMediaQuery } from '../../../manager/hooks/useMedia';
@@ -96,17 +97,7 @@ function BaseModal({
9697
);
9798
}
9899

99-
const { keyboardProps } = useKeyboard({
100-
onKeyDown: (e) => {
101-
if (e.key === 'Escape' && dismissOnEscape) {
102-
console.log('we closing ourselves');
103-
onEscapeKeyDown?.(e.nativeEvent);
104-
if (!e.nativeEvent.defaultPrevented) {
105-
close();
106-
}
107-
}
108-
},
109-
});
100+
const overlayRef = useRef<HTMLDivElement>(null);
110101

111102
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
112103
const [{ status, isMounted }, toggle] = useTransitionState({
@@ -115,14 +106,39 @@ function BaseModal({
115106
unmountOnExit: true,
116107
});
117108

109+
// Create state for the overlay trigger
110+
const state = useOverlayTriggerState({
111+
isOpen: open || isMounted,
112+
defaultOpen,
113+
onOpenChange: (isOpen: boolean) => {
114+
toggle(isOpen);
115+
onOpenChange?.(isOpen);
116+
},
117+
});
118+
118119
const close = () => {
119-
handleOpenChange(false);
120+
state.close();
120121
};
121122

122-
const handleOpenChange = (isOpen: boolean) => {
123-
toggle(isOpen);
124-
onOpenChange?.(isOpen);
125-
};
123+
const { modalProps, underlayProps } = useModalOverlay(
124+
{
125+
isDismissable: dismissOnClickOutside,
126+
isKeyboardDismissDisabled: true,
127+
shouldCloseOnInteractOutside: onInteractOutside
128+
? (element: Element) => {
129+
const mockedEvent = new MouseEvent('click', {
130+
bubbles: true,
131+
cancelable: true,
132+
relatedTarget: element,
133+
});
134+
onInteractOutside(mockedEvent);
135+
return !mockedEvent.defaultPrevented;
136+
}
137+
: undefined,
138+
},
139+
state,
140+
overlayRef
141+
);
126142

127143
// Sync external open state with transition state
128144
useEffect(() => {
@@ -134,7 +150,7 @@ function BaseModal({
134150
}
135151
}, [open, defaultOpen, isMounted, toggle]);
136152

137-
// Call onOpenChange ourselves when the modal is initially opened, as react-aria won't.
153+
// Call onOpenChange ourselves when the modal is initially opened
138154
useEffect(() => {
139155
if (isMounted && (open || defaultOpen)) {
140156
onOpenChange?.(true);
@@ -146,34 +162,36 @@ function BaseModal({
146162
return null;
147163
}
148164

149-
return (
150-
<ModalOverlay
151-
defaultOpen={defaultOpen}
152-
isOpen={open || isMounted}
153-
onOpenChange={handleOpenChange}
154-
isDismissable={dismissOnClickOutside}
155-
// TODO in Storybook 11: switch back to using the prop
156-
// In SB10, we call useKeyboard to support the deprecated `onEscapeKeyDown` prop
157-
// isKeyboardDismissDisabled={true}
158-
isKeyboardDismissDisabled={!dismissOnEscape}
159-
shouldCloseOnInteractOutside={
160-
onInteractOutside
161-
? (element) => {
162-
const mockedEvent = new MouseEvent('click', {
163-
bubbles: true,
164-
cancelable: true,
165-
relatedTarget: element,
166-
});
167-
onInteractOutside(mockedEvent);
168-
return !mockedEvent.defaultPrevented;
169-
}
170-
: undefined
165+
const finalModalProps = mergeProps(modalProps, {
166+
onKeyDown: (e: RAKeyboardEvent) => {
167+
if (e.key !== 'Escape') {
168+
modalProps.onKeyDown?.(e);
169+
} else {
170+
if (dismissOnEscape) {
171+
onEscapeKeyDown?.(e.nativeEvent);
172+
if (!e.nativeEvent.defaultPrevented) {
173+
close();
174+
}
175+
}
171176
}
172-
>
173-
<Components.Overlay $status={status} $transitionDuration={transitionDuration} />
174-
<ModalUpstream>
175-
<Dialog aria-label={ariaLabel}>
177+
},
178+
});
179+
180+
return (
181+
<Overlay disableFocusManagement>
182+
{/* Overlay won't place focus within the modal on its own, and so its own FocusScope
183+
starts cycling through focusable elements only after we've clicked or tabbed into the modal.
184+
So we use our own focus scope and autofocus within on mount. */}
185+
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
186+
<FocusScope restoreFocus contain autoFocus>
187+
<Components.Overlay
188+
$status={status}
189+
$transitionDuration={transitionDuration}
190+
{...underlayProps}
191+
/>
192+
<div role="dialog" aria-label={ariaLabel} ref={overlayRef} {...finalModalProps}>
176193
<ModalContext.Provider value={{ close }}>
194+
{/* We need to set the FocusScope ourselves somehow, Overlay won't set it. */}
177195
<Components.Container
178196
$variant={variant}
179197
$status={status}
@@ -186,9 +204,9 @@ function BaseModal({
186204
{children}
187205
</Components.Container>
188206
</ModalContext.Provider>
189-
</Dialog>
190-
</ModalUpstream>
191-
</ModalOverlay>
207+
</div>
208+
</FocusScope>
209+
</Overlay>
192210
);
193211
}
194212

0 commit comments

Comments
 (0)