Mbonea's picture
Overhaul journal UI: pending resolve sheet, gold FAB nav, and Home week score.
709db16
Raw
History Blame Contribute Delete
4.16 kB
/** 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>
);
}