From a5e40060f00741ab2a993f370ae87cdffb2abb56 Mon Sep 17 00:00:00 2001 From: ytwei Date: Tue, 25 Aug 2026 20:54:29 +0800 Subject: [PATCH] fix: improve course navigation accessibility --- .github/workflows/ci.yml | 8 ++ .gitignore | 2 + frontend/e2e/navigation.spec.ts | 86 +++++++++++++++++++++ frontend/package-lock.json | 64 ++++++++++++++++ frontend/package.json | 4 +- frontend/playwright.config.ts | 20 +++++ frontend/src/App.tsx | 129 ++++++++++++++++++++++++++++++-- frontend/src/styles.css | 4 + frontend/vite.config.ts | 6 +- 9 files changed, 315 insertions(+), 8 deletions(-) create mode 100644 frontend/e2e/navigation.spec.ts create mode 100644 frontend/playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 159e393..4e9da41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,11 @@ jobs: - name: Build working-directory: frontend run: npm run build + + - name: Install Playwright browser + working-directory: frontend + run: npx playwright install --with-deps chromium + + - name: Browser tests + working-directory: frontend + run: npm run test:e2e diff --git a/.gitignore b/.gitignore index 75506d9..f139edd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ work/ node_modules/ frontend/dist/ frontend/coverage/ +frontend/playwright-report/ +frontend/test-results/ *.tsbuildinfo Thumbs.db .env diff --git a/frontend/e2e/navigation.spec.ts b/frontend/e2e/navigation.spec.ts new file mode 100644 index 0000000..8832409 --- /dev/null +++ b/frontend/e2e/navigation.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from "@playwright/test"; + +test.describe("course navigation", () => { + test("keeps the full desktop sidebar reachable at laptop height", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto("/#/map"); + + const sidebar = page.locator("aside.sidebar"); + const lastNavigationItem = sidebar.getByRole("button", { name: /Problem-solving Lab/ }); + + await expect(sidebar).toBeVisible(); + await expect(sidebar).not.toHaveAttribute("aria-hidden"); + await expect(sidebar).not.toHaveAttribute("inert"); + const sidebarMetrics = await sidebar.evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })); + expect(sidebarMetrics.scrollHeight).toBeGreaterThan(sidebarMetrics.clientHeight); + + await sidebar.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await expect(lastNavigationItem).toBeInViewport(); + + await lastNavigationItem.click(); + await expect(page).toHaveURL(/#\/problem-solving-lab$/); + }); +}); + +test.describe("mobile course drawer", () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test.beforeEach(async ({ page }) => { + await page.goto("/#/map"); + }); + + test("keeps the closed drawer out of the focus order and restores focus after Escape", async ({ page }) => { + const menuButton = page.locator(`button[aria-controls="mobile-course-navigation"]`); + const sidebar = page.locator("#mobile-course-navigation"); + const firstSidebarButton = sidebar.locator("button").first(); + + await expect(menuButton).toHaveAttribute("aria-expanded", "false"); + await expect(sidebar).toHaveAttribute("aria-hidden", "true"); + await expect(sidebar).toHaveAttribute("inert", ""); + + await menuButton.focus(); + await page.keyboard.press("Tab"); + await expect(firstSidebarButton).not.toBeFocused(); + + await menuButton.click(); + await expect(menuButton).toHaveAttribute("aria-label", "關閉課程選單"); + await expect(menuButton).toHaveAttribute("aria-expanded", "true"); + await expect(sidebar).toHaveAttribute("aria-hidden", "false"); + await expect(sidebar).not.toHaveAttribute("inert"); + + const lastSidebarButton = sidebar.locator("button").last(); + await expect(firstSidebarButton).toBeFocused(); + + await page.keyboard.press("Shift+Tab"); + await expect(lastSidebarButton).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(firstSidebarButton).toBeFocused(); + + await page.keyboard.press("Escape"); + await expect(menuButton).toHaveAttribute("aria-expanded", "false"); + await expect(sidebar).toHaveAttribute("aria-hidden", "true"); + await expect(sidebar).toHaveAttribute("inert", ""); + await expect(menuButton).toBeFocused(); + }); + + test("closes and returns focus when selecting a route", async ({ page }) => { + const menuButton = page.locator(`button[aria-controls="mobile-course-navigation"]`); + const sidebar = page.locator("#mobile-course-navigation"); + const scrim = page.getByRole("button", { name: "關閉選單", exact: true }); + + await menuButton.click(); + await scrim.click(); + await expect(menuButton).toBeFocused(); + + await menuButton.click(); + await sidebar.getByRole("button", { name: /Git Lab/ }).click(); + + await expect(page).toHaveURL(/#\/lab$/); + await expect(menuButton).toBeFocused(); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f1d3176..4ae74f0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@types/node": "^24.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -815,6 +816,22 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1866,6 +1883,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7d22587..fb1a332 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,13 +10,15 @@ "lint": "tsc --noEmit", "review": "node ../.github/scripts/ollama-review.mjs", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "test:e2e": "playwright test" }, "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@types/node": "^24.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..6fcfef3 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + reporter: [["list"], ["html", { outputFolder: "playwright-report", open: "never" }]], + use: { + baseURL: "http://127.0.0.1:4173", + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + webServer: { + command: "npm run dev -- --host 127.0.0.1 --port 4173", + url: "http://127.0.0.1:4173", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0f22401..a90556a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect, useMemo, useState } from "react"; +import { Fragment, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { curriculum } from "./curriculum"; import { aggregateProgress, completedReadyTopicIds } from "./progress/aggregation"; import { createLocalStorageProgressRepository } from "./progress/repository"; @@ -19,10 +19,34 @@ const topicNavigationEntries = getTopicNavigationEntries(curriculum); const corePracticeTopics = topicNavigationEntries.filter((topic) => topic.trackKind === "core"); const extensionTopics = topicNavigationEntries.filter((topic) => topic.trackKind === "extension"); +const MOBILE_NAVIGATION_QUERY = "(max-width: 720px)"; +const MOBILE_NAVIGATION_ID = "mobile-course-navigation"; +const FOCUSABLE_ELEMENT_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "[tabindex]:not([tabindex=\"-1\"])", +].join(", "); + +function matchesMobileNavigationQuery(): boolean { + return typeof window !== "undefined" && window.matchMedia(MOBILE_NAVIGATION_QUERY).matches; +} + +function getFocusableElements(container: HTMLElement | null): HTMLElement[] { + if (!container) return []; + return Array.from(container.querySelectorAll(FOCUSABLE_ELEMENT_SELECTOR)); +} + export default function App() { const [route, setRoute] = useState(() => resolveRoute(window.location.hash, curriculum, TOPIC_MODULE_IDS)); const [menuOpen, setMenuOpen] = useState(false); + const [isMobileViewport, setIsMobileViewport] = useState(matchesMobileNavigationQuery); const [progressRevision, setProgressRevision] = useState(0); + const menuButtonRef = useRef(null); + const sidebarRef = useRef(null); + const wasMenuOpenRef = useRef(false); const progressRepository = useMemo(() => createLocalStorageProgressRepository(window.localStorage), []); const progress = aggregateProgress(curriculum, progressRepository); const completedTopicIds = useMemo( @@ -30,6 +54,50 @@ export default function App() { [progressRepository, progressRevision], ); const activeTrack = route.kind === "track" ? curriculum.tracks.find((track) => track.id === route.trackId) : undefined; + + useEffect(() => { + const mediaQuery = window.matchMedia(MOBILE_NAVIGATION_QUERY); + const syncViewport = () => { + setIsMobileViewport(mediaQuery.matches); + if (!mediaQuery.matches) setMenuOpen(false); + }; + + syncViewport(); + mediaQuery.addEventListener("change", syncViewport); + return () => mediaQuery.removeEventListener("change", syncViewport); + }, []); + + useEffect(() => { + const sidebar = sidebarRef.current; + if (!sidebar) return; + sidebar.toggleAttribute("inert", isMobileViewport && !menuOpen); + }, [isMobileViewport, menuOpen]); + + useEffect(() => { + const wasMenuOpen = wasMenuOpenRef.current; + const activeElement = document.activeElement; + + if (isMobileViewport && menuOpen && (!wasMenuOpen || !sidebarRef.current?.contains(activeElement))) { + getFocusableElements(sidebarRef.current)[0]?.focus(); + } + + if (isMobileViewport && !menuOpen && wasMenuOpen) { + menuButtonRef.current?.focus(); + } + + wasMenuOpenRef.current = menuOpen; + }, [isMobileViewport, menuOpen]); + + useEffect(() => { + if (!isMobileViewport || !menuOpen) return; + + const previousBodyOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previousBodyOverflow; + }; + }, [isMobileViewport, menuOpen]); + useEffect(() => { const syncRoute = () => { const parsedRoute = parseRoute(window.location.hash); @@ -68,15 +136,54 @@ export default function App() { if (route.topicId) completeTopic(route.topicId); } + function handleSidebarKeyDown(event: KeyboardEvent) { + if (!isMobileViewport || !menuOpen) return; + + if (event.key === "Escape") { + event.preventDefault(); + setMenuOpen(false); + return; + } + + if (event.key !== "Tab") return; + + const focusableElements = getFocusableElements(sidebarRef.current); + const firstFocusableElement = focusableElements[0]; + const lastFocusableElement = focusableElements[focusableElements.length - 1]; + if (!firstFocusableElement || !lastFocusableElement) return; + + const activeElement = document.activeElement; + if (!sidebarRef.current?.contains(activeElement)) { + event.preventDefault(); + (event.shiftKey ? lastFocusableElement : firstFocusableElement).focus(); + return; + } + + if (event.shiftKey && activeElement === firstFocusableElement) { + event.preventDefault(); + lastFocusableElement.focus(); + } else if (!event.shiftKey && activeElement === lastFocusableElement) { + event.preventDefault(); + firstFocusableElement.focus(); + } + } + return (
-