import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ReactNode } from 'react'; interface VisualViewportState { height: number; bottomInset: number; } interface MobileBottomSheetProps { children: ReactNode; legend?: ReactNode; onCoveredHeightChange?: (height: number) => void; } function getVisualViewportState(avoidKeyboard: boolean): VisualViewportState { if (!avoidKeyboard) { return { height: window.innerHeight, bottomInset: 0, }; } const vv = window.visualViewport; if (!vv) { return { height: window.innerHeight, bottomInset: 0, }; } const bottomInset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); return { height: vv.height, bottomInset, }; } function useVisualViewportState(avoidKeyboard: boolean): VisualViewportState { const [state, setState] = useState(() => getVisualViewportState(avoidKeyboard)); useEffect(() => { const vv = window.visualViewport; const update = () => { const next = getVisualViewportState(avoidKeyboard); setState((prev) => prev.height === next.height && prev.bottomInset === next.bottomInset ? prev : next ); }; update(); window.addEventListener('resize', update); window.addEventListener('orientationchange', update); if (avoidKeyboard) { vv?.addEventListener('resize', update); vv?.addEventListener('scroll', update); } return () => { window.removeEventListener('resize', update); window.removeEventListener('orientationchange', update); if (avoidKeyboard) { vv?.removeEventListener('resize', update); vv?.removeEventListener('scroll', update); } }; }, [avoidKeyboard]); return state; } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function isKeyboardEditableElement(element: HTMLElement): boolean { if (element instanceof HTMLTextAreaElement) return true; if (element instanceof HTMLInputElement) { return ![ 'button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit', ].includes(element.type); } return element.isContentEditable; } function getKeyboardEditableElement(target: EventTarget | null): HTMLElement | null { if (!(target instanceof Element)) return null; const element = target.closest('input, textarea, [contenteditable]'); if (!(element instanceof HTMLElement)) return null; return isKeyboardEditableElement(element) ? element : null; } export default function MobileBottomSheet({ children, legend, onCoveredHeightChange, }: MobileBottomSheetProps) { const [keyboardAvoidanceActive, setKeyboardAvoidanceActive] = useState(false); const viewport = useVisualViewportState(keyboardAvoidanceActive); const sheetRef = useRef(null); const scrollRef = useRef(null); const dragStartYRef = useRef(0); const dragStartHeightRef = useRef(0); const scrollIntoViewTimerRef = useRef(null); const [height, setHeight] = useState(null); const [isDragging, setIsDragging] = useState(false); const heightBounds = useMemo(() => { const available = viewport.height; return { min: Math.min(132, Math.max(104, available * 0.22)), initial: Math.min(available * 0.56, Math.max(330, available * 0.44)), max: Math.max(300, available - 12), }; }, [viewport.height]); const currentHeight = clamp(height ?? heightBounds.initial, heightBounds.min, heightBounds.max); useEffect(() => { setHeight((value) => value == null ? value : clamp(value, heightBounds.min, heightBounds.max) ); }, [heightBounds]); useEffect(() => { onCoveredHeightChange?.(Math.round(currentHeight + viewport.bottomInset)); }, [currentHeight, onCoveredHeightChange, viewport.bottomInset]); const handlePointerDown = useCallback( (e: React.PointerEvent) => { e.preventDefault(); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); dragStartYRef.current = e.clientY; dragStartHeightRef.current = currentHeight; setIsDragging(true); }, [currentHeight] ); const handlePointerMove = useCallback( (e: React.PointerEvent) => { if (dragStartHeightRef.current === 0) return; const nextHeight = dragStartHeightRef.current + dragStartYRef.current - e.clientY; setHeight(clamp(nextHeight, heightBounds.min, heightBounds.max)); }, [heightBounds] ); const handlePointerUp = useCallback(() => { if (dragStartHeightRef.current === 0) return; dragStartHeightRef.current = 0; setIsDragging(false); }, []); const handleSheetPointerDown = useCallback((event: React.PointerEvent) => { if (getKeyboardEditableElement(event.target)) return; const activeElement = document.activeElement; if ( activeElement instanceof HTMLElement && sheetRef.current?.contains(activeElement) && isKeyboardEditableElement(activeElement) ) { activeElement.blur(); } }, []); useEffect(() => { const sheet = sheetRef.current; if (!sheet) return; const handleFocusIn = (event: FocusEvent) => { const target = getKeyboardEditableElement(event.target); if (!target || !sheet.contains(target)) return; setKeyboardAvoidanceActive(true); const keyboardMinHeight = Math.min(heightBounds.max, Math.max(300, viewport.height * 0.55)); setHeight((value) => Math.max(value ?? heightBounds.initial, keyboardMinHeight)); if (scrollIntoViewTimerRef.current != null) { window.clearTimeout(scrollIntoViewTimerRef.current); } scrollIntoViewTimerRef.current = window.setTimeout(() => { target.scrollIntoView({ block: 'center', behavior: 'smooth' }); }, 120); }; const handleFocusOut = (event: FocusEvent) => { const nextTarget = getKeyboardEditableElement(event.relatedTarget); if (nextTarget && sheet.contains(nextTarget)) return; setKeyboardAvoidanceActive(false); }; sheet.addEventListener('focusin', handleFocusIn); sheet.addEventListener('focusout', handleFocusOut); return () => { sheet.removeEventListener('focusin', handleFocusIn); sheet.removeEventListener('focusout', handleFocusOut); if (scrollIntoViewTimerRef.current != null) { window.clearTimeout(scrollIntoViewTimerRef.current); } }; }, [heightBounds.initial, heightBounds.max, viewport.height]); return (
0 ? undefined : 'height 140ms ease, bottom 180ms ease', }} >
{legend && (
{legend}
)}
{children}
); }