Spaces:
Sleeping
Sleeping
File size: 4,158 Bytes
391340f 709db16 391340f 709db16 391340f 709db16 391340f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 | /** 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>
);
}
|