Skip to content

Commit e4e66dc

Browse files
committed
refactor(web): simplify form-sheet clip-path; skip cascade for page child
- 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
1 parent bc0ca47 commit e4e66dc

2 files changed

Lines changed: 46 additions & 48 deletions

File tree

src/TrueSheet.web.tsx

Lines changed: 45 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
115115

116116
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
117117
const isLandscapeOrTablet = windowWidth >= 600 || windowWidth > windowHeight;
118+
const isFormSheet = isLandscapeOrTablet && presentation === 'form';
118119

119120
// presentation='form' implies a floating/detached sheet on web — mirrors iOS
120121
// form-sheet semantics where the sheet is never edge-attached.
@@ -483,7 +484,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
483484
methodsRef,
484485
drawerContentRef,
485486
isOpen,
486-
isLandscapeOrTablet && presentation === 'form'
487+
isFormSheet
487488
);
488489
dismissAboveRef.current = dismissAbove;
489490

@@ -497,14 +498,31 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
497498

498499
const transition = `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
499500
const wrapperTransition = `clip-path ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
501+
const CLIP_NONE = 'inset(0px round 0px)';
502+
503+
// Animate clip-path on the wrapper. Dedupes via DOM read so repeat ticks
504+
// (mutation observer) skip identical writes. Seeds CLIP_NONE before the
505+
// first inset() so the browser interpolates between two inset() shapes —
506+
// `none → inset()` interpolates inconsistently.
507+
const setClip = (next: string) => {
508+
if (!parentWrapper) return;
509+
const current = parentWrapper.style.clipPath;
510+
if (current === next) return;
511+
if (next === CLIP_NONE && !current) return;
512+
if (!current) {
513+
parentWrapper.style.transition = '';
514+
parentWrapper.style.clipPath = CLIP_NONE;
515+
// eslint-disable-next-line no-void
516+
void parentWrapper.offsetHeight;
517+
}
518+
parentWrapper.style.transition = wrapperTransition;
519+
parentWrapper.style.clipPath = next;
520+
};
500521

501522
if (descendants.length === 0) {
502523
parent.style.transition = transition;
503524
parent.style.transform = '';
504-
if (parentWrapper && parentWrapper.style.clipPath) {
505-
parentWrapper.style.transition = wrapperTransition;
506-
parentWrapper.style.clipPath = 'inset(0px round 0px)';
507-
}
525+
setClip(CLIP_NONE);
508526
return;
509527
}
510528

@@ -520,36 +538,22 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
520538
return targetY;
521539
};
522540

523-
// When a form-sheet descendant is present, clip the parent to the form
524-
// card's viewport box so the parts that would peek above/around the card
525-
// are hidden. Form-card box is derived from the child's inline styles
526-
// (vs. getBoundingClientRect) so it's the at-rest box, not skewed by
527-
// vaul's wrapper slide-in. Insets are relative to the parent wrapper's
528-
// rect — the parent wrapper isn't necessarily viewport-sized (e.g., it
529-
// has DEFAULT_MAX_WIDTH on tablet/landscape).
530-
// Clip-path transitions with the same duration/easing as the cascade
531-
// transform so the visible box and the drawer's content slide together;
532-
// otherwise the content animates against a static wrapper boundary.
533-
// We seed clip-path from `inset(0)` (= no clip) before setting the target
534-
// inset so the browser interpolates between two inset() shapes — going
535-
// from `none` directly to inset() is interpolated inconsistently.
541+
// When a form-sheet descendant opens, clip the parent to the child card's
542+
// viewport box so it doesn't peek above/around. Geometry comes from the
543+
// child's inline styles (not getBoundingClientRect) to read the at-rest
544+
// box, unskewed by vaul's slide-in.
536545
const applyFormClip = () => {
537-
if (!parentWrapper) return;
538546
const form = descendants.find((d) => d.isFormSheetRef.current);
539547
if (!form) {
540-
if (parentWrapper.style.clipPath) {
541-
parentWrapper.style.transition = wrapperTransition;
542-
parentWrapper.style.clipPath = 'inset(0px round 0px)';
543-
}
548+
setClip(CLIP_NONE);
544549
return;
545550
}
546551
const childDrawer = form.nodeRef.current;
547552
const childWrapper = childDrawer?.closest<HTMLElement>('[data-vaul-detached-wrapper]');
548-
if (!childDrawer || !childWrapper) return;
553+
if (!parentWrapper || !childDrawer || !childWrapper) return;
549554
const snapY = parseFloat(childDrawer.style.getPropertyValue('--snap-point-height')) || 0;
550555
const childBottomGap = parseFloat(childWrapper.style.bottom) || 0;
551-
const childMaxW =
552-
parseFloat(childWrapper.style.maxWidth) || window.innerWidth;
556+
const childMaxW = parseFloat(childWrapper.style.maxWidth) || window.innerWidth;
553557
const formLeft = (window.innerWidth - childMaxW) / 2;
554558
const formRight = (window.innerWidth + childMaxW) / 2;
555559
const formBottom = window.innerHeight - childBottomGap;
@@ -558,29 +562,27 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
558562
const left = Math.max(0, formLeft - rect.left);
559563
const right = Math.max(0, rect.right - formRight);
560564
const bottom = Math.max(0, rect.bottom - formBottom);
561-
// Seed inset(0 round 0) so the first transition interpolates between
562-
// two inset() shapes with matching `round` values (a missing `round`
563-
// would interpolate inconsistently).
564-
if (!parentWrapper.style.clipPath) {
565-
parentWrapper.style.transition = '';
566-
parentWrapper.style.clipPath = 'inset(0px round 0px)';
567-
// eslint-disable-next-line no-void
568-
void parentWrapper.offsetHeight;
569-
}
570-
parentWrapper.style.transition = wrapperTransition;
571565
const radius = cornerRadius ?? DEFAULT_CORNER_RADIUS;
572-
parentWrapper.style.clipPath = `inset(${top}px ${right}px ${bottom}px ${left}px round ${radius}px)`;
566+
setClip(`inset(${top}px ${right}px ${bottom}px ${left}px round ${radius}px)`);
573567
};
574568

