feat(dialog): focus trap, autofocus on open, scrollable body

This commit is contained in:
Vadym Samoilenko 2026-05-13 11:06:19 +01:00
parent 2425e241c0
commit 823b41e28d
2 changed files with 65 additions and 3 deletions

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { ref, toRef, onMounted, onUnmounted } from 'vue'
import Button from './Button.vue'
import { useFocusTrap } from '@/composables/useFocusTrap'
const props = withDefaults(
defineProps<{
@ -16,6 +17,10 @@ const emit = defineEmits<{
close: []
}>()
const contentRef = ref<HTMLElement | null>(null)
const isOpen = toRef(props, 'open')
useFocusTrap(contentRef, isOpen)
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.open) {
emit('close')
@ -48,9 +53,10 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
<!-- Dialog panel -->
<div
ref="contentRef"
:class="['relative w-full bg-card border border-border rounded-lg shadow-xl z-10', maxWidth]"
role="dialog"
:aria-modal="true"
aria-modal="true"
:aria-label="title"
>
<!-- Header -->
@ -69,7 +75,7 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
</div>
<!-- Body -->
<div class="px-6 pb-4">
<div class="px-6 pb-4 max-h-[85vh] overflow-y-auto">
<slot />
</div>

View file

@ -0,0 +1,56 @@
import { watch, onUnmounted } from 'vue'
import type { Ref } from 'vue'
const FOCUSABLE = [
'[autofocus]',
'button:not([disabled])',
'[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ')
export function useFocusTrap(containerRef: Ref<HTMLElement | null>, isOpen: Ref<boolean>) {
let previousFocus: Element | null = null
function getFocusable(): HTMLElement[] {
if (!containerRef.value) return []
return Array.from(containerRef.value.querySelectorAll<HTMLElement>(FOCUSABLE))
}
function onKeydown(e: KeyboardEvent) {
if (!isOpen.value || !containerRef.value) return
if (e.key !== 'Tab') return
const focusable = getFocusable()
if (!focusable.length) { e.preventDefault(); 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() }
}
}
watch(isOpen, (open) => {
if (open) {
previousFocus = document.activeElement
document.addEventListener('keydown', onKeydown)
// Defer to next tick so the dialog content is rendered
setTimeout(() => {
const focusable = getFocusable()
if (focusable.length) focusable[0].focus()
}, 50)
} else {
document.removeEventListener('keydown', onKeydown)
if (previousFocus && 'focus' in previousFocus) {
(previousFocus as HTMLElement).focus()
}
}
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeydown)
})
}