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 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..7a23ad5e 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. @@ -482,7 +483,8 @@ const TrueSheetComponent = forwardRef((props, const { isNested, dismissAbove, descendants } = useSheetStack( methodsRef, drawerContentRef, - isOpen + isOpen, + isFormSheet ); dismissAboveRef.current = dismissAbove; @@ -492,28 +494,89 @@ 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(',')})`; + 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 = ''; + setClip(CLIP_NONE); 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; + 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 + // 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 = () => { + const form = descendants.find((d) => d.isFormSheetRef.current); + if (!form) { + setClip(CLIP_NONE); + return; } - return targetY; + const childDrawer = form.nodeRef.current; + const childWrapper = childDrawer?.closest('[data-vaul-detached-wrapper]'); + 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 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); + const radius = cornerRadius ?? DEFAULT_CORNER_RADIUS; + 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; @@ -533,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 @@ -662,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 ccb7c0c0..75fd37d7 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,18 @@ 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;