fix(hosts): expose controls for overflowing editor tabs (#1537)

* fix(hosts): expose controls for overflowing editor tabs

* test(hosts): mock resize observation in admin panel tests
This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-10-03 21:00:55 +08:00
1 parent 021670ca1d
commit bd0858b5a2
4 files changed
+162 -9

No files matched your search

+2
View File
@@ -116,6 +116,8 @@
"updateLinkLabel": "Update available. Open the release notes on GitHub"
},
"common": {
"scrollTabsLeft": "Scroll tabs left",
"scrollTabsRight": "Scroll tabs right",
"close": "Close",
"minimize": "Minimize",
"online": "Online",
+75 -7
View File
@@ -1,6 +1,19 @@
/* eslint-disable react-refresh/only-export-components */
import { useEffect, useRef, type ComponentType, type ReactNode } from "react";
import { KeyRound, Settings, Terminal } from "lucide-react";
import {
useEffect,
useRef,
useState,
type ComponentType,
type ReactNode,
} from "react";
import {
ChevronLeft,
ChevronRight,
KeyRound,
Settings,
Terminal,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { byOrderThenId, createRegistry } from "@/lib/registry";
/** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */
@@ -197,6 +210,27 @@ export function TabStrip({
variant?: "primary" | "secondary";
}) {
const ref = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const [scroll, setScroll] = useState({ left: false, right: false });
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () =>
setScroll({
left: el.scrollLeft > 1,
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
});
const observer = new ResizeObserver(update);
observer.observe(el);
if (el.firstElementChild) observer.observe(el.firstElementChild);
el.addEventListener("scroll", update);
update();
return () => {
observer.disconnect();
el.removeEventListener("scroll", update);
};
}, [tabs]);
useEffect(() => {
const el = ref.current;
if (!el) return;
@@ -229,14 +263,48 @@ export function TabStrip({
);
};
const overflow = scroll.left || scroll.right;
return (
<div
ref={ref}
className={`overflow-x-auto scrollbar-none ${
variant === "secondary" ? "border-t border-border bg-card" : ""
}`}
className={`flex min-w-0 items-center ${variant === "secondary" ? "border-t border-border bg-card" : ""}`}
>
<div className="flex min-w-max">{tabs.map(renderTab)}</div>
{overflow && (
<button
type="button"
aria-label={t("common.scrollTabsLeft")}
title={t("common.scrollTabsLeft")}
disabled={!scroll.left}
className="shrink-0 p-1 disabled:opacity-30"
onClick={() =>
ref.current?.scrollBy({
left: -ref.current.clientWidth * 0.75,
behavior: "smooth",
})
}
>
<ChevronLeft className="size-4" />
</button>
)}
<div ref={ref} className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
<div className="flex min-w-max">{tabs.map(renderTab)}</div>
</div>
{overflow && (
<button
type="button"
aria-label={t("common.scrollTabsRight")}
title={t("common.scrollTabsRight")}
disabled={!scroll.right}
className="shrink-0 p-1 disabled:opacity-30"
onClick={() =>
ref.current?.scrollBy({
left: ref.current.clientWidth * 0.75,
behavior: "smooth",
})
}
>
<ChevronRight className="size-4" />
</button>
)}
</div>
);
}
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AdminUserManagePanel } from "../../sidebar/AdminUserManagePanel";
@@ -71,7 +71,20 @@ function renderPanel(
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
beforeEach(() => {
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
for (const fn of Object.values(api)) fn.mockClear();
});
@@ -0,0 +1,70 @@
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { TabStrip } from "@/sidebar/HostManagerTabs";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("reveals clipped tabs and updates the scroll controls after scrolling and resizing", () => {
let resize = () => {};
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: () => void) {
resize = callback;
}
observe() {}
disconnect() {}
},
);
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(200);
const width = vi
.spyOn(HTMLElement.prototype, "scrollWidth", "get")
.mockReturnValue(600);
const scrollBy = vi.fn();
HTMLElement.prototype.scrollBy = scrollBy;
const onTabChange = vi.fn();
const view = render(
<TabStrip
tabs={[
{ id: "general", label: "General", icon: null },
{ id: "metrics", label: "Metrics", icon: null },
]}
activeTab="general"
onTabChange={onTabChange}
/>,
);
const left = screen.getByLabelText(
"common.scrollTabsLeft",
) as HTMLButtonElement;
const right = screen.getByLabelText(
"common.scrollTabsRight",
) as HTMLButtonElement;
expect(left.disabled).toBe(true);
expect(right.disabled).toBe(false);
fireEvent.click(right);
expect(scrollBy).toHaveBeenCalledWith({ left: 150, behavior: "smooth" });
const strip = view.container.querySelector(".overflow-x-auto") as HTMLElement;
strip.scrollLeft = 400;
fireEvent.scroll(strip);
expect(left.disabled).toBe(false);
expect(right.disabled).toBe(true);
fireEvent.click(screen.getByText("Metrics"));
expect(onTabChange).toHaveBeenCalledWith("metrics");
strip.scrollLeft = 0;
width.mockReturnValue(200);
act(resize);
expect(screen.queryByLabelText("common.scrollTabsRight")).toBeNull();
});