feat(dialog): focus trap, autofocus on open, scrollable body
This commit is contained in:
parent
2425e241c0
commit
823b41e28d
2 changed files with 65 additions and 3 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
56
web/src/composables/useFocusTrap.ts
Normal file
56
web/src/composables/useFocusTrap.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue