/** Interruptible bottom sheet with direct drag and projected release. */ import { animate } from "motion"; import type { ComponentChildren } from "preact"; import { useEffect, useRef } from "preact/hooks"; import { project, rubberband, velocityFromSamples, type PointerSample } from "../motion/gestures"; import { prefersReducedMotion, springs } from "../motion/springs"; type Props = { open: boolean; title: string; children: ComponentChildren; onClose: () => void; /** ~85% viewport for resolve flows. */ tall?: boolean; }; export function Sheet({ open, title, children, onClose, tall = false }: Props) { const panel = useRef(null); const scrim = useRef(null); const drag = useRef({ startY: 0, startOffset: 0, offset: 0, samples: [] as PointerSample[] }); const animation = useRef<{ cancel: () => void } | null>(null); const renderOffset = (offset: number) => { const el = panel.current; if (!el) return; const height = el.offsetHeight || 1; drag.current.offset = offset; el.style.transform = `translateY(${offset}px)`; if (scrim.current) scrim.current.style.opacity = String(0.45 * (1 - Math.min(1, offset / height))); }; const settle = (target: number, velocity = 0) => { const el = panel.current; if (!el) return; animation.current?.cancel(); if (prefersReducedMotion()) { renderOffset(target); if (target > 0) onClose(); return; } const controls = animate(el, { y: target }, { ...springs.sheet, velocity }); animation.current = controls; const height = el.offsetHeight || 1; animate(scrim.current!, { opacity: target ? 0 : 0.45 }, springs.press); controls.finished.then(() => { drag.current.offset = target; if (target >= height) onClose(); }).catch(() => {}); }; useEffect(() => { const el = panel.current; if (!open || !el) return; const height = el.offsetHeight; renderOffset(height); requestAnimationFrame(() => settle(0)); }, [open]); if (!open) return null; const onPointerDown = (event: PointerEvent) => { const el = panel.current; if (!el || event.button !== 0) return; animation.current?.cancel(); const matrix = new DOMMatrixReadOnly(getComputedStyle(el).transform); const current = Number.isFinite(matrix.m42) ? matrix.m42 : drag.current.offset; drag.current = { startY: event.clientY, startOffset: current, offset: current, samples: [{ t: performance.now(), x: event.clientX, y: event.clientY }], }; el.setPointerCapture(event.pointerId); }; const onPointerMove = (event: PointerEvent) => { const el = panel.current; if (!el || !el.hasPointerCapture(event.pointerId)) return; const raw = drag.current.startOffset + event.clientY - drag.current.startY; const offset = raw < 0 ? rubberband(raw, el.offsetHeight) : raw; drag.current.samples.push({ t: performance.now(), x: event.clientX, y: event.clientY }); renderOffset(offset); }; const onPointerUp = (event: PointerEvent) => { const el = panel.current; if (!el || !el.hasPointerCapture(event.pointerId)) return; el.releasePointerCapture(event.pointerId); const velocity = velocityFromSamples(drag.current.samples); const projected = drag.current.offset + project(velocity); settle(projected > el.offsetHeight * 0.45 ? el.offsetHeight : 0, velocity); }; return ( ); }