Créer et publier une librairie Vue 3 — composant de patch notes avec mode Vue, composable, mode standalone CDN, personnalisation CSS et publication npm.
Le composant principal. Il gère l'affichage de la modal, les transitions, la personnalisation CSS via custom properties, et expose une API impérative via defineExpose.
import { ref, computed, onMounted, watch } from 'vue'
import type { PatchNotesConfig, CSSCustomization } from '../types/settings'
interface Props {
projectId: string
version: string
patchnotes: PatchNotesConfig
title?: string
closeButtonText?: string
forceShow?: boolean
manual?: boolean
cssCustomization?: CSSCustomization
}
const props = withDefaults(defineProps<Props>(), {
title: '🎉 Nouveautés',
closeButtonText: 'Compris !',
forceShow: false,
manual: false
})
const emit = defineEmits<{
(e: 'close'): void
(e: 'shown'): void
}>()manual permet de désactiver l'affichage automatique au montage — utile quand on veut contrôler le moment d'affichage depuis le parent.
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="isVisible"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click.self="handleClose"
>
<div class="absolute inset-0 bg-slate-900/30 backdrop-blur-[2px]"></div>
<div
class="relative w-full max-w-xl max-h-[80vh] rounded-lg shadow-xl overflow-hidden border op-modal"
:style="customStyles"
>
<!-- Header sticky -->
<div class="sticky top-0 z-10 border-b px-6 py-4 op-header">
<h2 class="text-lg font-semibold op-title">{{ title }}</h2>
<p class="text-xs mt-0.5 op-version">Version {{ version }}</p>
</div>
<!-- Contenu scrollable -->
<div class="overflow-y-auto max-h-[calc(80vh-140px)] px-6 py-5">
<div v-for="(section, i) in patchnotes.sections" :key="i">
<span class="... op-section-tag">{{ section.title }}</span>
<ul>
<li
v-for="(item, j) in section.items"
:key="j"
class="op-item"
>
{{ item }}
</li>
</ul>
</div>
</div>
<!-- Footer sticky -->
<div class="sticky bottom-0 border-t px-6 py-4 op-footer">
<button @click="handleClose" class="w-full op-button">
{{ closeButtonText }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template><Teleport to="body"> déplace la modal en dehors du DOM de l'application — elle s'affiche au-dessus de tout sans dépendre du z-index du parent. @click.self sur l'overlay ferme la modal sans propager vers le contenu.
La personnalisation ne passe pas par des classes Tailwind dynamiques — trop complexe à purger. On utilise des CSS custom properties injectées en inline style :
const customStyles = computed(() => {
if (!props.cssCustomization) return {}
const styles: Record<string, string> = {}
if (props.cssCustomization.primaryColor)
styles['--op-primary-color'] = props.cssCustomization.primaryColor
if (props.cssCustomization.backgroundColor)
styles['--op-bg-color'] = props.cssCustomization.backgroundColor
// ... autres propriétés
return styles
})Les valeurs par défaut sont définies dans le CSS scoped :
.op-modal {
--op-bg-color: #ffffff;
--op-text-color: #334155;
--op-primary-color: #0f172a;
--op-border-color: #e2e8f0;
--op-button-bg-color: #0f172a;
--op-button-text-color: #ffffff;
}.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-active > div:last-child,
.modal-leave-active > div:last-child {
transition: transform 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from > div:last-child,
.modal-leave-to > div:last-child {
transform: scale(0.95) translateY(-20px);
}L'overlay fade in/out, la card scale + slide simultanément. Deux éléments, deux animations indépendantes dans la même transition.
const show = () => { isVisible.value = true; emit('shown') }
const hide = () => { isVisible.value = false }
const reset = () => { localStorage.removeItem(getStorageKey()) }
defineExpose({ show, hide, reset, isVisible })defineExpose rend ces méthodes accessibles via templateRef.value.show() depuis le parent, et via instance.show() depuis le mode standalone.