From 8cb750c4bfd9b99b51938a3cf30b8ca16f56d9d9 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 8 Sep 2026 14:49:23 +0100 Subject: [PATCH 1/4] feat: restructure Settings into accessible tabbed sections Settings rendered all 17 AppSettings keys as one unbroken scroll, with the Pointer group alone stacking five sub-blocks, ten disabled-gated fieldsets and three always-visible paragraphs. Split it into five tabbed sections: General (default), Pointer, Cursor, Privacy, Updates. Cursor splits off along a seam that already existed: the overlay block was separately gated on capabilities.cursorOverlay, so on a platform without overlay support the tab simply does not render, matching the previous conditional. Adds a real Tabs primitive rather than copying the Support view's tablist, which is not a complete WAI-ARIA implementation (no roving tabindex, no arrow keys, no aria-controls). Tabs implements the APG pattern with manual activation: arrows and Home/End move focus with wraparound, selection changes only on click or Enter/Space, so focus never moves unexpectedly. Panels are role=tabpanel labelled by their tab. SettingGroup sections stay inside each panel, so the role=region landmarks, aria-labelledby names and heading structure are unchanged. focusUpdates now selects the Updates tab first, then scrolls and focuses the same #settings-updates region once its panel has mounted. The autosave state machine is untouched; because it keys off the settings object rather than mounted controls, edits made on a tab survive switching away from it, which is covered by a new test. Tests: 75 -> 82. The 44-line mega-test splits into three per-tab tests and the rebase test now switches tabs between its two assertions. New coverage for tablist keyboard behaviour and roving tabindex, the Cursor tab's absence without overlay capability, the banner deep link from a non-default tab, and pending edits surviving a tab switch. beforeEach now restores browserState.capabilities so the capability test cannot leak. Refs #685 Co-Authored-By: Claude Opus 5 (1M context) --- src/settings/CursorSection.tsx | 21 +++++ src/settings/GeneralSection.tsx | 6 ++ src/settings/PointerSection.tsx | 41 ++++++++++ src/settings/PrivacySection.tsx | 6 ++ src/settings/SettingsView.tsx | 96 +++++++++------------- src/settings/Tabs.tsx | 42 ++++++++++ src/settings/UpdatesSection.tsx | 8 +- src/settings/controls.tsx | 3 + src/settings/settings.test.tsx | 136 ++++++++++++++++++++++++++++++-- src/styles.css | 4 + 10 files changed, 297 insertions(+), 66 deletions(-) create mode 100644 src/settings/CursorSection.tsx create mode 100644 src/settings/GeneralSection.tsx create mode 100644 src/settings/PointerSection.tsx create mode 100644 src/settings/PrivacySection.tsx create mode 100644 src/settings/Tabs.tsx diff --git a/src/settings/CursorSection.tsx b/src/settings/CursorSection.tsx new file mode 100644 index 00000000..ff5c9096 --- /dev/null +++ b/src/settings/CursorSection.tsx @@ -0,0 +1,21 @@ +import type { AppSettings } from "../types"; +import { + OptionGroup, SettingGroup, Toggle, overlaySizeOptions, overlayVisibilityOptions, + type SettingsUpdate, +} from "./controls"; + +export function CursorSection({ settings, update }: { settings: AppSettings; update: SettingsUpdate }) { + return + update("cursorOverlayEnabled", value)} /> +
+ legend="Overlay visibility" disabled={!settings.cursorOverlayEnabled} options={overlayVisibilityOptions} value={settings.cursorOverlayVisibility} onChange={(next) => update("cursorOverlayVisibility", next)}> +

On input hides shortly after pointer activity stops. While controlling stays visible until the session ends.

+ + legend="Overlay size" columns="three" disabled={!settings.cursorOverlayEnabled} options={overlaySizeOptions} value={settings.cursorOverlaySize} onChange={(next) => update("cursorOverlaySize", next)} /> +
Overlay color
+ {(["red", "green", "blue", "yellow", "white"] as const).map((value) => )} +
+ update("cursorCrosshairs", value)} /> +
+
; +} diff --git a/src/settings/GeneralSection.tsx b/src/settings/GeneralSection.tsx new file mode 100644 index 00000000..e4620cc6 --- /dev/null +++ b/src/settings/GeneralSection.tsx @@ -0,0 +1,6 @@ +import type { AppSettings } from "../types"; +import { SettingGroup, Toggle, type SettingsUpdate } from "./controls"; + +export function GeneralSection({ settings, update }: { settings: AppSettings; update: SettingsUpdate }) { + return update("startWithSystem", value)} />; +} diff --git a/src/settings/PointerSection.tsx b/src/settings/PointerSection.tsx new file mode 100644 index 00000000..e4e95c79 --- /dev/null +++ b/src/settings/PointerSection.tsx @@ -0,0 +1,41 @@ +import type { AppSettings } from "../types"; +import { + OptionGroup, SettingGroup, Toggle, accelerationOptions, dwellDelayOptions, keyRepeatDelayOptions, + movementValue, pointerSpeedOptions, pointerSpeedValues, repeatIntervalOptions, secondsOptions, + type SettingsUpdate, +} from "./controls"; + +export function PointerSection({ settings, update }: { settings: AppSettings; update: SettingsUpdate }) { + return +
Pointer speed {settings.pointerScalePercent}%
+ {pointerSpeedOptions.map((value) => )} +
+ {([{"label":"Small","base":4.5},{"label":"Medium","base":12},{"label":"Large","base":26}] as const).map(({ label, base }) =>
{label}{movementValue(base, settings.pointerScalePercent)}
)} +
+
+ update("mouseRepeatEnabled", value)} /> +
+ legend="Movement interval" columns="four" disabled={!settings.mouseRepeatEnabled} options={secondsOptions(repeatIntervalOptions)} value={settings.moveRepeatIntervalMs} onChange={(next) => update("moveRepeatIntervalMs", next)} /> + legend="Movement acceleration" columns="four" disabled={!settings.mouseRepeatEnabled} options={accelerationOptions} value={settings.mouseRepeatAccelerationDurationMs} onChange={(next) => update("mouseRepeatAccelerationDurationMs", next)} /> + legend="Scroll interval" columns="four" disabled={!settings.mouseRepeatEnabled} options={secondsOptions(repeatIntervalOptions)} value={settings.scrollRepeatIntervalMs} onChange={(next) => update("scrollRepeatIntervalMs", next)} /> +
+
+
+ update("keyRepeatEnabled", value)} /> +
+ legend="Delay before repeating" columns="four" disabled={!settings.keyRepeatEnabled} options={keyRepeatDelayOptions} value={settings.keyRepeatInitialDelayMs} onChange={(next) => update("keyRepeatInitialDelayMs", next)} /> + legend="Key interval" columns="four" disabled={!settings.keyRepeatEnabled} options={secondsOptions(repeatIntervalOptions)} value={settings.keyRepeatIntervalMs} onChange={(next) => update("keyRepeatIntervalMs", next)} /> +

