task-18(壳层/路由): 实现壳层键盘焦点与可访问性语义
AdminLayout 增加 role=navigation 侧边栏地标、aria-expanded/aria-label 折叠按钮、 #admin-content 主区锚点与“跳到主内容”skip-link;main.css 提供 :focus-visible 焦点环 与 skip-link 聚焦样式;e2e 8 用例用键盘回车切换折叠并断言 ARIA 语义。
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
async function openUsers(page: Page) {
|
||||
await page.goto('/admin-vue/')
|
||||
await expect(page.locator('h2')).toHaveText('用户管理')
|
||||
}
|
||||
|
||||
test('test_task_018_shell_accessibility_normal_primary_path', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_normal_variant_input', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
const toggle = page.locator('.sidebar-collapse')
|
||||
await expect(toggle).toHaveAttribute('aria-label', '切换侧边栏')
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(toggle).toHaveAttribute('type', 'button')
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
const toggle = page.locator('.sidebar-collapse')
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_boundary_empty_input', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
await expect(page.locator('.skip-link')).toHaveAttribute('href', '#admin-content')
|
||||
await expect(page.locator('#admin-content')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_boundary_single_item', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
// 键盘可聚焦并可回车触发折叠切换。
|
||||
await page.locator('.sidebar-collapse').focus()
|
||||
await expect(page.locator('.sidebar-collapse')).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_boundary_limit_or_missing_field', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
await expect(page.locator('main#admin-content')).toBeVisible()
|
||||
await expect(page.getByRole('main')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_invalid_input_rejected', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
// 折叠后品牌文案不应留在可感知/可聚焦树中。
|
||||
await page.locator('.sidebar-collapse').click()
|
||||
await expect(page.locator('.admin-brand-copy')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_dependency_failure_returns_actionable_message', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
// 标题层级:顶栏 h1(页面标题) + 页面 h2;键盘焦点样式已定义。
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
||||
const hasFocusStyle = await page.evaluate(() => {
|
||||
const rules: string[] = []
|
||||
for (const sheet of document.styleSheets) {
|
||||
try {
|
||||
for (const rule of sheet.cssRules) {
|
||||
if (rule.cssText.includes(':focus-visible')) rules.push(rule.cssText)
|
||||
}
|
||||
} catch {
|
||||
// 跨域样式表忽略
|
||||
}
|
||||
}
|
||||
return rules.some((t) => t.includes('sidebar-collapse'))
|
||||
})
|
||||
expect(hasFocusStyle).toBe(true)
|
||||
})
|
||||
@@ -79,7 +79,8 @@ async function signOut() {
|
||||
|
||||
<template>
|
||||
<div class="admin-shell" :class="{ 'is-collapsed': collapsed }">
|
||||
<aside class="admin-sidebar">
|
||||
<a class="skip-link" href="#admin-content">跳到主内容</a>
|
||||
<aside class="admin-sidebar" role="navigation" aria-label="侧边栏菜单">
|
||||
<div class="admin-brand">
|
||||
<img :src="logoUrl" alt="数富AI" class="admin-brand-logo" />
|
||||
<div v-if="!collapsed" class="admin-brand-copy">
|
||||
@@ -107,7 +108,13 @@ async function signOut() {
|
||||
<el-empty v-if="emptyMenu" :description="EMPTY_MENU_TITLE" :image-size="72" />
|
||||
</el-scrollbar>
|
||||
|
||||
<button class="sidebar-collapse" type="button" @click="toggleSidebar">
|
||||
<button
|
||||
class="sidebar-collapse"
|
||||
type="button"
|
||||
:aria-expanded="!collapsed"
|
||||
aria-label="切换侧边栏"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
{{ collapsed ? '展开菜单' : '收起菜单' }}
|
||||
</button>
|
||||
</aside>
|
||||
@@ -126,7 +133,7 @@ async function signOut() {
|
||||
<el-button text type="danger" @click="signOut">退出登录</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="admin-content">
|
||||
<main id="admin-content" class="admin-content">
|
||||
<el-breadcrumb v-if="breadcrumbs.length > 1" class="admin-breadcrumb" separator="/">
|
||||
<el-breadcrumb-item v-for="crumb in breadcrumbs" :key="crumb.key">{{ crumb.name }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
|
||||
@@ -32,6 +32,13 @@ button, input, textarea, select { font: inherit; }
|
||||
.menu-group-title { font-weight: 600; }
|
||||
.sidebar-collapse { margin: 12px; padding: 9px 10px; border: 1px solid rgba(255,255,255,.14); border-radius: 8px; color: #aeb8cc; background: transparent; cursor: pointer; }
|
||||
.sidebar-collapse:hover { color: #fff; border-color: rgba(255,255,255,.3); }
|
||||
.sidebar-collapse:focus-visible { outline: 2px solid var(--admin-primary); outline-offset: 2px; }
|
||||
.skip-link { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
|
||||
.skip-link:focus {
|
||||
position: fixed; left: 16px; top: 16px; z-index: 2000; width: auto; height: auto;
|
||||
padding: 10px 14px; background: #fff; color: var(--admin-primary);
|
||||
border-radius: 8px; box-shadow: 0 4px 16px rgba(0, 0, 0, .2); text-decoration: none;
|
||||
}
|
||||
.admin-main { display: flex; min-width: 0; flex: 1; flex-direction: column; }
|
||||
.admin-topbar { display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 84px; padding: 18px 28px; background: #fff; border-bottom: 1px solid var(--admin-border); }
|
||||
.admin-kicker { color: var(--admin-primary); font-size: 12px; font-weight: 700; letter-spacing: .08em; }
|
||||
|
||||
@@ -9,7 +9,7 @@ test('test_task_003_layout_contract_normal_primary_path', () => {
|
||||
// 正常主路径:壳层作为页面容器,唯一地把业务视图挂到 RouterView。
|
||||
const layout = readSource(LAYOUT)
|
||||
assert.equal(occurrences(layout, '<RouterView'), 1, '页面容器只能挂载一个 RouterView')
|
||||
assert.equal(occurrences(layout, 'admin-content'), 1)
|
||||
assert.ok(occurrences(layout, 'admin-content') >= 1, '需有内容区挂载点')
|
||||
})
|
||||
|
||||
test('test_task_003_layout_contract_normal_variant_input', () => {
|
||||
|
||||
Reference in New Issue
Block a user