fix(ui): harden theme toggle accessibility
This commit is contained in:
parent
cdb269e505
commit
b923ac8947
8 changed files with 75 additions and 9 deletions
|
|
@ -35,6 +35,7 @@ describe("React app dashboard state view", () => {
|
||||||
|
|
||||||
expect(html).toContain('data-ui-theme-toggle="true"');
|
expect(html).toContain('data-ui-theme-toggle="true"');
|
||||||
expect(html).toContain('aria-label="Switch to light theme"');
|
expect(html).toContain('aria-label="Switch to light theme"');
|
||||||
|
expect(html).not.toContain("aria-pressed");
|
||||||
expect(html).toContain("Dark");
|
expect(html).toContain("Dark");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
12
src/App.tsx
12
src/App.tsx
|
|
@ -93,7 +93,7 @@ export default function App() {
|
||||||
const [theme, setTheme] = useState<UiTheme>(() => {
|
const [theme, setTheme] = useState<UiTheme>(() => {
|
||||||
if (typeof window === "undefined") return "dark";
|
if (typeof window === "undefined") return "dark";
|
||||||
|
|
||||||
return resolveInitialUiTheme(window.localStorage);
|
return resolveInitialUiTheme(getThemeStorage());
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -113,7 +113,7 @@ export default function App() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.dataset.uiTheme = theme;
|
document.documentElement.dataset.uiTheme = theme;
|
||||||
persistUiTheme(theme, window.localStorage);
|
persistUiTheme(theme, getThemeStorage());
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -173,3 +173,11 @@ function stateIcon(state: DashboardRuntimeState["state"]): string {
|
||||||
if (state === "loading") return "mdi:progress-clock";
|
if (state === "loading") return "mdi:progress-clock";
|
||||||
return "mdi:tray";
|
return "mdi:tray";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getThemeStorage(): Storage | undefined {
|
||||||
|
try {
|
||||||
|
return window.localStorage;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ export function ThemeToggle({ theme, onThemeChange }: ThemeToggleProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-pressed={theme === "dark"}
|
|
||||||
className="theme-toggle"
|
className="theme-toggle"
|
||||||
data-ui-theme-toggle="true"
|
data-ui-theme-toggle="true"
|
||||||
onClick={() => onThemeChange(nextTheme)}
|
onClick={() => onThemeChange(nextTheme)}
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,9 @@ export const Light: Story = {
|
||||||
args: {
|
args: {
|
||||||
theme: "light",
|
theme: "light",
|
||||||
},
|
},
|
||||||
|
parameters: {
|
||||||
|
globals: {
|
||||||
|
theme: "light",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
getNextUiTheme,
|
getNextUiTheme,
|
||||||
isUiTheme,
|
isUiTheme,
|
||||||
|
persistUiTheme,
|
||||||
resolveInitialUiTheme,
|
resolveInitialUiTheme,
|
||||||
UI_THEME_STORAGE_KEY,
|
UI_THEME_STORAGE_KEY,
|
||||||
} from "./theme";
|
} from "./theme";
|
||||||
|
|
@ -25,6 +26,26 @@ describe("UI theme preference", () => {
|
||||||
expect(resolveInitialUiTheme(storage)).toBe("dark");
|
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", () => {
|
test("detects and toggles supported themes", () => {
|
||||||
expect(isUiTheme("light")).toBe(true);
|
expect(isUiTheme("light")).toBe(true);
|
||||||
expect(isUiTheme("dark")).toBe(true);
|
expect(isUiTheme("dark")).toBe(true);
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export function resolveInitialUiTheme(
|
||||||
storage?: ReadableThemeStorage | null,
|
storage?: ReadableThemeStorage | null,
|
||||||
fallback: UiTheme = "dark",
|
fallback: UiTheme = "dark",
|
||||||
): UiTheme {
|
): UiTheme {
|
||||||
const stored = readStoredTheme(storage);
|
const stored = safeReadStoredTheme(storage);
|
||||||
|
|
||||||
return isUiTheme(stored) ? stored : fallback;
|
return isUiTheme(stored) ? stored : fallback;
|
||||||
}
|
}
|
||||||
|
|
@ -33,12 +33,26 @@ export function persistUiTheme(
|
||||||
): void {
|
): void {
|
||||||
if (!storage) return;
|
if (!storage) return;
|
||||||
|
|
||||||
|
try {
|
||||||
if ("setItem" in storage) {
|
if ("setItem" in storage) {
|
||||||
storage.setItem(UI_THEME_STORAGE_KEY, theme);
|
storage.setItem(UI_THEME_STORAGE_KEY, theme);
|
||||||
return;
|
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(
|
function readStoredTheme(
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,24 @@ test.describe("dashboard page QA gate", () => {
|
||||||
expect(results.violations).toEqual([]);
|
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 }) => {
|
test("honors reduced-motion preferences", async ({ page }) => {
|
||||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 274 KiB |
Loading…
Add table
Add a link
Reference in a new issue