@@ -4,6 +4,22 @@ import React, { forwardRef, PropsWithoutRef, ReactNode, useEffect, useImperative
44import { createPortal } from 'react-dom' ;
55import styled from 'styled-components' ;
66
7+ export const BackDrop = styled . div `
8+ display: none; /* Hidden by default */
9+ position: fixed;
10+ z-index: 99; /* Ensure the backdrop is just below the modal */
11+ left: 0;
12+ width: 100vw; /* Full viewport width */
13+
14+ background-color: rgba(10, 9, 11, 0.3);
15+ top: ${ ( { theme } ) => theme . calc . titleBarHeight } ;
16+ height: ${ ( { theme } ) => theme . calc . height } ;
17+
18+ &.open {
19+ display: block; /* Show the backdrop when open */
20+ }
21+ ` ;
22+
723export const DialogContainer = styled . dialog `
824 overflow: visible;
925 padding: 0;
@@ -16,38 +32,41 @@ export const DialogContainer = styled.dialog`
1632 top: ${ ( { theme } ) => theme . calc . titleBarHeight } ;
1733 height: ${ ( { theme } ) => theme . calc . height } ;
1834 left: 100%;
35+
36+ &.open {
37+ transform: translateX(-100%);
38+ opacity: 1;
39+ }
40+ }
41+
42+ &.open {
43+ display: block;
44+ z-index: 100;
1945 }
2046
2147 &.center {
2248 opacity: 0;
49+
50+ &.open {
51+ opacity: 1;
52+ top: 40%;
53+ }
2354 }
2455
2556 & > div {
2657 left: unset;
2758 position: unset;
2859 }
2960
30- &::backdrop {
31- background-color: rgba(10, 9, 11, 0.3);
32- top: ${ ( { theme } ) => theme . calc . titleBarHeight } ;
33- height: ${ ( { theme } ) => theme . calc . height } ;
34- }
35-
36- &.right[open] {
37- transform: translateX(-100%);
38- }
39-
40- &.center[open] {
41- opacity: 1;
42- }
43-
4461 &:focus {
4562 outline: 0;
4663 }
4764` ;
4865
4966const onDialogCancel = ( e : Event ) => e . preventDefault ( ) ;
5067
68+ const focusableHtmlElements = 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])' ;
69+
5170const animationKeys = {
5271 right : {
5372 open : [
@@ -80,19 +99,39 @@ const animationOption = {
8099 easing : 'ease-in' ,
81100} as const ;
82101
83- const closeDialogWithAnimation = ( dialog : HTMLDialogElement , isCenter : boolean , onFinish : ( ) => void ) => {
102+ const closeDialogWithAnimation = ( dialog : HTMLDialogElement , backdrop : HTMLDivElement , isCenter : boolean , onFinish : ( ) => void ) => {
84103 const animation = dialog . animate ( isCenter ? animationKeys . center . close : animationKeys . right . close , animationOption ) ;
104+
85105 animation . onfinish = ( ) => {
86- onFinish ( ) ;
106+ dialog . classList . remove ( 'open' ) ;
107+ backdrop . classList . remove ( 'open' ) ;
87108 dialog . close ( ) ;
109+ onFinish ( ) ;
88110 dialog . removeEventListener ( 'cancel' , onDialogCancel ) ;
111+
112+ // If another dialogs is open, then focus the first focusable element in this modal
113+ const otherDialogs = document . querySelectorAll ( 'dialog.open' ) ;
114+ if ( otherDialogs . length ) {
115+ const focusableElements = otherDialogs [ otherDialogs . length - 1 ] . querySelectorAll ( focusableHtmlElements ) ;
116+ if ( focusableElements . length > 0 ) {
117+ ( focusableElements [ 0 ] as HTMLElement ) . focus ( ) ;
118+ }
119+ }
89120 } ;
90121} ;
91122
92- const openDialogWithAnimation = ( dialog : HTMLDialogElement , isCenter : boolean ) => {
123+ const openDialogWithAnimation = ( dialog : HTMLDialogElement , backdrop : HTMLDivElement , isCenter : boolean ) => {
93124 dialog . addEventListener ( 'cancel' , onDialogCancel ) ;
94- dialog . showModal ( ) ;
125+ dialog . classList . add ( 'open' ) ;
126+ backdrop . classList . add ( 'open' ) ;
95127 dialog . animate ( isCenter ? animationKeys . center . open : animationKeys . right . open , animationOption ) ;
128+
129+ setTimeout ( ( ) => {
130+ const focusableElements = dialog . querySelectorAll ( focusableHtmlElements ) ;
131+ if ( focusableElements ?. length ) {
132+ ( focusableElements [ 0 ] as HTMLElement ) . focus ( ) ;
133+ }
134+ } , animationOption . duration ) ;
96135} ;
97136
98137/**
@@ -138,10 +177,12 @@ export const defineEditorOverlay = <Keys extends string, Props extends Record<st
138177 const handleCloseRef = useEditorHandlingCloseRef ( ) ;
139178 const [ currentDialog , setCurrentDialog ] = useState < Keys | undefined > ( undefined ) ;
140179 const [ isCenter , setIsCenter ] = useState ( false ) ;
180+ const backdropRef = useRef < HTMLDivElement > ( null ) ;
141181
142182 const closeDialog = ( ) => {
143183 handleCloseRef . current ?. onClose ( ) ;
144- if ( dialogRef . current ) closeDialogWithAnimation ( dialogRef . current , isCenter , ( ) => setCurrentDialog ( undefined ) ) ;
184+ if ( dialogRef . current && backdropRef . current )
185+ closeDialogWithAnimation ( dialogRef . current , backdropRef . current , isCenter , ( ) => setCurrentDialog ( undefined ) ) ;
145186 } ;
146187
147188 const currentlyRenderedDialog = useMemo ( ( ) => {
@@ -154,14 +195,17 @@ export const defineEditorOverlay = <Keys extends string, Props extends Record<st
154195 if ( handleCloseRef . current ?. canClose ( ) ) closeDialog ( ) ;
155196 } ;
156197
157- const onClickOutside = ( e : React . MouseEvent < HTMLDialogElement , MouseEvent > ) => {
198+ const onClickOutside = ( e : React . MouseEvent < HTMLDivElement , MouseEvent > ) => {
158199 if ( e . currentTarget === e . target ) onEscape ( ) ;
159200 } ;
160201
161202 const openDialog = ( name : Keys , isCenterDialog ?: boolean ) => {
162203 setIsCenter ( isCenterDialog || false ) ;
163204 setCurrentDialog ( name ) ;
164- if ( dialogRef . current ) openDialogWithAnimation ( dialogRef . current , isCenterDialog || false ) ;
205+ // Without tick, if the dialog change between center and right, the dialog is not displayed correctly
206+ setTimeout ( ( ) => {
207+ if ( dialogRef . current && backdropRef . current ) openDialogWithAnimation ( dialogRef . current , backdropRef . current , isCenterDialog || false ) ;
208+ } , 0 ) ;
165209 } ;
166210
167211 // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -181,12 +225,105 @@ export const defineEditorOverlay = <Keys extends string, Props extends Record<st
181225 // eslint-disable-next-line react-hooks/exhaustive-deps
182226 } , [ currentDialog ] ) ;
183227
184- return createPortal (
185- < DialogContainer ref = { dialogRef } onMouseDown = { onClickOutside } className = { isCenter ? 'center' : 'right' } >
186- { currentlyRenderedDialog }
187- </ DialogContainer > ,
188- document . querySelector ( '#dialogs' ) || document . createElement ( 'div' )
189- ) ;
228+ useEffect ( ( ) => {
229+ const handleTabKey = ( event : KeyboardEvent ) => {
230+ if ( event . key !== 'Tab' ) return ;
231+
232+ const openDialogs = document . querySelectorAll ( 'dialog.open' ) ;
233+ if ( openDialogs . length === 0 ) return ;
234+
235+ const lastDialog = openDialogs [ openDialogs . length - 1 ] as HTMLDialogElement ;
236+ if ( ! lastDialog . contains ( event . target as Node ) ) return ;
237+
238+ const focusableElements = Array . from ( lastDialog . querySelectorAll ( focusableHtmlElements ) ) as HTMLElement [ ] ;
239+ if ( focusableElements . length === 0 ) return ;
240+
241+ const firstElement = focusableElements [ 0 ] ;
242+ const lastElement = focusableElements [ focusableElements . length - 1 ] ;
243+
244+ if ( event . shiftKey ) {
245+
246+ if ( document . activeElement === firstElement ) {
247+ event . preventDefault ( ) ;
248+ if ( focusableElements [ focusableElements . length - 2 ] . getAttribute ( 'type' ) === 'hidden' ) {
249+ const lastFocusableElement = focusableElements
250+ . reverse ( )
251+ . find ( ( el ) => el . getAttribute ( 'type' ) !== 'hidden' && el . getAttribute ( 'aria-hidden' ) !== 'true' ) ;
252+ lastFocusableElement ?. focus ( ) ;
253+ } else {
254+ focusableElements [ focusableElements . length - 2 ] . focus ( ) ;
255+ }
256+ }
257+ } else {
258+ if ( document . activeElement === lastElement ) {
259+ event . preventDefault ( ) ;
260+ focusableElements [ 1 ] . focus ( ) ;
261+ }
262+ }
263+ } ;
264+
265+ const handleCtrlA = ( event : KeyboardEvent ) => {
266+ if ( event . key === 'a' && ( event . ctrlKey || event . metaKey ) ) {
267+ const openDialogs = document . querySelectorAll ( 'dialog.open' ) ;
268+ if ( openDialogs . length === 0 ) return ;
269+
270+ const lastDialog = openDialogs [ openDialogs . length - 1 ] as HTMLDialogElement ;
271+ if ( lastDialog . contains ( event . target as Node ) ) {
272+ event . preventDefault ( ) ;
273+ const target = event . target as HTMLElement ;
274+
275+ if ( target . tagName === 'INPUT' || target . tagName === 'TEXTAREA' ) {
276+ ( target as HTMLInputElement | HTMLTextAreaElement ) . select ( ) ;
277+ } else {
278+ const range = document . createRange ( ) ;
279+ range . selectNodeContents ( lastDialog ) ;
280+ const selection = window . getSelection ( ) ;
281+ selection ?. removeAllRanges ( ) ;
282+ selection ?. addRange ( range ) ;
283+ }
284+ }
285+ }
286+ } ;
287+
288+ window . addEventListener ( 'keydown' , handleCtrlA ) ;
289+ window . addEventListener ( 'keydown' , handleTabKey ) ;
290+ return ( ) => {
291+ window . removeEventListener ( 'keydown' , handleCtrlA ) ;
292+ window . removeEventListener ( 'keydown' , handleTabKey ) ;
293+ } ;
294+ } , [ currentDialog ] ) ;
295+
296+ return createPortal (
297+ < >
298+ < BackDrop ref = { backdropRef } onClick = { onClickOutside } > </ BackDrop >
299+ < DialogContainer ref = { dialogRef } className = { isCenter ? 'center' : 'right' } >
300+ < div
301+ tabIndex = { 0 }
302+ aria-hidden = "true"
303+ onFocus = { ( e ) => {
304+ const focusableElements = Array . from ( dialogRef . current ?. querySelectorAll ( focusableHtmlElements ) || [ ] ) as HTMLElement [ ] ;
305+ if ( focusableElements . length > 0 && e . relatedTarget === focusableElements [ focusableElements . length - 1 ] ) {
306+ requestAnimationFrame ( ( ) => focusableElements [ 0 ] . focus ( ) ) ;
307+ }
308+ } }
309+ > </ div >
310+
311+ { currentlyRenderedDialog }
312+
313+ < div
314+ tabIndex = { 0 }
315+ aria-hidden = "true"
316+ onFocus = { ( e ) => {
317+ const focusableElements = Array . from ( dialogRef . current ?. querySelectorAll ( focusableHtmlElements ) || [ ] ) as HTMLElement [ ] ;
318+ if ( focusableElements . length > 0 && e . relatedTarget === focusableElements [ 0 ] ) {
319+ requestAnimationFrame ( ( ) => focusableElements [ focusableElements . length - 1 ] . focus ( ) ) ;
320+ }
321+ } }
322+ > </ div >
323+ </ DialogContainer >
324+ </ > ,
325+ document . querySelector ( '#dialogs' ) || document . createElement ( 'div' )
326+ ) ;
190327 } ) ;
191328 reactComponent . displayName = displayName ;
192329 return reactComponent ;
0 commit comments