дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
394 рядки
16 KiB
TypeScript
394 рядки
16 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { useState } from "react";
|
|
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
|
import {
|
|
dropIndex,
|
|
revealHorizontally,
|
|
revealVertically,
|
|
TabStripRegistry,
|
|
Tabs,
|
|
type TabItem,
|
|
type TabStripHandle,
|
|
type TabsDnd,
|
|
} from "./Tabs";
|
|
import { setDragRequiresAlt } from "./pointerDrag";
|
|
import styles from "./Tabs.module.css";
|
|
|
|
describe("dropIndex", () => {
|
|
it("finds the insertion index from tab midpoints", () => {
|
|
const rects = [
|
|
{ left: 0, top: 0, width: 100, height: 24 },
|
|
{ left: 100, top: 0, width: 50, height: 24 },
|
|
];
|
|
expect(dropIndex(10, 12, rects)).toBe(0);
|
|
expect(dropIndex(60, 12, rects)).toBe(1);
|
|
expect(dropIndex(130, 12, rects)).toBe(2);
|
|
expect(dropIndex(500, 12, rects)).toBe(2);
|
|
// Above or below the tabs still counts as their row.
|
|
expect(dropIndex(60, -5, rects)).toBe(1);
|
|
expect(dropIndex(60, 40, rects)).toBe(1);
|
|
expect(dropIndex(10, 12, [])).toBe(0);
|
|
});
|
|
|
|
it("follows wrapped rows", () => {
|
|
const rects = [
|
|
{ left: 0, top: 0, width: 100, height: 24 },
|
|
{ left: 100, top: 0, width: 100, height: 24 },
|
|
{ left: 0, top: 26, width: 100, height: 24 },
|
|
];
|
|
expect(dropIndex(180, 12, rects)).toBe(2);
|
|
expect(dropIndex(20, 30, rects)).toBe(2);
|
|
expect(dropIndex(150, 30, rects)).toBe(3);
|
|
expect(dropIndex(150, 25, rects)).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("revealHorizontally", () => {
|
|
const el = (left: number, right: number, scrollLeft = 0) =>
|
|
({ scrollLeft, getBoundingClientRect: () => ({ left, right }) }) as unknown as HTMLElement;
|
|
|
|
it("scrolls right until the whole tab (incl. close button) is visible", () => {
|
|
const scroller = el(0, 500, 100);
|
|
revealHorizontally(scroller, el(400, 560));
|
|
expect(scroller.scrollLeft).toBe(160);
|
|
});
|
|
it("scrolls left for a tab cut off on the left", () => {
|
|
const scroller = el(0, 500, 300);
|
|
revealHorizontally(scroller, el(-50, 100));
|
|
expect(scroller.scrollLeft).toBe(250);
|
|
});
|
|
it("aligns the left edge of a tab wider than the strip", () => {
|
|
const scroller = el(0, 300, 0);
|
|
revealHorizontally(scroller, el(200, 700));
|
|
expect(scroller.scrollLeft).toBe(200);
|
|
});
|
|
it("leaves a fully visible tab alone", () => {
|
|
const scroller = el(0, 500, 40);
|
|
revealHorizontally(scroller, el(100, 200));
|
|
expect(scroller.scrollLeft).toBe(40);
|
|
});
|
|
});
|
|
|
|
describe("revealVertically", () => {
|
|
it("scrolls a column of tabs just enough to show the tab", () => {
|
|
const el = (top: number, bottom: number, scrollTop = 0) =>
|
|
({ scrollTop, getBoundingClientRect: () => ({ top, bottom }) }) as unknown as HTMLElement;
|
|
const scroller = el(0, 300, 50);
|
|
revealVertically(scroller, el(290, 314));
|
|
expect(scroller.scrollTop).toBe(64);
|
|
revealVertically(scroller, el(-20, 4));
|
|
expect(scroller.scrollTop).toBe(44);
|
|
});
|
|
});
|
|
|
|
describe("Tabs interactions", () => {
|
|
const items = [
|
|
{ id: "a", label: "A" },
|
|
{ id: "b", label: "B" },
|
|
{ id: "c", label: "C", closable: false },
|
|
];
|
|
|
|
it("Alt+click on a close button closes the other tabs", () => {
|
|
const onClose = vi.fn();
|
|
const onCloseOthers = vi.fn();
|
|
render(<Tabs items={items} activeId="a" onSelect={() => {}} onClose={onClose} onCloseOthers={onCloseOthers} />);
|
|
const close = screen.getAllByRole("button", { name: "Close Tab" })[1]!;
|
|
fireEvent.click(close, { altKey: true });
|
|
expect(onCloseOthers).toHaveBeenCalledWith("b");
|
|
fireEvent.click(close);
|
|
expect(onClose).toHaveBeenCalledWith("b");
|
|
});
|
|
|
|
it("Shift+left-click closes an editor tab without selecting it", () => {
|
|
const onClose = vi.fn();
|
|
const onSelect = vi.fn();
|
|
render(<Tabs items={items} activeId="a" onSelect={onSelect} onClose={onClose} closeOnShiftClick />);
|
|
fireEvent.click(screen.getByRole("tab", { name: "B" }), { shiftKey: true, detail: 1 });
|
|
expect(onClose).toHaveBeenCalledWith("b");
|
|
expect(onSelect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("Shift+click only closes closable tabs", () => {
|
|
const onClose = vi.fn();
|
|
const onSelect = vi.fn();
|
|
render(<Tabs items={items} activeId="a" onSelect={onSelect} onClose={onClose} closeOnShiftClick />);
|
|
fireEvent.click(screen.getByRole("tab", { name: "C" }), { shiftKey: true, detail: 1 });
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
expect(onSelect).toHaveBeenCalledWith("c");
|
|
});
|
|
|
|
it("middle click closes a tab without selecting it", () => {
|
|
const onClose = vi.fn();
|
|
const onSelect = vi.fn();
|
|
render(<Tabs items={items} activeId="a" onSelect={onSelect} onClose={onClose} />);
|
|
fireEvent.mouseDown(screen.getByRole("tab", { name: "B" }), { button: 1 });
|
|
expect(onClose).toHaveBeenCalledWith("b");
|
|
expect(onSelect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("middle click ignores tabs that cannot close", () => {
|
|
const onClose = vi.fn();
|
|
const onSelect = vi.fn();
|
|
render(<Tabs items={items} activeId="a" onSelect={onSelect} onClose={onClose} />);
|
|
fireEvent.mouseDown(screen.getByRole("tab", { name: "C" }), { button: 1 });
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
expect(onSelect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps Shift+click selection where the strip does not opt in", () => {
|
|
const onClose = vi.fn();
|
|
const onSelect = vi.fn();
|
|
render(<Tabs items={items} activeId="a" onSelect={onSelect} onClose={onClose} />);
|
|
fireEvent.click(screen.getByRole("tab", { name: "B" }), { shiftKey: true, detail: 1 });
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
expect(onSelect).toHaveBeenCalledWith("b");
|
|
});
|
|
|
|
it("does not treat a keyboard click with Shift held as a close", () => {
|
|
const onClose = vi.fn();
|
|
const onSelect = vi.fn();
|
|
render(<Tabs items={items} activeId="a" onSelect={onSelect} onClose={onClose} closeOnShiftClick />);
|
|
fireEvent.click(screen.getByRole("tab", { name: "B" }), { shiftKey: true });
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
expect(onSelect).toHaveBeenCalledWith("b");
|
|
});
|
|
|
|
it("lets a container close the selected tab when it is closable", () => {
|
|
const handles: TabStripHandle[] = [];
|
|
const register = (h: TabStripHandle) => (handles.push(h), () => void handles.splice(handles.indexOf(h), 1));
|
|
const onClose = vi.fn();
|
|
const ui = (activeId: string) => (
|
|
<TabStripRegistry.Provider value={register}>
|
|
<Tabs items={items} activeId={activeId} onSelect={() => {}} onClose={onClose} />
|
|
</TabStripRegistry.Provider>
|
|
);
|
|
const { rerender, unmount } = render(ui("b"));
|
|
expect(handles[0]!.closeSelected()).toBe(true);
|
|
expect(onClose).toHaveBeenCalledWith("b");
|
|
rerender(ui("c"));
|
|
expect(handles[0]!.closeSelected()).toBe(false);
|
|
unmount();
|
|
expect(handles).toHaveLength(0);
|
|
});
|
|
|
|
it("a vertical strip moves with ↑/↓, shows actions above the tabs and has no overflow dropdown", () => {
|
|
const onSelect = vi.fn();
|
|
render(
|
|
<Tabs
|
|
items={items}
|
|
activeId="a"
|
|
onSelect={onSelect}
|
|
orientation="vertical"
|
|
aria-label="Vertical"
|
|
trailing={<button type="button">Actions</button>}
|
|
/>,
|
|
);
|
|
const list = screen.getByRole("tablist", { name: "Vertical" });
|
|
expect(list.getAttribute("aria-orientation")).toBe("vertical");
|
|
const [first] = screen.getAllByRole("tab");
|
|
fireEvent.keyDown(first!, { key: "ArrowDown" });
|
|
expect(onSelect).toHaveBeenLastCalledWith("b");
|
|
fireEvent.keyDown(first!, { key: "ArrowUp" });
|
|
expect(onSelect).toHaveBeenLastCalledWith("c");
|
|
fireEvent.keyDown(first!, { key: "ArrowRight" });
|
|
expect(onSelect).toHaveBeenCalledTimes(2);
|
|
const actions = screen.getByRole("button", { name: "Actions" });
|
|
expect(actions.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
expect(screen.queryByRole("button", { name: "Show Hidden Tabs" })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Tabs close button in a narrow strip (#1740)", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const items = [
|
|
{ id: "a", label: "A" },
|
|
{ id: "b", label: "B" },
|
|
{ id: "c", label: "C" },
|
|
];
|
|
// "C" sits scrolled out of a 300px strip: its close button is past the right edge.
|
|
const clippedTabs = [
|
|
{ left: 0, right: 140 },
|
|
{ left: 140, right: 280 },
|
|
{ left: 280, right: 460 },
|
|
];
|
|
|
|
/** Rects of the scroller (role=tablist) and each tab slot, shifted by its scrollLeft like a real layout; everything else 1000x600. */
|
|
const mockRects = (scroller: { left: number; right: number }, tabs: Array<{ left: number; right: number }>) =>
|
|
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
|
if (this.getAttribute("role") === "tablist")
|
|
return new DOMRect(scroller.left, 0, scroller.right - scroller.left, 28);
|
|
if (this.parentElement?.getAttribute("role") === "tablist") {
|
|
const r = tabs[[...this.parentElement.children].indexOf(this)]!;
|
|
const shift = -this.parentElement.scrollLeft;
|
|
return new DOMRect(r.left + shift, 0, r.right - r.left, 24);
|
|
}
|
|
return new DOMRect(0, 0, 1000, 600);
|
|
});
|
|
|
|
it("clicking the active tab brings its clipped close button back into view", () => {
|
|
mockRects({ left: 0, right: 300 }, clippedTabs);
|
|
const onSelect = vi.fn();
|
|
render(<Tabs items={items} activeId="c" onSelect={onSelect} onClose={() => {}} />);
|
|
const scroller = screen.getByRole("tablist");
|
|
// After the strip narrows, scrollLeft sits at 0 and the X of the active "C" stays clipped:
|
|
// clicking the tab doesn't change activeId, so it must reveal the tab on its own.
|
|
scroller.scrollLeft = 0;
|
|
fireEvent.click(screen.getByRole("tab", { name: "C" }));
|
|
expect(onSelect).toHaveBeenCalledWith("c");
|
|
expect(scroller.scrollLeft).toBe(160); // min(460 - 300, 280 - 0)
|
|
});
|
|
|
|
it("keeps the active tab revealed when the strip is resized narrower", () => {
|
|
class RO {
|
|
static latest: { callback: ResizeObserverCallback } | null = null;
|
|
callback: ResizeObserverCallback;
|
|
constructor(callback: ResizeObserverCallback) {
|
|
this.callback = callback;
|
|
RO.latest = this;
|
|
}
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
}
|
|
vi.stubGlobal("ResizeObserver", RO);
|
|
mockRects({ left: 0, right: 400 }, clippedTabs);
|
|
render(<Tabs items={items} activeId="c" onSelect={() => {}} onClose={() => {}} />);
|
|
const scroller = screen.getByRole("tablist");
|
|
expect(scroller.scrollLeft).toBe(60); // mounted wide: min(460 - 400, 280)
|
|
// The editor area narrows and the strip's resize observer fires with the new box.
|
|
mockRects({ left: 0, right: 300 }, clippedTabs);
|
|
act(() => RO.latest!.callback([], RO.latest as unknown as ResizeObserver));
|
|
expect(scroller.scrollLeft).toBe(160); // min(460 - 300, 280)
|
|
});
|
|
});
|
|
|
|
describe("Tabs drag & drop", () => {
|
|
const TYPE = "application/x-test-tab";
|
|
let hit: Element | null;
|
|
beforeEach(() => {
|
|
hit = null;
|
|
Object.defineProperty(document, "elementFromPoint", { configurable: true, value: () => hit });
|
|
// Tabs are 100px wide in display order (`order` while a drag previews a move).
|
|
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
|
const strip = this.parentElement;
|
|
if (strip?.getAttribute("role") !== "tablist") return new DOMRect(0, 0, 1000, 600);
|
|
const slot = this.style.order === "" ? [...strip.children].indexOf(this) : Number(this.style.order);
|
|
return new DOMRect(slot * 100, 0, 100, 24);
|
|
});
|
|
setDragRequiresAlt(() => false);
|
|
});
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
function Strip({ label, initial, onDrop }: { label: string; initial: string[]; onDrop?: TabsDnd["onDrop"] }) {
|
|
const [ids, setIds] = useState(initial);
|
|
const items: TabItem[] = ids.map((id) => ({ id, label: id }));
|
|
const dnd: TabsDnd = {
|
|
type: TYPE,
|
|
payload: (id) => id,
|
|
onDrop:
|
|
onDrop ??
|
|
((id, index) =>
|
|
setIds((list) => {
|
|
const from = list.indexOf(id);
|
|
const rest = list.filter((it) => it !== id);
|
|
rest.splice(index > from ? index - 1 : index, 0, id);
|
|
return rest;
|
|
})),
|
|
};
|
|
return <Tabs aria-label={label} items={items} activeId={ids[0]!} onSelect={() => {}} dnd={dnd} />;
|
|
}
|
|
|
|
const strip = (label: string) => screen.getByRole("tablist", { name: label });
|
|
const tab = (label: string, name: string) => within(strip(label)).getByRole("tab", { name }).parentElement!;
|
|
// Labels in display order, the way the strip lays them out.
|
|
const shown = (label: string) =>
|
|
within(strip(label))
|
|
.getAllByRole("tab")
|
|
.map((b) => b.parentElement!)
|
|
.sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left)
|
|
.map((el) => el.textContent);
|
|
const pointer = (el: Element | Window, type: string, x: number) =>
|
|
fireEvent(
|
|
el,
|
|
new MouseEvent(type, {
|
|
bubbles: true,
|
|
button: 0,
|
|
buttons: type === "pointerup" ? 0 : 1,
|
|
clientX: x,
|
|
clientY: 12,
|
|
}),
|
|
);
|
|
|
|
it("moves the other tabs out of the way while a tab is dragged, like IDEA (#1052)", () => {
|
|
render(<Strip label="Terminal sessions" initial={["Local", "Local (2)", "Local (3)"]} />);
|
|
const dragged = tab("Terminal sessions", "Local");
|
|
pointer(within(dragged).getByRole("tab"), "pointerdown", 50);
|
|
hit = strip("Terminal sessions");
|
|
pointer(window, "pointermove", 60);
|
|
expect(dragged).toHaveClass(styles.dragging!);
|
|
const ghost = document.body.lastElementChild as HTMLElement;
|
|
expect(ghost).toHaveAttribute("aria-hidden", "true");
|
|
expect(ghost.style.position).toBe("fixed");
|
|
expect(shown("Terminal sessions")).toEqual(["Local", "Local (2)", "Local (3)"]);
|
|
|
|
// Past the neighbour's midpoint the slot moves; it stays until the pointer crosses back.
|
|
pointer(window, "pointermove", 160);
|
|
expect(shown("Terminal sessions")).toEqual(["Local (2)", "Local", "Local (3)"]);
|
|
pointer(window, "pointermove", 120);
|
|
expect(shown("Terminal sessions")).toEqual(["Local (2)", "Local", "Local (3)"]);
|
|
expect(document.querySelector(`.${styles.dropBefore}, .${styles.dropAfter}`)).toBeNull();
|
|
|
|
pointer(window, "pointerup", 120);
|
|
expect(shown("Terminal sessions")).toEqual(["Local (2)", "Local", "Local (3)"]);
|
|
expect(
|
|
within(strip("Terminal sessions"))
|
|
.getAllByRole("tab")
|
|
.map((b) => b.textContent),
|
|
).toEqual(["Local (2)", "Local", "Local (3)"]);
|
|
expect(tab("Terminal sessions", "Local")).not.toHaveClass(styles.dragging!);
|
|
expect(document.body.lastElementChild).not.toBe(ghost);
|
|
});
|
|
|
|
it("puts the slot back when the pointer leaves the strip and drops nothing on Escape", () => {
|
|
const onDrop = vi.fn();
|
|
render(<Strip label="Tabs" initial={["a", "b", "c"]} onDrop={onDrop} />);
|
|
pointer(within(tab("Tabs", "a")).getByRole("tab"), "pointerdown", 50);
|
|
hit = strip("Tabs");
|
|
pointer(window, "pointermove", 260);
|
|
expect(shown("Tabs")).toEqual(["b", "c", "a"]);
|
|
hit = null;
|
|
pointer(window, "pointermove", 260);
|
|
expect(shown("Tabs")).toEqual(["a", "b", "c"]);
|
|
hit = strip("Tabs");
|
|
pointer(window, "pointermove", 260);
|
|
fireEvent.keyDown(window, { key: "Escape" });
|
|
pointer(window, "pointerup", 260);
|
|
expect(onDrop).not.toHaveBeenCalled();
|
|
expect(shown("Tabs")).toEqual(["a", "b", "c"]);
|
|
});
|
|
|
|
it("marks the insertion point for a tab dragged in from another strip", () => {
|
|
const onDrop = vi.fn();
|
|
render(
|
|
<>
|
|
<Strip label="Left" initial={["a", "b"]} />
|
|
<Strip label="Right" initial={["c", "d"]} onDrop={onDrop} />
|
|
</>,
|
|
);
|
|
pointer(within(tab("Left", "a")).getByRole("tab"), "pointerdown", 50);
|
|
hit = strip("Right");
|
|
pointer(window, "pointermove", 20);
|
|
expect(tab("Right", "c")).toHaveClass(styles.dropBefore!);
|
|
expect(shown("Right")).toEqual(["c", "d"]);
|
|
expect(shown("Left")).toEqual(["a", "b"]);
|
|
pointer(window, "pointermove", 180);
|
|
expect(tab("Right", "d")).toHaveClass(styles.dropAfter!);
|
|
pointer(window, "pointerup", 180);
|
|
expect(onDrop).toHaveBeenCalledWith("a", 2);
|
|
});
|
|
});
|