дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
fix(workbench): keep the active tab close button visible when the editor area narrows (#1784)
Signed-off-by: Inf1mum <[email protected]>
Цей коміт міститься в:
1 джерело
6e18f32a32
коміт
c08657ba17
3 files changed
+96
-7
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in new issue
Заблокувати користувача