fix : scroll-lock empilable, contenteditable focusable et tests focus-trap du MalioDrawer

This commit is contained in:
2026-05-21 16:44:46 +02:00
parent e7af92808f
commit 527766ab4c
2 changed files with 72 additions and 4 deletions

View File

@@ -263,4 +263,48 @@ describe('MalioDrawer', () => {
wrapper.unmount()
trigger.remove()
})
it('moves focus to the close button on open (default showClose)', async () => {
const wrapper = mount(DrawerForTest, {
props: { modelValue: false, showClose: true },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.setProps({ modelValue: true })
await wrapper.vm.$nextTick()
expect(document.activeElement).toBe(wrapper.find('[data-test="close-button"]').element)
wrapper.unmount()
})
it('wraps focus to the first element when Tab is pressed on the last element', async () => {
const wrapper = mount(DrawerForTest, {
props: { modelValue: true, showClose: false },
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.vm.$nextTick()
const last = wrapper.find('[data-test="btn2"]').element as HTMLElement
last.focus()
expect(document.activeElement).toBe(last)
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab' })
expect(document.activeElement).toBe(wrapper.find('[data-test="btn1"]').element)
wrapper.unmount()
})
it('wraps focus to the last element when Shift+Tab is pressed on the first element', async () => {
const wrapper = mount(DrawerForTest, {
props: { modelValue: true, showClose: false },
slots: { default: '<button data-test="btn1">First</button><button data-test="btn2">Last</button>' },
attachTo: document.body,
global: { stubs: { Teleport: true } },
})
await wrapper.vm.$nextTick()
const first = wrapper.find('[data-test="btn1"]').element as HTMLElement
first.focus()
expect(document.activeElement).toBe(first)
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Tab', shiftKey: true })
expect(document.activeElement).toBe(wrapper.find('[data-test="btn2"]').element)
wrapper.unmount()
})
})

View File

@@ -95,6 +95,9 @@ import { twMerge } from 'tailwind-merge'
defineOptions({ name: 'MalioDrawer', inheritAttrs: false })
// Module-level counter shared across all drawer instances to support stacked drawers.
let openDrawerCount = 0
const props = withDefaults(
defineProps<{
id?: string
@@ -150,18 +153,26 @@ const isRendered = ref(isOpen.value)
const panelRef = ref<HTMLElement | null>(null)
let previouslyFocused: HTMLElement | null = null
// Per-instance flag: true while this drawer holds a scroll-lock count slot.
let lockedByThisInstance = false
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"])',
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]:not([contenteditable="false"])',
),
).filter((el) => el.tabIndex !== -1)
}
function onOpen() {
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
document.body.style.overflow = 'hidden'
if (!lockedByThisInstance) {
lockedByThisInstance = true
openDrawerCount++
if (openDrawerCount === 1) {
document.body.style.overflow = 'hidden'
}
}
nextTick(() => {
const panel = panelRef.value
if (!panel) return
@@ -171,7 +182,13 @@ function onOpen() {
}
function onClose() {
document.body.style.overflow = ''
if (lockedByThisInstance) {
lockedByThisInstance = false
openDrawerCount = Math.max(0, openDrawerCount - 1)
if (openDrawerCount === 0) {
document.body.style.overflow = ''
}
}
previouslyFocused?.focus?.()
previouslyFocused = null
}
@@ -191,7 +208,14 @@ onMounted(() => {
})
onBeforeUnmount(() => {
document.body.style.overflow = ''
// If this instance is still holding a scroll-lock slot, release it.
if (lockedByThisInstance) {
lockedByThisInstance = false
openDrawerCount = Math.max(0, openDrawerCount - 1)
if (openDrawerCount === 0) {
document.body.style.overflow = ''
}
}
})
function onBackdropClick() {