feat(web): add a reusable modal component
3b4d23a parent: 5028966 added
web/src/components/Modal.vue +54 -0 | new file mode 100644 | ||
| @@ -0,0 +1,54 @@ | ||
| 1 | +<script setup lang="ts"> | |
| 2 | +/** | |
| 3 | + * Une popup centrée sur un fond assombri : un clic à côté ou Échap la ferme. | |
| 4 | + * Avec le slot `nav`, une colonne grise à gauche (les rubriques) ; le slot par défaut défile sous l'en-tête. | |
| 5 | + */ | |
| 6 | +import { onMounted, onUnmounted } from "vue"; | |
| 7 | +import { useI18n } from "vue-i18n"; | |
| 8 | + | |
| 9 | +withDefaults(defineProps<{ title?: string; width?: string; height?: string }>(), { title: "", width: "1120px", height: "760px" }); | |
| 10 | +const emit = defineEmits<{ close: [] }>(); | |
| 11 | +const { t } = useI18n(); | |
| 12 | + | |
| 13 | +function onKey(e: KeyboardEvent) { | |
| 14 | + if (e.key === "Escape") emit("close"); | |
| 15 | +} | |
| 16 | +onMounted(() => window.addEventListener("keydown", onKey)); | |
| 17 | +onUnmounted(() => window.removeEventListener("keydown", onKey)); | |
| 18 | +</script> | |
| 19 | + | |
| 20 | +<template> | |
| 21 | + <div class="overlay" @click.self="emit('close')"> | |
| 22 | + <div class="modal" :class="{ 'with-nav': $slots.nav }" :style="{ maxWidth: width, height: `min(${height}, 100%)` }" role="dialog" aria-modal="true"> | |
| 23 | + <aside v-if="$slots.nav" class="nav"><slot name="nav" /></aside> | |
| 24 | + <div class="main"> | |
| 25 | + <div class="head"> | |
| 26 | + <span class="title">{{ title }}</span> | |
| 27 | + <slot name="head" /> | |
| 28 | + <span class="spacer" /> | |
| 29 | + <button class="close" @click="emit('close')">{{ t("common.close") }} ×</button> | |
| 30 | + </div> | |
| 31 | + <div class="body"><slot /></div> | |
| 32 | + </div> | |
| 33 | + </div> | |
| 34 | + </div> | |
| 35 | +</template> | |
| 36 | + | |
| 37 | +<style scoped> | |
| 38 | +.overlay { | |
| 39 | + position: fixed; inset: 0; z-index: 40; background: rgba(24, 25, 27, .32); | |
| 40 | + display: flex; align-items: center; justify-content: center; padding: 32px; | |
| 41 | +} | |
| 42 | +.modal { | |
| 43 | + width: 100%; background: #fff; border-radius: 6px; box-shadow: 0 24px 64px rgba(0, 0, 0, .22); | |
| 44 | + display: grid; grid-template-columns: minmax(0, 1fr); overflow: hidden; | |
| 45 | +} | |
| 46 | +.modal.with-nav { grid-template-columns: 220px minmax(0, 1fr); } | |
| 47 | +.nav { display: flex; flex-direction: column; gap: 2px; padding: 20px 12px; background: #F7F7F5; border-right: 1px solid var(--line); min-height: 0; } | |
| 48 | +.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; } | |
| 49 | +.head { display: flex; align-items: center; gap: 12px; padding: 16px 24px; border-bottom: 1px solid var(--line); } | |
| 50 | +.title { font-size: 15px; font-weight: 600; } | |
| 51 | +.close { border: none; background: none; padding: 0; font-size: 13px; color: var(--muted); cursor: pointer; } | |
| 52 | +.close:hover { color: var(--ink); } | |
| 53 | +.body { flex: 1; overflow: auto; padding: 20px 24px 28px; display: flex; flex-direction: column; gap: 18px; } | |
| 54 | +</style> | |
| new file mode 100644 | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | +<script setup lang="ts"> | ||
| 2 | +/** | ||
| 3 | + * Une popup centrée sur un fond assombri : un clic à côté ou Échap la ferme. | ||
| 4 | + * Avec le slot `nav`, une colonne grise à gauche (les rubriques) ; le slot par défaut défile sous l'en-tête. | ||
| 5 | + */ | ||
| 6 | +import { onMounted, onUnmounted } from "vue"; | ||
| 7 | +import { useI18n } from "vue-i18n"; | ||
| 8 | + | ||
| 9 | +withDefaults(defineProps<{ title?: string; width?: string; height?: string }>(), { title: "", width: "1120px", height: "760px" }); | ||
| 10 | +const emit = defineEmits<{ close: [] }>(); | ||
| 11 | +const { t } = useI18n(); | ||
| 12 | + | ||
| 13 | +function onKey(e: KeyboardEvent) { | ||
| 14 | + if (e.key === "Escape") emit("close"); | ||
| 15 | +} | ||
| 16 | +onMounted(() => window.addEventListener("keydown", onKey)); | ||
| 17 | +onUnmounted(() => window.removeEventListener("keydown", onKey)); | ||
| 18 | +</script> | ||
| 19 | + | ||
| 20 | +<template> | ||
| 21 | + <div class="overlay" @click.self="emit('close')"> | ||
| 22 | + <div class="modal" :class="{ 'with-nav': $slots.nav }" :style="{ maxWidth: width, height: `min(${height}, 100%)` }" role="dialog" aria-modal="true"> | ||
| 23 | + <aside v-if="$slots.nav" class="nav"><slot name="nav" /></aside> | ||
| 24 | + <div class="main"> | ||
| 25 | + <div class="head"> | ||
| 26 | + <span class="title">{{ title }}</span> | ||
| 27 | + <slot name="head" /> | ||
| 28 | + <span class="spacer" /> | ||
| 29 | + <button class="close" @click="emit('close')">{{ t("common.close") }} ×</button> | ||
| 30 | + </div> | ||
| 31 | + <div class="body"><slot /></div> | ||
| 32 | + </div> | ||
| 33 | + </div> | ||
| 34 | + </div> | ||
| 35 | +</template> | ||
| 36 | + | ||
| 37 | +<style scoped> | ||
| 38 | +.overlay { | ||
| 39 | + position: fixed; inset: 0; z-index: 40; background: rgba(24, 25, 27, .32); | ||
| 40 | + display: flex; align-items: center; justify-content: center; padding: 32px; | ||
| 41 | +} | ||
| 42 | +.modal { | ||
| 43 | + width: 100%; background: #fff; border-radius: 6px; box-shadow: 0 24px 64px rgba(0, 0, 0, .22); | ||
| 44 | + display: grid; grid-template-columns: minmax(0, 1fr); overflow: hidden; | ||
| 45 | +} | ||
| 46 | +.modal.with-nav { grid-template-columns: 220px minmax(0, 1fr); } | ||
| 47 | +.nav { display: flex; flex-direction: column; gap: 2px; padding: 20px 12px; background: #F7F7F5; border-right: 1px solid var(--line); min-height: 0; } | ||
| 48 | +.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; } | ||
| 49 | +.head { display: flex; align-items: center; gap: 12px; padding: 16px 24px; border-bottom: 1px solid var(--line); } | ||
| 50 | +.title { font-size: 15px; font-weight: 600; } | ||
| 51 | +.close { border: none; background: none; padding: 0; font-size: 13px; color: var(--muted); cursor: pointer; } | ||
| 52 | +.close:hover { color: var(--ink); } | ||
| 53 | +.body { flex: 1; overflow: auto; padding: 20px 24px 28px; display: flex; flex-direction: column; gap: 18px; } | ||
| 54 | +</style> | ||