@@ -44,6 +44,55 @@ const BaseDialogContent = React.forwardRef<
4444 ) => {
4545 const { t } = useTranslation ( ) ;
4646
47+ const closeButtonRef = React . useRef < HTMLButtonElement > ( null ) ;
48+ const blockerTimeoutRef = React . useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
49+
50+ /**
51+ * To band-aid a bug in Safari, we create a synthetic "dialog-hover-blocker"
52+ * element. We need to always be sure to clean that up on unmount so we
53+ * don't leak memory for every dialog we open.
54+ */
55+ React . useEffect ( ( ) => {
56+ return ( ) => {
57+ if ( blockerTimeoutRef . current ) {
58+ clearTimeout ( blockerTimeoutRef . current ) ;
59+ }
60+ const blocker = document . getElementById ( 'dialog-hover-blocker' ) ;
61+ if ( blocker ?. parentNode ) {
62+ blocker . parentNode . removeChild ( blocker ) ;
63+ }
64+ } ;
65+ } , [ ] ) ;
66+
67+ /**
68+ * On Safari mobile, tapping the dialog close button triggers a "sticky hover"
69+ * on elements underneath after the dialog closes (such as dropdown menus).
70+ * We handle touch separately by adding a temporary synthetic blocker element to
71+ * absorb the hover state, then programmatically trigger the close.
72+ *
73+ * If we don't do this, closing the dialog on mobile Safari can inadvertently
74+ * trigger elements z-indexed directly underneath the dialog close button.
75+ */
76+ const handleCloseTouchEnd = ( e : React . TouchEvent ) => {
77+ e . preventDefault ( ) ; // Prevent the synthetic click.
78+
79+ // Only create one blocker at a time.
80+ if ( ! document . getElementById ( 'dialog-hover-blocker' ) ) {
81+ const blocker = document . createElement ( 'div' ) ;
82+ blocker . id = 'dialog-hover-blocker' ;
83+ blocker . style . cssText = 'position:fixed;inset:0;z-index:9999;' ;
84+ document . body . appendChild ( blocker ) ;
85+
86+ blockerTimeoutRef . current = setTimeout ( ( ) => {
87+ blocker . remove ( ) ;
88+ blockerTimeoutRef . current = null ;
89+ } , 300 ) ;
90+ }
91+
92+ // Programmatically trigger the close via Radix.
93+ closeButtonRef . current ?. click ( ) ;
94+ } ;
95+
4796 return (
4897 < BaseDialogPortal >
4998 < BaseDialogOverlay className = { cn ( shouldBlurBackdrop ? 'backdrop-blur' : '' ) } />
@@ -65,12 +114,14 @@ const BaseDialogContent = React.forwardRef<
65114
66115 { shouldShowCloseButton ? (
67116 < DialogPrimitive . Close
117+ ref = { closeButtonRef }
68118 className = { cn (
69119 'ring-offset-background data-[state=open]:bg-accent data-[state=open]:text-muted-foreground' ,
70120 'absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100' ,
71121 'focus:outline-none focus:ring-offset-2 disabled:pointer-events-none' ,
72122 'text-link' ,
73123 ) }
124+ onTouchEnd = { handleCloseTouchEnd }
74125 >
75126 < RxCross2 className = "size-4" />
76127 < span className = "sr-only" > { t ( 'Close' ) } </ span >
0 commit comments