Files
malio-layer-ui/app/components/malio/drawer/Drawer.vue
tristan f3e298e03b [#MUI-35] Refonte du composant drawer (#49)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #49
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-21 15:17:58 +00:00

294 lines
7.2 KiB
Vue

<template>
<Teleport to="body">
<Transition
:name="`drawer-${side}`"
appear
@after-leave="isRendered = false"
>
<div
v-if="isRendered && isOpen"
:id="componentId"
class="fixed inset-0 z-50 flex"
:class="side === 'right' ? 'justify-end' : 'justify-start'"
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 h-full w-full max-w-md flex-col bg-white',
drawerClass,
)"
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 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
v-if="$slots.footer"
:class="footerClass"
data-test="footer"
>
<slot name="footer" />
</div>
</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: 'MalioDrawer', inheritAttrs: false })
const props = withDefaults(
defineProps<{
id?: string
modelValue?: boolean
side?: 'right' | 'left'
showClose?: boolean
dismissable?: boolean
closeOnEscape?: boolean
ariaLabel?: string
drawerClass?: string
overlayClass?: string
headerClass?: string
bodyClass?: string
footerClass?: string
}>(),
{
id: '',
modelValue: undefined,
side: 'right',
showClose: true,
dismissable: true,
closeOnEscape: true,
ariaLabel: '',
drawerClass: '',
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-drawer-${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 drawer 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
openDrawerCount++
if (openDrawerCount === 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
openDrawerCount = Math.max(0, openDrawerCount - 1)
if (openDrawerCount === 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
openDrawerCount = Math.max(0, openDrawerCount - 1)
if (openDrawerCount === 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 MalioDrawer instances: only the last open drawer releases the body scroll-lock.
let openDrawerCount = 0
</script>
<style scoped>
.drawer-right-enter-active,
.drawer-right-leave-active,
.drawer-left-enter-active,
.drawer-left-leave-active {
transition: opacity 0.2s ease;
}
.drawer-right-enter-active > div:last-child,
.drawer-right-leave-active > div:last-child,
.drawer-left-enter-active > div:last-child,
.drawer-left-leave-active > div:last-child {
transition: transform 0.3s ease;
}
.drawer-right-enter-from,
.drawer-right-leave-to,
.drawer-left-enter-from,
.drawer-left-leave-to {
opacity: 0;
}
.drawer-right-enter-from > div:last-child,
.drawer-right-leave-to > div:last-child {
transform: translateX(100%);
}
.drawer-left-enter-from > div:last-child,
.drawer-left-leave-to > div:last-child {
transform: translateX(-100%);
}
</style>