diff --git a/app/components/malio/drawer/Drawer.test.ts b/app/components/malio/drawer/Drawer.test.ts index 4b70073..2b952a9 100644 --- a/app/components/malio/drawer/Drawer.test.ts +++ b/app/components/malio/drawer/Drawer.test.ts @@ -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: '' }, + 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: '' }, + 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() + }) }) diff --git a/app/components/malio/drawer/Drawer.vue b/app/components/malio/drawer/Drawer.vue index 4676d35..101872f 100644 --- a/app/components/malio/drawer/Drawer.vue +++ b/app/components/malio/drawer/Drawer.vue @@ -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(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( - '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() {