fix(ui): harden theme toggle accessibility

This commit is contained in:
vince 2026-06-20 00:58:30 +02:00
parent cdb269e505
commit b923ac8947
8 changed files with 75 additions and 9 deletions

View file

@ -35,6 +35,7 @@ describe("React app dashboard state view", () => {
expect(html).toContain('data-ui-theme-toggle="true"');
expect(html).toContain('aria-label="Switch to light theme"');
expect(html).not.toContain("aria-pressed");
expect(html).toContain("Dark");
});
});

View file

@ -93,7 +93,7 @@ export default function App() {
const [theme, setTheme] = useState<UiTheme>(() => {
if (typeof window === "undefined") return "dark";
return resolveInitialUiTheme(window.localStorage);
return resolveInitialUiTheme(getThemeStorage());
});
useEffect(() => {
@ -113,7 +113,7 @@ export default function App() {
useEffect(() => {
document.documentElement.dataset.uiTheme = theme;
persistUiTheme(theme, window.localStorage);
persistUiTheme(theme, getThemeStorage());
}, [theme]);
useEffect(() => {
@ -173,3 +173,11 @@ function stateIcon(state: DashboardRuntimeState["state"]): string {
if (state === "loading") return "mdi:progress-clock";
return "mdi:tray";
}
function getThemeStorage(): Storage | undefined {
try {
return window.localStorage;
} catch {
return undefined;
}
}

View file

@ -14,7 +14,6 @@ export function ThemeToggle({ theme, onThemeChange }: ThemeToggleProps) {
return (
<button
aria-label={label}
aria-pressed={theme === "dark"}
className="theme-toggle"
data-ui-theme-toggle="true"
onClick={() => onThemeChange(nextTheme)}

View file

@ -20,4 +20,9 @@ export const Light: Story = {
args: {
theme: "light",
},
parameters: {
globals: {
theme: "light",
},
},
};

View file

@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
import {
getNextUiTheme,
isUiTheme,
persistUiTheme,
resolveInitialUiTheme,
UI_THEME_STORAGE_KEY,
} from "./theme";
@ -25,6 +26,26 @@ describe("UI theme preference", () => {
expect(resolveInitialUiTheme(storage)).toBe("dark");
});
test("falls back when stored preferences cannot be read", () => {
const storage = {
getItem() {
throw new Error("storage blocked");
},
};
expect(resolveInitialUiTheme(storage)).toBe("dark");
});
test("ignores persistence failures", () => {
const storage = {
setItem() {
throw new Error("quota exceeded");
},
};
expect(() => persistUiTheme("light", storage)).not.toThrow();
});
test("detects and toggles supported themes", () => {
expect(isUiTheme("light")).toBe(true);
expect(isUiTheme("dark")).toBe(true);

View file

@ -22,7 +22,7 @@ export function resolveInitialUiTheme(
storage?: ReadableThemeStorage | null,
fallback: UiTheme = "dark",
): UiTheme {
const stored = readStoredTheme(storage);
const stored = safeReadStoredTheme(storage);
return isUiTheme(stored) ? stored : fallback;
}
@ -33,12 +33,26 @@ export function persistUiTheme(
): void {
if (!storage) return;
if ("setItem" in storage) {
storage.setItem(UI_THEME_STORAGE_KEY, theme);
return;
}
try {
if ("setItem" in storage) {
storage.setItem(UI_THEME_STORAGE_KEY, theme);
return;
}
storage.set(UI_THEME_STORAGE_KEY, theme);
storage.set(UI_THEME_STORAGE_KEY, theme);
} catch {
// Browser storage may be blocked or quota-constrained.
}
}
function safeReadStoredTheme(
storage?: ReadableThemeStorage | null,
): string | undefined {
try {
return readStoredTheme(storage);
} catch {
return undefined;
}
}
function readStoredTheme(

View file

@ -240,6 +240,24 @@ test.describe("dashboard page QA gate", () => {
expect(results.violations).toEqual([]);
});
test("passes automated accessibility checks in light mode", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-desktop");
await page.goto("/");
await waitForDashboardReady(page);
await page.getByRole("button", { name: "Switch to light theme" }).click();
await expect(page.locator("html")).toHaveAttribute("data-ui-theme", "light");
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
await expect(page).toHaveScreenshot("dashboard-light-desktop.png", {
fullPage: true,
});
});
test("honors reduced-motion preferences", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB