From 99012f04c2914053eb5a463c5885a39eb4544844 Mon Sep 17 00:00:00 2001 From: mptyl Date: Fri, 14 Aug 2026 19:01:20 +0200 Subject: [PATCH] fix(frontend): make Pi operator tabs keyboard accessible --- frontend/src/shell/PiManagement.test.tsx | 97 +++++++++++++++++++++--- frontend/src/shell/PiManagement.tsx | 51 ++++++++++++- 2 files changed, 135 insertions(+), 13 deletions(-) diff --git a/frontend/src/shell/PiManagement.test.tsx b/frontend/src/shell/PiManagement.test.tsx index 5be1850f..683ff4b3 100644 --- a/frontend/src/shell/PiManagement.test.tsx +++ b/frontend/src/shell/PiManagement.test.tsx @@ -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 --source build --yes --drain"); - expect(linux).toHaveTextContent("pi rollback --yes"); + expect(linux).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version --source build --yes --drain"); + expect(linux).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version --source pull --image @sha256: --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 --source build --yes --drain"); - expect(macos).toHaveTextContent("pi maintenance recover --yes"); + expect(macos).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version --source pull --image @sha256: --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 --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 --source pull --image @sha256: --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 () => { diff --git a/frontend/src/shell/PiManagement.tsx b/frontend/src/shell/PiManagement.tsx index 319c5c3c..1d925439 100644 --- a/frontend/src/shell/PiManagement.tsx +++ b/frontend/src/shell/PiManagement.tsx @@ -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(); - const activeDetails = piPlatforms.find((platform) => platform.id === activePlatform); + const [focusedPlatform, setFocusedPlatform] = useState("linux"); + const tabRefs = useRef>({ linux: null, macos: null, windows: null }); + + function activateAndFocus(platform: PiPlatform) { + setFocusedPlatform(platform); + setActivePlatform(platform); + tabRefs.current[platform]?.focus(); + } + + function handleTabKeyDown(event: React.KeyboardEvent, 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 (
@@ -223,19 +246,39 @@ function PiPlatformInstructions() { ))}
- {activeDetails &&
} + {piPlatforms.map((platform) => ( + + ))}
);