feat(a11y): apply ARIA roles, landmarks, focus management, touch targets and contrast fixes across components
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
)}>
|
||||
|
||||
Reference in New Issue
Block a user