Holding a navigation key on the remote repeats it, like holding a key on a keyboard. Applies to the arrow keys, Tab, Backspace, Delete, Page Up, and Page Down.

+
+
+
+ update("dwellClickEnabled", value)} /> +
+ legend="Dwell delay" columns="five" disabled={!settings.dwellClickEnabled} options={secondsOptions(dwellDelayOptions)} value={settings.dwellClickDelayMs} onChange={(next) => update("dwellClickDelayMs", next)} /> +

After Android pointer movement stops, a countdown appears and performs one left click. Move again to rearm it.

+
+
+
; +} diff --git a/src/settings/PrivacySection.tsx b/src/settings/PrivacySection.tsx new file mode 100644 index 00000000..71216857 --- /dev/null +++ b/src/settings/PrivacySection.tsx @@ -0,0 +1,6 @@ +import type { AppSettings, AppState } from "../types"; +import { SettingGroup, Toggle, type SettingsUpdate } from "./controls"; + +export function PrivacySection({ state, settings, update, chooseTelemetry, busy }: { state: AppState; settings: AppSettings; update: SettingsUpdate; chooseTelemetry: (enabled: boolean) => void; busy: boolean }) { + return update("shareDiagnostics", value)} />{state.telemetry.consent === "undecided" &&
}

{state.telemetry.available ? state.telemetry.consent === "undecided" ? "No choice recorded yet. Nothing is sent unless you choose Share diagnostics." : state.telemetry.consent === "enabled" ? "Consent recorded. You can turn this off at any time to delete queued reports." : "Opted out. No diagnostic reports are stored or sent." : "Diagnostic reporting is unavailable in this build."} Privacy policy

; +} diff --git a/src/settings/SettingsView.tsx b/src/settings/SettingsView.tsx index 9c0edcbd..a84c90f4 100644 --- a/src/settings/SettingsView.tsx +++ b/src/settings/SettingsView.tsx @@ -1,70 +1,50 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Settings } from "lucide-react"; import type { AppSettings, AppState } from "../types"; -import { - OptionGroup, SettingGroup, Toggle, accelerationOptions, dwellDelayOptions, keyRepeatDelayOptions, - movementValue, overlaySizeOptions, overlayVisibilityOptions, pointerSpeedOptions, - pointerSpeedValues, repeatIntervalOptions, secondsOptions, -} from "./controls"; -import { UpdateControls, type UpdateAction } from "./UpdatesSection"; +import { Tabs, TabPanel } from "./Tabs"; +import { GeneralSection } from "./GeneralSection"; +import { PointerSection } from "./PointerSection"; +import { CursorSection } from "./CursorSection"; +import { PrivacySection } from "./PrivacySection"; +import { UpdatesSection, type UpdateAction } from "./UpdatesSection"; export function SettingsView({ state, settings, onChange, chooseTelemetry, updateAction, cancelUpdate, busy, focusUpdates, onUpdatesFocused }: { state: AppState; settings: AppSettings; onChange: (next: AppSettings) => void; chooseTelemetry: (enabled: boolean) => void; updateAction: (action: UpdateAction) => void; cancelUpdate: () => void; busy: boolean; focusUpdates: boolean; onUpdatesFocused: () => void }) { const updatesRef = useRef(null); + const [active, setActive] = useState("general"); + + const tabs = useMemo(() => [ + { id: "general", label: "General" }, + { id: "pointer", label: "Pointer" }, + ...(state.capabilities.cursorOverlay ? [{ id: "cursor", label: "Cursor" }] : []), + { id: "privacy", label: "Privacy" }, + { id: "updates", label: "Updates" }, + ], [state.capabilities.cursorOverlay]); + + useEffect(() => { + if (!tabs.some((tab) => tab.id === active)) setActive("general"); + }, [tabs, active]); + useEffect(() => { - if (!focusUpdates) return; + if (focusUpdates) setActive("updates"); + }, [focusUpdates]); + + useEffect(() => { + if (!focusUpdates || active !== "updates") return; updatesRef.current?.scrollIntoView?.({ block: "start" }); updatesRef.current?.focus({ preventScroll: true }); onUpdatesFocused(); - }, [focusUpdates, onUpdatesFocused]); + }, [focusUpdates, active, onUpdatesFocused]); + const update = (key: K, value: AppSettings[K]) => onChange({ ...settings, [key]: value }); - return

Settings

Startup, pointer, privacy, and updates

- update("startWithSystem", value)} /> - -
Pointer speed {settings.pointerScalePercent}%
- {pointerSpeedOptions.map((value) => )} -
- {([{"label":"Small","base":4.5},{"label":"Medium","base":12},{"label":"Large","base":26}] as const).map(({ label, base }) =>
{label}{movementValue(base, settings.pointerScalePercent)}
)} -
-
- update("mouseRepeatEnabled", value)} /> -
- legend="Movement interval" columns="four" disabled={!settings.mouseRepeatEnabled} options={secondsOptions(repeatIntervalOptions)} value={settings.moveRepeatIntervalMs} onChange={(next) => update("moveRepeatIntervalMs", next)} /> - legend="Movement acceleration" columns="four" disabled={!settings.mouseRepeatEnabled} options={accelerationOptions} value={settings.mouseRepeatAccelerationDurationMs} onChange={(next) => update("mouseRepeatAccelerationDurationMs", next)} /> - legend="Scroll interval" columns="four" disabled={!settings.mouseRepeatEnabled} options={secondsOptions(repeatIntervalOptions)} value={settings.scrollRepeatIntervalMs} onChange={(next) => update("scrollRepeatIntervalMs", next)} /> -
-
-
- update("keyRepeatEnabled", value)} /> -
- legend="Delay before repeating" columns="four" disabled={!settings.keyRepeatEnabled} options={keyRepeatDelayOptions} value={settings.keyRepeatInitialDelayMs} onChange={(next) => update("keyRepeatInitialDelayMs", next)} /> - legend="Key interval" columns="four" disabled={!settings.keyRepeatEnabled} options={secondsOptions(repeatIntervalOptions)} value={settings.keyRepeatIntervalMs} onChange={(next) => update("keyRepeatIntervalMs", next)} /> -

