| 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>
294 lines
7.2 KiB
Vue
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>
|