1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { createSidebarListMotion } from "./Sidebar.motion";
4
5class TestAnimation {
6 progress: number | null = 0;
7 playState: AnimationPlayState = "running";
8 effect = { getComputedTiming: () => ({ progress: this.progress }) };
9 cancel = vi.fn(() => {
10 this.playState = "idle";
11 });
12 private onFinish: (() => void) | undefined;
13 addEventListener(_type: string, listener: () => void) {
14 this.onFinish = listener;
15 }
16 finish() {
17 this.playState = "finished";
18 this.onFinish?.();
19 }
20}
21
22class TestRow {
23 offsetTop = 0;
24 offsetLeft = 4;
25 offsetWidth = 260;
26 namespaceURI = "http://www.w3.org/1999/xhtml";
27 dragTranslate = 0;
28 style: Record<string, string> = {};
29 inert = false;
30 attributes: { name: string; value: string }[] = [];
31 children: TestRow[] = [];
32 clones: TestRow[] = [];
33 remove = vi.fn();
34 animations: TestAnimation[] = [];
35 constructor(
36 readonly name: string,
37 public offsetHeight = 82,
38 ) {}
39 getBoundingClientRect() {
40 return { top: this.offsetTop + this.dragTranslate, height: this.offsetHeight };
41 }
42 setAttribute(name: string, value: string) {
43 this.removeAttribute(name);
44 this.attributes.push({ name, value });
45 }
46 removeAttribute(name: string) {
47 this.attributes = this.attributes.filter((attribute) => attribute.name !== name);
48 }
49 querySelectorAll(_selector: string): TestRow[] {
50 return this.children.flatMap((child) => [child, ...child.querySelectorAll("*")]);
51 }
52 cloneNode(_deep: boolean): TestRow {
53 const clone = new TestRow(`${this.name} clone`, this.offsetHeight);
54 clone.namespaceURI = this.namespaceURI;
55 clone.style = { ...this.style };
56 clone.attributes = this.attributes.map((attribute) => ({ ...attribute }));
57 clone.children = this.children.map((child) => child.cloneNode(true));
58 this.clones.push(clone);
59 return clone;
60 }
61 animate = vi.fn((_frames: Keyframe[], _options: KeyframeAnimationOptions) => {
62 const animation = new TestAnimation();
63 this.animations.push(animation);
64 return animation;
65 });
66}
67
68function fixture(rows: TestRow[]) {
69 const media = { matches: false };
70 const parent = {
71 children: rows,
72 ownerDocument: { defaultView: { matchMedia: () => media } },
73 getBoundingClientRect: () => ({ top: 0 }),
74 append(node: TestRow) {
75 parent.children.push(node);
76 node.remove.mockImplementation(() => {
77 parent.children = parent.children.filter((child) => child !== node);
78 });
79 },
80 };
81 function layout(next: TestRow[]) {
82 let top = 8;
83 for (const row of next) {
84 row.offsetTop = top;
85 top += row.offsetHeight + 1;
86 }
87 parent.children = [
88 ...next,
89 ...parent.children.filter((row) => row.style.position === "absolute"),
90 ];
91 }
92 layout(rows);
93 const motion = createSidebarListMotion(parent as unknown as HTMLUListElement);
94 return { motion, layout, media, parent };
95}
96
97function expectMove(row: TestRow, offset: number) {
98 expect(row.animate).toHaveBeenLastCalledWith(
99 [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }],
100 { duration: 150, easing: "ease-out" },
101 );
102}
103
104beforeEach(() => vi.stubGlobal("HTMLElement", TestRow));
105afterEach(() => vi.unstubAllGlobals());
106
107describe("sidebar list motion", () => {
108 it("moves a retained Active row into Settled with its displaced peers", () => {
109 const pinnedHeader = new TestRow("Pinned", 0);
110 const pinned = new TestRow("pin");
111 const divider = new TestRow("Active", 0);
112 const a = new TestRow("a");
113 const b = new TestRow("b");
114 const settledHeader = new TestRow("Settled", 32);
115 const settled = new TestRow("settled", 36);
116 const rows = [pinnedHeader, pinned, divider, a, b, settledHeader, settled];
117 const { motion, layout } = fixture(rows);
118 motion.update(true);
119 expect(rows.every((row) => row.animations.length === 0)).toBe(true);
120
121 a.offsetHeight = 36;
122 layout([pinnedHeader, pinned, divider, b, settledHeader, settled, a]);
123 motion.update(true);
124 expectMove(a, -153);
125 expectMove(b, 83);
126 expectMove(settledHeader, 83);
127 expectMove(settled, 83);
128 expect(pinned.animate).not.toHaveBeenCalled();
129 expect(divider.animate).not.toHaveBeenCalled();
130 });
131
132 it("refreshes the drop baseline without replay and animates the next ordinary move", () => {
133 const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")];
134 const { motion, layout } = fixture([a, b, c]);
135 motion.update(true);
136 motion.suspend();
137 a.dragTranslate = 300;
138 b.dragTranslate = -83;
139 motion.update(false);
140 motion.suspend();
141 layout([b, a, c]);
142 a.dragTranslate = b.dragTranslate = 0;
143 motion.update(true);
144 expect([a, b, c].every((row) => row.animations.length === 0)).toBe(true);
145
146 layout([c, b, a]);
147 motion.update(true);
148 expectMove(c, 166);
149 expectMove(b, -83);
150 expectMove(a, -83);
151 });
152
153 it("glides every row from its released position into its committed slot", () => {
154 const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")];
155 const { motion, layout } = fixture([a, b, c]);
156 motion.update(true);
157 motion.suspend();
158 // a is lifted 130px below its slot; b previews upward into a's place
159 // and c holds a 16px label gap open below the divider.
160 a.dragTranslate = 130;
161 b.dragTranslate = -83;
162 c.dragTranslate = 16;
163 motion.update(false);
164 motion.release();
165 layout([b, a, c]);
166 a.dragTranslate = b.dragTranslate = c.dragTranslate = 0;
167 motion.update(true);
168 // Lifted top 138, committed slot top 91: glide the remaining 47px.
169 expectMove(a, 47);
170 // b already sits where it lands; c closes its 16px gap.
171 expect(b.animations).toHaveLength(0);
172 expectMove(c, 16);
173 });
174
175 it("does not glide on release when motion is reduced", () => {
176 const [a, b] = [new TestRow("a"), new TestRow("b")];
177 const { motion, layout, media } = fixture([a, b]);
178 motion.update(true);
179 media.matches = true;
180 a.dragTranslate = 100;
181 motion.release();
182 layout([b, a]);
183 a.dragTranslate = 0;
184 motion.update(true);
185 expect(a.animations).toHaveLength(0);
186 expect(b.animations).toHaveLength(0);
187 });
188
189 it("does not carry a canceled drag's transformed position into the next move", () => {
190 const a = new TestRow("a");
191 const b = new TestRow("b");
192 const { motion, layout } = fixture([a, b]);
193 motion.update(true);
194 motion.suspend();
195 a.dragTranslate = 500;
196 b.dragTranslate = -83;
197 motion.update(false);
198 motion.suspend();
199 a.dragTranslate = b.dragTranslate = 0;
200 motion.update(true);
201 expect(a.animate).not.toHaveBeenCalled();
202 expect(b.animate).not.toHaveBeenCalled();
203
204 layout([b, a]);
205 motion.update(true);
206 expectMove(a, -83);
207 expectMove(b, 83);
208 });
209
210 it("retargets rapid changes from the current visual position", () => {
211 const [a, b, c] = [new TestRow("a", 99), new TestRow("b", 99), new TestRow("c", 99)];
212 const { motion, layout } = fixture([a, b, c]);
213 motion.update(true);
214 layout([b, c, a]);
215 motion.update(true);
216 expectMove(a, -200);
217 const first = a.animations[0]!;
218 first.progress = 0.25;
219
220 layout([b, a, c]);
221 motion.update(true);
222 expect(first.cancel).toHaveBeenCalledOnce();
223 expectMove(a, -50);
224 first.finish();
225 motion.suspend();
226 expect(a.animations[1]!.cancel).toHaveBeenCalledOnce();
227 });
228
229 it("keeps an uninterrupted movement when the layout position does not change", () => {
230 const a = new TestRow("a");
231 const b = new TestRow("b");
232 const { motion, layout } = fixture([a, b]);
233 motion.update(true);
234 layout([b, a]);
235 motion.update(true);
236 a.animations[0]!.progress = 0.5;
237 motion.update(true);
238 expect(a.animate).toHaveBeenCalledOnce();
239 expect(a.animations[0]!.cancel).not.toHaveBeenCalled();
240 });
241
242 it("cancels owned motion on suspension and never animates a disposed list", () => {
243 const a = new TestRow("a");
244 const b = new TestRow("b");
245 const { motion, layout } = fixture([a, b]);
246 motion.update(true);
247 layout([b, a]);
248 motion.update(true);
249 motion.suspend();
250 expect(a.animations[0]!.cancel).toHaveBeenCalledOnce();
251 expect(b.animations[0]!.cancel).toHaveBeenCalledOnce();
252 motion.update(false);
253 layout([a, b]);
254 motion.update(true);
255 motion.dispose();
256 expect(a.animations[1]!.cancel).toHaveBeenCalledOnce();
257 layout([b, a]);
258 motion.update(true);
259 expect(a.animate).toHaveBeenCalledTimes(2);
260 });
261
262 it("fades a collapsed-shelf exit at its current visual box and a new wake in", () => {
263 const a = new TestRow("a");
264 const b = new TestRow("b");
265 const fresh = new TestRow("new");
266 a.setAttribute("data-thread-item", "a");
267 a.children = [new TestRow("button")];
268 a.children[0]!.setAttribute("id", "thread-control");
269 a.children[0]!.setAttribute("data-testid", "thread-control");
270 a.children[0]!.setAttribute("data-state", "open");
271 const icon = new TestRow("provider icon");
272 icon.namespaceURI = "http://www.w3.org/2000/svg";
273 icon.setAttribute("id", "provider-mask");
274 icon.setAttribute("mask", "url(#provider-mask)");
275 a.children.push(icon);
276 const { motion, layout, parent } = fixture([a, b]);
277 motion.update(true);
278 layout([b, a]);
279 motion.update(true);
280 a.animations[0]!.progress = 0.5;
281 layout([b, fresh]);
282 motion.update(true);
283 expect(a.animations[0]!.cancel).toHaveBeenCalledOnce();
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 );
294 const clone = a.clones[0]!;
295 expect(clone.style).toMatchObject({
296 position: "absolute",
297 top: "49.5px",
298 left: "4px",
299 width: "260px",
300 height: "82px",
301 transform: "none",
302 pointerEvents: "none",
303 });
304 expect(clone.inert).toBe(true);
305 expect(clone.attributes).toEqual([{ name: "aria-hidden", value: "true" }]);
306 expect(clone.children[0]!.attributes).toEqual([{ name: "data-state", value: "open" }]);
307 expect(clone.children[1]!.attributes).toEqual(icon.attributes);
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 );
318 expect(parent.children.includes(clone)).toBe(true);
319 motion.update(true);
320 expect(clone.animations).toHaveLength(1);
321 expect(clone.clones).toHaveLength(0);
322 clone.animations[0]!.finish();
323 expect(parent.children.includes(clone)).toBe(false);
324 });
325
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
426 it("clears exit clones on pickup and does not fade the release commit", () => {
427 const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")];
428 const { motion, layout, parent } = fixture([a, b]);
429 motion.update(false);
430 layout([b]);
431 motion.update(true);
432 const clone = a.clones[0]!;
433 motion.suspend();
434 expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce();
435 expect(parent.children.includes(clone)).toBe(false);
436 motion.update(false);
437 motion.suspend();
438 layout([c]);
439 motion.update(true);
440 expect(b.clones).toHaveLength(0);
441 expect(c.animations).toHaveLength(0);
442 layout([c, a]);
443 motion.update(true);
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 );
454 motion.dispose();
455 expect(a.animations.at(-1)!.cancel).toHaveBeenCalledOnce();
456 });
457
458 it("carries entry opacity into a quick exit and removes artifacts on a silent update", () => {
459 const a = new TestRow("a");
460 const marker = new TestRow("boundary", 0);
461 const { motion, layout, parent } = fixture([marker]);
462 motion.update(false);
463 layout([marker, a]);
464 motion.update(true);
465 a.animations[0]!.progress = 0.4;
466 layout([]);
467 motion.update(true);
468 expect(marker.clones).toHaveLength(0);
469 const clone = a.clones[0]!;
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 );
480 motion.update(false);
481 expect(parent.children).toEqual([]);
482 expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce();
483 });
484
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
504 it("skips fades when a large list change would clone too many rows", () => {
505 const rows = Array.from({ length: 41 }, (_, index) => new TestRow(`row-${index}`));
506 const { motion, layout, parent } = fixture(rows);
507 motion.update(true);
508 layout([]);
509 motion.update(true);
510 expect(rows.every((row) => row.clones.length === 0)).toBe(true);
511 expect(parent.children).toHaveLength(0);
512 const entering = Array.from({ length: 41 }, (_, index) => new TestRow(`new-${index}`));
513 layout(entering);
514 motion.update(true);
515 expect(entering.every((row) => row.animate.mock.calls.length === 0)).toBe(true);
516 });
517
518 it("still slides rows when one removal displaces a large list", () => {
519 const rows = Array.from({ length: 60 }, (_, index) => new TestRow(`row-${index}`));
520 const { motion, layout } = fixture(rows);
521 motion.update(true);
522 const [removed, ...rest] = rows;
523 layout(rest);
524 motion.update(true);
525 expect(removed!.clones).toHaveLength(1);
526 expect(rest.every((row) => row.animations.length === 1)).toBe(true);
527 });
528
529 it("respects reduced motion while keeping the next baseline fresh", () => {
530 const a = new TestRow("a");
531 const b = new TestRow("b");
532 const { motion, layout, media } = fixture([a, b]);
533 motion.update(true);
534 media.matches = true;
535 layout([b, a]);
536 motion.update(true);
537 expect(a.animate).not.toHaveBeenCalled();
538 media.matches = false;
539 layout([a, b]);
540 motion.update(true);
541 expectMove(a, 83);
542 expectMove(b, -83);
543 });
544});