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>
  );
}