fix(a11y): add focus management and ARIA attributes to dialogs
Adds role="dialog", aria-modal, focus trap, auto-focus close button, and Escape key handling to ZapDialog and SettingsModal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
3c709c143f
commit
3258c047d9
@@ -3,7 +3,13 @@
|
||||
<Transition name="settings-modal">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="dialogRef"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Settings"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@keydown.escape="$emit('update:open', false)"
|
||||
@keydown.tab="trapFocus"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
@@ -15,6 +21,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-white/96">Settings</h2>
|
||||
<button
|
||||
ref="closeButtonRef"
|
||||
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/10 transition-all"
|
||||
aria-label="Close settings"
|
||||
@click="$emit('update:open', false)"
|
||||
@@ -192,11 +199,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useMemoryStore, type MemoryItem } from '@/stores/memory'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
@@ -204,6 +211,34 @@ defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
}>()
|
||||
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
const closeButtonRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function trapFocus(e: KeyboardEvent) {
|
||||
const dialog = dialogRef.value
|
||||
if (!dialog) return
|
||||
const focusable = dialog.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
if (focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (isOpen) {
|
||||
await nextTick()
|
||||
closeButtonRef.value?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
// --- Memory ---
|
||||
const memoryStore = useMemoryStore()
|
||||
const newMemoryText = ref('')
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="dialogRef"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Send zap"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
@click.self="close"
|
||||
@keydown.escape="close"
|
||||
@keydown.tab="trapFocus"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" @click="close" />
|
||||
<div class="relative glass-card w-[320px] max-w-[90vw] p-5 space-y-4 animate-scale-in">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-bold text-white/90">Zap</h3>
|
||||
<button
|
||||
ref="closeButtonRef"
|
||||
class="min-w-[44px] min-h-[44px] flex items-center justify-center rounded text-white/40 hover:text-white/70 transition-colors"
|
||||
aria-label="Close"
|
||||
@click="close"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -118,6 +126,9 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
const closeButtonRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const amountPresets = [21, 100, 500, 1000, 5000, 10000]
|
||||
const amount = ref(21)
|
||||
const message = ref('')
|
||||
@@ -216,8 +227,29 @@ function copyInvoice() {
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (!open) {
|
||||
function trapFocus(e: KeyboardEvent) {
|
||||
const dialog = dialogRef.value
|
||||
if (!dialog) return
|
||||
const focusable = dialog.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
if (focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.isOpen, async (open) => {
|
||||
if (open) {
|
||||
await nextTick()
|
||||
closeButtonRef.value?.focus()
|
||||
} else {
|
||||
invoice.value = ''
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user