fix: stabilize Toolbox navigation layout

This commit is contained in:
2026-07-23 01:10:18 +02:00
parent a058858b50
commit c296281f89
12 changed files with 750 additions and 251 deletions

View File

@@ -1,11 +1,17 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import {
TOOLBOX_PREFERENCES_KEY,
type ToolboxAppManifest,
} from "@add-ideas/toolbox-contract";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AppShell } from "../src/index.js";
import { AppShell, ToolboxHeader } from "../src/index.js";
const currentApp: ToolboxAppManifest = {
schemaVersion: 1,
@@ -112,16 +118,40 @@ describe("AppShell", () => {
expect(
document.querySelector("[data-toolbox-context='standalone']"),
).toBeInTheDocument();
const controls = screen.getByLabelText("Toolbox controls");
expect(
screen.queryByRole("navigation", { name: "Toolbox applications" }),
Array.from(controls.children, (element) =>
element.getAttribute("aria-label"),
),
).toEqual([
"Personalize",
"Apps",
"Source for PDF Workbench on Gitea",
"Help",
]);
fireEvent.click(screen.getByRole("button", { name: "Apps" }));
expect(
screen.getByRole("navigation", { name: "Toolbox applications" }),
).toHaveTextContent("Open this app from Toolbox");
expect(
screen.getByRole("link", { name: /Source for PDF Workbench/u }),
).toHaveAttribute("href", "https://git.example.test/pdf-tools");
fireEvent.click(screen.getByRole("button", { name: "Help" }));
expect(onHelp).toHaveBeenCalledOnce();
expect(screen.getByText("Personalize")).toBeInTheDocument();
expect(screen.getByText("Apps")).toBeInTheDocument();
expect(
screen.queryByRole("navigation", { name: "Toolbox applications" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Personalize" }),
).toHaveTextContent("Personalize");
expect(screen.getByRole("button", { name: "Apps" })).toHaveTextContent(
"Apps",
);
expect(document.querySelector(".toolbox-shell__icon")).toBeNull();
expect(screen.getByText("add·ideas Toolbox").closest("a")).toBeNull();
expect(document.querySelector(".toolbox-shell__footer")).toContainElement(
screen.getByLabelText("Version 1.2.3"),
);
});
it("loads same-origin context and creates full-page contextual switch links", async () => {
@@ -140,7 +170,13 @@ describe("AppShell", () => {
</AppShell>,
);
const navigation = await screen.findByRole("navigation", {
await waitFor(() =>
expect(
document.querySelector("[data-toolbox-context='connected']"),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole("button", { name: "Apps" }));
const navigation = screen.getByRole("navigation", {
name: "Toolbox applications",
});
expect(navigation).toBeInTheDocument();
@@ -152,10 +188,14 @@ describe("AppShell", () => {
expect(destination.searchParams.get("toolbox")).toBe(
"https://tools.example.test/toolbox.catalog.json",
);
expect(screen.getByRole("link", { name: "add·ideas" })).toHaveAttribute(
"href",
"https://tools.example.test/",
);
expect(
screen.getByRole("link", { name: "add·ideas Toolbox" }),
).toHaveAttribute("href", "https://tools.example.test/");
expect(
screen
.getByRole("link", { name: "add·ideas Toolbox" })
.querySelector("img"),
).toHaveAttribute("src", "https://tools.example.test/favicon.svg");
expect(
document.querySelector("[data-toolbox-context='connected']"),
).toBeInTheDocument();
@@ -183,8 +223,9 @@ describe("AppShell", () => {
code: "cross-origin",
});
expect(fetch).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Apps" }));
expect(
screen.queryByRole("navigation", { name: "Toolbox applications" }),
screen.getByRole("navigation", { name: "Toolbox applications" }),
).toHaveTextContent("Open this app from Toolbox");
});
@@ -236,7 +277,7 @@ describe("AppShell", () => {
const shell = document.querySelector(".toolbox-shell");
expect(shell).toHaveAttribute("data-toolbox-theme", "dark");
fireEvent.click(screen.getByText("Personalize"));
fireEvent.click(screen.getByRole("button", { name: "Personalize" }));
fireEvent.click(screen.getByRole("button", { name: "Light" }));
expect(shell).toHaveAttribute("data-toolbox-theme", "light");
expect(
@@ -244,10 +285,19 @@ describe("AppShell", () => {
).toMatchObject({ theme: "light" });
});
it("resolves a relative explicit manifest URL against the deployed page", async () => {
it("resolves manifest-relative header links without rendering an app icon", async () => {
render(
<AppShell
app={currentApp}
app={{
...currentApp,
actions: [
{
id: "source",
label: "Source",
url: "./source/",
},
],
}}
manifestUrl="./toolbox-app.json"
contextOptions={{
location: "https://tools.example.test/nested/pdf/index.html",
@@ -257,9 +307,122 @@ describe("AppShell", () => {
</AppShell>,
);
expect(document.querySelector(".toolbox-shell__icon")).toHaveAttribute(
"src",
"https://tools.example.test/nested/pdf/icon.svg",
expect(
screen.getByRole("link", { name: "Source for PDF Workbench on Gitea" }),
).toHaveAttribute("href", "https://tools.example.test/nested/pdf/source/");
expect(document.querySelector(".toolbox-shell__icon")).toBeNull();
});
it("uses one accessible controlled popover for Personalize and Apps", () => {
const onHelp = vi.fn();
render(
<ToolboxHeader
title="PDF Workbench"
subtitle="Local PDF tools"
theme="system"
personalizeContent={<a href="#preferences">Full preferences</a>}
helpAction={{ onClick: onHelp }}
sourceHref="https://git.example.test/pdf-tools"
apps={[
{
id: "xslt",
name: "XSLT Workbench",
href: "#xslt",
},
]}
/>,
);
const personalize = screen.getByRole("button", { name: "Personalize" });
const apps = screen.getByRole("button", { name: "Apps" });
expect(
screen.getByRole("group", { name: "Toolbox controls" }),
).toBeInTheDocument();
expect(personalize).toHaveAttribute("aria-label", "Personalize");
expect(apps).toHaveAttribute("aria-label", "Apps");
expect(apps).not.toHaveAttribute("aria-haspopup");
fireEvent.click(personalize);
expect(
screen.getByRole("dialog", { name: "Personalize Toolbox" }),
).toHaveTextContent("Full preferences");
expect(personalize).toHaveAttribute("aria-expanded", "true");
expect(
screen.getByRole("link", { name: "Full preferences" }),
).toHaveFocus();
fireEvent.click(apps);
expect(
screen.queryByRole("dialog", { name: "Personalize Toolbox" }),
).not.toBeInTheDocument();
const navigation = screen.getByRole("navigation", {
name: "Toolbox applications",
});
expect(navigation).toBeInTheDocument();
expect(
within(navigation).getByRole("link", { name: "XSLT Workbench" }),
).toHaveFocus();
fireEvent.keyDown(document, { key: "Escape" });
expect(
screen.queryByRole("navigation", { name: "Toolbox applications" }),
).not.toBeInTheDocument();
expect(apps).toHaveFocus();
fireEvent.click(personalize);
fireEvent.pointerDown(document.body);
expect(
screen.queryByRole("dialog", { name: "Personalize Toolbox" }),
).not.toBeInTheDocument();
fireEvent.click(apps);
fireEvent.click(
within(
screen.getByRole("navigation", { name: "Toolbox applications" }),
).getByRole("link", { name: "XSLT Workbench" }),
);
expect(
screen.queryByRole("navigation", { name: "Toolbox applications" }),
).not.toBeInTheDocument();
});
it("keeps the legacy onPersonalize callback when no popover content exists", () => {
const onPersonalize = vi.fn();
const onThemeChange = vi.fn();
render(
<ToolboxHeader
title="PDF Workbench"
theme="system"
onPersonalize={onPersonalize}
onThemeChange={onThemeChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Personalize" }));
expect(onPersonalize).toHaveBeenCalledOnce();
expect(
screen.queryByRole("dialog", { name: "Personalize Toolbox" }),
).not.toBeInTheDocument();
});
it("closes Personalize if its content becomes unavailable", () => {
const { rerender } = render(
<ToolboxHeader
title="PDF Workbench"
theme="system"
onThemeChange={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Personalize" }));
expect(
screen.getByRole("dialog", { name: "Personalize Toolbox" }),
).toBeInTheDocument();
rerender(<ToolboxHeader title="PDF Workbench" theme="system" />);
expect(
screen.queryByRole("dialog", { name: "Personalize Toolbox" }),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Personalize" })).toBeDisabled();
});
});