Skip to content

Commit 442dd74

Browse files
committed
fix(web): drop Radix body pointer-events lock
Pass modal={false} to DialogPrimitive.Root so Radix never sets body.style.pointerEvents='none'. Render overlay via @radix-ui/react-presence directly since Radix's Overlay returns null when non-modal. Click-trapping/click-through is handled on the overlay element itself, preserving dimmedDetentIndex parity without ever touching document.body. Also: fix stray ':' in [data-vaul-handle-hitarea] selector.
1 parent 19b93da commit 442dd74

3 files changed

Lines changed: 46 additions & 124 deletions

File tree

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@
122122
},
123123
"peerDependencies": {
124124
"@radix-ui/react-dialog": ">=1",
125+
"@radix-ui/react-presence": ">=1",
125126
"@react-navigation/native": ">=7",
126127
"react": "*",
127128
"react-native": "*",
@@ -132,6 +133,9 @@
132133
"@radix-ui/react-dialog": {
133134
"optional": true
134135
},
136+
"@radix-ui/react-presence": {
137+
"optional": true
138+
},
135139
"@react-navigation/native": {
136140
"optional": true
137141
},

src/web/vaul/index.tsx

Lines changed: 41 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import React from 'react';
44

55
import * as DialogPrimitive from '@radix-ui/react-dialog';
6+
import { Presence } from '@radix-ui/react-presence';
67

78
import { DrawerContext, useDrawerContext } from './context';
89
import './style.css';
@@ -239,19 +240,6 @@ export function Root({
239240
setTimeout(() => {
240241
onAnimationEnd?.(o);
241242
}, TRANSITIONS.DURATION * 1000);
242-
243-
if (o && !modal) {
244-
if (typeof window !== 'undefined') {
245-
window.requestAnimationFrame(() => {
246-
document.body.style.pointerEvents = 'auto';
247-
});
248-
}
249-
}
250-
251-
if (!o) {
252-
// This will be removed when the exit animation ends (`500ms`)
253-
document.body.style.pointerEvents = 'auto';
254-
}
255243
},
256244
});
257245
const [hasBeenOpened, setHasBeenOpened] = React.useState<boolean>(false);
@@ -566,88 +554,6 @@ export function Root({
566554
onPositionChangeRef.current = onPositionChange;
567555
});
568556

