fix(frontend): make Pi operator tabs keyboard accessible

This commit is contained in:
2026-08-14 19:01:20 +02:00
parent 1df881d98d
commit 99012f04c2
2 changed files with 135 additions and 13 deletions
+88 -9
View File
@@ -172,11 +172,24 @@ test("shows a seven-step host-terminal workflow in scrollable platform tabs", as
renderManagement();
const tablist = await screen.findByRole("tablist", { name: "Pi host platform" });
expect(within(tablist).getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["Linux", "macOS", "Windows"]);
const [linuxTab, macosTab, windowsTab] = within(tablist).getAllByRole("tab");
expect([linuxTab, macosTab, windowsTab].map((tab) => tab.textContent)).toEqual(["Linux", "macOS", "Windows"]);
expect(screen.getByRole("dialog", { name: "Pi management" })).toHaveClass("max-h-[calc(100vh-6rem)]");
expect(screen.getByTestId("pi-platform-instructions-scroll")).toHaveClass("overflow-y-scroll");
expect(screen.queryByRole("tabpanel")).not.toBeInTheDocument();
expect(within(tablist).getAllByRole("tab").every((tab) => tab.getAttribute("aria-selected") === "false")).toBe(true);
expect([linuxTab, macosTab, windowsTab].every((tab) => tab.getAttribute("aria-selected") === "false")).toBe(true);
expect(linuxTab).toHaveAttribute("tabindex", "0");
expect(macosTab).toHaveAttribute("tabindex", "-1");
expect(windowsTab).toHaveAttribute("tabindex", "-1");
for (const tab of [linuxTab, macosTab, windowsTab]) {
const panelId = tab.getAttribute("aria-controls");
const panel = panelId ? document.getElementById(panelId) : null;
expect(panel).toBeInTheDocument();
expect(panel).toHaveAttribute("role", "tabpanel");
expect(panel).toHaveAttribute("aria-labelledby", tab.id);
expect(panel).toHaveAttribute("hidden");
expect(panel).toHaveAttribute("tabindex", "-1");
}
expect(screen.getByText("Using the host terminal:")).toBeVisible();
expect(screen.queryByText(/not this browser page/i)).not.toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Host update" })).not.toBeInTheDocument();
@@ -185,9 +198,51 @@ test("shows a seven-step host-terminal workflow in scrollable platform tabs", as
expect(await screen.findByText("Select the provider, model, and reasoning used for new Pi work. Credentials stay in protected host files.")).toBeVisible();
expect(screen.getByText("Shows at most 200 recent lines with declared secret values removed.")).toBeVisible();
await user.click(within(tablist).getByRole("tab", { name: "Linux" }));
linuxTab.focus();
await user.keyboard("{ArrowRight}");
expect(macosTab).toHaveFocus();
expect(macosTab).toHaveAttribute("aria-selected", "true");
expect(macosTab).toHaveAttribute("tabindex", "0");
expect(linuxTab).toHaveAttribute("tabindex", "-1");
await user.keyboard("{ArrowRight}");
expect(windowsTab).toHaveFocus();
expect(windowsTab).toHaveAttribute("aria-selected", "true");
expect(windowsTab).toHaveAttribute("tabindex", "0");
expect(macosTab).toHaveAttribute("tabindex", "-1");
await user.keyboard("{ArrowRight}");
expect(linuxTab).toHaveFocus();
expect(linuxTab).toHaveAttribute("aria-selected", "true");
await user.keyboard("{ArrowLeft}");
expect(windowsTab).toHaveFocus();
await user.keyboard("{Home}");
expect(linuxTab).toHaveFocus();
await user.keyboard("{End}");
expect(windowsTab).toHaveFocus();
await user.click(windowsTab);
expect(windowsTab).toHaveAttribute("aria-selected", "false");
expect(screen.queryByRole("tabpanel")).not.toBeInTheDocument();
await user.keyboard(" ");
expect(windowsTab).toHaveAttribute("aria-selected", "true");
await user.keyboard(" ");
expect(windowsTab).toHaveAttribute("aria-selected", "false");
await user.keyboard("{Enter}");
expect(windowsTab).toHaveAttribute("aria-selected", "true");
await user.keyboard("{Enter}");
expect(windowsTab).toHaveAttribute("aria-selected", "false");
await user.click(linuxTab);
const linux = screen.getByRole("tabpanel", { name: "Linux" });
expect(within(linux).getAllByRole("listitem")).toHaveLength(7);
expect(within(linux).getAllByRole("heading", { level: 4 }).map((heading) => heading.textContent)).toEqual([
"Open the project root",
"Edit the provider catalog",
"Enable the model",
"Set the provider credential",
"Reload Pi configuration",
"Update the Pi version",
"Recover a failed update",
]);
expect(linux).toHaveTextContent("The deploy directory is in the ThothII project root, beside compose.yaml");
expect(linux).toHaveTextContent("deploy/pi/models.json");
expect(linux).toHaveTextContent("deploy/pi/settings.json");
@@ -195,29 +250,53 @@ test("shows a seven-step host-terminal workflow in scrollable platform tabs", as
expect(linux).toHaveTextContent("enabledModels uses provider/model identifiers");
expect(linux).toHaveTextContent("PI_AUTH_FILE is a setting in the installation environment file");
expect(linux).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi restart --yes --drain");
expect(linux).toHaveTextContent("pi update --version <VERSION> --source build --yes --drain");
expect(linux).toHaveTextContent("pi rollback --yes");
expect(linux).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source build --yes --drain");
expect(linux).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain");
expect(linux).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi rollback --yes");
expect(linux).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi maintenance recover --yes");
expect(linux).not.toHaveTextContent("~/.pi/agent/");
await user.click(within(tablist).getByRole("tab", { name: "macOS" }));
await user.click(macosTab);
const macos = screen.getByRole("tabpanel", { name: "macOS" });
expect(within(macos).getAllByRole("listitem")).toHaveLength(7);
expect(within(macos).getAllByRole("heading", { level: 4 }).map((heading) => heading.textContent)).toEqual([
"Open the project root",
"Edit the provider catalog",
"Enable the model",
"Set the provider credential",
"Reload Pi configuration",
"Update the Pi version",
"Recover a failed update",
]);
expect(macos).toHaveTextContent("The deploy directory is in the ThothII project root, beside compose.yaml");
expect(macos).toHaveTextContent("deploy/pi/models.json");
expect(macos).toHaveTextContent("deploy/pi/settings.json");
expect(macos).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi restart --yes --drain");
expect(macos).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source build --yes --drain");
expect(macos).toHaveTextContent("pi maintenance recover --yes");
expect(macos).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain");
expect(macos).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi rollback --yes");
expect(macos).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi maintenance recover --yes");
await user.click(within(tablist).getByRole("tab", { name: "Windows" }));
await user.click(windowsTab);
const windows = screen.getByRole("tabpanel", { name: "Windows" });
expect(within(windows).getAllByRole("listitem")).toHaveLength(7);
expect(within(windows).getAllByRole("heading", { level: 4 }).map((heading) => heading.textContent)).toEqual([
"Open the project root",
"Edit the provider catalog",
"Enable the model",
"Set the provider credential",
"Reload Pi configuration",
"Update the Pi version",
"Recover a failed update",
]);
expect(windows).toHaveTextContent("The deploy directory is in the ThothII project root, beside compose.yaml");
expect(windows).toHaveTextContent("deploy\\pi\\models.json");
expect(windows).toHaveTextContent("deploy\\pi\\settings.json");
expect(windows).toHaveTextContent('& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi restart --yes --drain');
expect(windows).toHaveTextContent('& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi update --version <VERSION> --source build --yes --drain');
expect(windows).toHaveTextContent("pi rollback --yes");
expect(windows).toHaveTextContent('& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain');
expect(windows).toHaveTextContent('& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi rollback --yes');
expect(windows).toHaveTextContent('& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi maintenance recover --yes');
});
test("reloads installation defaults when the panel is reopened", async () => {
+47 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, CircleAlert, ClipboardCheck, FlaskConical, LoaderCircle, ScrollText, X } from "lucide-react";
import {
@@ -213,7 +213,30 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
function PiPlatformInstructions() {
const [activePlatform, setActivePlatform] = useState<PiPlatform>();
const activeDetails = piPlatforms.find((platform) => platform.id === activePlatform);
const [focusedPlatform, setFocusedPlatform] = useState<PiPlatform>("linux");
const tabRefs = useRef<Record<PiPlatform, HTMLButtonElement | null>>({ linux: null, macos: null, windows: null });
function activateAndFocus(platform: PiPlatform) {
setFocusedPlatform(platform);
setActivePlatform(platform);
tabRefs.current[platform]?.focus();
}
function handleTabKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, platform: PiPlatform) {
const index = piPlatforms.findIndex((item) => item.id === platform);
const nextPlatform = event.key === "ArrowRight"
? piPlatforms[(index + 1) % piPlatforms.length]
: event.key === "ArrowLeft"
? piPlatforms[(index - 1 + piPlatforms.length) % piPlatforms.length]
: event.key === "Home"
? piPlatforms[0]
: event.key === "End"
? piPlatforms[piPlatforms.length - 1]
: undefined;
if (!nextPlatform) return;
event.preventDefault();
activateAndFocus(nextPlatform.id);
}
return (
<section aria-label="Pi operator workflow" className="mt-2 grid gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
@@ -223,19 +246,39 @@ function PiPlatformInstructions() {
<button
key={platform.id}
id={`pi-${platform.id}-tab`}
ref={(element) => { tabRefs.current[platform.id] = element; }}
type="button"
role="tab"
aria-selected={activePlatform === platform.id}
aria-controls={`pi-${platform.id}-panel`}
tabIndex={focusedPlatform === platform.id ? 0 : -1}
className={activePlatform === platform.id ? "rounded bg-background px-2 py-1.5 font-semibold text-foreground shadow-sm" : "rounded px-2 py-1.5 font-semibold text-muted-foreground hover:bg-background/70 hover:text-foreground"}
onClick={() => setActivePlatform(platform.id)}
onFocus={() => setFocusedPlatform(platform.id)}
onKeyDown={(event) => handleTabKeyDown(event, platform.id)}
onClick={() => {
setFocusedPlatform(platform.id);
setActivePlatform((current) => current === platform.id ? undefined : platform.id);
}}
>
{platform.label}
</button>
))}
</div>
<div data-testid="pi-platform-instructions-scroll" className="min-h-0 max-h-[min(34vh,22rem)] overflow-y-scroll overscroll-contain pr-2">
{activeDetails && <div id={`pi-${activeDetails.id}-panel`} role="tabpanel" aria-label={activeDetails.label} aria-labelledby={`pi-${activeDetails.id}-tab`} tabIndex={0} className="outline-none"><PiInstructionSteps details={activeDetails.details} /></div>}
{piPlatforms.map((platform) => (
<div
key={platform.id}
id={`pi-${platform.id}-panel`}
role="tabpanel"
aria-label={platform.label}
aria-labelledby={`pi-${platform.id}-tab`}
tabIndex={activePlatform === platform.id ? 0 : -1}
hidden={activePlatform !== platform.id}
className="outline-none"
>
<PiInstructionSteps details={platform.details} />
</div>
))}
</div>
</section>
);