Holding a navigation key on the remote repeats it, like holding a key on a keyboard. Applies to the arrow keys, Tab, Backspace, Delete, Page Up, and Page Down.

-
-
-
- update("dwellClickEnabled", value)} /> -
- legend="Dwell delay" columns="five" disabled={!settings.dwellClickEnabled} options={secondsOptions(dwellDelayOptions)} value={settings.dwellClickDelayMs} onChange={(next) => update("dwellClickDelayMs", next)} /> -

After Android pointer movement stops, a countdown appears and performs one left click. Move again to rearm it.

-
-
- {state.capabilities.cursorOverlay && <> - update("cursorOverlayEnabled", value)} /> -
- legend="Overlay visibility" disabled={!settings.cursorOverlayEnabled} options={overlayVisibilityOptions} value={settings.cursorOverlayVisibility} onChange={(next) => update("cursorOverlayVisibility", next)}> -

On input hides shortly after pointer activity stops. While controlling stays visible until the session ends.

- - legend="Overlay size" columns="three" disabled={!settings.cursorOverlayEnabled} options={overlaySizeOptions} value={settings.cursorOverlaySize} onChange={(next) => update("cursorOverlaySize", next)} /> -
Overlay color
- {(["red", "green", "blue", "yellow", "white"] as const).map((value) => )} -
- update("cursorCrosshairs", value)} /> -
- } -
- update("shareDiagnostics", value)} />{state.telemetry.consent === "undecided" &&
}

{state.telemetry.available ? state.telemetry.consent === "undecided" ? "No choice recorded yet. Nothing is sent unless you choose Share diagnostics." : state.telemetry.consent === "enabled" ? "Consent recorded. You can turn this off at any time to delete queued reports." : "Opted out. No diagnostic reports are stored or sent." : "Diagnostic reporting is unavailable in this build."} Privacy policy

- + + return

Settings

How Switchify PC behaves on this computer

