Files
Central/frontend/components/ui/AppModal.vue
tristan 157d7c96b9 style : use modal component for forms based on Lesstime pattern
- Create reusable AppModal component (Teleport, backdrop blur, transitions)
- Replace inline forms with modals on list and detail pages
- Consistent with Lesstime TaskModal design (header, body scroll, footer)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:02:51 +02:00

129 lines
4.0 KiB
Vue

<template>
<Teleport v-if="isOpen" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="close"
/>
<!-- Modal -->
<div
class="relative z-10 flex w-full flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
:class="maxWidthClass"
style="max-height: min(90vh, 900px)"
>
<!-- Header -->
<div class="border-b border-neutral-100 bg-neutral-50/80 px-4 py-4 sm:px-8 sm:py-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
<slot name="title" />
</h2>
<button
class="rounded-md p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
@click="close"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
</div>
<!-- Body -->
<div class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
<slot />
</div>
<!-- Footer -->
<div class="border-t border-neutral-100 px-4 py-4 sm:px-8">
<div class="flex justify-end gap-3">
<slot name="footer">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="close"
>
{{ cancelLabel }}
</button>
<MalioButton
:loading="loading"
@click="$emit('submit')"
>
{{ submitLabel }}
</MalioButton>
</slot>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
modelValue: boolean
submitLabel?: string
cancelLabel?: string
loading?: boolean
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'
}>(), {
submitLabel: 'Enregistrer',
cancelLabel: 'Annuler',
loading: false,
maxWidth: '2xl',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'submit'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
function close() {
isOpen.value = false
}
const maxWidthClass = computed(() => {
const map = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
}
return map[props.maxWidth]
})
</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 cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from > div:last-child {
transform: scale(0.95) translateY(8px);
opacity: 0;
}
.modal-leave-to > div:last-child {
transform: scale(0.97);
opacity: 0;
}
</style>