summaryrefslogtreecommitdiffhomepage
path: root/src/features/layout/ui/panel-motion.ts
blob: 7fc5edf66d9860ae09e604179b474ec1cb17e058 (plain)
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
import { gsap } from "gsap";
import type { TransitionConfig } from "svelte/transition";

/**
 * GSAP height animations for sidebar panels — the DOM EFFECT half (stable
 * panel identity lives in `../logic/panel-keys.ts`).
 *
 * Every animation changes ONLY the panel wrapper's height (clipped with
 * `overflow: hidden`); the panel's contents keep their natural size the whole
 * time — the wrapper reveals / hides them like a drawer, it never scales
 * them. The wrapper's bottom padding is the gap between panels, so it grows /
 * shrinks with the panel and the panels below slide smoothly.
 *
 *  - `panelGrowIn`  — Svelte `in:` transition: an added panel grows 0 → auto.
 *  - `panelShrinkOut` — Svelte `out:` transition: a closed panel shrinks to 0
 *    while staying in flow (Svelte keeps the node for the returned duration).
 *  - `morphPanelHeight` — a swapped view: the panel BOX (the well, so its
 *    edges move with it — no empty gap below a shrinking box) animates from
 *    the old view's height to the new one's.
 *
 * Svelte's `in:` does not run on first mount, so loading the page never
 * animates. Disabled when the user prefers reduced motion (or the
 * environment can't tell, e.g. jsdom) — then every change is instant.
 */

const DURATION = 0.35;
const EASE = "power2.inOut";

export function panelMotionAllowed(): boolean {
  return (
    typeof window !== "undefined" &&
    typeof window.matchMedia === "function" &&
    !window.matchMedia("(prefers-reduced-motion: reduce)").matches
  );
}

export function panelGrowIn(node: HTMLElement): TransitionConfig {
  if (!panelMotionAllowed()) return { duration: 0 };
  gsap.killTweensOf(node);
  gsap.fromTo(
    node,
    { height: 0, overflow: "hidden" },
    {
      height: "auto",
      duration: DURATION,
      ease: EASE,
      clearProps: "height,overflow",
    },
  );
  return { duration: DURATION * 1000 };
}

export function panelShrinkOut(node: HTMLElement): TransitionConfig {
  if (!panelMotionAllowed()) return { duration: 0 };
  // e.g. closed mid-grow / mid-swap: shrink from wherever it is.
  gsap.killTweensOf([node, ...node.querySelectorAll(":scope > *")]);
  gsap.to(node, {
    height: 0,
    overflow: "hidden",
    duration: DURATION,
    ease: EASE,
  });
  return { duration: DURATION * 1000 };
}

/**
 * Animate `node` from `fromHeight` (its height before the view swapped) to
 * its new natural height. Call AFTER the DOM shows the new view.
 */
export function morphPanelHeight(node: HTMLElement, fromHeight: number): void {
  if (!panelMotionAllowed()) return;
  gsap.killTweensOf(node);
  gsap.fromTo(
    node,
    { height: fromHeight, overflow: "hidden" },
    {
      height: "auto",
      duration: DURATION,
      ease: EASE,
      clearProps: "height,overflow",
    },
  );
}