/// import { createElement, forwardRef, isValidElement, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { View, useColorScheme, useWindowDimensions } from 'react-native'; import { Drawer } from './web/vaul'; import { TRANSITIONS } from './web/vaul/constants'; import type { DetentChangeEvent, DetentInfoEventPayload, DidBlurEvent, DidDismissEvent, DidFocusEvent, DidPresentEvent, DragBeginEvent, DragChangeEvent, DragEndEvent, MountEvent, PositionChangeEvent, SheetDetent, TrueSheetMethods, TrueSheetProps, TrueSheetStaticMethods, WillBlurEvent, WillDismissEvent, WillFocusEvent, WillPresentEvent, } from './TrueSheet.types'; import { usePortalContainer, useRegisterSheet, useSheetStack } from './TrueSheetProvider.web'; import { COLOR_SURFACE_CONTAINER_LOW_DARK, COLOR_SURFACE_CONTAINER_LOW_LIGHT, DEFAULT_ANCHOR_OFFSET, DEFAULT_CORNER_RADIUS, DEFAULT_DETACHED_OFFSET, DEFAULT_GRABBER_COLOR_DARK, DEFAULT_GRABBER_COLOR_LIGHT, DEFAULT_GRABBER_HEIGHT, DEFAULT_GRABBER_TOP_MARGIN, DEFAULT_GRABBER_WIDTH, DEFAULT_FORM_SHEET_HEIGHT_RATIO, DEFAULT_FORM_SHEET_WIDTH, DEFAULT_MAX_WIDTH, } from './web/constants'; const TrueSheetComponent = forwardRef((props, ref) => { const { children, name, dismissible = true, draggable = true, cornerRadius, style, backgroundColor: backgroundColorProp, maxContentHeight, maxContentWidth, anchor = 'center', anchorOffset = DEFAULT_ANCHOR_OFFSET, grabber = true, grabberOptions, detents = [0.5, 1], dimmed = true, dimmedDetentIndex = 0, initialDetentIndex = -1, header, headerStyle, footer, footerStyle, scrollable = false, presentation = 'page', detached = false, detachedOffset = DEFAULT_DETACHED_OFFSET, elevation = 4, insetAdjustment = 'automatic', initialDetentAnimated = true, onPositionChange, onWillPresent, onDidPresent, onWillDismiss, onDidDismiss, onDetentChange, onDragBegin, onDragChange, onDragEnd, onMount, onWillFocus, onDidFocus, onWillBlur, onDidBlur, } = props; const validDetents = useMemo( () => detents.filter((d): d is SheetDetent => typeof d === 'number' || d === 'auto'), [detents] ); const snapPointsProps = useMemo< { snapPoints: SheetDetent[]; fadeFromIndex: number } | { snapPoints?: undefined } >(() => { if (validDetents.length === 0) return {}; return { snapPoints: validDetents, fadeFromIndex: Math.min(dimmedDetentIndex, validDetents.length - 1), }; }, [validDetents, dimmedDetentIndex]); 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. const effectiveDetached = presentation === 'form' || detached; const colorScheme = useColorScheme(); const backgroundColor = backgroundColorProp ?? (colorScheme === 'dark' ? COLOR_SURFACE_CONTAINER_LOW_DARK : COLOR_SURFACE_CONTAINER_LOW_LIGHT); const shouldAutoPresent = initialDetentIndex >= 0 && initialDetentIndex < validDetents.length; const [isOpen, setIsOpen] = useState(shouldAutoPresent); const [activeSnapPoint, setActiveSnapPoint] = useState( () => validDetents[shouldAutoPresent ? initialDetentIndex : 0] ?? null ); // Keep activeSnapPoint valid if detents change (e.g., prop updates). useEffect(() => { if (validDetents.length === 0) return; setActiveSnapPoint((current) => current != null && validDetents.includes(current) ? current : validDetents[0]! ); }, [validDetents]); const validDetentsRef = useRef(validDetents); validDetentsRef.current = validDetents; const handleSetActiveSnapPoint = useCallback((snapPoint: number | string | null) => { setActiveSnapPoint( snapPoint == null ? null : typeof snapPoint === 'number' || snapPoint === 'auto' ? (snapPoint as SheetDetent) : null ); }, []); const handleOpenChange = useCallback( (open: boolean) => { if (!open && isOpen) { setIsOpen(false); } }, [isOpen] ); const portalContainer = usePortalContainer(); const handlePointerDownOutside = (e: Event) => { const target = e.target; if (!(target instanceof Node)) return; // Pointer down that landed outside this sheet's portal container (e.g., // in another screen's tree when navigating) should not close the drawer. if (portalContainer && !portalContainer.contains(target)) { e.preventDefault(); return; } // The footer is rendered via vaul's `detachedSiblings` as a sibling of // Drawer.Content inside [data-vaul-detached-wrapper], so Radix treats // clicks on it as "outside" the content. Don't dismiss for clicks that // landed inside the wrapper. if (target instanceof Element) { const wrapper = drawerContentRef.current?.closest('[data-vaul-detached-wrapper]'); if (wrapper && wrapper.contains(target)) { e.preventDefault(); } } }; const dismissAboveRef = useRef<(animated?: boolean) => Promise>(async () => {}); const methods = useMemo( () => ({ present: async (index = 0) => { const detent = validDetentsRef.current[index]; if (detent === undefined) { throw new Error( `TrueSheet: present index (${index}) is out of bounds. detents array has ${validDetentsRef.current.length} item(s)` ); } setActiveSnapPoint(detent); setIsOpen(true); }, dismiss: async () => { setIsOpen(false); }, resize: async (index) => { const detent = validDetentsRef.current[index]; if (detent === undefined) { throw new Error( `TrueSheet: resize index (${index}) is out of bounds. detents array has ${validDetentsRef.current.length} item(s)` ); } setActiveSnapPoint(detent); }, dismissStack: async (animated) => { await dismissAboveRef.current(animated); }, }), [] ); useImperativeHandle(ref, () => methods, [methods]); const methodsRef = useRef(methods); useRegisterSheet(name, methodsRef); const drawerContentRef = useRef(null); // Present/dismiss events. The sheet settles via a CSS `transform` transition // on either the drawer (snap-points on autopresent) or the wrapper (whole- // card slide on reopen/dismiss). `Animation.finished` from the Web Animations // API tracks whichever is actually running — reflects what the browser is // doing, doesn't miss when no transition runs (same-value change), and // handles interruptions correctly (a drag/resnap mid-present resolves only // once all transform animations drain). const onWillPresentRef = useRef(onWillPresent); const onDidPresentRef = useRef(onDidPresent); const onWillDismissRef = useRef(onWillDismiss); const onDidDismissRef = useRef(onDidDismiss); const onDetentChangeRef = useRef(onDetentChange); const onDragBeginRef = useRef(onDragBegin); const onDragChangeRef = useRef(onDragChange); const onDragEndRef = useRef(onDragEnd); const onPositionChangeRef = useRef(onPositionChange); const activeSnapPointRef = useRef(activeSnapPoint); useEffect(() => { onWillPresentRef.current = onWillPresent; onDidPresentRef.current = onDidPresent; onWillDismissRef.current = onWillDismiss; onDidDismissRef.current = onDidDismiss; onDetentChangeRef.current = onDetentChange; onDragBeginRef.current = onDragBegin; onDragChangeRef.current = onDragChange; onDragEndRef.current = onDragEnd; onPositionChangeRef.current = onPositionChange; activeSnapPointRef.current = activeSnapPoint; }); const computeDetentInfo = useCallback((): DetentInfoEventPayload => { const snap = activeSnapPointRef.current; const index = snap != null ? validDetentsRef.current.indexOf(snap) : -1; const position = drawerContentRef.current?.getBoundingClientRect().top ?? 0; const detent = typeof snap === 'number' ? snap : 0; return { index, position, detent }; }, []); // Mirror Android: interpolate fractional index and detent from the drawer's // top-Y so continuous position updates (drag, animation) carry smooth values // between detent boundaries. Numeric detent d → top-Y = (1 - d) * effectiveH. // 'auto' resolves to the [data-vaul-auto-size-wrapper] element's measured // offsetHeight — same signal vaul uses to compute its snap offset. const interpolateFromPosition = useCallback( (position: number): { index: number; detent: number } => { const snaps = validDetentsRef.current; const count = snaps.length; if (count === 0) return { index: -1, detent: 0 }; const windowH = window.innerHeight; const effectiveH = effectiveDetached ? windowH - detachedOffset : windowH; // Matches vaul's height ceiling: min(effectiveH, maxContentHeight). const ceiling = maxContentHeight !== undefined ? Math.min(effectiveH, maxContentHeight) : effectiveH; const autoWrapper = drawerContentRef.current?.querySelector( '[data-vaul-auto-size-wrapper]' ); const autoHeight = Math.min(autoWrapper?.offsetHeight ?? ceiling / 2, ceiling); const positions: number[] = []; const values: number[] = []; for (let i = 0; i < count; i++) { const d = snaps[i]; if (typeof d === 'number') { const h = Math.min(d * effectiveH, ceiling); positions.push(effectiveH - h); values.push(effectiveH > 0 ? h / effectiveH : 0); } else { positions.push(effectiveH - autoHeight); values.push(effectiveH > 0 ? autoHeight / effectiveH : 0); } } // Absorb subpixel drift from getBoundingClientRect so at-rest positions // don't sneak into the below-first branch and emit near-zero negatives // like `-1e-8` (which render as "-1" via JS scientific-notation toString). const epsilon = 0.5; const firstPos = positions[0]!; const lastPos = positions[count - 1]!; if (position > firstPos + epsilon) { // Two ranges: index spans the full animation (windowH of wrapper // travel) so it's smooth for driving dependent animations end-to-end; // detent tracks the sheet's visible-height ratio (windowH - firstPos) // so its 0–values[0] fade has fine resolution while the sheet is still // in view. Both clamp to keep outputs in [-1, 0]. const indexRaw = (position - firstPos) / windowH; const detentRaw = (position - firstPos) / Math.max(1, windowH - firstPos); const indexProgress = Math.max(0, Math.min(1, indexRaw)); const detentProgress = Math.max(0, Math.min(1, detentRaw)); return { index: -indexProgress, detent: Math.max(0, values[0]! * (1 - detentProgress)), }; } if (count === 1) return { index: 0, detent: values[0]! }; // Clamp into the segment range so subpixel drift at the boundaries // resolves cleanly to the nearest segment edge (index 0 or count-1). const clamped = Math.max(lastPos, Math.min(firstPos, position)); for (let i = 0; i < count - 1; i++) { const pos = positions[i]!; const nextPos = positions[i + 1]!; if (clamped >= nextPos && clamped <= pos) { const range = pos - nextPos; const progress = range > 0 ? (pos - clamped) / range : 0; const clampedProgress = Math.max(0, Math.min(1, progress)); return { index: i + clampedProgress, detent: values[i]! + clampedProgress * (values[i + 1]! - values[i]!), }; } } return { index: count - 1, detent: values[count - 1]! }; }, [effectiveDetached, detachedOffset, maxContentHeight] ); const handlePositionChange = useCallback( (position: number) => { const { index, detent } = interpolateFromPosition(position); onPositionChangeRef.current?.({ nativeEvent: { index, position, detent, realtime: true }, } as PositionChangeEvent); }, [interpolateFromPosition] ); // Fire onMount once after first render. React-mount is the earliest point // the component is ready for imperative calls, matching the native // "ready for present" contract. Declared before the present/dismiss effect // so it fires first during autopresent (onMount → onWillPresent). const onMountRef = useRef(onMount); useEffect(() => { onMountRef.current = onMount; }); useEffect(() => { onMountRef.current?.({ nativeEvent: null } as MountEvent); }, []); // Start at `false` so a mount with `isOpen=true` (autopresent via // `initialDetentIndex`) is detected as a false→true transition and fires // `onWillPresent`. const wasOpenRef = useRef(false); useEffect(() => { const wasOpen = wasOpenRef.current; wasOpenRef.current = isOpen; if (!isOpen && !wasOpen) return undefined; const present = !wasOpen && isOpen; if (present) { // Pair willFocus with willPresent on initial present — mirrors native // iOS where viewWillAppear dispatches both. The descendant-stack focus // effect handles subsequent gained/lost transitions. onWillPresentRef.current?.({ nativeEvent: computeDetentInfo() } as WillPresentEvent); onWillFocusRef.current?.({ nativeEvent: null } as WillFocusEvent); } else if (wasOpen && !isOpen) { // Pair willBlur with willDismiss on dismiss — mirrors native iOS // emitWillDismissEvents (blur fires before dismiss). onWillBlurRef.current?.({ nativeEvent: null } as WillBlurEvent); onWillDismissRef.current?.({ nativeEvent: null } as WillDismissEvent); } else { return undefined; } const fireDone = () => { if (present) { onDidPresentRef.current?.({ nativeEvent: computeDetentInfo() } as DidPresentEvent); onDidFocusRef.current?.({ nativeEvent: null } as DidFocusEvent); } else { onDidBlurRef.current?.({ nativeEvent: null } as DidBlurEvent); onDidDismissRef.current?.({ nativeEvent: null } as DidDismissEvent); } }; let canceled = false; let rafId = 0; const start = () => { if (canceled) return; const drawer = drawerContentRef.current; if (!drawer) { // Drawer hasn't mounted yet (Radix Presence defers the portal mount // past the first effect pass). Poll until the ref is populated. rafId = window.requestAnimationFrame(start); return; } const wrapper = drawer.closest('[data-vaul-detached-wrapper]') ?? null; const targets = wrapper ? [drawer, wrapper] : [drawer]; const waitForSettle = (): void => { if (canceled) return; // Force style recalc so transitions queued by vaul's effects this // commit are registered in `getAnimations()`. RAF callbacks run BEFORE // the frame's style recalc, and ignoring this returns a stale empty // list — we'd fire `did` immediately with nothing queued. drawer.offsetHeight; const pending = targets.flatMap((el) => el.getAnimations().filter((a) => a.playState !== 'finished') ); if (pending.length === 0) { fireDone(); return; } // allSettled: resolve even when a transition is canceled (drag / // resnap), then re-check — a replacement transition may have started. Promise.allSettled(pending.map((a) => a.finished)).then(() => { if (!canceled) waitForSettle(); }); }; waitForSettle(); }; rafId = window.requestAnimationFrame(start); return () => { canceled = true; window.cancelAnimationFrame(rafId); }; }, [isOpen, computeDetentInfo]); // Fire onDetentChange only while open→open. Present/dismiss have their own // events and carry detent info via onDidPresent, so we skip those edges. const detentChangeStateRef = useRef({ isOpen, activeSnapPoint }); useEffect(() => { const prev = detentChangeStateRef.current; detentChangeStateRef.current = { isOpen, activeSnapPoint }; if (!prev.isOpen || !isOpen) return; if (prev.activeSnapPoint === activeSnapPoint) return; onDetentChangeRef.current?.({ nativeEvent: computeDetentInfo() } as DetentChangeEvent); }, [isOpen, activeSnapPoint, computeDetentInfo]); // Vaul's `onDrag` fires once per pointermove while dragging; the first tick // after an idle gap marks the drag boundary, so track it via a ref. const isDraggingRef = useRef(false); const handleDrag = useCallback(() => { if (!isDraggingRef.current) { isDraggingRef.current = true; onDragBeginRef.current?.({ nativeEvent: computeDetentInfo() } as DragBeginEvent); } onDragChangeRef.current?.({ nativeEvent: computeDetentInfo() } as DragChangeEvent); }, [computeDetentInfo]); const handleRelease = useCallback(() => { if (!isDraggingRef.current) return; isDraggingRef.current = false; onDragEndRef.current?.({ nativeEvent: computeDetentInfo() } as DragEndEvent); }, [computeDetentInfo]); const { isNested, dismissAbove, descendants } = useSheetStack( methodsRef, drawerContentRef, isOpen, isFormSheet ); dismissAboveRef.current = dismissAbove; // Mirror Android: translate this sheet down to match the deepest descendant's // top so the whole stack visually aligns. Cascades because every ancestor // re-runs whenever the stack (and thus its descendants) changes. useEffect(() => { const parent = drawerContentRef.current; if (!parent) return; // Skip while dismissing: this sheet's stack pop changes `descendants`, // which would re-fire the effect and write `wrapper.style.transition = // 'clip-path …'`, clobbering vaul's just-written `'transform …'` for the // dismiss animation. Vaul fully owns this sheet's transitions on the way // out — nothing to align with anymore. if (!isOpen) 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; 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 parent has a form-sheet descendant, clip the parent to // the child card's viewport box so it doesn't peek above/around. Only // applies when this sheet is itself form — a page parent should remain // visible behind/around a floating form child. 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 = isFormSheet ? descendants.find((d) => d.isFormSheetRef.current) : undefined; if (!form) { setClip(CLIP_NONE); return; } 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; if (Math.abs(currentY - targetY) < 0.5) return; parent.style.transition = transition; parent.style.transform = `translate3d(0, ${targetY}px, 0)`; }; const raf = requestAnimationFrame(apply); // Vaul re-runs snapToPoint on window resize (e.g., mobile keyboard open) // which clobbers the cascade transform. Re-apply whenever the parent's // inline style changes. const observer = new MutationObserver(apply); observer.observe(parent, { attributes: true, attributeFilter: ['style'] }); return () => { cancelAnimationFrame(raf); observer.disconnect(); }; }, [descendants, activeSnapPoint, cornerRadius, isFormSheet, isOpen]); // 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 // synchronously at the transition boundary; did-events fire once the cascade // transform drains. Intermediate count changes (1↔2) don't re-fire — this // sheet stays blurred throughout. const onWillBlurRef = useRef(onWillBlur); const onDidBlurRef = useRef(onDidBlur); const onWillFocusRef = useRef(onWillFocus); const onDidFocusRef = useRef(onDidFocus); useEffect(() => { onWillBlurRef.current = onWillBlur; onDidBlurRef.current = onDidBlur; onWillFocusRef.current = onWillFocus; onDidFocusRef.current = onDidFocus; }); const prevDescendantCountRef = useRef(0); useEffect(() => { const prevCount = prevDescendantCountRef.current; const count = descendants.length; prevDescendantCountRef.current = count; if (!isOpen) return; const gained = count > 0 && prevCount === 0; const lost = count === 0 && prevCount > 0; if (!gained && !lost) return; if (gained) { onWillBlurRef.current?.({ nativeEvent: null } as WillBlurEvent); } else { onWillFocusRef.current?.({ nativeEvent: null } as WillFocusEvent); } const drawer = drawerContentRef.current; if (!drawer) return; let canceled = false; const fireDone = () => { if (canceled) return; if (gained) onDidBlurRef.current?.({ nativeEvent: null } as DidBlurEvent); else onDidFocusRef.current?.({ nativeEvent: null } as DidFocusEvent); }; const rafId = window.requestAnimationFrame(() => { if (canceled) return; // Force style recalc so the cascade effect's queued transform registers. drawer.offsetHeight; const pending = drawer.getAnimations().filter((a) => a.playState !== 'finished'); if (pending.length === 0) { fireDone(); return; } Promise.allSettled(pending.map((a) => a.finished)).then(() => { if (!canceled) fireDone(); }); }); return () => { canceled = true; window.cancelAnimationFrame(rafId); }; }, [isOpen, descendants.length]); const effectiveCornerRadius = cornerRadius ?? DEFAULT_CORNER_RADIUS; // Shadow cast upward from the sheet's top edge toward the background. Matches // Android's `elevation` semantics roughly — the sheet "lifts" off whatever is // behind it. Scales linearly so higher elevation reads as more separation. // Applied to the vaul wrapper (not the drawer) as `filter: drop-shadow`: the // wrapper clips the drawer (overflow: hidden + contain: paint), which would // cut off `box-shadow` on the drawer at the wrapper edges — visible in // detached mode (bottom blur clipped in the floating gap) and when the // wrapper is narrowed by maxWidth/anchor margins (lateral blur clipped at // wrapper edges). drop-shadow on the wrapper follows the post-clip silhouette // and isn't clipped by the wrapper itself. const dropShadow = elevation > 0 ? `drop-shadow(0 ${-elevation}px ${elevation * 3}px rgba(0, 0, 0, 0.15))` : undefined; const mergedContentStyle = useMemo( () => ({ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', flexDirection: 'column', borderTopLeftRadius: effectiveCornerRadius, borderTopRightRadius: effectiveCornerRadius, backgroundColor: backgroundColor as string, // Clip children to the rounded top so headers/content with their own // background don't bleed past the corners. overflow: 'hidden', // Lift content above iOS home indicator / bottom safe area when enabled. paddingBottom: insetAdjustment === 'automatic' ? 'env(safe-area-inset-bottom, 0px)' : 0, }), [backgroundColor, effectiveCornerRadius, insetAdjustment] ); const defaultGrabberColor = colorScheme === 'dark' ? DEFAULT_GRABBER_COLOR_DARK : DEFAULT_GRABBER_COLOR_LIGHT; const grabberHeight = grabberOptions?.height ?? DEFAULT_GRABBER_HEIGHT; // Footer is rendered inside the wrapper via `detachedSiblings`, so it // follows the wrapper on dismiss and drag-overshoot. Positioning is // relative to the wrapper (contain: paint creates the containing block). const footerFloatStyle = useMemo( () => ({ position: 'fixed', left: 0, right: 0, bottom: 0, // Wrapper has `pointer-events: none` to let clicks fall through; the // footer must opt back in. pointerEvents: 'auto', }), [] ); // Form-sheet style (presentation='form'): centered floating card with a // default width and a height fit to content. We reuse the existing detached // mechanic so drag/snap math stays correct — the wrapper is bottom-attached // with a computed offset that centers it vertically. `presentation` is // absolute: when 'form', `maxContentWidth` is ignored and the card uses // DEFAULT_FORM_SHEET_WIDTH. // 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, // clamped between a minimum ratio of the viewport and a maximum derived // from `detachedOffset` (the breathing room left at top + bottom of the // floating card). const [measuredContentHeight, setMeasuredContentHeight] = useState(0); const effectiveMaxContentHeight = useMemo(() => { if (maxContentHeight !== undefined) return maxContentHeight; if (!isFormSheet) return undefined; const min = windowHeight * DEFAULT_FORM_SHEET_HEIGHT_RATIO; const max = Math.max(min, windowHeight - 2 * detachedOffset); if (measuredContentHeight <= 0) return min; return Math.max(min, Math.min(measuredContentHeight, max)); }, [maxContentHeight, isFormSheet, windowHeight, detachedOffset, measuredContentHeight]); // Center the form sheet using the actual visible drawer height. Vaul // auto-sizes to content (capped by `maxContentHeight`), so when content is // shorter than `effectiveMaxContentHeight`'s min-clamped floor, using that // for the offset would push a small sheet below the viewport center. const effectiveDetachedOffset = useMemo(() => { if (!isFormSheet) return detachedOffset; const max = Math.max(0, windowHeight - 2 * detachedOffset); const visibleHeight = measuredContentHeight > 0 ? Math.min(measuredContentHeight, max) : (effectiveMaxContentHeight ?? 0); return Math.max(0, (windowHeight - visibleHeight) / 2); }, [isFormSheet, windowHeight, detachedOffset, measuredContentHeight, effectiveMaxContentHeight]); // The wrapper holds all horizontal sizing/anchoring so its rounded-bottom // clip (when detached) aligns with the drawer's horizontal bounds on // desktop — otherwise its corners sit at the far viewport edges. // - presentation='form' → DEFAULT_FORM_SHEET_WIDTH on tablet/landscape; // `maxContentWidth` is ignored ('form' is absolute). // - presentation='page' → `maxContentWidth` (any viewport) or // DEFAULT_MAX_WIDTH (tablet/landscape readability cap). // Detached without a width constraint applies anchorOffset on both edges so // the floating card breathes from the viewport sides. const wrapperStyle = useMemo(() => { // Mobile portrait ignores width sizing entirely (matches iOS/Android: // both apply `maxContentWidth` only when not on a portrait phone). // `detached` + `detachedOffset` are still respected via the wrapper. const maxWidth = isLandscapeOrTablet ? presentation === 'form' ? DEFAULT_FORM_SHEET_WIDTH : (maxContentWidth ?? DEFAULT_MAX_WIDTH) : undefined; const needsMargins = maxWidth != null || effectiveDetached; if (!needsMargins && !dropShadow) return undefined; const next: React.CSSProperties = {}; if (dropShadow) next.filter = dropShadow; if (!needsMargins) return next; let marginLeft: number | string; let marginRight: number | string; if (isFormSheet) { marginLeft = 'auto'; marginRight = 'auto'; } else if (maxWidth == null) { marginLeft = anchorOffset; marginRight = anchorOffset; } else { marginLeft = anchor === 'left' ? anchorOffset : 'auto'; marginRight = anchor === 'right' ? anchorOffset : 'auto'; } if (maxWidth != null) next.maxWidth = maxWidth; next.marginLeft = marginLeft; next.marginRight = marginRight; return next; }, [ isLandscapeOrTablet, isFormSheet, maxContentWidth, presentation, anchor, anchorOffset, effectiveDetached, dropShadow, ]); // Absolute-position the grabber so it overlays the content top-edge // instead of consuming flow height — mirrors native iOS/Android, where // the grabber sits in the rounded corner zone above the content and // doesn't push the header down or inflate the 'auto' detent measurement. const handleStyle = useMemo( () => ({ position: 'absolute', top: grabberOptions?.topMargin ?? DEFAULT_GRABBER_TOP_MARGIN, left: '50%', transform: 'translateX(-50%)', height: grabberHeight, width: grabberOptions?.width ?? DEFAULT_GRABBER_WIDTH, borderRadius: grabberOptions?.cornerRadius ?? grabberHeight / 2, backgroundColor: (grabberOptions?.color ?? defaultGrabberColor) as string, opacity: 1, // Above absolute-positioned headers (which often use zIndex:1 to overlay // scroll content) so the grabber stays draggable — critical when // `handleOnly` mode means only the grabber can drag the sheet. zIndex: 2, }), [grabberOptions, grabberHeight, defaultGrabberColor] ); return ( {isValidElement(footer) ? footer : createElement(footer)} ) : undefined } > Sheet {grabber && } {scrollable ? ( // vaul wraps children in `[data-vaul-auto-size-wrapper]` (display: // flow-root) which doesn't honor descendant flex layout. Use an // absolute fill sized to the visible portion (via vaul's // `--snap-point-height` var) so the inner flex column has a // definite height for the scroll container's flex:1 to fill.
{header && ( {isValidElement(header) ? header : createElement(header)} )}
{children}
) : ( <> {header && ( {isValidElement(header) ? header : createElement(header)} )} {children} )}
); }); const overlayStyle: React.CSSProperties = { position: 'fixed', inset: 0, backgroundColor: 'rgba(0, 0, 0, 0.5)', }; const scrollableLayoutStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, height: 'calc(100% - var(--snap-point-height, 0px))', display: 'flex', flexDirection: 'column', }; const scrollableContainerStyle: React.CSSProperties = { flex: 1, minHeight: 0, overflowY: 'auto', overscrollBehavior: 'contain', touchAction: 'pan-y', }; const visuallyHiddenStyle: React.CSSProperties = { position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0, 0, 0, 0)', whiteSpace: 'nowrap', border: 0, }; const STATIC_METHOD_ERROR = 'Static methods are not supported on web. Use the useTrueSheet() hook instead.'; export const TrueSheet = TrueSheetComponent as typeof TrueSheetComponent & TrueSheetStaticMethods; const rejectStatic = async (): Promise => { throw new Error(STATIC_METHOD_ERROR); }; TrueSheet.present = rejectStatic; TrueSheet.dismiss = rejectStatic; TrueSheet.dismissStack = rejectStatic; TrueSheet.resize = rejectStatic; TrueSheet.dismissAll = rejectStatic;