feat(a11y): apply ARIA roles, landmarks, focus management, touch targets and contrast fixes across components

This commit is contained in:
DrSmoothl
2026-03-05 21:57:36 +08:00
parent c12d1ca42a
commit c658b2314d
32 changed files with 365 additions and 156 deletions

View File

@@ -35,7 +35,7 @@ interface HeaderProps {
export function Header({
sidebarOpen,
// mobileMenuOpen, // unused - kept in props for API compatibility
mobileMenuOpen,
searchOpen,
actualTheme,
onSidebarToggle,
@@ -67,6 +67,8 @@ export function Header({
{/* 移动端菜单按钮 */}
<button
onClick={onMobileMenuToggle}
aria-label={t('a11y.closeMenu')}
aria-expanded={mobileMenuOpen}
className="rounded-lg p-2 hover:bg-accent lg:hidden"
>
<Menu className="h-5 w-5" />
@@ -75,8 +77,9 @@ export function Header({
{/* 桌面端侧边栏收起/展开按钮 */}
<button
onClick={onSidebarToggle}
aria-label={sidebarOpen ? t('header.collapseSidebar') : t('header.expandSidebar')}
aria-expanded={sidebarOpen}
className="hidden rounded-lg p-2 hover:bg-accent lg:block"
title={sidebarOpen ? t('header.collapseSidebar') : t('header.expandSidebar')}
>
<ChevronLeft
className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')}
@@ -120,9 +123,10 @@ export function Header({
{/* 搜索框 */}
<button
onClick={() => onSearchOpenChange(true)}
aria-label={t('header.searchPlaceholder')}
className="relative hidden md:flex items-center w-64 h-9 pl-9 pr-16 bg-background/50 border rounded-md hover:bg-accent/50 transition-colors text-left"
>
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<span className="text-sm text-muted-foreground">{t('header.searchPlaceholder')}</span>
<Kbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2">
<span className="text-xs"></span>K
@@ -179,8 +183,8 @@ export function Header({
const newTheme = actualTheme === 'dark' ? 'light' : 'dark'
toggleThemeWithTransition(newTheme, onThemeChange, e)
}}
aria-label={actualTheme === 'dark' ? t('header.switchToLight') : t('header.switchToDark')}
className="rounded-lg p-2 hover:bg-accent"
title={actualTheme === 'dark' ? t('header.switchToLight') : t('header.switchToDark')}
>
{actualTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>

View File

@@ -1,9 +1,12 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter } from '@tanstack/react-router'
import { BackgroundLayer } from '@/components/background-layer'
import { BackToTop } from '@/components/back-to-top'
import { HttpWarningBanner } from '@/components/http-warning-banner'
import { SkipNav } from '@/components/ui/skip-nav'
import { useAnnounce } from '@/components/ui/announcer'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useTheme } from '@/components/use-theme'
import { useAuthGuard } from '@/hooks/use-auth'
@@ -12,6 +15,7 @@ import { useBackground } from '@/hooks/use-background'
import { TitleBar } from '@/components/electron/TitleBar'
import { isElectron } from '@/lib/runtime'
import { cn } from '@/lib/utils'
import { menuSections } from './constants'
import { Header } from './Header'
import { Sidebar } from './Sidebar'
import type { LayoutProps } from './types'
@@ -19,6 +23,8 @@ import type { LayoutProps } from './types'
export function Layout({ children }: LayoutProps) {
const { t } = useTranslation()
const { checking } = useAuthGuard() // 检查认证状态
const router = useRouter()
const announce = useAnnounce()
const [sidebarOpen, setSidebarOpen] = useState(true)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
@@ -52,6 +58,41 @@ export function Layout({ children }: LayoutProps) {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
// 路由变更:焦点管理 + 屏幕阅读器播报 + document.title 更新
useEffect(() => {
// 构建 路径 -> 页面标题 的映射表(以当前语言 t() 翻译)
const pathToLabel: Record<string, string> = {}
for (const section of menuSections) {
for (const item of section.items) {
pathToLabel[item.path] = t(item.label)
}
}
const unsubscribe = router.subscribe('onResolved', () => {
const pathname = router.state.location.pathname
const pageTitle = pathToLabel[pathname] ?? 'MaiBot Dashboard'
const fullTitle = pageTitle === 'MaiBot Dashboard'
? 'MaiBot Dashboard'
: `${pageTitle} — MaiBot Dashboard`
// 更新 document.title
document.title = fullTitle
// 屏幕阅读器朗读导航结果
announce(t('a11y.navigatedTo', { page: pageTitle }), 'polite')
// 将焦点移到主内容区(仅当焦点不在其内部时)
const mainEl = document.getElementById('main-content')
if (mainEl && !mainEl.contains(document.activeElement)) {
// requestAnimationFrame 确保 DOM 已渲染完成
requestAnimationFrame(() => {
mainEl.focus({ preventScroll: true })
})
}
})
return unsubscribe
}, [router, announce, t])
// 认证检查中,显示加载状态
if (checking) {
@@ -74,8 +115,9 @@ export function Layout({ children }: LayoutProps) {
const pageBg = useBackground('page')
return (
<TooltipProvider delayDuration={300}>
{isElectron() && <TitleBar />}
<TooltipProvider delayDuration={300}>
<SkipNav />
{isElectron() && <TitleBar />}
<div className={cn('flex h-screen overflow-hidden', isElectron() && 'pt-8')}>
{/* Sidebar */}
<Sidebar
@@ -88,11 +130,12 @@ export function Layout({ children }: LayoutProps) {
{/* Mobile overlay */}
{mobileMenuOpen && (
<div
aria-hidden="true"
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setMobileMenuOpen(false)}
/>
)}
)}
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* HTTP 安全警告横幅 */}
@@ -111,7 +154,11 @@ export function Layout({ children }: LayoutProps) {
/>
{/* Page content */}
<main className="relative flex-1 overflow-hidden bg-background">
<main
id="main-content"
tabIndex={-1}
className="relative flex-1 overflow-hidden bg-background outline-none"
>
<BackgroundLayer config={pageBg} layerId="page" />
{children}
</main>

View File

@@ -44,7 +44,9 @@ export function Sidebar({
"flex-1 overflow-x-hidden",
!sidebarOpen && "lg:w-16"
)}>
<nav className={cn(
<nav
aria-label={t('a11y.sidebarNav')}
className={cn(
"p-4",
!sidebarOpen && "lg:p-2 lg:w-16"
)}>