diff --git a/docs/screenshots/en/1.png b/docs/screenshots/en/1.png index 3869726..2714bd3 100644 Binary files a/docs/screenshots/en/1.png and b/docs/screenshots/en/1.png differ diff --git a/docs/screenshots/en/2.png b/docs/screenshots/en/2.png index 355cbd1..cfd91ac 100644 Binary files a/docs/screenshots/en/2.png and b/docs/screenshots/en/2.png differ diff --git a/docs/screenshots/en/3.png b/docs/screenshots/en/3.png index 1905d80..8a3fc73 100644 Binary files a/docs/screenshots/en/3.png and b/docs/screenshots/en/3.png differ diff --git a/docs/screenshots/en/4.png b/docs/screenshots/en/4.png index 8527d7c..ea6db27 100644 Binary files a/docs/screenshots/en/4.png and b/docs/screenshots/en/4.png differ diff --git a/docs/screenshots/en/5.png b/docs/screenshots/en/5.png index f74664d..c8c64ad 100644 Binary files a/docs/screenshots/en/5.png and b/docs/screenshots/en/5.png differ diff --git a/docs/screenshots/jp/1.png b/docs/screenshots/jp/1.png index 5930746..c4266d4 100644 Binary files a/docs/screenshots/jp/1.png and b/docs/screenshots/jp/1.png differ diff --git a/docs/screenshots/jp/2.png b/docs/screenshots/jp/2.png index c261f16..be67f6a 100644 Binary files a/docs/screenshots/jp/2.png and b/docs/screenshots/jp/2.png differ diff --git a/docs/screenshots/jp/3.png b/docs/screenshots/jp/3.png index 48fddaf..f2bca32 100644 Binary files a/docs/screenshots/jp/3.png and b/docs/screenshots/jp/3.png differ diff --git a/docs/screenshots/jp/4.png b/docs/screenshots/jp/4.png index 1b83996..84a7cc3 100644 Binary files a/docs/screenshots/jp/4.png and b/docs/screenshots/jp/4.png differ diff --git a/docs/screenshots/jp/5.png b/docs/screenshots/jp/5.png index c0d54c1..025dc21 100644 Binary files a/docs/screenshots/jp/5.png and b/docs/screenshots/jp/5.png differ diff --git a/docs/screenshots/zh-CN/1.png b/docs/screenshots/zh-CN/1.png index a2299c1..3c6ca2f 100644 Binary files a/docs/screenshots/zh-CN/1.png and b/docs/screenshots/zh-CN/1.png differ diff --git a/docs/screenshots/zh-CN/2.png b/docs/screenshots/zh-CN/2.png index de9b689..6ca8e55 100644 Binary files a/docs/screenshots/zh-CN/2.png and b/docs/screenshots/zh-CN/2.png differ diff --git a/docs/screenshots/zh-CN/3.png b/docs/screenshots/zh-CN/3.png index e67265a..afa4680 100644 Binary files a/docs/screenshots/zh-CN/3.png and b/docs/screenshots/zh-CN/3.png differ diff --git a/docs/screenshots/zh-CN/4.png b/docs/screenshots/zh-CN/4.png index 505ce40..ce67f0e 100644 Binary files a/docs/screenshots/zh-CN/4.png and b/docs/screenshots/zh-CN/4.png differ diff --git a/docs/screenshots/zh-CN/5.png b/docs/screenshots/zh-CN/5.png index b4dcda3..30a889d 100644 Binary files a/docs/screenshots/zh-CN/5.png and b/docs/screenshots/zh-CN/5.png differ diff --git a/package.json b/package.json index 7e57dd2..c9fc636 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "build": "tsc && vite build", "check": "tsc --noEmit", "preview": "vite preview", + "test:ui:accessibility": "node tests/app-shell-accessibility-ui.cjs", + "screenshots": "node scripts/capture-readme-screenshots.cjs", "tauri": "tauri" }, "dependencies": { diff --git a/scripts/capture-readme-screenshots.cjs b/scripts/capture-readme-screenshots.cjs new file mode 100644 index 0000000..a189386 --- /dev/null +++ b/scripts/capture-readme-screenshots.cjs @@ -0,0 +1,84 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..'); +const locales = [ + { directory: 'zh-CN', value: 'zh-CN' }, + { directory: 'en', value: 'en' }, + { directory: 'jp', value: 'ja' }, +]; +const pages = [ + { file: '1.png', navigationIndex: 0 }, + { file: '2.png', navigationIndex: 2 }, + { file: '3.png', navigationIndex: 1 }, + { file: '4.png', navigationIndex: 4 }, + { file: '5.png', navigationIndex: 3 }, +]; + +(async () => { + const { createServer } = await import('vite'); + let baseUrl = 'http://127.0.0.1:1420/'; + let server; + let browser; + try { + try { + const response = await fetch(baseUrl); + if (!response.ok) throw new Error(String(response.status)); + } catch { + const react = (await import('@vitejs/plugin-react')).default; + server = await createServer({ + configFile: false, + root, + plugins: [react()], + logLevel: 'error', + server: { host: '127.0.0.1', port: 1420, strictPort: false }, + }); + await server.listen(); + const address = server.httpServer.address(); + if (typeof address === 'object' && address) { + baseUrl = `http://127.0.0.1:${address.port}/`; + } + const response = await fetch(baseUrl); + if (!response.ok) throw new Error(`Vite server failed at ${baseUrl}`); + await response.text(); + } + const channel = process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined); + browser = await chromium.launch(channel ? { channel, headless: true } : { headless: true }); + + for (const locale of locales) { + const context = await browser.newContext({ + viewport: { width: 1396, height: 900 }, + deviceScaleFactor: 1, + colorScheme: 'light', + }); + const page = await context.newPage(); + await page.goto(`${baseUrl}?mock=running`, { waitUntil: 'commit' }); + await page.locator('.app-shell').waitFor(); + if (locale.value !== 'zh-CN') { + await page.locator('.sidebar-language-trigger').click(); + await page.locator(`#sidebar-language-list [role="option"] span[lang="${locale.value}"]`).click(); + } + await page.locator('#browser-mock-toolbar').evaluate((element) => { element.style.display = 'none'; }); + const outputDirectory = path.join(root, 'docs', 'screenshots', locale.directory); + await fs.mkdir(outputDirectory, { recursive: true }); + + for (const target of pages) { + await page.locator('.nav-section button').nth(target.navigationIndex).click(); + await page.waitForTimeout(350); + await page.screenshot({ + path: path.join(outputDirectory, target.file), + animations: 'disabled', + }); + } + await context.close(); + } + console.log('Updated README screenshots for zh-CN, en and ja.'); + } finally { + if (browser) await browser.close(); + if (server) await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/src/App.tsx b/src/App.tsx index 56d4ddc..fabf66b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { MessageNotice } from './appNotice'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { @@ -35,6 +35,7 @@ import { AppUpdateDialog, AppUpdateProvider, useAppUpdate } from './appUpdate'; import { appUpdateIndicatorState } from './appUpdateModel'; import { canOpenAppPage, isAlwaysAvailablePage } from './navigation'; import { useThemePreference } from './theme'; +import { useDialogFocusTrap } from './components/useDialogFocusTrap'; const CONTACT_URL = 'https://qm.qq.com/q/3queDaIG'; @@ -131,7 +132,13 @@ function AppContent() { const [languageMenuOpen, setLanguageMenuOpen] = useState(false); const [theme, setTheme] = useThemePreference(); const [windowsClosePrompt, setWindowsClosePrompt] = useState(null); - const closeDialogRef = useRef(null); + const closeDialogRef = useDialogFocusTrap({ + active: Boolean(windowsClosePrompt), + onEscape: windowsClosePrompt?.resolvingAction + ? undefined + : () => setWindowsClosePrompt(null), + preventEscape: Boolean(windowsClosePrompt?.resolvingAction), + }); const languageMenuRef = useRef(null); const languageButtonRef = useRef(null); const { status } = useCoreRuntime(); @@ -218,16 +225,21 @@ function AppContent() { }; }, []); - useEffect(() => { - if (!windowsClosePrompt || windowsClosePrompt.resolvingAction) { - return; - } - - const frame = window.requestAnimationFrame(() => { - closeDialogRef.current?.focus(); - }); - return () => window.cancelAnimationFrame(frame); - }, [windowsClosePrompt]); + const handleLanguageListKeyDown = (event: ReactKeyboardEvent) => { + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + const options = Array.from(event.currentTarget.querySelectorAll('[role="option"]')); + if (!options.length) return; + const current = options.indexOf(document.activeElement as HTMLButtonElement); + const next = event.key === 'Home' + ? 0 + : event.key === 'End' + ? options.length - 1 + : event.key === 'ArrowDown' + ? (Math.max(current, -1) + 1) % options.length + : (current <= 0 ? options.length : current) - 1; + event.preventDefault(); + options[next]?.focus(); + }; const select = (pageId: PageId) => { if (!canOpenAppPage(pageId, coreReady)) { @@ -383,6 +395,15 @@ function AppContent() { aria-expanded={languageMenuOpen} aria-controls="sidebar-language-list" onClick={() => setLanguageMenuOpen((open) => !open)} + onKeyDown={(event) => { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return; + event.preventDefault(); + setLanguageMenuOpen(true); + window.requestAnimationFrame(() => { + const options = languageMenuRef.current?.querySelectorAll('[role="option"]'); + options?.[event.key === 'ArrowUp' ? options.length - 1 : 0]?.focus(); + }); + }} >