| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | |
| 3 | import { createSidebarListMotion } from "./Sidebar.motion"; |
| 4 | |
| 5 | class 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 | |
| 22 | class 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 | |
| 68 | function 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 | |
| 97 | function 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 | |
| 104 | beforeEach(() => vi.stubGlobal("HTMLElement", TestRow)); |
| 105 | afterEach(() => vi.unstubAllGlobals()); |
| 106 | |
| 107 | describe("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 | }); |