mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
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:
1 parent
021670ca1d
commit
bd0858b5a2
4 files changed
+161
-8
No files matched your search
@@ -116,6 +116,8 @@
|
|||||||
"updateLinkLabel": "Update available. Open the release notes on GitHub"
|
"updateLinkLabel": "Update available. Open the release notes on GitHub"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"scrollTabsLeft": "Scroll tabs left",
|
||||||
|
"scrollTabsRight": "Scroll tabs right",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"minimize": "Minimize",
|
"minimize": "Minimize",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { useEffect, useRef, type ComponentType, type ReactNode } from "react";
|
import {
|
||||||
import { KeyRound, Settings, Terminal } from "lucide-react";
|
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";
|
import { byOrderThenId, createRegistry } from "@/lib/registry";
|
||||||
|
|
||||||
/** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */
|
/** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */
|
||||||
@@ -197,6 +210,27 @@ export function TabStrip({
|
|||||||
variant?: "primary" | "secondary";
|
variant?: "primary" | "secondary";
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -229,14 +263,48 @@ export function TabStrip({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const overflow = scroll.left || scroll.right;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
className={`flex min-w-0 items-center ${variant === "secondary" ? "border-t border-border bg-card" : ""}`}
|
||||||
className={`overflow-x-auto scrollbar-none ${
|
|
||||||
variant === "secondary" ? "border-t border-border bg-card" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
|
{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 className="flex min-w-max">{tabs.map(renderTab)}</div>
|
||||||
</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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
import { AdminUserManagePanel } from "../../sidebar/AdminUserManagePanel";
|
import { AdminUserManagePanel } from "../../sidebar/AdminUserManagePanel";
|
||||||
@@ -71,7 +71,20 @@ function renderPanel(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"ResizeObserver",
|
||||||
|
class {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
);
|
||||||
for (const fn of Object.values(api)) fn.mockClear();
|
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();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user