feat(website): 三轮视觉改造与页面过渡动画
改造概要(30项): - 第一轮:Hero重构/Section差异化/SocialProof强化/CTA对比度/About架构 - 第二轮:字体优化/背景交替/Solutions差异化/Footer五列/MegaDropdown修复 - 第三轮:卡片交互/表单层级/CTA统一/时间线标记/连接线/三列布局/移动导航/Button微交互/SEO Schema - P3-2:template.tsx+Framer Motion页面过渡/loading.tsx加载状态 - 清理:删除未用组件/hooks,修复重复移动导航,清理冗余CSS
This commit is contained in:
@@ -1,133 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { AnimatedNumber, StatCard } from './animated-number';
|
||||
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
span: ({ children, ...props }: any) => <span {...props}>{children}</span>,
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
},
|
||||
useInView: () => true,
|
||||
}));
|
||||
|
||||
describe('AnimatedNumber', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render number', () => {
|
||||
render(<AnimatedNumber value={100} />);
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with prefix', () => {
|
||||
render(<AnimatedNumber value={100} prefix="$" />);
|
||||
expect(screen.getByText(/\$0/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with suffix', () => {
|
||||
render(<AnimatedNumber value={100} suffix="+" />);
|
||||
expect(screen.getByText(/0\+/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with prefix and suffix', () => {
|
||||
render(<AnimatedNumber value={100} prefix="$" suffix="+" />);
|
||||
expect(screen.getByText(/\$0\+/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with custom className', () => {
|
||||
const { container } = render(<AnimatedNumber value={100} className="custom-class" />);
|
||||
const element = container.querySelector('.custom-class');
|
||||
expect(element).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Animation', () => {
|
||||
it('should accept duration prop', () => {
|
||||
render(<AnimatedNumber value={100} duration={3000} />);
|
||||
const element = screen.getByText('0');
|
||||
expect(element).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should accept delay prop', () => {
|
||||
render(<AnimatedNumber value={100} delay={500} />);
|
||||
const element = screen.getByText('0');
|
||||
expect(element).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should start from 0', () => {
|
||||
render(<AnimatedNumber value={100} />);
|
||||
const element = screen.getByText('0');
|
||||
expect(element).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle zero value', () => {
|
||||
render(<AnimatedNumber value={0} />);
|
||||
const element = screen.getByText('0');
|
||||
expect(element).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle large numbers', () => {
|
||||
render(<AnimatedNumber value={1000000} />);
|
||||
const element = screen.getByText('0');
|
||||
expect(element).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle decimal numbers', () => {
|
||||
render(<AnimatedNumber value={99} />);
|
||||
const element = screen.getByText('0');
|
||||
expect(element).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatCard', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render stat card', () => {
|
||||
render(<StatCard value={100} label="Users" />);
|
||||
expect(screen.getByText('Users')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with prefix', () => {
|
||||
render(<StatCard value={100} label="Revenue" prefix="$" />);
|
||||
expect(screen.getByText('Revenue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with suffix', () => {
|
||||
render(<StatCard value={100} label="Growth" suffix="%" />);
|
||||
expect(screen.getByText('Growth')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with prefix and suffix', () => {
|
||||
render(<StatCard value={100} label="Score" prefix="+" suffix="pts" />);
|
||||
expect(screen.getByText('Score')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with index', () => {
|
||||
render(<StatCard value={100} label="Users" index={2} />);
|
||||
expect(screen.getByText('Users')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have text-center class', () => {
|
||||
const { container } = render(<StatCard value={100} label="Users" />);
|
||||
const card = container.querySelector('.text-center');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have group class', () => {
|
||||
const { container } = render(<StatCard value={100} label="Users" />);
|
||||
const card = container.querySelector('.group');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
|
||||
interface AnimatedNumberProps {
|
||||
value: number;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
suffix?: string;
|
||||
prefix?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AnimatedNumber({
|
||||
value,
|
||||
duration = 2000,
|
||||
delay = 0,
|
||||
suffix = '',
|
||||
prefix = '',
|
||||
className = '',
|
||||
}: AnimatedNumberProps) {
|
||||
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(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = (now - startTime) / duration;
|
||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
||||
const currentValue = Math.floor(easeOutQuart * value);
|
||||
|
||||
setCount(currentValue);
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}, [isInView, value, 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 StatCardProps {
|
||||
value: number;
|
||||
label: string;
|
||||
suffix?: string;
|
||||
prefix?: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export function StatCard({ value, label, suffix = '', prefix = '', index = 0 }: StatCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="text-center group cursor-default"
|
||||
>
|
||||
<div className="text-3xl sm:text-4xl font-bold tech-gradient-text mb-2">
|
||||
<AnimatedNumber
|
||||
value={value}
|
||||
suffix={suffix}
|
||||
prefix={prefix}
|
||||
delay={index * 100}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--color-text-muted)] group-hover:text-[var(--color-text-tertiary)] transition-colors">
|
||||
{label}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { BackButton } from './back-button';
|
||||
|
||||
describe('BackButton', () => {
|
||||
const mockBack = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockBack.mockClear();
|
||||
// Mock window.history.back
|
||||
Object.defineProperty(window, 'history', {
|
||||
value: {
|
||||
back: mockBack,
|
||||
forward: jest.fn(),
|
||||
go: jest.fn(),
|
||||
length: 1,
|
||||
pushState: jest.fn(),
|
||||
replaceState: jest.fn(),
|
||||
scrollRestoration: 'auto',
|
||||
state: null,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render back button', () => {
|
||||
render(<BackButton />);
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render button text', () => {
|
||||
render(<BackButton />);
|
||||
expect(screen.getByText('返回')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render arrow icon', () => {
|
||||
const { container } = render(<BackButton />);
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interaction', () => {
|
||||
it('should call window.history.back() when clicked', () => {
|
||||
render(<BackButton />);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(mockBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have ghost variant', () => {
|
||||
const { container } = render(<BackButton />);
|
||||
const button = container.querySelector('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have small size', () => {
|
||||
const { container } = render(<BackButton />);
|
||||
const button = container.querySelector('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/**
|
||||
* BackButton - 统一的返回按钮组件
|
||||
*
|
||||
* 在纯静态导出模式下使用 window.history.back() 替代 Next.js 的 router.back(),
|
||||
* 确保在无服务端路由的环境下正常工作。
|
||||
*/
|
||||
export function BackButton() {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[#5C5C5C] hover:text-[#C41E3A] hover:bg-transparent h-auto py-2 px-3"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
返回
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -35,11 +35,11 @@ export function BackToTop() {
|
||||
exit={shouldReduceMotion ? {} : { opacity: 0, y: 20, scale: 0.8 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
onClick={scrollToTop}
|
||||
className="fixed right-4 bottom-20 md:bottom-8 md:right-8 z-40 p-3 bg-[#C41E3A] text-white rounded-full shadow-lg hover:bg-[#A01830] hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-offset-2"
|
||||
className="fixed right-4 bottom-20 md:bottom-8 md:right-8 z-40 p-3 bg-[var(--color-brand-primary)] text-white rounded-full shadow-lg hover:bg-[var(--color-brand-primary-hover)] hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)] focus:ring-offset-2"
|
||||
aria-label="返回顶部"
|
||||
title="返回顶部"
|
||||
style={{
|
||||
boxShadow: '0 4px 14px rgba(196, 30, 58, 0.4)',
|
||||
boxShadow: '0 4px 14px rgba(var(--color-brand-primary-rgb), 0.4)',
|
||||
}}
|
||||
whileHover={shouldReduceMotion ? {} : { scale: 1.1 }}
|
||||
whileTap={shouldReduceMotion ? {} : { scale: 0.95 }}
|
||||
|
||||
@@ -7,21 +7,21 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-[#1C1C1C] focus-visible:ring-2 focus-visible:ring-[#1C1C1C]/50 transition-all duration-300 overflow-hidden",
|
||||
"inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-[var(--color-primary)] focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]/50 transition-all duration-300 overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-[#C41E3A] text-white border-transparent shadow-sm",
|
||||
default: "bg-[var(--color-brand-primary)] text-white border-transparent shadow-sm",
|
||||
secondary:
|
||||
"bg-[#1C1C1C] text-white border-transparent shadow-sm",
|
||||
"bg-[var(--color-primary)] text-white border-transparent shadow-sm",
|
||||
destructive:
|
||||
"bg-[#C41E3A] text-white border-transparent hover:bg-[#A01830]",
|
||||
"bg-[var(--color-brand-primary)] text-white border-transparent hover:bg-[var(--color-brand-primary-hover)]",
|
||||
outline:
|
||||
"border-[#1C1C1C] text-[#1C1C1C] bg-transparent hover:bg-[#F5F5F5]",
|
||||
ghost: "text-[#5C5C5C] hover:text-[#1C1C1C] hover:bg-[#F5F5F5]",
|
||||
success: "bg-[#16A34A] text-white border-transparent hover:bg-[#15803D]",
|
||||
warning: "bg-[#D97706] text-white border-transparent hover:bg-[#B45309]",
|
||||
info: "bg-[#5C5C5C] text-white border-transparent hover:bg-[#3D3D3D]",
|
||||
"border-[var(--color-primary)] text-[var(--color-primary)] bg-transparent hover:bg-[var(--color-primary-lighter)]",
|
||||
ghost: "text-[var(--color-text-placeholder)] hover:text-[var(--color-primary)] hover:bg-[var(--color-primary-lighter)]",
|
||||
success: "bg-[var(--color-success)] text-white border-transparent hover:bg-[var(--color-success-hover)]",
|
||||
warning: "bg-[var(--color-warning)] text-white border-transparent hover:bg-[var(--color-warning-hover)]",
|
||||
info: "bg-[var(--color-info)] text-white border-transparent hover:bg-[var(--color-primary-light)]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('Button Component', () => {
|
||||
it('should apply default variant styles', () => {
|
||||
render(<Button>Default</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-[#C41E3A]');
|
||||
expect(button).toHaveClass('bg-[var(--color-brand-primary)]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,20 +27,20 @@ describe('Button Component', () => {
|
||||
it('should apply secondary variant styles', () => {
|
||||
render(<Button variant="secondary">Secondary</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-[#1C1C1C]');
|
||||
expect(button).toHaveClass('bg-[var(--color-primary)]');
|
||||
});
|
||||
|
||||
it('should apply outline variant styles', () => {
|
||||
render(<Button variant="outline">Outline</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('border-2');
|
||||
expect(button).toHaveClass('border-[#1C1C1C]');
|
||||
expect(button).toHaveClass('border-[var(--color-primary)]');
|
||||
});
|
||||
|
||||
it('should apply ghost variant styles', () => {
|
||||
render(<Button variant="ghost">Ghost</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('text-[#3D3D3D]');
|
||||
expect(button).toHaveClass('text-[var(--color-text-secondary)]');
|
||||
});
|
||||
|
||||
it('should apply link variant styles', () => {
|
||||
@@ -52,7 +52,7 @@ describe('Button Component', () => {
|
||||
it('should apply destructive variant styles', () => {
|
||||
render(<Button variant="destructive">Destructive</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-[#C41E3A]');
|
||||
expect(button).toHaveClass('bg-[var(--color-brand-primary)]');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,22 +7,22 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[#1C1C1C] focus-visible:ring-offset-2 focus-visible:ring-offset-white min-h-[44px] min-w-[44px] touch-manipulation",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-white min-h-[44px] min-w-[44px] touch-manipulation",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-[#C41E3A] text-white hover:bg-[#A01830] hover:shadow-[0_4px_12px_rgba(196,30,58,0.25)] hover:-translate-y-0.5 active:scale-[0.98]",
|
||||
"bg-[var(--color-brand-primary)] text-white hover:bg-[var(--color-brand-primary-hover)] hover:shadow-[0_4px_12px_rgba(var(--color-brand-primary-rgb),0.25)] hover:-translate-y-0.5 active:scale-[0.98]",
|
||||
secondary:
|
||||
"bg-[#1C1C1C] text-white hover:bg-[#0A0A0A] hover:shadow-[0_4px_12px_rgba(28,28,28,0.25)] hover:-translate-y-0.5 active:scale-[0.98]",
|
||||
"bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)] hover:shadow-[0_4px_12px_rgba(var(--color-primary-rgb),0.25)] hover:-translate-y-0.5 active:scale-[0.98]",
|
||||
destructive:
|
||||
"bg-[#C41E3A] text-white hover:bg-[#A01830] focus-visible:ring-[#C41E3A]",
|
||||
"bg-[var(--color-brand-primary)] text-white hover:bg-[var(--color-brand-primary-hover)] focus-visible:ring-[var(--color-brand-primary)]",
|
||||
outline:
|
||||
"border-2 border-[#1C1C1C] bg-transparent text-[#1C1C1C]",
|
||||
"border-2 border-[var(--color-primary)] bg-transparent text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white hover:-translate-y-0.5 active:scale-[0.98]",
|
||||
ghost:
|
||||
"text-[#3D3D3D] hover:bg-[#F5F5F5] hover:text-[#1C1C1C]",
|
||||
"text-[var(--color-primary-light)] hover:bg-[var(--color-primary-lighter)] hover:text-[var(--color-primary)]",
|
||||
link:
|
||||
"text-[#1C1C1C] underline-offset-4 hover:underline hover:text-[#C41E3A]",
|
||||
"text-[var(--color-primary)] underline-offset-4 hover:underline hover:text-[var(--color-brand-primary)]",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-4 py-2",
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('Card Components', () => {
|
||||
it('should apply default styles', () => {
|
||||
render(<Card data-testid="card">Test</Card>);
|
||||
const card = screen.getByTestId('card');
|
||||
expect(card).toHaveClass('bg-[#FAFAFA]');
|
||||
expect(card).toHaveClass('bg-[var(--color-bg-secondary)]');
|
||||
expect(card).toHaveClass('rounded-xl');
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('Card Components', () => {
|
||||
it('should have hover effects', () => {
|
||||
render(<Card data-testid="card">Test</Card>);
|
||||
const card = screen.getByTestId('card');
|
||||
expect(card).toHaveClass('hover:border-[#1C1C1C]');
|
||||
expect(card).toHaveClass('hover:border-[var(--color-text-primary)]');
|
||||
expect(card).toHaveClass('hover:shadow-[0_8px_24px_rgba(0,0,0,0.06)]');
|
||||
});
|
||||
});
|
||||
@@ -88,7 +88,7 @@ describe('Card Components', () => {
|
||||
render(<CardTitle data-testid="title">Test</CardTitle>);
|
||||
const title = screen.getByTestId('title');
|
||||
expect(title).toHaveClass('font-semibold');
|
||||
expect(title).toHaveClass('text-[#1C1C1C]');
|
||||
expect(title).toHaveClass('text-[var(--color-text-primary)]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('Card Components', () => {
|
||||
it('should apply default styles', () => {
|
||||
render(<CardDescription data-testid="desc">Test</CardDescription>);
|
||||
const desc = screen.getByTestId('desc');
|
||||
expect(desc).toHaveClass('text-[#5C5C5C]');
|
||||
expect(desc).toHaveClass('text-[var(--color-text-muted)]');
|
||||
expect(desc).toHaveClass('text-sm');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-[#FAFAFA] text-[#1C1C1C] flex flex-col gap-6 rounded-xl border border-[#E5E5E5] py-6 shadow-[0_2px_8px_rgba(0,0,0,0.04)] transition-all duration-300 hover:border-[#1C1C1C] hover:shadow-[0_8px_24px_rgba(0,0,0,0.06)] hover:-translate-y-1",
|
||||
"bg-[var(--color-bg-section)] text-[var(--color-text-primary)] flex flex-col gap-6 rounded-xl border border-[var(--color-border-primary)] py-6 shadow-[0_2px_8px_rgba(0,0,0,0.04)] transition-all duration-300 hover:border-[var(--color-primary)] hover:shadow-[0_8px_24px_rgba(0,0,0,0.06)] hover:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -32,7 +32,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold text-[#1C1C1C]", className)}
|
||||
className={cn("leading-none font-semibold text-[var(--color-text-primary)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -42,7 +42,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-[#5C5C5C] text-sm", className)}
|
||||
className={cn("text-[var(--color-text-placeholder)] text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -75,7 +75,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6 border-t border-[#E5E5E5]", className)}
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6 border-t border-[var(--color-border-primary)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -23,24 +23,24 @@ interface ScenarioConfig {
|
||||
const scenarioConfig: Record<string, ScenarioConfig> = {
|
||||
isolation: {
|
||||
icon: Lock,
|
||||
accentColor: '#C41E3A',
|
||||
accentColor: 'var(--color-brand-primary)',
|
||||
accentColorRgb: '196, 30, 58',
|
||||
glowStart: '#C41E3A',
|
||||
glowEnd: '#7C3AED',
|
||||
glowStart: 'var(--color-brand-primary)',
|
||||
glowEnd: 'var(--color-accent-purple)',
|
||||
},
|
||||
growth: {
|
||||
icon: TrendingUp,
|
||||
accentColor: '#D97706',
|
||||
accentColor: 'var(--color-warning)',
|
||||
accentColorRgb: '217, 119, 6',
|
||||
glowStart: '#D97706',
|
||||
glowEnd: '#16A34A',
|
||||
glowStart: 'var(--color-warning)',
|
||||
glowEnd: 'var(--color-success)',
|
||||
},
|
||||
compliance: {
|
||||
icon: Shield,
|
||||
accentColor: '#16A34A',
|
||||
accentColor: 'var(--color-success)',
|
||||
accentColorRgb: '22, 163, 74',
|
||||
glowStart: '#16A34A',
|
||||
glowEnd: '#0891B2',
|
||||
glowStart: 'var(--color-success)',
|
||||
glowEnd: 'var(--color-accent-cyan)',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -68,20 +68,20 @@ export function ChallengeCard({ title, description, scenario, href, index }: Cha
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-mono tracking-widest text-[#A3A3A3]">
|
||||
<span className="text-xs font-mono tracking-widest text-[var(--color-text-subtle)]">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 leading-tight tracking-tight text-[#1C1C1C]">
|
||||
<h3 className="text-xl font-semibold mb-3 leading-tight tracking-tight text-[var(--color-text-primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-[#595959] leading-relaxed mb-6 min-h-[3.5rem]">
|
||||
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed mb-6 min-h-[3.5rem]">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-[#C41E3A]">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-[var(--color-brand-primary)]">
|
||||
<span>了解方案</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from './dialog';
|
||||
|
||||
jest.mock('@radix-ui/react-dialog', () => ({
|
||||
Root: ({ children, open }: any) => <div data-open={open}>{children}</div>,
|
||||
Trigger: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
||||
Portal: ({ children }: any) => <div>{children}</div>,
|
||||
Close: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
||||
Overlay: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
Content: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
Title: ({ children, ...props }: any) => <h2 {...props}>{children}</h2>,
|
||||
Description: ({ children, ...props }: any) => <p {...props}>{children}</p>,
|
||||
}));
|
||||
|
||||
describe('Dialog Components', () => {
|
||||
describe('Dialog', () => {
|
||||
it('should render dialog root', () => {
|
||||
render(
|
||||
<Dialog>
|
||||
<div>Dialog Content</div>
|
||||
</Dialog>
|
||||
);
|
||||
expect(screen.getByText('Dialog Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DialogTrigger', () => {
|
||||
it('should render trigger button', () => {
|
||||
render(
|
||||
<Dialog>
|
||||
<DialogTrigger>Open Dialog</DialogTrigger>
|
||||
</Dialog>
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Open Dialog' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DialogContent', () => {
|
||||
it('should render content with children', () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<p>Dialog Body</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
expect(screen.getByText('Dialog Body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent className="custom-class">
|
||||
<p>Test</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
const content = screen.getByText('Test').parentElement;
|
||||
expect(content).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DialogHeader', () => {
|
||||
it('should render header with children', () => {
|
||||
render(<DialogHeader>Header Content</DialogHeader>);
|
||||
expect(screen.getByText('Header Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
render(<DialogHeader className="custom-header">Test</DialogHeader>);
|
||||
const header = screen.getByText('Test');
|
||||
expect(header).toHaveClass('custom-header');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DialogFooter', () => {
|
||||
it('should render footer with children', () => {
|
||||
render(<DialogFooter>Footer Content</DialogFooter>);
|
||||
expect(screen.getByText('Footer Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
render(<DialogFooter className="custom-footer">Test</DialogFooter>);
|
||||
const footer = screen.getByText('Test');
|
||||
expect(footer).toHaveClass('custom-footer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DialogTitle', () => {
|
||||
it('should render title text', () => {
|
||||
render(<DialogTitle>Dialog Title</DialogTitle>);
|
||||
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render as h2 element', () => {
|
||||
render(<DialogTitle>Test Title</DialogTitle>);
|
||||
const title = screen.getByRole('heading', { level: 2 });
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
render(<DialogTitle className="custom-title">Test</DialogTitle>);
|
||||
const title = screen.getByText('Test');
|
||||
expect(title).toHaveClass('custom-title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DialogDescription', () => {
|
||||
it('should render description text', () => {
|
||||
render(<DialogDescription>Dialog Description</DialogDescription>);
|
||||
expect(screen.getByText('Dialog Description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
render(<DialogDescription className="custom-desc">Test</DialogDescription>);
|
||||
const desc = screen.getByText('Test');
|
||||
expect(desc).toHaveClass('custom-desc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dialog Composition', () => {
|
||||
it('should render complete dialog structure', () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Test Dialog</DialogTitle>
|
||||
<DialogDescription>This is a test dialog</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div>Dialog Body</div>
|
||||
<DialogFooter>
|
||||
<button>Close Dialog</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is a test dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dialog Body')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Close Dialog' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible title', () => {
|
||||
render(<DialogTitle>Accessible Title</DialogTitle>);
|
||||
const title = screen.getByRole('heading', { name: 'Accessible Title' });
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should support custom ARIA attributes', () => {
|
||||
render(
|
||||
<DialogContent aria-label="Test Dialog">
|
||||
<p>Content</p>
|
||||
</DialogContent>
|
||||
);
|
||||
const content = screen.getByText('Content').parentElement;
|
||||
expect(content).toHaveAttribute('aria-label', 'Test Dialog');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-1.5 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight text-[#1C1C1C]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-[#5C5C5C]', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -1,164 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
} from './dropdown-menu';
|
||||
|
||||
describe('DropdownMenu', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render dropdown menu trigger', () => {
|
||||
render(
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Item 1</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
expect(screen.getByText('Open Menu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render menu items', () => {
|
||||
render(
|
||||
<DropdownMenu defaultOpen>
|
||||
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Item 1</DropdownMenuItem>
|
||||
<DropdownMenuItem>Item 2</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render menu label', () => {
|
||||
render(
|
||||
<DropdownMenu defaultOpen>
|
||||
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Menu Label</DropdownMenuLabel>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
expect(screen.getByText('Menu Label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render separator', () => {
|
||||
render(
|
||||
<DropdownMenu defaultOpen>
|
||||
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Item 1</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Item 2</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
const separator = document.querySelector('[role="separator"]');
|
||||
expect(separator).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interaction', () => {
|
||||
it('should open menu on trigger click', () => {
|
||||
render(
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Item 1</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
const trigger = screen.getByText('Open Menu');
|
||||
fireEvent.click(trigger);
|
||||
|
||||
expect(trigger).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should close menu on item click', () => {
|
||||
const onSelect = jest.fn();
|
||||
render(
|
||||
<DropdownMenu defaultOpen>
|
||||
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onSelect={onSelect}>Item 1</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
const item = screen.getByText('Item 1');
|
||||
fireEvent.click(item);
|
||||
|
||||
expect(onSelect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DropdownMenuShortcut', () => {
|
||||
it('should render shortcut text', () => {
|
||||
render(
|
||||
<DropdownMenu defaultOpen>
|
||||
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
Save
|
||||
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
expect(screen.getByText('Save')).toBeInTheDocument();
|
||||
expect(screen.getByText('⌘S')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should apply custom className to trigger', () => {
|
||||
render(
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="custom-trigger">Open Menu</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Item 1</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
expect(screen.getByText('Open Menu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className to content', () => {
|
||||
render(
|
||||
<DropdownMenu defaultOpen>
|
||||
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="custom-content">
|
||||
<DropdownMenuItem>Item 1</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className to item', () => {
|
||||
render(
|
||||
<DropdownMenu defaultOpen>
|
||||
<DropdownMenuTrigger>Open Menu</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="custom-item">Item 1</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,224 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-[#F0F0F0] dark:focus:bg-[#333333] data-[state=open]:bg-[#F0F0F0] dark:data-[state=open]:bg-[#333333]',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<svg
|
||||
className="ml-auto h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-[#E5E5E5] dark:border-[#333333] bg-white dark:bg-[#171717] p-1 text-[#171717] dark:text-[#FAFAFA] shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-[#E5E5E5] dark:border-[#333333] bg-white dark:bg-[#171717] p-1 text-[#171717] dark:text-[#FAFAFA] shadow-lg',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-[#F0F0F0] dark:focus:bg-[#333333] focus:text-[#171717] dark:focus:text-[#FAFAFA] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-[#F0F0F0] dark:focus:bg-[#333333] focus:text-[#171717] dark:focus:text-[#FAFAFA] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-[#F0F0F0] dark:focus:bg-[#333333] focus:text-[#171717] dark:focus:text-[#FAFAFA] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<svg
|
||||
className="h-2 w-2 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-[#E5E5E5] dark:bg-[#333333]', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -151,7 +151,7 @@ describe('ErrorBoundary', () => {
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: '重试' });
|
||||
expect(button).toHaveClass('bg-[#C41E3A]');
|
||||
expect(button).toHaveClass('bg-[var(--color-brand-primary)]');
|
||||
expect(button).toHaveClass('text-white');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,13 +48,13 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-4">出错了</h2>
|
||||
<p className="text-[#5C5C5C] mb-6">
|
||||
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">出错了</h2>
|
||||
<p className="text-[var(--color-text-placeholder)] mb-6">
|
||||
抱歉,页面出现了问题。请刷新页面重试。
|
||||
</p>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: undefined })}
|
||||
className="px-6 py-2.5 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] transition-colors focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-offset-2"
|
||||
className="px-6 py-2.5 bg-[var(--color-brand-primary)] text-white rounded-lg hover:bg-[var(--color-brand-primary-hover)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)] focus:ring-offset-2"
|
||||
aria-label="重试"
|
||||
>
|
||||
重试
|
||||
|
||||
@@ -18,16 +18,14 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
|
||||
return (
|
||||
<div className="relative w-14 h-20 sm:w-16 sm:h-24 md:w-20 md:h-28 perspective-1000">
|
||||
{/* 背景卡片 - 静态显示当前数字 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-white via-white to-gray-50 rounded-lg shadow-xl overflow-hidden border border-gray-200">
|
||||
{/* 上半部分 */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1/2 bg-gradient-to-b from-white to-gray-50 border-b border-gray-300 overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[#C41E3A]">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] via-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] rounded-lg shadow-xl overflow-hidden border border-[var(--color-flip-card-border)]">
|
||||
<div className="absolute top-0 left-0 right-0 h-1/2 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] border-b border-[var(--color-flip-card-border)] overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[var(--color-brand-primary)]">
|
||||
{digit}
|
||||
</div>
|
||||
</div>
|
||||
{/* 下半部分 */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1/2 bg-gradient-to-b from-white to-gray-50 overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[#C41E3A]">
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1/2 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[var(--color-brand-primary)]">
|
||||
{digit}
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,13 +40,13 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
|
||||
animate={{ rotateX: -180 }}
|
||||
exit={{ rotateX: -180 }}
|
||||
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="absolute inset-0 bg-gradient-to-b from-white to-gray-50 rounded-t-lg overflow-hidden border-t border-l border-r border-gray-200"
|
||||
className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] rounded-t-lg overflow-hidden border-t border-l border-r border-[var(--color-flip-card-border)]"
|
||||
style={{
|
||||
transformOrigin: 'bottom',
|
||||
backfaceVisibility: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[#C41E3A]">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[var(--color-brand-primary)]">
|
||||
{prevDigit}
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -60,22 +58,21 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
|
||||
animate={{ rotateX: 0 }}
|
||||
exit={{ rotateX: 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="absolute inset-0 bg-gradient-to-b from-white to-gray-50 rounded-b-lg overflow-hidden border-b border-l border-r border-gray-200"
|
||||
className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] rounded-b-lg overflow-hidden border-b border-l border-r border-[var(--color-flip-card-border)]"
|
||||
style={{
|
||||
transformOrigin: 'top',
|
||||
backfaceVisibility: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[#C41E3A]">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[var(--color-brand-primary)]">
|
||||
{digit}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 中间分割线和装饰 */}
|
||||
<div className="absolute top-1/2 left-0 right-0 h-px bg-gray-400 transform -translate-y-1/2" />
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-gray-300/50" />
|
||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-gray-300/50" />
|
||||
<div className="absolute top-1/2 left-0 right-0 h-px bg-[var(--color-flip-card-divider)] transform -translate-y-1/2" />
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-[var(--color-flip-card-divider-subtle)]/50" />
|
||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-[var(--color-flip-card-divider-subtle)]/50" />
|
||||
|
||||
{/* 侧面阴影增强立体感 */}
|
||||
<div className="absolute inset-0 rounded-lg shadow-[inset_0_2px_4px_rgba(0,0,0,0.1)] pointer-events-none" />
|
||||
@@ -116,7 +113,7 @@ function FlipCard({ value, label, maxDigits = 2 }: FlipCardProps) {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm sm:text-base text-[#5C5C5C] font-medium">{label}</div>
|
||||
<div className="text-sm sm:text-base text-[var(--color-text-placeholder)] font-medium">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,21 +126,21 @@ interface FlipClockProps {
|
||||
|
||||
export function FlipClock({ years, months, days }: FlipClockProps) {
|
||||
return (
|
||||
<div className="bg-[#FFFBF5] rounded-2xl p-8 border border-[#E5E5E5]">
|
||||
<h2 className="text-2xl font-bold text-[#1C1C1C] mb-6">运营时长</h2>
|
||||
<p className="text-[#5C5C5C] mb-6 leading-relaxed">
|
||||
自 <span className="font-semibold text-[#1C1C1C]">2026 年 1 月 15 日</span> 成立以来
|
||||
<div className="bg-[var(--color-bg-secondary)] rounded-2xl p-8 border border-[var(--color-border-primary)]">
|
||||
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-6">运营时长</h2>
|
||||
<p className="text-[var(--color-text-placeholder)] mb-6 leading-relaxed">
|
||||
自 <span className="font-semibold text-[var(--color-text-primary)]">2026 年 1 月 15 日</span> 成立以来
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap justify-center items-center gap-4 sm:gap-6 mb-6">
|
||||
<FlipCard value={years} label="年" maxDigits={2} />
|
||||
<div className="text-2xl sm:text-3xl font-bold text-[#C41E3A]">:</div>
|
||||
<div className="text-2xl sm:text-3xl font-bold text-[var(--color-brand-primary)]">:</div>
|
||||
<FlipCard value={months} label="个月" maxDigits={2} />
|
||||
<div className="text-2xl sm:text-3xl font-bold text-[#C41E3A]">:</div>
|
||||
<div className="text-2xl sm:text-3xl font-bold text-[var(--color-brand-primary)]">:</div>
|
||||
<FlipCard value={days} label="天" maxDigits={3} />
|
||||
</div>
|
||||
|
||||
<p className="text-[#5C5C5C] leading-relaxed font-medium">
|
||||
<p className="text-[var(--color-text-placeholder)] leading-relaxed font-medium">
|
||||
持续打磨产品,致力于为企业提供优质的数字化转型服务
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,583 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
|
||||
interface InkDropProps {
|
||||
size?: number;
|
||||
opacity?: number;
|
||||
color?: string;
|
||||
blur?: number;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InkDrop({
|
||||
size = 20,
|
||||
opacity = 0.15,
|
||||
color = '#1C1C1C',
|
||||
blur = 0,
|
||||
delay = 0,
|
||||
duration: _duration = 8,
|
||||
className = ''
|
||||
}: InkDropProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={`absolute rounded-full ${className}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: color,
|
||||
opacity,
|
||||
filter: blur > 0 ? `blur(${blur}px)` : 'none',
|
||||
}}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{
|
||||
scale: [0.8, 1.2, 1],
|
||||
opacity: [0, opacity, opacity],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface InkSplashProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InkSplash({
|
||||
size = 60,
|
||||
color = '#C41E3A',
|
||||
opacity = 0.2,
|
||||
delay = 0,
|
||||
className = ''
|
||||
}: InkSplashProps) {
|
||||
return (
|
||||
<motion.svg
|
||||
viewBox="0 0 100 100"
|
||||
width={size}
|
||||
height={size}
|
||||
className={`absolute ${className}`}
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity, scale: 1 }}
|
||||
transition={{ duration: 1.2, delay, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<motion.path
|
||||
d="M50 10 Q30 25 35 50 Q30 75 50 90 Q70 75 65 50 Q70 25 50 10"
|
||||
fill={color}
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: 2, delay: delay + 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface SealStampProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SealStamp({
|
||||
size = 40,
|
||||
color = '#C41E3A',
|
||||
opacity = 0.15,
|
||||
delay = 0,
|
||||
className = ''
|
||||
}: SealStampProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={`absolute ${className}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
border: `2px solid ${color}`,
|
||||
borderRadius: '4px',
|
||||
opacity: 0,
|
||||
transform: 'rotate(-8deg)',
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 1.5, rotate: -20 }}
|
||||
animate={{ opacity, scale: 1, rotate: -8 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 15,
|
||||
delay,
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-1 border border-current"
|
||||
style={{ borderColor: color, opacity: 0.5 }}
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: delay + 0.3 }}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InkStainProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
blur?: number;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InkStain({
|
||||
size = 100,
|
||||
color = '#1C1C1C',
|
||||
opacity = 0.05,
|
||||
blur = 20,
|
||||
delay = 0,
|
||||
className = ''
|
||||
}: InkStainProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={`absolute ${className}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size * 0.8,
|
||||
backgroundColor: color,
|
||||
borderRadius: '50% 40% 60% 45%',
|
||||
opacity: 0,
|
||||
filter: `blur(${blur}px)`,
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity, scale: 1 }}
|
||||
transition={{ duration: 2, delay, ease: [0.16, 1, 0.3, 1] }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface InkLineProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InkLine({
|
||||
width = 200,
|
||||
height = 2,
|
||||
color = '#1C1C1C',
|
||||
opacity = 0.1,
|
||||
delay = 0,
|
||||
className = ''
|
||||
}: InkLineProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={`absolute ${className}`}
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
backgroundColor: color,
|
||||
opacity: 0,
|
||||
borderRadius: height / 2,
|
||||
}}
|
||||
initial={{ opacity: 0, scaleX: 0 }}
|
||||
animate={{ opacity, scaleX: 1 }}
|
||||
transition={{ duration: 1.5, delay, ease: [0.16, 1, 0.3, 1] }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface BrushStrokeProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BrushStroke({
|
||||
width = 150,
|
||||
height = 30,
|
||||
color = '#C41E3A',
|
||||
opacity = 0.12,
|
||||
delay = 0,
|
||||
className = ''
|
||||
}: BrushStrokeProps) {
|
||||
return (
|
||||
<motion.svg
|
||||
viewBox="0 0 150 30"
|
||||
width={width}
|
||||
height={height}
|
||||
className={`absolute ${className}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity }}
|
||||
transition={{ duration: 1, delay }}
|
||||
>
|
||||
<motion.path
|
||||
d="M0 15 Q20 5 40 15 Q60 25 80 15 Q100 5 120 15 Q135 20 150 15"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 2, delay: delay + 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface FloatingInkProps {
|
||||
count?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FloatingInk({ count = 15, className = '' }: FloatingInkProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const elements = useMemo(() => {
|
||||
if (!isMounted) {return [];}
|
||||
const items = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const type = i % 5;
|
||||
const baseDelay = i * 0.15;
|
||||
|
||||
items.push({
|
||||
id: i,
|
||||
type,
|
||||
delay: baseDelay,
|
||||
props: {
|
||||
left: `${10 + Math.random() * 80}%`,
|
||||
top: `${10 + Math.random() * 80}%`,
|
||||
},
|
||||
animX: Math.random() * 10 - 5,
|
||||
animDuration: 6 + Math.random() * 4,
|
||||
size: type === 0 ? 8 + Math.random() * 16 :
|
||||
type === 1 ? 4 + Math.random() * 8 :
|
||||
type === 2 ? 20 + Math.random() * 30 :
|
||||
type === 3 ? 60 + Math.random() * 80 : 80 + Math.random() * 100,
|
||||
opacity: type === 0 ? 0.08 + Math.random() * 0.1 :
|
||||
type === 1 ? 0.1 + Math.random() * 0.15 :
|
||||
type === 2 ? 0.08 + Math.random() * 0.08 :
|
||||
type === 3 ? 0.03 + Math.random() * 0.04 : 0.06 + Math.random() * 0.08,
|
||||
blur: type === 0 ? Math.random() * 2 : 0,
|
||||
height: type === 4 ? 15 + Math.random() * 20 : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [count, isMounted]);
|
||||
|
||||
return (
|
||||
<div className={`absolute inset-0 pointer-events-none overflow-hidden ${className}`}>
|
||||
{elements.map((el) => (
|
||||
<motion.div
|
||||
key={el.id}
|
||||
className="absolute"
|
||||
style={el.props}
|
||||
animate={{
|
||||
y: [0, -20, 0],
|
||||
x: [0, el.animX, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: el.animDuration,
|
||||
delay: el.delay,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
{el.type === 0 && (
|
||||
<InkDrop
|
||||
size={el.size}
|
||||
opacity={el.opacity}
|
||||
blur={el.blur}
|
||||
delay={el.delay}
|
||||
/>
|
||||
)}
|
||||
{el.type === 1 && (
|
||||
<InkDrop
|
||||
size={el.size}
|
||||
opacity={el.opacity}
|
||||
color="#C41E3A"
|
||||
delay={el.delay}
|
||||
/>
|
||||
)}
|
||||
{el.type === 2 && (
|
||||
<SealStamp
|
||||
size={el.size}
|
||||
opacity={el.opacity}
|
||||
delay={el.delay}
|
||||
/>
|
||||
)}
|
||||
{el.type === 3 && (
|
||||
<InkStain
|
||||
size={el.size}
|
||||
opacity={el.opacity}
|
||||
delay={el.delay}
|
||||
/>
|
||||
)}
|
||||
{el.type === 4 && (
|
||||
<BrushStroke
|
||||
width={el.size}
|
||||
height={el.height}
|
||||
opacity={el.opacity}
|
||||
delay={el.delay}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InkDecorationProps {
|
||||
variant?: 'minimal' | 'balanced' | 'rich';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DropPosition {
|
||||
left: string;
|
||||
top: string;
|
||||
size: number;
|
||||
opacity: number;
|
||||
blur: number;
|
||||
isRed: boolean;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface SplashPosition {
|
||||
left: string;
|
||||
top: string;
|
||||
size: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface SealPosition {
|
||||
left: string;
|
||||
top: string;
|
||||
size: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface StainPosition {
|
||||
left: string;
|
||||
top: string;
|
||||
size: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface StrokePosition {
|
||||
left: string;
|
||||
top: string;
|
||||
width: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export function InkDecoration({ variant = 'balanced', className = '' }: InkDecorationProps) {
|
||||
const [dropPositions, setDropPositions] = useState<DropPosition[]>([]);
|
||||
const [splashPositions, setSplashPositions] = useState<SplashPosition[]>([]);
|
||||
const [sealPositions, setSealPositions] = useState<SealPosition[]>([]);
|
||||
const [stainPositions, setStainPositions] = useState<StainPosition[]>([]);
|
||||
const [strokePositions, setStrokePositions] = useState<StrokePosition[]>([]);
|
||||
|
||||
const config = {
|
||||
minimal: { drops: 3, splashes: 1, seals: 1, stains: 1, strokes: 1 },
|
||||
balanced: { drops: 5, splashes: 2, seals: 2, stains: 2, strokes: 2 },
|
||||
rich: { drops: 8, splashes: 3, seals: 3, stains: 3, strokes: 3 },
|
||||
};
|
||||
|
||||
const { drops, splashes, seals, stains, strokes } = config[variant];
|
||||
|
||||
useEffect(() => {
|
||||
setDropPositions(Array.from({ length: drops }, (_, i) => ({
|
||||
left: `${15 + (i * 70 / drops)}%`,
|
||||
top: `${20 + Math.random() * 60}%`,
|
||||
size: 6 + Math.random() * 14,
|
||||
opacity: 0.06 + Math.random() * 0.1,
|
||||
blur: Math.random() * 3,
|
||||
isRed: i % 3 === 0,
|
||||
duration: 5 + Math.random() * 3,
|
||||
})));
|
||||
|
||||
setSplashPositions(Array.from({ length: splashes }, (_, i) => ({
|
||||
left: `${20 + (i * 60 / splashes)}%`,
|
||||
top: `${15 + Math.random() * 70}%`,
|
||||
size: 40 + Math.random() * 40,
|
||||
duration: 7 + Math.random() * 3,
|
||||
})));
|
||||
|
||||
setSealPositions(Array.from({ length: seals }, (_, i) => ({
|
||||
left: `${25 + (i * 50 / seals)}%`,
|
||||
top: `${25 + Math.random() * 50}%`,
|
||||
size: 25 + Math.random() * 25,
|
||||
duration: 6 + Math.random() * 2,
|
||||
})));
|
||||
|
||||
setStainPositions(Array.from({ length: stains }, (_, i) => ({
|
||||
left: `${10 + (i * 80 / stains)}%`,
|
||||
top: `${30 + Math.random() * 40}%`,
|
||||
size: 80 + Math.random() * 60,
|
||||
duration: 8 + Math.random() * 4,
|
||||
})));
|
||||
|
||||
setStrokePositions(Array.from({ length: strokes }, (_, i) => ({
|
||||
left: `${15 + (i * 70 / strokes)}%`,
|
||||
top: `${40 + Math.random() * 30}%`,
|
||||
width: 100 + Math.random() * 100,
|
||||
duration: 6 + Math.random() * 3,
|
||||
})));
|
||||
}, [drops, splashes, seals, stains, strokes]);
|
||||
|
||||
return (
|
||||
<div className={`absolute inset-0 pointer-events-none overflow-hidden ${className}`}>
|
||||
{dropPositions.map((pos, i) => (
|
||||
<motion.div
|
||||
key={`drop-${i}`}
|
||||
className="absolute"
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
animate={{
|
||||
y: [0, -15, 0],
|
||||
scale: [1, 1.1, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: pos.duration,
|
||||
delay: i * 0.2,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
<InkDrop
|
||||
size={pos.size}
|
||||
opacity={pos.opacity}
|
||||
blur={pos.blur}
|
||||
color={pos.isRed ? '#C41E3A' : '#1C1C1C'}
|
||||
delay={i * 0.1}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{splashPositions.map((pos, i) => (
|
||||
<motion.div
|
||||
key={`splash-${i}`}
|
||||
className="absolute"
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
animate={{
|
||||
y: [0, -10, 0],
|
||||
rotate: [0, 5, -5, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: pos.duration,
|
||||
delay: i * 0.3,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
<InkSplash size={pos.size} opacity={0.12} delay={i * 0.15} />
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{sealPositions.map((pos, i) => (
|
||||
<motion.div
|
||||
key={`seal-${i}`}
|
||||
className="absolute"
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
animate={{
|
||||
y: [0, -8, 0],
|
||||
rotate: [-8, -5, -10, -8],
|
||||
}}
|
||||
transition={{
|
||||
duration: pos.duration,
|
||||
delay: i * 0.25,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
<SealStamp size={pos.size} opacity={0.1} delay={i * 0.2} />
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{stainPositions.map((pos, i) => (
|
||||
<motion.div
|
||||
key={`stain-${i}`}
|
||||
className="absolute"
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
animate={{
|
||||
scale: [1, 1.05, 1],
|
||||
opacity: [0.04, 0.06, 0.04],
|
||||
}}
|
||||
transition={{
|
||||
duration: pos.duration,
|
||||
delay: i * 0.35,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
<InkStain size={pos.size} opacity={0.05} delay={i * 0.1} />
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{strokePositions.map((pos, i) => (
|
||||
<motion.div
|
||||
key={`stroke-${i}`}
|
||||
className="absolute"
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
animate={{
|
||||
x: [0, 10, 0],
|
||||
opacity: [0.08, 0.12, 0.08],
|
||||
}}
|
||||
transition={{
|
||||
duration: pos.duration,
|
||||
delay: i * 0.3,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
<BrushStroke width={pos.width} opacity={0.1} delay={i * 0.15} />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InkBackground() {
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 2 }}
|
||||
className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-[radial-gradient(ellipse_at_center,rgba(28,28,28,0.02)_0%,transparent_70%)]"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 2, delay: 0.3 }}
|
||||
className="absolute bottom-0 right-1/4 w-[400px] h-[400px] bg-[radial-gradient(ellipse_at_center,rgba(196,30,58,0.03)_0%,transparent_60%)]"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 2, delay: 0.6 }}
|
||||
className="absolute top-1/3 right-0 w-[300px] h-[300px] bg-[radial-gradient(ellipse_at_center,rgba(28,28,28,0.015)_0%,transparent_50%)]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,8 @@ interface InkGlowCardProps {
|
||||
}
|
||||
|
||||
const DEFAULT_ACCENT = '196, 30, 58';
|
||||
const DEFAULT_GLOW_START = '#C41E3A';
|
||||
const DEFAULT_GLOW_END = '#D97706';
|
||||
const DEFAULT_GLOW_START = 'var(--color-brand-primary)';
|
||||
const DEFAULT_GLOW_END = 'var(--color-warning)';
|
||||
|
||||
export function InkGlowCard({
|
||||
children,
|
||||
@@ -75,12 +75,12 @@ export function InkGlowCard({
|
||||
{href ? (
|
||||
<StaticLink
|
||||
href={href}
|
||||
className="relative block rounded-2xl bg-white overflow-hidden transition-all duration-500"
|
||||
className="relative block rounded-2xl bg-[var(--color-bg-primary)] overflow-hidden transition-all duration-500"
|
||||
style={{
|
||||
boxShadow: isHovered
|
||||
? `0 16px 32px rgba(0,0,0,0.08), 0 0 0 1px rgba(${accentColorRgb}, 0.08)`
|
||||
? `0 20px 40px rgba(0,0,0,0.1), 0 0 0 1px rgba(${accentColorRgb}, 0.12)`
|
||||
: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
transform: isHovered ? 'translateY(-4px)' : 'translateY(0)',
|
||||
transform: isHovered ? 'translateY(-6px)' : 'translateY(0)',
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
@@ -90,12 +90,12 @@ export function InkGlowCard({
|
||||
</StaticLink>
|
||||
) : (
|
||||
<div
|
||||
className="relative rounded-2xl bg-white overflow-hidden transition-all duration-500"
|
||||
className="relative rounded-2xl bg-[var(--color-bg-primary)] overflow-hidden transition-all duration-500"
|
||||
style={{
|
||||
boxShadow: isHovered
|
||||
? `0 16px 32px rgba(0,0,0,0.08), 0 0 0 1px rgba(${accentColorRgb}, 0.08)`
|
||||
? `0 20px 40px rgba(0,0,0,0.1), 0 0 0 1px rgba(${accentColorRgb}, 0.12)`
|
||||
: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
transform: isHovered ? 'translateY(-4px)' : 'translateY(0)',
|
||||
transform: isHovered ? 'translateY(-6px)' : 'translateY(0)',
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import '@testing-library/jest-dom';
|
||||
import { Input } from './input';
|
||||
|
||||
jest.mock('@/lib/utils', () => ({
|
||||
cn: (...args: any[]) => args.filter(Boolean).join(' '),
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
describe('Input', () => {
|
||||
@@ -166,7 +166,7 @@ describe('Input', () => {
|
||||
it('should have error styling when error exists', () => {
|
||||
render(<Input error="错误信息" />);
|
||||
const input = screen.getByRole('textbox');
|
||||
expect(input.className).toMatch(/border-\[#C41E3A\]/);
|
||||
expect(input.className).toMatch(/border-\[var\(--color-brand-primary\)\]/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-[#3D3D3D] mb-2"
|
||||
className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2"
|
||||
>
|
||||
{label}
|
||||
{props.required && <span className="text-[#C41E3A] ml-1">*</span>}
|
||||
{props.required && <span className="text-[var(--color-brand-primary)] ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
@@ -30,10 +30,10 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
data-slot="input"
|
||||
data-testid={props['data-testid']}
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-[#8C8C8C] selection:bg-[#1C1C1C] selection:text-white h-14 min-h-[56px] w-full min-w-0 rounded-lg border border-[#E5E5E5] bg-[#FAFAFA] px-4 py-3 text-base text-[#1C1C1C] shadow-sm transition-all duration-300 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 touch-manipulation md:h-12 md:min-h-[44px] md:py-2",
|
||||
"focus-visible:border-[#1C1C1C] focus-visible:ring-2 focus-visible:ring-[#1C1C1C]/50 focus-visible:shadow-lg focus-visible:shadow-[#1C1C1C]/20",
|
||||
"hover:border-[#3D3D3D]",
|
||||
error && "border-[#C41E3A] focus-visible:border-[#C41E3A] focus-visible:ring-[#C41E3A]/50",
|
||||
"file:text-foreground placeholder:text-[var(--color-text-hint)] selection:bg-[var(--color-primary)] selection:text-white h-14 min-h-[56px] w-full min-w-0 rounded-lg border border-[var(--color-border-primary)] bg-[var(--color-bg-section)] px-4 py-3 text-base text-[var(--color-text-primary)] shadow-sm transition-all duration-300 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 touch-manipulation md:h-12 md:min-h-[44px] md:py-2",
|
||||
"focus-visible:border-[var(--color-primary)] focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]/50 focus-visible:shadow-lg focus-visible:shadow-[var(--color-primary)]/20",
|
||||
"hover:border-[var(--color-text-secondary)]",
|
||||
error && "border-[var(--color-brand-primary)] focus-visible:border-[var(--color-brand-primary)] focus-visible:ring-[var(--color-brand-primary)]/50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -43,7 +43,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p id={errorId} className="mt-1 text-sm text-[#C41E3A]" role="alert" data-testid="error-message">
|
||||
<p id={errorId} className="mt-1 text-sm text-[var(--color-brand-primary)]" role="alert" data-testid="error-message">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { InsightCard } from './insight-card';
|
||||
|
||||
jest.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: ({ src, alt, fill, className }: any) => (
|
||||
<img src={src} alt={alt} className={className} data-fill={fill} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/ink-glow-card', () => ({
|
||||
InkGlowCard: ({ children, className, href }: { children: React.ReactNode; className?: string; href?: string }) => (
|
||||
<article className={className} data-href={href}>
|
||||
{children}
|
||||
</article>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/badge', () => ({
|
||||
Badge: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<span className={className}>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lucide-react', () => ({
|
||||
Calendar: () => <span data-testid="calendar-icon" />,
|
||||
Clock: () => <span data-testid="clock-icon" />,
|
||||
ArrowRight: () => <span data-testid="arrow-right-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock('next/link', () => {
|
||||
const MockLink = ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
|
||||
<a href={href} {...props}>{children}</a>
|
||||
);
|
||||
MockLink.displayName = 'MockLink';
|
||||
return MockLink;
|
||||
});
|
||||
|
||||
describe('InsightCard', () => {
|
||||
const defaultProps = {
|
||||
title: 'Test Title',
|
||||
excerpt: 'Test excerpt',
|
||||
category: 'Technology',
|
||||
readTime: '5 min',
|
||||
publishedAt: '2026-01-01',
|
||||
href: '/test-article',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render insight card', () => {
|
||||
render(<InsightCard {...defaultProps} />);
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
render(<InsightCard {...defaultProps} />);
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render excerpt', () => {
|
||||
render(<InsightCard {...defaultProps} />);
|
||||
expect(screen.getByText('Test excerpt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render category badge', () => {
|
||||
render(<InsightCard {...defaultProps} />);
|
||||
expect(screen.getByText('Technology')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render read time', () => {
|
||||
render(<InsightCard {...defaultProps} />);
|
||||
expect(screen.getByText('5 min')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render published date', () => {
|
||||
render(<InsightCard {...defaultProps} />);
|
||||
expect(screen.getByText('2026-01-01')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render read more link', () => {
|
||||
render(<InsightCard {...defaultProps} />);
|
||||
expect(screen.getByText('阅读更多')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Image', () => {
|
||||
it('should render image when imageUrl is provided', () => {
|
||||
render(<InsightCard {...defaultProps} imageUrl="/test.jpg" />);
|
||||
const image = screen.getByAltText('Test Title');
|
||||
expect(image).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render image when imageUrl is not provided', () => {
|
||||
render(<InsightCard {...defaultProps} />);
|
||||
const image = screen.queryByRole('img');
|
||||
expect(image).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Featured', () => {
|
||||
it('should not be featured by default', () => {
|
||||
const { container } = render(<InsightCard {...defaultProps} />);
|
||||
const article = container.querySelector('article');
|
||||
expect(article).not.toHaveClass('md:col-span-2');
|
||||
});
|
||||
|
||||
it('should be featured when featured prop is true', () => {
|
||||
const { container } = render(<InsightCard {...defaultProps} featured />);
|
||||
const article = container.querySelector('article');
|
||||
expect(article).toHaveClass('md:col-span-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have article element', () => {
|
||||
const { container } = render(<InsightCard {...defaultProps} />);
|
||||
const article = container.querySelector('article');
|
||||
expect(article).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass featured class to InkGlowCard', () => {
|
||||
const { container } = render(<InsightCard {...defaultProps} featured />);
|
||||
const article = container.querySelector('article');
|
||||
expect(article).toHaveClass('md:col-span-2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { Calendar, Clock, ArrowRight } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { InkGlowCard } from '@/components/ui/ink-glow-card';
|
||||
|
||||
export interface InsightCardProps {
|
||||
title: string;
|
||||
excerpt: string;
|
||||
category: string;
|
||||
readTime: string;
|
||||
publishedAt: string;
|
||||
imageUrl?: string;
|
||||
href: string;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
export function InsightCard({
|
||||
title,
|
||||
excerpt,
|
||||
category,
|
||||
readTime,
|
||||
publishedAt,
|
||||
imageUrl,
|
||||
href,
|
||||
featured = false,
|
||||
}: InsightCardProps) {
|
||||
return (
|
||||
<InkGlowCard
|
||||
href={href}
|
||||
accentColorRgb="196, 30, 58"
|
||||
glowStart="#C41E3A"
|
||||
glowEnd="#D97706"
|
||||
className={featured ? 'md:col-span-2' : ''}
|
||||
>
|
||||
{imageUrl && (
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={title}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{category}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1 text-xs text-[#A3A3A3]">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{readTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-2 line-clamp-2">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-[#595959] mb-5 line-clamp-2">
|
||||
{excerpt}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-xs text-[#A3A3A3]">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>{publishedAt}</span>
|
||||
</div>
|
||||
|
||||
<span className="inline-flex items-center gap-1 text-sm font-medium text-[#C41E3A]">
|
||||
阅读更多
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</InkGlowCard>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ describe('Loading Skeleton Components', () => {
|
||||
const { container } = render(<Skeleton />);
|
||||
const skeleton = container.firstChild as HTMLElement;
|
||||
expect(skeleton).toHaveClass('animate-pulse');
|
||||
expect(skeleton).toHaveClass('bg-[#F5F5F5]');
|
||||
expect(skeleton).toHaveClass('bg-[var(--color-primary-lighter)]');
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ export function Skeleton({ className }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-pulse rounded-md bg-[#F5F5F5]',
|
||||
'animate-pulse rounded-md bg-[var(--color-bg-hover)]',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
@@ -19,7 +19,7 @@ export function Skeleton({ className }: SkeletonProps) {
|
||||
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<div className="bg-[#FAFAFA] rounded-xl border border-[#E5E5E5] p-6">
|
||||
<div className="bg-[var(--color-bg-section)] rounded-xl border border-[var(--color-border-primary)] p-6">
|
||||
<Skeleton className="h-12 w-12 rounded-xl mb-4" />
|
||||
<Skeleton className="h-6 w-3/4 mb-3" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
@@ -30,7 +30,7 @@ export function CardSkeleton() {
|
||||
|
||||
export function ServiceCardSkeleton() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#E5E5E5] p-6 h-full">
|
||||
<div className="bg-[var(--color-bg-primary)] rounded-xl border border-[var(--color-border-primary)] p-6 h-full">
|
||||
<Skeleton className="h-14 w-14 rounded-xl mb-4" />
|
||||
<Skeleton className="h-6 w-3/4 mb-3" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
@@ -41,7 +41,7 @@ export function ServiceCardSkeleton() {
|
||||
|
||||
export function CaseCardSkeleton() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#E5E5E5] overflow-hidden">
|
||||
<div className="bg-[var(--color-bg-primary)] rounded-xl border border-[var(--color-border-primary)] overflow-hidden">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<div className="p-6">
|
||||
<Skeleton className="h-5 w-1/2 mb-3" />
|
||||
@@ -55,7 +55,7 @@ export function CaseCardSkeleton() {
|
||||
|
||||
export function ProductCardSkeleton() {
|
||||
return (
|
||||
<div className="bg-[#FAFAFA] rounded-xl border border-[#E5E5E5] p-6 h-full flex flex-col">
|
||||
<div className="bg-[var(--color-bg-section)] rounded-xl border border-[var(--color-border-primary)] p-6 h-full flex flex-col">
|
||||
<Skeleton className="h-6 w-1/3 mb-3" />
|
||||
<Skeleton className="h-7 w-3/4 mb-4" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
@@ -67,7 +67,7 @@ export function ProductCardSkeleton() {
|
||||
|
||||
export function NewsCardSkeleton() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#E5E5E5] overflow-hidden">
|
||||
<div className="bg-[var(--color-bg-primary)] rounded-xl border border-[var(--color-border-primary)] overflow-hidden">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<div className="p-6">
|
||||
<Skeleton className="h-5 w-1/4 mb-3" />
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { OptimizedImage } from './optimized-image';
|
||||
|
||||
// Mock next/image
|
||||
jest.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onLoad, onError, className, ...props }: React.ComponentProps<'img'>) => (
|
||||
<img
|
||||
{...props}
|
||||
className={className}
|
||||
data-testid="optimized-image"
|
||||
onLoad={onLoad}
|
||||
onError={onError}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('OptimizedImage', () => {
|
||||
it('should render with loading state initially', () => {
|
||||
render(
|
||||
<OptimizedImage
|
||||
src="/test-image.jpg"
|
||||
alt="Test image"
|
||||
width={400}
|
||||
height={300}
|
||||
/>
|
||||
);
|
||||
|
||||
// 应该显示加载动画
|
||||
expect(screen.getByTestId('optimized-image')).toHaveClass('opacity-0');
|
||||
});
|
||||
|
||||
it('should show error state when image fails to load', async () => {
|
||||
render(
|
||||
<OptimizedImage
|
||||
src="/invalid-image.jpg"
|
||||
alt="Invalid image"
|
||||
width={400}
|
||||
height={300}
|
||||
/>
|
||||
);
|
||||
|
||||
const img = screen.getByTestId('optimized-image');
|
||||
|
||||
// 触发错误事件
|
||||
img.dispatchEvent(new Event('error'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('图片加载失败')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show image when loaded', async () => {
|
||||
render(
|
||||
<OptimizedImage
|
||||
src="/test-image.jpg"
|
||||
alt="Test image"
|
||||
width={400}
|
||||
height={300}
|
||||
/>
|
||||
);
|
||||
|
||||
const img = screen.getByTestId('optimized-image');
|
||||
|
||||
// 触发加载完成事件
|
||||
img.dispatchEvent(new Event('load'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(img).toHaveClass('opacity-100');
|
||||
});
|
||||
});
|
||||
|
||||
it('should render with correct alt text', () => {
|
||||
render(
|
||||
<OptimizedImage
|
||||
src="/test-image.jpg"
|
||||
alt="Descriptive alt text"
|
||||
width={400}
|
||||
height={300}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByAltText('Descriptive alt text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use fill mode when specified', () => {
|
||||
render(
|
||||
<OptimizedImage
|
||||
src="/test-image.jpg"
|
||||
alt="Fill mode image"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
);
|
||||
|
||||
const container = screen.getByTestId('optimized-image').parentElement;
|
||||
expect(container).toHaveClass('relative', 'overflow-hidden', 'w-full', 'h-full');
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface OptimizedImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fill?: boolean;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
priority?: boolean;
|
||||
sizes?: string;
|
||||
quality?: number;
|
||||
placeholder?: 'blur' | 'empty';
|
||||
blurDataURL?: string;
|
||||
}
|
||||
|
||||
export function OptimizedImage({
|
||||
src,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
fill = false,
|
||||
className,
|
||||
containerClassName,
|
||||
priority = false,
|
||||
sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw',
|
||||
quality = 85,
|
||||
placeholder = 'blur',
|
||||
blurDataURL,
|
||||
}: OptimizedImageProps) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
// 生成默认的模糊占位符
|
||||
const defaultBlurDataURL = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iNDAwIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjFmNWY5Ii8+PC9zdmc+';
|
||||
|
||||
// 使用 callback 来处理加载状态
|
||||
const handleLoad = useCallback(() => {
|
||||
setIsLoaded(true);
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback(() => {
|
||||
setError(true);
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-gray-100 flex items-center justify-center',
|
||||
containerClassName
|
||||
)}
|
||||
style={!fill ? { width, height } : undefined}
|
||||
>
|
||||
<span className="text-gray-400 text-sm">图片加载失败</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden',
|
||||
fill ? 'w-full h-full' : '',
|
||||
containerClassName
|
||||
)}
|
||||
style={!fill ? { width, height } : undefined}
|
||||
>
|
||||
{/* 模糊占位符背景 */}
|
||||
{!isLoaded && placeholder === 'blur' && (
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center blur-sm scale-110 transition-opacity duration-500"
|
||||
style={{
|
||||
backgroundImage: `url(${blurDataURL || defaultBlurDataURL})`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 加载动画 */}
|
||||
{!isLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-[#C41E3A] rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={fill ? undefined : width}
|
||||
height={fill ? undefined : height}
|
||||
fill={fill}
|
||||
priority={priority}
|
||||
sizes={sizes}
|
||||
quality={quality}
|
||||
placeholder={placeholder}
|
||||
blurDataURL={blurDataURL || defaultBlurDataURL}
|
||||
className={cn(
|
||||
'transition-opacity duration-500',
|
||||
isLoaded ? 'opacity-100' : 'opacity-0',
|
||||
className
|
||||
)}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { PageHeader } from './page-header';
|
||||
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
h1: ({ children, ...props }: any) => <h1 {...props}>{children}</h1>,
|
||||
p: ({ children, ...props }: any) => <p {...props}>{children}</p>,
|
||||
},
|
||||
useInView: () => true,
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/badge', () => ({
|
||||
Badge: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<span className={className}>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('PageHeader', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render page header', () => {
|
||||
render(<PageHeader title="Test Title" />);
|
||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
render(<PageHeader title="Test Title" />);
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render description when provided', () => {
|
||||
render(<PageHeader title="Test" description="Test description" />);
|
||||
expect(screen.getByText('Test description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render description when not provided', () => {
|
||||
render(<PageHeader title="Test" />);
|
||||
expect(screen.queryByText('Test description')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render badge when provided', () => {
|
||||
render(<PageHeader title="Test" badge="Test Badge" />);
|
||||
expect(screen.getByText('Test Badge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render badge when not provided', () => {
|
||||
render(<PageHeader title="Test" />);
|
||||
expect(screen.queryByText('Test Badge')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Effects', () => {
|
||||
it('should have background styling', () => {
|
||||
const { container } = render(<PageHeader title="Test" />);
|
||||
const bgDiv = container.querySelector('.bg-\\[\\#FAFAFA\\]');
|
||||
expect(bgDiv).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have overflow hidden', () => {
|
||||
const { container } = render(<PageHeader title="Test" />);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper).toHaveClass('overflow-hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have container class', () => {
|
||||
const { container } = render(<PageHeader title="Test" />);
|
||||
const containerDiv = container.querySelector('.container-wide');
|
||||
expect(containerDiv).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<PageHeader title="Test" className="custom-class" />);
|
||||
const customDiv = container.querySelector('.custom-class');
|
||||
expect(customDiv).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface PageHeaderProps {
|
||||
badge?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({ badge, title, description, className = '' }: PageHeaderProps) {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden bg-[#FAFAFA]">
|
||||
<div className="container-wide relative z-10 pt-32 pb-20" ref={ref}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={`max-w-4xl mx-auto text-center ${className}`}
|
||||
>
|
||||
{badge && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="mb-6"
|
||||
>
|
||||
<Badge variant="outline">{badge}</Badge>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="text-3xl sm:text-4xl font-semibold text-[#1C1C1C] mb-4"
|
||||
>
|
||||
{title}
|
||||
</motion.h1>
|
||||
|
||||
{description && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="text-base text-[#595959] max-w-2xl mx-auto leading-relaxed"
|
||||
>
|
||||
{description}
|
||||
</motion.p>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence, type Variants, type Transition } from 'framer-motion';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
const defaultTransition: Transition = {
|
||||
duration: 0.4,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
};
|
||||
|
||||
export const fadeInVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
},
|
||||
enter: {
|
||||
opacity: 1,
|
||||
transition: defaultTransition,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: { ...defaultTransition, duration: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
export const slideUpVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
},
|
||||
enter: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: defaultTransition,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
transition: { ...defaultTransition, duration: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
export const slideRightVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
x: -20,
|
||||
},
|
||||
enter: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: defaultTransition,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
x: 20,
|
||||
transition: { ...defaultTransition, duration: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
export const scaleVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
scale: 0.98,
|
||||
},
|
||||
enter: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: defaultTransition,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 1.02,
|
||||
transition: { ...defaultTransition, duration: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
export const inkRevealPageVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
clipPath: 'circle(0% at 50% 50%)',
|
||||
},
|
||||
enter: {
|
||||
opacity: 1,
|
||||
clipPath: 'circle(100% at 50% 50%)',
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
clipPath: 'circle(0% at 50% 50%)',
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const curtainVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: '100%',
|
||||
},
|
||||
enter: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: '-100%',
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: ReactNode;
|
||||
variants?: Variants;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageTransition({
|
||||
children,
|
||||
variants = fadeInVariants,
|
||||
className = '',
|
||||
}: PageTransitionProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial="initial"
|
||||
animate="enter"
|
||||
exit="exit"
|
||||
variants={variants}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
interface FadeTransitionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FadeTransition({ children, className = '' }: FadeTransitionProps) {
|
||||
return (
|
||||
<PageTransition variants={fadeInVariants} className={className}>
|
||||
{children}
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
|
||||
interface SlideUpTransitionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SlideUpTransition({ children, className = '' }: SlideUpTransitionProps) {
|
||||
return (
|
||||
<PageTransition variants={slideUpVariants} className={className}>
|
||||
{children}
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
|
||||
interface InkRevealTransitionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InkRevealTransition({ children, className = '' }: InkRevealTransitionProps) {
|
||||
return (
|
||||
<PageTransition variants={inkRevealPageVariants} className={className}>
|
||||
{children}
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionTransitionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function SectionTransition({ children, className = '', delay = 0 }: SectionTransitionProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StaggerChildrenProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
staggerDelay?: number;
|
||||
}
|
||||
|
||||
export function StaggerChildren({ children, className = '', staggerDelay = 0.1 }: StaggerChildrenProps) {
|
||||
const containerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: staggerDelay,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={containerVariants}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AnimatedSectionProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
animation?: 'fade' | 'slide' | 'scale' | 'ink';
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function AnimatedSection({
|
||||
children,
|
||||
className = '',
|
||||
animation = 'fade',
|
||||
delay = 0,
|
||||
}: AnimatedSectionProps) {
|
||||
const getVariants = (): Variants => {
|
||||
switch (animation) {
|
||||
case 'slide':
|
||||
return {
|
||||
hidden: { opacity: 0, y: 40 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'scale':
|
||||
return {
|
||||
hidden: { opacity: 0, scale: 0.95 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'ink':
|
||||
return {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
scale: 0.9,
|
||||
filter: 'blur(10px)',
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
filter: 'blur(0px)',
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
default:
|
||||
return {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={getVariants()}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingTransitionProps {
|
||||
isLoading: boolean;
|
||||
children: ReactNode;
|
||||
loadingComponent?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingTransition({
|
||||
isLoading,
|
||||
children,
|
||||
loadingComponent,
|
||||
className = '',
|
||||
}: LoadingTransitionProps) {
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{isLoading ? (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={className}
|
||||
>
|
||||
{loadingComponent || (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||
className="w-8 h-8 border-2 border-[#C41E3A] border-t-transparent rounded-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="content"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonLoaderProps {
|
||||
className?: string;
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
export function SkeletonLoader({ className = '', animate = true }: SkeletonLoaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
bg-[#F5F5F5] rounded-lg
|
||||
${animate ? 'animate-pulse' : ''}
|
||||
${className}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardSkeletonProps {
|
||||
count?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardSkeleton({ count = 1, className = '' }: CardSkeletonProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div key={index} className="bg-white border border-[#E5E5E5] rounded-xl p-6 space-y-4">
|
||||
<SkeletonLoader className="h-4 w-3/4" />
|
||||
<SkeletonLoader className="h-4 w-1/2" />
|
||||
<SkeletonLoader className="h-20 w-full" />
|
||||
<div className="flex gap-2">
|
||||
<SkeletonLoader className="h-8 w-20" />
|
||||
<SkeletonLoader className="h-8 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,22 +21,22 @@ interface ProductStyleConfig {
|
||||
}
|
||||
|
||||
const productConfig: ProductStyleConfig[] = [
|
||||
{ icon: Database, accentColor: '#C41E3A', accentColorRgb: '196, 30, 58', glowStart: '#C41E3A', glowEnd: '#D97706' },
|
||||
{ icon: Users, accentColor: '#D97706', accentColorRgb: '217, 119, 6', glowStart: '#D97706', glowEnd: '#16A34A' },
|
||||
{ icon: FileText, accentColor: '#2563EB', accentColorRgb: '37, 99, 235', glowStart: '#2563EB', glowEnd: '#7C3AED' },
|
||||
{ icon: BarChart3, accentColor: '#16A34A', accentColorRgb: '22, 163, 74', glowStart: '#16A34A', glowEnd: '#0891B2' },
|
||||
{ icon: Truck, accentColor: '#7C3AED', accentColorRgb: '124, 58, 237', glowStart: '#7C3AED', glowEnd: '#C41E3A' },
|
||||
{ icon: Building2, accentColor: '#0891B2', accentColorRgb: '8, 145, 178', glowStart: '#0891B2', glowEnd: '#2563EB' },
|
||||
{ icon: Database, accentColor: 'var(--color-brand-primary)', accentColorRgb: '196, 30, 58', glowStart: 'var(--color-brand-primary)', glowEnd: 'var(--color-warning)' },
|
||||
{ icon: Users, accentColor: 'var(--color-warning)', accentColorRgb: '217, 119, 6', glowStart: 'var(--color-warning)', glowEnd: 'var(--color-success)' },
|
||||
{ icon: FileText, accentColor: 'var(--color-accent-blue)', accentColorRgb: '37, 99, 235', glowStart: 'var(--color-accent-blue)', glowEnd: 'var(--color-accent-purple)' },
|
||||
{ icon: BarChart3, accentColor: 'var(--color-success)', accentColorRgb: '22, 163, 74', glowStart: 'var(--color-success)', glowEnd: 'var(--color-accent-cyan)' },
|
||||
{ icon: Truck, accentColor: 'var(--color-accent-purple)', accentColorRgb: '124, 58, 237', glowStart: 'var(--color-accent-purple)', glowEnd: 'var(--color-brand-primary)' },
|
||||
{ icon: Building2, accentColor: 'var(--color-accent-cyan)', accentColorRgb: '8, 145, 178', glowStart: 'var(--color-accent-cyan)', glowEnd: 'var(--color-accent-blue)' },
|
||||
];
|
||||
|
||||
const defaultConfig: ProductStyleConfig = {
|
||||
icon: Database, accentColor: '#C41E3A', accentColorRgb: '196, 30, 58', glowStart: '#C41E3A', glowEnd: '#D97706',
|
||||
icon: Database, accentColor: 'var(--color-brand-primary)', accentColorRgb: '196, 30, 58', glowStart: 'var(--color-brand-primary)', glowEnd: 'var(--color-warning)',
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
'研发中': { bg: 'rgba(196, 30, 58, 0.08)', text: '#C41E3A', border: 'rgba(196, 30, 58, 0.15)' },
|
||||
'内测中': { bg: 'rgba(217, 119, 6, 0.08)', text: '#D97706', border: 'rgba(217, 119, 6, 0.15)' },
|
||||
'已发布': { bg: 'rgba(22, 163, 74, 0.08)', text: '#16A34A', border: 'rgba(22, 163, 74, 0.15)' },
|
||||
'研发中': { bg: 'rgba(var(--color-brand-primary-rgb), 0.08)', text: 'var(--color-brand-primary)', border: 'rgba(var(--color-brand-primary-rgb), 0.15)' },
|
||||
'内测中': { bg: 'rgba(var(--color-warning-rgb), 0.08)', text: 'var(--color-warning)', border: 'rgba(var(--color-warning-rgb), 0.15)' },
|
||||
'已发布': { bg: 'rgba(var(--color-success-rgb), 0.08)', text: 'var(--color-success)', border: 'rgba(var(--color-success-rgb), 0.15)' },
|
||||
} as const;
|
||||
|
||||
export function ProductCard({ title, description, href, index, status }: ProductCardProps) {
|
||||
@@ -77,21 +77,21 @@ export function ProductCard({ title, description, href, index, status }: Product
|
||||
{status}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-mono tracking-widest text-[#A3A3A3]">
|
||||
<span className="text-xs font-mono tracking-widest text-[var(--color-text-subtle)]">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold mb-2 leading-snug tracking-tight text-[#1C1C1C]">
|
||||
<h3 className="text-lg font-semibold mb-2 leading-snug tracking-tight text-[var(--color-text-primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-[#595959] leading-relaxed line-clamp-3 mb-5">
|
||||
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed line-clamp-3 mb-5">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-[#A3A3A3] group-hover:text-[#C41E3A] transition-colors">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-[var(--color-text-subtle)] group-hover:text-[var(--color-brand-primary)] transition-colors">
|
||||
<span>了解规划</span>
|
||||
<ArrowUpRight className="w-4 h-4" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion, useScroll, useTransform, useSpring, type Variants, type HTMLMotionProps } from 'framer-motion';
|
||||
import { useRef, type ReactNode } from 'react';
|
||||
|
||||
export const scrollRevealVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 50,
|
||||
scale: 0.95,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const inkRevealVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
filter: 'blur(10px)',
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
filter: 'blur(0px)',
|
||||
transition: {
|
||||
duration: 1,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const slideInLeftVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
x: -50,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const slideInRightVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
x: 50,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface ScrollRevealProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
variants?: Variants;
|
||||
once?: boolean;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export function ScrollReveal({
|
||||
children,
|
||||
className = '',
|
||||
delay = 0,
|
||||
variants: _variants = scrollRevealVariants,
|
||||
once = true,
|
||||
threshold = 0.1,
|
||||
...props
|
||||
}: ScrollRevealProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start end', 'start center'],
|
||||
});
|
||||
|
||||
const opacity = useTransform(scrollYProgress, [0, threshold], [0, 1]);
|
||||
const y = useTransform(scrollYProgress, [0, threshold], [50, 0]);
|
||||
const scale = useTransform(scrollYProgress, [0, threshold], [0.95, 1]);
|
||||
|
||||
const smoothOpacity = useSpring(opacity, { stiffness: 100, damping: 30 });
|
||||
const smoothY = useSpring(y, { stiffness: 100, damping: 30 });
|
||||
const smoothScale = useSpring(scale, { stiffness: 100, damping: 30 });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
style={{
|
||||
opacity: smoothOpacity,
|
||||
y: smoothY,
|
||||
scale: smoothScale,
|
||||
}}
|
||||
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
||||
whileInView={{ opacity: 1, y: 0, scale: 1 }}
|
||||
viewport={{ once, amount: threshold }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ParallaxSectionProps extends HTMLMotionProps<'section'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
export function ParallaxSection({
|
||||
children,
|
||||
className = '',
|
||||
speed = 0.5,
|
||||
...props
|
||||
}: ParallaxSectionProps) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start end', 'end start'],
|
||||
});
|
||||
|
||||
const y = useTransform(scrollYProgress, [0, 1], ['0%', `${speed * 100}%`]);
|
||||
const smoothY = useSpring(y, { stiffness: 100, damping: 30 });
|
||||
|
||||
return (
|
||||
<motion.section ref={ref} style={{ y: smoothY }} className={className} {...props}>
|
||||
{children}
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ParallaxImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
export function ParallaxImage({ src, alt, className = '', speed = 0.3 }: ParallaxImageProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start end', 'end start'],
|
||||
});
|
||||
|
||||
const y = useTransform(scrollYProgress, [0, 1], [`${-speed * 50}%`, `${speed * 50}%`]);
|
||||
const scale = useTransform(scrollYProgress, [0, 0.5, 1], [1.1, 1, 1.1]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`overflow-hidden ${className}`}>
|
||||
<motion.img
|
||||
src={src}
|
||||
alt={alt}
|
||||
style={{ y, scale }}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScaleOnScrollProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
minScale?: number;
|
||||
maxScale?: number;
|
||||
}
|
||||
|
||||
export function ScaleOnScroll({
|
||||
children,
|
||||
className = '',
|
||||
minScale = 0.8,
|
||||
maxScale = 1,
|
||||
...props
|
||||
}: ScaleOnScrollProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start end', 'center center'],
|
||||
});
|
||||
|
||||
const scale = useTransform(scrollYProgress, [0, 1], [minScale, maxScale]);
|
||||
const smoothScale = useSpring(scale, { stiffness: 100, damping: 30 });
|
||||
|
||||
return (
|
||||
<motion.div ref={ref} style={{ scale: smoothScale }} className={className} {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FadeOnScrollProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
direction?: 'up' | 'down' | 'left' | 'right';
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
export function FadeOnScroll({
|
||||
children,
|
||||
className = '',
|
||||
direction = 'up',
|
||||
distance = 50,
|
||||
...props
|
||||
}: FadeOnScrollProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start end', 'center center'],
|
||||
});
|
||||
|
||||
const transformUp = useTransform(scrollYProgress, [0, 1], [distance, 0]);
|
||||
const transformDown = useTransform(scrollYProgress, [0, 1], [-distance, 0]);
|
||||
const transformLeft = useTransform(scrollYProgress, [0, 1], [distance, 0]);
|
||||
const transformRight = useTransform(scrollYProgress, [0, 1], [-distance, 0]);
|
||||
|
||||
const getTransform = () => {
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
return transformUp;
|
||||
case 'down':
|
||||
return transformDown;
|
||||
case 'left':
|
||||
return transformLeft;
|
||||
case 'right':
|
||||
return transformRight;
|
||||
default:
|
||||
return transformUp;
|
||||
}
|
||||
};
|
||||
|
||||
const transform = getTransform();
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.5], [0, 1]);
|
||||
|
||||
const smoothTransform = useSpring(transform, { stiffness: 100, damping: 30 });
|
||||
const smoothOpacity = useSpring(opacity, { stiffness: 100, damping: 30 });
|
||||
|
||||
const style =
|
||||
direction === 'left' || direction === 'right' ? { x: smoothTransform, opacity: smoothOpacity } : { y: smoothTransform, opacity: smoothOpacity };
|
||||
|
||||
return (
|
||||
<motion.div ref={ref} style={style} className={className} {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StaggerRevealProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
staggerDelay?: number;
|
||||
containerDelay?: number;
|
||||
}
|
||||
|
||||
export function StaggerReveal({
|
||||
children,
|
||||
className = '',
|
||||
staggerDelay = 0.1,
|
||||
containerDelay = 0,
|
||||
...props
|
||||
}: StaggerRevealProps) {
|
||||
const containerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: staggerDelay,
|
||||
delayChildren: containerDelay,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={containerVariants}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StaggerItemProps extends HTMLMotionProps<'div'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StaggerItem({ children, className = '', ...props }: StaggerItemProps) {
|
||||
const itemVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 30,
|
||||
scale: 0.95,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div variants={itemVariants} className={className} {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TextRevealProps extends HTMLMotionProps<'div'> {
|
||||
text: string;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function TextReveal({ text, className = '', delay = 0, ...props }: TextRevealProps) {
|
||||
const words = text.split(' ');
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
delayChildren: delay,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const wordVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
variants={containerVariants}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{words.map((word, index) => (
|
||||
<motion.span
|
||||
key={index}
|
||||
variants={wordVariants}
|
||||
className="inline-block mr-2"
|
||||
>
|
||||
{word}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProgressIndicatorProps {
|
||||
className?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function ProgressIndicator({ className = '', color = '#C41E3A' }: ProgressIndicatorProps) {
|
||||
const { scrollYProgress } = useScroll();
|
||||
const scaleX = useSpring(scrollYProgress, { stiffness: 100, damping: 30 });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`fixed top-0 left-0 right-0 h-1 origin-left z-50 ${className}`}
|
||||
style={{ scaleX, backgroundColor: color }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScrollTriggeredCounterProps {
|
||||
end: number;
|
||||
duration?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScrollTriggeredCounter({
|
||||
end,
|
||||
duration: _duration = 2,
|
||||
prefix = '',
|
||||
suffix = '',
|
||||
className = '',
|
||||
}: ScrollTriggeredCounterProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ['start end', 'center center'],
|
||||
});
|
||||
|
||||
const displayNumber = useTransform(scrollYProgress, [0, 1], [0, end]);
|
||||
const rounded = useTransform(displayNumber, (latest) => Math.round(latest));
|
||||
|
||||
return (
|
||||
<motion.span ref={ref} className={className}>
|
||||
{prefix}
|
||||
<motion.span>{rounded}</motion.span>
|
||||
{suffix}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
@@ -41,10 +41,10 @@ export function ScrollProgress() {
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-[#C41E3A] to-[#E04A68] origin-left"
|
||||
className="h-full bg-gradient-to-r from-[var(--color-brand-primary)] to-[var(--color-brand-primary-light)] origin-left"
|
||||
style={{
|
||||
scaleX,
|
||||
boxShadow: '0 0 10px rgba(196, 30, 58, 0.3)',
|
||||
boxShadow: '0 0 10px rgba(var(--color-brand-primary-rgb), 0.3)',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from './sheet';
|
||||
|
||||
describe('Sheet', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render sheet trigger', () => {
|
||||
render(
|
||||
<Sheet>
|
||||
<SheetTrigger>Open Sheet</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
expect(screen.getByText('Open Sheet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render sheet content when open', () => {
|
||||
render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetTrigger>Open Sheet</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
expect(screen.getByText('Sheet Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render sheet title', () => {
|
||||
render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetTrigger>Open Sheet</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: 'Sheet Title' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render sheet description', () => {
|
||||
render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetTrigger>Open Sheet</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
<SheetDescription>Sheet Description</SheetDescription>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
expect(screen.getByText('Sheet Description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render sheet footer', () => {
|
||||
render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetTrigger>Open Sheet</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetFooter>
|
||||
<button>Footer Button</button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
expect(screen.getByText('Footer Button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interaction', () => {
|
||||
it('should open sheet on trigger click', () => {
|
||||
render(
|
||||
<Sheet>
|
||||
<SheetTrigger>Open Sheet</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
const trigger = screen.getByText('Open Sheet');
|
||||
fireEvent.click(trigger);
|
||||
|
||||
expect(screen.getByText('Sheet Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should close sheet on close button click', () => {
|
||||
render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetTrigger>Open Sheet</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close/i });
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(screen.queryByText('Sheet Title')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sides', () => {
|
||||
it('should render right side by default', () => {
|
||||
const { container } = render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetTrigger>Open Sheet</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render left side', () => {
|
||||
const { container } = render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetTrigger>Open Sheet</SheetTrigger>
|
||||
<SheetContent side="left">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render top side', () => {
|
||||
const { container } = render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetTrigger>Open Sheet</SheetTrigger>
|
||||
<SheetContent side="top">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render bottom side', () => {
|
||||
const { container } = render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetTrigger>Open Sheet</SheetTrigger>
|
||||
<SheetContent side="bottom">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should apply custom className to content', () => {
|
||||
render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetTrigger>Open Sheet</SheetTrigger>
|
||||
<SheetContent className="custom-content">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
expect(screen.getByText('Sheet Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className to header', () => {
|
||||
render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetTrigger>Open Sheet</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader className="custom-header">
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
expect(screen.getByText('Sheet Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className to footer', () => {
|
||||
render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetTrigger>Open Sheet</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetFooter className="custom-footer">
|
||||
<button>Footer</button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
expect(screen.getByText('Footer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Close Button', () => {
|
||||
it('should show close button by default', () => {
|
||||
render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetTrigger>Open Sheet</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide close button when showCloseButton is false', () => {
|
||||
render(
|
||||
<Sheet defaultOpen>
|
||||
<SheetTrigger>Open Sheet</SheetTrigger>
|
||||
<SheetContent showCloseButton={false}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sheet Title</SheetTitle>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /close/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { STATS } from '@/lib/constants';
|
||||
|
||||
export function StatsBar() {
|
||||
return (
|
||||
<div className="border-t border-[#E5E5E5] bg-white">
|
||||
<div className="container-wide py-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-12">
|
||||
{STATS.map((stat) => (
|
||||
<div key={stat.label} className="text-center">
|
||||
<div className="text-3xl sm:text-4xl font-bold text-[#C41E3A] mb-1">
|
||||
{stat.value}
|
||||
</div>
|
||||
<div className="text-sm text-[#595959]">
|
||||
{stat.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Quote } from 'lucide-react';
|
||||
import { InkGlowCard } from '@/components/ui/ink-glow-card';
|
||||
|
||||
interface TestimonialBlockProps {
|
||||
quote: string;
|
||||
author: string;
|
||||
title: string;
|
||||
company: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function TestimonialBlock({ quote, author, title, company, index }: TestimonialBlockProps) {
|
||||
return (
|
||||
<InkGlowCard
|
||||
index={index}
|
||||
accentColorRgb="196, 30, 58"
|
||||
glowStart="#C41E3A"
|
||||
glowEnd="#D97706"
|
||||
>
|
||||
<div className="p-6 md:p-8">
|
||||
<Quote className="w-7 h-7 text-[#C41E3A]/15 mb-5" />
|
||||
<blockquote className="text-[#1C1C1C] leading-relaxed mb-6 text-base">
|
||||
“{quote}”
|
||||
</blockquote>
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-[#F0F0F0]">
|
||||
<div className="w-9 h-9 rounded-full bg-[#FAFAFA] flex items-center justify-center text-[#C41E3A] font-semibold text-sm">
|
||||
{author.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[#1C1C1C]">{author}</div>
|
||||
<div className="text-xs text-[#A3A3A3]">{title},{company}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InkGlowCard>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import '@testing-library/jest-dom';
|
||||
import { Textarea } from './textarea';
|
||||
|
||||
jest.mock('@/lib/utils', () => ({
|
||||
cn: (...args: any[]) => args.filter(Boolean).join(' '),
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
describe('Textarea', () => {
|
||||
@@ -154,7 +154,7 @@ describe('Textarea', () => {
|
||||
it('should have error styling when error exists', () => {
|
||||
render(<Textarea error="错误信息" />);
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea.className).toMatch(/border-\[#C41E3A\]/);
|
||||
expect(textarea.className).toMatch(/border-\[var\(--color-brand-primary\)\]/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={textareaId}
|
||||
className="block text-sm font-medium text-[#3D3D3D] mb-2"
|
||||
className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2"
|
||||
>
|
||||
{label}
|
||||
{props.required && <span className="text-[#C41E3A] ml-1">*</span>}
|
||||
{props.required && <span className="text-[var(--color-brand-primary)] ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
@@ -29,10 +29,10 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
data-slot="textarea"
|
||||
data-testid={props['data-testid']}
|
||||
className={cn(
|
||||
"placeholder:text-[#8C8C8C] selection:bg-[#1C1C1C] selection:text-white min-h-[140px] w-full rounded-lg border border-[#E5E5E5] bg-[#FAFAFA] px-4 py-3 text-base text-[#1C1C1C] shadow-sm transition-all duration-300 outline-none disabled:cursor-not-allowed disabled:opacity-50 md:min-h-[80px] md:text-sm md:py-2 resize-none",
|
||||
"focus-visible:border-[#1C1C1C] focus-visible:ring-2 focus-visible:ring-[#1C1C1C]/50 focus-visible:shadow-lg focus-visible:shadow-[#1C1C1C]/20",
|
||||
"hover:border-[#3D3D3D]",
|
||||
error && "border-[#C41E3A] focus-visible:border-[#C41E3A] focus-visible:ring-[#C41E3A]/50",
|
||||
"placeholder:text-[var(--color-text-hint)] selection:bg-[var(--color-primary)] selection:text-white min-h-[140px] w-full rounded-lg border border-[var(--color-border-primary)] bg-[var(--color-bg-section)] px-4 py-3 text-base text-[var(--color-text-primary)] shadow-sm transition-all duration-300 outline-none disabled:cursor-not-allowed disabled:opacity-50 md:min-h-[80px] md:text-sm md:py-2 resize-none",
|
||||
"focus-visible:border-[var(--color-primary)] focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]/50 focus-visible:shadow-lg focus-visible:shadow-[var(--color-primary)]/20",
|
||||
"hover:border-[var(--color-text-secondary)]",
|
||||
error && "border-[var(--color-brand-primary)] focus-visible:border-[var(--color-brand-primary)] focus-visible:ring-[var(--color-brand-primary)]/50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -42,7 +42,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p id={errorId} className="mt-1 text-sm text-[#C41E3A]" role="alert">
|
||||
<p id={errorId} className="mt-1 text-sm text-[var(--color-brand-primary)]" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -36,29 +36,29 @@ export function Toast({
|
||||
};
|
||||
|
||||
const bgColors = {
|
||||
success: 'bg-green-50 border-green-200',
|
||||
error: 'bg-red-50 border-red-200',
|
||||
info: 'bg-blue-50 border-blue-200'
|
||||
success: 'bg-[var(--color-toast-success-bg)] border-[var(--color-toast-success-border)]',
|
||||
error: 'bg-[var(--color-toast-error-bg)] border-[var(--color-toast-error-border)]',
|
||||
info: 'bg-[var(--color-toast-info-bg)] border-[var(--color-toast-info-border)]'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={dataTestId}
|
||||
data-type={type}
|
||||
className={`fixed bottom-4 right-4 z-50 flex items-center gap-3 px-4 py-3 bg-white rounded-lg shadow-lg border transition-all duration-300 ${
|
||||
className={`fixed bottom-4 right-4 z-50 flex items-center gap-3 px-4 py-3 bg-[var(--color-bg-primary)] rounded-lg shadow-lg border transition-all duration-300 ${
|
||||
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'
|
||||
} ${bgColors[type]}`}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
{icons[type]}
|
||||
<p className="text-sm font-medium text-[#1C1C1C]">{message}</p>
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)]">{message}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(onClose, 300);
|
||||
}}
|
||||
className="ml-2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
className="ml-2 text-[var(--color-toast-close)] hover:text-[var(--color-toast-close-hover)] transition-colors"
|
||||
aria-label="关闭提示"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { TouchButton } from './touch-button';
|
||||
|
||||
describe('TouchButton', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render button with text', () => {
|
||||
render(<TouchButton>Click Me</TouchButton>);
|
||||
expect(screen.getByText('Click Me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render button element', () => {
|
||||
render(<TouchButton>Button</TouchButton>);
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(
|
||||
<TouchButton className="custom-class">Button</TouchButton>
|
||||
);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Variants', () => {
|
||||
it('should render primary variant by default', () => {
|
||||
const { container } = render(<TouchButton>Primary</TouchButton>);
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render secondary variant', () => {
|
||||
const { container } = render(<TouchButton variant="secondary">Secondary</TouchButton>);
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render ghost variant', () => {
|
||||
const { container } = render(<TouchButton variant="ghost">Ghost</TouchButton>);
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sizes', () => {
|
||||
it('should render small size', () => {
|
||||
const { container } = render(<TouchButton size="sm">Small</TouchButton>);
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render medium size by default', () => {
|
||||
const { container } = render(<TouchButton>Medium</TouchButton>);
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render large size', () => {
|
||||
const { container } = render(<TouchButton size="lg">Large</TouchButton>);
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Full Width', () => {
|
||||
it('should not be full width by default', () => {
|
||||
const { container } = render(<TouchButton>Button</TouchButton>);
|
||||
expect(container.firstChild).not.toHaveClass('w-full');
|
||||
});
|
||||
|
||||
it('should be full width when fullWidth is true', () => {
|
||||
const { container } = render(<TouchButton fullWidth>Button</TouchButton>);
|
||||
expect(container.firstChild).toHaveClass('w-full');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('should not be disabled by default', () => {
|
||||
render(<TouchButton>Button</TouchButton>);
|
||||
expect(screen.getByRole('button')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should be disabled when disabled prop is true', () => {
|
||||
render(<TouchButton disabled>Button</TouchButton>);
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Touch Events', () => {
|
||||
it('should handle touch start', () => {
|
||||
const { container } = render(<TouchButton>Button</TouchButton>);
|
||||
const button = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.touchStart(button);
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle touch end', () => {
|
||||
const { container } = render(<TouchButton>Button</TouchButton>);
|
||||
const button = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.touchStart(button);
|
||||
fireEvent.touchEnd(button);
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle touch cancel', () => {
|
||||
const { container } = render(<TouchButton>Button</TouchButton>);
|
||||
const button = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.touchStart(button);
|
||||
fireEvent.touchCancel(button);
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Click Events', () => {
|
||||
it('should handle click events', () => {
|
||||
const onClick = jest.fn();
|
||||
render(<TouchButton onClick={onClick}>Button</TouchButton>);
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode, type ButtonHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TouchButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export function TouchButton({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
className,
|
||||
disabled,
|
||||
...props
|
||||
}: TouchButtonProps) {
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
|
||||
const baseStyles = `
|
||||
inline-flex items-center justify-center font-medium
|
||||
transition-all duration-150 ease-out
|
||||
active:scale-95
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100
|
||||
touch-manipulation
|
||||
`;
|
||||
|
||||
const variants = {
|
||||
primary: `
|
||||
bg-[#C41E3A] text-white
|
||||
hover:bg-[#A01830]
|
||||
focus-visible:ring-[#C41E3A]
|
||||
${isPressed ? 'bg-[#8B1429]' : ''}
|
||||
`,
|
||||
secondary: `
|
||||
bg-[#F5F5F5] text-[#171717]
|
||||
hover:bg-[#E5E5E5]
|
||||
border border-[#E5E5E5]
|
||||
focus-visible:ring-[#C41E3A]
|
||||
${isPressed ? 'bg-[#D4D4D4]' : ''}
|
||||
`,
|
||||
ghost: `
|
||||
bg-transparent text-[#525252]
|
||||
hover:bg-[#FEF2F4] hover:text-[#C41E3A]
|
||||
focus-visible:ring-[#C41E3A]
|
||||
${isPressed ? 'bg-[#FCE8EC] text-[#C41E3A]' : ''}
|
||||
`,
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'text-sm px-4 py-2 rounded-md gap-1.5 min-h-[36px]',
|
||||
md: 'text-base px-5 py-2.5 rounded-lg gap-2 min-h-[44px]',
|
||||
lg: 'text-lg px-6 py-3 rounded-lg gap-2 min-h-[52px]',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
baseStyles,
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
fullWidth && 'w-full',
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
onTouchStart={() => setIsPressed(true)}
|
||||
onTouchEnd={() => setIsPressed(false)}
|
||||
onTouchCancel={() => setIsPressed(false)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { TouchSwipe } from './touch-swipe';
|
||||
|
||||
describe('TouchSwipe', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<TouchSwipe>
|
||||
<div>Test Content</div>
|
||||
</TouchSwipe>
|
||||
);
|
||||
expect(screen.getByText('Test Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(
|
||||
<TouchSwipe className="custom-class">
|
||||
<div>Test</div>
|
||||
</TouchSwipe>
|
||||
);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Touch Events', () => {
|
||||
it('should handle touch start', () => {
|
||||
const { container } = render(
|
||||
<TouchSwipe>
|
||||
<div>Test</div>
|
||||
</TouchSwipe>
|
||||
);
|
||||
|
||||
const div = container.firstChild as HTMLElement;
|
||||
fireEvent.touchStart(div, {
|
||||
touches: [{ clientX: 100, clientY: 100 }],
|
||||
});
|
||||
|
||||
expect(div).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle touch end', () => {
|
||||
const { container } = render(
|
||||
<TouchSwipe>
|
||||
<div>Test</div>
|
||||
</TouchSwipe>
|
||||
);
|
||||
|
||||
const div = container.firstChild as HTMLElement;
|
||||
fireEvent.touchStart(div, {
|
||||
touches: [{ clientX: 100, clientY: 100 }],
|
||||
});
|
||||
fireEvent.touchEnd(div, {
|
||||
changedTouches: [{ clientX: 150, clientY: 100 }],
|
||||
});
|
||||
|
||||
expect(div).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onSwipeLeft when swiping left', () => {
|
||||
const onSwipeLeft = jest.fn();
|
||||
const { container } = render(
|
||||
<TouchSwipe onSwipeLeft={onSwipeLeft}>
|
||||
<div>Test</div>
|
||||
</TouchSwipe>
|
||||
);
|
||||
|
||||
const div = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.touchStart(div, {
|
||||
touches: [{ clientX: 150, clientY: 100 }],
|
||||
});
|
||||
|
||||
fireEvent.touchEnd(div, {
|
||||
changedTouches: [{ clientX: 50, clientY: 100 }],
|
||||
});
|
||||
|
||||
expect(onSwipeLeft).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onSwipeRight when swiping right', () => {
|
||||
const onSwipeRight = jest.fn();
|
||||
const { container } = render(
|
||||
<TouchSwipe onSwipeRight={onSwipeRight}>
|
||||
<div>Test</div>
|
||||
</TouchSwipe>
|
||||
);
|
||||
|
||||
const div = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.touchStart(div, {
|
||||
touches: [{ clientX: 50, clientY: 100 }],
|
||||
});
|
||||
|
||||
fireEvent.touchEnd(div, {
|
||||
changedTouches: [{ clientX: 150, clientY: 100 }],
|
||||
});
|
||||
|
||||
expect(onSwipeRight).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not trigger swipe when below threshold', () => {
|
||||
const onSwipeLeft = jest.fn();
|
||||
const { container } = render(
|
||||
<TouchSwipe onSwipeLeft={onSwipeLeft} threshold={100}>
|
||||
<div>Test</div>
|
||||
</TouchSwipe>
|
||||
);
|
||||
|
||||
const div = container.firstChild as HTMLElement;
|
||||
fireEvent.touchStart(div, {
|
||||
touches: [{ clientX: 100, clientY: 100 }],
|
||||
});
|
||||
fireEvent.touchEnd(div, {
|
||||
changedTouches: [{ clientX: 80, clientY: 100 }],
|
||||
});
|
||||
|
||||
expect(onSwipeLeft).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Threshold', () => {
|
||||
it('should use default threshold of 50', () => {
|
||||
const onSwipeLeft = jest.fn();
|
||||
const { container } = render(
|
||||
<TouchSwipe onSwipeLeft={onSwipeLeft}>
|
||||
<div>Test</div>
|
||||
</TouchSwipe>
|
||||
);
|
||||
|
||||
const div = container.firstChild as HTMLElement;
|
||||
fireEvent.touchStart(div, {
|
||||
touches: [{ clientX: 100, clientY: 100 }],
|
||||
});
|
||||
fireEvent.touchEnd(div, {
|
||||
changedTouches: [{ clientX: 40, clientY: 100 }],
|
||||
});
|
||||
|
||||
expect(onSwipeLeft).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept custom threshold', () => {
|
||||
const onSwipeLeft = jest.fn();
|
||||
const { container } = render(
|
||||
<TouchSwipe onSwipeLeft={onSwipeLeft} threshold={200}>
|
||||
<div>Test</div>
|
||||
</TouchSwipe>
|
||||
);
|
||||
|
||||
const div = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.touchStart(div, {
|
||||
touches: [{ clientX: 300, clientY: 100 }],
|
||||
});
|
||||
|
||||
fireEvent.touchEnd(div, {
|
||||
changedTouches: [{ clientX: 50, clientY: 100 }],
|
||||
});
|
||||
|
||||
expect(onSwipeLeft).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, ReactNode } from 'react';
|
||||
|
||||
interface TouchSwipeProps {
|
||||
children: ReactNode;
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
threshold?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TouchSwipe({
|
||||
children,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
threshold = 50,
|
||||
className = '',
|
||||
}: TouchSwipeProps) {
|
||||
const touchStartX = useRef(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
if (e.touches[0]) {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
if (e.changedTouches[0]) {
|
||||
const touchEndX = e.changedTouches[0].clientX;
|
||||
const diff = touchStartX.current - touchEndX;
|
||||
|
||||
if (Math.abs(diff) > threshold) {
|
||||
if (diff > 0 && onSwipeLeft) {
|
||||
onSwipeLeft();
|
||||
} else if (diff < 0 && onSwipeRight) {
|
||||
onSwipeRight();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user