569-
// Radix's DismissableLayer (modal) sets body pointer-events to "none" so the
570-
// overlay traps clicks. When the active snap is below fadeFromIndex the
571-
// overlay is fully transparent, and we want clicks to reach the content
572-
// behind the drawer — so we restore body interactivity. DismissableLayer
573-
// captures its DOM node via a ref-callback `setNode`, so its body write
574-
// runs one render later than ours on initial open — re-assert on rAF so
575-
// the final write is ours.
576-
React.useEffect(() => {
577-
if (!modal || !snapPoints || fadeFromIndex === undefined) return;
578-
// On close, restore body interactivity. `useControllableState.onChange`
579-
// only fires for internal `setIsOpen` calls — when the parent flips the
580-
// `open` prop imperatively, the reset at line ~201 never runs.
581-
if (!isOpen) {
582-
document.body.style.pointerEvents = 'auto';
583-
return;
584-
}
585-
if (typeof activeSnapPointIndex !== 'number') return;
586-
const isBelowFade = activeSnapPointIndex < fadeFromIndex;
587-
const value = isBelowFade ? 'auto' : 'none';
588-
document.body.style.pointerEvents = value;
589-
const id = window.requestAnimationFrame(() => {
590-
document.body.style.pointerEvents = value;
591-
});
592-
593-
// React Navigation keeps unfocused screens mounted with `display: none` on
594-
// web, so the drawer never unmounts and neither `isOpen` nor this effect's
595-
// deps change — a lingering `none` body style would then leak to whichever
596-
// screen becomes visible. Observe the drawer: when an ancestor hides it,
597-
// restore `auto`; when it returns, re-apply the desired value.
598-
let observer: IntersectionObserver | null = null;
599-
let rafId = 0;
600-
const startObserving = () => {
601-
const node = drawerRef.current;
602-
if (!node) {
603-
rafId = window.requestAnimationFrame(startObserving);
604-
return;
605-
}
606-
observer = new IntersectionObserver((entries) => {
607-
const entry = entries[entries.length - 1];
608-
if (!entry) return;
609-
document.body.style.pointerEvents = entry.isIntersecting ? value : 'auto';
610-
});
611-
observer.observe(node);
612-
};
613-
rafId = window.requestAnimationFrame(startObserving);
614-
615-
// Always restore on cleanup so an unmount while the sheet is still open
616-
// doesn't leave the page uninteractive. If the effect re-runs for a
617-
// normal state change, it re-applies the correct value.
618-
return () => {
619-
window.cancelAnimationFrame(id);
620-
window.cancelAnimationFrame(rafId);
621-
observer?.disconnect();
622-
document.body.style.pointerEvents = 'auto';
623-
};
624-
}, [isOpen, modal, snapPoints, fadeFromIndex, activeSnapPointIndex]);
625-
626-
// Radix DismissableLayer's body-restore on unmount is broken: its two
627-
// useEffects (see @radix-ui/react-dismissable-layer dist/index.mjs:68-92)
628-
// run cleanups in reverse declaration order, so the layers-set decrement
629-
// (effect B) runs BEFORE the size-1 body-restore check (effect A). When
630-
// the last layer unmounts, A sees size=0 and skips the restore — body
631-
// stays 'none'. Take ownership of the restore here on Drawer.Root unmount.
632-
// Skipped when nested or non-modal so an inner drawer's unmount doesn't
633-
// unlock an outer drawer's modal trap. Also bail if any other Radix
634-
// dismissable layer is still open (sibling Dialog/AlertDialog/Popover) —
635-
// that one wants the lock kept.
636-
React.useEffect(() => {
637-
if (nested || !modal) return;
638-
return () => {
639-
if (typeof document === 'undefined') return;
640-
if (document.body.style.pointerEvents !== 'none') return;
641-
if (
642-
document.querySelector(
643-
'[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]'
644-
)
645-
)
646-
return;
647-
document.body.style.pointerEvents = 'auto';
648-
};
649-
}, [nested, modal]);
650-
651557
React.useEffect(() => {
652558
function onVisualViewportChange() {
653559
if (!drawerRef.current || !repositionInputs) return;
@@ -916,15 +822,6 @@ export function Root({
916822
}
917823
}
918824

919-
React.useEffect(() => {
920-
if (!modal) {
921-
// Need to do this manually unfortunately
922-
window.requestAnimationFrame(() => {
923-
document.body.style.pointerEvents = 'auto';
924-
});
925-
}
926-
}, [modal]);
927-
928825
return (
929826
<DialogPrimitive.Root
930827
defaultOpen={defaultOpen}
@@ -939,7 +836,14 @@ export function Root({
939836
setIsOpen(open);
940837
}}
941838
open={isOpen}
942-
modal={modal}
839+
// Always non-modal at the Radix level — Radix's modal mode locks
840+
// body.style.pointerEvents and its restore on layer unmount is buggy
841+
// (see git history). Vaul's own overlay handles click-trapping; the
842+
// overlay sets pointer-events: none below fadeFromIndex so clicks
843+
// pass through to the page (matches native dimmedDetentIndex). Vaul's
844+
// own `modal` prop still drives overlay rendering and click-outside
845+
// behavior below.
846+
modal={false}
943847
>
944848
<DrawerContext.Provider
945849
value={{
@@ -1035,26 +939,38 @@ export const Overlay = React.forwardRef<
1035939
typeof activeSnapPointIndex === 'number' &&
1036940
activeSnapPointIndex < fadeFromIndex;
1037941

942+
// Render our own overlay element instead of `DialogPrimitive.Overlay`:
943+
// Radix's Overlay returns null when the Dialog is non-modal, and we run
944+
// Radix as `modal={false}` to keep it from locking body pointer-events.
945+
// `Presence` keeps the node mounted through the closing animation
946+
// (`fadeOut`) before unmounting.
1038947
return (
1039-
<DialogPrimitive.Overlay
1040-
onMouseUp={onMouseUp}
1041-
ref={composedRef}
1042-
data-vaul-overlay=""
1043-
data-vaul-snap-points={isOpen && hasSnapPoints ? 'true' : 'false'}
1044-
data-vaul-delayed-snap-points={delayedSnapPoints ? 'true' : 'false'}
1045-
data-vaul-snap-points-overlay={isOpen && shouldFade ? 'true' : 'false'}
1046-
data-vaul-animate={shouldAnimate?.current ? 'true' : 'false'}
1047-
{...rest}
1048-
style={isBelowFade ? overlayBelowFadeStyle(style) : style}
1049-
/>
948+
<Presence present={isOpen}>
949+
<div
950+
onMouseUp={onMouseUp}
951+
ref={composedRef}
952+
data-state={isOpen ? 'open' : 'closed'}
953+
data-vaul-overlay=""
954+
data-vaul-snap-points={isOpen && hasSnapPoints ? 'true' : 'false'}
955+
data-vaul-delayed-snap-points={delayedSnapPoints ? 'true' : 'false'}
956+
data-vaul-snap-points-overlay={isOpen && shouldFade ? 'true' : 'false'}
957+
data-vaul-animate={shouldAnimate?.current ? 'true' : 'false'}
958+
{...rest}
959+
style={overlayStyle(style, !!isBelowFade)}
960+
/>
961+
</Presence>
1050962
);
1051963
});
1052964

1053965
Overlay.displayName = 'Drawer.Overlay';
1054966

1055-
const overlayPointerEventsNone: React.CSSProperties = { pointerEvents: 'none' };
1056-
const overlayBelowFadeStyle = (style: React.CSSProperties | undefined): React.CSSProperties =>
1057-
style ? { ...style, ...overlayPointerEventsNone } : overlayPointerEventsNone;
967+
const overlayStyle = (
968+
style: React.CSSProperties | undefined,
969+
isBelowFade: boolean
970+
): React.CSSProperties => ({
971+
...style,
972+
pointerEvents: isBelowFade ? 'none' : 'auto',
973+
});
1058974

1059975
export type ContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
1060976
/**
@@ -1385,10 +1301,12 @@ export const Content = React.forwardRef<HTMLDivElement, ContentProps>(
13851301
}
13861302
}}
13871303
onFocusOutside={(e) => {
1388-
if (!modal || isBelowFade) {
1389-
e.preventDefault();
1390-
return;
1391-
}
1304+
// Never auto-dismiss the sheet on focus shifts. Without Radix's
1305+
// FocusScope trap (we run modal={false}), this fires whenever
1306+
// focus naturally leaves the drawer — e.g. React Navigation's
1307+
// web driver hides the previous screen via display:none, which
1308+
// moves focus off the trigger and dismisses the sheet.
1309+
e.preventDefault();
13921310
}}
13931311
onPointerMove={(event) => {
13941312
lastKnownPointerEventRef.current = event;

src/web/vaul/style.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@
182182
}
183183

184184
@media (pointer: fine) {
185-
[data-vaul-handle-hitarea]: {
185+
[data-vaul-handle-hitarea] {
186186
width: 100%;
187187
height: 100%;
188188
}

0 commit comments

Comments
 (0)