feat: Sprint 3 — delight, polish, and responsive sidebar
Celebration micro-interactions: - SuccessToast: auto-dismissing pill toast (success/info/warning variants) - ConfettiBurst: pure CSS 20-particle confetti on project creation - Project wizard: confetti + toast on successful creation - Vacation approval/rejection: contextual toasts - Allocation status change: success toast - Button: active:scale-[0.97] press feedback on all variants Collapsible sidebar + responsive: - Desktop: toggle collapse (72px icons-only mode) with localStorage persistence - NavTooltip: hover labels on collapsed icons - Mobile: hamburger menu + slide-in overlay with backdrop - Auto-close sidebar on mobile navigation - Scroll-to-top on route change (smooth behavior) Hover polish + accessibility: - Table rows: animated left-border accent + hover-lift - Stat cards + widgets: hover elevation + border glow - Timeline blocks: scale(1.02) + shadow-md on hover - Smooth scroll globally with prefers-reduced-motion fallback - Filter chips: framer-motion scale+fade enter/exit - Dropdowns: scaleY origin-top reveal animation - Preferences modal: scale+fade entrance - Link underline: animated ::after width expansion on hover Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface ConfettiBurstProps {
|
||||
trigger: boolean;
|
||||
particleCount?: number;
|
||||
duration?: number;
|
||||
colors?: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
"#6366f1", // indigo
|
||||
"#8b5cf6", // violet
|
||||
"#ec4899", // pink
|
||||
"#f59e0b", // amber
|
||||
"#10b981", // emerald
|
||||
"#3b82f6", // blue
|
||||
];
|
||||
|
||||
const KEYFRAMES_ID = "confetti-burst-keyframes";
|
||||
|
||||
function ensureKeyframes() {
|
||||
if (typeof document === "undefined") return;
|
||||
if (document.getElementById(KEYFRAMES_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = KEYFRAMES_ID;
|
||||
style.textContent = `
|
||||
@keyframes confetti-burst {
|
||||
0% {
|
||||
transform: translate(0, 0) rotate(0deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(var(--confetti-x), var(--confetti-y)) rotate(var(--confetti-r)) scale(0.3);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
export function ConfettiBurst({
|
||||
trigger,
|
||||
particleCount = 20,
|
||||
duration = 800,
|
||||
colors = DEFAULT_COLORS,
|
||||
}: ConfettiBurstProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const prevTrigger = useRef(false);
|
||||
|
||||
const burst = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
ensureKeyframes();
|
||||
|
||||
const particles: HTMLDivElement[] = [];
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const el = document.createElement("div");
|
||||
const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.5;
|
||||
const distance = 40 + Math.random() * 80;
|
||||
const x = Math.cos(angle) * distance;
|
||||
const y = Math.sin(angle) * distance - 20 + Math.random() * 60; // gravity bias
|
||||
const rotation = Math.random() * 720 - 360;
|
||||
|
||||
el.style.cssText = `
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: -3px 0 0 -3px;
|
||||
border-radius: ${Math.random() > 0.5 ? "50%" : "1px"};
|
||||
background: ${colors[i % colors.length]};
|
||||
pointer-events: none;
|
||||
--confetti-x: ${x}px;
|
||||
--confetti-y: ${y}px;
|
||||
--confetti-r: ${rotation}deg;
|
||||
animation: confetti-burst ${duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
`;
|
||||
container.appendChild(el);
|
||||
particles.push(el);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
particles.forEach((el) => el.remove());
|
||||
}, duration + 50);
|
||||
}, [particleCount, duration, colors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trigger && !prevTrigger.current) {
|
||||
burst();
|
||||
}
|
||||
prevTrigger.current = trigger;
|
||||
}, [trigger, burst]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="pointer-events-none absolute inset-0 overflow-visible"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user