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() {