diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 7ccfe9f14..7749192ba 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -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", diff --git a/src/ui/sidebar/HostManagerTabs.tsx b/src/ui/sidebar/HostManagerTabs.tsx index eeefad956..d1e39a4f8 100644 --- a/src/ui/sidebar/HostManagerTabs.tsx +++ b/src/ui/sidebar/HostManagerTabs.tsx @@ -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(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 (
-
{tabs.map(renderTab)}
+ {overflow && ( + + )} +
+
{tabs.map(renderTab)}
+
+ {overflow && ( + + )}
); } diff --git a/src/ui/tests/sidebar/AdminUserManagePanel.test.tsx b/src/ui/tests/sidebar/AdminUserManagePanel.test.tsx index 9646139b3..83f830cfe 100644 --- a/src/ui/tests/sidebar/AdminUserManagePanel.test.tsx +++ b/src/ui/tests/sidebar/AdminUserManagePanel.test.tsx @@ -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(); }); diff --git a/src/ui/tests/sidebar/tab-strip-scroll.test.tsx b/src/ui/tests/sidebar/tab-strip-scroll.test.tsx new file mode 100644 index 000000000..ea99c5d5f --- /dev/null +++ b/src/ui/tests/sidebar/tab-strip-scroll.test.tsx @@ -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( + , + ); + 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(); +});