fix(workbench): keep the active tab close button visible when the editor area narrows (#1784)

Signed-off-by: Inf1mum <[email protected]>
Цей коміт міститься в:
Vadim Lazuk authored and GitHub committed 2026-11-07 00:22:19 +08:00
1 джерело 6e18f32a32
коміт c08657ba17
3 files changed
+96 -7

No files matched your search

+2
Переглянути файл
@@ -91,6 +91,8 @@
display: flex;
align-items: center;
width: 20px;
/* Squeezed tabs shrink the label, never the close button (#1740). */
flex: none;
margin-right: 4px;
}
.close {
+69 -1
Переглянути файл
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import {
dropIndex,
revealHorizontally,
@@ -198,6 +198,74 @@ describe("Tabs interactions", () => {
});
});
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;
+25 -6
Переглянути файл
@@ -5,6 +5,7 @@
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
@@ -213,24 +214,39 @@ export function Tabs({
});
}, [dndType]);
// Bring a tab's whole slot (label + close button) into view. Narrowing the strip clips
// the active tab's close button (#1740), and clicking the already-active tab doesn't
// change `activeId`, so the strip must also reveal on clicks and resizes.
const revealTab = useCallback(
(id: string | null) => {
const scroller = scrollRef.current;
const tab = id ? tabRefs.current.get(id)?.parentElement : null;
if (scroller && tab) (vertical ? revealVertically : revealHorizontally)(scroller, tab);
},
[vertical],
);
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el) return;
// A vertical strip shows its scrollbar instead of the overflow dropdown.
const measure = () => setOverflow(!vertical && el.scrollWidth > el.clientWidth + 1);
const measure = () => {
setOverflow(!vertical && el.scrollWidth > el.clientWidth + 1);
// A resize (e.g. the editor area narrowing) must scroll the active tab back
// into view instead of leaving its close button clipped until a tab switch.
revealTab(latest.current.activeId);
};
measure();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [items.length, vertical]);
}, [items.length, revealTab, vertical]);
useEffect(() => {
// The whole tab (label + close button) into view, without scrolling any outer container.
const tab = activeId ? tabRefs.current.get(activeId)?.parentElement : null;
const scroller = scrollRef.current;
if (tab && scroller) (vertical ? revealVertically : revealHorizontally)(scroller, tab);
}, [activeId, vertical]);
revealTab(activeId);
}, [activeId, revealTab]);
const focusTab = (index: number) => {
const item = items[(index + items.length) % items.length];
@@ -389,6 +405,9 @@ export function Tabs({
return;
}
onSelect(item.id);
// Revealing on every click: re-selecting the active tab doesn't change
// `activeId`, so the reveal effect wouldn't re-run (#1740).
revealTab(item.id);
}}
onDoubleClick={onDoubleClick ? () => onDoubleClick(item.id) : undefined}
onKeyDown={(e) => onKeyDown(e, index, item)}