575569
const apply = () => {
570+
applyFormClip();
571+
// Mirror iOS: a page-sheet child fully covers a form-sheet parent, so the
572+
// cascade push-down has no visible effect — and would briefly peek above
573+
// the page during the present animation. Leave the parent put.
574+
const child = descendants[0];
575+
if (isFormSheet && child && !child.isFormSheetRef.current) {
576+
parent.style.transition = transition;
577+
parent.style.transform = '';
578+
return;
579+
}
576580
const targetY = computeTargetY();
577581
const match = parent.style.transform.match(/translate3d\([^,]*,\s*(-?\d*\.?\d+)px/);
578582
const currentY = match ? parseFloat(match[1]!) : 0;
579-
if (Math.abs(currentY - targetY) >= 0.5) {
580-
parent.style.transition = transition;
581-
parent.style.transform = `translate3d(0, ${targetY}px, 0)`;
582-
}
583-
applyFormClip();
583+
if (Math.abs(currentY - targetY) < 0.5) return;
584+
parent.style.transition = transition;
585+
parent.style.transform = `translate3d(0, ${targetY}px, 0)`;
584586
};
585587

586588
const raf = requestAnimationFrame(apply);
@@ -594,7 +596,7 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
594596
cancelAnimationFrame(raf);
595597
observer.disconnect();
596598
};
597-
}, [descendants, activeSnapPoint]);
599+
}, [descendants, activeSnapPoint, cornerRadius, isFormSheet]);
598600

599601
// Focus/blur events fire when a descendant sheet appears on top of this one
600602
// (blur) or when all descendants are dismissed (focus). will-events fire
@@ -723,7 +725,6 @@ const TrueSheetComponent = forwardRef<TrueSheetMethods, TrueSheetProps>((props,
723725
// with a computed offset that centers it vertically. `presentation` is
724726
// absolute: when 'form', `maxContentWidth` is ignored and the card uses
725727
// DEFAULT_FORM_SHEET_WIDTH.
726-
const isFormSheet = isLandscapeOrTablet && presentation === 'form';
727728

728729
// Vaul measures the auto-size wrapper's offsetHeight (always, post fork).
729730
// Track it here so the form sheet can size its card to fit content,

src/TrueSheetProvider.web.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,7 @@ export function useSheetStack(
178178
const isFormSheetRef = useRef(isFormSheet);
179179
isFormSheetRef.current = isFormSheet;
180180

181-
const entry = useMemo<StackEntry>(
182-
() => ({ ref, nodeRef, isFormSheetRef }),
183-
[ref, nodeRef]
184-
);
181+
const entry = useMemo<StackEntry>(() => ({ ref, nodeRef, isFormSheetRef }), [ref, nodeRef]);
185182

186183
useEffect(() => {
187184
if (!ctx || !isOpen) return;

0 commit comments

Comments
 (0)