diff options
Diffstat (limited to 'src/features/layout/ui')
| -rw-r--r-- | src/features/layout/ui/TilePanels.svelte | 148 | ||||
| -rw-r--r-- | src/features/layout/ui/TileRoot.test.ts | 53 | ||||
| -rw-r--r-- | src/features/layout/ui/panel-motion.ts | 83 |
3 files changed, 252 insertions, 32 deletions
diff --git a/src/features/layout/ui/TilePanels.svelte b/src/features/layout/ui/TilePanels.svelte index 9162b00..cc5fa89 100644 --- a/src/features/layout/ui/TilePanels.svelte +++ b/src/features/layout/ui/TilePanels.svelte @@ -8,9 +8,17 @@ */ import type { LayoutOp, PanelsNode, Path } from "@dispatch/layout-contract"; import PlusIcon from "phosphor-svelte/lib/PlusIcon"; + import { untrack } from "svelte"; import { viewRegistry } from "../logic/registry"; import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "../logic/panels-edit"; + import { + initialPanelKeys, + type PanelKeys, + reconcilePanelKeys, + swappedPanels, + } from "../logic/panel-keys"; import { markerStyle } from "../logic/marker"; + import { morphPanelHeight, panelGrowIn, panelShrinkOut } from "./panel-motion"; import TileLeaf from "./TileLeaf.svelte"; let { @@ -38,45 +46,122 @@ // appear in the dropdowns. Reactive so late registrations re-render options. const panelViews = $derived($viewRegistry.filter((v) => v.panelEligible)); const addView = $derived(firstPanelView(panelViews)); + + // Stable per-panel keys (pure reconcile, memoised across tree updates) so a + // closed panel is the one that animates out, not whichever is last. + // A local ✕ click hints WHICH panel left (consumed by the next tree change; + // ignored if that change isn't exactly this removal, e.g. a remote edit). + let keyMemo: PanelKeys | null = null; + let removeHint: number | undefined; + const keyed = $derived.by(() => { + const views = node.panels.map((p) => p.view); + keyMemo = + keyMemo === null ? initialPanelKeys(views) : reconcilePanelKeys(keyMemo, views, removeHint); + removeHint = undefined; + return keyMemo; + }); + + function removePanel(idx: number): void { + removeHint = idx; + onEdit([removePanelOp(path, idx)]); + } + + // View SWAP: the panel BOX (the well) animates from the old view's height + // to the new one's. Measure before the DOM updates, animate after. (Adds/removes are + // the per-panel in:/out: transitions below.) + let columnEl = $state<HTMLDivElement | undefined>(); + /** The keys the DOM currently shows (set after each update). */ + let shownKeys: PanelKeys | null = null; + let before: { keys: PanelKeys; heights: Map<number, number> } | null = null; + $effect.pre(() => { + const next = keyed; + untrack(() => { + const heights = new Map<number, number>(); + for (const el of panelEls()) { + const well = wellOf(el); + if (well !== null) heights.set(Number(el.dataset.panelKey), well.offsetHeight); + } + before = { keys: shownKeys ?? next, heights }; + }); + }); + $effect(() => { + const now = keyed; + untrack(() => { + const prev = before; + before = null; + shownKeys = now; + if (prev === null) return; + for (const key of swappedPanels(prev.keys, now).keys()) { + const el = panelEls().find((e) => Number(e.dataset.panelKey) === key); + const well = el === undefined ? null : wellOf(el); + const from = prev.heights.get(key); + if (well !== null && from !== undefined) morphPanelHeight(well, from); + } + }); + }); + + function wellOf(panel: HTMLElement): HTMLElement | null { + return panel.querySelector<HTMLElement>(":scope > [data-panel-well]"); + } + + function panelEls(): HTMLElement[] { + return columnEl === undefined + ? [] + : [...columnEl.querySelectorAll<HTMLElement>(":scope > [data-panel-key]")]; + } </script> <!-- `shrink-0`: this column is the sidebar's SCROLLED content, so it keeps its natural height — if it shrank to the scroller (min-h-0 in a flex column), its children overflowed it and the trailing margin was never scrollable. --> +<!-- No flex `gap`: each panel's wrapper carries the gap as `pb-2` instead, so + it grows/shrinks WITH the panel's height animation (a flex gap would jump). --> <div - class="flex min-h-0 shrink-0 flex-col gap-2" + bind:this={columnEl} + class="flex min-h-0 shrink-0 flex-col" style={renderMarker ? markerStyle(node.marker) : undefined} + data-panels > - {#each node.panels as panel, idx (idx)} - <!-- `.inset-well` (app.css): recessed into the sidebar, like the chat's - thinking/tool blocks. --> - <div class="inset-well flex flex-col p-3"> - <div class="flex items-center gap-1"> - <select - class="select select-bordered select-sm flex-1" - aria-label="Select a view" - value={panel.view} - onchange={(e) => onEdit([swapPanelOp(path, idx, e.currentTarget.value)])} - > - <option value="" disabled>Select a view</option> - {#each panelViews as view (view.id)} - <option value={view.id}>{view.label}</option> - {/each} - </select> - {#if idx > 0} - <button - type="button" - class="btn btn-square btn-ghost btn-sm shrink-0" - aria-label="Remove view" - onclick={() => onEdit([removePanelOp(path, idx)])} + {#each node.panels as panel, idx (keyed.keys[idx])} + <!-- Add/close animate the WRAPPER's height (it carries the inter-panel gap, + so panels below slide smoothly); a view swap animates the WELL's + height. Either way only a height is tweened (clipped) — the contents + keep their natural size. `.inset-well` (app.css): recessed into the + sidebar, like the chat's thinking/tool blocks. --> + <div + class="shrink-0 pb-2" + data-panel-key={keyed.keys[idx]} + in:panelGrowIn + out:panelShrinkOut + > + <div class="inset-well flex flex-col p-3" data-panel-well> + <div class="flex items-center gap-1"> + <select + class="select select-bordered select-sm flex-1" + aria-label="Select a view" + value={panel.view} + onchange={(e) => onEdit([swapPanelOp(path, idx, e.currentTarget.value)])} > - ✕ - </button> - {/if} - </div> + <option value="" disabled>Select a view</option> + {#each panelViews as view (view.id)} + <option value={view.id}>{view.label}</option> + {/each} + </select> + {#if idx > 0} + <button + type="button" + class="btn btn-square btn-ghost btn-sm shrink-0" + aria-label="Remove view" + onclick={() => removePanel(idx)} + > + ✕ + </button> + {/if} + </div> - <div class="mt-2"> - <TileLeaf node={panel} {ports} /> + <div class="mt-2"> + <TileLeaf node={panel} {ports} /> + </div> </div> </div> {/each} @@ -85,8 +170,9 @@ <!-- `.raised-btn` (app.css): the mirror of the view wells above it — pops out where they sink in; pressed, it sinks in like them. Height = DaisyUI's default button size (`--size-field` × 10), as the `btn` it - replaced; `shrink-0` so the column can never squash it. `mb-2` = the - gap between views (`gap-2`), so it doesn't sit tight to the end. --> + replaced; `shrink-0` so the column can never squash it. The last panel's + `pb-2` is the gap above it; `mb-2` = the same gap below, so it + doesn't sit tight to the end. --> <button type="button" class="raised-btn mb-2 h-[calc(var(--size-field,0.25rem)*10)] w-full shrink-0" diff --git a/src/features/layout/ui/TileRoot.test.ts b/src/features/layout/ui/TileRoot.test.ts index 2f95854..4b404a1 100644 --- a/src/features/layout/ui/TileRoot.test.ts +++ b/src/features/layout/ui/TileRoot.test.ts @@ -1,6 +1,7 @@ import type { LayoutTree, SplitNode } from "@dispatch/layout-contract"; import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; +import { gsap } from "gsap"; import { describe, expect, it, vi } from "vitest"; import { createCollapseStore, registerView, unregisterView } from "../index"; import FixtureA from "./fixtures/FixtureA.svelte"; @@ -271,7 +272,7 @@ describe("TileRoot", () => { const { container } = render(TileRoot, { props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, }); - const panels = container.querySelector("[data-testid='fixture-b']")?.closest(".gap-2"); + const panels = container.querySelector("[data-testid='fixture-b']")?.closest("[data-panels]"); expect(panels?.getAttribute("style") ?? "").not.toContain("outline"); }); @@ -341,3 +342,53 @@ describe("TileRoot registry cleanup", () => { } }); }); + +describe("TilePanels — panel identity + height animation", () => { + const wrappers = (): HTMLElement[] => [ + ...document.querySelectorAll<HTMLElement>("[data-panels] > [data-panel-key]"), + ]; + + it("closing the MIDDLE panel removes that panel's DOM, keeping the others' nodes", async () => { + const { rerender } = render(TileRoot, { + props: { tree: panelsOnlyTree("fix-b", "fix-a", "fix-b"), collapse: createCollapseStore() }, + }); + const [first, , third] = wrappers(); + await rerender({ tree: panelsOnlyTree("fix-b", "fix-b"), collapse: createCollapseStore() }); + // Identity, not position: the third panel's node survived (index keying + // would have reused the middle node and dropped the last one). + expect(wrappers()).toEqual([first, third]); + expect(screen.queryByTestId("fixture-a")).toBeNull(); + }); + + it("✕ on one of two IDENTICAL views removes exactly that panel", async () => { + const user = userEvent.setup(); + const tree = panelsOnlyTree("fix-b", "fix-a", "fix-a"); + const { rerender } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + const [first, , third] = wrappers(); + // Click the MIDDLE panel's ✕ (the first panel has none), then the owner + // applies the edit — the two fix-a panels are indistinguishable by data. + const [middleClose] = screen.getAllByRole("button", { name: "Remove view" }); + await user.click(middleClose as HTMLElement); + await rerender({ tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() }); + expect(wrappers()).toEqual([first, third]); + }); + + it("with motion allowed, a closed panel stays in flow while it shrinks out", async () => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: false }) as unknown as MediaQueryList), + ); + try { + const { rerender } = render(TileRoot, { + props: { tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() }, + }); + const closing = wrappers()[1]; + await rerender({ tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() }); + // jsdom's stubbed Element.animate never finishes, so the outro holds. + expect(closing?.isConnected).toBe(true); + expect(gsap.getTweensOf(closing as HTMLElement)).toHaveLength(1); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/src/features/layout/ui/panel-motion.ts b/src/features/layout/ui/panel-motion.ts new file mode 100644 index 0000000..7fc5edf --- /dev/null +++ b/src/features/layout/ui/panel-motion.ts @@ -0,0 +1,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", + }, + ); +} |
