perf(motion): 清理末批 P0 —— 移除循环动画 + 删除死代码
按用户决策收尾 Task #16,P0 硬违规 13 → 0: - 移除 3 处循环动画(均违反 CONTEXT.md「禁循环动画」) detail-hero 徽章 Sparkles 旋转 4s、滚动箭头浮动 2s、brand-visuals 半径脉冲 4s → motion.* 改静态元素,保留视觉语义但不再循环 - 删除死代码 src/lib/animations.tsx(923 行,全站零引用,含 10 处 P0 + spring variants) 同步删除 src/lib/animations.test.tsx - 级联修复:brand-visuals 因删 guard 冗余的 shouldReduceMotion 声明一并移除 验证: - 审计 P0 13 → 0,P1 163→156,P2 28→25(死代码移除导致分母下降) - tsc --noEmit 0 error;eslint 0 error - 视觉基线不变:时长/循环改动在 reducedMotion+animations disabled 下不改变最终像素
This commit is contained in:
@@ -83,12 +83,9 @@ export function DetailHero({
|
|||||||
borderColor: `${theme.accentColor}25`,
|
borderColor: `${theme.accentColor}25`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<motion.span
|
<span>
|
||||||
animate={{ rotate: [0, 360] }}
|
|
||||||
transition={{ duration: 4, repeat: Infinity, ease: 'linear' as const }}
|
|
||||||
>
|
|
||||||
<Sparkles className="w-3.5 h-3.5" />
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
</motion.span>
|
</span>
|
||||||
{badge || theme.badge}
|
{badge || theme.badge}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@@ -192,13 +189,11 @@ export function DetailHero({
|
|||||||
transition={{ delay: 1.5, duration: 0.3 }}
|
transition={{ delay: 1.5, duration: 0.3 }}
|
||||||
className="absolute bottom-8 left-1/2 -translate-x-1/2"
|
className="absolute bottom-8 left-1/2 -translate-x-1/2"
|
||||||
>
|
>
|
||||||
<motion.div
|
<div
|
||||||
animate={{ y: [0, 8, 0] }}
|
|
||||||
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' as const }}
|
|
||||||
className="text-text-muted cursor-pointer hover:text-text-secondary transition-colors"
|
className="text-text-muted cursor-pointer hover:text-text-secondary transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronDown className="w-6 h-6" />
|
<ChevronDown className="w-6 h-6" />
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
|||||||
|
|
||||||
// ===== 抽象几何装饰 - 用于各 section 背景 =====
|
// ===== 抽象几何装饰 - 用于各 section 背景 =====
|
||||||
export function GeometricDecoration({ variant = 'circles' }: { variant?: 'circles' | 'lines' | 'grid' | 'waves' }) {
|
export function GeometricDecoration({ variant = 'circles' }: { variant?: 'circles' | 'lines' | 'grid' | 'waves' }) {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
|
||||||
|
|
||||||
if (variant === 'circles') {
|
if (variant === 'circles') {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
|
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
|
||||||
@@ -14,14 +12,10 @@ export function GeometricDecoration({ variant = 'circles' }: { variant?: 'circle
|
|||||||
<circle cx="100" cy="100" r="80" fill="none" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="0.5" />
|
<circle cx="100" cy="100" r="80" fill="none" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="0.5" />
|
||||||
<circle cx="100" cy="100" r="60" fill="none" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="0.5" />
|
<circle cx="100" cy="100" r="60" fill="none" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="0.5" />
|
||||||
<circle cx="100" cy="100" r="40" fill="none" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="0.5" />
|
<circle cx="100" cy="100" r="40" fill="none" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="0.5" />
|
||||||
{!shouldReduceMotion && (
|
<circle
|
||||||
<motion.circle
|
cx="100" cy="100" r="20"
|
||||||
cx="100" cy="100" r="20"
|
fill="rgba(var(--color-brand-rgb, 196, 30, 58), 0.1)"
|
||||||
fill="rgba(var(--color-brand-rgb, 196, 30, 58), 0.1)"
|
/>
|
||||||
animate={{ r: [20, 35, 20], opacity: [0.1, 0.05, 0.1] }}
|
|
||||||
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</svg>
|
</svg>
|
||||||
<svg className="absolute bottom-[15%] left-[8%] w-48 h-48 opacity-[0.03]" viewBox="0 0 200 200">
|
<svg className="absolute bottom-[15%] left-[8%] w-48 h-48 opacity-[0.03]" viewBox="0 0 200 200">
|
||||||
<rect x="40" y="40" width="120" height="120" rx="20" fill="none" stroke="currentColor" className="text-[var(--color-text-subtle)]" strokeWidth="0.5" transform="rotate(15 100 100)" />
|
<rect x="40" y="40" width="120" height="120" rx="20" fill="none" stroke="currentColor" className="text-[var(--color-text-subtle)]" strokeWidth="0.5" transform="rotate(15 100 100)" />
|
||||||
|
|||||||
@@ -1,841 +0,0 @@
|
|||||||
import { describe, it, expect, jest } from '@jest/globals';
|
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
|
|
||||||
jest.mock('framer-motion', () => ({
|
|
||||||
motion: {
|
|
||||||
div: ({ children, initial, animate, variants, className, whileHover, whileTap, transition, ...props }: any) => (
|
|
||||||
<div
|
|
||||||
data-testid="motion-div"
|
|
||||||
data-initial={JSON.stringify(initial)}
|
|
||||||
data-animate={JSON.stringify(animate)}
|
|
||||||
data-variants={JSON.stringify(variants)}
|
|
||||||
data-while-hover={JSON.stringify(whileHover)}
|
|
||||||
data-while-tap={JSON.stringify(whileTap)}
|
|
||||||
data-transition={JSON.stringify(transition)}
|
|
||||||
className={className}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
button: ({ children, onClick, className, whileHover, whileTap, ...props }: any) => (
|
|
||||||
<button
|
|
||||||
data-testid="motion-button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={className}
|
|
||||||
data-while-hover={JSON.stringify(whileHover)}
|
|
||||||
data-while-tap={JSON.stringify(whileTap)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
span: ({ children, className, animate, initial, transition, ...props }: any) => (
|
|
||||||
<span
|
|
||||||
data-testid="motion-span"
|
|
||||||
className={className}
|
|
||||||
data-animate={JSON.stringify(animate)}
|
|
||||||
data-initial={JSON.stringify(initial)}
|
|
||||||
data-transition={JSON.stringify(transition)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
svg: ({ children, className, ...props }: any) => (
|
|
||||||
<svg data-testid="motion-svg" className={className} {...props}>
|
|
||||||
{children}
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
circle: ({ variants, ...props }: any) => (
|
|
||||||
<circle data-testid="motion-circle" data-variants={JSON.stringify(variants)} {...props} />
|
|
||||||
),
|
|
||||||
path: ({ variants, ...props }: any) => (
|
|
||||||
<path data-testid="motion-path" data-variants={JSON.stringify(variants)} {...props} />
|
|
||||||
),
|
|
||||||
a: ({ children, className, ...props }: any) => (
|
|
||||||
<a data-testid="motion-a" className={className} {...props}>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
h1: ({ children, className, ...props }: any) => (
|
|
||||||
<h1 data-testid="motion-h1" className={className} {...props}>
|
|
||||||
{children}
|
|
||||||
</h1>
|
|
||||||
),
|
|
||||||
h2: ({ children, className, ...props }: any) => (
|
|
||||||
<h2 data-testid="motion-h2" className={className} {...props}>
|
|
||||||
{children}
|
|
||||||
</h2>
|
|
||||||
),
|
|
||||||
p: ({ children, className, ...props }: any) => (
|
|
||||||
<p data-testid="motion-p" className={className} {...props}>
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
AnimatePresence: ({ children }: any) => <>{children}</>,
|
|
||||||
useInView: jest.fn(() => true),
|
|
||||||
useSpring: jest.fn((value) => value),
|
|
||||||
useTransform: jest.fn((value) => value),
|
|
||||||
useMotionValue: jest.fn((initial) => ({
|
|
||||||
get: () => initial,
|
|
||||||
set: jest.fn(),
|
|
||||||
onChange: jest.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('Animation Variants', () => {
|
|
||||||
describe('inkVariants', () => {
|
|
||||||
it('should have correct hidden state', async () => {
|
|
||||||
const { inkVariants } = await import('./animations');
|
|
||||||
expect(inkVariants.hidden).toEqual({
|
|
||||||
opacity: 0,
|
|
||||||
scale: 0.8,
|
|
||||||
filter: 'blur(10px)',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct visible state', async () => {
|
|
||||||
const { inkVariants } = await import('./animations');
|
|
||||||
expect(inkVariants.visible).toHaveProperty('opacity', 1);
|
|
||||||
expect(inkVariants.visible).toHaveProperty('scale', 1);
|
|
||||||
expect(inkVariants.visible).toHaveProperty('filter', 'blur(0px)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct transition configuration', async () => {
|
|
||||||
const { inkVariants } = await import('./animations');
|
|
||||||
const transition = (inkVariants.visible as any)?.transition;
|
|
||||||
expect(transition.duration).toBe(0.8);
|
|
||||||
expect(transition.ease).toEqual([0.16, 1, 0.3, 1]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sealStampVariants', () => {
|
|
||||||
it('should have correct hidden state', async () => {
|
|
||||||
const { sealStampVariants } = await import('./animations');
|
|
||||||
expect(sealStampVariants.hidden).toEqual({
|
|
||||||
opacity: 0,
|
|
||||||
scale: 1.5,
|
|
||||||
rotate: -15,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct visible state', async () => {
|
|
||||||
const { sealStampVariants } = await import('./animations');
|
|
||||||
expect(sealStampVariants.visible).toHaveProperty('opacity', 1);
|
|
||||||
expect(sealStampVariants.visible).toHaveProperty('scale', 1);
|
|
||||||
expect(sealStampVariants.visible).toHaveProperty('rotate', 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use spring animation', async () => {
|
|
||||||
const { sealStampVariants } = await import('./animations');
|
|
||||||
const transition = (sealStampVariants.visible as any)?.transition;
|
|
||||||
expect(transition.type).toBe('spring');
|
|
||||||
expect(transition.stiffness).toBe(300);
|
|
||||||
expect(transition.damping).toBe(20);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('brushStrokeVariants', () => {
|
|
||||||
it('should have correct hidden state', async () => {
|
|
||||||
const { brushStrokeVariants } = await import('./animations');
|
|
||||||
expect(brushStrokeVariants.hidden).toEqual({
|
|
||||||
pathLength: 0,
|
|
||||||
opacity: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct visible state', async () => {
|
|
||||||
const { brushStrokeVariants } = await import('./animations');
|
|
||||||
expect(brushStrokeVariants.visible).toHaveProperty('pathLength', 1);
|
|
||||||
expect(brushStrokeVariants.visible).toHaveProperty('opacity', 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fadeUpVariants', () => {
|
|
||||||
it('should have correct hidden state', async () => {
|
|
||||||
const { fadeUpVariants } = await import('./animations');
|
|
||||||
expect(fadeUpVariants.hidden).toEqual({
|
|
||||||
opacity: 0,
|
|
||||||
y: 30,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct visible state', async () => {
|
|
||||||
const { fadeUpVariants } = await import('./animations');
|
|
||||||
expect(fadeUpVariants.visible).toHaveProperty('opacity', 1);
|
|
||||||
expect(fadeUpVariants.visible).toHaveProperty('y', 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('staggerContainerVariants', () => {
|
|
||||||
it('should have staggerChildren configured', async () => {
|
|
||||||
const { staggerContainerVariants } = await import('./animations');
|
|
||||||
const transition = (staggerContainerVariants.visible as any)?.transition;
|
|
||||||
expect(transition.staggerChildren).toBe(0.1);
|
|
||||||
expect(transition.delayChildren).toBe(0.1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('staggerItemVariants', () => {
|
|
||||||
it('should have correct hidden state', async () => {
|
|
||||||
const { staggerItemVariants } = await import('./animations');
|
|
||||||
expect(staggerItemVariants.hidden).toEqual({
|
|
||||||
opacity: 0,
|
|
||||||
y: 20,
|
|
||||||
scale: 0.95,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct visible state', async () => {
|
|
||||||
const { staggerItemVariants } = await import('./animations');
|
|
||||||
expect(staggerItemVariants.visible).toHaveProperty('opacity', 1);
|
|
||||||
expect(staggerItemVariants.visible).toHaveProperty('y', 0);
|
|
||||||
expect(staggerItemVariants.visible).toHaveProperty('scale', 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Animation Components', () => {
|
|
||||||
describe('InkReveal', () => {
|
|
||||||
it('should render children correctly', async () => {
|
|
||||||
const { InkReveal } = await import('./animations');
|
|
||||||
render(<InkReveal>Test Content</InkReveal>);
|
|
||||||
expect(screen.getByText('Test Content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom className', async () => {
|
|
||||||
const { InkReveal } = await import('./animations');
|
|
||||||
render(<InkReveal className="custom-class">Test</InkReveal>);
|
|
||||||
const element = screen.getByTestId('motion-div');
|
|
||||||
expect(element).toHaveClass('custom-class');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use inkVariants', async () => {
|
|
||||||
const { InkReveal, inkVariants } = await import('./animations');
|
|
||||||
render(<InkReveal>Test</InkReveal>);
|
|
||||||
const element = screen.getByTestId('motion-div');
|
|
||||||
const variants = JSON.parse(element.getAttribute('data-variants') || '{}');
|
|
||||||
expect(variants).toEqual(inkVariants);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SealStamp', () => {
|
|
||||||
it('should render children correctly', async () => {
|
|
||||||
const { SealStamp } = await import('./animations');
|
|
||||||
render(<SealStamp>Seal Content</SealStamp>);
|
|
||||||
expect(screen.getByText('Seal Content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom className', async () => {
|
|
||||||
const { SealStamp } = await import('./animations');
|
|
||||||
render(<SealStamp className="seal-class">Test</SealStamp>);
|
|
||||||
const element = screen.getByTestId('motion-div');
|
|
||||||
expect(element).toHaveClass('seal-class');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('FadeUp', () => {
|
|
||||||
it('should render children correctly', async () => {
|
|
||||||
const { FadeUp } = await import('./animations');
|
|
||||||
render(<FadeUp>Fade Content</FadeUp>);
|
|
||||||
expect(screen.getByText('Fade Content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom duration', async () => {
|
|
||||||
const { FadeUp } = await import('./animations');
|
|
||||||
render(<FadeUp duration={1.2}>Test</FadeUp>);
|
|
||||||
const element = screen.getByTestId('motion-div');
|
|
||||||
const variants = JSON.parse(element.getAttribute('data-variants') || '{}');
|
|
||||||
expect(variants.visible.transition.duration).toBe(1.2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply delay prop', async () => {
|
|
||||||
const { FadeUp } = await import('./animations');
|
|
||||||
render(<FadeUp delay={0.3}>Test</FadeUp>);
|
|
||||||
const element = screen.getByTestId('motion-div');
|
|
||||||
const variants = JSON.parse(element.getAttribute('data-variants') || '{}');
|
|
||||||
expect(variants.visible.transition.delay).toBe(0.3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('StaggerContainer', () => {
|
|
||||||
it('should render children correctly', async () => {
|
|
||||||
const { StaggerContainer } = await import('./animations');
|
|
||||||
render(
|
|
||||||
<StaggerContainer>
|
|
||||||
<div>Item 1</div>
|
|
||||||
<div>Item 2</div>
|
|
||||||
</StaggerContainer>
|
|
||||||
);
|
|
||||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Item 2')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom staggerDelay', async () => {
|
|
||||||
const { StaggerContainer } = await import('./animations');
|
|
||||||
render(<StaggerContainer staggerDelay={0.2}>Test</StaggerContainer>);
|
|
||||||
const element = screen.getByTestId('motion-div');
|
|
||||||
const variants = JSON.parse(element.getAttribute('data-variants') || '{}');
|
|
||||||
expect(variants.visible.transition.staggerChildren).toBe(0.2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('StaggerItem', () => {
|
|
||||||
it('should render children correctly', async () => {
|
|
||||||
const { StaggerItem } = await import('./animations');
|
|
||||||
render(<StaggerItem>Item Content</StaggerItem>);
|
|
||||||
expect(screen.getByText('Item Content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('RippleButton', () => {
|
|
||||||
it('should render children correctly', async () => {
|
|
||||||
const { RippleButton } = await import('./animations');
|
|
||||||
render(<RippleButton>Click Me</RippleButton>);
|
|
||||||
expect(screen.getByText('Click Me')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle click events', async () => {
|
|
||||||
const { RippleButton } = await import('./animations');
|
|
||||||
const handleClick = jest.fn();
|
|
||||||
render(<RippleButton onClick={handleClick}>Click Me</RippleButton>);
|
|
||||||
|
|
||||||
const button = screen.getByTestId('motion-button');
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(handleClick).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom className', async () => {
|
|
||||||
const { RippleButton } = await import('./animations');
|
|
||||||
render(<RippleButton className="custom-button">Test</RippleButton>);
|
|
||||||
const element = screen.getByTestId('motion-button');
|
|
||||||
expect(element).toHaveClass('custom-button');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CountUp', () => {
|
|
||||||
it('should render with prefix and suffix', async () => {
|
|
||||||
const { CountUp } = await import('./animations');
|
|
||||||
render(<CountUp end={100} prefix="$" suffix="%" />);
|
|
||||||
expect(screen.getByText(/\$/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/%/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom className', async () => {
|
|
||||||
const { CountUp } = await import('./animations');
|
|
||||||
render(<CountUp end={100} className="counter-class" />);
|
|
||||||
const element = screen.getByTestId('motion-span');
|
|
||||||
expect(element).toHaveClass('counter-class');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Typewriter', () => {
|
|
||||||
it('should render component correctly', async () => {
|
|
||||||
const { Typewriter } = await import('./animations');
|
|
||||||
render(<Typewriter text="Hello" />);
|
|
||||||
const cursor = screen.getByText('|');
|
|
||||||
expect(cursor).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom className', async () => {
|
|
||||||
const { Typewriter } = await import('./animations');
|
|
||||||
render(<Typewriter text="Test" className="typewriter-class" />);
|
|
||||||
const container = screen.getByText('|').closest('.typewriter-class');
|
|
||||||
expect(container).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('FloatingElement', () => {
|
|
||||||
it('should render children correctly', async () => {
|
|
||||||
const { FloatingElement } = await import('./animations');
|
|
||||||
render(<FloatingElement>Floating Content</FloatingElement>);
|
|
||||||
expect(screen.getByText('Floating Content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom amplitude', async () => {
|
|
||||||
const { FloatingElement } = await import('./animations');
|
|
||||||
render(<FloatingElement amplitude={20}>Test</FloatingElement>);
|
|
||||||
const element = screen.getByTestId('motion-div');
|
|
||||||
const animate = JSON.parse(element.getAttribute('data-animate') || '{}');
|
|
||||||
expect(animate.y).toEqual([-20, 20, -20]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PulseElement', () => {
|
|
||||||
it('should render children correctly', async () => {
|
|
||||||
const { PulseElement } = await import('./animations');
|
|
||||||
render(<PulseElement>Pulse Content</PulseElement>);
|
|
||||||
expect(screen.getByText('Pulse Content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom scale', async () => {
|
|
||||||
const { PulseElement } = await import('./animations');
|
|
||||||
render(<PulseElement scale={1.1}>Test</PulseElement>);
|
|
||||||
const element = screen.getByTestId('motion-div');
|
|
||||||
const animate = JSON.parse(element.getAttribute('data-animate') || '{}');
|
|
||||||
expect(animate.scale).toEqual([1, 1.1, 1]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GradientText', () => {
|
|
||||||
it('should render children correctly', async () => {
|
|
||||||
const { GradientText } = await import('./animations');
|
|
||||||
render(<GradientText>Gradient Text</GradientText>);
|
|
||||||
expect(screen.getByText('Gradient Text')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom colors', async () => {
|
|
||||||
const { GradientText } = await import('./animations');
|
|
||||||
render(<GradientText colors={['#ff0000', '#00ff00', '#0000ff']}>Test</GradientText>);
|
|
||||||
const element = screen.getByTestId('motion-span');
|
|
||||||
expect(element).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SplitText', () => {
|
|
||||||
it('should render text correctly', async () => {
|
|
||||||
const { SplitText } = await import('./animations');
|
|
||||||
render(<SplitText text="Hi" />);
|
|
||||||
expect(screen.getByText('H')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('i')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom className', async () => {
|
|
||||||
const { SplitText } = await import('./animations');
|
|
||||||
render(<SplitText text="Test" className="split-class" />);
|
|
||||||
const elements = screen.getAllByTestId('motion-span');
|
|
||||||
const parentElement = elements.find(el => el.classList.contains('split-class'));
|
|
||||||
expect(parentElement).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GlitchText', () => {
|
|
||||||
it('should render text correctly', async () => {
|
|
||||||
const { GlitchText } = await import('./animations');
|
|
||||||
render(<GlitchText text="Glitch" />);
|
|
||||||
const glitchElements = screen.getAllByText('Glitch');
|
|
||||||
expect(glitchElements.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom className', async () => {
|
|
||||||
const { GlitchText } = await import('./animations');
|
|
||||||
render(<GlitchText text="Test" className="glitch-class" />);
|
|
||||||
const testElements = screen.getAllByText('Test');
|
|
||||||
const container = testElements[0]?.closest('.glitch-class');
|
|
||||||
expect(container).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('MagneticButton', () => {
|
|
||||||
it('should render children correctly', async () => {
|
|
||||||
const { MagneticButton } = await import('./animations');
|
|
||||||
render(<MagneticButton>Magnetic</MagneticButton>);
|
|
||||||
expect(screen.getByText('Magnetic')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle click events', async () => {
|
|
||||||
const { MagneticButton } = await import('./animations');
|
|
||||||
const handleClick = jest.fn();
|
|
||||||
render(<MagneticButton onClick={handleClick}>Click</MagneticButton>);
|
|
||||||
|
|
||||||
const element = screen.getByText('Click');
|
|
||||||
fireEvent.click(element);
|
|
||||||
|
|
||||||
expect(handleClick).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom className', async () => {
|
|
||||||
const { MagneticButton } = await import('./animations');
|
|
||||||
render(<MagneticButton className="magnetic-class">Test</MagneticButton>);
|
|
||||||
const element = screen.getByText('Test').closest('.magnetic-class');
|
|
||||||
expect(element).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom strength', async () => {
|
|
||||||
const { MagneticButton } = await import('./animations');
|
|
||||||
render(<MagneticButton strength={0.5}>Test</MagneticButton>);
|
|
||||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('BlurReveal', () => {
|
|
||||||
it('should render children correctly', async () => {
|
|
||||||
const { BlurReveal } = await import('./animations');
|
|
||||||
render(<BlurReveal>Blur Content</BlurReveal>);
|
|
||||||
expect(screen.getByText('Blur Content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom className', async () => {
|
|
||||||
const { BlurReveal } = await import('./animations');
|
|
||||||
render(<BlurReveal className="blur-class">Test</BlurReveal>);
|
|
||||||
const element = screen.getByTestId('motion-div');
|
|
||||||
expect(element).toHaveClass('blur-class');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('WaveText', () => {
|
|
||||||
it('should render text correctly', async () => {
|
|
||||||
const { WaveText } = await import('./animations');
|
|
||||||
render(<WaveText text="Hi" />);
|
|
||||||
expect(screen.getByText('H')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('i')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ShimmerButton', () => {
|
|
||||||
it('should render children correctly', async () => {
|
|
||||||
const { ShimmerButton } = await import('./animations');
|
|
||||||
render(<ShimmerButton>Shimmer</ShimmerButton>);
|
|
||||||
expect(screen.getByText('Shimmer')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle click events', async () => {
|
|
||||||
const { ShimmerButton } = await import('./animations');
|
|
||||||
const handleClick = jest.fn();
|
|
||||||
render(<ShimmerButton onClick={handleClick}>Click</ShimmerButton>);
|
|
||||||
|
|
||||||
const button = screen.getByTestId('motion-button');
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(handleClick).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom shimmerColor', async () => {
|
|
||||||
const { ShimmerButton } = await import('./animations');
|
|
||||||
render(<ShimmerButton shimmerColor="rgba(0,0,0,0.5)">Test</ShimmerButton>);
|
|
||||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('RotatingBorder', () => {
|
|
||||||
it('should render children correctly', async () => {
|
|
||||||
const { RotatingBorder } = await import('./animations');
|
|
||||||
render(<RotatingBorder>Border Content</RotatingBorder>);
|
|
||||||
expect(screen.getByText('Border Content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom className', async () => {
|
|
||||||
const { RotatingBorder } = await import('./animations');
|
|
||||||
render(<RotatingBorder className="rotating-class">Test</RotatingBorder>);
|
|
||||||
const container = screen.getByText('Test').closest('.rotating-class');
|
|
||||||
expect(container).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept custom borderWidth', async () => {
|
|
||||||
const { RotatingBorder } = await import('./animations');
|
|
||||||
render(<RotatingBorder borderWidth={4}>Test</RotatingBorder>);
|
|
||||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept custom colors', async () => {
|
|
||||||
const { RotatingBorder } = await import('./animations');
|
|
||||||
render(<RotatingBorder colors={['red', 'blue', 'green']}>Test</RotatingBorder>);
|
|
||||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept custom duration', async () => {
|
|
||||||
const { RotatingBorder } = await import('./animations');
|
|
||||||
render(<RotatingBorder duration={8}>Test</RotatingBorder>);
|
|
||||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CounterWithEffect', () => {
|
|
||||||
it('should render with prefix and suffix', async () => {
|
|
||||||
const { CounterWithEffect } = await import('./animations');
|
|
||||||
render(<CounterWithEffect end={100} prefix="$" suffix="%" />);
|
|
||||||
expect(screen.getByText(/100|\$/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/%/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom className', async () => {
|
|
||||||
const { CounterWithEffect } = await import('./animations');
|
|
||||||
render(<CounterWithEffect end={100} className="counter-eff-class" />);
|
|
||||||
const element = screen.getByTestId('motion-span');
|
|
||||||
expect(element).toHaveClass('counter-eff-class');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept bounce effect', async () => {
|
|
||||||
const { CounterWithEffect } = await import('./animations');
|
|
||||||
render(<CounterWithEffect end={100} effect="bounce" />);
|
|
||||||
const element = screen.getByTestId('motion-span');
|
|
||||||
expect(element).toBeInTheDocument();
|
|
||||||
const animate = JSON.parse(element.getAttribute('data-animate') || '{}');
|
|
||||||
expect(animate).toEqual({ y: [0, -10, 0] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept slide effect', async () => {
|
|
||||||
const { CounterWithEffect } = await import('./animations');
|
|
||||||
render(<CounterWithEffect end={100} effect="slide" />);
|
|
||||||
const element = screen.getByTestId('motion-span');
|
|
||||||
expect(element).toBeInTheDocument();
|
|
||||||
const animate = JSON.parse(element.getAttribute('data-animate') || '{}');
|
|
||||||
expect(animate).toEqual({ y: 0, opacity: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept flip effect', async () => {
|
|
||||||
const { CounterWithEffect } = await import('./animations');
|
|
||||||
render(<CounterWithEffect end={100} effect="flip" />);
|
|
||||||
const element = screen.getByTestId('motion-span');
|
|
||||||
expect(element).toBeInTheDocument();
|
|
||||||
const animate = JSON.parse(element.getAttribute('data-animate') || '{}');
|
|
||||||
expect(animate).toEqual({ rotateX: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render initial count value', async () => {
|
|
||||||
const { CounterWithEffect } = await import('./animations');
|
|
||||||
render(<CounterWithEffect end={42} />);
|
|
||||||
// Component renders with end value, then useEffect resets to 0
|
|
||||||
// due to useInView mock returning true
|
|
||||||
const element = screen.getByTestId('motion-span');
|
|
||||||
expect(element).toBeInTheDocument();
|
|
||||||
expect(element.textContent).toBe('0');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Enhanced Component Tests', () => {
|
|
||||||
describe('CountUp', () => {
|
|
||||||
it('should render with custom duration', async () => {
|
|
||||||
const { CountUp } = await import('./animations');
|
|
||||||
render(<CountUp end={1000} duration={3000} />);
|
|
||||||
const element = screen.getByTestId('motion-span');
|
|
||||||
expect(element).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render with prefix and suffix', async () => {
|
|
||||||
const { CountUp } = await import('./animations');
|
|
||||||
render(<CountUp end={100} prefix="$" suffix="%" />);
|
|
||||||
expect(screen.getByText(/\$/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/%/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('RippleButton', () => {
|
|
||||||
it('should create ripple on click', async () => {
|
|
||||||
const { RippleButton } = await import('./animations');
|
|
||||||
render(<RippleButton>Click Me</RippleButton>);
|
|
||||||
|
|
||||||
const button = screen.getByTestId('motion-button');
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
// Ripple creates a motion.span
|
|
||||||
expect(screen.getByTestId('motion-button')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle multiple clicks', async () => {
|
|
||||||
const { RippleButton } = await import('./animations');
|
|
||||||
const handleClick = jest.fn();
|
|
||||||
render(<RippleButton onClick={handleClick}>Click Me</RippleButton>);
|
|
||||||
|
|
||||||
const button = screen.getByTestId('motion-button');
|
|
||||||
fireEvent.click(button);
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(handleClick).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom rippleColor', async () => {
|
|
||||||
const { RippleButton } = await import('./animations');
|
|
||||||
render(<RippleButton rippleColor="rgba(0, 0, 255, 0.5)">Click</RippleButton>);
|
|
||||||
|
|
||||||
const button = screen.getByTestId('motion-button');
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(screen.getByText('Click')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('RotatingBorder', () => {
|
|
||||||
it('should render with custom borderWidth', async () => {
|
|
||||||
const { RotatingBorder } = await import('./animations');
|
|
||||||
render(<RotatingBorder borderWidth={4}>Test</RotatingBorder>);
|
|
||||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render with custom duration', async () => {
|
|
||||||
const { RotatingBorder } = await import('./animations');
|
|
||||||
render(<RotatingBorder duration={8}>Test</RotatingBorder>);
|
|
||||||
const motionDiv = screen.getAllByTestId('motion-div')[0]!;
|
|
||||||
const animate = JSON.parse(motionDiv.getAttribute('data-animate') || '{}');
|
|
||||||
expect(animate).toEqual({ rotate: 360 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have infinite repeat transition', async () => {
|
|
||||||
const { RotatingBorder } = await import('./animations');
|
|
||||||
render(<RotatingBorder duration={4}>Test</RotatingBorder>);
|
|
||||||
const motionDiv = screen.getAllByTestId('motion-div')[0]!;
|
|
||||||
const transitionAttr = motionDiv.getAttribute('data-transition') || '';
|
|
||||||
// JSON.stringify(Infinity) returns null, so check string representation
|
|
||||||
expect(transitionAttr).toContain('"repeat"');
|
|
||||||
expect(transitionAttr).toContain('"duration"');
|
|
||||||
expect(transitionAttr).toContain('4');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SplitText', () => {
|
|
||||||
it('should render with custom delay', async () => {
|
|
||||||
const { SplitText } = await import('./animations');
|
|
||||||
render(<SplitText text="ABC" delay={0.5} />);
|
|
||||||
expect(screen.getByText('A')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('B')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('C')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('WaveText', () => {
|
|
||||||
it('should render with custom delay', async () => {
|
|
||||||
const { WaveText } = await import('./animations');
|
|
||||||
render(<WaveText text="ABC" delay={0.5} />);
|
|
||||||
const spans = screen.getAllByTestId('motion-span');
|
|
||||||
expect(spans.length).toBeGreaterThanOrEqual(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Typewriter', () => {
|
|
||||||
it('should render with custom speed', async () => {
|
|
||||||
const { Typewriter } = await import('./animations');
|
|
||||||
render(<Typewriter text="Hello" speed={100} />);
|
|
||||||
const cursor = screen.getByText('|');
|
|
||||||
expect(cursor).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GradientText', () => {
|
|
||||||
it('should render with custom colors', async () => {
|
|
||||||
const { GradientText } = await import('./animations');
|
|
||||||
render(<GradientText colors={['#ff0000', '#00ff00', '#0000ff']}>Test</GradientText>);
|
|
||||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render with animation duration', async () => {
|
|
||||||
const { GradientText } = await import('./animations');
|
|
||||||
render(<GradientText duration={5}>Test</GradientText>);
|
|
||||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ShimmerButton', () => {
|
|
||||||
it('should render with animation', async () => {
|
|
||||||
const { ShimmerButton } = await import('./animations');
|
|
||||||
render(<ShimmerButton>Shimmer</ShimmerButton>);
|
|
||||||
const motionDiv = screen.getAllByTestId('motion-div')[0]!;
|
|
||||||
const animate = JSON.parse(motionDiv.getAttribute('data-animate') || '{}');
|
|
||||||
expect(animate).toHaveProperty('backgroundPosition');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('MagneticButton', () => {
|
|
||||||
it('should render with custom strength', async () => {
|
|
||||||
const { MagneticButton } = await import('./animations');
|
|
||||||
render(<MagneticButton strength={0.5}>Test</MagneticButton>);
|
|
||||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle click events', async () => {
|
|
||||||
const { MagneticButton } = await import('./animations');
|
|
||||||
const handleClick = jest.fn();
|
|
||||||
render(<MagneticButton onClick={handleClick}>Click</MagneticButton>);
|
|
||||||
|
|
||||||
const element = screen.getByText('Click');
|
|
||||||
fireEvent.click(element);
|
|
||||||
|
|
||||||
expect(handleClick).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('BlurReveal', () => {
|
|
||||||
it('should render with custom delay', async () => {
|
|
||||||
const { BlurReveal } = await import('./animations');
|
|
||||||
render(<BlurReveal delay={0.5}>Test</BlurReveal>);
|
|
||||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GlitchText', () => {
|
|
||||||
it('should render text correctly', async () => {
|
|
||||||
const { GlitchText } = await import('./animations');
|
|
||||||
render(<GlitchText text="Test" />);
|
|
||||||
const elements = screen.getAllByText('Test');
|
|
||||||
expect(elements.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('FloatingElement', () => {
|
|
||||||
it('should render with custom duration', async () => {
|
|
||||||
const { FloatingElement } = await import('./animations');
|
|
||||||
render(<FloatingElement duration={5}>Test</FloatingElement>);
|
|
||||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PulseElement', () => {
|
|
||||||
it('should render with custom duration', async () => {
|
|
||||||
const { PulseElement } = await import('./animations');
|
|
||||||
render(<PulseElement duration={3}>Test</PulseElement>);
|
|
||||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Animation Hooks', () => {
|
|
||||||
describe('useParallax', () => {
|
|
||||||
it('should be defined', async () => {
|
|
||||||
const { useParallax } = await import('./animations');
|
|
||||||
expect(useParallax).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useSmoothSpring', () => {
|
|
||||||
it('should be defined', async () => {
|
|
||||||
const { useSmoothSpring } = await import('./animations');
|
|
||||||
expect(useSmoothSpring).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SVG Components', () => {
|
|
||||||
describe('InkDropSVG', () => {
|
|
||||||
it('should render SVG correctly', async () => {
|
|
||||||
const { InkDropSVG } = await import('./animations');
|
|
||||||
render(<InkDropSVG />);
|
|
||||||
expect(screen.getByTestId('motion-svg')).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId('motion-circle')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom className', async () => {
|
|
||||||
const { InkDropSVG } = await import('./animations');
|
|
||||||
render(<InkDropSVG className="ink-drop-class" />);
|
|
||||||
const element = screen.getByTestId('motion-svg');
|
|
||||||
expect(element).toHaveClass('ink-drop-class');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('InkSplash', () => {
|
|
||||||
it('should render SVG correctly', async () => {
|
|
||||||
const { InkSplash } = await import('./animations');
|
|
||||||
render(<InkSplash />);
|
|
||||||
expect(screen.getByTestId('motion-svg')).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId('motion-path')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom color', async () => {
|
|
||||||
const { InkSplash } = await import('./animations');
|
|
||||||
render(<InkSplash color="#ff0000" />);
|
|
||||||
const path = screen.getByTestId('motion-path');
|
|
||||||
expect(path).toHaveAttribute('fill', '#ff0000');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply custom size', async () => {
|
|
||||||
const { InkSplash } = await import('./animations');
|
|
||||||
render(<InkSplash size={200} />);
|
|
||||||
const svg = screen.getByTestId('motion-svg');
|
|
||||||
expect(svg).toHaveAttribute('width', '200');
|
|
||||||
expect(svg).toHaveAttribute('height', '200');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,923 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion, useInView, useSpring, useTransform, type MotionValue, type Variants, type HTMLMotionProps } from 'framer-motion';
|
|
||||||
import { useRef, useEffect, useState, type ReactNode } from 'react';
|
|
||||||
|
|
||||||
export const inkVariants: Variants = {
|
|
||||||
hidden: {
|
|
||||||
opacity: 0,
|
|
||||||
scale: 0.8,
|
|
||||||
filter: 'blur(10px)',
|
|
||||||
},
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
scale: 1,
|
|
||||||
filter: 'blur(0px)',
|
|
||||||
transition: {
|
|
||||||
duration: 0.8,
|
|
||||||
ease: [0.16, 1, 0.3, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sealStampVariants: Variants = {
|
|
||||||
hidden: {
|
|
||||||
opacity: 0,
|
|
||||||
scale: 1.5,
|
|
||||||
rotate: -15,
|
|
||||||
},
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
scale: 1,
|
|
||||||
rotate: 0,
|
|
||||||
transition: {
|
|
||||||
type: 'spring',
|
|
||||||
stiffness: 300,
|
|
||||||
damping: 20,
|
|
||||||
mass: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const brushStrokeVariants: Variants = {
|
|
||||||
hidden: {
|
|
||||||
pathLength: 0,
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
visible: {
|
|
||||||
pathLength: 1,
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
pathLength: {
|
|
||||||
duration: 1.5,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
},
|
|
||||||
opacity: {
|
|
||||||
duration: 0.3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fadeUpVariants: Variants = {
|
|
||||||
hidden: {
|
|
||||||
opacity: 0,
|
|
||||||
y: 30,
|
|
||||||
},
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.6,
|
|
||||||
ease: [0.16, 1, 0.3, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const staggerContainerVariants: Variants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.1,
|
|
||||||
delayChildren: 0.1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const staggerItemVariants: Variants = {
|
|
||||||
hidden: {
|
|
||||||
opacity: 0,
|
|
||||||
y: 20,
|
|
||||||
scale: 0.95,
|
|
||||||
},
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
ease: [0.16, 1, 0.3, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
interface InkRevealProps extends HTMLMotionProps<'div'> {
|
|
||||||
children: ReactNode;
|
|
||||||
delay?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InkReveal({ children, delay = 0, className = '', ...props }: InkRevealProps) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const isInView = useInView(ref, { once: true, margin: '-50px' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
ref={ref}
|
|
||||||
initial="hidden"
|
|
||||||
animate={isInView ? 'visible' : 'hidden'}
|
|
||||||
variants={inkVariants}
|
|
||||||
transition={{ delay }}
|
|
||||||
className={className}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SealStampProps extends HTMLMotionProps<'div'> {
|
|
||||||
children: ReactNode;
|
|
||||||
delay?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SealStamp({ children, delay = 0, className = '', ...props }: SealStampProps) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const isInView = useInView(ref, { once: true, margin: '-50px' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
ref={ref}
|
|
||||||
initial="hidden"
|
|
||||||
animate={isInView ? 'visible' : 'hidden'}
|
|
||||||
variants={sealStampVariants}
|
|
||||||
transition={{ delay }}
|
|
||||||
className={className}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FadeUpProps extends HTMLMotionProps<'div'> {
|
|
||||||
children: ReactNode;
|
|
||||||
delay?: number;
|
|
||||||
duration?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FadeUp({ children, delay = 0, duration = 0.6, className = '', ...props }: FadeUpProps) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const isInView = useInView(ref, { once: true, margin: '-50px' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
ref={ref}
|
|
||||||
initial="hidden"
|
|
||||||
animate={isInView ? 'visible' : 'hidden'}
|
|
||||||
variants={{
|
|
||||||
hidden: { opacity: 0, y: 30 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
transition: {
|
|
||||||
duration,
|
|
||||||
delay,
|
|
||||||
ease: [0.16, 1, 0.3, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
className={className}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StaggerContainerProps extends HTMLMotionProps<'div'> {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
staggerDelay?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StaggerContainer({ children, className = '', staggerDelay = 0.1, ...props }: StaggerContainerProps) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const isInView = useInView(ref, { once: true, margin: '-50px' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
ref={ref}
|
|
||||||
initial="hidden"
|
|
||||||
animate={isInView ? 'visible' : 'hidden'}
|
|
||||||
variants={{
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: staggerDelay,
|
|
||||||
delayChildren: 0.1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
className={className}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StaggerItemProps extends HTMLMotionProps<'div'> {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StaggerItem({ children, className = '', ...props }: StaggerItemProps) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
variants={staggerItemVariants}
|
|
||||||
className={className}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RippleButtonProps {
|
|
||||||
children: ReactNode;
|
|
||||||
onClick?: () => void;
|
|
||||||
className?: string;
|
|
||||||
rippleColor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RippleButton({ children, onClick, className = '', rippleColor = 'rgba(196, 30, 58, 0.3)' }: RippleButtonProps) {
|
|
||||||
const [ripples, setRipples] = useState<Array<{ x: number; y: number; id: number }>>([]);
|
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
const button = e.currentTarget;
|
|
||||||
const rect = button.getBoundingClientRect();
|
|
||||||
const x = e.clientX - rect.left;
|
|
||||||
const y = e.clientY - rect.top;
|
|
||||||
const id = Date.now();
|
|
||||||
|
|
||||||
setRipples((prev) => [...prev, { x, y, id }]);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setRipples((prev) => prev.filter((r) => r.id !== id));
|
|
||||||
}, 600);
|
|
||||||
|
|
||||||
onClick?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.button
|
|
||||||
onClick={handleClick}
|
|
||||||
whileHover={{ scale: 1.02 }}
|
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
|
||||||
className={`relative overflow-hidden ${className}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{ripples.map((ripple) => (
|
|
||||||
<motion.span
|
|
||||||
key={ripple.id}
|
|
||||||
initial={{ scale: 0, opacity: 1 }}
|
|
||||||
animate={{ scale: 4, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
|
||||||
className="absolute w-4 h-4 rounded-full pointer-events-none"
|
|
||||||
style={{
|
|
||||||
left: ripple.x - 8,
|
|
||||||
top: ripple.y - 8,
|
|
||||||
backgroundColor: rippleColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</motion.button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function useParallax(value: MotionValue<number>, distance: number) {
|
|
||||||
return useTransform(value, [0, 1], [-distance, distance]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSmoothSpring(value: MotionValue<number>, springConfig = { stiffness: 100, damping: 30, restDelta: 0.001 }) {
|
|
||||||
return useSpring(value, springConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CountUpProps {
|
|
||||||
end: number;
|
|
||||||
duration?: number;
|
|
||||||
delay?: number;
|
|
||||||
prefix?: string;
|
|
||||||
suffix?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CountUp({ end, duration = 2000, delay = 0, prefix = '', suffix = '', className = '' }: CountUpProps) {
|
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
|
||||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
|
||||||
const hasAnimated = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isInView || hasAnimated.current) {return;}
|
|
||||||
|
|
||||||
hasAnimated.current = true;
|
|
||||||
|
|
||||||
const startTime = Date.now() + delay;
|
|
||||||
const endTime = startTime + duration;
|
|
||||||
|
|
||||||
const animate = () => {
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (now < startTime) {
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (now >= endTime) {
|
|
||||||
setCount(end);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const progress = (now - startTime) / duration;
|
|
||||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
|
||||||
const currentValue = Math.floor(easeOutQuart * end);
|
|
||||||
|
|
||||||
setCount(currentValue);
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
}, [isInView, end, duration, delay]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.span
|
|
||||||
ref={ref}
|
|
||||||
initial={{ opacity: 0, scale: 0.5 }}
|
|
||||||
animate={isInView ? { opacity: 1, scale: 1 } : {}}
|
|
||||||
transition={{ duration: 0.5, delay: delay / 1000 }}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{prefix}
|
|
||||||
{count}
|
|
||||||
{suffix}
|
|
||||||
</motion.span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TypewriterProps {
|
|
||||||
text: string;
|
|
||||||
delay?: number;
|
|
||||||
speed?: number;
|
|
||||||
className?: string;
|
|
||||||
cursorClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Typewriter({ text, delay = 0, speed = 50, className = '', cursorClassName = '' }: TypewriterProps) {
|
|
||||||
const [displayText, setDisplayText] = useState('');
|
|
||||||
const [_isTyping, setIsTyping] = useState(false);
|
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
|
||||||
const isInView = useInView(ref, { once: true });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isInView) {return;}
|
|
||||||
|
|
||||||
const startTyping = setTimeout(() => {
|
|
||||||
setIsTyping(true);
|
|
||||||
let currentIndex = 0;
|
|
||||||
|
|
||||||
const typeInterval = setInterval(() => {
|
|
||||||
if (currentIndex < text.length) {
|
|
||||||
setDisplayText(text.slice(0, currentIndex + 1));
|
|
||||||
currentIndex++;
|
|
||||||
} else {
|
|
||||||
clearInterval(typeInterval);
|
|
||||||
setIsTyping(false);
|
|
||||||
}
|
|
||||||
}, speed);
|
|
||||||
|
|
||||||
return () => clearInterval(typeInterval);
|
|
||||||
}, delay);
|
|
||||||
|
|
||||||
return () => clearTimeout(startTyping);
|
|
||||||
}, [isInView, text, delay, speed]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span ref={ref} className={className}>
|
|
||||||
{displayText}
|
|
||||||
<motion.span
|
|
||||||
animate={{ opacity: [1, 0] }}
|
|
||||||
transition={{ duration: 0.5, repeat: Infinity, repeatType: 'reverse' }}
|
|
||||||
className={cursorClassName}
|
|
||||||
style={{ marginLeft: '2px' }}
|
|
||||||
>
|
|
||||||
|
|
|
||||||
</motion.span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FloatingElementProps {
|
|
||||||
children: ReactNode;
|
|
||||||
amplitude?: number;
|
|
||||||
duration?: number;
|
|
||||||
delay?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FloatingElement({ children, amplitude = 10, duration = 3, delay = 0, className = '' }: FloatingElementProps) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
y: [-amplitude, amplitude, -amplitude],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration,
|
|
||||||
delay,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
}}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PulseElementProps {
|
|
||||||
children: ReactNode;
|
|
||||||
scale?: number;
|
|
||||||
duration?: number;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PulseElement({ children, scale = 1.05, duration = 2, className = '' }: PulseElementProps) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
scale: [1, scale, 1],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
}}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InkDropSVG({ className = '' }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<motion.svg
|
|
||||||
viewBox="0 0 100 100"
|
|
||||||
className={className}
|
|
||||||
initial="hidden"
|
|
||||||
animate="visible"
|
|
||||||
>
|
|
||||||
<motion.circle
|
|
||||||
cx="50"
|
|
||||||
cy="50"
|
|
||||||
r="40"
|
|
||||||
fill="currentColor"
|
|
||||||
variants={{
|
|
||||||
hidden: { scale: 0, opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
scale: [0, 1.2, 1],
|
|
||||||
opacity: [0, 1, 1],
|
|
||||||
transition: {
|
|
||||||
duration: 0.8,
|
|
||||||
ease: [0.16, 1, 0.3, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</motion.svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GradientTextProps {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
colors?: string[];
|
|
||||||
duration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GradientText({
|
|
||||||
children,
|
|
||||||
className = '',
|
|
||||||
colors = ['#C41E3A', '#E04A68', '#C41E3A'],
|
|
||||||
duration = 3
|
|
||||||
}: GradientTextProps) {
|
|
||||||
return (
|
|
||||||
<motion.span
|
|
||||||
className={className}
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(90deg, ${colors.join(', ')})`,
|
|
||||||
backgroundSize: '200% 100%',
|
|
||||||
WebkitBackgroundClip: 'text',
|
|
||||||
WebkitTextFillColor: 'transparent',
|
|
||||||
backgroundClip: 'text',
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
backgroundPosition: ['0% 50%', '100% 50%', '0% 50%'],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'linear',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SplitTextProps {
|
|
||||||
text: string;
|
|
||||||
className?: string;
|
|
||||||
delay?: number;
|
|
||||||
staggerDelay?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SplitText({ text, className = '', delay = 0, staggerDelay = 0.03 }: SplitTextProps) {
|
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
|
||||||
const isInView = useInView(ref, { once: true });
|
|
||||||
|
|
||||||
const container = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: staggerDelay,
|
|
||||||
delayChildren: delay,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const child: Variants = {
|
|
||||||
hidden: {
|
|
||||||
opacity: 0,
|
|
||||||
y: 20,
|
|
||||||
rotateX: -90,
|
|
||||||
},
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
rotateX: 0,
|
|
||||||
transition: {
|
|
||||||
type: 'spring' as const,
|
|
||||||
stiffness: 100,
|
|
||||||
damping: 12,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.span
|
|
||||||
ref={ref}
|
|
||||||
variants={container}
|
|
||||||
initial="hidden"
|
|
||||||
animate={isInView ? 'visible' : 'hidden'}
|
|
||||||
className={className}
|
|
||||||
style={{ display: 'inline-block', fontFamily: 'inherit' }}
|
|
||||||
>
|
|
||||||
{text.split('').map((char, index) => (
|
|
||||||
<motion.span
|
|
||||||
key={index}
|
|
||||||
variants={child}
|
|
||||||
style={{ display: 'inline-block', fontFamily: 'inherit' }}
|
|
||||||
>
|
|
||||||
{char === ' ' ? '\u00A0' : char}
|
|
||||||
</motion.span>
|
|
||||||
))}
|
|
||||||
</motion.span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GlitchTextProps {
|
|
||||||
text: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GlitchText({ text, className = '' }: GlitchTextProps) {
|
|
||||||
return (
|
|
||||||
<span className={`relative ${className}`}>
|
|
||||||
<span className="relative z-10">{text}</span>
|
|
||||||
<motion.span
|
|
||||||
className="absolute top-0 left-0 text-brand z-0"
|
|
||||||
animate={{ x: [-2, 2, -2], opacity: [0.8, 0.4, 0.8] }}
|
|
||||||
transition={{ duration: 0.3, repeat: Infinity }}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</motion.span>
|
|
||||||
<motion.span
|
|
||||||
className="absolute top-0 left-0 text-[#1C1C1C] z-0"
|
|
||||||
animate={{ x: [2, -2, 2], opacity: [0.8, 0.4, 0.8] }}
|
|
||||||
transition={{ duration: 0.3, repeat: Infinity, delay: 0.15 }}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</motion.span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MagneticButtonProps {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
strength?: number;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MagneticButton({ children, className = '', strength = 0.3, onClick }: MagneticButtonProps) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (!ref.current) {return;}
|
|
||||||
const rect = ref.current.getBoundingClientRect();
|
|
||||||
const centerX = rect.left + rect.width / 2;
|
|
||||||
const centerY = rect.top + rect.height / 2;
|
|
||||||
const x = (e.clientX - centerX) * strength;
|
|
||||||
const y = (e.clientY - centerY) * strength;
|
|
||||||
setPosition({ x, y });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
setPosition({ x: 0, y: 0 });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
ref={ref}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
animate={{ x: position.x, y: position.y }}
|
|
||||||
transition={{ type: 'spring', stiffness: 150, damping: 15, mass: 0.1 }}
|
|
||||||
className={className}
|
|
||||||
onClick={onClick}
|
|
||||||
style={{ display: 'inline-block' }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BlurRevealProps {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
delay?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BlurReveal({ children, className = '', delay = 0 }: BlurRevealProps) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const isInView = useInView(ref, { once: true, margin: '-50px' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
ref={ref}
|
|
||||||
initial={{ filter: 'blur(20px)', opacity: 0, scale: 1.1 }}
|
|
||||||
animate={isInView ? { filter: 'blur(0px)', opacity: 1, scale: 1 } : {}}
|
|
||||||
transition={{ duration: 1, delay, ease: [0.16, 1, 0.3, 1] }}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WaveTextProps {
|
|
||||||
text: string;
|
|
||||||
className?: string;
|
|
||||||
delay?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WaveText({ text, className = '', delay = 0 }: WaveTextProps) {
|
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
|
||||||
const isInView = useInView(ref, { once: true });
|
|
||||||
|
|
||||||
const container = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.05,
|
|
||||||
delayChildren: delay,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const child: Variants = {
|
|
||||||
hidden: { y: 0 },
|
|
||||||
visible: {
|
|
||||||
y: [0, -10, 0],
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
ease: 'easeInOut' as const,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.span
|
|
||||||
ref={ref}
|
|
||||||
variants={container}
|
|
||||||
initial="hidden"
|
|
||||||
animate={isInView ? 'visible' : 'hidden'}
|
|
||||||
className={className}
|
|
||||||
style={{ display: 'inline-block' }}
|
|
||||||
>
|
|
||||||
{text.split('').map((char, index) => (
|
|
||||||
<motion.span
|
|
||||||
key={index}
|
|
||||||
variants={child}
|
|
||||||
style={{ display: 'inline-block' }}
|
|
||||||
transition={{ delay: index * 0.05 }}
|
|
||||||
>
|
|
||||||
{char === ' ' ? '\u00A0' : char}
|
|
||||||
</motion.span>
|
|
||||||
))}
|
|
||||||
</motion.span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RotatingBorderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
borderWidth?: number;
|
|
||||||
duration?: number;
|
|
||||||
colors?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RotatingBorder({
|
|
||||||
children,
|
|
||||||
className = '',
|
|
||||||
borderWidth = 2,
|
|
||||||
duration = 4,
|
|
||||||
colors = ['#C41E3A', '#1C1C1C', '#C41E3A']
|
|
||||||
}: RotatingBorderProps) {
|
|
||||||
const gradient = `conic-gradient(from 0deg, ${colors.join(', ')})`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`relative p-[${borderWidth}px] rounded-lg overflow-hidden ${className}`}>
|
|
||||||
<motion.div
|
|
||||||
className="absolute inset-0"
|
|
||||||
style={{ background: gradient }}
|
|
||||||
animate={{ rotate: 360 }}
|
|
||||||
transition={{ duration, repeat: Infinity, ease: 'linear' }}
|
|
||||||
/>
|
|
||||||
<div className="relative bg-white rounded-md">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShimmerButtonProps {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
shimmerColor?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShimmerButton({ children, className = '', shimmerColor = 'rgba(255,255,255,0.3)', onClick }: ShimmerButtonProps) {
|
|
||||||
return (
|
|
||||||
<motion.button
|
|
||||||
whileHover={{ scale: 1.02 }}
|
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
className={`relative overflow-hidden ${className}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="absolute inset-0"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(90deg, transparent, ${shimmerColor}, transparent)`,
|
|
||||||
backgroundSize: '200% 100%',
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
backgroundPosition: ['-200% 0', '200% 0'],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 1.5,
|
|
||||||
repeat: Infinity,
|
|
||||||
repeatDelay: 2,
|
|
||||||
ease: 'linear',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="relative z-10">{children}</span>
|
|
||||||
</motion.button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InkSplashProps {
|
|
||||||
className?: string;
|
|
||||||
color?: string;
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InkSplash({ className = '', color = '#C41E3A', size = 100 }: InkSplashProps) {
|
|
||||||
const pathVariants: Variants = {
|
|
||||||
hidden: { pathLength: 0, opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
pathLength: 1,
|
|
||||||
opacity: 1,
|
|
||||||
transition: { duration: 1.5, ease: [0.16, 1, 0.3, 1] as const },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.svg
|
|
||||||
viewBox="0 0 100 100"
|
|
||||||
className={className}
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
initial="hidden"
|
|
||||||
animate="visible"
|
|
||||||
>
|
|
||||||
<motion.path
|
|
||||||
d="M50 10 C30 20 20 40 25 60 C30 80 50 90 50 90 C50 90 70 80 75 60 C80 40 70 20 50 10"
|
|
||||||
fill={color}
|
|
||||||
variants={pathVariants}
|
|
||||||
/>
|
|
||||||
</motion.svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CounterWithEffectProps {
|
|
||||||
end: number;
|
|
||||||
duration?: number;
|
|
||||||
prefix?: string;
|
|
||||||
suffix?: string;
|
|
||||||
className?: string;
|
|
||||||
effect?: 'bounce' | 'slide' | 'flip';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CounterWithEffect({
|
|
||||||
end,
|
|
||||||
duration = 2000,
|
|
||||||
prefix = '',
|
|
||||||
suffix = '',
|
|
||||||
className = '',
|
|
||||||
effect = 'bounce'
|
|
||||||
}: CounterWithEffectProps) {
|
|
||||||
const [count, setCount] = useState(end);
|
|
||||||
const [_prevCount, setPrevCount] = useState(0);
|
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
|
||||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
|
||||||
const hasAnimated = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isInView || hasAnimated.current) {return;}
|
|
||||||
hasAnimated.current = true;
|
|
||||||
|
|
||||||
setCount(0);
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
const animate = () => {
|
|
||||||
const progress = Math.min((Date.now() - startTime) / duration, 1);
|
|
||||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
|
||||||
const newCount = Math.floor(easeOutQuart * end);
|
|
||||||
|
|
||||||
if (newCount !== count) {
|
|
||||||
setPrevCount(count);
|
|
||||||
setCount(newCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progress < 1) {
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
}, [isInView, end, duration, count]);
|
|
||||||
|
|
||||||
const effectVariants = {
|
|
||||||
bounce: {
|
|
||||||
initial: { y: 0 },
|
|
||||||
animate: { y: [0, -10, 0] },
|
|
||||||
transition: { duration: 0.3 },
|
|
||||||
},
|
|
||||||
slide: {
|
|
||||||
initial: { y: 20, opacity: 0 },
|
|
||||||
animate: { y: 0, opacity: 1 },
|
|
||||||
transition: { duration: 0.2 },
|
|
||||||
},
|
|
||||||
flip: {
|
|
||||||
initial: { rotateX: 90 },
|
|
||||||
animate: { rotateX: 0 },
|
|
||||||
transition: { duration: 0.2 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.span
|
|
||||||
ref={ref}
|
|
||||||
key={count}
|
|
||||||
initial={effectVariants[effect].initial}
|
|
||||||
animate={effectVariants[effect].animate}
|
|
||||||
transition={effectVariants[effect].transition}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{prefix}{count}{suffix}
|
|
||||||
</motion.span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user