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:
Dorian
2026-03-04 22:44:11 +00:00
co-authored by Claude Opus 4.6
parent 3c709c143f
commit 3258c047d9
2 changed files with 71 additions and 4 deletions
@@ -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 = ''
}