Skip to content

Commit 2b249e9

Browse files
authored
fix(BaseDialog): remediate a close button bug on iOS Safari (#4174)
1 parent 61b2667 commit 2b249e9

File tree

1 file changed

+51
-0
lines changed

1 file changed

+51
-0
lines changed

resources/js/common/components/+vendor/BaseDialog.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)