✨ feat(frontend): composant NarratorBubble avec 5 stages du Bug
Story 3.2 : Implémentation du narrateur-guide "Le Bug" - Composant NarratorBubble.vue avec effet typewriter - 5 SVG représentant l'évolution de la mascotte (silhouette à révélation) - Animation slide-up/fade-out avec prefers-reduced-motion - Support clavier (Espace/Entrée pour skip, Échap pour fermer) - Accessibilité (aria-live, role="status", sr-only) - Responsive (position adaptée mobile avec bottom-bar) - Traductions narrator.clickToSkip et narrator.bugAlt Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
182
frontend/app/components/feature/NarratorBubble.vue
Normal file
182
frontend/app/components/feature/NarratorBubble.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
message: string
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
skip: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const progressionStore = useProgressionStore()
|
||||
|
||||
const messageRef = computed(() => props.message)
|
||||
const { displayedText, isTyping, skip, start } = useTypewriter(messageRef, {
|
||||
speed: 40,
|
||||
})
|
||||
|
||||
const bugImages: Record<number, string> = {
|
||||
1: '/images/bug/bug-stage-1.svg',
|
||||
2: '/images/bug/bug-stage-2.svg',
|
||||
3: '/images/bug/bug-stage-3.svg',
|
||||
4: '/images/bug/bug-stage-4.svg',
|
||||
5: '/images/bug/bug-stage-5.svg',
|
||||
}
|
||||
|
||||
const currentBugImage = computed(() => {
|
||||
return bugImages[progressionStore.narratorStage] || bugImages[1]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVisible) => {
|
||||
if (newVisible && props.message) {
|
||||
start()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.message,
|
||||
(newMessage) => {
|
||||
if (newMessage && props.visible) {
|
||||
start()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function handleInteraction() {
|
||||
if (isTyping.value) {
|
||||
skip()
|
||||
emit('skip')
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.code === 'Space' || e.code === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleInteraction()
|
||||
}
|
||||
if (e.code === 'Escape') {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="narrator-slide">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="narrator-bubble fixed bottom-4 left-4 right-4 md:left-auto md:right-8 md:max-w-md z-50"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
tabindex="0"
|
||||
@click="handleInteraction"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div
|
||||
class="flex items-start gap-4 bg-sky-dark-50 rounded-xl p-4 shadow-xl border border-sky-dark-100"
|
||||
>
|
||||
<div class="shrink-0 w-16 h-16 md:w-20 md:h-20">
|
||||
<img
|
||||
:src="currentBugImage"
|
||||
:alt="t('narrator.bugAlt', { stage: progressionStore.narratorStage })"
|
||||
class="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-narrative text-sky-text text-base md:text-lg leading-relaxed">
|
||||
{{ displayedText }}
|
||||
<span
|
||||
v-if="isTyping"
|
||||
class="inline-block w-0.5 h-5 bg-sky-accent animate-blink ml-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<span class="sr-only">{{ message }}</span>
|
||||
|
||||
<p v-if="isTyping" class="text-xs text-sky-text-muted mt-2 font-ui">
|
||||
{{ t('narrator.clickToSkip') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 p-1 text-sky-text-muted hover:text-sky-text transition-colors"
|
||||
:aria-label="t('common.close')"
|
||||
@click.stop="emit('close')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.narrator-slide-enter-active,
|
||||
.narrator-slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.narrator-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.narrator-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
51%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-blink {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.narrator-bubble {
|
||||
bottom: calc(var(--bottom-bar-height, 64px) + 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.narrator-slide-enter-active,
|
||||
.narrator-slide-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.narrator-slide-enter-from,
|
||||
.narrator-slide-leave-to {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.animate-blink {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user