summaryrefslogtreecommitdiffhomepage
path: root/src/features/tabs/ui/tab-flip.ts
blob: ac9daaf2fb8c59d22f886a378526c50790b7b170 (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
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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
import { gsap } from "gsap";
import { isGroupFlipId, leavingFlipIds } from "../tab-flip";

/**
 * FLIP animation for the tab grid — the DOM EFFECT half (the pure id scheme
 * lives in `../tab-flip.ts`).
 *
 * Driven by the owning component in two calls:
 *  - `capture(nextIds)` — BEFORE the DOM updates: measure every
 *    `[data-flip-id]` element (First), and build an inert GHOST for each
 *    element the next render drops (a closed tab / dissolved group box).
 *  - `play()` — AFTER the DOM updates: measure again (Last), then run three
 *    STAGGERED phases, each only when it has something to do —
 *      1. SHRINK — ghosts collapse to 0 height (everything else holds still);
 *      2. MOVE   — cards slide from their old box to their new one, and each
 *                  group box's tint morphs from its old rect to its new one;
 *      3. GROW   — new cards (and new group boxes' tints) grow from 0 height.
 *
 * PERFORMANCE: this is a hand-rolled FLIP, not GSAP's Flip plugin. Flip
 * recorded computed styles per element and re-measured while building its
 * timeline — ~430ms of main-thread work per close with ~40 nested tabs, which
 * both delayed the click and swallowed the shrink phase. Here each change
 * costs exactly two batched layout reads (one in `capture`, one in `play`);
 * every write happens after its batch of reads. Each phase is ONE GSAP tween
 * on a plain progress value whose `onUpdate` writes inline styles directly —
 * GSAP never reads an element's computed style (per-element CSS tweens did,
 * forcing a style recalc per moved card). Cards only TRANSLATE (plus a
 * width tween when a card turns into / out of a double-width group parent);
 * a group box never moves itself — only its absolutely-positioned, empty tint
 * layer does (inset tween), so no card is ever double-translated.
 *
 * Like the sidebar panels (`layout/ui/panel-motion.ts`), shrink/grow change
 * only a HEIGHT, clipped — contents keep their natural size. Grow is a
 * `clip-path` reveal so the settled grid never shifts under it.
 *
 * Data-driven, not click-driven: it animates ANY change to the tab set (a
 * summoned subagent, a close from another device, a regroup). Re-entrant —
 * `capture` measures the in-flight VISUAL boxes, `play` clears in-flight
 * motion before measuring the new layout. Disabled when the user prefers
 * reduced motion (or the environment can't tell, e.g. jsdom).
 */
export interface TabFlip {
  capture(nextIds: ReadonlySet<string>): void;
  play(): void;
}

const SHRINK = 0.1;
const MOVE = 0.1;
const GROW = 0.1;
/** Close: starts slow, finishes fast (accelerates away). */
const SHRINK_EASE = "power2.in";
/** Move: slow → fast → slow. */
const MOVE_EASE = "power2.inOut";
/** Open: starts fast, settles slowly (the mirror of close). */
const GROW_EASE = "power2.out";
const GHOST_ATTR = "data-tab-ghost";
/** Fully clipped from the bottom edge up (0 visible height). */
const CLIP_HIDDEN = "inset(0% 0% 100% 0%)";
/** Every inline style this module may leave on a live element mid-flight. */
const MOTION_PROPS = ["transform", "width", "clip-path", "top", "right", "bottom", "left"];

interface Captured {
  /** Visual box (incl. any in-flight motion) per flip id. Cards: the card; boxes: the tint. */
  readonly rects: ReadonlyMap<string, DOMRect>;
  /** Built but not yet started — their shrink starts in `play`. */
  readonly ghosts: readonly HTMLElement[];
}

export function createTabFlip(getScroller: () => HTMLElement | undefined): TabFlip {
  let captured: Captured | null = null;
  /** Live elements carrying motion styles, so `play` can reset exactly those. */
  const animating = new Set<HTMLElement>();
  /** The running MOVE + GROW phase tweens (ghost shrinks run independently). */
  let phases: gsap.core.Tween[] = [];

  return {
    capture(nextIds) {
      captured = null;
      const scroller = getScroller();
      if (scroller === undefined || !motionAllowed()) return;
      const els = flipElements(scroller);
      if (els.length === 0) return;

      // READS (one layout).
      const rects = new Map<string, DOMRect>();
      for (const el of els) rects.set(idOf(el), measureTarget(el).getBoundingClientRect());
      const leaving = leavingFlipIds([...rects.keys()], nextIds);
      const byId = new Map(els.map((el) => [idOf(el), el]));
      const plans = leaving
        .map((id) => byId.get(id))
        .filter((el): el is HTMLElement => el !== undefined)
        .map((el) => planGhost(scroller, el, rects.get(idOf(el))))
        .filter((p): p is GhostPlan => p !== null);

      // WRITES.
      captured = { rects, ghosts: plans.map(insertGhost) };
    },

    play() {
      const prev = captured;
      captured = null;
      const scroller = getScroller();
      if (prev === null || scroller === undefined) return;

      // Reset in-flight motion on live elements so the read below is the
      // settled layout (their previous VISUAL boxes are already in `prev`).
      for (const tween of phases) tween.kill();
      phases = [];
      for (const el of animating) clearMotion(el);
      animating.clear();

      // READS (one layout).
      const next = flipElements(scroller).map((el) => {
        const target = measureTarget(el);
        return { target, id: idOf(el), rect: target.getBoundingClientRect() };
      });

      // Plan (pure arithmetic — no DOM access).
      const entering: HTMLElement[] = [];
      const movers: Mover[] = [];
      for (const { target, id, rect } of next) {
        const before = prev.rects.get(id);
        if (before === undefined) entering.push(target);
        else if (!sameBox(before, rect))
          movers.push(planMove(target, isGroupFlipId(id), before, rect));
      }
      const moveAt = prev.ghosts.length > 0 ? SHRINK : 0;
      const growAt = moveAt + (movers.length > 0 ? MOVE : 0);

      // WRITES — first frame of every phase NOW (movers hold at their old
      // boxes, new elements are fully clipped) so nothing flashes in between.
      for (const m of movers) m.render(0);
      for (const el of entering) el.style.clipPath = CLIP_HIDDEN;

      // 1. SHRINK — one tween drives every ghost.
      if (prev.ghosts.length > 0) {
        const ghosts = prev.ghosts.map((g) => ({ g, h: Number.parseFloat(g.style.height) }));
        gsap.to(
          { p: 0 },
          {
            p: 1,
            duration: SHRINK,
            ease: SHRINK_EASE,
            onUpdate(this: gsap.core.Tween) {
              const p = this.targets<{ p: number }>()[0]?.p ?? 1;
              for (const { g, h } of ghosts) g.style.height = `${h * (1 - p)}px`;
            },
            onComplete: () => {
              for (const { g } of ghosts) g.remove();
            },
          },
        );
      }

      // 2. MOVE — one tween drives every card slide + tint morph.
      if (movers.length > 0) {
        for (const m of movers) animating.add(m.el);
        phases.push(
          gsap.to(
            { p: 0 },
            {
              p: 1,
              duration: MOVE,
              delay: moveAt,
              ease: MOVE_EASE,
              onUpdate(this: gsap.core.Tween) {
                const p = this.targets<{ p: number }>()[0]?.p ?? 1;
                for (const m of movers) m.render(p);
              },
              onComplete: () => {
                for (const m of movers) {
                  clearMotion(m.el);
                  animating.delete(m.el);
                }
              },
            },
          ),
        );
      }

      // 3. GROW — one tween reveals every new card / tint from its top edge.
      if (entering.length > 0) {
        for (const el of entering) animating.add(el);
        phases.push(
          gsap.to(
            { p: 0 },
            {
              p: 1,
              duration: GROW,
              delay: growAt,
              ease: GROW_EASE,
              onUpdate(this: gsap.core.Tween) {
                const p = this.targets<{ p: number }>()[0]?.p ?? 1;
                const clip = `inset(0% 0% ${(1 - p) * 100}% 0%)`;
                for (const el of entering) el.style.clipPath = clip;
              },
              onComplete: () => {
                for (const el of entering) {
                  clearMotion(el);
                  animating.delete(el);
                }
              },
            },
          ),
        );
      }
    },
  };
}

interface Mover {
  readonly el: HTMLElement;
  /** Write the element's styles at move progress `p` (0 = old box, 1 = home). */
  render(p: number): void;
}

/**
 * A card slides (and, when it turned into / out of a double-width group
 * parent, resizes) from its old box; a group tint morphs its insets from its
 * old rect. Both end at their natural layout (`p = 1` ⇒ zero offsets).
 */
function planMove(el: HTMLElement, isTint: boolean, from: DOMRect, to: DOMRect): Mover {
  if (isTint) {
    const top = from.top - to.top;
    const left = from.left - to.left;
    const right = to.right - from.right;
    const bottom = to.bottom - from.bottom;
    return {
      el,
      render(p) {
        const k = 1 - p;
        el.style.top = `${top * k}px`;
        el.style.left = `${left * k}px`;
        el.style.right = `${right * k}px`;
        el.style.bottom = `${bottom * k}px`;
      },
    };
  }
  const dx = from.left - to.left;
  const dy = from.top - to.top;
  const resized = Math.abs(from.width - to.width) >= 0.5;
  return {
    el,
    render(p) {
      const k = 1 - p;
      el.style.transform = `translate(${dx * k}px, ${dy * k}px)`;
      if (resized) el.style.width = `${to.width + (from.width - to.width) * k}px`;
    },
  };
}

function clearMotion(el: HTMLElement): void {
  for (const prop of MOTION_PROPS) el.style.removeProperty(prop);
}

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

function flipElements(scroller: HTMLElement): HTMLElement[] {
  return [...scroller.querySelectorAll<HTMLElement>("[data-flip-id]")];
}

function idOf(el: HTMLElement): string {
  return el.dataset.flipId ?? "";
}

/** What VISUALLY represents a flip element: a card itself, a group box's tint. */
function measureTarget(el: HTMLElement): HTMLElement {
  if (!isGroupFlipId(idOf(el))) return el;
  return el.querySelector<HTMLElement>(":scope > [data-group-backdrop]") ?? el;
}

/** Whether two rects are the same box (sub-pixel tolerance). */
function sameBox(a: DOMRect, b: DOMRect): boolean {
  const close = (x: number, y: number) => Math.abs(x - y) < 0.5;
  return (
    close(a.left, b.left) &&
    close(a.top, b.top) &&
    close(a.width, b.width) &&
    close(a.height, b.height)
  );
}

interface GhostPlan {
  readonly source: HTMLElement;
  readonly host: HTMLElement;
  readonly isGroup: boolean;
  readonly left: number;
  readonly top: number;
  readonly width: number;
  readonly height: number;
}

/**
 * READ half of a ghost: where an inert copy of a leaving element must sit.
 *
 * Paint order matters (cards are `relative`, so everything here stacks in DOM
 * order): a leaving CARD's ghost goes on the scroller, ON TOP of the grid. A
 * leaving GROUP BOX copies only its tint and goes FIRST in the box's
 * enclosing grid: above the enclosing box's own tint, but BELOW every card.
 */
function planGhost(
  scroller: HTMLElement,
  el: HTMLElement,
  rect: DOMRect | undefined,
): GhostPlan | null {
  const isGroup = isGroupFlipId(idOf(el));
  const source = measureTarget(el);
  const host = isGroup ? el.parentElement : scroller;
  if (host === null || rect === undefined || (isGroup && source === el)) return null;
  const h = host.getBoundingClientRect();
  return {
    source,
    host,
    isGroup,
    left: rect.left - h.left - host.clientLeft + host.scrollLeft,
    top: rect.top - h.top - host.clientTop + host.scrollTop,
    width: rect.width,
    height: rect.height,
  };
}

/**
 * WRITE half of a ghost: a clipping frame (whose height later shrinks to 0)
 * around a full-size copy — only the frame animates, the copy never resizes.
 */
function insertGhost(plan: GhostPlan): HTMLElement {
  const copy = plan.source.cloneNode(!plan.isGroup) as HTMLElement;
  // A ghost is never a tab/group to queries, tests or assistive tech.
  for (const node of [copy, ...copy.querySelectorAll<HTMLElement>("*")]) {
    for (const attr of [
      "data-flip-id",
      "data-conversation-id",
      "data-group-backdrop",
      "role",
      "id",
    ]) {
      node.removeAttribute(attr);
    }
  }
  Object.assign(copy.style, {
    position: "absolute",
    inset: "auto",
    left: "0",
    top: "0",
    width: `${plan.width}px`,
    height: `${plan.height}px`,
    margin: "0",
    transform: "none",
    clipPath: "none",
  });
  const ghost = document.createElement("div");
  ghost.setAttribute(GHOST_ATTR, "");
  ghost.setAttribute("aria-hidden", "true");
  ghost.inert = true;
  Object.assign(ghost.style, {
    position: "absolute",
    left: `${plan.left}px`,
    top: `${plan.top}px`,
    width: `${plan.width}px`,
    height: `${plan.height}px`,
    overflow: "hidden",
    pointerEvents: "none",
    zIndex: plan.isGroup ? "auto" : "2",
  });
  ghost.appendChild(copy);
  if (plan.isGroup) plan.host.prepend(ghost);
  else plan.host.appendChild(ghost);
  return ghost;
}