// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file BottomSheet.tsx * @input Uses React, StyleX, core hooks/utils, named BottomSheetPanel, BottomSheetSwitcherContext * @output Exports BottomSheet component and BottomSheetProps * @position Public BottomSheet router plus private standalone/switcher hosts * * BottomSheet selects one of two focused hosts. A standalone host owns its * native dialog lifecycle; a switcher item participates in the parent's shared * dialog and transition state machine. Both render the same BottomSheetPanel, * which owns sheet presentation, gestures, mobile-keyboard accommodation, and * motion completion. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/BottomSheet/BottomSheetPanel.tsx * - /packages/core/src/BottomSheet/BottomSheetEdgeTint.tsx * - /packages/core/src/BottomSheet/BottomSheet.doc.mjs * - /packages/core/src/BottomSheet/BottomSheet.test.tsx * - /packages/core/src/BottomSheet/BottomSheetSwitcher.tsx * - /packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx * - /apps/storybook/stories/BottomSheet.stories.tsx * - /packages/cli/assets/templates/blocks/components/BottomSheet/BottomSheetShowcase.tsx */ import { use, useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import type { BaseProps } from "../BaseProps.js"; import type { DialogPurpose } from "../Dialog/index.js"; import "../theme/tokens.stylex.js"; import { colorVars, durationVars, easeVars } from "../theme/tokens.stylex.js"; import { useDevWarning, useScrollLock } from "../hooks/index.js"; import { isImeKeyEvent } from "../utils/index.js"; import { BottomSheetPanel, type BottomSheetPanelMotion, type BottomSheetPanelState } from "./BottomSheetPanel.js"; import { BottomSheetEdgeTint } from "./BottomSheetEdgeTint.js"; import { BottomSheetSwitcherContext, type BottomSheetSwitcherContextValue, type BottomSheetSwitcherPhase } from "./BottomSheetSwitcherContext.js"; export type { BottomSheetHeight, BottomSheetSnapPoint } from "./BottomSheetPanel.js"; import type { BottomSheetHeight, BottomSheetSnapPoint } from "./BottomSheetPanel.js"; interface BottomSheetSharedProps extends BaseProps { /** Ref forwarded to the visual sheet panel
. */ ref?: React.Ref; /** Accessible label for the sheet. */ label: string; /** Sheet content, rendered below the grab handle in a scrollable area. */ children: ReactNode; /** Height budget or custom CSS length. Only fully expanded Tall is keyboard-aware. @default 'capped' */ height?: BottomSheetHeight | number | string; /** * Extra heights the sheet can rest at when dragged; its own height is always * the tallest stop, and omitting this gives a sheet that only opens and * closes. Each stop is the sheet's visible height: a number is a viewport * fraction (`0.5` is half the screen), `'50%'` the same in CSS, `'320px'` an * absolute length. A stop of a quarter of the sheet or less is a peek — it * slides away instead of reflowing, and thins the scrim. */ snapPoints?: ReadonlyArray; /** * Configures implicit dismissal behavior, matching Dialog. * - required: Blocks swipe, scrim click, and Escape * - form: Blocks swipe and scrim click, allows Escape * - info: Allows swipe, scrim click, and Escape * @default 'info' */ purpose?: DialogPurpose; } interface StandaloneBottomSheetProps extends BottomSheetSharedProps { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; hasScrim?: boolean; /** Element that receives focus after the sheet closes. */ finalFocusRef?: RefObject; sheetId?: never; } interface SwitcherBottomSheetProps extends BottomSheetSharedProps { sheetId: string; isOpen?: never; onOpenChange?: never; hasScrim?: never; } export type BottomSheetProps = StandaloneBottomSheetProps | SwitcherBottomSheetProps; function panelStateForSwitcherPhase(phase: BottomSheetSwitcherPhase, alignmentOffset: number): BottomSheetPanelState { switch (phase) { case 'active': return { kind: 'open', entering: false }; case 'entering': return { kind: 'open', entering: true }; case 'covered': case 'aligning': case 'fading': return { kind: 'retained', motion: phase, alignmentOffset }; case 'exiting': return { kind: 'exiting' }; case 'hidden': return { kind: 'hidden' }; } } // preventScroll on both: presenting a sheet must not scroll the page to reveal // what it just focused. For an autofocused field that reveal is the mobile // keyboard's, and it moves the document under a fixed sheet; useMobileKeyboard // brings the field into view within the sheet instead. function focusPanel(panel: HTMLElement | null, isModal: boolean): void { const activeElement = document.activeElement; if (activeElement != null && panel?.contains(activeElement)) { return; } const autofocus = panel?.querySelector('[data-autofocus]'); if (autofocus != null) { autofocus.focus({ preventScroll: true }); } else if (isModal) { panel?.focus({ preventScroll: true }); } } function StandaloneBottomSheet({ ref, isOpen, onOpenChange, label, children, height = 'capped', snapPoints, hasScrim = true, finalFocusRef, purpose = 'info', xstyle, ...props }: StandaloneBottomSheetProps) { const dialogRef = useRef(null); const panelRef = useRef(null); const triggerRef = useRef(null); const [isPresented, setIsPresented] = useState(isOpen); const shouldPresent = isOpen || isPresented; const panelState: BottomSheetPanelState = isOpen ? { kind: 'open', entering: false } : isPresented ? { kind: 'exiting' } : { kind: 'hidden' }; const dismissOnEscape = useCallback(() => { if (purpose !== 'required') { onOpenChange(false); } }, [onOpenChange, purpose]); const dismissOnLightInteraction = useCallback(() => { if (purpose === 'info') { onOpenChange(false); } }, [onOpenChange, purpose]); const handlePanelElementChange = useCallback((element: HTMLDivElement | null) => { panelRef.current = element; }, []); const handleScrimOpacity = useCallback((opacity: number) => { dialogRef.current?.style.setProperty('--_sheet-scrim-opacity', String(opacity)); }, []); useEffect(() => { const dialog = dialogRef.current; if (dialog == null || !isOpen) { return; } // The controlled prop opens an already-mounted dialog; presentation state // must latch here so a later close can keep it mounted through its exit. // eslint-disable-next-line @eslint-react/set-state-in-effect -- latches controlled open state for exit animation setIsPresented(true); dialog.style.setProperty('--_sheet-scrim-opacity', '1'); const wasOpen = dialog.open; if (!wasOpen) { if (hasScrim) { triggerRef.current = document.activeElement as HTMLElement | null; dialog.showModal(); } else { dialog.show(); } focusPanel(panelRef.current, hasScrim); } }, [hasScrim, isOpen]); useEffect(() => { if (!isOpen && isPresented && hasScrim) { handleScrimOpacity(0); } }, [handleScrimOpacity, hasScrim, isOpen, isPresented]); const handleMotionComplete = useCallback((motion: BottomSheetPanelMotion) => { if (motion !== 'exiting' || isOpen) { return; } const dialog = dialogRef.current; if (dialog?.open) { dialog.close(); } setIsPresented(false); (finalFocusRef?.current ?? triggerRef.current)?.focus(); triggerRef.current = null; }, [finalFocusRef, isOpen]); useScrollLock(shouldPresent && hasScrim); useDevWarning('BottomSheet', 'requires a non-empty `label` for an accessible name; the open sheet ' + 'has no built-in heading to derive one from.', isOpen && !label); const handleCancel = useCallback((event: React.SyntheticEvent) => { // No IME guard here: `cancel` is a plain Event carrying no composition // state, and handleKeyDown claims a composing Escape before the browser // can raise the close request that would arrive here. event.preventDefault(); dismissOnEscape(); }, [dismissOnEscape]); const handleKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.key !== 'Escape') { return; } // Claim the key before reading it: an unclaimed Escape lets the browser // raise its own close request, which lands on handleCancel and dismisses // on the same keypress. event.preventDefault(); // An IME fires this keydown to cancel an in-progress composition, ahead // of compositionend. It is a composition cancel, not a dismissal command // — see utils/ime; Dialog and BottomSheetSwitcher guard the same way. if (isImeKeyEvent(event.nativeEvent)) { return; } dismissOnEscape(); }, [dismissOnEscape]); const handleClick = useCallback((event: React.MouseEvent) => { if (hasScrim && event.target === event.currentTarget) { dismissOnLightInteraction(); } }, [dismissOnLightInteraction, hasScrim]); return
{children}
; } interface SwitcherBottomSheetItemProps extends SwitcherBottomSheetProps { switcher: BottomSheetSwitcherContextValue; } function SwitcherBottomSheetItem({ switcher, ref, sheetId, label, children, height = 'capped', snapPoints, purpose = 'info', xstyle, ...props }: SwitcherBottomSheetItemProps) { const { activeSheet, hasScrim, onActiveSheetChange, getSheetPhase, getSheetAlignmentOffset, registerSheetElement, registerSheetLabel, registerSheetPurpose, onSheetEnterStart, onSheetTransitionComplete, onSheetScrimOpacityChange } = switcher; const hasValidSheetId = typeof sheetId === 'string' && sheetId.length > 0; const phase = hasValidSheetId ? getSheetPhase(sheetId) : 'hidden'; const alignmentOffset = hasValidSheetId ? getSheetAlignmentOffset(sheetId) : 0; const panelState = panelStateForSwitcherPhase(phase, alignmentOffset); const isInteractive = phase === 'active' || phase === 'entering'; const isInactive = phase === 'covered' || phase === 'aligning' || phase === 'fading' || phase === 'exiting'; const isPresented = phase !== 'hidden'; const isTopSheet = phase === 'active' || phase === 'entering'; const panelRef = useRef(null); const previousPhaseRef = useRef(phase); const previousPhase = previousPhaseRef.current; const hasPresentedRef = useRef(false); useLayoutEffect(() => { previousPhaseRef.current = phase; }, [phase]); const dismissOnSwipe = useCallback(() => { if (purpose === 'info' && hasValidSheetId && activeSheet === sheetId) { onActiveSheetChange(null); } }, [activeSheet, hasValidSheetId, onActiveSheetChange, purpose, sheetId]); const handlePanelElementChange = useCallback((element: HTMLDivElement | null) => { panelRef.current = element; if (hasValidSheetId) { registerSheetElement(sheetId, element); } }, [hasValidSheetId, registerSheetElement, sheetId]); const handleMotionStart = useCallback((motion: BottomSheetPanelMotion) => { if (motion === 'entering' && hasValidSheetId) { onSheetEnterStart(sheetId); } }, [hasValidSheetId, onSheetEnterStart, sheetId]); const handleMotionComplete = useCallback((motion: BottomSheetPanelMotion) => { if (hasValidSheetId) { onSheetTransitionComplete({ sheetId, phase: motion }); } }, [hasValidSheetId, onSheetTransitionComplete, sheetId]); const handleScrimOpacity = useCallback((opacity: number) => { if (hasValidSheetId) { onSheetScrimOpacityChange(sheetId, opacity); } }, [hasValidSheetId, onSheetScrimOpacityChange, sheetId]); useLayoutEffect(() => { if (!hasValidSheetId) { return; } registerSheetLabel(sheetId, label); return () => registerSheetLabel(sheetId, null); }, [hasValidSheetId, label, registerSheetLabel, sheetId]); useLayoutEffect(() => { if (!hasValidSheetId) { return; } registerSheetPurpose(sheetId, purpose); return () => registerSheetPurpose(sheetId, null); }, [hasValidSheetId, purpose, registerSheetPurpose, sheetId]); useEffect(() => { if (isInteractive) { const wasInteractive = previousPhase === 'active' || previousPhase === 'entering'; if (!hasPresentedRef.current || !wasInteractive) { focusPanel(panelRef.current, hasScrim); } hasPresentedRef.current = true; } else if (phase === 'hidden') { hasPresentedRef.current = false; } }, [hasScrim, isInteractive, phase, previousPhase]); useDevWarning('BottomSheet', 'requires a non-empty `label` for an accessible name; the open sheet ' + 'has no built-in heading to derive one from.', isInteractive && !label); return ; } /** * A mobile touch sheet that either owns a native dialog or participates in a * BottomSheetSwitcher shared dialog when given a sheetId inside that context. */ export function BottomSheet(props: BottomSheetProps) { const switcher = use(BottomSheetSwitcherContext); const runtimeSheetId = (props as { sheetId?: string; }).sheetId; const hasValidSheetId = typeof runtimeSheetId === 'string' && runtimeSheetId.length > 0; useDevWarning('BottomSheet', 'requires a non-empty `sheetId` when nested in ' + 'BottomSheetSwitcher; standalone `isOpen` / `onOpenChange` props are ' + 'ignored there.', switcher != null && !hasValidSheetId); useDevWarning('BottomSheet', '`sheetId` only works inside BottomSheetSwitcher. Use `isOpen` and ' + '`onOpenChange` for a standalone sheet.', switcher == null && runtimeSheetId != null); if (switcher != null) { return ; } return ; } BottomSheet.displayName = 'BottomSheet';