authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-21 02:11:26-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-21 02:11:26-03:00
logf391b88c3754047c3c9434b76328699c083723b0
treeae903ac7fc107e797562fd8610a980e1a91216fa
parent30e3649c3281fe88b3d8d125293f1bb58132a3c2
signature Signed by PGP key B5690EEEBB952194

fix(web): move sidebar shelves as one block (#11772)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>

2 files changed, 226 insertions(+), 32 deletions(-)

apps/web/src/components/Sidebar.motion.test.ts+159-16
...@@ -281,10 +281,16 @@ describe("sidebar list motion", () => {...@@ -281,10 +281,16 @@ describe("sidebar list motion", () => {
281 layout([b, fresh]);281 layout([b, fresh]);
282 motion.update(true);282 motion.update(true);
283 expect(a.animations[0]!.cancel).toHaveBeenCalledOnce();283 expect(a.animations[0]!.cancel).toHaveBeenCalledOnce();
284 expect(fresh.animate).toHaveBeenLastCalledWith([{ opacity: 0 }, { opacity: 1 }], {284 expect(fresh.animate).toHaveBeenLastCalledWith(
285 duration: 150,285 [
286 easing: "ease-out",286 { opacity: 0, transform: "translateY(83px)" },
287 });287 { opacity: 1, transform: "translateY(0px)" },
288 ],
289 {
290 duration: 150,
291 easing: "ease-out",
292 },
293 );
288 const clone = a.clones[0]!;294 const clone = a.clones[0]!;
289 expect(clone.style).toMatchObject({295 expect(clone.style).toMatchObject({
290 position: "absolute",296 position: "absolute",
...@@ -299,10 +305,16 @@ describe("sidebar list motion", () => {...@@ -299,10 +305,16 @@ describe("sidebar list motion", () => {
299 expect(clone.attributes).toEqual([{ name: "aria-hidden", value: "true" }]);305 expect(clone.attributes).toEqual([{ name: "aria-hidden", value: "true" }]);
300 expect(clone.children[0]!.attributes).toEqual([{ name: "data-state", value: "open" }]);306 expect(clone.children[0]!.attributes).toEqual([{ name: "data-state", value: "open" }]);
301 expect(clone.children[1]!.attributes).toEqual(icon.attributes);307 expect(clone.children[1]!.attributes).toEqual(icon.attributes);
302 expect(clone.animate).toHaveBeenCalledWith([{ opacity: 1 }, { opacity: 0 }], {308 expect(clone.animate).toHaveBeenCalledWith(
303 duration: 150,309 [
304 easing: "ease-out",310 { opacity: 1, transform: "translateY(0px)" },
305 });311 { opacity: 0, transform: "translateY(-83px)" },
312 ],
313 {
314 duration: 150,
315 easing: "ease-out",
316 },
317 );
306 expect(parent.children.includes(clone)).toBe(true);318 expect(parent.children.includes(clone)).toBe(true);
307 motion.update(true);319 motion.update(true);
308 expect(clone.animations).toHaveLength(1);320 expect(clone.animations).toHaveLength(1);
...@@ -311,6 +323,106 @@ describe("sidebar list motion", () => {...@@ -311,6 +323,106 @@ describe("sidebar list motion", () => {
311 expect(parent.children.includes(clone)).toBe(false);323 expect(parent.children.includes(clone)).toBe(false);
312 });324 });
313325
326 it("rides entering rows on the shelf displacement so an opened shelf moves as one block", () => {
327 const a = new TestRow("a", 40);
328 const header = new TestRow("header", 32);
329 const x = new TestRow("x", 36);
330 const y = new TestRow("y", 36);
331 const z = new TestRow("z", 36);
332 const { motion, layout } = fixture([a, header, x]);
333 motion.update(true);
334 // The shelf is anchored below the list, so two revealed rows lift the
335 // header and its existing rows by the same 72px.
336 layout([a, header, x, y, z]);
337 for (const row of [header, x, y, z]) row.offsetTop -= 72;
338 motion.update(true);
339 expectMove(header, 72);
340 expectMove(x, 72);
341 expect(a.animate).not.toHaveBeenCalled();
342 expect(y.animate).toHaveBeenLastCalledWith(
343 [
344 { opacity: 0, transform: "translateY(72px)" },
345 { opacity: 1, transform: "translateY(0px)" },
346 ],
347 { duration: 150, easing: "ease-out" },
348 );
349 expect(z.animate).toHaveBeenLastCalledWith(
350 [
351 { opacity: 0, transform: "translateY(72px)" },
352 { opacity: 1, transform: "translateY(0px)" },
353 ],
354 { duration: 150, easing: "ease-out" },
355 );
356 });
357
358 it("rides new rows on a retained header's unfinished travel", () => {
359 const header = new TestRow("header", 32);
360 const above = new TestRow("above", 39);
361 const incoming = new TestRow("incoming", 36);
362 const { motion, layout } = fixture([header]);
363 motion.update(true);
364 layout([above, header]);
365 motion.update(true);
366 header.animations[0]!.progress = 0.25;
367 layout([above, header, incoming]);
368 motion.update(true);
369 expect(incoming.animate).toHaveBeenLastCalledWith(
370 [
371 { opacity: 0, transform: "translateY(-30px)" },
372 { opacity: 1, transform: "translateY(0px)" },
373 ],
374 { duration: 150, easing: "ease-out" },
375 );
376 });
377
378 it("cancels in-flight row travel when a bulk update skips animation", () => {
379 const a = new TestRow("a");
380 const b = new TestRow("b");
381 const { motion, layout } = fixture([a, b]);
382 motion.update(true);
383 layout([b, a]);
384 motion.update(true);
385 const first = a.animations[0]!;
386 first.progress = 0.4;
387 const incoming = Array.from({ length: 41 }, (_, index) => new TestRow(`new-${index}`));
388 layout(incoming);
389 motion.update(true);
390 expect(first.cancel).toHaveBeenCalledOnce();
391 expect(incoming.every((row) => row.animate.mock.calls.length === 0)).toBe(true);
392 });
393
394 it("keeps in-flight shelf entry travel when the shelf closes mid-animation", () => {
395 const a = new TestRow("a", 40);
396 const header = new TestRow("header", 32);
397 const x = new TestRow("x", 36);
398 const y = new TestRow("y", 36);
399 const { motion, layout } = fixture([a, header, x]);
400 motion.update(true);
401 layout([a, header, x, y]);
402 for (const row of [header, x, y]) row.offsetTop -= 36;
403 motion.update(true);
404 const entry = y.animations[0]!;
405 header.animations[0]!.progress = 0.25;
406 x.animations[0]!.progress = 0.25;
407 entry.progress = 0.25;
408 layout([a, header, x]);
409 motion.update(true);
410 expect(entry.cancel).toHaveBeenCalledOnce();
411 const clone = y.clones[0]!;
412 // Remaining entry travel (36 * 0.75) is baked into the clone's box so the
413 // fade starts from the row's current visual top instead of jumping to 0.
414 expect(clone.style.top).toBe("110px");
415 expect(clone.animate).toHaveBeenCalledWith(
416 [
417 { opacity: 0.25, transform: "translateY(0px)" },
418 { opacity: 0, transform: "translateY(9px)" },
419 ],
420 { duration: 150, easing: "ease-out" },
421 );
422 expectMove(header, -9);
423 expectMove(x, -9);
424 });
425
314 it("clears exit clones on pickup and does not fade the release commit", () => {426 it("clears exit clones on pickup and does not fade the release commit", () => {
315 const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")];427 const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")];
316 const { motion, layout, parent } = fixture([a, b]);428 const { motion, layout, parent } = fixture([a, b]);
...@@ -329,10 +441,16 @@ describe("sidebar list motion", () => {...@@ -329,10 +441,16 @@ describe("sidebar list motion", () => {
329 expect(c.animations).toHaveLength(0);441 expect(c.animations).toHaveLength(0);
330 layout([c, a]);442 layout([c, a]);
331 motion.update(true);443 motion.update(true);
332 expect(a.animate).toHaveBeenCalledWith([{ opacity: 0 }, { opacity: 1 }], {444 expect(a.animate).toHaveBeenCalledWith(
333 duration: 150,445 [
334 easing: "ease-out",446 { opacity: 0, transform: "translateY(0px)" },
335 });447 { opacity: 1, transform: "translateY(0px)" },
448 ],
449 {
450 duration: 150,
451 easing: "ease-out",
452 },
453 );
336 motion.dispose();454 motion.dispose();
337 expect(a.animations.at(-1)!.cancel).toHaveBeenCalledOnce();455 expect(a.animations.at(-1)!.cancel).toHaveBeenCalledOnce();
338 });456 });
...@@ -349,15 +467,40 @@ describe("sidebar list motion", () => {...@@ -349,15 +467,40 @@ describe("sidebar list motion", () => {
349 motion.update(true);467 motion.update(true);
350 expect(marker.clones).toHaveLength(0);468 expect(marker.clones).toHaveLength(0);
351 const clone = a.clones[0]!;469 const clone = a.clones[0]!;
352 expect(clone.animate).toHaveBeenCalledWith([{ opacity: 0.4 }, { opacity: 0 }], {470 expect(clone.animate).toHaveBeenCalledWith(
353 duration: 150,471 [
354 easing: "ease-out",472 { opacity: 0.4, transform: "translateY(0px)" },
355 });473 { opacity: 0, transform: "translateY(40px)" },
474 ],
475 {
476 duration: 150,
477 easing: "ease-out",
478 },
479 );
356 motion.update(false);480 motion.update(false);
357 expect(parent.children).toEqual([]);481 expect(parent.children).toEqual([]);
358 expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce();482 expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce();
359 });483 });
360484
485 it("retargets an in-flight entry when a later layout shift moves the row", () => {
486 const a = new TestRow("a", 40);
487 const header = new TestRow("header", 32);
488 const x = new TestRow("x", 36);
489 const y = new TestRow("y", 36);
490 const { motion, layout } = fixture([a, header, x]);
491 motion.update(true);
492 layout([a, header, x, y]);
493 for (const row of [header, x, y]) row.offsetTop -= 36;
494 motion.update(true);
495 const entry = y.animations[0]!;
496 entry.progress = 0.25;
497 for (const row of [header, x, y]) row.offsetTop += 40;
498 motion.update(true);
499 expect(entry.cancel).not.toHaveBeenCalled();
500 // Remaining 27px of the 36px entry plus the new 40px shift.
501 expectMove(y, -13);
502 });
503
361 it("skips fades when a large list change would clone too many rows", () => {504 it("skips fades when a large list change would clone too many rows", () => {
362 const rows = Array.from({ length: 41 }, (_, index) => new TestRow(`row-${index}`));505 const rows = Array.from({ length: 41 }, (_, index) => new TestRow(`row-${index}`));
363 const { motion, layout, parent } = fixture(rows);506 const { motion, layout, parent } = fixture(rows);
apps/web/src/components/Sidebar.motion.ts+67-16
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1const motionTiming = { duration: 150, easing: "ease-out" };1const motionTiming = { duration: 150, easing: "ease-out" };
2// Rows normally ride their displaced neighbour's travel. Absent a moving
3// neighbour, a row still travels on its own, clamped so a tall card does not
4// slide its full height.
5const rowTravel = (height: number) => Math.min(height, 40);
2// A project filter change or a bulk snooze swaps a large part of the list at6// A project filter change or a bulk snooze swaps a large part of the list at
3// once. Fades are the expensive part: every removed row gets a deep clone and7// once. Fades are the expensive part: every removed row gets a deep clone and
4// every clone and entering row gets its own animation, and the layout reads8// every clone and entering row gets its own animation, and the layout reads
...@@ -23,7 +27,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) {...@@ -23,7 +27,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
23 "(prefers-reduced-motion: reduce)",27 "(prefers-reduced-motion: reduce)",
24 );28 );
25 const running = new Map<HTMLElement, { animation: Animation; offset: number }>();29 const running = new Map<HTMLElement, { animation: Animation; offset: number }>();
26 const entering = new Map<HTMLElement, Animation>();30 const entering = new Map<HTMLElement, { animation: Animation; travel: number }>();
27 const exiting = new Map<HTMLElement, Animation>();31 const exiting = new Map<HTMLElement, Animation>();
28 // Visual tops at drag release, relative to the list, so the release32 // Visual tops at drag release, relative to the list, so the release
29 // commit can glide every row from where dnd-kit left it into its slot.33 // commit can glide every row from where dnd-kit left it into its slot.
...@@ -31,15 +35,19 @@ export function createSidebarListMotion(parent: HTMLUListElement) {...@@ -31,15 +35,19 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
3135
32 const remainingOffset = (node: HTMLElement) => {36 const remainingOffset = (node: HTMLElement) => {
33 const current = running.get(node);37 const current = running.get(node);
34 return current ? current.offset * (1 - progress(current.animation)) : 0;38 const run = current ? current.offset * (1 - progress(current.animation)) : 0;
39 const entry = entering.get(node);
40 const enter = entry ? entry.travel * (1 - progress(entry.animation)) : 0;
41 return run + enter;
35 };42 };
36 const clearFades = () => {43 const clearFades = () => {
37 for (const animation of [...entering.values(), ...exiting.values()]) animation.cancel();44 for (const entry of entering.values()) entry.animation.cancel();
45 for (const animation of exiting.values()) animation.cancel();
38 for (const node of exiting.keys()) node.remove();46 for (const node of exiting.keys()) node.remove();
39 entering.clear();47 entering.clear();
40 exiting.clear();48 exiting.clear();
41 };49 };
42 const fadeOut = (node: HTMLElement, position: RowPosition) => {50 const fadeOut = (node: HTMLElement, position: RowPosition, travel: number) => {
43 if (position.height === 0) return;51 if (position.height === 0) return;
44 // React owns the removed row; only a noninteractive copy stays for the fade.52 // React owns the removed row; only a noninteractive copy stays for the fade.
45 const clone = node.cloneNode(true) as HTMLElement;53 const clone = node.cloneNode(true) as HTMLElement;
...@@ -72,8 +80,12 @@ export function createSidebarListMotion(parent: HTMLUListElement) {...@@ -72,8 +80,12 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
72 });80 });
73 parent.append(clone);81 parent.append(clone);
74 const entry = entering.get(node);82 const entry = entering.get(node);
83 const entryProgress = entry ? progress(entry.animation) : 1;
75 const animation = clone.animate(84 const animation = clone.animate(
76 [{ opacity: entry ? progress(entry) : 1 }, { opacity: 0 }],85 [
86 { opacity: entryProgress, transform: "translateY(0px)" },
87 { opacity: 0, transform: `translateY(${travel}px)` },
88 ],
77 motionTiming,89 motionTiming,
78 );90 );
79 exiting.set(clone, animation);91 exiting.set(clone, animation);
...@@ -99,7 +111,10 @@ export function createSidebarListMotion(parent: HTMLUListElement) {...@@ -99,7 +111,10 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
99 };111 };
100 const move = (node: HTMLElement, offset: number) => {112 const move = (node: HTMLElement, offset: number) => {
101 cancel(node);113 cancel(node);
102 if (offset === 0) return;114 const entry = entering.get(node);
115 // The newer transform supersedes entry travel; its original opacity keeps fading.
116 if (entry) entry.travel = 0;
117 if (offset === 0 && !entry) return;
103 const animation = node.animate(118 const animation = node.animate(
104 [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }],119 [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }],
105 motionTiming,120 motionTiming,
...@@ -144,15 +159,42 @@ export function createSidebarListMotion(parent: HTMLUListElement) {...@@ -144,15 +159,42 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
144 positions !== null &&159 positions !== null &&
145 !reducedMotion?.matches &&160 !reducedMotion?.matches &&
146 fadeCount <= MAX_FADED_ROWS_PER_UPDATE;161 fadeCount <= MAX_FADED_ROWS_PER_UPDATE;
162 const movedDelta = new Map<HTMLElement, number>();
163 const nextOrder = [...next.keys()];
164 const oldOrder = positions === null ? [] : [...positions.keys()];
165 const ridingDelta = (
166 order: readonly HTMLElement[],
167 index: number,
168 retained: (node: HTMLElement) => boolean,
169 ) => {
170 for (let cursor = index - 1; cursor >= 0; cursor--) {
171 const node = order[cursor]!;
172 const delta = movedDelta.get(node);
173 if (delta !== undefined) return delta;
174 if (retained(node)) return remainingOffset(node);
175 }
176 return undefined;
177 };
147 if (!shouldAnimate) clearFades();178 if (!shouldAnimate) clearFades();
148 else {179 else {
180 // A shelf that opens above its collapsed anchor shifts every retained
181 // row by the same amount. Entering rows take that same displacement so
182 // the shelf arrives as one moving block instead of rows popping into
183 // their final slots; exiting rows leave by it.
184 for (const [node, position] of next) {
185 const previousTop = positions!.get(node)?.top;
186 if (previousTop === undefined || previousTop === position.top) continue;
187 movedDelta.set(node, previousTop + remainingOffset(node) - position.top);
188 }
149 for (const [node, position] of positions!) {189 for (const [node, position] of positions!) {
150 if (!next.has(node)) fadeOut(node, position);190 if (next.has(node)) continue;
191 const delta = ridingDelta(oldOrder, oldOrder.indexOf(node), (n) => next.has(n));
192 fadeOut(node, position, delta === undefined ? rowTravel(position.height) : -delta);
151 }193 }
152 }194 }
153 for (const [node, animation] of entering) {195 for (const [node, entry] of entering) {
154 if (!next.has(node)) {196 if (!next.has(node)) {
155 animation.cancel();197 entry.animation.cancel();
156 entering.delete(node);198 entering.delete(node);
157 }199 }
158 }200 }
...@@ -160,26 +202,35 @@ export function createSidebarListMotion(parent: HTMLUListElement) {...@@ -160,26 +202,35 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
160 if (!shouldAnimate || !next.has(node)) cancel(node);202 if (!shouldAnimate || !next.has(node)) cancel(node);
161 }203 }
162 if (shouldAnimate) {204 if (shouldAnimate) {
163 for (const [node, position] of next) {205 for (const [index, node] of nextOrder.entries()) {
164 const previousTop = positions?.get(node)?.top;206 const position = next.get(node)!;
207 const previousTop = positions!.get(node)?.top;
165 if (previousTop === undefined) {208 if (previousTop === undefined) {
166 if (position.height > 0) {209 if (position.height > 0) {
167 const animation = node.animate([{ opacity: 0 }, { opacity: 1 }], motionTiming);210 const delta = ridingDelta(nextOrder, index, (n) => positions!.has(n));
168 entering.set(node, animation);211 const travel = delta === undefined ? -rowTravel(position.height) : delta;
212 const animation = node.animate(
213 [
214 { opacity: 0, transform: `translateY(${travel}px)` },
215 { opacity: 1, transform: "translateY(0px)" },
216 ],
217 motionTiming,
218 );
219 entering.set(node, { animation, travel });
169 animation.addEventListener(220 animation.addEventListener(
170 "finish",221 "finish",
171 () => {222 () => {
172 if (entering.get(node) === animation) entering.delete(node);223 if (entering.get(node)?.animation === animation) entering.delete(node);
173 },224 },
174 { once: true },225 { once: true },
175 );226 );
176 }227 }
177 continue;228 continue;
178 }229 }
179 if (previousTop === position.top) continue;230 const delta = movedDelta.get(node);
180 // Computed progress includes the effect's easing. Only our own231 // Computed progress includes the effect's easing. Only our own
181 // translate is carried forward; dnd-kit's transforms are never read.232 // translate is carried forward; dnd-kit's transforms are never read.
182 move(node, previousTop + remainingOffset(node) - position.top);233 if (delta !== undefined) move(node, delta);
183 }234 }
184 }235 }
185 if (released !== null) {236 if (released !== null) {