import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useVisualViewportInsets } from '../../hooks/useVisualViewport'; export interface BottomSheetProps { open: boolean; onClose: () => void; title: string; children: React.ReactNode; } /** Vertical drag distance (px) past which releasing the handle/header * commits to closing the sheet, rather than snapping back open. */ const SWIPE_DISMISS_THRESHOLD_PX = 80; /** * Mobile-only bottom sheet (Phase A). Slides up from the bottom of the * viewport to host one of the existing side-panel components * (BlocksPanel/PagesPanel/LayersPanel/AssetsPanel/GuidedStyles) unchanged, * as the mobile replacement for the desktop fixed left/right panel columns. * See `MobilePanelBar`, which owns which sheet (if any) is open. * * Deliberately NOT a generic replacement for `src/ui/Modal.tsx` (centered * dialog chrome) -- this is anchored to the bottom, sized to ~65dvh, and * has its own drag-handle affordance + internally scrollable body, all of * which Modal doesn't need for its centered use cases. * * Phase B adds two more deferred behaviors on top of Phase A's static sheet: * - **Swipe-to-dismiss**: a touchstart/move/end drag tracked on the handle * row + header (not the scrollable body, so it never fights a panel's own * vertical scroll) that follows the finger while dragging down and either * commits to `onClose()` past `SWIPE_DISMISS_THRESHOLD_PX` or snaps back * with a short transition otherwise. * - **On-screen-keyboard clearance**: `useVisualViewportInsets` reports how * much the visual viewport has shrunk from the bottom (i.e. the * keyboard's height); that's surfaced as a `--keyboard-inset` CSS custom * property (see editor.css) which both lifts the whole backdrop/sheet * clear of the keyboard AND caps the sheet's own max-height so it never * extends into the space the keyboard occupies. A `focusin` listener also * scrolls the newly-focused input into view once the keyboard has had a * moment to animate in, as a second line of defense for a field that's * still off-screen after the resize alone (e.g. deep in a long panel). */ export const BottomSheet: React.FC = ({ open, onClose, title, children }) => { useEffect(() => { if (!open) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); const prevOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { window.removeEventListener('keydown', handleKeyDown); document.body.style.overflow = prevOverflow; }; }, [open, onClose]); const { keyboardInset } = useVisualViewportInsets(); const bodyRef = useRef(null); useEffect(() => { if (!open) return; const bodyEl = bodyRef.current; if (!bodyEl) return; const handleFocusIn = (e: FocusEvent) => { const target = e.target as HTMLElement | null; if (!target) return; const tag = target.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable) { // Give the on-screen keyboard a moment to finish animating in // before scrolling -- doing it immediately measures the pre-keyboard // layout and can undershoot. window.setTimeout(() => { target.scrollIntoView({ block: 'center', behavior: 'smooth' }); }, 300); } }; bodyEl.addEventListener('focusin', handleFocusIn); return () => bodyEl.removeEventListener('focusin', handleFocusIn); }, [open]); // Swipe-to-dismiss: tracked only on the handle row + header (never the // scrollable `.mobile-sheet-body`, which needs its own vertical touch // scrolling to keep working unimpeded). // // The authoritative drag distance lives in a REF (`dragOffsetRef`), // updated synchronously and imperatively on every touchmove -- NOT solely // in the `dragOffset` state used for rendering the drag transform. Two // touchmoves can fire back-to-back inside the same synchronous event // dispatch (e.g. a fast real swipe, or synthetic touch events fired // without a yield between them), and React batches their `setDragOffset` // calls into a single pending update that hasn't committed yet by the // time `touchend` runs in that same tick -- reading the `dragOffset` // STATE value directly in `handleTouchEnd` would then see a stale // (pre-drag) value and wrongly decide the swipe didn't clear the // threshold. The ref sidesteps that entirely (plain synchronous mutation, // no batching). Relatedly, `onClose()` mutates an ANCESTOR component's // state (MobileChromeContext's `closeSheet`) -- it must be called as a // plain statement in the event handler, never from inside a `setState` // functional updater (that runs during this component's own render phase // and trips React's "Cannot update a component while rendering a // different component" warning). const dragStartYRef = useRef(null); const dragOffsetRef = useRef(0); const [dragOffset, setDragOffset] = useState(0); const handleTouchStart = useCallback((e: React.TouchEvent) => { if (e.touches.length !== 1) return; dragStartYRef.current = e.touches[0].clientY; }, []); const handleTouchMove = useCallback((e: React.TouchEvent) => { if (dragStartYRef.current === null) return; const delta = e.touches[0].clientY - dragStartYRef.current; // Only follow downward drags -- dragging up shouldn't do anything (the // sheet is already fully open; there's no "expand further" state). const next = Math.max(0, delta); dragOffsetRef.current = next; setDragOffset(next); }, []); const handleTouchEnd = useCallback(() => { dragStartYRef.current = null; const finalOffset = dragOffsetRef.current; dragOffsetRef.current = 0; setDragOffset(0); if (finalOffset > SWIPE_DISMISS_THRESHOLD_PX) onClose(); }, [onClose]); if (!open) return null; return (
{ if (e.target === e.currentTarget) onClose(); }} style={{ ['--keyboard-inset' as any]: `${keyboardInset}px` }} > {/* Deliberately NOT aria-modal: the tab bar stays reachable/operable while a sheet is open (tapping another tab swaps sheets directly), so the rest of the screen must not be marked inert to assistive tech the way a true modal dialog would be. */}
{title}