+ + + {active === "general" && } + {active === "pointer" && } + {active === "cursor" && } + {active === "privacy" && } + {active === "updates" && } +
; } diff --git a/src/settings/Tabs.tsx b/src/settings/Tabs.tsx new file mode 100644 index 00000000..ed219d78 --- /dev/null +++ b/src/settings/Tabs.tsx @@ -0,0 +1,42 @@ +import { useRef, type KeyboardEvent, type ReactNode } from "react"; + +export type TabDefinition = { id: string; label: string }; + +export function tabId(id: string) { return `settings-tab-${id}`; } +export function panelId(id: string) { return `settings-panel-${id}`; } + +export function Tabs({ tabs, active, onSelect, label }: { tabs: readonly TabDefinition[]; active: string; onSelect: (id: string) => void; label: string }) { + const listRef = useRef(null); + + const focusTab = (index: number) => { + const buttons = listRef.current?.querySelectorAll('[role="tab"]'); + buttons?.[(index + tabs.length) % tabs.length]?.focus(); + }; + + const onKeyDown = (event: KeyboardEvent, index: number) => { + if (event.key === "ArrowRight") focusTab(index + 1); + else if (event.key === "ArrowLeft") focusTab(index - 1); + else if (event.key === "Home") focusTab(0); + else if (event.key === "End") focusTab(tabs.length - 1); + else return; + event.preventDefault(); + }; + + return
+ {tabs.map((tab, index) => )} +
; +} + +export function TabPanel({ id, children }: { id: string; children: ReactNode }) { + return
{children}
; +} diff --git a/src/settings/UpdatesSection.tsx b/src/settings/UpdatesSection.tsx index eedd21e3..2494df55 100644 --- a/src/settings/UpdatesSection.tsx +++ b/src/settings/UpdatesSection.tsx @@ -1,5 +1,7 @@ import { Download, RefreshCw, X } from "lucide-react"; -import type { UpdateState } from "../types"; +import type { Ref } from "react"; +import type { AppState, UpdateState } from "../types"; +import { SettingGroup } from "./controls"; export type UpdateAction = "check" | "download" | "install"; @@ -42,3 +44,7 @@ export function UpdateControls({ update, run, cancel }: { update: UpdateState; r
{action && }{update.status === "downloading" && }{(update.status === "checking" || update.status === "applying") && }
; } + +export function UpdatesSection({ state, run, cancel, sectionRef }: { state: AppState; run: (action: UpdateAction) => void; cancel: () => void; sectionRef: Ref }) { + return ; +} diff --git a/src/settings/controls.tsx b/src/settings/controls.tsx index dd091bbe..e9cc65e2 100644 --- a/src/settings/controls.tsx +++ b/src/settings/controls.tsx @@ -1,3 +1,4 @@ +import type { AppSettings } from "../types"; import type { ReactNode, Ref } from "react"; export function Toggle({ checked, disabled = false, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (next: boolean) => void }) { @@ -56,3 +57,5 @@ export const overlayVisibilityOptions = [ export const overlaySizeOptions = (["small", "medium", "large"] as const) .map((value) => ({ value, label: value[0].toUpperCase() + value.slice(1) })); + +export type SettingsUpdate = (key: K, value: AppSettings[K]) => void; diff --git a/src/settings/settings.test.tsx b/src/settings/settings.test.tsx index 65ca49a4..ec565a29 100644 --- a/src/settings/settings.test.tsx +++ b/src/settings/settings.test.tsx @@ -5,14 +5,18 @@ import { api, browserState } from "../api"; import type { AppSettings } from "../types"; const defaultBrowserSettings = structuredClone(browserState.settings); +const defaultCapabilities = structuredClone(browserState.capabilities); function stateWithSettings(settings: AppSettings) { return { ...structuredClone(browserState), settings: structuredClone(settings) }; } +const selectTab = (name: string) => fireEvent.click(screen.getByRole("tab", { name })); + describe("Switchify PC settings", () => { beforeEach(() => { browserState.settings = structuredClone(defaultBrowserSettings); + browserState.capabilities = structuredClone(defaultCapabilities); browserState.bluetooth = "initializing"; browserState.pendingPairings = []; browserState.pairedDevices = []; @@ -34,6 +38,7 @@ describe("Switchify PC settings", () => { render(); await screen.findByRole("heading", { name: "Switchify PC" }); fireEvent.click(screen.getByRole("button", { name: "Settings" })); + selectTab("Updates"); expect(screen.getByText("Downloading Switchify PC 1.0.0-beta.2…")).toBeInTheDocument(); expect(screen.getByRole("progressbar", { name: "Update download progress" })).toHaveAttribute("value", "50"); @@ -48,6 +53,7 @@ describe("Switchify PC settings", () => { render(); await screen.findByRole("heading", { name: "Switchify PC" }); fireEvent.click(screen.getByRole("button", { name: "Settings" })); + selectTab("Updates"); expect(screen.getByRole("alert")).toHaveTextContent("Download failed"); fireEvent.click(screen.getByRole("button", { name: "Retry" })); expect(download).toHaveBeenCalledOnce(); @@ -59,6 +65,7 @@ describe("Switchify PC settings", () => { render(); await screen.findByRole("heading", { name: "Switchify PC" }); fireEvent.click(screen.getByRole("button", { name: "Settings" })); + selectTab("Updates"); fireEvent.click(screen.getByRole("button", { name: "Install and restart" })); expect(install).toHaveBeenCalledOnce(); }); @@ -69,6 +76,7 @@ describe("Switchify PC settings", () => { render(); await screen.findByRole("heading", { name: "Switchify PC" }); fireEvent.click(screen.getByRole("button", { name: "Settings" })); + selectTab("Updates"); expect(screen.getByRole("status")).toHaveTextContent("Download cancelled. You can retry when ready."); fireEvent.click(screen.getByRole("button", { name: "Retry download" })); expect(download).toHaveBeenCalledOnce(); @@ -79,6 +87,7 @@ describe("Switchify PC settings", () => { render(); await screen.findByRole("heading", { name: "Switchify PC" }); fireEvent.click(screen.getByRole("button", { name: "Settings" })); + selectTab("Updates"); fireEvent.click(screen.getByRole("button", { name: "Check for updates" })); expect(await screen.findByRole("alert")).toHaveTextContent("Update service unavailable"); checkForUpdates.mockRestore(); @@ -91,6 +100,7 @@ describe("Switchify PC settings", () => { browserState.updater = { status, version: null, downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }; render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + selectTab("Updates"); expect(screen.getByText(description)).toBeInTheDocument(); expect(screen.getByRole("button", { name: action })).toBeInTheDocument(); @@ -99,6 +109,7 @@ describe("Switchify PC settings", () => { it("exposes key repeat settings and disables them with the toggle", async () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + selectTab("Pointer"); const toggle = screen.getByRole("checkbox", { name: "Repeat held keys" }); expect(toggle).toBeChecked(); @@ -125,12 +136,14 @@ describe("Switchify PC settings", () => { expect(screen.getByRole("group", { name: "Key interval" })).toBeDisabled(); }); - it("opens settings with accessible native controls", async () => { + it("opens settings with accessible General and Pointer controls", async () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); expect(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Save settings" })).not.toBeInTheDocument(); expect(screen.getByRole("checkbox", { name: "Start with system" })).toBeInTheDocument(); + + selectTab("Pointer"); expect(screen.getByRole("button", { name: "100% pointer speed" })).toHaveAttribute("aria-pressed", "true"); expect(screen.getByRole("checkbox", { name: "Repeat mouse movement" })).toBeChecked(); expect(screen.getByRole("group", { name: "Movement acceleration" })).not.toBeDisabled(); @@ -143,6 +156,13 @@ describe("Switchify PC settings", () => { expect(screen.getByText("5.5")).toBeInTheDocument(); fireEvent.click(screen.getByRole("checkbox", { name: "Repeat mouse movement" })); expect(screen.getByRole("group", { name: "Movement acceleration" })).toBeDisabled(); + }); + + it("exposes the dwell controls and their explanatory note", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + selectTab("Pointer"); + const dwell = screen.getByRole("checkbox", { name: "Dwell to click" }); expect(dwell).not.toBeChecked(); expect(screen.getByRole("group", { name: "Dwell delay" })).toBeDisabled(); @@ -157,6 +177,13 @@ describe("Switchify PC settings", () => { fireEvent.click(within(dwellDelay).getByRole("button", { name: "1.5s" })); expect(within(dwellDelay).getByRole("button", { name: "1.5s" })).toHaveAttribute("aria-pressed", "true"); expect(screen.getByText(/After Android pointer movement stops/)).toBeInTheDocument(); + }); + + it("exposes the cursor overlay controls on their own tab", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + selectTab("Cursor"); + expect(screen.getByRole("checkbox", { name: "Show cursor overlay" })).toBeChecked(); expect(screen.getByRole("button", { name: "While controlling" })).toHaveAttribute("aria-pressed", "true"); expect(screen.getByText(/On input hides shortly/)).toBeInTheDocument(); @@ -169,11 +196,11 @@ describe("Switchify PC settings", () => { fireEvent.click(screen.getByRole("checkbox", { name: "Show cursor overlay" })); expect(screen.getByRole("checkbox", { name: "Show crosshairs" })).toBeDisabled(); }); - it("automatically saves a settings change", async () => { const saveSettings = vi.spyOn(api, "saveSettings").mockImplementation(async (settings) => stateWithSettings(settings)); render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + selectTab("Privacy"); fireEvent.click(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })); @@ -185,6 +212,7 @@ describe("Switchify PC settings", () => { const saveSettings = vi.spyOn(api, "saveSettings").mockImplementation(async (settings) => stateWithSettings(settings)); render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + selectTab("Pointer"); fireEvent.click(screen.getByRole("checkbox", { name: "Dwell to click" })); await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ dwellClickEnabled: true, dwellClickDelayMs: 1000 }))); @@ -198,6 +226,7 @@ describe("Switchify PC settings", () => { vi.spyOn(api, "saveSettings").mockRejectedValueOnce(new Error("Settings storage unavailable")); render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + selectTab("Pointer"); fireEvent.click(screen.getByRole("checkbox", { name: "Dwell to click" })); @@ -209,6 +238,7 @@ describe("Switchify PC settings", () => { it("explains telemetry consent and links to the privacy policy", async () => { const first = render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + selectTab("Privacy"); expect(screen.getByText(/Nothing is sent unless you choose Share diagnostics/)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Don't share" })); expect(await screen.findByText("Opted out. No diagnostic reports are stored or sent.")).toBeInTheDocument(); @@ -218,6 +248,7 @@ describe("Switchify PC settings", () => { browserState.telemetry = { consent: "undecided", available: false }; render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + selectTab("Privacy"); expect(await screen.findByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeDisabled(); expect(screen.getByText("Diagnostic reporting is unavailable in this build.")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Share diagnostics" })).toBeDisabled(); @@ -231,6 +262,7 @@ describe("Switchify PC settings", () => { })); render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + selectTab("Pointer"); fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); fireEvent.click(screen.getByRole("button", { name: "75% pointer speed" })); @@ -262,6 +294,7 @@ describe("Switchify PC settings", () => { })); render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + selectTab("Pointer"); fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); const runtimeState = { @@ -292,12 +325,15 @@ describe("Switchify PC settings", () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + selectTab("Privacy"); fireEvent.click(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })); + selectTab("Pointer"); fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); act(() => stateHandler?.(stateWithSettings({ ...defaultBrowserSettings, pointerScalePercent: 150 }))); - expect(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeChecked(); expect(screen.getByRole("combobox", { name: "Exact pointer speed" })).toHaveValue("150"); + selectTab("Privacy"); + expect(screen.getByRole("checkbox", { name: "Share anonymous diagnostic data" })).toBeChecked(); await act(async () => { saves[0].resolve(stateWithSettings(saves[0].settings)); @@ -312,7 +348,6 @@ describe("Switchify PC settings", () => { saves[1].resolve(stateWithSettings(saves[1].settings)); }); }); - it("restores confirmed settings when automatic saving fails", async () => { vi.spyOn(api, "saveSettings").mockRejectedValueOnce(new Error("Settings storage unavailable")); render(); @@ -330,10 +365,11 @@ describe("Switchify PC settings", () => { browserState.updater = { status: "available", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }; render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); - const updates = screen.getByRole("region", { name: "Updates" }); - expect(updates).not.toHaveFocus(); + expect(screen.queryByRole("region", { name: "Updates" })).not.toBeInTheDocument(); fireEvent.click(within(screen.getByRole("status", { name: "Application update" })).getByRole("button", { name: "View update" })); + const updates = await screen.findByRole("region", { name: "Updates" }); await waitFor(() => expect(updates).toHaveFocus()); + expect(screen.getByRole("tab", { name: "Updates" })).toHaveAttribute("aria-selected", "true"); }); it("does not replay a consumed Updates focus request on normal Settings navigation", async () => { @@ -346,7 +382,93 @@ describe("Switchify PC settings", () => { fireEvent.click(screen.getByRole("button", { name: "Home" })); fireEvent.click(screen.getByRole("button", { name: "Settings" })); - expect(screen.getByRole("region", { name: "Updates" })).not.toHaveFocus(); + expect(screen.getByRole("tab", { name: "General" })).toHaveAttribute("aria-selected", "true"); + expect(screen.queryByRole("region", { name: "Updates" })).not.toBeInTheDocument(); + }); + + + it("names every settings section as a tab and marks only the active one selected", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + const tablist = screen.getByRole("tablist", { name: "Settings sections" }); + expect(within(tablist).getAllByRole("tab").map((tab) => tab.textContent)) + .toEqual(["General", "Pointer", "Cursor", "Privacy", "Updates"]); + expect(screen.getByRole("tab", { name: "General" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tabpanel")).toHaveAccessibleName("General"); + }); + + it("keeps a single tab stop and moves focus with the arrow keys without activating", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + const general = screen.getByRole("tab", { name: "General" }); + const pointer = screen.getByRole("tab", { name: "Pointer" }); + const updates = screen.getByRole("tab", { name: "Updates" }); + expect(general).toHaveAttribute("tabindex", "0"); + expect(pointer).toHaveAttribute("tabindex", "-1"); + + general.focus(); + fireEvent.keyDown(general, { key: "ArrowRight" }); + expect(pointer).toHaveFocus(); + // Manual activation: moving focus must not change the selected panel. + expect(general).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("checkbox", { name: "Start with system" })).toBeInTheDocument(); + + fireEvent.keyDown(pointer, { key: "ArrowLeft" }); + expect(general).toHaveFocus(); + fireEvent.keyDown(general, { key: "ArrowLeft" }); + expect(updates).toHaveFocus(); + fireEvent.keyDown(updates, { key: "ArrowRight" }); + expect(general).toHaveFocus(); + fireEvent.keyDown(general, { key: "End" }); + expect(updates).toHaveFocus(); + fireEvent.keyDown(updates, { key: "Home" }); + expect(general).toHaveFocus(); + + fireEvent.click(pointer); + expect(pointer).toHaveAttribute("aria-selected", "true"); + expect(pointer).toHaveAttribute("tabindex", "0"); + expect(general).toHaveAttribute("tabindex", "-1"); + }); + + it("omits the Cursor tab when the platform cannot show a cursor overlay", async () => { + browserState.capabilities = { ...structuredClone(defaultCapabilities), cursorOverlay: false }; + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + expect(screen.queryByRole("tab", { name: "Cursor" })).not.toBeInTheDocument(); + expect(screen.getAllByRole("tab").map((tab) => tab.textContent)) + .toEqual(["General", "Pointer", "Privacy", "Updates"]); + selectTab("Pointer"); + expect(screen.queryByRole("checkbox", { name: "Show cursor overlay" })).not.toBeInTheDocument(); + }); + + it("switches to the Updates tab when the banner is used from another tab", async () => { + browserState.updater = { status: "available", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }; + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + selectTab("Pointer"); + expect(screen.getByRole("tab", { name: "Pointer" })).toHaveAttribute("aria-selected", "true"); + + fireEvent.click(within(screen.getByRole("status", { name: "Application update" })).getByRole("button", { name: "View update" })); + + expect(await screen.findByRole("region", { name: "Updates" })).toHaveFocus(); + expect(screen.getByRole("tab", { name: "Updates" })).toHaveAttribute("aria-selected", "true"); + }); + + it("keeps a pending edit on an inactive tab and saves it", async () => { + const saveSettings = vi.spyOn(api, "saveSettings").mockImplementation(async (settings) => stateWithSettings(settings)); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + selectTab("Pointer"); + fireEvent.click(screen.getByRole("button", { name: "50% pointer speed" })); + selectTab("Privacy"); + selectTab("Pointer"); + + expect(screen.getByRole("button", { name: "50% pointer speed" })).toHaveAttribute("aria-pressed", "true"); + await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ pointerScalePercent: 50 }))); }); }); diff --git a/src/styles.css b/src/styles.css index 6082fc5b..f0af0d43 100644 --- a/src/styles.css +++ b/src/styles.css @@ -290,3 +290,7 @@ button:disabled { cursor: default; opacity: 0.55; } } .color-swatch.white { border-color: var(--inactive); } } + +.settings-tabs { display: grid; grid-template-columns: repeat(var(--tab-count), 1fr); width: 100%; min-width: 0; } +.settings-tabs button { min-width: 0; padding: 0 8px; } +.settings-panel:focus-visible { outline: 2px solid var(--focus); outline-offset: 4px; } From ea86aaed90934a7d98ef479c12e5defbd2603c8e Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 8 Sep 2026 14:59:25 +0100 Subject: [PATCH 2/4] fix: correct three ARIA contract defects in the Tabs primitive Review findings on the tablist, all in the primitive the restructure exists to provide. Roving tabindex was keyed on the selected tab rather than the focused one, so arrowing to a tab moved DOM focus without moving the tab stop: tabbing out and back returned to the selected tab and discarded the user's position. The tab stop now follows focus while the tablist has it and falls back to the selected tab once focus leaves. Selecting a tab also moves the stop, which matches a real browser click and is not covered by jsdom's fireEvent.click. aria-controls was set on every tab, but only the selected panel is rendered, so inactive tabs pointed at ids that do not exist. Dangling IDREFs break "go to controlled element" in screen readers and fail axe's aria-valid-attr-value. It is now set only on the selected tab. TabPanel hardcoded tabIndex={0} although every panel contains focusable controls, adding a redundant tab stop and drawing a focus ring around the whole panel before the first real control. Removed, along with the now-unused .settings-panel:focus-visible rule. Tests: 82 -> 84, covering the tab stop following focus, its return on blur out of the tablist, and aria-controls matching the rendered panel. Refs #685 Co-Authored-By: Claude Opus 5 (1M context) --- src/settings/Tabs.tsx | 26 +++++++++++++++++++------ src/settings/settings.test.tsx | 35 ++++++++++++++++++++++++++++++++++ src/styles.css | 1 - 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/settings/Tabs.tsx b/src/settings/Tabs.tsx index ed219d78..ee81db62 100644 --- a/src/settings/Tabs.tsx +++ b/src/settings/Tabs.tsx @@ -1,4 +1,4 @@ -import { useRef, type KeyboardEvent, type ReactNode } from "react"; +import { useRef, useState, type FocusEvent, type KeyboardEvent, type ReactNode } from "react"; export type TabDefinition = { id: string; label: string }; @@ -7,6 +7,11 @@ export function panelId(id: string) { return `settings-panel-${id}`; } export function Tabs({ tabs, active, onSelect, label }: { tabs: readonly TabDefinition[]; active: string; onSelect: (id: string) => void; label: string }) { const listRef = useRef(null); + // The tab stop follows focus while the tablist has it, and falls back to the + // selected tab once focus leaves, so arrowing to a tab and tabbing away and + // back does not discard the user's position. + const [focused, setFocused] = useState(null); + const tabStop = focused ?? active; const focusTab = (index: number) => { const buttons = listRef.current?.querySelectorAll('[role="tab"]'); @@ -22,21 +27,30 @@ export function Tabs({ tabs, active, onSelect, label }: { tabs: readonly TabDefi event.preventDefault(); }; - return
+ const onBlur = (event: FocusEvent) => { + if (!event.currentTarget.contains(event.relatedTarget)) setFocused(null); + }; + + return
{tabs.map((tab, index) => )}
; } export function TabPanel({ id, children }: { id: string; children: ReactNode }) { - return
{children}
; + // No tabIndex: every panel contains focusable controls, so making the panel + // itself tabbable would add a redundant stop before the first real control. + return
{children}
; } diff --git a/src/settings/settings.test.tsx b/src/settings/settings.test.tsx index ec565a29..eaf6b6cc 100644 --- a/src/settings/settings.test.tsx +++ b/src/settings/settings.test.tsx @@ -414,6 +414,9 @@ describe("Switchify PC settings", () => { // Manual activation: moving focus must not change the selected panel. expect(general).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("checkbox", { name: "Start with system" })).toBeInTheDocument(); + // The tab stop follows focus, so tabbing out and back does not discard it. + expect(pointer).toHaveAttribute("tabindex", "0"); + expect(general).toHaveAttribute("tabindex", "-1"); fireEvent.keyDown(pointer, { key: "ArrowLeft" }); expect(general).toHaveFocus(); @@ -471,4 +474,36 @@ describe("Switchify PC settings", () => { await waitFor(() => expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ pointerScalePercent: 50 }))); }); + + it("returns the tab stop to the selected tab when focus leaves the tablist", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + const general = screen.getByRole("tab", { name: "General" }); + const pointer = screen.getByRole("tab", { name: "Pointer" }); + general.focus(); + fireEvent.keyDown(general, { key: "ArrowRight" }); + expect(pointer).toHaveAttribute("tabindex", "0"); + + const startup = screen.getByRole("checkbox", { name: "Start with system" }); + startup.focus(); + fireEvent.blur(pointer, { relatedTarget: startup }); + expect(general).toHaveAttribute("tabindex", "0"); + expect(pointer).toHaveAttribute("tabindex", "-1"); + }); + + it("points aria-controls only at the panel that is rendered", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + const general = screen.getByRole("tab", { name: "General" }); + expect(general).toHaveAttribute("aria-controls", "settings-panel-general"); + expect(document.getElementById("settings-panel-general")).toBeInTheDocument(); + for (const name of ["Pointer", "Cursor", "Privacy", "Updates"]) { + expect(screen.getByRole("tab", { name })).not.toHaveAttribute("aria-controls"); + } + // The panel is not a tab stop of its own; its controls are. + expect(screen.getByRole("tabpanel")).not.toHaveAttribute("tabindex"); + }); + }); diff --git a/src/styles.css b/src/styles.css index f0af0d43..b04d0e2f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -293,4 +293,3 @@ button:disabled { cursor: default; opacity: 0.55; } .settings-tabs { display: grid; grid-template-columns: repeat(var(--tab-count), 1fr); width: 100%; min-width: 0; } .settings-tabs button { min-width: 0; padding: 0 8px; } -.settings-panel:focus-visible { outline: 2px solid var(--focus); outline-offset: 4px; } From 0d0b4a19acb68722defcef42c4b2d892d57de3a2 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 8 Sep 2026 15:09:33 +0100 Subject: [PATCH 3/4] fix: make the tab stop persistent and the panel conditionally tabbable Second review pass on the tablist. The tab stop now stays on the last tab that held focus instead of resetting when focus leaves the tablist, which is what the roving tabindex was for and what the previous commit's comment already claimed. It falls back to the selected tab only when the focused tab no longer exists. TabPanel dropped tabIndex unconditionally, but the Updates panel has no tabbable element while a check or install is in flight: the action button unmounts and its replacement is disabled. The panel now measures its own content and takes a tab stop only when nothing inside it can take focus, per the APG rule. Also removes a test that asserted the tab stop resets on remount. It exercised nothing: leaving and re-entering Settings unmounts SettingsView, so the state resets regardless of the guard it claimed to cover. Tests: 84 -> 85. Refs #685 Co-Authored-By: Claude Opus 5 (1M context) --- src/settings/Tabs.tsx | 28 +++++++++++++++------------- src/settings/settings.test.tsx | 19 +++++++++++++++---- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/settings/Tabs.tsx b/src/settings/Tabs.tsx index ee81db62..0e86f775 100644 --- a/src/settings/Tabs.tsx +++ b/src/settings/Tabs.tsx @@ -1,17 +1,18 @@ -import { useRef, useState, type FocusEvent, type KeyboardEvent, type ReactNode } from "react"; +import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; export type TabDefinition = { id: string; label: string }; export function tabId(id: string) { return `settings-tab-${id}`; } export function panelId(id: string) { return `settings-panel-${id}`; } +const TABBABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + export function Tabs({ tabs, active, onSelect, label }: { tabs: readonly TabDefinition[]; active: string; onSelect: (id: string) => void; label: string }) { const listRef = useRef(null); - // The tab stop follows focus while the tablist has it, and falls back to the - // selected tab once focus leaves, so arrowing to a tab and tabbing away and - // back does not discard the user's position. + // The tab stop stays on the last tab that held focus, so arrowing to a tab and + // then tabbing away and back returns to it rather than to the selected tab. const [focused, setFocused] = useState(null); - const tabStop = focused ?? active; + const tabStop = tabs.some((tab) => tab.id === focused) ? focused : active; const focusTab = (index: number) => { const buttons = listRef.current?.querySelectorAll('[role="tab"]'); @@ -27,11 +28,7 @@ export function Tabs({ tabs, active, onSelect, label }: { tabs: readonly TabDefi event.preventDefault(); }; - const onBlur = (event: FocusEvent) => { - if (!event.currentTarget.contains(event.relatedTarget)) setFocused(null); - }; - - return
+ return
{tabs.map((tab, index) =>
; + const ref = useRef(null); + // A panel is only its own tab stop when nothing inside it can take focus, + // which the Updates panel hits while a check or install is in flight. + const [tabbable, setTabbable] = useState(false); + useEffect(() => { + setTabbable(!ref.current?.querySelector(TABBABLE)); + }); + return
{children}
; } diff --git a/src/settings/settings.test.tsx b/src/settings/settings.test.tsx index eaf6b6cc..0cd8a9c0 100644 --- a/src/settings/settings.test.tsx +++ b/src/settings/settings.test.tsx @@ -475,7 +475,7 @@ describe("Switchify PC settings", () => { }); - it("returns the tab stop to the selected tab when focus leaves the tablist", async () => { + it("keeps the tab stop on the last focused tab after focus leaves the tablist", async () => { render(); fireEvent.click(await screen.findByRole("button", { name: "Settings" })); @@ -487,9 +487,20 @@ describe("Switchify PC settings", () => { const startup = screen.getByRole("checkbox", { name: "Start with system" }); startup.focus(); - fireEvent.blur(pointer, { relatedTarget: startup }); - expect(general).toHaveAttribute("tabindex", "0"); - expect(pointer).toHaveAttribute("tabindex", "-1"); + expect(pointer).toHaveAttribute("tabindex", "0"); + expect(general).toHaveAttribute("tabindex", "-1"); + }); + + it("makes the panel a tab stop only when nothing inside it can take focus", async () => { + browserState.updater = { status: "checking", version: null, downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }; + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + // General holds a focusable toggle, so the panel must not add its own stop. + expect(screen.getByRole("tabpanel")).not.toHaveAttribute("tabindex"); + + selectTab("Updates"); + // While checking, the only button is disabled, so the panel becomes reachable. + await waitFor(() => expect(screen.getByRole("tabpanel")).toHaveAttribute("tabindex", "0")); }); it("points aria-controls only at the panel that is rendered", async () => { From 0094395386c5c9edb117abd9d2f820fe6a04776e Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 8 Sep 2026 15:19:00 +0100 Subject: [PATCH 4/4] fix: stop the tab stop stranding and the focused panel losing focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third review pass on the tablist. Selecting a tab programmatically — as the update banner does — left the roving tab stop on whichever tab last held focus, so the tablist's only tab stop could sit on a tab that is no longer selected while the selected one was unreachable by Tab. The stop now returns to the selection whenever it changes, which also covers ordinary clicks and removes the need to set focus state from the click handler. TabPanel's reactive tabIndex was removed from the element while it held focus: on the Updates panel, finishing a check swaps the disabled button for an enabled one, so the panel stopped being tabbable and browsers drop focus to the body. jsdom does not, which is why this needed an explicit assertion rather than a focus check. The panel now keeps its stop for as long as it holds focus. Restores .settings-panel:focus-visible, dropped when TabPanel lost its unconditional tabIndex but still needed now that it has a conditional one, so that stop uses the app focus ring rather than the UA default. Both regression tests were verified to fail with their fix reverted. An earlier version of the panel-focus test was vacuous — it mutated browserState without triggering a render — and now drives a real onState event instead. Tests: 85 -> 87. Refs #685 Co-Authored-By: Claude Opus 5 (1M context) --- src/settings/Tabs.tsx | 10 ++++++-- src/settings/settings.test.tsx | 47 ++++++++++++++++++++++++++++++++++ src/styles.css | 2 ++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/settings/Tabs.tsx b/src/settings/Tabs.tsx index 0e86f775..5b84470f 100644 --- a/src/settings/Tabs.tsx +++ b/src/settings/Tabs.tsx @@ -13,6 +13,10 @@ export function Tabs({ tabs, active, onSelect, label }: { tabs: readonly TabDefi // then tabbing away and back returns to it rather than to the selected tab. const [focused, setFocused] = useState(null); const tabStop = tabs.some((tab) => tab.id === focused) ? focused : active; + // Selecting a tab — by click or programmatically, as the update banner does — + // moves the stop back onto the selection, so it can never strand the tab stop + // on a tab that is no longer selected. + useEffect(() => { setFocused(null); }, [active]); const focusTab = (index: number) => { const buttons = listRef.current?.querySelectorAll('[role="tab"]'); @@ -41,7 +45,7 @@ export function Tabs({ tabs, active, onSelect, label }: { tabs: readonly TabDefi tabIndex={tabStop === tab.id ? 0 : -1} onFocus={() => setFocused(tab.id)} onKeyDown={(event) => onKeyDown(event, index)} - onClick={() => { setFocused(tab.id); onSelect(tab.id); }} + onClick={() => onSelect(tab.id)} >{tab.label})}
; } @@ -52,7 +56,9 @@ export function TabPanel({ id, children }: { id: string; children: ReactNode }) // which the Updates panel hits while a check or install is in flight. const [tabbable, setTabbable] = useState(false); useEffect(() => { - setTabbable(!ref.current?.querySelector(TABBABLE)); + // Keep the stop while the panel itself holds focus: removing tabIndex from + // the focused element would drop focus to the document body. + setTabbable((current) => !ref.current?.querySelector(TABBABLE) || (current && ref.current === document.activeElement)); }); return
{children}
; } diff --git a/src/settings/settings.test.tsx b/src/settings/settings.test.tsx index 0cd8a9c0..8e53e415 100644 --- a/src/settings/settings.test.tsx +++ b/src/settings/settings.test.tsx @@ -517,4 +517,51 @@ describe("Switchify PC settings", () => { expect(screen.getByRole("tabpanel")).not.toHaveAttribute("tabindex"); }); + + it("moves the tab stop onto the selection when the banner selects Updates", async () => { + browserState.updater = { status: "available", version: "1.0.0-beta.2", downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }; + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + + const pointer = screen.getByRole("tab", { name: "Pointer" }); + fireEvent.click(pointer); + pointer.focus(); + expect(pointer).toHaveAttribute("tabindex", "0"); + + fireEvent.click(within(screen.getByRole("status", { name: "Application update" })).getByRole("button", { name: "View update" })); + await screen.findByRole("region", { name: "Updates" }); + + // The tab stop must not strand on Pointer once Updates becomes the selection. + const updates = screen.getByRole("tab", { name: "Updates" }); + expect(updates).toHaveAttribute("aria-selected", "true"); + expect(updates).toHaveAttribute("tabindex", "0"); + expect(pointer).toHaveAttribute("tabindex", "-1"); + }); + + it("keeps the panel tab stop while the panel itself holds focus", async () => { + let stateHandler: ((state: typeof browserState) => void) | undefined; + vi.spyOn(api, "onState").mockImplementation(async (handler) => { + stateHandler = handler; + return () => undefined; + }); + browserState.updater = { status: "checking", version: null, downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }; + render(); + fireEvent.click(await screen.findByRole("button", { name: "Settings" })); + selectTab("Updates"); + + const panel = screen.getByRole("tabpanel"); + await waitFor(() => expect(panel).toHaveAttribute("tabindex", "0")); + panel.focus(); + expect(panel).toHaveFocus(); + + // Finishing the check swaps the disabled button for an enabled one. Dropping + // tabIndex from the focused panel would send focus to the document body. + act(() => stateHandler?.({ + ...structuredClone(browserState), + updater: { status: "current", version: null, downloadedBytes: 0, totalBytes: null, error: null, retryAction: null }, + })); + expect(screen.getByRole("button", { name: "Check for updates" })).toBeInTheDocument(); + expect(panel).toHaveAttribute("tabindex", "0"); + expect(panel).toHaveFocus(); + }); }); diff --git a/src/styles.css b/src/styles.css index b04d0e2f..a9701015 100644 --- a/src/styles.css +++ b/src/styles.css @@ -293,3 +293,5 @@ button:disabled { cursor: default; opacity: 0.55; } .settings-tabs { display: grid; grid-template-columns: repeat(var(--tab-count), 1fr); width: 100%; min-width: 0; } .settings-tabs button { min-width: 0; padding: 0 8px; } + +.settings-panel:focus-visible { outline: 2px solid var(--focus); outline-offset: 4px; }