fix : scroll-lock empilable, contenteditable focusable et tests focus-trap du MalioDrawer
This commit is contained in:
@@ -263,4 +263,48 @@ describe('MalioDrawer', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
trigger.remove()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ import { twMerge } from 'tailwind-merge'
|
|||||||
|
|
||||||
defineOptions({ name: 'MalioDrawer', inheritAttrs: false })
|
defineOptions({ name: 'MalioDrawer', inheritAttrs: false })
|
||||||
|
|
||||||
|
// Module-level counter shared across all drawer instances to support stacked drawers.
|
||||||
|
let openDrawerCount = 0
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
id?: string
|
id?: string
|
||||||
@@ -150,18 +153,26 @@ const isRendered = ref(isOpen.value)
|
|||||||
const panelRef = ref<HTMLElement | null>(null)
|
const panelRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
let previouslyFocused: 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[] {
|
function getFocusable(container: HTMLElement): HTMLElement[] {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
container.querySelectorAll<HTMLElement>(
|
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)
|
).filter((el) => el.tabIndex !== -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onOpen() {
|
function onOpen() {
|
||||||
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
|
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(() => {
|
nextTick(() => {
|
||||||
const panel = panelRef.value
|
const panel = panelRef.value
|
||||||
if (!panel) return
|
if (!panel) return
|
||||||
@@ -171,7 +182,13 @@ function onOpen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onClose() {
|
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?.focus?.()
|
||||||
previouslyFocused = null
|
previouslyFocused = null
|
||||||
}
|
}
|
||||||
@@ -191,7 +208,14 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
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() {
|
function onBackdropClick() {
|
||||||
|
|||||||
Reference in New Issue
Block a user