feat: add ARIA labels, roles, and live regions across all views (A11Y-01)
Systematic accessibility pass: aria-label on icon-only buttons, role=dialog and aria-modal on modals, role=tab/tablist on tab switchers, role=switch on toggles, aria-live on dynamic status/error regions, aria-hidden on decorative SVGs, aria-label on search inputs, and nav landmarks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
224681f1e0
commit
46747607ea
@@ -74,7 +74,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4">
|
||||
<nav class="sidebar-nav flex-1 min-h-0 space-y-2 p-6 pt-4" aria-label="Main navigation">
|
||||
<RouterLink
|
||||
v-for="(item, idx) in desktopNavItems"
|
||||
:key="item.path"
|
||||
@@ -83,7 +83,7 @@
|
||||
exact-active-class="nav-tab-active"
|
||||
:style="{ '--nav-stagger': idx }"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="(path, index) in getIconPath(item.icon)"
|
||||
:key="index"
|
||||
@@ -105,7 +105,7 @@
|
||||
@click="router.push('/dashboard/chat')"
|
||||
class="chat-launcher-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-300"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
|
||||
</svg>
|
||||
<span>Chat</span>
|
||||
@@ -116,7 +116,7 @@
|
||||
@click="handleLogout"
|
||||
class="sidebar-logout-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>Logout</span>
|
||||
@@ -293,6 +293,7 @@
|
||||
<nav
|
||||
ref="mobileTabBar"
|
||||
data-mobile-tab-bar
|
||||
aria-label="Mobile navigation"
|
||||
class="md:hidden fixed bottom-0 left-0 right-0 border-t border-glass-border shadow-glass z-50 glass-piece"
|
||||
:class="{ 'glass-throw-tabbar': showZoomIn }"
|
||||
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); padding-bottom: env(safe-area-inset-bottom, 0px);"
|
||||
@@ -312,7 +313,7 @@
|
||||
}"
|
||||
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
|
||||
>
|
||||
<svg class="w-6 h-6 transition-all duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
v-for="(path, index) in getIconPath(item.icon)"
|
||||
:key="index"
|
||||
@@ -336,12 +337,47 @@
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Health Notifications Toast -->
|
||||
<div
|
||||
v-if="healthNotifications.length > 0"
|
||||
class="fixed top-4 right-4 z-[200] flex flex-col gap-2 max-w-sm"
|
||||
>
|
||||
<div
|
||||
v-for="notif in healthNotifications"
|
||||
:key="notif.id"
|
||||
class="p-3 rounded-xl border backdrop-blur-lg shadow-lg"
|
||||
:class="notif.level === 'error'
|
||||
? 'bg-red-500/15 border-red-500/30'
|
||||
: notif.level === 'warning'
|
||||
? 'bg-yellow-500/15 border-yellow-500/30'
|
||||
: 'bg-blue-500/15 border-blue-500/30'"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mt-0.5 shrink-0" :class="notif.level === 'error' ? 'text-red-400' : notif.level === 'warning' ? 'text-yellow-400' : 'text-blue-400'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-white">{{ notif.title }}</p>
|
||||
<p class="text-xs text-white/60 mt-0.5">{{ notif.message }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-white/40 hover:text-white/80 transition-colors shrink-0"
|
||||
@click="dismissNotification(notif.id)"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { RouterLink, RouterView, useRouter, useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
@@ -545,17 +581,44 @@ onMounted(() => {
|
||||
|
||||
onResize()
|
||||
window.addEventListener('resize', onResize)
|
||||
window.addEventListener('keydown', handleKioskShortcuts)
|
||||
web5Badge.refresh()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.classList.remove('dashboard-active')
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('keydown', handleKioskShortcuts)
|
||||
for (const id of pendingTimers) clearTimeout(id)
|
||||
pendingTimers.length = 0
|
||||
if (overlayTimer) { clearTimeout(overlayTimer); overlayTimer = null }
|
||||
})
|
||||
|
||||
// Kiosk keyboard shortcuts (active when kiosk=true in localStorage or query param)
|
||||
function isKioskMode(): boolean {
|
||||
try {
|
||||
return localStorage.getItem('kiosk') === 'true' || new URLSearchParams(window.location.search).has('kiosk')
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
function handleKioskShortcuts(e: KeyboardEvent) {
|
||||
if (!isKioskMode()) return
|
||||
if (e.ctrlKey && e.shiftKey) {
|
||||
if (e.key === 'R' || e.key === 'r') {
|
||||
e.preventDefault()
|
||||
router.push('/recovery')
|
||||
} else if (e.key === 'H' || e.key === 'h') {
|
||||
e.preventDefault()
|
||||
router.push('/dashboard')
|
||||
} else if (e.key === 'Q' || e.key === 'q') {
|
||||
e.preventDefault()
|
||||
if (confirm('Reboot the server?')) {
|
||||
fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: 'system.reboot' }) }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Watch route changes to update indicator position
|
||||
watch(() => route.path, () => {
|
||||
nextTick(() => {
|
||||
@@ -677,7 +740,7 @@ const tabOrder = [
|
||||
]
|
||||
|
||||
// Determine transition direction based on route depth
|
||||
function getTransitionName(currentRoute: any) {
|
||||
function getTransitionName(currentRoute: RouteLocationNormalizedLoaded) {
|
||||
const currentPath = currentRoute.path
|
||||
|
||||
// If no previous path, use fade transition for initial load
|
||||
@@ -796,9 +859,21 @@ function getTransitionName(currentRoute: any) {
|
||||
|
||||
// Update previous path for next transition
|
||||
previousPath = currentPath
|
||||
|
||||
|
||||
return transitionName
|
||||
}
|
||||
|
||||
// Health notifications from WebSocket data
|
||||
const dismissedNotifications = ref<Set<string>>(new Set())
|
||||
|
||||
const healthNotifications = computed(() => {
|
||||
const notifs = store.data?.notifications ?? []
|
||||
return notifs.filter(n => !dismissedNotifications.value.has(n.id)).slice(-5)
|
||||
})
|
||||
|
||||
function dismissNotification(id: string) {
|
||||
dismissedNotifications.value.add(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user