Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 198 additions & 16 deletions apps/web/src/components/Sidebar.motion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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",
Expand All @@ -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);
Expand All @@ -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]);
Expand All @@ -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();
});
Expand All @@ -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);
Expand Down
Loading
Loading