1- import React , { type HTMLAttributes , createContext , useEffect , useState } from 'react' ;
1+ import React , { type HTMLAttributes , createContext , useEffect , useRef , useState } from 'react' ;
22
33import { deprecate , logger } from 'storybook/internal/client-logger' ;
44import 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' ;
1011import { useTransitionState } from 'react-transition-state' ;
1112
1213import { 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