From bc0ca47589afedceb3ccd1912186c79358ccbd19 Mon Sep 17 00:00:00 2001 From: lodev09 Date: Wed, 20 May 2026 04:52:55 +0800 Subject: [PATCH 1/4] chore: update podfile --- example/bare/ios/Podfile.lock | 4 +- src/TrueSheet.web.tsx | 69 +++++++++++++++++++++++++++++++++-- src/TrueSheetProvider.web.tsx | 16 +++++++- 3 files changed, 81 insertions(+), 8 deletions(-) diff --git a/example/bare/ios/Podfile.lock b/example/bare/ios/Podfile.lock index 841c0228..c354481b 100644 --- a/example/bare/ios/Podfile.lock +++ b/example/bare/ios/Podfile.lock @@ -2777,7 +2777,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNTrueSheet (3.10.0): + - RNTrueSheet (3.11.0-beta.7): - boost - DoubleConversion - fast_float @@ -3248,7 +3248,7 @@ SPEC CHECKSUMS: RNGestureHandler: b407c25a3f51d22dd7430e6b530f753f112b7e77 RNReanimated: 292cd58688552a22b3fc1cefcfbc49b336dfed68 RNScreens: afaf526a9c804c3b4503f950cf3e67ed81e29ada - RNTrueSheet: 819132cbb5713a5794cc0574c4d5fa7859a7c7de + RNTrueSheet: eca911005316e6b28240ea15132c8f768517a0c1 RNWorklets: 01efdd402d236a13651ea5ea5437ca85a44e7afa SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: b669e79fa0f8d3f6f5e35372345f54b99e06b13c diff --git a/src/TrueSheet.web.tsx b/src/TrueSheet.web.tsx index 67107223..59717d17 100644 --- a/src/TrueSheet.web.tsx +++ b/src/TrueSheet.web.tsx @@ -482,7 +482,8 @@ const TrueSheetComponent = forwardRef((props, const { isNested, dismissAbove, descendants } = useSheetStack( methodsRef, drawerContentRef, - isOpen + isOpen, + isLandscapeOrTablet && presentation === 'form' ); dismissAboveRef.current = dismissAbove; @@ -492,12 +493,18 @@ const TrueSheetComponent = forwardRef((props, useEffect(() => { const parent = drawerContentRef.current; if (!parent) return; + const parentWrapper = parent.closest('[data-vaul-detached-wrapper]'); const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`; + const wrapperTransition = `clip-path ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`; if (descendants.length === 0) { parent.style.transition = transition; parent.style.transform = ''; + if (parentWrapper && parentWrapper.style.clipPath) { + parentWrapper.style.transition = wrapperTransition; + parentWrapper.style.clipPath = 'inset(0px round 0px)'; + } return; } @@ -513,13 +520,67 @@ const TrueSheetComponent = forwardRef((props, return targetY; }; + // When a form-sheet descendant is present, clip the parent to the form + // card's viewport box so the parts that would peek above/around the card + // are hidden. Form-card box is derived from the child's inline styles + // (vs. getBoundingClientRect) so it's the at-rest box, not skewed by + // vaul's wrapper slide-in. Insets are relative to the parent wrapper's + // rect — the parent wrapper isn't necessarily viewport-sized (e.g., it + // has DEFAULT_MAX_WIDTH on tablet/landscape). + // Clip-path transitions with the same duration/easing as the cascade + // transform so the visible box and the drawer's content slide together; + // otherwise the content animates against a static wrapper boundary. + // We seed clip-path from `inset(0)` (= no clip) before setting the target + // inset so the browser interpolates between two inset() shapes — going + // from `none` directly to inset() is interpolated inconsistently. + const applyFormClip = () => { + if (!parentWrapper) return; + const form = descendants.find((d) => d.isFormSheetRef.current); + if (!form) { + if (parentWrapper.style.clipPath) { + parentWrapper.style.transition = wrapperTransition; + parentWrapper.style.clipPath = 'inset(0px round 0px)'; + } + return; + } + const childDrawer = form.nodeRef.current; + const childWrapper = childDrawer?.closest('[data-vaul-detached-wrapper]'); + if (!childDrawer || !childWrapper) return; + const snapY = parseFloat(childDrawer.style.getPropertyValue('--snap-point-height')) || 0; + const childBottomGap = parseFloat(childWrapper.style.bottom) || 0; + const childMaxW = + parseFloat(childWrapper.style.maxWidth) || window.innerWidth; + const formLeft = (window.innerWidth - childMaxW) / 2; + const formRight = (window.innerWidth + childMaxW) / 2; + const formBottom = window.innerHeight - childBottomGap; + const rect = parentWrapper.getBoundingClientRect(); + const top = Math.max(0, snapY - rect.top); + const left = Math.max(0, formLeft - rect.left); + const right = Math.max(0, rect.right - formRight); + const bottom = Math.max(0, rect.bottom - formBottom); + // Seed inset(0 round 0) so the first transition interpolates between + // two inset() shapes with matching `round` values (a missing `round` + // would interpolate inconsistently). + if (!parentWrapper.style.clipPath) { + parentWrapper.style.transition = ''; + parentWrapper.style.clipPath = 'inset(0px round 0px)'; + // eslint-disable-next-line no-void + void parentWrapper.offsetHeight; + } + parentWrapper.style.transition = wrapperTransition; + const radius = cornerRadius ?? DEFAULT_CORNER_RADIUS; + parentWrapper.style.clipPath = `inset(${top}px ${right}px ${bottom}px ${left}px round ${radius}px)`; + }; + const apply = () => { const targetY = computeTargetY(); const match = parent.style.transform.match(/translate3d\([^,]*,\s*(-?\d*\.?\d+)px/); const currentY = match ? parseFloat(match[1]!) : 0; - if (Math.abs(currentY - targetY) < 0.5) return; - parent.style.transition = transition; - parent.style.transform = `translate3d(0, ${targetY}px, 0)`; + if (Math.abs(currentY - targetY) >= 0.5) { + parent.style.transition = transition; + parent.style.transform = `translate3d(0, ${targetY}px, 0)`; + } + applyFormClip(); }; const raf = requestAnimationFrame(apply); diff --git a/src/TrueSheetProvider.web.tsx b/src/TrueSheetProvider.web.tsx index ccb7c0c0..7c74ed42 100644 --- a/src/TrueSheetProvider.web.tsx +++ b/src/TrueSheetProvider.web.tsx @@ -19,6 +19,7 @@ type NodeRef = RefObject; interface StackEntry { ref: SheetRef; nodeRef: NodeRef; + isFormSheetRef: RefObject; } interface SheetContextValue { @@ -166,10 +167,21 @@ export function useRegisterSheet(name: string | undefined, ref: SheetRef): void * Registers the sheet in the open stack while `isOpen` is true and returns * live data used by each sheet to render stacked visuals and dismiss children. */ -export function useSheetStack(ref: SheetRef, nodeRef: NodeRef, isOpen: boolean) { +export function useSheetStack( + ref: SheetRef, + nodeRef: NodeRef, + isOpen: boolean, + isFormSheet: boolean +) { const ctx = useContext(SheetContext); - const entry = useMemo(() => ({ ref, nodeRef }), [ref, nodeRef]); + const isFormSheetRef = useRef(isFormSheet); + isFormSheetRef.current = isFormSheet; + + const entry = useMemo( + () => ({ ref, nodeRef, isFormSheetRef }), + [ref, nodeRef] + ); useEffect(() => { if (!ctx || !isOpen) return; From e4e66dcdc03d90a545f921d844db09b54f25e846 Mon Sep 17 00:00:00 2001 From: lodev09 Date: Wed, 20 May 2026 05:17:30 +0800 Subject: [PATCH 2/4] refactor(web): simplify form-sheet clip-path; skip cascade for page child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract setClip helper to unify CLIP_NONE reset and dedupe writes - Add cornerRadius to cascade effect deps (was stale closure) - Mirror iOS: form parent with page child skips cascade transform — the page fully covers the form, so the push-down would only peek mid-animation --- src/TrueSheet.web.tsx | 89 ++++++++++++++++++----------------- src/TrueSheetProvider.web.tsx | 5 +- 2 files changed, 46 insertions(+), 48 deletions(-) diff --git a/src/TrueSheet.web.tsx b/src/TrueSheet.web.tsx index 59717d17..3685a757 100644 --- a/src/TrueSheet.web.tsx +++ b/src/TrueSheet.web.tsx @@ -115,6 +115,7 @@ const TrueSheetComponent = forwardRef((props, const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const isLandscapeOrTablet = windowWidth >= 600 || windowWidth > windowHeight; + const isFormSheet = isLandscapeOrTablet && presentation === 'form'; // presentation='form' implies a floating/detached sheet on web — mirrors iOS // form-sheet semantics where the sheet is never edge-attached. @@ -483,7 +484,7 @@ const TrueSheetComponent = forwardRef((props, methodsRef, drawerContentRef, isOpen, - isLandscapeOrTablet && presentation === 'form' + isFormSheet ); dismissAboveRef.current = dismissAbove; @@ -497,14 +498,31 @@ const TrueSheetComponent = forwardRef((props, const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`; const wrapperTransition = `clip-path ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`; + const CLIP_NONE = 'inset(0px round 0px)'; + + // Animate clip-path on the wrapper. Dedupes via DOM read so repeat ticks + // (mutation observer) skip identical writes. Seeds CLIP_NONE before the + // first inset() so the browser interpolates between two inset() shapes — + // `none → inset()` interpolates inconsistently. + const setClip = (next: string) => { + if (!parentWrapper) return; + const current = parentWrapper.style.clipPath; + if (current === next) return; + if (next === CLIP_NONE && !current) return; + if (!current) { + parentWrapper.style.transition = ''; + parentWrapper.style.clipPath = CLIP_NONE; + // eslint-disable-next-line no-void + void parentWrapper.offsetHeight; + } + parentWrapper.style.transition = wrapperTransition; + parentWrapper.style.clipPath = next; + }; if (descendants.length === 0) { parent.style.transition = transition; parent.style.transform = ''; - if (parentWrapper && parentWrapper.style.clipPath) { - parentWrapper.style.transition = wrapperTransition; - parentWrapper.style.clipPath = 'inset(0px round 0px)'; - } + setClip(CLIP_NONE); return; } @@ -520,36 +538,22 @@ const TrueSheetComponent = forwardRef((props, return targetY; }; - // When a form-sheet descendant is present, clip the parent to the form - // card's viewport box so the parts that would peek above/around the card - // are hidden. Form-card box is derived from the child's inline styles - // (vs. getBoundingClientRect) so it's the at-rest box, not skewed by - // vaul's wrapper slide-in. Insets are relative to the parent wrapper's - // rect — the parent wrapper isn't necessarily viewport-sized (e.g., it - // has DEFAULT_MAX_WIDTH on tablet/landscape). - // Clip-path transitions with the same duration/easing as the cascade - // transform so the visible box and the drawer's content slide together; - // otherwise the content animates against a static wrapper boundary. - // We seed clip-path from `inset(0)` (= no clip) before setting the target - // inset so the browser interpolates between two inset() shapes — going - // from `none` directly to inset() is interpolated inconsistently. + // When a form-sheet descendant opens, clip the parent to the child card's + // viewport box so it doesn't peek above/around. Geometry comes from the + // child's inline styles (not getBoundingClientRect) to read the at-rest + // box, unskewed by vaul's slide-in. const applyFormClip = () => { - if (!parentWrapper) return; const form = descendants.find((d) => d.isFormSheetRef.current); if (!form) { - if (parentWrapper.style.clipPath) { - parentWrapper.style.transition = wrapperTransition; - parentWrapper.style.clipPath = 'inset(0px round 0px)'; - } + setClip(CLIP_NONE); return; } const childDrawer = form.nodeRef.current; const childWrapper = childDrawer?.closest('[data-vaul-detached-wrapper]'); - if (!childDrawer || !childWrapper) return; + if (!parentWrapper || !childDrawer || !childWrapper) return; const snapY = parseFloat(childDrawer.style.getPropertyValue('--snap-point-height')) || 0; const childBottomGap = parseFloat(childWrapper.style.bottom) || 0; - const childMaxW = - parseFloat(childWrapper.style.maxWidth) || window.innerWidth; + const childMaxW = parseFloat(childWrapper.style.maxWidth) || window.innerWidth; const formLeft = (window.innerWidth - childMaxW) / 2; const formRight = (window.innerWidth + childMaxW) / 2; const formBottom = window.innerHeight - childBottomGap; @@ -558,29 +562,27 @@ const TrueSheetComponent = forwardRef((props, const left = Math.max(0, formLeft - rect.left); const right = Math.max(0, rect.right - formRight); const bottom = Math.max(0, rect.bottom - formBottom); - // Seed inset(0 round 0) so the first transition interpolates between - // two inset() shapes with matching `round` values (a missing `round` - // would interpolate inconsistently). - if (!parentWrapper.style.clipPath) { - parentWrapper.style.transition = ''; - parentWrapper.style.clipPath = 'inset(0px round 0px)'; - // eslint-disable-next-line no-void - void parentWrapper.offsetHeight; - } - parentWrapper.style.transition = wrapperTransition; const radius = cornerRadius ?? DEFAULT_CORNER_RADIUS; - parentWrapper.style.clipPath = `inset(${top}px ${right}px ${bottom}px ${left}px round ${radius}px)`; + setClip(`inset(${top}px ${right}px ${bottom}px ${left}px round ${radius}px)`); }; const apply = () => { + applyFormClip(); + // Mirror iOS: a page-sheet child fully covers a form-sheet parent, so the + // cascade push-down has no visible effect — and would briefly peek above + // the page during the present animation. Leave the parent put. + const child = descendants[0]; + if (isFormSheet && child && !child.isFormSheetRef.current) { + parent.style.transition = transition; + parent.style.transform = ''; + return; + } const targetY = computeTargetY(); const match = parent.style.transform.match(/translate3d\([^,]*,\s*(-?\d*\.?\d+)px/); const currentY = match ? parseFloat(match[1]!) : 0; - if (Math.abs(currentY - targetY) >= 0.5) { - parent.style.transition = transition; - parent.style.transform = `translate3d(0, ${targetY}px, 0)`; - } - applyFormClip(); + if (Math.abs(currentY - targetY) < 0.5) return; + parent.style.transition = transition; + parent.style.transform = `translate3d(0, ${targetY}px, 0)`; }; const raf = requestAnimationFrame(apply); @@ -594,7 +596,7 @@ const TrueSheetComponent = forwardRef((props, cancelAnimationFrame(raf); observer.disconnect(); }; - }, [descendants, activeSnapPoint]); + }, [descendants, activeSnapPoint, cornerRadius, isFormSheet]); // Focus/blur events fire when a descendant sheet appears on top of this one // (blur) or when all descendants are dismissed (focus). will-events fire @@ -723,7 +725,6 @@ const TrueSheetComponent = forwardRef((props, // with a computed offset that centers it vertically. `presentation` is // absolute: when 'form', `maxContentWidth` is ignored and the card uses // DEFAULT_FORM_SHEET_WIDTH. - const isFormSheet = isLandscapeOrTablet && presentation === 'form'; // Vaul measures the auto-size wrapper's offsetHeight (always, post fork). // Track it here so the form sheet can size its card to fit content, diff --git a/src/TrueSheetProvider.web.tsx b/src/TrueSheetProvider.web.tsx index 7c74ed42..75fd37d7 100644 --- a/src/TrueSheetProvider.web.tsx +++ b/src/TrueSheetProvider.web.tsx @@ -178,10 +178,7 @@ export function useSheetStack( const isFormSheetRef = useRef(isFormSheet); isFormSheetRef.current = isFormSheet; - const entry = useMemo( - () => ({ ref, nodeRef, isFormSheetRef }), - [ref, nodeRef] - ); + const entry = useMemo(() => ({ ref, nodeRef, isFormSheetRef }), [ref, nodeRef]); useEffect(() => { if (!ctx || !isOpen) return; From c2a81abde6a37c7a8a16301d7e2756b58eb74889 Mon Sep 17 00:00:00 2001 From: lodev09 Date: Wed, 20 May 2026 05:22:25 +0800 Subject: [PATCH 3/4] fix(web): cascade target tracks immediate child only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walking all descendants pushed an ancestor further when a grandchild opened — even when the immediate child stayed put (e.g., child skipped its cascade for a page grandchild), leaving a visible gap. --- src/TrueSheet.web.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/TrueSheet.web.tsx b/src/TrueSheet.web.tsx index 3685a757..7a23ad5e 100644 --- a/src/TrueSheet.web.tsx +++ b/src/TrueSheet.web.tsx @@ -526,16 +526,16 @@ const TrueSheetComponent = forwardRef((props, return; } + // Track only the immediate child's snap point. Walking deeper descendants + // would push this sheet further when a grandchild opens, even when our + // own child didn't move (e.g., child skipped its cascade for a page + // grandchild) — leaving a visible gap between this sheet and its child. const computeTargetY = () => { const parentSnap = parseFloat(parent.style.getPropertyValue('--snap-point-height')) || 0; - let targetY = parentSnap; - for (const d of descendants) { - const node = d.nodeRef.current; - if (!node) continue; - const snap = parseFloat(node.style.getPropertyValue('--snap-point-height')) || 0; - if (snap > targetY) targetY = snap; - } - return targetY; + const node = descendants[0]?.nodeRef.current; + if (!node) return parentSnap; + const childSnap = parseFloat(node.style.getPropertyValue('--snap-point-height')) || 0; + return Math.max(parentSnap, childSnap); }; // When a form-sheet descendant opens, clip the parent to the child card's From a7f23a9c2bd2e3ba02faabf7cec4bcf1126a7ba8 Mon Sep 17 00:00:00 2001 From: lodev09 Date: Wed, 20 May 2026 05:25:38 +0800 Subject: [PATCH 4/4] docs: changelog for #687 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8092b285..ae854656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - **Web**: Fixed detached/form sheet autopresent animation, scrollable content, footer click, and grabber drag interactions. ([#684](https://github.com/lodev09/react-native-true-sheet/pull/684) by [@lodev09](https://github.com/lodev09)) +- **Web**: Clip parent to form-card box when child is a form sheet, skip cascade when form parent has page child, and track only immediate child for cascade target. ([#687](https://github.com/lodev09/react-native-true-sheet/pull/687) by [@lodev09](https://github.com/lodev09)) + - **iOS**: Fixed Mac Catalyst build issue. ([#685](https://github.com/lodev09/react-native-true-sheet/pull/685) by [@theeket](https://github.com/theeket)) ### ⚠️ Breaking