diff options
| author | Adam Malczewski <[email protected]> | 2026-10-21 21:12:52 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-21 21:12:52 +0900 |
| commit | 4b1a139140236f82ad0c749c7ef2175b3aaae25f (patch) | |
| tree | db680b39854e50685bcb3fe84a72b9f888f6a25b /src/features | |
| parent | bbf9728721bd25fc5b0532c16088e9e025dadb40 (diff) | |
| download | dispatch-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.ts | 15 | ||||
| -rw-r--r-- | src/features/tabs/ui/tab-flip.ts | 181 |
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; } |
