diff --git a/apps/web/src/components/Sidebar.motion.test.ts b/apps/web/src/components/Sidebar.motion.test.ts index 8313bed9da..9a4597822a 100644 --- a/apps/web/src/components/Sidebar.motion.test.ts +++ b/apps/web/src/components/Sidebar.motion.test.ts @@ -239,6 +239,45 @@ describe("sidebar list motion", () => { expect(a.animations[0]!.cancel).not.toHaveBeenCalled(); }); + it("gives a simple appended row its own travel when existing rows stay still", () => { + const existing = new TestRow("existing", 82); + const incoming = new TestRow("incoming", 82); + const { motion, layout } = fixture([existing]); + motion.update(true); + layout([existing, incoming]); + motion.update(true); + + expect(existing.animate).not.toHaveBeenCalled(); + expect(incoming.animate).toHaveBeenCalledWith( + [ + { opacity: 0, transform: "translateY(-40px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + }); + + it("does not ride a distant moving row across a stationary neighbour", () => { + const moving = new TestRow("moving"); + const stationary = new TestRow("stationary"); + const incoming = new TestRow("incoming"); + const { motion, layout } = fixture([moving, stationary]); + motion.update(true); + layout([moving, stationary, incoming]); + moving.offsetTop -= 40; + motion.update(true); + + expectMove(moving, 40); + expect(stationary.animate).not.toHaveBeenCalled(); + expect(incoming.animate).toHaveBeenCalledWith( + [ + { opacity: 0, transform: "translateY(-40px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + }); + it("cancels owned motion on suspension and never animates a disposed list", () => { const a = new TestRow("a"); const b = new TestRow("b"); @@ -281,10 +320,16 @@ describe("sidebar list motion", () => { layout([b, fresh]); motion.update(true); expect(a.animations[0]!.cancel).toHaveBeenCalledOnce(); - expect(fresh.animate).toHaveBeenLastCalledWith([{ opacity: 0 }, { opacity: 1 }], { - duration: 150, - easing: "ease-out", - }); + expect(fresh.animate).toHaveBeenLastCalledWith( + [ + { opacity: 0, transform: "translateY(83px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { + duration: 150, + easing: "ease-out", + }, + ); const clone = a.clones[0]!; expect(clone.style).toMatchObject({ position: "absolute", @@ -299,10 +344,16 @@ describe("sidebar list motion", () => { expect(clone.attributes).toEqual([{ name: "aria-hidden", value: "true" }]); expect(clone.children[0]!.attributes).toEqual([{ name: "data-state", value: "open" }]); expect(clone.children[1]!.attributes).toEqual(icon.attributes); - expect(clone.animate).toHaveBeenCalledWith([{ opacity: 1 }, { opacity: 0 }], { - duration: 150, - easing: "ease-out", - }); + expect(clone.animate).toHaveBeenCalledWith( + [ + { opacity: 1, transform: "translateY(0px)" }, + { opacity: 0, transform: "translateY(-83px)" }, + ], + { + duration: 150, + easing: "ease-out", + }, + ); expect(parent.children.includes(clone)).toBe(true); motion.update(true); expect(clone.animations).toHaveLength(1); @@ -311,6 +362,106 @@ describe("sidebar list motion", () => { expect(parent.children.includes(clone)).toBe(false); }); + it("rides entering rows on the shelf displacement so an opened shelf moves as one block", () => { + const a = new TestRow("a", 40); + const header = new TestRow("header", 32); + const x = new TestRow("x", 36); + const y = new TestRow("y", 36); + const z = new TestRow("z", 36); + const { motion, layout } = fixture([a, header, x]); + motion.update(true); + // The shelf is anchored below the list, so two revealed rows lift the + // header and its existing rows by the same 72px. + layout([a, header, x, y, z]); + for (const row of [header, x, y, z]) row.offsetTop -= 72; + motion.update(true); + expectMove(header, 72); + expectMove(x, 72); + expect(a.animate).not.toHaveBeenCalled(); + expect(y.animate).toHaveBeenLastCalledWith( + [ + { opacity: 0, transform: "translateY(72px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + expect(z.animate).toHaveBeenLastCalledWith( + [ + { opacity: 0, transform: "translateY(72px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + }); + + it("rides new rows on a retained header's unfinished travel", () => { + const header = new TestRow("header", 32); + const above = new TestRow("above", 39); + const incoming = new TestRow("incoming", 36); + const { motion, layout } = fixture([header]); + motion.update(true); + layout([above, header]); + motion.update(true); + header.animations[0]!.progress = 0.25; + layout([above, header, incoming]); + motion.update(true); + expect(incoming.animate).toHaveBeenLastCalledWith( + [ + { opacity: 0, transform: "translateY(-30px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + }); + + it("cancels in-flight row travel when a bulk update skips animation", () => { + const a = new TestRow("a"); + const b = new TestRow("b"); + const { motion, layout } = fixture([a, b]); + motion.update(true); + layout([b, a]); + motion.update(true); + const first = a.animations[0]!; + first.progress = 0.4; + const incoming = Array.from({ length: 41 }, (_, index) => new TestRow(`new-${index}`)); + layout(incoming); + motion.update(true); + expect(first.cancel).toHaveBeenCalledOnce(); + expect(incoming.every((row) => row.animate.mock.calls.length === 0)).toBe(true); + }); + + it("keeps in-flight shelf entry travel when the shelf closes mid-animation", () => { + const a = new TestRow("a", 40); + const header = new TestRow("header", 32); + const x = new TestRow("x", 36); + const y = new TestRow("y", 36); + const { motion, layout } = fixture([a, header, x]); + motion.update(true); + layout([a, header, x, y]); + for (const row of [header, x, y]) row.offsetTop -= 36; + motion.update(true); + const entry = y.animations[0]!; + header.animations[0]!.progress = 0.25; + x.animations[0]!.progress = 0.25; + entry.progress = 0.25; + layout([a, header, x]); + motion.update(true); + expect(entry.cancel).toHaveBeenCalledOnce(); + const clone = y.clones[0]!; + // Remaining entry travel (36 * 0.75) is baked into the clone's box so the + // fade starts from the row's current visual top instead of jumping to 0. + expect(clone.style.top).toBe("110px"); + expect(clone.animate).toHaveBeenCalledWith( + [ + { opacity: 0.25, transform: "translateY(0px)" }, + { opacity: 0, transform: "translateY(9px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + expectMove(header, -9); + expectMove(x, -9); + }); + it("clears exit clones on pickup and does not fade the release commit", () => { const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")]; const { motion, layout, parent } = fixture([a, b]); @@ -329,10 +480,16 @@ describe("sidebar list motion", () => { expect(c.animations).toHaveLength(0); layout([c, a]); motion.update(true); - expect(a.animate).toHaveBeenCalledWith([{ opacity: 0 }, { opacity: 1 }], { - duration: 150, - easing: "ease-out", - }); + expect(a.animate).toHaveBeenCalledWith( + [ + { opacity: 0, transform: "translateY(-40px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { + duration: 150, + easing: "ease-out", + }, + ); motion.dispose(); expect(a.animations.at(-1)!.cancel).toHaveBeenCalledOnce(); }); @@ -349,15 +506,40 @@ describe("sidebar list motion", () => { motion.update(true); expect(marker.clones).toHaveLength(0); const clone = a.clones[0]!; - expect(clone.animate).toHaveBeenCalledWith([{ opacity: 0.4 }, { opacity: 0 }], { - duration: 150, - easing: "ease-out", - }); + expect(clone.animate).toHaveBeenCalledWith( + [ + { opacity: 0.4, transform: "translateY(0px)" }, + { opacity: 0, transform: "translateY(40px)" }, + ], + { + duration: 150, + easing: "ease-out", + }, + ); motion.update(false); expect(parent.children).toEqual([]); expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce(); }); + it("retargets an in-flight entry when a later layout shift moves the row", () => { + const a = new TestRow("a", 40); + const header = new TestRow("header", 32); + const x = new TestRow("x", 36); + const y = new TestRow("y", 36); + const { motion, layout } = fixture([a, header, x]); + motion.update(true); + layout([a, header, x, y]); + for (const row of [header, x, y]) row.offsetTop -= 36; + motion.update(true); + const entry = y.animations[0]!; + entry.progress = 0.25; + for (const row of [header, x, y]) row.offsetTop += 40; + motion.update(true); + expect(entry.cancel).not.toHaveBeenCalled(); + // Remaining 27px of the 36px entry plus the new 40px shift. + expectMove(y, -13); + }); + it("skips fades when a large list change would clone too many rows", () => { const rows = Array.from({ length: 41 }, (_, index) => new TestRow(`row-${index}`)); const { motion, layout, parent } = fixture(rows); diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index d70c839d49..7e13130354 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -1,4 +1,8 @@ const motionTiming = { duration: 150, easing: "ease-out" }; +// Rows normally ride their displaced neighbour's travel. Absent a moving +// neighbour, a row still travels on its own, clamped so a tall card does not +// slide its full height. +const rowTravel = (height: number) => Math.min(height, 40); // A project filter change or a bulk snooze swaps a large part of the list at // once. Fades are the expensive part: every removed row gets a deep clone and // every clone and entering row gets its own animation, and the layout reads @@ -23,7 +27,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { "(prefers-reduced-motion: reduce)", ); const running = new Map(); - const entering = new Map(); + const entering = new Map(); const exiting = new Map(); // Visual tops at drag release, relative to the list, so the release // commit can glide every row from where dnd-kit left it into its slot. @@ -31,15 +35,19 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const remainingOffset = (node: HTMLElement) => { const current = running.get(node); - return current ? current.offset * (1 - progress(current.animation)) : 0; + const run = current ? current.offset * (1 - progress(current.animation)) : 0; + const entry = entering.get(node); + const enter = entry ? entry.travel * (1 - progress(entry.animation)) : 0; + return run + enter; }; const clearFades = () => { - for (const animation of [...entering.values(), ...exiting.values()]) animation.cancel(); + for (const entry of entering.values()) entry.animation.cancel(); + for (const animation of exiting.values()) animation.cancel(); for (const node of exiting.keys()) node.remove(); entering.clear(); exiting.clear(); }; - const fadeOut = (node: HTMLElement, position: RowPosition) => { + const fadeOut = (node: HTMLElement, position: RowPosition, travel: number) => { if (position.height === 0) return; // React owns the removed row; only a noninteractive copy stays for the fade. const clone = node.cloneNode(true) as HTMLElement; @@ -72,8 +80,12 @@ export function createSidebarListMotion(parent: HTMLUListElement) { }); parent.append(clone); const entry = entering.get(node); + const entryProgress = entry ? progress(entry.animation) : 1; const animation = clone.animate( - [{ opacity: entry ? progress(entry) : 1 }, { opacity: 0 }], + [ + { opacity: entryProgress, transform: "translateY(0px)" }, + { opacity: 0, transform: `translateY(${travel}px)` }, + ], motionTiming, ); exiting.set(clone, animation); @@ -99,7 +111,10 @@ export function createSidebarListMotion(parent: HTMLUListElement) { }; const move = (node: HTMLElement, offset: number) => { cancel(node); - if (offset === 0) return; + const entry = entering.get(node); + // The newer transform supersedes entry travel; its original opacity keeps fading. + if (entry) entry.travel = 0; + if (offset === 0 && !entry) return; const animation = node.animate( [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], motionTiming, @@ -144,15 +159,45 @@ export function createSidebarListMotion(parent: HTMLUListElement) { positions !== null && !reducedMotion?.matches && fadeCount <= MAX_FADED_ROWS_PER_UPDATE; + const movedDelta = new Map(); + const nextOrder = [...next.keys()]; + const oldOrder = positions === null ? [] : [...positions.keys()]; + const ridingDelta = ( + order: readonly HTMLElement[], + index: number, + retained: (node: HTMLElement) => boolean, + ) => { + for (let cursor = index - 1; cursor >= 0; cursor--) { + const node = order[cursor]!; + const delta = movedDelta.get(node); + if (delta !== undefined) return delta === 0 ? undefined : delta; + if (retained(node)) { + const offset = remainingOffset(node); + return offset === 0 ? undefined : offset; + } + } + return undefined; + }; if (!shouldAnimate) clearFades(); else { + // A shelf that opens above its collapsed anchor shifts every retained + // row by the same amount. Entering rows take that same displacement so + // the shelf arrives as one moving block instead of rows popping into + // their final slots; exiting rows leave by it. + for (const [node, position] of next) { + const previousTop = positions!.get(node)?.top; + if (previousTop === undefined || previousTop === position.top) continue; + movedDelta.set(node, previousTop + remainingOffset(node) - position.top); + } for (const [node, position] of positions!) { - if (!next.has(node)) fadeOut(node, position); + if (next.has(node)) continue; + const delta = ridingDelta(oldOrder, oldOrder.indexOf(node), (n) => next.has(n)); + fadeOut(node, position, delta === undefined ? rowTravel(position.height) : -delta); } } - for (const [node, animation] of entering) { + for (const [node, entry] of entering) { if (!next.has(node)) { - animation.cancel(); + entry.animation.cancel(); entering.delete(node); } } @@ -160,26 +205,35 @@ export function createSidebarListMotion(parent: HTMLUListElement) { if (!shouldAnimate || !next.has(node)) cancel(node); } if (shouldAnimate) { - for (const [node, position] of next) { - const previousTop = positions?.get(node)?.top; + for (const [index, node] of nextOrder.entries()) { + const position = next.get(node)!; + const previousTop = positions!.get(node)?.top; if (previousTop === undefined) { if (position.height > 0) { - const animation = node.animate([{ opacity: 0 }, { opacity: 1 }], motionTiming); - entering.set(node, animation); + const delta = ridingDelta(nextOrder, index, (n) => positions!.has(n)); + const travel = delta === undefined ? -rowTravel(position.height) : delta; + const animation = node.animate( + [ + { opacity: 0, transform: `translateY(${travel}px)` }, + { opacity: 1, transform: "translateY(0px)" }, + ], + motionTiming, + ); + entering.set(node, { animation, travel }); animation.addEventListener( "finish", () => { - if (entering.get(node) === animation) entering.delete(node); + if (entering.get(node)?.animation === animation) entering.delete(node); }, { once: true }, ); } continue; } - if (previousTop === position.top) continue; + const delta = movedDelta.get(node); // Computed progress includes the effect's easing. Only our own // translate is carried forward; dnd-kit's transforms are never read. - move(node, previousTop + remainingOffset(node) - position.top); + if (delta !== undefined) move(node, delta); } } if (released !== null) {