// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file BottomSheetPanel.tsx * @input Uses React, StyleX, theme tokens, sheet gestures, shared scroll behavior, and the host label * @output Internal BottomSheetPanel with a keyboard-reachable scrolling body and motion-state types * @position Shared presentation layer for standalone and switcher BottomSheets * * This component owns everything intrinsic to a sheet surface: height budgets, * drag and snap gestures, the handle and scrolling body, motion styles, and * transition completion. It owns the body's keyboard access; dialog focus * entry/return, inert state, and switcher registration belong to the host. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/BottomSheet/BottomSheet.tsx * - /packages/core/src/BottomSheet/BottomSheetPanel.test.tsx * - /packages/core/src/BottomSheet/snapOffsets.ts * - /packages/core/src/BottomSheet/useMobileKeyboard.ts * - /packages/core/src/BottomSheet/useSheetGestures.ts */ import { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import type { BaseProps } from "../BaseProps.js"; import { useDevWarning } from "../hooks/index.js"; import { useScrollableArea } from "../hooks/useScrollableArea.js"; import "../theme/tokens.stylex.js"; import { borderVars, colorVars, durationVars, easeVars, focusVars, radiusVars, shadowVars, sizeVars, spacingVars } from "../theme/tokens.stylex.js"; import { mergeProps, themeProps } from "../utils/index.js"; import { focusOutlineStyles } from "../utils/focusOutline.stylex.js"; import { overlayPaddingReset } from "../Layout/padding.stylex.js"; import { isValidSnapPoint, resolveSnapPoints, type BottomSheetSnapPoint } from "./snapOffsets.js"; import { useMobileKeyboard } from "./useMobileKeyboard.js"; import { useSheetGestures } from "./useSheetGestures.js"; const HEIGHT_BUDGETS = { hug: '92dvh', capped: '62dvh', tall: '92dvh' } as const; export type BottomSheetHeight = keyof typeof HEIGHT_BUDGETS; export type { BottomSheetSnapPoint }; // SYNC: must match OVERSCROLL_MAX in useSheetGestures.ts. const OVERSCROLL_PADDING = 48; const MOBILE_KEYBOARD_BOTTOM_CLEARANCE = 48; const TRANSITION_BACKSTOP_BUFFER_MS = 50; // The floating handle bar's height. The bar is out of flow, so this is not // layout space the content pays for -- the pill (4px, centered) lands 10-14px // from the sheet's top edge, inside the space a content wrapper's own top // padding already provides. const HANDLE_BAR_HEIGHT = spacingVars['--spacing-6']; // Measure the same viewport the height budgets are written against. Those are // `dvh`, which the virtual keyboard does not shrink, so reading // `visualViewport` here would make the two disagree by exactly the keyboard's // height: every detent would move while the sheet it measures did not. A // keyboard is `useMobileKeyboard`'s business — it holds the sheet still and // scrolls the body — and it does not redefine the sheet's detents. function layoutViewportHeight(): number { return typeof window === 'undefined' ? 0 : window.innerHeight; } /** * A stable identity for a set of snap points. Type-tagged, so the fraction * `0.5` and the (invalid) string `'0.5'` cannot collide on one key. * * Recomputed every render — the panel re-renders on every frame of a drag — so * it stays a single pass over a handful of values, and everything derived from * the points hangs off it instead of being rebuilt per frame. */ function snapPointsKeyFor(points: ReadonlyArray | undefined): string { let key = ''; for (const point of points ?? []) { key += `${typeof point}:${point}|`; } return key; } const styles = { sheet: { kfzvcC: "x67bb7w", kB7OPa: "x9f619", kVAEAm: "x1n2onr6", k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", kAzted: "x2lwn1j", kzqmXN: "xh8yej3", ks0D6T: "x11gisft", kWkggS: "x10xzikg", kEafiO: "x11xkdxz", kPef9Z: "x13fuv20", kLZC3w: "x1pc3f07", k2ei4v: "xgbv0en", kVhnKS: "x1t7ytsu", kGJrpR: "x1j92z86", ke9TFa: "xw8tdv1", k8ry5P: "x18b5jzi", kBCPoo: "x1gejf6u", krdFHd: "x81l70g", kfmiAY: "x7hs6f1", kGVxlE: "x1kcpxr7", kI3sdo: "x1a2a7pz", kVQacm: "xb3r6kr", kGO01o: "x1wkw6tp", k1K539: "x1if0o47", k3aq6I: "xnn1q72 xhbqy3z", kSiTet: "x1hc1fzr", k1ekBW: "xwcsmn1", kIyJzY: "x80gvsz", kAMwcw: "xlr8y92", k6sLGO: "x1q1rmc8", k6CgDc: "xzg1mie", $$css: true as const }, sheetClosing: { k3aq6I: "x1weeur4", kAMwcw: "xv2sgk5", $$css: true as const }, sheetFading: { kSiTet: "xg01cxk", $$css: true as const }, sheetInactive: { kfzvcC: "x47corl", $$css: true as const }, body: { kzQI83: "x1iyjqo2", kAzted: "x2lwn1j", kB7OPa: "x9f619", kFalU9: "xx69xxh", kInvED: "x1wfwxd8 x1nw7mhf", kWkggS: "x10xzikg", kGO01o: "x18d9i69", $$css: true as const }, tallKeyboardBody: { kFlMNQ: "xrw9175", k5JduY: "x1s928wv", kc3QkE: "xhkezso", k4zj60: "xu6fald", kloYau: "x2q1x1w", $$css: true as const }, budget: { kZKoxP: "x1tpcejd", $$css: true as const }, hugHeight: { kZKoxP: "xg7h5cd", kskxy: "x14bu9tk", $$css: true as const } }; export type BottomSheetPanelMotion = 'entering' | 'aligning' | 'fading' | 'exiting'; export type BottomSheetPanelState = { kind: 'hidden'; } | { kind: 'open'; entering: boolean; } | { kind: 'retained'; motion: 'covered' | 'aligning' | 'fading'; alignmentOffset: number; } | { kind: 'exiting'; }; interface BottomSheetPanelProps extends BaseProps { /** Ref forwarded to the visual sheet panel. */ ref?: React.Ref; state: BottomSheetPanelState; height: BottomSheetHeight | number | string; /** Existing host name, reused for the keyboard scrolling group. */ label: string; children: ReactNode; snapPoints?: ReadonlyArray; isSwipeDismissAllowed?: boolean; /** Whether the host has locked page scrolling (a modal, scrim-backed sheet). */ isPageScrollLocked?: boolean; onDismiss: () => void; onScrimOpacity: (opacity: number) => void; onElementChange?: (element: HTMLDivElement | null) => void; onMotionStart?: (motion: BottomSheetPanelMotion) => void; onMotionComplete?: (motion: BottomSheetPanelMotion) => void; } function motionForState(state: BottomSheetPanelState): BottomSheetPanelMotion | null { if (state.kind === 'open') { return state.entering ? 'entering' : null; } if (state.kind === 'retained') { return state.motion === 'covered' ? null : state.motion; } return state.kind === 'exiting' ? 'exiting' : null; } function waitForTransition(element: HTMLElement | null, propertyName: 'transform' | 'opacity', complete: () => void): () => void { if (element == null) { complete(); return () => {}; } let done = false; let timer: ReturnType | null = null; const finish = () => { if (done) { return; } done = true; if (timer != null) { clearTimeout(timer); } element.removeEventListener('transitionend', handleTransitionEnd); element.removeEventListener('transitioncancel', handleTransitionEnd); complete(); }; const handleTransitionEnd = (event: TransitionEvent) => { if (event.target === element && event.propertyName === propertyName) { finish(); } }; element.addEventListener('transitionend', handleTransitionEnd); element.addEventListener('transitioncancel', handleTransitionEnd); const computedStyle = getComputedStyle(element); if (element.style.transition.trim() === 'none' || computedStyle.transition.trim() === 'none') { finish(); return () => {}; } const properties = computedStyle.transitionProperty.split(',').map(value => value.trim()); const durations = computedStyle.transitionDuration.split(',').map(parseTransitionTime); const delays = computedStyle.transitionDelay.split(',').map(parseTransitionTime); let hasUnresolvedTiming = false; const transitionMs = properties.reduce((longest, property, index) => { if (property !== propertyName && property !== 'all') { return longest; } const duration = durations[index % durations.length]; const delay = delays[index % delays.length]; if (duration == null || delay == null) { hasUnresolvedTiming = true; return longest; } return Math.max(longest, duration + delay); }, 0); if (hasUnresolvedTiming) { // JSDOM can leave CSS variables unresolved. In that case the native event // remains authoritative; choosing a fixed timeout here would make an // assumption about the consumer's theme. return () => { element.removeEventListener('transitionend', handleTransitionEnd); element.removeEventListener('transitioncancel', handleTransitionEnd); }; } if (transitionMs <= 0) { finish(); return () => {}; } timer = setTimeout(finish, transitionMs + TRANSITION_BACKSTOP_BUFFER_MS); return () => { if (timer != null) { clearTimeout(timer); } element.removeEventListener('transitionend', handleTransitionEnd); element.removeEventListener('transitioncancel', handleTransitionEnd); }; } function parseTransitionTime(value: string): number | null { const normalizedValue = value.trim(); if (!/^-?(?:\d+|\d*\.\d+)(?:ms|s)$/.test(normalizedValue)) { return null; } const time = Number.parseFloat(value); return normalizedValue.endsWith('ms') ? time : time * 1000; } /** Internal visual and gesture surface shared by every BottomSheet host. */ export function BottomSheetPanel({ ref, state, height, label, children, snapPoints, className, style, tabIndex, xstyle, isSwipeDismissAllowed = true, isPageScrollLocked = false, onDismiss, onScrimOpacity, onElementChange, onMotionStart, onMotionComplete, ...props }: BottomSheetPanelProps) { const elementRef = useRef(null); const previousStateRef = useRef(state); const reactivatedEntranceRef = useRef(false); const onMotionStartRef = useRef(onMotionStart); const onMotionCompleteRef = useRef(onMotionComplete); const startedMotionRef = useRef(null); const pendingMotionCompleteRef = useRef(null); const isEntering = state.kind === 'open' && state.entering; const previousState = previousStateRef.current; const wasEntering = previousState.kind === 'open' && previousState.entering; if (isEntering && !wasEntering) { reactivatedEntranceRef.current = previousState.kind === 'retained'; } else if (!isEntering) { reactivatedEntranceRef.current = false; } useLayoutEffect(() => { previousStateRef.current = state; onMotionStartRef.current = onMotionStart; onMotionCompleteRef.current = onMotionComplete; }, [onMotionComplete, onMotionStart, state]); const isInteractive = state.kind === 'open'; const isPresented = state.kind !== 'hidden'; const isRetained = state.kind === 'retained'; const isInactive = isRetained || state.kind === 'exiting'; const isClosing = state.kind === 'exiting'; const isFading = isRetained && state.motion === 'fading'; const alignmentOffset = isRetained ? state.alignmentOffset : 0; // Everything derived from the snap points keys off their identity, not the // array's: the resolver's identity is the hook's signal that the stops // changed, so it must not churn on a re-render the drag caused. The memo // reads the points through a ref, so the resolver resolves them at call time // — against whatever the viewport is then, not what it was at memo time. const snapPointsKey = snapPointsKeyFor(snapPoints); const snapPointsRef = useRef(snapPoints); snapPointsRef.current = snapPoints; const { snapHeights, ignoredSnapPointsMessage } = useMemo(() => { if (snapPointsKey === '') { return { snapHeights: undefined, ignoredSnapPointsMessage: '' }; } const ignored = (snapPointsRef.current ?? []).filter(point => !isValidSnapPoint(point)); return { snapHeights: () => resolveSnapPoints(snapPointsRef.current ?? [], layoutViewportHeight()), ignoredSnapPointsMessage: ignored.length === 0 ? '' : `snapPoints ignored ${JSON.stringify(ignored)}. A snap point is a viewport fraction above 0 and up to 1 (0.5 is half the screen), a px length ('320px'), or a percentage ('50%').` }; }, [snapPointsKey]); useDevWarning('BottomSheet', ignoredSnapPointsMessage, ignoredSnapPointsMessage !== ''); const { contentProps, handleProps, bodyProps, bodyElementRef, sheetRef, dragOffset, settledOffset, isDragging, sheetHeight, scrollPreservationInset, settlingLayoutOffset, settledLayoutOffset, completeScrollAreaSettle } = useSheetGestures({ isOpen: isInteractive, canDismiss: isSwipeDismissAllowed, offscreenBlockEndInset: OVERSCROLL_PADDING, onDismiss, snapHeights, onScrimOpacity }); const { getViewportProps, getContentProps } = useScrollableArea({ // The previous overflow-y:auto also computed overflow-x:auto. Keep wide // content reachable instead of clipping that existing inline scroll path. axis: 'both', keyboardAccess: { owner: 'contentOrViewport', label }, overscroll: 'contain', // The sheet body deliberately remains the boundary for sheet-local sticky // content while fitting, as it was before shared scroll adoption. stickyContainment: 'always' }); const setElement = useCallback((element: HTMLDivElement | null) => { sheetRef(element); elementRef.current = element; onElementChange?.(element); }, [onElementChange, sheetRef]); useMobileKeyboard({ bodyRef: bodyElementRef, bottomClearance: MOBILE_KEYBOARD_BOTTOM_CLEARANCE, isEnabled: height === 'tall', isFullyExpanded: settledOffset === 0, isPageScrollLocked, isSheetTraveling: isDragging && dragOffset !== settledOffset, isOpen: isInteractive, isPresented, sheetRef: elementRef }); // Keep controller registration attached to one stable host ref. React // detaches an old callback ref when a consumer supplies a new identity; if // that public ref were merged with setElement, an ordinary parent rerender // could be mistaken for the panel unmounting and cancel a sheet handoff. useImperativeHandle(ref, () => elementRef.current as HTMLDivElement, []); const motion = motionForState(state); useLayoutEffect(() => { if (motion == null) { return; } startedMotionRef.current = null; pendingMotionCompleteRef.current = null; if (motion === 'entering' && reactivatedEntranceRef.current) { pendingMotionCompleteRef.current = motion; return; } return waitForTransition(elementRef.current, motion === 'fading' ? 'opacity' : 'transform', () => { if (startedMotionRef.current === motion) { onMotionCompleteRef.current?.(motion); } else { pendingMotionCompleteRef.current = motion; } }); }, [motion]); // The snap is transform-only; the layout height reconciles when it lands. // waitForTransition — the same helper the motion states use — resolves that // even when no `transitionend` is coming: inline or computed // `transition: none`, a zero duration, and a timer backstop otherwise. useLayoutEffect(() => { if (settlingLayoutOffset == null) { return; } return waitForTransition(elementRef.current, 'transform', completeScrollAreaSettle); }, [completeScrollAreaSettle, settlingLayoutOffset]); useEffect(() => { if (motion == null) { return; } onMotionStartRef.current?.(motion); startedMotionRef.current = motion; if (pendingMotionCompleteRef.current === motion) { pendingMotionCompleteRef.current = null; onMotionCompleteRef.current?.(motion); } return () => { if (startedMotionRef.current === motion) { startedMotionRef.current = null; } if (pendingMotionCompleteRef.current === motion) { pendingMotionCompleteRef.current = null; } }; }, [motion]); const isNamedHeight = typeof height === 'string' && height in HEIGHT_BUDGETS; const budget = isNamedHeight ? HEIGHT_BUDGETS[height as BottomSheetHeight] : typeof height === 'number' ? `${height}px` : height; const hasMeasuredSheet = sheetHeight > 0; let gestureTransform = contentProps.style.transform; let resizedHeight: string | undefined; if (hasMeasuredSheet) { // The sheet's travel is split across two properties: `layoutOffset` is the // part the scrolling area gives up as layout height, and the remainder is // a compositor transform. Live gestures and snaps only ever move the // transform — the layout height changes at rest, in one reconciling render // whose visible geometry is identical (see useSheetGestures). // - dragging above the base restores the full height below the viewport // - a peek settles with layout 0, so it slides rather than reflowing const layoutOffset = isDragging ? dragOffset < settledOffset ? 0 : settledLayoutOffset : settlingLayoutOffset ?? settledLayoutOffset; const activeOffset = isDragging ? dragOffset : settledOffset; const translation = activeOffset - layoutOffset; // At layout 0 the sheet is its natural height, so leave that to CSS — // `hug` sheets must stay fit-content. While the sheet is in motion it is // pinned in px instead: content reflowing mid-gesture would move the // surface out from under the finger, or under the snap. const isTraveling = isDragging || settlingLayoutOffset != null; resizedHeight = layoutOffset > 0 || isTraveling ? `${Math.max(0, sheetHeight - Math.max(0, layoutOffset))}px` : undefined; gestureTransform = translation !== 0 ? `translateY(${translation}px)` : undefined; } const retainedTransform = alignmentOffset > 0 ? [gestureTransform, `translateY(${alignmentOffset}px)`].filter(Boolean).join(' ') : gestureTransform; const gestureStyle = { ...contentProps.style, transform: gestureTransform, height: resizedHeight }; return
; } BottomSheetPanel.displayName = 'BottomSheetPanel';