import { type ReactNode, useCallback, useRef } from 'react'; import { Modal, Platform, Pressable, StyleSheet, useWindowDimensions, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Extrapolation, interpolate, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; import { scheduleOnRN } from 'react-native-worklets'; import { useFocusTrap } from '../../../hooks/use-focus-trap'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { useSafeInsets } from '../../../hooks/use-safe-insets'; import { cn } from '../../../lib/cn'; import type { SurfaceElevation } from '../../../lib/elevated'; import { GLASS_SURFACE } from '../../../lib/glass'; import { MODAL_RADIUS } from '../../../lib/radius'; import { Surface } from '../../display/Surface/surface'; import { OverlayOutlet } from '../Overlay/overlay-portal'; import { OverlayScrim } from '../Overlay/overlay-scrim'; import type { OverlayType } from '../Overlay/overlay-type'; import { useSheetPresence } from '../Overlay/use-sheet-presence'; const HANDLE_HEIGHT = 28; const IS_ANDROID = Platform.OS === 'android'; const styles = StyleSheet.create({ sheetContainer: { zIndex: 1, ...(IS_ANDROID ? { elevation: 24 } : null), }, }); type SheetHandleProps = { className?: string }; function SheetHandle({ className }: SheetHandleProps) { return ( // The grabber is a drag affordance for pointers only — there is no // equivalent gesture for a screen reader, and the sheet is dismissed via the // backdrop button or the back gesture instead, so it stays out of the tree. ); } export type BottomSheetProps = { open?: boolean; /** * Fires when the sheet asks to be dismissed — always with `false` — from the * outside press, a drag down, or Android's back button. `open` is the * consumer's own state, so there is no open direction to report. */ onOpenChange?: (open: boolean) => void; /** Handle system back/Escape separately from outside presses and drag dismissal. */ onRequestClose?: () => void; children: ReactNode; containerClassName?: string; onAfterClose?: () => void; /** When true, the sheet stretches to full screen height instead of capping at 90%. */ fullSheet?: boolean; /** * Swap the sheet panel's ladder shadow for the input field's large, diffuse * halo (`shadow-floating`). It replaces the `shadow-elevated-N` rung rather * than adding to it, so the panel keeps its `elevation` tint but trades the * layered drop for the halo. @default false */ floating?: boolean; /** Use the shared lightweight blur and glass rim on the panel. @default false */ glass?: boolean; /** * Surface elevation of the sheet panel (0–3) — drives the background tint * and the `shadow-elevated-N` recipe. `0` is the flat resting surface (no * shadow or border). Defaults to `6`. */ elevation?: SurfaceElevation; /** The scrim behind the sheet: `"blur"`, `"opacity"`, or `"none"` — the sheet floats over the page with no scrim. Defaults to `"blur"`. */ overlay?: OverlayType; /** When false, pressing outside the sheet will not close it. Defaults to true. */ closeOnOutsidePress?: boolean; // Phase 5.4 — slot classNames /** Additional class names merged onto the drag handle bar. */ handleClassName?: string; /** Additional class names merged onto the backdrop overlay. */ backdropClassName?: string; /** * Wrap content in device safe-area insets (home indicator bottom; also status * bar top when `fullSheet` is true). Requires `react-native-safe-area-context` * and `` in the tree. Set to `false` to manage insets * yourself. @default true */ safeArea?: boolean; /** * Names the sheet for assistive technology. Pass the same words as the * visible heading so the announcement matches what is on screen; without it * the dialog is announced only as "dialog". */ accessibilityLabel?: string; /** * Label for the backdrop's dismiss button, which is how a screen-reader user * closes the sheet (the drag handle has no accessible equivalent). * @default 'Close' */ closeAccessibilityLabel?: string; /** * Fires after the sheet has fully presented (iOS `Modal.onShow`) — the moment * it is safe to request keyboard focus on content inside it. No-op on web. */ onShow?: () => void; testID?: string; }; export function BottomSheet({ open, onOpenChange, onRequestClose, children, containerClassName, onAfterClose, fullSheet, floating = false, glass = false, elevation = 3, overlay = 'blur', closeOnOutsidePress = true, handleClassName, backdropClassName, safeArea = true, accessibilityLabel, closeAccessibilityLabel = 'Close', onShow, testID, }: BottomSheetProps) { const isOpen = open ?? false; const { height } = useWindowDimensions(); const reduced = useReducedMotion(); const insets = useSafeInsets(); const sheetRef = useRef(null); // Native gets containment from Modal + accessibilityViewIsModal; on web the // Modal is a plain fixed div and Tab would walk out into the page behind it. useFocusTrap(sheetRef, isOpen); const { isMounted, translateY } = useSheetPresence({ open: isOpen, screenExtent: height, onAfterClose, reducedMotion: reduced, }); const dragStartY = useSharedValue(0); const handleClose = useCallback(() => { onOpenChange?.(false); }, [onOpenChange]); const sheetStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], })); const backdropStyle = useAnimatedStyle(() => { const opacity = interpolate(translateY.value, [0, height], [1, 0], Extrapolation.CLAMP); return { opacity }; }); const handleGesture = Gesture.Pan() .onBegin(() => { dragStartY.value = translateY.value; }) .onUpdate((event) => { translateY.value = Math.max(0, dragStartY.value + event.translationY); }) .onEnd((event) => { const shouldDismiss = translateY.value > height * 0.18 || event.velocityY > 1200; if (shouldDismiss) { scheduleOnRN(handleClose); return; } translateY.value = withSpring(0, { damping: 40, stiffness: 300, overshootClamping: true }); }); const handleOverlayPress = useCallback(() => { if (closeOnOutsidePress) handleClose(); }, [closeOnOutsidePress, handleClose]); // The backdrop doubles as the only reachable dismiss control for screen-reader // and keyboard users, so its a11y props flip with `closeOnOutsidePress`. const backdropA11y = closeOnOutsidePress ? ({ accessibilityRole: 'button', accessibilityLabel: closeAccessibilityLabel, importantForAccessibility: 'yes', focusable: true, } as const) : ({ accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants', 'aria-hidden': true, focusable: false, } as const); const sheetRadiusClass = fullSheet ? 'rounded-t-none' : 'rounded-t-modal'; const sheetHeightStyle = { maxHeight: fullSheet ? height : Math.round(height * 0.9), height: fullSheet ? height : undefined }; const contentInsetStyle = safeArea ? { paddingTop: fullSheet ? insets.top : 0, paddingBottom: insets.bottom } : undefined; if (!isMounted) return null; return ( {overlay === 'none' ? null : ( )} {fullSheet ? null : ( // The backdrop is also the only dismiss control a screen-reader or // keyboard user can reach — the drag handle is pointer-only — so it // carries a real button role and label rather than being an // unlabelled tap target. When `closeOnOutsidePress` is off it does // nothing, and it leaves the a11y tree instead of lying about it. )} {fullSheet ? null : } {children} {/* Overlay outlet: above the sheet, outside the scroll containers and drag gesture. Touch-transparent on its empty areas. */} ); }