feat(10-04): implement toast store, ToastContainer, mount in App.vue
- toast.js: reactive toasts array, show/dismiss with auto-dismiss via setTimeout - ToastContainer.vue: Options API, Teleport to body, TransitionGroup, data-test attr - type-to-class maps: accentClass/iconName/iconColorClass for success/error/warning/info - App.vue: import and mount <ToastContainer /> after layout div - all 10 tests pass; SettingsAccountTab and TotpEnrollment regressions green
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<ToastContainer />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -13,6 +14,7 @@ import { onMounted } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import AppSidebar from './components/layout/AppSidebar.vue'
|
import AppSidebar from './components/layout/AppSidebar.vue'
|
||||||
import AuthLayout from './layouts/AuthLayout.vue'
|
import AuthLayout from './layouts/AuthLayout.vue'
|
||||||
|
import ToastContainer from './components/ui/ToastContainer.vue'
|
||||||
import { useTopicsStore } from './stores/topics.js'
|
import { useTopicsStore } from './stores/topics.js'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="fixed bottom-4 right-4 z-[9999] flex flex-col-reverse gap-2 pointer-events-none">
|
||||||
|
<TransitionGroup name="toast">
|
||||||
|
<div
|
||||||
|
v-for="toast in toastStore.toasts"
|
||||||
|
:key="toast.id"
|
||||||
|
data-test="toast"
|
||||||
|
class="pointer-events-auto flex items-center gap-3 bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden max-w-sm min-w-[280px] cursor-pointer"
|
||||||
|
@click="toastStore.dismiss(toast.id)"
|
||||||
|
>
|
||||||
|
<div class="w-1 self-stretch shrink-0" :class="accentClass(toast.type)"></div>
|
||||||
|
<AppIcon :name="iconName(toast.type)" class="w-5 h-5 shrink-0" :class="iconColorClass(toast.type)" />
|
||||||
|
<p class="text-sm text-gray-800 flex-1 py-3 pr-4">{{ toast.message }}</p>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import AppIcon from './AppIcon.vue'
|
||||||
|
import { useToastStore } from '../../stores/toast.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ToastContainer',
|
||||||
|
components: { AppIcon },
|
||||||
|
data() {
|
||||||
|
return { toastStore: useToastStore() }
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
accentClass(type) {
|
||||||
|
const map = {
|
||||||
|
success: 'bg-green-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
warning: 'bg-amber-400',
|
||||||
|
info: 'bg-sky-400',
|
||||||
|
}
|
||||||
|
return map[type] ?? 'bg-gray-400'
|
||||||
|
},
|
||||||
|
iconName(type) {
|
||||||
|
const map = {
|
||||||
|
success: 'checkCircle',
|
||||||
|
error: 'exclamationCircle',
|
||||||
|
warning: 'warning',
|
||||||
|
info: 'exclamationCircle',
|
||||||
|
}
|
||||||
|
return map[type] ?? 'exclamationCircle'
|
||||||
|
},
|
||||||
|
iconColorClass(type) {
|
||||||
|
const map = {
|
||||||
|
success: 'text-green-500',
|
||||||
|
error: 'text-red-500',
|
||||||
|
warning: 'text-amber-500',
|
||||||
|
info: 'text-sky-500',
|
||||||
|
}
|
||||||
|
return map[type] ?? 'text-gray-500'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toast-enter-active,
|
||||||
|
.toast-leave-active {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.toast-enter-from,
|
||||||
|
.toast-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
/**
|
import { ref } from 'vue'
|
||||||
* Phase 7.1 STUB — Phase 10 (UX-10) fills in the full implementation.
|
|
||||||
*
|
|
||||||
* The signature `show(message, type, duration)` is locked and Phase 10 must
|
|
||||||
* honor it without modifying Phase 8 call sites.
|
|
||||||
*
|
|
||||||
* Default values match UI-SPEC.md:
|
|
||||||
* type = 'success' (NOT 'info')
|
|
||||||
* duration = 4000
|
|
||||||
*/
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
export const useToastStore = defineStore('toast', () => {
|
export const useToastStore = defineStore('toast', () => {
|
||||||
// eslint-disable-next-line no-unused-vars
|
const toasts = ref([])
|
||||||
|
|
||||||
function show(message, type = 'success', duration = 4000) {
|
function show(message, type = 'success', duration = 4000) {
|
||||||
// No-op stub — Phase 10 implements rendering.
|
const id = Date.now() + Math.random()
|
||||||
|
toasts.value.push({ id, message, type, duration })
|
||||||
|
if (duration > 0) setTimeout(() => dismiss(id), duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { show }
|
function dismiss(id) {
|
||||||
|
toasts.value = toasts.value.filter(t => t.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { toasts, show, dismiss }
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user