Files
PodTui/tests/nested-scroll.test.tsx

129 lines
4.4 KiB
TypeScript

/**
* Nested scroll behavior — the innermost scrollbox under the cursor wins.
*
* opentui bubbles wheel events up the renderable tree, so without a guard
* every ancestor scrollbox scrolls in lockstep. This pins the fix from
* `src/utils/nested-scroll.ts`: two nested scrollboxes (an inner one nested
* inside an outer one, as a description pane sits inside a list pane) must
* treat the wheel as owned by the innermost scrollbox under the cursor, and
* only chain out to the outer one when the inner is at its boundary.
*/
import { describe, test, expect, afterAll } from "bun:test";
import { testRender } from "@opentui/solid";
import { installNestedScrollBehavior } from "../src/utils/nested-scroll";
import type { ScrollBoxRenderable } from "@opentui/core";
installNestedScrollBehavior();
type TestSetup = {
renderOnce: () => Promise<void>;
mockMouse: {
scroll: (x: number, y: number, direction: "up" | "down") => Promise<void>;
};
renderer: { destroy: () => Promise<void> };
};
async function renderNested(): Promise<{
setup: TestSetup;
outer: () => ScrollBoxRenderable;
inner: () => ScrollBoxRenderable;
destroy: () => Promise<void>;
}> {
let outer: ScrollBoxRenderable | undefined;
let inner: ScrollBoxRenderable | undefined;
const setup = (await testRender(
() => (
// Outer spans the full 25-row terminal; the inner scrollbox sits at
// rows 3..12 (a top spacer above, a tall spacer below so the outer
// has room to scroll). Inner holds 30 rows -> max scroll 20.
<box flexDirection="column" width={60} height={25}>
<scrollbox ref={(el: ScrollBoxRenderable) => (outer = el)} height="100%">
<box height={3} />
<scrollbox
ref={(el: ScrollBoxRenderable) => (inner = el)}
height={10}
width="100%"
>
{Array.from({ length: 30 }, (_, i) => (
<box height={1}>
<text>row {i}</text>
</box>
))}
</scrollbox>
<box height={40} />
</scrollbox>
</box>
),
{ width: 60, height: 25, useThread: false },
)) as unknown as TestSetup;
// Give the renderer a chance to compute scrollbox layout (scrollHeight).
for (let i = 0; i < 40 && (inner?.scrollHeight ?? 0) <= 10; i++) {
await setup.renderOnce();
const { promise, resolve } = Promise.withResolvers<void>();
setTimeout(resolve, 50);
await promise;
}
if (!inner || !outer) throw new Error("scrollboxes did not render");
return {
setup,
outer: () => outer!,
inner: () => inner!,
destroy: async () => {
setup.renderer.destroy();
},
};
}
const cleanups: (() => void | Promise<void>)[] = [];
afterAll(async () => {
for (const c of cleanups) {
try {
await c();
} catch {
// renderer already torn down — ignore
}
}
});
describe("nested scroll favors the innermost scrollbox under the cursor", () => {
test("wheel over the inner section scrolls only the inner scrollbox", async () => {
const { setup, inner, outer, destroy } = await renderNested();
cleanups.push(destroy);
expect(inner().scrollTop).toBe(0);
await setup.mockMouse.scroll(5, 5, "down"); // inside inner (rows 3..12)
expect(inner().scrollTop).toBe(1);
expect(outer().scrollTop).toBe(0);
});
test("wheel over the outer section (outside the inner) scrolls only the outer", async () => {
const { setup, inner, outer, destroy } = await renderNested();
cleanups.push(destroy);
await setup.mockMouse.scroll(5, 20, "down"); // below inner, still in outer
expect(outer().scrollTop).toBe(1);
expect(inner().scrollTop).toBe(0);
});
test("at the inner's bottom edge the wheel chains out to the outer scrollbox", async () => {
const { setup, inner, outer, destroy } = await renderNested();
cleanups.push(destroy);
for (let i = 0; i < 30; i++) await setup.mockMouse.scroll(5, 5, "down");
expect(inner().scrollTop).toBe(20); // pinned at max (30 rows - 10 viewport)
const before = outer().scrollTop;
await setup.mockMouse.scroll(5, 5, "down");
expect(inner().scrollTop).toBe(20); // inner stays pinned
expect(outer().scrollTop).toBe(before + 1); // outer took over
});
test("wheel up favors the inner again once it has room above", async () => {
const { setup, inner, outer, destroy } = await renderNested();
cleanups.push(destroy);
await setup.mockMouse.scroll(5, 5, "down");
expect(inner().scrollTop).toBe(1);
await setup.mockMouse.scroll(5, 5, "up");
expect(inner().scrollTop).toBe(0); // inner wins again
expect(outer().scrollTop).toBe(0);
});
});