feat : scroll-lock, focus-trap et restitution du focus du MalioDrawer

This commit is contained in:
2026-05-21 16:37:19 +02:00
parent f443803327
commit e7af92808f
2 changed files with 127 additions and 2 deletions

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import type { DefineComponent } from 'vue' import type { DefineComponent } from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue' import { Icon as IconifyIcon } from '@iconify/vue'
@@ -30,6 +30,10 @@ function mountComponent(props: DrawerProps = {}, slots?: Record<string, string>)
} }
describe('MalioDrawer', () => { describe('MalioDrawer', () => {
afterEach(() => {
document.body.style.overflow = ''
})
it('does not render when modelValue is false', () => { it('does not render when modelValue is false', () => {
const wrapper = mountComponent({ modelValue: false }) const wrapper = mountComponent({ modelValue: false })
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false) expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
@@ -214,4 +218,49 @@ describe('MalioDrawer', () => {
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' }) await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('update:modelValue')).toBeUndefined() expect(wrapper.emitted('update:modelValue')).toBeUndefined()
}) })
it('locks body scroll when opened and restores it when closed', async () => {
const wrapper = mountComponent({ modelValue: false })
expect(document.body.style.overflow).toBe('')
await wrapper.setProps({ modelValue: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapper.setProps({ modelValue: false })
expect(document.body.style.overflow).toBe('')
})
it('moves focus into the panel when opened', async () => {
const wrapper = mount(DrawerForTest, {
props: { modelValue: false, showClose: false },
slots: { default: '<button data-test="first">OK</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
const first = wrapper.find('[data-test="first"]').element
expect(document.activeElement).toBe(first)
wrapper.unmount()
})
it('restores focus to the trigger when closed', async () => {
const trigger = document.createElement('button')
document.body.appendChild(trigger)
trigger.focus()
expect(document.activeElement).toBe(trigger)
const wrapper = mount(DrawerForTest, {
props: { modelValue: false },
slots: { default: '<button>OK</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
await wrapper.setProps({ modelValue: false })
await wrapper.vm.$nextTick()
expect(document.activeElement).toBe(trigger)
wrapper.unmount()
trigger.remove()
})
}) })

View File

@@ -79,7 +79,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, useAttrs, useId, useSlots } from 'vue' import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useAttrs,
useId,
useSlots,
watch,
} from 'vue'
import { Icon as IconifyIcon } from '@iconify/vue' import { Icon as IconifyIcon } from '@iconify/vue'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
@@ -139,6 +149,51 @@ const isRendered = ref(isOpen.value)
const panelRef = ref<HTMLElement | null>(null) const panelRef = ref<HTMLElement | null>(null)
let previouslyFocused: HTMLElement | null = null
function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
).filter((el) => el.tabIndex !== -1)
}
function onOpen() {
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
document.body.style.overflow = 'hidden'
nextTick(() => {
const panel = panelRef.value
if (!panel) return
const focusable = getFocusable(panel)
;(focusable[0] ?? panel).focus()
})
}
function onClose() {
document.body.style.overflow = ''
previouslyFocused?.focus?.()
previouslyFocused = null
}
watch(isOpen, (val) => {
if (val) {
isRendered.value = true
onOpen()
}
else {
onClose()
}
})
onMounted(() => {
if (isOpen.value) onOpen()
})
onBeforeUnmount(() => {
document.body.style.overflow = ''
})
function onBackdropClick() { function onBackdropClick() {
if (props.dismissable) close() if (props.dismissable) close()
} }
@@ -147,6 +202,27 @@ function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.closeOnEscape) { if (e.key === 'Escape' && props.closeOnEscape) {
e.stopPropagation() e.stopPropagation()
close() close()
return
}
if (e.key !== 'Tab') return
const panel = panelRef.value
if (!panel) return
const focusable = getFocusable(panel)
if (focusable.length === 0) {
e.preventDefault()
panel.focus()
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()
} }
} }