2026-07-13 08:07:51 -07:00
|
|
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
|
import { useVisualViewportInsets } from '../../hooks/useVisualViewport';
|
2026-07-13 06:54:15 -07:00
|
|
|
|
|
|
|
|
export interface BottomSheetProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
title: string;
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 08:07:51 -07:00
|
|
|
/** 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;
|
|
|
|
|
|
2026-07-13 06:54:15 -07:00
|
|
|
/**
|
|
|
|
|
* 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.
|
2026-07-13 08:07:51 -07:00
|
|
|
*
|
|
|
|
|
* 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).
|
2026-07-13 06:54:15 -07:00
|
|
|
*/
|
|
|
|
|
export const BottomSheet: React.FC<BottomSheetProps> = ({ 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]);
|
|
|
|
|
|
2026-07-13 08:07:51 -07:00
|
|
|
const { keyboardInset } = useVisualViewportInsets();
|
|
|
|
|
|
|
|
|
|
const bodyRef = useRef<HTMLDivElement>(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<number | null>(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]);
|
|
|
|
|
|
2026-07-13 06:54:15 -07:00
|
|
|
if (!open) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className="mobile-sheet-backdrop"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
if (e.target === e.currentTarget) onClose();
|
|
|
|
|
}}
|
2026-07-13 08:07:51 -07:00
|
|
|
style={{ ['--keyboard-inset' as any]: `${keyboardInset}px` }}
|
2026-07-13 06:54:15 -07:00
|
|
|
>
|
2026-07-13 07:30:12 -07:00
|
|
|
{/* 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. */}
|
2026-07-13 08:07:51 -07:00
|
|
|
<div
|
|
|
|
|
className="mobile-sheet"
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-label={title}
|
|
|
|
|
style={{
|
|
|
|
|
transform: dragOffset ? `translateY(${dragOffset}px)` : undefined,
|
|
|
|
|
transition: dragOffset ? 'none' : 'transform 0.2s ease',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className="mobile-sheet-handle-row"
|
|
|
|
|
onTouchStart={handleTouchStart}
|
|
|
|
|
onTouchMove={handleTouchMove}
|
|
|
|
|
onTouchEnd={handleTouchEnd}
|
|
|
|
|
onTouchCancel={handleTouchEnd}
|
|
|
|
|
>
|
2026-07-13 06:54:15 -07:00
|
|
|
<span className="mobile-sheet-handle" aria-hidden="true" />
|
|
|
|
|
</div>
|
2026-07-13 08:07:51 -07:00
|
|
|
<div
|
|
|
|
|
className="mobile-sheet-header"
|
|
|
|
|
onTouchStart={handleTouchStart}
|
|
|
|
|
onTouchMove={handleTouchMove}
|
|
|
|
|
onTouchEnd={handleTouchEnd}
|
|
|
|
|
onTouchCancel={handleTouchEnd}
|
|
|
|
|
>
|
2026-07-13 06:54:15 -07:00
|
|
|
<span className="mobile-sheet-title">{title}</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="mobile-sheet-close"
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
aria-label={`Close ${title}`}
|
|
|
|
|
>
|
|
|
|
|
<i className="fa fa-times" aria-hidden="true" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-07-13 08:07:51 -07:00
|
|
|
<div className="mobile-sheet-body" ref={bodyRef}>{children}</div>
|
2026-07-13 06:54:15 -07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|