Add Confetti Animation for Newly Added Tabs in AreaTabs Component
- Integrated the 'canvas-confetti' library to enhance user experience by triggering confetti animations when new tabs are added. - Implemented logic to track newly added areas and animate their appearance with a highlight effect. - Updated the AreaTabs.vue component to include transition animations for tab entries and exits, improving visual feedback during navigation. - Enhanced styling for newly added tabs to draw attention and improve user engagement.
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
import { Wrench, FlaskConical, Ticket, Sparkles, GraduationCap, Home } from 'lucide-vue-next'
|
import { Wrench, FlaskConical, Ticket, Sparkles, GraduationCap, Home } from 'lucide-vue-next'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import confetti from 'canvas-confetti'
|
||||||
|
|
||||||
type RoleCode = 'private' | 'educator' | 'company'
|
type RoleCode = 'private' | 'educator' | 'company'
|
||||||
|
|
||||||
@@ -104,11 +105,77 @@ const currentArea = computed(() => {
|
|||||||
return matchedArea?.id || ''
|
return matchedArea?.id || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Track previous area IDs for animation detection
|
||||||
|
const previousAreaIds = ref<string[]>([])
|
||||||
|
const newlyAddedAreaId = ref<string | null>(null)
|
||||||
|
const tabRefs = ref<Record<string, HTMLElement>>({})
|
||||||
|
|
||||||
|
// Watch for changes in visible areas to trigger animations
|
||||||
|
watch(visibleAreas, (newAreas, oldAreas) => {
|
||||||
|
const newAreaIds = newAreas.map(a => a.id)
|
||||||
|
const oldAreaIds = oldAreas?.map(a => a.id) || []
|
||||||
|
|
||||||
|
// Find newly added areas
|
||||||
|
const addedIds = newAreaIds.filter(id => !oldAreaIds.includes(id))
|
||||||
|
|
||||||
|
if (addedIds.length > 0) {
|
||||||
|
// Mark as newly added for highlight animation
|
||||||
|
newlyAddedAreaId.value = addedIds[0]
|
||||||
|
|
||||||
|
// Trigger confetti after a small delay (so element is rendered)
|
||||||
|
setTimeout(() => {
|
||||||
|
const areaId = addedIds[0]
|
||||||
|
const element = tabRefs.value[areaId]
|
||||||
|
if (element) {
|
||||||
|
triggerConfetti(element)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
// Clear highlight after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
newlyAddedAreaId.value = null
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
previousAreaIds.value = newAreaIds
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Initialize previous area IDs
|
||||||
|
onMounted(() => {
|
||||||
|
previousAreaIds.value = visibleAreas.value.map(a => a.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Confetti effect from element position
|
||||||
|
function triggerConfetti(element: HTMLElement) {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
const x = (rect.left + rect.width / 2) / window.innerWidth
|
||||||
|
const y = (rect.top + rect.height / 2) / window.innerHeight
|
||||||
|
|
||||||
|
// Confetti burst from tab position
|
||||||
|
confetti({
|
||||||
|
particleCount: 25,
|
||||||
|
spread: 50,
|
||||||
|
origin: { x, y },
|
||||||
|
colors: ['#E91E85', '#FF6B9D', '#C77DFF', '#9D4EDD'],
|
||||||
|
ticks: 200,
|
||||||
|
gravity: 1.2,
|
||||||
|
scalar: 0.8,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function navigateToArea(area: ProductArea) {
|
function navigateToArea(area: ProductArea) {
|
||||||
if (area.enabled) {
|
if (area.enabled) {
|
||||||
navigateTo(area.route)
|
navigateTo(area.route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setTabRef(areaId: string, el: any) {
|
||||||
|
if (el) {
|
||||||
|
// Extract the actual DOM element from Vue component instance
|
||||||
|
const domElement = el.$el || el
|
||||||
|
tabRefs.value[areaId] = domElement
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -116,46 +183,53 @@ function navigateToArea(area: ProductArea) {
|
|||||||
<!-- Desktop: Tabs -->
|
<!-- Desktop: Tabs -->
|
||||||
<Tabs :model-value="currentArea" class="hidden md:block">
|
<Tabs :model-value="currentArea" class="hidden md:block">
|
||||||
<TabsList class="h-auto p-2 bg-white/5">
|
<TabsList class="h-auto p-2 bg-white/5">
|
||||||
<TabsTrigger v-for="area in visibleAreas" :key="area.id" :value="area.id"
|
<TransitionGroup name="tab" tag="div" class="flex items-center gap-1">
|
||||||
:disabled="!area.enabled" :class="[
|
<TabsTrigger v-for="area in visibleAreas" :key="area.id" :ref="(el) => setTabRef(area.id, el)"
|
||||||
'gap-2 py-3 md:py-4 data-[state=active]:bg-accent data-[state=active]:text-white data-[state=active]:shadow-md',
|
:value="area.id" :disabled="!area.enabled" :class="[
|
||||||
!area.enabled && 'opacity-50 cursor-not-allowed',
|
'gap-2 py-3 md:py-4 data-[state=active]:bg-accent data-[state=active]:text-white data-[state=active]:shadow-md transition-all duration-300',
|
||||||
]" @click="navigateToArea(area)">
|
!area.enabled && 'opacity-50 cursor-not-allowed',
|
||||||
<component :is="area.icon" class="h-4 w-4" />
|
newlyAddedAreaId === area.id && 'tab-highlight',
|
||||||
<span>{{ area.label }}</span>
|
]" @click="navigateToArea(area)">
|
||||||
<Badge v-if="area.badge" :class="[
|
<component :is="area.icon" class="h-4 w-4" />
|
||||||
'ml-1 text-[10px] px-1.5 py-0.5 pointer-events-none rounded-[35px]',
|
<span>{{ area.label }}</span>
|
||||||
currentArea === area.id
|
<Badge v-if="area.badge" :class="[
|
||||||
? 'bg-white/90 text-purple-950 border-white/50'
|
'ml-1 text-[10px] px-1.5 py-0.5 pointer-events-none rounded-[35px]',
|
||||||
: 'bg-experimenta-accent/20 text-experimenta-accent border-experimenta-accent/30'
|
currentArea === area.id
|
||||||
]">
|
? 'bg-white/90 text-purple-950 border-white/50'
|
||||||
{{ area.badge }}
|
: 'bg-experimenta-accent/20 text-experimenta-accent border-experimenta-accent/30'
|
||||||
</Badge>
|
]">
|
||||||
</TabsTrigger>
|
{{ area.badge }}
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TransitionGroup>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<!-- Mobile: Horizontal scroll with cards (matching desktop styling) -->
|
<!-- Mobile: Horizontal scroll with cards (matching desktop styling) -->
|
||||||
<div class="md:hidden overflow-x-auto scrollbar-hide">
|
<div class="md:hidden overflow-x-auto scrollbar-hide">
|
||||||
<div class="inline-flex h-auto items-center justify-center rounded-[35px] bg-white/5 p-2 min-w-max">
|
<div class="inline-flex h-auto items-center justify-center rounded-[35px] bg-white/5 p-2 min-w-max">
|
||||||
<button v-for="area in visibleAreas" :key="area.id" :disabled="!area.enabled" :class="[
|
<TransitionGroup name="tab" tag="div" class="inline-flex items-center gap-2">
|
||||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[25px] px-4 py-3 text-lg font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-0',
|
<button v-for="area in visibleAreas" :key="area.id" :ref="(el) => setTabRef(area.id, el)"
|
||||||
currentArea === area.id
|
:disabled="!area.enabled" :class="[
|
||||||
? 'bg-accent text-white shadow-md'
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[25px] px-4 py-3 text-lg font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-0 duration-300',
|
||||||
: 'text-white/70 hover:text-white',
|
currentArea === area.id
|
||||||
!area.enabled && 'opacity-50 cursor-not-allowed',
|
? 'bg-accent text-white shadow-md'
|
||||||
]" @click="navigateToArea(area)">
|
: 'text-white/70 hover:text-white',
|
||||||
<component :is="area.icon" class="h-4 w-4" />
|
!area.enabled && 'opacity-50 cursor-not-allowed',
|
||||||
<span>{{ area.label }}</span>
|
newlyAddedAreaId === area.id && 'tab-highlight',
|
||||||
<Badge v-if="area.badge" :class="[
|
]" @click="navigateToArea(area)">
|
||||||
'ml-1 text-[10px] px-1.5 py-0.5 pointer-events-none rounded-[35px]',
|
<component :is="area.icon" class="h-4 w-4" />
|
||||||
currentArea === area.id
|
<span>{{ area.label }}</span>
|
||||||
? 'bg-white/90 text-purple-950 border-white/50'
|
<Badge v-if="area.badge" :class="[
|
||||||
: 'bg-experimenta-accent/20 text-experimenta-accent border-experimenta-accent/30'
|
'ml-1 text-[10px] px-1.5 py-0.5 pointer-events-none rounded-[35px]',
|
||||||
]">
|
currentArea === area.id
|
||||||
{{ area.badge }}
|
? 'bg-white/90 text-purple-950 border-white/50'
|
||||||
</Badge>
|
: 'bg-experimenta-accent/20 text-experimenta-accent border-experimenta-accent/30'
|
||||||
</button>
|
]">
|
||||||
|
{{ area.badge }}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,4 +245,60 @@ function navigateToArea(area: ProductArea) {
|
|||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Vue TransitionGroup animations for tabs */
|
||||||
|
.tab-enter-active {
|
||||||
|
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-leave-active {
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px) scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-10px) scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-move {
|
||||||
|
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlight animation for newly added tabs */
|
||||||
|
@keyframes highlight-glow {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(233, 30, 133, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px 4px rgba(233, 30, 133, 0.6),
|
||||||
|
0 0 40px 8px rgba(201, 125, 255, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes highlight-pulse {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-highlight {
|
||||||
|
animation: highlight-glow 1s ease-in-out 2,
|
||||||
|
highlight-pulse 0.5s ease-in-out 2;
|
||||||
|
background: linear-gradient(135deg, rgba(233, 30, 133, 0.2), rgba(201, 125, 255, 0.2)) !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"@nuxtjs/i18n": "^10.1.2",
|
"@nuxtjs/i18n": "^10.1.2",
|
||||||
"@vee-validate/zod": "^4.15.1",
|
"@vee-validate/zod": "^4.15.1",
|
||||||
"@vueuse/core": "^14.0.0",
|
"@vueuse/core": "^14.0.0",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
|||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^14.0.0
|
specifier: ^14.0.0
|
||||||
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
|
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
|
||||||
|
canvas-confetti:
|
||||||
|
specifier: ^1.9.4
|
||||||
|
version: 1.9.4
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -2194,6 +2197,9 @@ packages:
|
|||||||
caniuse-lite@1.0.30001751:
|
caniuse-lite@1.0.30001751:
|
||||||
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
|
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
|
||||||
|
|
||||||
|
canvas-confetti@1.9.4:
|
||||||
|
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
|
||||||
|
|
||||||
case-anything@3.1.2:
|
case-anything@3.1.2:
|
||||||
resolution: {integrity: sha512-wljhAjDDIv/hM2FzgJnYQg90AWmZMNtESCjTeLH680qTzdo0nErlCxOmgzgX4ZsZAtIvqHyD87ES8QyriXB+BQ==}
|
resolution: {integrity: sha512-wljhAjDDIv/hM2FzgJnYQg90AWmZMNtESCjTeLH680qTzdo0nErlCxOmgzgX4ZsZAtIvqHyD87ES8QyriXB+BQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -7482,6 +7488,8 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001751: {}
|
caniuse-lite@1.0.30001751: {}
|
||||||
|
|
||||||
|
canvas-confetti@1.9.4: {}
|
||||||
|
|
||||||
case-anything@3.1.2: {}
|
case-anything@3.1.2: {}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
|
|||||||
Reference in New Issue
Block a user