Spaces:
Sleeping
Sleeping
| /** 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<HTMLDivElement>(null); | |
| const scrim = useRef<HTMLButtonElement>(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 ( | |
| <div class="sheet-layer" role="presentation"> | |
| <button ref={scrim} class="sheet-scrim" aria-label="Close" onClick={onClose} /> | |
| <div | |
| ref={panel} | |
| class={`sheet-panel sheet-material ${tall ? "sheet-tall" : ""}`.trim()} | |
| role="dialog" | |
| aria-modal="true" | |
| aria-label={title} | |
| > | |
| <div | |
| class="sheet-handle-zone" | |
| onPointerDown={onPointerDown} | |
| onPointerMove={onPointerMove} | |
| onPointerUp={onPointerUp} | |
| onPointerCancel={onPointerUp} | |
| > | |
| <span class="sheet-handle" /> | |
| <h2>{title}</h2> | |
| </div> | |
| <div class="sheet-content">{children}</div> | |
| </div> | |
| </div> | |
| ); | |
| } | |