summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-21 21:12:52 +0900
committerAdam Malczewski <[email protected]>2026-10-21 21:12:52 +0900
commit4b1a139140236f82ad0c749c7ef2175b3aaae25f (patch)
treedb680b39854e50685bcb3fe84a72b9f888f6a25b /src/features
parentbbf9728721bd25fc5b0532c16088e9e025dadb40 (diff)
downloaddispatch-web-4b1a139140236f82ad0c749c7ef2175b3aaae25f.tar.gz
dispatch-web-4b1a139140236f82ad0c749c7ef2175b3aaae25f.zip
feat(tabs): staggered shrink → move → grow tab animation (height-only, no fades)
Diffstat (limited to 'src/features')
-rw-r--r--src/features/tabs/ui.test.ts15
-rw-r--r--src/features/tabs/ui/tab-flip.ts181
2 files changed, 135 insertions, 61 deletions
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index 408ec6e..adfb3fb 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -1,5 +1,6 @@
import { fireEvent, render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
+import { gsap } from "gsap";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Tab } from "./tabs";
import TabList from "./ui/TabList.svelte";
@@ -461,7 +462,14 @@ describe("TabList", () => {
const grid = screen.getByRole("tablist");
const boxGhost = grid.firstElementChild as HTMLElement;
expect(boxGhost).toHaveAttribute("data-tab-ghost");
- expect(boxGhost.childElementCount).toBe(0); // tint only, no card copies
+ // A clipping frame around a copy of the tint only — no card copies.
+ expect(boxGhost.childElementCount).toBe(1);
+ expect(boxGhost.firstElementChild?.childElementCount).toBe(0);
+ // Ghosts SHRINK (a clipped height), they don't fade or scale.
+ for (const g of ghosts) {
+ expect(g.style.overflow).toBe("hidden");
+ expect(gsap.getTweensOf(g)).toHaveLength(1);
+ }
const cardGhost = ghosts.find((g) => g !== boxGhost);
expect(cardGhost?.parentElement).toBe(grid.parentElement);
expect(cardGhost).toHaveTextContent("Child 1");
@@ -479,6 +487,11 @@ describe("TabList", () => {
await rerender({ ...props, tabs: grouped });
expect(document.querySelector("[data-tab-ghost]")).toBeNull();
expect(screen.getAllByRole("tab")).toHaveLength(grouped.length);
+ // New cards start fully clipped (0 visible height) and grow in.
+ for (const id of ["c1", "c2", "g"]) {
+ expect(card(id).style.clipPath).toBe("inset(0% 0% 100% 0%)");
+ expect(gsap.getTweensOf(card(id))).toHaveLength(1);
+ }
});
});
diff --git a/src/features/tabs/ui/tab-flip.ts b/src/features/tabs/ui/tab-flip.ts
index 9d0f0f8..4be0b15 100644
--- a/src/features/tabs/ui/tab-flip.ts
+++ b/src/features/tabs/ui/tab-flip.ts
@@ -8,14 +8,22 @@ gsap.registerPlugin(Flip);
* GSAP Flip animation for the tab grid — the DOM EFFECT half of the tab
* FLIP (the pure id scheme lives in `../tab-flip.ts`).
*
- * Two-phase, driven by the owning component:
+ * Driven by the owning component in two calls:
* - `capture(nextIds)` — BEFORE the DOM updates: record every `[data-flip-id]`
* element's position (First), and spawn an inert GHOST for each element the
- * next render drops, so a closed tab / dissolved group box fades out in
- * place instead of vanishing.
- * - `play()` — AFTER the DOM updates: Flip every surviving element from its
- * recorded position to its new one (Last → Invert → Play), scale new cards
- * in, and fade new group boxes' backdrops in.
+ * next render drops (a closed tab / dissolved group box).
+ * - `play()` — AFTER the DOM updates: 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 — surviving elements Flip from their old box to their new
+ * one (Last → Invert → Play);
+ * 3. GROW — new cards (and new group boxes' tints) grow from 0 height.
+ *
+ * Like the sidebar panels (`layout/ui/panel-motion.ts`), shrink/grow change
+ * only a HEIGHT, clipped — the contents keep their natural size and are
+ * revealed / hidden from the bottom edge, never scaled or faded. Grow is a
+ * `clip-path` reveal so the grid's layout (already settled by MOVE) 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 —
@@ -27,67 +35,105 @@ export interface TabFlip {
play(): void;
}
-const DURATION = 0.4;
+const SHRINK = 0.1;
+const MOVE = 0.15;
+const GROW = 0.1;
+const SIZE_EASE = "power2.inOut";
+const MOVE_EASE = "power3.out";
const GHOST_ATTR = "data-tab-ghost";
+/** Fully clipped from the bottom edge up / fully revealed. */
+const CLIP_HIDDEN = "inset(0% 0% 100% 0%)";
+const CLIP_SHOWN = "inset(0% 0% 0% 0%)";
+
+interface Captured {
+ readonly state: ReturnType<typeof Flip.getState>;
+ readonly rects: ReadonlyMap<string, DOMRect>;
+ readonly ghosted: boolean;
+}
export function createTabFlip(getScroller: () => HTMLElement | undefined): TabFlip {
- let state: ReturnType<typeof Flip.getState> | null = null;
+ let captured: Captured | null = null;
return {
capture(nextIds) {
- state = null;
+ captured = null;
const scroller = getScroller();
if (scroller === undefined || !motionAllowed()) return;
const els = flipElements(scroller);
if (els.length === 0) return;
- state = Flip.getState(els);
+ const rects = new Map(els.map((el) => [el.dataset.flipId ?? "", el.getBoundingClientRect()]));
const byId = new Map(els.map((el) => [el.dataset.flipId ?? "", el]));
+ let ghosted = false;
for (const id of leavingFlipIds([...byId.keys()], nextIds)) {
const el = byId.get(id);
- if (el !== undefined) spawnGhost(scroller, el, isGroupFlipId(id));
+ if (el !== undefined) ghosted = spawnGhost(scroller, el, isGroupFlipId(id)) || ghosted;
}
+ captured = { state: Flip.getState(els), rects, ghosted };
},
play() {
- const captured = state;
- state = null;
+ const prev = captured;
+ captured = null;
const scroller = getScroller();
- if (captured === null || scroller === undefined) return;
- Flip.from(captured, {
- targets: flipElements(scroller),
- duration: DURATION,
- ease: "power3.out",
- nested: true,
- onEnter: (elements) => {
- for (const el of elements) {
- if (!(el instanceof HTMLElement)) continue;
- if (isGroupFlipId(el.dataset.flipId ?? "")) {
- // The box's CARDS are flipping into place — fade only its tint.
- const backdrop = el.querySelector<HTMLElement>(":scope > [data-group-backdrop]");
- if (backdrop !== null) {
- gsap.fromTo(backdrop, { opacity: 0 }, { opacity: 1, duration: DURATION });
- }
- } else {
- gsap.fromTo(
- el,
- { opacity: 0, scale: 0.6 },
- {
- opacity: 1,
- scale: 1,
- duration: DURATION,
- delay: DURATION * 0.35, // let the grid open the gap first
- ease: "back.out(1.7)",
- clearProps: "opacity,transform",
- },
- );
- }
- }
- },
+ if (prev === null || scroller === undefined) return;
+
+ const els = flipElements(scroller);
+ const staying = els.filter((el) => prev.rects.has(el.dataset.flipId ?? ""));
+ const entering = els.filter((el) => !prev.rects.has(el.dataset.flipId ?? ""));
+ const moved = staying.some((el) => {
+ const before = prev.rects.get(el.dataset.flipId ?? "");
+ return before !== undefined && !sameBox(before, el.getBoundingClientRect());
});
+
+ // Phase timing: skip a phase that has nothing to animate.
+ const moveAt = prev.ghosted ? SHRINK : 0;
+ const growAt = moveAt + (moved ? MOVE : 0);
+
+ // 3. GROW — hide new elements NOW (through shrink + move), reveal later.
+ for (const el of entering) {
+ const target = isGroupFlipId(el.dataset.flipId ?? "")
+ ? el.querySelector<HTMLElement>(":scope > [data-group-backdrop]") // tint only
+ : el;
+ if (target === null) continue;
+ gsap.killTweensOf(target, "clipPath");
+ gsap.fromTo(
+ target,
+ { clipPath: CLIP_HIDDEN },
+ {
+ clipPath: CLIP_SHOWN,
+ duration: GROW,
+ delay: growAt,
+ ease: SIZE_EASE,
+ clearProps: "clipPath",
+ },
+ );
+ }
+
+ // 2. MOVE — Flip holds survivors at their old boxes until `moveAt`.
+ if (moved) {
+ Flip.from(prev.state, {
+ targets: staying,
+ duration: MOVE,
+ delay: moveAt,
+ ease: MOVE_EASE,
+ nested: true,
+ });
+ }
},
};
}
+/** 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)
+ );
+}
+
function motionAllowed(): boolean {
return (
typeof window.matchMedia === "function" &&
@@ -101,26 +147,29 @@ function flipElements(scroller: HTMLElement): HTMLElement[] {
/**
* An inert visual copy of a leaving element, absolutely positioned over its
- * last on-screen rect, that shrinks + fades out and removes itself.
+ * last on-screen rect, that SHRINKS to 0 height (phase 1) and removes itself.
+ * The ghost is a clipping frame around a full-size copy, so only the frame's
+ * height animates — the copy inside never resizes or reflows.
*
* Paint order matters (cards are `relative`, so everything here stacks in DOM
- * order): a leaving CARD's ghost is appended to the scroller so it fades out
- * ON TOP of the reflow. A leaving GROUP BOX copies only its backdrop (the
+ * order): a leaving CARD's ghost is appended to the scroller so it shrinks
+ * ON TOP of the grid. A leaving GROUP BOX copies only its backdrop (the
* tint — its cards either flip elsewhere or ghost themselves) and is inserted
* as the FIRST child of the box's enclosing grid: above the enclosing box's
- * own tint, but BELOW every card, so the dissolving tint never dims the cards
- * flying out of it.
+ * own tint, but BELOW every card, so the dissolving tint never covers them.
+ *
+ * Returns whether a ghost was spawned.
*/
-function spawnGhost(scroller: HTMLElement, el: HTMLElement, isGroup: boolean): void {
+function spawnGhost(scroller: HTMLElement, el: HTMLElement, isGroup: boolean): boolean {
const source = isGroup ? el.querySelector<HTMLElement>(":scope > [data-group-backdrop]") : el;
- if (source === null) return;
+ if (source === null) return false;
const host = isGroup ? el.parentElement : scroller;
- if (host === null) return;
+ if (host === null) return false;
const r = el.getBoundingClientRect();
const h = host.getBoundingClientRect();
- const ghost = source.cloneNode(!isGroup) as HTMLElement;
+ const copy = source.cloneNode(!isGroup) as HTMLElement;
// A ghost is never a tab/group to queries, tests or assistive tech.
- for (const node of [ghost, ...ghost.querySelectorAll<HTMLElement>("*")]) {
+ for (const node of [copy, ...copy.querySelectorAll<HTMLElement>("*")]) {
for (const attr of [
"data-flip-id",
"data-conversation-id",
@@ -131,6 +180,18 @@ function spawnGhost(scroller: HTMLElement, el: HTMLElement, isGroup: boolean): v
node.removeAttribute(attr);
}
}
+ // The copy keeps the element's full size for the whole animation.
+ Object.assign(copy.style, {
+ position: "absolute",
+ left: "0",
+ top: "0",
+ width: `${r.width}px`,
+ height: `${r.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;
@@ -140,18 +201,18 @@ function spawnGhost(scroller: HTMLElement, el: HTMLElement, isGroup: boolean): v
top: `${r.top - h.top - host.clientTop + host.scrollTop}px`,
width: `${r.width}px`,
height: `${r.height}px`,
- margin: "0",
- transform: "none",
+ overflow: "hidden",
pointerEvents: "none",
zIndex: isGroup ? "auto" : "2",
});
+ ghost.appendChild(copy);
if (isGroup) host.prepend(ghost);
else host.appendChild(ghost);
gsap.to(ghost, {
- opacity: 0,
- scale: isGroup ? 1 : 0.6,
- duration: DURATION * 0.75,
- ease: "power2.in",
+ height: 0,
+ duration: SHRINK,
+ ease: SIZE_EASE,
onComplete: () => ghost.remove(),
});
+ return true;
}