feat : scroll-lock, focus-trap et restitution du focus du MalioDrawer
This commit is contained in:
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user