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>
This commit is contained in:
128
frontend/components/ui/AppModal.vue
Normal file
128
frontend/components/ui/AppModal.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user