Files
archy/packages/app/src/composables/useFocusTrap.ts
T
DorianandClaude Opus 4.6 725f629f69 feat(a11y): accessibility audit, high contrast, i18n foundation, skip nav (M17.1-M17.8)
- Keyboard navigation: useFocusTrap() for modals, useRovingTabindex() for grids
- ARIA audit: composables for focus trap and roving tabindex patterns
- High contrast mode: @media (prefers-contrast: more) + .high-contrast class
- i18n foundation: en/es/fr locale files, useI18n() composable with auto-detect
- RTL layout support: dir attribute toggling based on locale
- Dyslexia-friendly font: .font-dyslexia CSS class with OpenDyslexic support
- Skip navigation link: .skip-nav CSS with focus-visible positioning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:57:37 +00:00

54 lines
1.3 KiB
TypeScript

import { onMounted, onUnmounted, type Ref } from 'vue'
export function useFocusTrap(containerRef: Ref<HTMLElement | null>) {
function getFocusableElements(): HTMLElement[] {
const container = containerRef.value
if (!container) return []
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
)
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
// Bubble up for close handlers
return
}
if (e.key !== 'Tab') return
const focusable = getFocusableElements()
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
onMounted(() => {
document.addEventListener('keydown', onKeyDown)
// Auto-focus first element
const focusable = getFocusableElements()
if (focusable.length > 0) {
focusable[0].focus()
}
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown)
})
}