fix(project): keep the tree viewport when expanding shifts rows above (#579)

The tree followed the lead row by index, so expanding or collapsing a folder above the selection shifted the index and scrolled back to the selected row. It now scrolls when the lead changes. A lead whose row has not loaded yet is kept pending and scrolled to once when the row appears, so preselected rows and async loads still come into view.

Covers keyboard navigation, speed search, Select Opened File and autoscroll from source, which all still scroll to the new lead.

Co-authored-by: a-rookie-of-C-language <[email protected]>
Цей коміт міститься в:
arookieofcтаa-rookie-of-C-language authored and GitHub committed 2026-10-31 23:44:34 +08:00
1 джерело 347e3525d2
коміт 369c63979d
2 files changed
+120 -3

No files matched your search

+103 -1
Переглянути файл
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { createRef } from "react";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { Tree, type TreeHandle } from "./Tree";
@@ -364,3 +364,105 @@ describe("Tree horizontal scrolling", () => {
}
});
});
describe("Tree scroll following", () => {
let scrollTo: ReturnType<typeof vi.fn>;
let original: PropertyDescriptor | undefined;
beforeAll(() => {
// jsdom has no Element.scrollTo; virtual-core calls it to write scrollTop.
scrollTo = vi.fn();
original = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTo");
Object.defineProperty(Element.prototype, "scrollTo", { value: scrollTo, configurable: true, writable: true });
});
afterAll(() => {
if (original) Object.defineProperty(Element.prototype, "scrollTo", original);
else delete (Element.prototype as { scrollTo?: unknown }).scrollTo;
});
it("brings the keyboard lead into view", () => {
const { key } = setup();
scrollTo.mockClear();
key("End");
expect(scrollTo).toHaveBeenCalled();
});
it("keeps the viewport when expanding a folder above the selection", () => {
// The selected file stays selected while rows inserted above it shift its
// index; scrolling then would jump to the selection from the opened folder.
const { tree } = setup({ defaultSelected: ["README.md"] });
scrollTo.mockClear();
const chevron = tree.querySelector("[data-tree-toggle]");
expect(chevron).not.toBeNull();
fireEvent.mouseDown(chevron!, { button: 0 });
expect(scrollTo).not.toHaveBeenCalled();
// The folder did open, inserting rows above the selection.
expect(within(tree).queryAllByRole("treeitem").length).toBeGreaterThan(2);
});
it.each(["defaultSelected", "selected"] as const)("scrolls a late %s lead once when its row loads", (selection) => {
const view = (roots: N[]) => (
<Tree<N>
roots={roots}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={(n) => n.children}
isExpandable={(n) => n.id === "src" || !!n.children}
defaultExpanded={["src"]}
{...{ [selection]: new Set(["late.ts"]) }}
/>
);
const { rerender } = render(view([{ id: "src" }]));
scrollTo.mockClear();
// An intermediate load still does not contain the selected row.
rerender(view([{ id: "src", children: [{ id: "main", children: [{ id: "App.tsx" }] }] }]));
expect(scrollTo).not.toHaveBeenCalled();
rerender(view([{ id: "src", children: [{ id: "main", children: [{ id: "App.tsx" }] }, { id: "late.ts" }] }]));
expect(scrollTo).toHaveBeenCalledTimes(1);
expect(screen.getByRole("treeitem", { name: "late.ts" })).toHaveAttribute("aria-selected", "true");
scrollTo.mockClear();
const folder = screen.getByRole("treeitem", { name: "main" });
fireEvent.mouseDown(folder.querySelector("[data-tree-toggle]")!, { button: 0 });
expect(screen.getByRole("treeitem", { name: "App.tsx" })).toBeInTheDocument();
expect(scrollTo).not.toHaveBeenCalled();
fireEvent.mouseDown(folder.querySelector("[data-tree-toggle]")!, { button: 0 });
expect(scrollTo).not.toHaveBeenCalled();
});
it("replaces a pending lead when the user selects another row", () => {
const view = (roots: N[]) => (
<Tree<N>
roots={roots}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={(n) => n.children}
isExpandable={(n) => !!n.children}
defaultSelected={["late.ts"]}
/>
);
const { rerender } = render(view([{ id: "README.md" }]));
fireEvent.keyDown(screen.getByRole("tree"), { key: "End" });
scrollTo.mockClear();
rerender(view([{ id: "late.ts" }, { id: "README.md" }]));
expect(screen.getByRole("treeitem", { name: "README.md" })).toHaveAttribute("aria-selected", "true");
expect(scrollTo).not.toHaveBeenCalled();
});
it("explicitly reveals an unchanged lead", () => {
const handle = createRef<TreeHandle>();
setup({ handleRef: handle, defaultSelected: ["README.md"] });
scrollTo.mockClear();
act(() => handle.current!.reveal("README.md"));
expect(scrollTo).toHaveBeenCalledTimes(1);
});
it("brings speed search matches into view", () => {
const { key, lead } = setup({ defaultExpanded: ["src", "main"] });
key("Home");
scrollTo.mockClear();
key("u");
expect(lead()).toBe("util.ts");
expect(scrollTo).toHaveBeenCalledTimes(1);
});
});
+17 -2
Переглянути файл
@@ -307,9 +307,24 @@ export function Tree<T>(props: TreeProps<T>) {
getItemKey: (i) => rows[i]!.key,
});
// Keep the keyboard lead in view when the lead changes — not when its index
// shifts because expanding or collapsing elsewhere inserted rows above it:
// following the index would yank the viewport from the folder the user just
// opened back to the selection.
const pendingScrollToLead = useRef<string | null>(null);
useEffect(() => {
if (leadIndex >= 0) virtualizer.scrollToIndex(leadIndex, { align: "auto" });
}, [leadIndex, virtualizer]);
pendingScrollToLead.current = lead;
}, [lead]);
// A preselected lead may arrive before its row is loaded. Retry until it
// appears, then stop following index changes for that selection.
useEffect(() => {
const key = pendingScrollToLead.current;
if (key == null) return;
const i = indexOfKey.get(key);
if (i === undefined) return;
virtualizer.scrollToIndex(i, { align: "auto" });
pendingScrollToLead.current = null;
}, [lead, indexOfKey, virtualizer]);
// Speed search owns Escape while active.
useEffect(() => {