feat : composant Modal (#MUI-36)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,320 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
|
import Modal from './Modal.vue'
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
id?: string
|
||||||
|
modelValue?: boolean
|
||||||
|
showClose?: boolean
|
||||||
|
dismissable?: boolean
|
||||||
|
closeOnEscape?: boolean
|
||||||
|
ariaLabel?: string
|
||||||
|
modalClass?: string
|
||||||
|
overlayClass?: string
|
||||||
|
headerClass?: string
|
||||||
|
bodyClass?: string
|
||||||
|
footerClass?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalForTest = Modal as DefineComponent<ModalProps>
|
||||||
|
|
||||||
|
function mountComponent(props: ModalProps = {}, slots?: Record<string, string>) {
|
||||||
|
return mount(ModalForTest, {
|
||||||
|
props,
|
||||||
|
slots,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MalioModal', () => {
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render when modelValue is false', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: false })
|
||||||
|
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the panel when modelValue is true', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('[data-test="panel"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('centers the modal (items-center justify-center)', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
const root = wrapper.find('.fixed')
|
||||||
|
expect(root.classes()).toContain('items-center')
|
||||||
|
expect(root.classes()).toContain('justify-center')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders default slot in the body', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ modelValue: true },
|
||||||
|
{ default: '<p data-test="content">Contenu</p>' },
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-test="body"] [data-test="content"]').text()).toBe('Contenu')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works in uncontrolled mode (defaults closed)', () => {
|
||||||
|
const wrapper = mountComponent()
|
||||||
|
expect(wrapper.find('[data-test="panel"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses custom id when provided', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, id: 'my-modal' })
|
||||||
|
expect(wrapper.find('.fixed').attributes('id')).toBe('my-modal')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates an id when not provided', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('.fixed').attributes('id')).toMatch(/^malio-modal-/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has role="dialog" and aria-modal on the panel', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
const panel = wrapper.find('[data-test="panel"]')
|
||||||
|
expect(panel.attributes('role')).toBe('dialog')
|
||||||
|
expect(panel.attributes('aria-modal')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies modalClass to the panel', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, modalClass: 'max-w-2xl' })
|
||||||
|
expect(wrapper.find('[data-test="panel"]').classes()).toContain('max-w-2xl')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the #header slot inside the header bar', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ modelValue: true },
|
||||||
|
{ header: '<h2 data-test="title">Titre</h2>' },
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-test="header"] [data-test="title"]').text()).toBe('Titre')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the header bar when showClose is true even without #header', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('[data-test="header"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render the header bar when no #header and showClose is false', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, showClose: false })
|
||||||
|
expect(wrapper.find('[data-test="header"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the close button by default', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the close button when showClose is false', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ modelValue: true, showClose: false },
|
||||||
|
{ header: '<h2>Titre</h2>' },
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-test="close-button"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('close button renders mdi:cancel-bold icon', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
const icon = wrapper.findComponent(IconifyIcon)
|
||||||
|
expect(icon.props('icon')).toBe('mdi:cancel-bold')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('close button has aria-label "Fermer"', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('[data-test="close-button"]').attributes('aria-label')).toBe('Fermer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue false and close on close button click', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
await wrapper.find('[data-test="close-button"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets aria-labelledby to the header id when #header is provided', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ modelValue: true, id: 'test-modal' },
|
||||||
|
{ header: '<h2>Titre</h2>' },
|
||||||
|
)
|
||||||
|
const panel = wrapper.find('[data-test="panel"]')
|
||||||
|
expect(panel.attributes('aria-labelledby')).toBe('test-modal-header')
|
||||||
|
expect(wrapper.find('[data-test="header-content"]').attributes('id')).toBe('test-modal-header')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets aria-label from ariaLabel when no #header is provided', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, ariaLabel: 'Boîte de dialogue' })
|
||||||
|
const panel = wrapper.find('[data-test="panel"]')
|
||||||
|
expect(panel.attributes('aria-label')).toBe('Boîte de dialogue')
|
||||||
|
expect(panel.attributes('aria-labelledby')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies headerClass to the header bar', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, headerClass: 'bg-m-primary' })
|
||||||
|
expect(wrapper.find('[data-test="header"]').classes()).toContain('bg-m-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the #footer slot in a footer pinned below the body', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ modelValue: true },
|
||||||
|
{ footer: '<button data-test="save">Enregistrer</button>' },
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-test="body"] [data-test="footer"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-test="footer"] [data-test="save"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render the footer when no #footer slot', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
expect(wrapper.find('[data-test="footer"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies bodyClass to the body', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, bodyClass: 'px-10' })
|
||||||
|
expect(wrapper.find('[data-test="body"]').classes()).toContain('px-10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies footerClass to the footer', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{ modelValue: true, footerClass: 'justify-end' },
|
||||||
|
{ footer: '<span>pied</span>' },
|
||||||
|
)
|
||||||
|
expect(wrapper.find('[data-test="footer"]').classes()).toContain('justify-end')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue false and close on backdrop click (dismissable)', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
await wrapper.find('[data-test="backdrop"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not close on backdrop click when dismissable is false', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, dismissable: false })
|
||||||
|
await wrapper.find('[data-test="backdrop"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies overlayClass to the backdrop', () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, overlayClass: 'bg-black/70' })
|
||||||
|
expect(wrapper.find('[data-test="backdrop"]').classes()).toContain('bg-black/70')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes on Escape key when closeOnEscape is true', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true })
|
||||||
|
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not close on Escape when closeOnEscape is false', async () => {
|
||||||
|
const wrapper = mountComponent({ modelValue: true, closeOnEscape: false })
|
||||||
|
await wrapper.find('[data-test="panel"]').trigger('keydown', { key: 'Escape' })
|
||||||
|
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(ModalForTest, {
|
||||||
|
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(ModalForTest, {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wraps focus to the first element when Tab is pressed on the last element', async () => {
|
||||||
|
const wrapper = mount(ModalForTest, {
|
||||||
|
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(ModalForTest, {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not release body scroll-lock when one stacked modal closes while another is still open', async () => {
|
||||||
|
const wrapperA = mount(ModalForTest, {
|
||||||
|
props: { modelValue: false },
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
const wrapperB = mount(ModalForTest, {
|
||||||
|
props: { modelValue: false },
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { stubs: { Teleport: true } },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapperA.setProps({ modelValue: true })
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
|
||||||
|
await wrapperB.setProps({ modelValue: true })
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
|
||||||
|
await wrapperB.setProps({ modelValue: false })
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
|
||||||
|
await wrapperA.setProps({ modelValue: false })
|
||||||
|
expect(document.body.style.overflow).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
name="modal"
|
||||||
|
appear
|
||||||
|
@after-leave="isRendered = false"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isRendered && isOpen"
|
||||||
|
:id="componentId"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
v-bind="attrs"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="twMerge('absolute inset-0 bg-black/40', overlayClass)"
|
||||||
|
data-test="backdrop"
|
||||||
|
@click="onBackdropClick"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="panelRef"
|
||||||
|
:class="twMerge(
|
||||||
|
'relative z-50 flex max-h-[85vh] w-full max-w-md flex-col rounded-malio bg-white',
|
||||||
|
modalClass,
|
||||||
|
)"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-labelledby="hasHeader ? headerId : undefined"
|
||||||
|
:aria-label="hasHeader ? undefined : (ariaLabel || undefined)"
|
||||||
|
tabindex="-1"
|
||||||
|
data-test="panel"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="hasHeader || showClose"
|
||||||
|
:class="twMerge('flex shrink-0 items-center justify-between gap-4 px-5 py-[25px]', headerClass)"
|
||||||
|
data-test="header"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:id="headerId"
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
data-test="header-content"
|
||||||
|
>
|
||||||
|
<slot name="header" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="showClose"
|
||||||
|
type="button"
|
||||||
|
aria-label="Fermer"
|
||||||
|
class="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-m-surface"
|
||||||
|
data-test="close-button"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<IconifyIcon
|
||||||
|
icon="mdi:cancel-bold"
|
||||||
|
:width="16"
|
||||||
|
:height="16"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="twMerge('flex-1 overflow-y-auto px-5', bodyClass)"
|
||||||
|
data-test="body"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="$slots.footer"
|
||||||
|
:class="twMerge('flex shrink-0 items-center gap-3 border-t border-m-border px-5 py-4', footerClass)"
|
||||||
|
data-test="footer"
|
||||||
|
>
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
useAttrs,
|
||||||
|
useId,
|
||||||
|
useSlots,
|
||||||
|
watch,
|
||||||
|
} from 'vue'
|
||||||
|
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
defineOptions({ name: 'MalioModal', inheritAttrs: false })
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
modelValue?: boolean
|
||||||
|
showClose?: boolean
|
||||||
|
dismissable?: boolean
|
||||||
|
closeOnEscape?: boolean
|
||||||
|
ariaLabel?: string
|
||||||
|
modalClass?: string
|
||||||
|
overlayClass?: string
|
||||||
|
headerClass?: string
|
||||||
|
bodyClass?: string
|
||||||
|
footerClass?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
modelValue: undefined,
|
||||||
|
showClose: true,
|
||||||
|
dismissable: true,
|
||||||
|
closeOnEscape: true,
|
||||||
|
ariaLabel: '',
|
||||||
|
modalClass: '',
|
||||||
|
overlayClass: '',
|
||||||
|
headerClass: '',
|
||||||
|
bodyClass: '',
|
||||||
|
footerClass: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'close'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
|
const generatedId = useId()
|
||||||
|
|
||||||
|
const componentId = computed(() => props.id || `malio-modal-${generatedId}`)
|
||||||
|
|
||||||
|
const slots = useSlots()
|
||||||
|
const headerId = computed(() => `${componentId.value}-header`)
|
||||||
|
const hasHeader = computed(() => !!slots.header)
|
||||||
|
|
||||||
|
const isControlled = computed(() => props.modelValue !== undefined)
|
||||||
|
const localValue = ref(false)
|
||||||
|
const isOpen = computed(() =>
|
||||||
|
isControlled.value ? props.modelValue! : localValue.value,
|
||||||
|
)
|
||||||
|
const isRendered = ref(isOpen.value)
|
||||||
|
|
||||||
|
const panelRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
let previouslyFocused: HTMLElement | null = null
|
||||||
|
// Per-instance flag: true while this modal 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"]), [contenteditable]:not([contenteditable="false"])',
|
||||||
|
),
|
||||||
|
).filter((el) => el.tabIndex !== -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpen() {
|
||||||
|
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null
|
||||||
|
if (!lockedByThisInstance) {
|
||||||
|
lockedByThisInstance = true
|
||||||
|
openModalCount++
|
||||||
|
if (openModalCount === 1) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
const panel = panelRef.value
|
||||||
|
if (!panel) return
|
||||||
|
const focusable = getFocusable(panel)
|
||||||
|
;(focusable[0] ?? panel).focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
if (lockedByThisInstance) {
|
||||||
|
lockedByThisInstance = false
|
||||||
|
openModalCount = Math.max(0, openModalCount - 1)
|
||||||
|
if (openModalCount === 0) {
|
||||||
|
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(() => {
|
||||||
|
// If this instance is still holding a scroll-lock slot, release it.
|
||||||
|
if (lockedByThisInstance) {
|
||||||
|
lockedByThisInstance = false
|
||||||
|
openModalCount = Math.max(0, openModalCount - 1)
|
||||||
|
if (openModalCount === 0) {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onBackdropClick() {
|
||||||
|
if (props.dismissable) close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && props.closeOnEscape) {
|
||||||
|
e.stopPropagation()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!isControlled.value) localValue.value = false
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// Shared across all MalioModal instances: only the last open modal releases the body scroll-lock.
|
||||||
|
let openModalCount = 0
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-enter-active,
|
||||||
|
.modal-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-active > div:last-child,
|
||||||
|
.modal-leave-active > div:last-child {
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from,
|
||||||
|
.modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-enter-from > div:last-child,
|
||||||
|
.modal-leave-to > div:last-child {
|
||||||
|
transform: scale(0.95);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user