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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
export interface Chip {
|
||||
label: string;
|
||||
onRemove: () => void;
|
||||
@@ -8,27 +12,41 @@ interface FilterChipsProps {
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
const chipVariants = {
|
||||
initial: { opacity: 0, scale: 0.8 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.8 },
|
||||
};
|
||||
|
||||
export function FilterChips({ chips, onClearAll }: FilterChipsProps) {
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{chips.map((chip) => (
|
||||
<span
|
||||
key={chip.label}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-brand-50 text-brand-700 border border-brand-200 px-2.5 py-0.5 text-xs"
|
||||
>
|
||||
{chip.label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={chip.onRemove}
|
||||
className="ml-0.5 hover:text-brand-900 transition-colors"
|
||||
aria-label={`Remove filter: ${chip.label}`}
|
||||
<AnimatePresence mode="popLayout">
|
||||
{chips.map((chip) => (
|
||||
<motion.span
|
||||
key={chip.label}
|
||||
variants={chipVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
layout
|
||||
className="inline-flex items-center gap-1 rounded-full bg-brand-50 text-brand-700 border border-brand-200 px-2.5 py-0.5 text-xs"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{chip.label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={chip.onRemove}
|
||||
className="ml-0.5 hover:text-brand-900 transition-colors"
|
||||
aria-label={`Remove filter: ${chip.label}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</motion.span>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface SuccessToastProps {
|
||||
show: boolean;
|
||||
message: string;
|
||||
onDone?: () => void;
|
||||
variant?: "success" | "info" | "warning";
|
||||
}
|
||||
|
||||
const VARIANT_STYLES = {
|
||||
success: {
|
||||
bg: "bg-emerald-50 dark:bg-emerald-950/80 border-emerald-200 dark:border-emerald-800",
|
||||
text: "text-emerald-800 dark:text-emerald-200",
|
||||
icon: (
|
||||
<svg className="h-4 w-4 text-emerald-500 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
info: {
|
||||
bg: "bg-blue-50 dark:bg-blue-950/80 border-blue-200 dark:border-blue-800",
|
||||
text: "text-blue-800 dark:text-blue-200",
|
||||
icon: (
|
||||
<svg className="h-4 w-4 text-blue-500 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<circle cx="12" cy="12" r="10" strokeWidth={2} />
|
||||
<path strokeLinecap="round" d="M12 16v-4M12 8h.01" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
warning: {
|
||||
bg: "bg-amber-50 dark:bg-amber-950/80 border-amber-200 dark:border-amber-800",
|
||||
text: "text-amber-800 dark:text-amber-200",
|
||||
icon: (
|
||||
<svg className="h-4 w-4 text-amber-500 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function SuccessToast({ show, message, onDone, variant = "success" }: SuccessToastProps) {
|
||||
const style = VARIANT_STYLES[variant];
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
const timer = setTimeout(() => {
|
||||
onDone?.();
|
||||
}, 2500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [show, onDone]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
initial={{ y: -40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -40, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
className="fixed top-4 left-1/2 z-[9999] -translate-x-1/2"
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-full border px-4 py-2 shadow-lg backdrop-blur-sm ${style.bg}`}
|
||||
>
|
||||
{style.icon}
|
||||
<span className={`text-sm font-medium ${style.text}`}>{message}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user