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", () => {
281281 layout([b, fresh]);
282282 motion.update(true);
283283 expect(a.animations[0]!.cancel).toHaveBeenCalledOnce();
284 expect(fresh.animate).toHaveBeenLastCalledWith([{ opacity: 0 }, { opacity: 1 }], {
285 duration: 150,
286 easing: "ease-out",
287 });
284 expect(fresh.animate).toHaveBeenLastCalledWith(
285 [
286 { opacity: 0, transform: "translateY(83px)" },
287 { opacity: 1, transform: "translateY(0px)" },
288 ],
289 {
290 duration: 150,
291 easing: "ease-out",
292 },
293 );
288294 const clone = a.clones[0]!;
289295 expect(clone.style).toMatchObject({
290296 position: "absolute",
......@@ -299,10 +305,16 @@ describe("sidebar list motion", () => {
299305 expect(clone.attributes).toEqual([{ name: "aria-hidden", value: "true" }]);
300306 expect(clone.children[0]!.attributes).toEqual([{ name: "data-state", value: "open" }]);
301307 expect(clone.children[1]!.attributes).toEqual(icon.attributes);
302 expect(clone.animate).toHaveBeenCalledWith([{ opacity: 1 }, { opacity: 0 }], {
303 duration: 150,
304 easing: "ease-out",
305 });
308 expect(clone.animate).toHaveBeenCalledWith(
309 [
310 { opacity: 1, transform: "translateY(0px)" },
311 { opacity: 0, transform: "translateY(-83px)" },
312 ],
313 {
314 duration: 150,
315 easing: "ease-out",
316 },
317 );
306318 expect(parent.children.includes(clone)).toBe(true);
307319 motion.update(true);
308320 expect(clone.animations).toHaveLength(1);
......@@ -311,6 +323,106 @@ describe("sidebar list motion", () => {
311323 expect(parent.children.includes(clone)).toBe(false);
312324 });
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
314426 it("clears exit clones on pickup and does not fade the release commit", () => {
315427 const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")];
316428 const { motion, layout, parent } = fixture([a, b]);
......@@ -329,10 +441,16 @@ describe("sidebar list motion", () => {
329441 expect(c.animations).toHaveLength(0);
330442 layout([c, a]);
331443 motion.update(true);
332 expect(a.animate).toHaveBeenCalledWith([{ opacity: 0 }, { opacity: 1 }], {
333 duration: 150,
334 easing: "ease-out",
335 });
444 expect(a.animate).toHaveBeenCalledWith(
445 [
446 { opacity: 0, transform: "translateY(0px)" },
447 { opacity: 1, transform: "translateY(0px)" },
448 ],
449 {
450 duration: 150,
451 easing: "ease-out",
452 },
453 );
336454 motion.dispose();
337455 expect(a.animations.at(-1)!.cancel).toHaveBeenCalledOnce();
338456 });
......@@ -349,15 +467,40 @@ describe("sidebar list motion", () => {
349467 motion.update(true);
350468 expect(marker.clones).toHaveLength(0);
351469 const clone = a.clones[0]!;
352 expect(clone.animate).toHaveBeenCalledWith([{ opacity: 0.4 }, { opacity: 0 }], {
353 duration: 150,
354 easing: "ease-out",
355 });
470 expect(clone.animate).toHaveBeenCalledWith(
471 [
472 { opacity: 0.4, transform: "translateY(0px)" },
473 { opacity: 0, transform: "translateY(40px)" },
474 ],
475 {
476 duration: 150,
477 easing: "ease-out",
478 },
479 );
356480 motion.update(false);
357481 expect(parent.children).toEqual([]);
358482 expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce();
359483 });
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
361504 it("skips fades when a large list change would clone too many rows", () => {
362505 const rows = Array.from({ length: 41 }, (_, index) => new TestRow(`row-${index}`));
363506 const { motion, layout, parent } = fixture(rows);
apps/web/src/components/Sidebar.motion.ts+67-16
......@@ -1,4 +1,8 @@
11const 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);
26// A project filter change or a bulk snooze swaps a large part of the list at
37// once. Fades are the expensive part: every removed row gets a deep clone and
48// every clone and entering row gets its own animation, and the layout reads
......@@ -23,7 +27,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
2327 "(prefers-reduced-motion: reduce)",
2428 );
2529 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 }>();
2731 const exiting = new Map<HTMLElement, Animation>();
2832 // Visual tops at drag release, relative to the list, so the release
2933 // commit can glide every row from where dnd-kit left it into its slot.
......@@ -31,15 +35,19 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
3135
3236 const remainingOffset = (node: HTMLElement) => {
3337 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;
3542 };
3643 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();
3846 for (const node of exiting.keys()) node.remove();
3947 entering.clear();
4048 exiting.clear();
4149 };
42 const fadeOut = (node: HTMLElement, position: RowPosition) => {
50 const fadeOut = (node: HTMLElement, position: RowPosition, travel: number) => {
4351 if (position.height === 0) return;
4452 // React owns the removed row; only a noninteractive copy stays for the fade.
4553 const clone = node.cloneNode(true) as HTMLElement;
......@@ -72,8 +80,12 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
7280 });
7381 parent.append(clone);
7482 const entry = entering.get(node);
83 const entryProgress = entry ? progress(entry.animation) : 1;
7584 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 ],
7789 motionTiming,
7890 );
7991 exiting.set(clone, animation);
......@@ -99,7 +111,10 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
99111 };
100112 const move = (node: HTMLElement, offset: number) => {
101113 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;
103118 const animation = node.animate(
104119 [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }],
105120 motionTiming,
......@@ -144,15 +159,42 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
144159 positions !== null &&
145160 !reducedMotion?.matches &&
146161 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 };
147178 if (!shouldAnimate) clearFades();
148179 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 }
149189 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);
151193 }
152194 }
153 for (const [node, animation] of entering) {
195 for (const [node, entry] of entering) {
154196 if (!next.has(node)) {
155 animation.cancel();
197 entry.animation.cancel();
156198 entering.delete(node);
157199 }
158200 }
......@@ -160,26 +202,35 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
160202 if (!shouldAnimate || !next.has(node)) cancel(node);
161203 }
162204 if (shouldAnimate) {
163 for (const [node, position] of next) {
164 const previousTop = positions?.get(node)?.top;
205 for (const [index, node] of nextOrder.entries()) {
206 const position = next.get(node)!;
207 const previousTop = positions!.get(node)?.top;
165208 if (previousTop === undefined) {
166209 if (position.height > 0) {
167 const animation = node.animate([{ opacity: 0 }, { opacity: 1 }], motionTiming);
168 entering.set(node, animation);
210 const delta = ridingDelta(nextOrder, index, (n) => positions!.has(n));
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 });
169220 animation.addEventListener(
170221 "finish",
171222 () => {
172 if (entering.get(node) === animation) entering.delete(node);
223 if (entering.get(node)?.animation === animation) entering.delete(node);
173224 },
174225 { once: true },
175226 );
176227 }
177228 continue;
178229 }
179 if (previousTop === position.top) continue;
230 const delta = movedDelta.get(node);
180231 // Computed progress includes the effect's easing. Only our own
181232 // 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);
183234 }
184235 }
185236 if (released !== null) {