feat(ui): 重构核心 UI 组件库,新增 shadcn/ui 组件

- 重构 Button、Card、Badge、Input、Textarea 等基础组件
- 新增 Accordion、Alert、Dialog、Dropdown、Form 等 shadcn/ui 组件
- 新增 AnimatedCounter、StatsShowcase、MetricCard 等数据展示组件
- 新增 ScrollReveal 滚动动画组件
- 重构 Toast 通知系统与 Tooltip 提示组件
- 更新设计令牌系统,对齐新品牌视觉
This commit is contained in:
张翔
2026-07-07 06:52:38 +08:00
parent 9053f69123
commit e78df62cd1
62 changed files with 4024 additions and 822 deletions
+65
View File
@@ -0,0 +1,65 @@
'use client';
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui Accordion 组件
* 基于 @radix-ui/react-accordion
*/
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
data-slot="accordion-item"
className={cn('border-b border-border-primary', className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
data-slot="accordion-trigger"
className={cn(
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2',
'[&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDown className="size-4 shrink-0 text-text-muted transition-transform duration-normal" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
data-slot="accordion-content"
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+136
View File
@@ -0,0 +1,136 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from './button';
/**
* 标准 shadcn/ui AlertDialog 组件
* 基于 @radix-ui/react-alert-dialog
*/
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
ref={ref}
data-slot="alert-dialog-overlay"
className={cn(
'fixed inset-0 z-50 bg-ink/60 backdrop-blur-sm',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
data-slot="alert-dialog-content"
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border-primary bg-bg-primary p-6 shadow-lg duration-fast',
'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',
'sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.ComponentProps<'div'>) => (
<div
data-slot="alert-dialog-header"
className={cn('flex flex-col space-y-2 text-center sm:text-left', className)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({ className, ...props }: React.ComponentProps<'div'>) => (
<div
data-slot="alert-dialog-footer"
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
data-slot="alert-dialog-title"
className={cn('text-lg font-semibold text-text-primary', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
data-slot="alert-dialog-description"
className={cn('text-sm text-text-muted', className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
data-slot="alert-dialog-action"
className={cn(buttonVariants({ variant: 'primary' }), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
data-slot="alert-dialog-cancel"
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+68
View File
@@ -0,0 +1,68 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui Alert 组件
* 纯 HTML 实现,不依赖 Radix
*/
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-bg-primary text-text-primary border-border-primary',
destructive:
'text-error bg-error-bg border-error [&>svg]:text-current',
success:
'text-success bg-success-bg border-success [&>svg]:text-current',
warning:
'text-warning bg-warning-bg border-warning [&>svg]:text-current',
info: 'text-info bg-info-bg border-info [&>svg]:text-current',
},
},
defaultVariants: {
variant: 'default',
},
}
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-title"
className={cn('col-start-2 font-medium leading-none tracking-tight', className)}
{...props}
/>
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-description"
className={cn(
'text-text-muted col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription, alertVariants };
+78
View File
@@ -0,0 +1,78 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import { useCountUp } from '@/hooks/use-count-up';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { cn } from '@/lib/utils';
interface AnimatedCounterProps {
value: number;
decimals?: number;
duration?: number;
prefix?: string;
suffix?: string;
className?: string;
/** 只在进入视口时开始动画 */
startOnView?: boolean;
/** 视口阈值 */
threshold?: number;
}
export function AnimatedCounter({
value,
decimals = 0,
duration = 1800,
prefix = '',
suffix = '',
className,
startOnView = true,
threshold = 0.3,
}: AnimatedCounterProps) {
const ref = useRef<HTMLSpanElement>(null);
const [start, setStart] = useState(!startOnView);
const shouldReduceMotion = useReducedMotion();
const count = useCountUp({
end: value,
decimals,
duration,
enabled: start && !shouldReduceMotion,
});
useEffect(() => {
if (!startOnView) return;
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setStart(true);
observer.disconnect();
}
},
{ threshold },
);
observer.observe(el);
return () => observer.disconnect();
}, [startOnView, threshold]);
const displayValue = start ? (shouldReduceMotion ? value : count) : value;
return (
<span
ref={ref}
className={cn('tabular-nums', className)}
aria-label={`${prefix}${value}${suffix}`}
>
{prefix}
{displayValue.toLocaleString('zh-CN', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})}
{suffix}
</span>
);
}
+56
View File
@@ -0,0 +1,56 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui Avatar 组件
* 基于 @radix-ui/react-avatar
*/
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
data-slot="avatar"
className={cn(
'relative flex size-10 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
data-slot="avatar-image"
className={cn('aspect-square size-full', className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
data-slot="avatar-fallback"
className={cn(
'flex size-full items-center justify-center rounded-full bg-bg-tertiary text-text-secondary text-sm font-medium',
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
+2 -5
View File
@@ -35,13 +35,10 @@ 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-[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"
className="fixed right-4 bottom-20 md:bottom-8 md:right-8 z-40 p-3 bg-brand text-white hover:bg-brand-hover transition-all duration-300 ease-out focus:outline-none focus:ring-2 focus:ring-brand focus:ring-offset-2 focus:ring-offset-white"
aria-label="返回顶部"
title="返回顶部"
style={{
boxShadow: '0 4px 14px rgba(var(--color-brand-primary-rgb), 0.4)',
}}
whileHover={shouldReduceMotion ? {} : { scale: 1.1 }}
whileHover={shouldReduceMotion ? {} : { scale: 1.05 }}
whileTap={shouldReduceMotion ? {} : { scale: 0.95 }}
>
<ArrowUp className="w-6 h-6" />
+5 -4
View File
@@ -40,9 +40,9 @@ describe('Badge', () => {
expect(badge).toBeInTheDocument();
});
it('should render destructive variant', () => {
const { container } = render(<Badge variant="destructive">Destructive</Badge>);
const badge = container.querySelector('[data-variant="destructive"]');
it('should render error variant', () => {
const { container } = render(<Badge variant="error">Error</Badge>);
const badge = container.querySelector('[data-variant="error"]');
expect(badge).toBeInTheDocument();
});
@@ -86,7 +86,8 @@ describe('Badge', () => {
it('should have rounded-full class', () => {
const { container } = render(<Badge>Badge</Badge>);
const badge = container.querySelector('.rounded-full');
// Badge 使用 rounded-smshadcn/ui 规范)
const badge = container.querySelector('.rounded-sm');
expect(badge).toBeInTheDocument();
});
+28 -23
View File
@@ -1,52 +1,57 @@
'use client';
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
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-[var(--color-primary)] focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]/50 transition-all duration-300 overflow-hidden",
'inline-flex items-center justify-center rounded-sm border px-3 py-1 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1.5 [&>svg]:pointer-events-none focus-visible:border-ink focus-visible:ring-2 focus-visible:ring-ink/20 transition-all duration-fast ease-out',
{
variants: {
variant: {
default: "bg-[var(--color-brand-primary)] text-white border-transparent shadow-sm",
default: 'bg-brand text-white border-transparent',
secondary:
"bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] border-[var(--color-border-primary)]",
destructive:
"bg-[var(--color-brand-primary)] text-white border-transparent hover:bg-[var(--color-brand-primary-hover)]",
'bg-bg-secondary text-text-secondary border-border-primary',
outline:
"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)]",
'border-border-accent text-text-primary bg-transparent',
ghost: 'text-text-muted hover:text-text-primary hover:bg-bg-secondary',
success: 'bg-success/10 text-success border-transparent',
warning: 'bg-warning/10 text-warning border-transparent',
error: 'bg-error/10 text-error border-transparent',
info: 'bg-info/10 text-info border-transparent',
},
size: {
sm: 'px-2 py-0.5 text-[10px]',
default: 'px-2.5 py-1 text-xs',
lg: 'px-3 py-1.5 text-sm',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
size: 'default',
},
}
)
);
function Badge({
className,
variant = "default",
variant = 'default',
size,
asChild = false,
...props
}: React.ComponentProps<"span"> &
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
const Comp = asChild ? Slot : 'span';
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
className={cn(badgeVariants({ variant, size }), className)}
{...props}
/>
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };
+12 -12
View File
@@ -11,13 +11,13 @@ export function GeometricDecoration({ variant = 'circles' }: { variant?: 'circle
return (
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
<svg className="absolute top-[10%] right-[5%] w-64 h-64 opacity-[0.04]" viewBox="0 0 200 200">
<circle cx="100" cy="100" r="80" fill="none" stroke="currentColor" className="text-[var(--color-brand-primary)]" strokeWidth="0.5" />
<circle cx="100" cy="100" r="60" fill="none" stroke="currentColor" className="text-[var(--color-brand-primary)]" strokeWidth="0.5" />
<circle cx="100" cy="100" r="40" fill="none" stroke="currentColor" className="text-[var(--color-brand-primary)]" strokeWidth="0.5" />
<circle cx="100" cy="100" r="80" fill="none" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="0.5" />
<circle cx="100" cy="100" r="60" fill="none" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="0.5" />
<circle cx="100" cy="100" r="40" fill="none" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="0.5" />
{!shouldReduceMotion && (
<motion.circle
cx="100" cy="100" r="20"
fill="rgba(var(--color-brand-primary-rgb, 196, 30, 58), 0.1)"
fill="rgba(var(--color-brand-rgb, 196, 30, 58), 0.1)"
animate={{ r: [20, 35, 20], opacity: [0.1, 0.05, 0.1] }}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
/>
@@ -35,9 +35,9 @@ export function GeometricDecoration({ variant = 'circles' }: { variant?: 'circle
return (
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
<svg className="absolute top-0 right-0 w-full h-full opacity-[0.02]" viewBox="0 0 1000 1000" preserveAspectRatio="none">
<line x1="800" y1="0" x2="1000" y2="400" stroke="currentColor" className="text-[var(--color-brand-primary)]" strokeWidth="1" />
<line x1="850" y1="0" x2="1000" y2="300" stroke="currentColor" className="text-[var(--color-brand-primary)]" strokeWidth="0.5" />
<line x1="900" y1="0" x2="1000" y2="200" stroke="currentColor" className="text-[var(--color-brand-primary)]" strokeWidth="0.5" />
<line x1="800" y1="0" x2="1000" y2="400" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="1" />
<line x1="850" y1="0" x2="1000" y2="300" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="0.5" />
<line x1="900" y1="0" x2="1000" y2="200" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="0.5" />
<line x1="0" y1="600" x2="300" y2="1000" stroke="currentColor" className="text-[var(--color-text-subtle)]" strokeWidth="0.5" />
<line x1="0" y1="700" x2="200" y2="1000" stroke="currentColor" className="text-[var(--color-text-subtle)]" strokeWidth="0.5" />
</svg>
@@ -52,7 +52,7 @@ export function GeometricDecoration({ variant = 'circles' }: { variant?: 'circle
export function DataBar({ value, max = 100, label, color = 'brand' }: { value: number; max?: number; label: string; color?: 'brand' | 'blue' | 'purple' | 'cyan' }) {
const percentage = Math.min((value / max) * 100, 100);
const colorMap = {
brand: 'var(--color-brand-primary)',
brand: 'var(--color-brand)',
blue: 'var(--color-accent-blue)',
purple: 'var(--color-accent-purple)',
cyan: 'var(--color-accent-cyan)',
@@ -83,7 +83,7 @@ export function GradientDivider() {
return (
<div className="flex items-center gap-4 py-2">
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-[var(--color-border-primary)] to-transparent" />
<div className="w-1.5 h-1.5 rounded-full bg-[var(--color-brand-primary)]" />
<div className="w-1.5 h-1.5 rounded-full bg-[var(--color-brand)]" />
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-[var(--color-border-primary)] to-transparent" />
</div>
);
@@ -99,13 +99,13 @@ export function BrandStamp({ children }: { children: React.ReactNode }) {
whileInView={{ scale: 1, opacity: 1, rotate: -5 }}
viewport={{ once: true }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="inline-block px-3 py-1.5 border-2 border-[var(--color-brand-primary)] rounded-sm"
className="inline-block px-3 py-1.5 border-2 border-[var(--color-brand)] rounded-sm"
style={{
transform: 'rotate(-5deg)',
background: 'repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(var(--color-brand-primary-rgb), 0.03) 2px, rgba(var(--color-brand-primary-rgb), 0.03) 4px)',
background: 'repeating-linear-gradient(45deg, transparent, transparent 2px, rgba(var(--color-brand-rgb), 0.03) 2px, rgba(var(--color-brand-rgb), 0.03) 4px)',
}}
>
<span className="text-xs font-bold tracking-widest text-[var(--color-brand-primary)] font-calligraphy">
<span className="text-xs font-bold tracking-widest text-[var(--color-brand)] font-calligraphy">
{children}
</span>
</motion.div>
+107
View File
@@ -0,0 +1,107 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui Breadcrumb 组件
* 纯 HTML 实现,不依赖 Radix
*/
function Breadcrumb({ className, ...props }: React.ComponentProps<'nav'>) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" className={cn('', className)} {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-text-muted sm:gap-2.5',
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-item"
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
);
}
type BreadcrumbLinkProps = React.ComponentProps<'a'> & {
asChild?: boolean;
};
function BreadcrumbLink({ asChild, className, ...props }: BreadcrumbLinkProps) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot="breadcrumb-link"
className={cn('transition-colors hover:text-text-primary', className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn('font-normal text-text-primary', className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
role="presentation"
aria-hidden="true"
data-slot="breadcrumb-separator"
className={cn('[&>svg]:size-3.5 text-text-subtle', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
role="presentation"
aria-hidden="true"
data-slot="breadcrumb-ellipsis"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only"></span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
+11 -6
View File
@@ -19,7 +19,8 @@ describe('Button Component', () => {
it('should apply default variant styles', () => {
render(<Button>Default</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('bg-[var(--color-brand-primary)]');
// default variant 为 primary,使用 bg-brand(项目 token 而非 CSS var
expect(button).toHaveClass('bg-brand');
});
});
@@ -27,20 +28,23 @@ describe('Button Component', () => {
it('should apply secondary variant styles', () => {
render(<Button variant="secondary">Secondary</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('bg-[var(--color-primary)]');
// secondary variant 使用 bg-ink
expect(button).toHaveClass('bg-ink');
});
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-[var(--color-primary)]');
// outline variant 使用 border-border-accent
expect(button).toHaveClass('border');
expect(button).toHaveClass('border-border-accent');
});
it('should apply ghost variant styles', () => {
render(<Button variant="ghost">Ghost</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('text-[var(--color-text-secondary)]');
// ghost variant 使用 text-text-secondary
expect(button).toHaveClass('text-text-secondary');
});
it('should apply link variant styles', () => {
@@ -52,7 +56,8 @@ describe('Button Component', () => {
it('should apply destructive variant styles', () => {
render(<Button variant="destructive">Destructive</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('bg-[var(--color-brand-primary)]');
// destructive variant 使用 bg-error(项目 token
expect(button).toHaveClass('bg-error');
});
});
+68 -47
View File
@@ -1,91 +1,112 @@
'use client';
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
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-[var(--color-brand-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-white min-h-[44px] min-w-[44px] touch-manipulation relative overflow-hidden",
'group inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all duration-fast ease-standard disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary min-h-[44px] min-w-[44px] touch-manipulation relative overflow-hidden select-none',
{
variants: {
variant: {
default:
"bg-[var(--color-brand-primary)] text-white hover:bg-[var(--color-brand-primary-hover)] hover:shadow-[0_4px_16px_rgba(var(--color-brand-primary-rgb),0.3)] hover:-translate-y-0.5 active:scale-[0.97] group/btn before:absolute before:inset-0 before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent before:-translate-x-full hover:before:translate-x-full before:transition-transform before:duration-700 before:ease-out",
primary:
'bg-brand text-white shadow-sm hover:bg-brand-hover hover:shadow-brand-hover hover:-translate-y-px active:translate-y-0 active:scale-[0.97] active:shadow-sm transition-all duration-fast ease-standard',
secondary:
"bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)] hover:shadow-[0_4px_16px_rgba(var(--color-primary-rgb),0.25)] hover:-translate-y-0.5 active:scale-[0.97]",
destructive:
"bg-[var(--color-brand-primary)] text-white hover:bg-[var(--color-brand-primary-hover)] focus-visible:ring-[var(--color-brand-primary)] active:scale-[0.97]",
'bg-ink text-white shadow-sm hover:bg-ink-light hover:shadow-md hover:-translate-y-px active:translate-y-0 active:scale-[0.97] active:shadow-sm transition-all duration-fast ease-standard',
outline:
"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 hover:border-[var(--color-brand-primary)] active:scale-[0.97] hover:shadow-[0_4px_12px_rgba(var(--color-brand-primary-rgb),0.15)]",
'border border-border-accent bg-transparent text-text-primary hover:bg-ink hover:text-white hover:border-ink hover:-translate-y-px hover:shadow-md active:translate-y-0 active:scale-[0.97] active:shadow-sm transition-all duration-fast ease-standard',
ghost:
"text-[var(--color-primary-light)] hover:bg-[var(--color-primary-lighter)] hover:text-[var(--color-brand-primary)]",
'text-text-secondary hover:bg-bg-secondary hover:text-text-primary active:scale-[0.97] transition-all duration-fast ease-standard',
link:
"text-[var(--color-primary)] underline-offset-4 hover:underline hover:text-[var(--color-brand-primary)] after:absolute after:bottom-0 after:left-0 after:h-[1.5px] after:w-0 hover:after:w-full after:bg-[var(--color-brand-primary)] after:transition-all after:duration-300",
'text-text-primary underline-offset-4 hover:text-brand relative after:absolute after:bottom-0 after:left-0 after:h-[2px] after:w-0 hover:after:w-full after:bg-brand after:transition-all after:duration-normal after:ease-standard',
destructive:
'bg-error text-white shadow-sm hover:bg-error-hover hover:-translate-y-px active:translate-y-0 active:scale-[0.97] transition-all duration-fast ease-standard',
},
size: {
default: "h-11 px-4 py-2",
sm: "h-9 rounded-md px-3 text-xs",
lg: "h-12 rounded-lg px-6 text-base",
icon: "h-11 w-11",
sm: 'h-9 rounded-md px-3 text-xs font-medium',
default: 'h-11 rounded-md px-5 py-2 font-medium',
lg: 'h-12 rounded-md px-6 text-base font-semibold',
xl: 'h-14 rounded-md px-8 text-base font-semibold',
icon: 'h-11 w-11 rounded-md',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'primary',
size: 'default',
},
}
)
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
asChild?: boolean;
showRipple?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, onClick, children, ...props }, ref) => {
const [ripples, setRipples] = React.useState<Array<{ id: number; x: number; y: number }>>([])
({ className, variant, size, asChild = false, onClick, children, showRipple = true, ...props }, ref) => {
const [ripples, setRipples] = React.useState<{ x: number; y: number; id: number }[]>([]);
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const id = Date.now()
setRipples(prev => [...prev, { id, x, y }])
setTimeout(() => {
setRipples(prev => prev.filter(r => r.id !== id))
}, 600)
onClick?.(e)
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (showRipple && variant !== 'link' && variant !== 'ghost') {
const button = e.currentTarget;
const rect = button.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const id = Date.now();
setRipples((prev) => [...prev, { x, y, id }]);
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== id));
}, 600);
}
onClick?.(e);
};
// asChild 模式:Slot 要求单个 React 元素子节点,因此跳过 ripple 和包裹 span。
// 直接把 children 交给 Slot,由 Slot 克隆并注入 className/onClick 等 props。
if (asChild) {
return (
<Slot
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
onClick={onClick}
{...props}
>
{children}
</Slot>
);
}
const Comp = asChild ? Slot : "button"
return (
<Comp
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
onClick={handleClick}
{...props}
>
{children}
{ripples.map(ripple => (
{ripples.map((ripple) => (
<span
key={ripple.id}
className="absolute rounded-full bg-white/30 pointer-events-none animate-ripple"
className="pointer-events-none absolute rounded-full bg-white/25 animate-ripple"
style={{
left: ripple.x,
top: ripple.y,
width: '20px',
height: '20px',
marginLeft: '-10px',
marginTop: '-10px',
transform: 'translate(-50%, -50%)',
}}
/>
))}
</Comp>
)
<span className="relative z-10 flex items-center justify-center gap-2">
{children}
</span>
</button>
);
}
)
Button.displayName = "Button"
);
Button.displayName = 'Button';
export { Button, buttonVariants }
export { Button, buttonVariants };
+18 -10
View File
@@ -31,8 +31,9 @@ describe('Card Components', () => {
it('should apply default styles', () => {
render(<Card data-testid="card">Test</Card>);
const card = screen.getByTestId('card');
expect(card).toHaveClass('bg-[var(--color-bg-secondary)]');
expect(card).toHaveClass('rounded-xl');
// Card 使用 bg-bg-primary + rounded-smshadcn/ui 规范 + 项目 token
expect(card).toHaveClass('bg-bg-primary');
expect(card).toHaveClass('rounded-sm');
});
it('should apply custom className', () => {
@@ -44,8 +45,9 @@ 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-[var(--color-text-primary)]');
expect(card).toHaveClass('hover:shadow-[0_8px_24px_rgba(0,0,0,0.06)]');
// Card hover 使用 border-border-secondary + shadow-md
expect(card).toHaveClass('hover:border-border-secondary');
expect(card).toHaveClass('hover:shadow-md');
});
});
@@ -68,7 +70,8 @@ describe('Card Components', () => {
it('should apply default styles', () => {
render(<CardHeader data-testid="header">Test</CardHeader>);
const header = screen.getByTestId('header');
expect(header).toHaveClass('px-6');
// CardHeader 使用 p-6 pb-4
expect(header).toHaveClass('p-6');
});
});
@@ -88,7 +91,8 @@ describe('Card Components', () => {
render(<CardTitle data-testid="title">Test</CardTitle>);
const title = screen.getByTestId('title');
expect(title).toHaveClass('font-semibold');
expect(title).toHaveClass('text-[var(--color-text-primary)]');
// CardTitle 使用 text-text-primary(项目 token
expect(title).toHaveClass('text-text-primary');
});
});
@@ -107,7 +111,8 @@ describe('Card Components', () => {
it('should apply default styles', () => {
render(<CardDescription data-testid="desc">Test</CardDescription>);
const desc = screen.getByTestId('desc');
expect(desc).toHaveClass('text-[var(--color-text-muted)]');
// CardDescription 使用 text-text-muted(项目 token
expect(desc).toHaveClass('text-text-muted');
expect(desc).toHaveClass('text-sm');
});
});
@@ -127,7 +132,8 @@ describe('Card Components', () => {
it('should apply default styles', () => {
render(<CardAction data-testid="action">Test</CardAction>);
const action = screen.getByTestId('action');
expect(action).toHaveClass('col-start-2');
// CardAction 使用 ml-auto(简化布局)
expect(action).toHaveClass('ml-auto');
});
});
@@ -146,7 +152,8 @@ describe('Card Components', () => {
it('should apply default styles', () => {
render(<CardContent data-testid="content">Test</CardContent>);
const content = screen.getByTestId('content');
expect(content).toHaveClass('px-6');
// CardContent 使用 p-6 pt-0
expect(content).toHaveClass('p-6');
});
});
@@ -165,7 +172,8 @@ describe('Card Components', () => {
it('should apply default styles', () => {
render(<CardFooter data-testid="footer">Test</CardFooter>);
const footer = screen.getByTestId('footer');
expect(footer).toHaveClass('px-6');
// CardFooter 使用 p-6 pt-0 + border-t
expect(footer).toHaveClass('p-6');
expect(footer).toHaveClass('border-t');
});
});
+24 -28
View File
@@ -1,84 +1,80 @@
import * as React from "react"
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
"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",
'bg-bg-primary text-text-primary flex flex-col rounded-sm border border-border-primary transition-all duration-normal ease-out hover:border-border-secondary hover:shadow-md',
className
)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
'flex flex-col gap-1.5 p-6 pb-4',
className
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold text-[var(--color-text-primary)]", className)}
className={cn('text-lg font-semibold leading-snug text-text-primary tracking-tight', className)}
{...props}
/>
)
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn("text-[var(--color-text-placeholder)] text-sm", className)}
className={cn('text-sm text-text-muted leading-relaxed', className)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
className={cn('ml-auto', className)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
className={cn('p-6 pt-0', className)}
{...props}
/>
)
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
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-[var(--color-border-primary)]", className)}
className={cn('flex items-center p-6 pt-0 mt-auto border-t border-border-light', className)}
{...props}
/>
)
);
}
export {
@@ -89,4 +85,4 @@ export {
CardAction,
CardDescription,
CardContent,
}
};
+3 -3
View File
@@ -36,10 +36,10 @@ export function ChallengeCard({ title, description, scenario, href, index }: Cha
<div className="flex items-start justify-between mb-5">
<div
className="w-11 h-11 rounded-xl flex items-center justify-center"
style={{ backgroundColor: `rgba(var(--color-brand-primary-rgb), ${config.iconBgOpacity})` }}
style={{ backgroundColor: `rgba(var(--color-brand-rgb), ${config.iconBgOpacity})` }}
>
<IconComponent
className="w-5 h-5 text-[var(--color-brand-primary)]"
className="w-5 h-5 text-[var(--color-brand)]"
strokeWidth={1.8}
/>
</div>
@@ -56,7 +56,7 @@ export function ChallengeCard({ title, description, scenario, href, index }: Cha
{description}
</p>
<div className="flex items-center gap-2 text-sm font-medium text-[var(--color-brand-primary)]">
<div className="flex items-center gap-2 text-sm font-medium text-[var(--color-brand)]">
<span></span>
<ArrowRight className="w-4 h-4" />
</div>
+44
View File
@@ -0,0 +1,44 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check, Minus } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui Checkbox 组件
* 基于 @radix-ui/react-checkbox
*/
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
data-slot="checkbox"
className={cn(
'peer size-4 shrink-0 rounded-sm border border-border-primary bg-bg-primary shadow-sm',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:bg-brand data-[state=checked]:text-white data-[state=checked]:border-brand',
'data-[state=indeterminate]:bg-brand data-[state=indeterminate]:text-white data-[state=indeterminate]:border-brand',
'transition-colors duration-fast',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className={cn('flex items-center justify-center text-current')}
>
{props.checked === 'indeterminate' ? (
<Minus className="size-3.5" />
) : (
<Check className="size-3.5" />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+201
View File
@@ -0,0 +1,201 @@
'use client';
import * as React from 'react';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui ContextMenu 组件
* 基于 @radix-ui/react-context-menu
*/
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & { inset?: boolean }
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
data-slot="context-menu-sub-trigger"
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
'focus:bg-bg-secondary focus:text-text-primary data-[state=open]:bg-bg-secondary data-[state=open]:text-text-primary',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto size-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
data-slot="context-menu-sub-content"
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border-primary bg-bg-primary p-1 text-text-primary 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',
className
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
data-slot="context-menu-content"
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border-primary bg-bg-primary p-1 text-text-primary shadow-md',
'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',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
data-slot="context-menu-item"
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
'focus:bg-bg-secondary focus:text-text-primary',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'[&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
className
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
data-slot="context-menu-checkbox-item"
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none',
'focus:bg-bg-secondary focus:text-text-primary',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="size-4 text-brand" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
data-slot="context-menu-radio-item"
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none',
'focus:bg-bg-secondary focus:text-text-primary',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="size-2 fill-brand text-brand" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
data-slot="context-menu-label"
className={cn('px-2 py-1.5 text-xs font-medium text-text-muted', inset && 'pl-8', className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
data-slot="context-menu-separator"
className={cn('-mx-1 my-1 h-px bg-border-primary', className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
data-slot="context-menu-shortcut"
className={cn('ml-auto text-xs tracking-widest text-text-muted', className)}
{...props}
/>
);
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
+124
View File
@@ -0,0 +1,124 @@
'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';
/**
* 标准 shadcn/ui Dialog 组件
* 基于 @radix-ui/react-dialog
*/
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}
data-slot="dialog-overlay"
className={cn(
'fixed inset-0 z-50 bg-ink/60 backdrop-blur-sm',
'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}
data-slot="dialog-content"
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border-primary bg-bg-primary p-6 shadow-lg duration-fast',
'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',
'sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close
data-slot="dialog-close"
className={cn(
'absolute right-4 top-4 rounded-sm opacity-70 transition-opacity',
'hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-brand',
'disabled:pointer-events-none',
className
)}
>
<X className="size-4" />
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.ComponentProps<'div'>) => (
<div
data-slot="dialog-header"
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.ComponentProps<'div'>) => (
<div
data-slot="dialog-footer"
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}
data-slot="dialog-title"
className={cn('text-lg font-semibold leading-none tracking-tight text-text-primary', 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}
data-slot="dialog-description"
className={cn('text-sm text-text-muted', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+203
View File
@@ -0,0 +1,203 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui DropdownMenu 组件
* 基于 @radix-ui/react-dropdown-menu
*/
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.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
data-slot="dropdown-menu-sub-trigger"
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
'focus:bg-bg-secondary data-[state=open]:bg-bg-secondary',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
data-slot="dropdown-menu-sub-content"
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border-primary bg-bg-primary p-1 text-text-primary 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',
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border-primary bg-bg-primary p-1 text-text-primary shadow-md',
'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.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
data-slot="dropdown-menu-item"
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',
'focus:bg-bg-secondary focus:text-text-primary',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'[&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
data-slot="dropdown-menu-checkbox-item"
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-bg-secondary focus:text-text-primary',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="size-4 text-brand" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
data-slot="dropdown-menu-radio-item"
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-bg-secondary focus:text-text-primary',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="size-2 fill-brand text-brand" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
data-slot="dropdown-menu-label"
className={cn('px-2 py-1.5 text-xs font-medium text-text-muted', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
data-slot="dropdown-menu-separator"
className={cn('-mx-1 my-1 h-px bg-border-primary', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
data-slot="dropdown-menu-shortcut"
className={cn('ml-auto text-xs tracking-widest text-text-muted', className)}
{...props}
/>
);
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+1 -1
View File
@@ -151,7 +151,7 @@ describe('ErrorBoundary', () => {
);
const button = screen.getByRole('button', { name: '重试' });
expect(button).toHaveClass('bg-[var(--color-brand-primary)]');
expect(button).toHaveClass('bg-[var(--color-brand)]');
expect(button).toHaveClass('text-white');
});
});
+1 -1
View File
@@ -54,7 +54,7 @@ export class ErrorBoundary extends Component<Props, State> {
</p>
<button
onClick={() => this.setState({ hasError: false, error: undefined })}
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"
className="px-6 py-2.5 bg-[var(--color-brand)] text-white rounded-lg hover:bg-[var(--color-brand-hover)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-offset-2"
aria-label="重试"
>
+6 -6
View File
@@ -20,12 +20,12 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
{/* 背景卡片 - 静态显示当前数字 */}
<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)]">
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[var(--color-brand)]">
{digit}
</div>
</div>
<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)]">
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[var(--color-brand)]">
{digit}
</div>
</div>
@@ -46,7 +46,7 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
backfaceVisibility: '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)]">
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[var(--color-brand)]">
{prevDigit}
</div>
</motion.div>
@@ -64,7 +64,7 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
backfaceVisibility: '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)]">
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[var(--color-brand)]">
{digit}
</div>
</motion.div>
@@ -134,9 +134,9 @@ export function FlipClock({ years, months, days }: FlipClockProps) {
<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-[var(--color-brand-primary)]">:</div>
<div className="text-2xl sm:text-3xl font-bold text-[var(--color-brand)]">:</div>
<FlipCard value={months} label="个月" maxDigits={2} />
<div className="text-2xl sm:text-3xl font-bold text-[var(--color-brand-primary)]">:</div>
<div className="text-2xl sm:text-3xl font-bold text-[var(--color-brand)]">:</div>
<FlipCard value={days} label="天" maxDigits={3} />
</div>
+205
View File
@@ -0,0 +1,205 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import {
Controller,
type ControllerProps,
type FieldPath,
type FieldValues,
FormProvider,
useFormContext,
type UseFormProps,
useForm,
} from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type { z } from 'zod';
import { cn } from '@/lib/utils';
import { Label } from './label';
/**
* 标准 shadcn/ui Form 组件
* 基于 react-hook-form + @hookform/resolvers/zod
*/
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
function FormField<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({ ...props }: ControllerProps<TFieldValues, TName>) {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField 必须在 <FormField> 内使用');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const FormItem = React.forwardRef<HTMLDivElement, React.ComponentProps<'div'>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
ref={ref}
data-slot="form-item"
className={cn('space-y-2', className)}
{...props}
/>
</FormItemContext.Provider>
);
}
);
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
htmlFor={formItemId}
data-slot="form-label"
className={cn(error && 'text-error', className)}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
data-slot="form-control"
id={formItemId}
aria-describedby={
!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.ComponentProps<'p'>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
data-slot="form-description"
className={cn('text-xs text-text-muted', className)}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.ComponentProps<'p'>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? '') : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
data-slot="form-message"
className={cn('text-xs font-medium text-error', className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = 'FormMessage';
/**
* useFormWithZod - 便捷的 zod 集成 hook
*
* 注意:zodResolver 类型签名要求 schema 的输入/输出均为 FieldValues
* 而泛型 T 的推断 _input 为 unknown。为兼容严格类型检查,使用 cast 适配。
* 调用方需保证 schema 为 z.object(...) 类型。
*/
function useFormWithZod<T extends z.ZodType<FieldValues>>(
schema: T,
options?: Omit<UseFormProps<z.infer<T>>, 'resolver'>
) {
return useForm<z.infer<T>>({
// zodResolver 泛型签名严格,使用 cast 适配(schema 已约束为 ZodType<FieldValues>
resolver: zodResolver(schema as never) as never,
...options,
});
}
export {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
useFormField,
useFormWithZod,
};
+36
View File
@@ -0,0 +1,36 @@
'use client';
import * as React from 'react';
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui HoverCard 组件
* 基于 @radix-ui/react-hover-card
*/
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
ref={ref}
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-64 rounded-md border border-border-primary bg-bg-primary p-4 text-text-primary shadow-md outline-none',
'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}
/>
</HoverCardPrimitive.Portal>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
+204
View File
@@ -0,0 +1,204 @@
// === shadcn/ui 标准组件 ===
export { Button, buttonVariants } from './button';
export type { ButtonProps } from './button';
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
} from './card';
export { Badge, badgeVariants } from './badge';
export { Input } from './input';
export { Textarea } from './textarea';
export { Label, labelVariants } from './label';
export { LabeledInput } from './labeled-input';
export type { LabeledInputProps } from './labeled-input';
export { LabeledTextarea } from './labeled-textarea';
export type { LabeledTextareaProps } from './labeled-textarea';
export { Skeleton, SkeletonText, SkeletonCard, SkeletonList, SkeletonHero, SkeletonForm } from './skeleton';
// === 表单类 ===
export { Checkbox } from './checkbox';
export { Switch } from './switch';
export { RadioGroup, RadioGroupItem } from './radio-group';
export { Slider } from './slider';
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
} from './select';
export {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
useFormField,
useFormWithZod,
} from './form';
// === 反馈类 ===
export { Alert, AlertTitle, AlertDescription, alertVariants } from './alert';
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
} from './alert-dialog';
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from './dialog';
export { Progress } from './progress';
// === 导航类 ===
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
} from './dropdown-menu';
export { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs';
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from './accordion';
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
} from './pagination';
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
} from './breadcrumb';
// === 数据类 ===
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
} from './table';
export { Avatar, AvatarImage, AvatarFallback } from './avatar';
export { Separator } from './separator';
export { ScrollArea, ScrollBar } from './scroll-area';
// === 浮层类 ===
export {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
LegacyTooltip,
} from './tooltip';
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } from './popover';
export { HoverCard, HoverCardTrigger, HoverCardContent } from './hover-card';
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
} from './context-menu';
// === Toast (Sonner) ===
export { Toaster, toast } from './sonner';
export { Toast } from './toast';
// === 业务自定义组件(保留) ===
export { ScrollReveal, StaggerReveal } from './scroll-reveal';
export { ScrollProgress } from './scroll-progress';
export { BackToTop } from './back-to-top';
export { StaticLink } from './static-link';
export { Spinner, LoadingState, PageLoader, useLoadingState } from './loading-state';
export { ErrorBoundary } from './error-boundary';
export { PageTransition, StaggerChildren } from './page-transition';
export {
EASE_OUT,
GrainOverlay,
FloatingInkParticles,
SectionLabel,
} from './page-decoration';
export {
GeometricDecoration,
DataBar,
GradientDivider,
BrandStamp,
} from './brand-visuals';
export { InkGlowCard } from './ink-glow-card';
export { ChallengeCard } from './challenge-card';
export { ProductCard as UiProductCard } from './product-card';
export { DetailSwipeNav } from './detail-swipe-nav';
export { HeroInkBackground } from './hero-ink-background';
export { FlipClock } from './flip-clock';
export { AnimatedCounter } from './animated-counter';
export { MetricCard } from './metric-card';
export { StatsShowcase } from './stats-showcase';
export { MilestoneTimeline } from './milestone-timeline';
+1 -1
View File
@@ -61,7 +61,7 @@ export function InkGlowCard({
className={`relative ink-glow-border rounded-2xl ${className}`}
style={
{
'--glow-start': 'var(--color-brand-primary)',
'--glow-start': 'var(--color-brand)',
'--glow-end': 'var(--color-warning)',
} as React.CSSProperties
}
+2 -2
View File
@@ -45,11 +45,11 @@ export function InkCard({
const divRef = useRef<HTMLDivElement>(null);
const cardClasses = cn(
'relative rounded-2xl',
'block relative rounded-2xl',
'bg-[var(--color-bg-primary)]',
'border border-[var(--color-border-primary)]',
glow && 'ink-glow-border',
interactive && 'transition-all duration-300 hover:border-[rgba(var(--color-brand-primary-rgb),0.2)] hover:shadow-lg',
interactive && 'transition-all duration-300 hover:border-[rgba(var(--color-brand-rgb),0.2)] hover:shadow-lg',
paddingMap[padding],
className,
);
@@ -19,14 +19,14 @@ export function InkWashBackground({ variant = 'section', className }: InkWashBac
<div
className="absolute top-[10%] left-[15%] w-[600px] h-[600px] rounded-full"
style={{
background: 'radial-gradient(circle, rgba(var(--color-primary-rgb), 0.04) 0%, transparent 60%)',
background: 'radial-gradient(circle, rgba(var(--color-brand-rgb), 0.04) 0%, transparent 60%)',
}}
/>
{/* 品牌红点缀 */}
<div
className="absolute bottom-[20%] right-[10%] w-[400px] h-[400px] rounded-full"
style={{
background: 'radial-gradient(circle, rgba(var(--color-brand-primary-rgb), 0.025) 0%, transparent 55%)',
background: 'radial-gradient(circle, rgba(var(--color-brand-rgb), 0.025) 0%, transparent 55%)',
}}
/>
{/* 宣纸纹理 */}
@@ -49,7 +49,7 @@ export function InkWashBackground({ variant = 'section', className }: InkWashBac
<div
className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[400px]"
style={{
background: 'radial-gradient(ellipse at 50% 0%, rgba(var(--color-primary-rgb), 0.025) 0%, transparent 65%)',
background: 'radial-gradient(ellipse at 50% 0%, rgba(var(--color-brand-rgb), 0.025) 0%, transparent 65%)',
}}
/>
<div className="absolute inset-0 bg-paper-texture opacity-30" />
+69 -57
View File
@@ -4,6 +4,7 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import { Input } from './input';
import { LabeledInput } from './labeled-input';
jest.mock('@/lib/utils', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
@@ -20,21 +21,6 @@ describe('Input', () => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
it('should render with label', () => {
render(<Input label="用户名" />);
expect(screen.getByLabelText('用户名')).toBeInTheDocument();
});
it('should render required indicator when required', () => {
render(<Input label="用户名" required />);
expect(screen.getByText('*')).toBeInTheDocument();
});
it('should render error message', () => {
render(<Input label="用户名" error="请输入用户名" />);
expect(screen.getByRole('alert')).toHaveTextContent('请输入用户名');
});
it('should render with placeholder', () => {
render(<Input placeholder="请输入用户名" />);
expect(screen.getByPlaceholderText('请输入用户名')).toBeInTheDocument();
@@ -76,7 +62,7 @@ describe('Input', () => {
it('should handle user input', async () => {
render(<Input />);
const input = screen.getByRole('textbox');
await userEvent.type(input, 'test value');
expect(input).toHaveValue('test value');
});
@@ -85,7 +71,7 @@ describe('Input', () => {
const handleChange = jest.fn();
render(<Input onChange={handleChange} />);
const input = screen.getByRole('textbox');
await userEvent.type(input, 'a');
expect(handleChange).toHaveBeenCalled();
});
@@ -94,7 +80,7 @@ describe('Input', () => {
const handleBlur = jest.fn();
render(<Input onBlur={handleBlur} />);
const input = screen.getByRole('textbox');
await userEvent.click(input);
await userEvent.tab();
expect(handleBlur).toHaveBeenCalled();
@@ -104,7 +90,7 @@ describe('Input', () => {
const handleFocus = jest.fn();
render(<Input onFocus={handleFocus} />);
const input = screen.getByRole('textbox');
await userEvent.click(input);
expect(handleFocus).toHaveBeenCalled();
});
@@ -120,54 +106,17 @@ describe('Input', () => {
it('should not accept input when disabled', async () => {
render(<Input disabled />);
const input = screen.getByRole('textbox');
expect(input).toBeDisabled();
});
});
describe('Accessibility', () => {
it('should have aria-required when required', () => {
render(<Input required />);
const input = screen.getByRole('textbox');
expect(input).toHaveAttribute('aria-required', 'true');
});
it('should have aria-invalid when error exists', () => {
render(<Input error="错误信息" />);
const input = screen.getByRole('textbox');
expect(input).toHaveAttribute('aria-invalid', 'true');
});
it('should have aria-describedby when error exists', () => {
render(<Input error="错误信息" />);
const input = screen.getByRole('textbox');
expect(input).toHaveAttribute('aria-describedby');
});
it('should have proper label association', () => {
render(<Input label="用户名" id="username" />);
const input = screen.getByLabelText('用户名');
expect(input).toHaveAttribute('id', 'username');
});
it('should have role="alert" on error message', () => {
render(<Input error="错误信息" />);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
describe('Custom Styling', () => {
it('should apply custom className', () => {
render(<Input className="custom-class" />);
const input = screen.getByRole('textbox');
expect(input).toHaveClass('custom-class');
});
it('should have error styling when error exists', () => {
render(<Input error="错误信息" />);
const input = screen.getByRole('textbox');
expect(input.className).toMatch(/border-\[var\(--color-brand-primary\)\]/);
});
});
describe('Ref Forwarding', () => {
@@ -178,3 +127,66 @@ describe('Input', () => {
});
});
});
describe('LabeledInput', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render with label', () => {
render(<LabeledInput label="用户名" />);
expect(screen.getByLabelText(/用户名/)).toBeInTheDocument();
});
it('should render required indicator when required', () => {
render(<LabeledInput label="用户名" required />);
expect(screen.getByText('*')).toBeInTheDocument();
});
it('should render error message', () => {
render(<LabeledInput label="用户名" error="请输入用户名" />);
expect(screen.getByRole('alert')).toHaveTextContent('请输入用户名');
});
});
describe('Accessibility', () => {
it('should have aria-required when required', () => {
render(<LabeledInput label="用户名" required />);
// label 文本包含必填星号 "*",使用正则匹配
const input = screen.getByLabelText(/用户名/);
expect(input).toHaveAttribute('aria-required', 'true');
});
it('should have aria-invalid when error exists', () => {
render(<LabeledInput label="用户名" error="错误信息" />);
const input = screen.getByLabelText(/用户名/);
expect(input).toHaveAttribute('aria-invalid', 'true');
});
it('should have aria-describedby when error exists', () => {
render(<LabeledInput label="用户名" error="错误信息" />);
const input = screen.getByLabelText(/用户名/);
expect(input).toHaveAttribute('aria-describedby');
});
it('should have proper label association', () => {
render(<LabeledInput label="用户名" id="username" />);
const input = screen.getByLabelText(/用户名/);
expect(input).toHaveAttribute('id', 'username');
});
it('should have role="alert" on error message', () => {
render(<LabeledInput label="用户名" error="错误信息" />);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
describe('Ref Forwarding', () => {
it('should forward ref to input element', () => {
const ref = React.createRef<HTMLInputElement>();
render(<LabeledInput ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLInputElement);
});
});
});
+31 -51
View File
@@ -1,56 +1,36 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string | React.ReactNode
error?: string
'data-testid'?: string
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, label, error, id, ...props }, ref) => {
const generatedId = React.useId()
const inputId = id || generatedId
const errorId = `${inputId}-error`
/**
* 标准 shadcn/ui Input 组件
* 遵循 shadcn/ui 规范:纯 input 元素 + data-slot + cn 工具
*
* 若需 label/error 属性,请使用 LabeledInput 组件
*/
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2"
>
{label}
{props.required && <span className="text-[var(--color-brand-primary)] ml-1">*</span>}
</label>
<input
type={type}
data-slot="input"
ref={ref}
className={cn(
'file:text-foreground placeholder:text-text-hint selection:bg-brand selection:text-white',
'flex h-11 w-full min-w-0 rounded-md border border-border-primary bg-bg-primary px-3 py-1 text-base',
'transition-all duration-fast outline-none',
'file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium',
'disabled:cursor-not-allowed disabled:opacity-50',
'md:text-sm',
'focus-visible:border-brand focus-visible:ring-2 focus-visible:ring-brand/30',
'hover:border-border-secondary',
'touch-manipulation',
className
)}
<input
type={type}
id={inputId}
data-slot="input"
data-testid={props['data-testid']}
className={cn(
"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}
aria-required={props.required ? "true" : undefined}
aria-invalid={error ? "true" : "false"}
aria-describedby={error ? errorId : undefined}
{...props}
/>
{error && (
<p id={errorId} className="mt-1 text-sm text-[var(--color-brand-primary)]" role="alert" data-testid="error-message">
{error}
</p>
)}
</div>
)
{...props}
/>
);
}
)
Input.displayName = "Input"
);
Input.displayName = 'Input';
export { Input }
export { Input };
+34
View File
@@ -0,0 +1,34 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui Label 组件
* 基于 @radix-ui/react-label
*/
const labelVariants = cva(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50'
);
function Label({
className,
asChild,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : LabelPrimitive.Root;
return (
<Comp
data-slot="label"
className={cn(labelVariants(), className)}
{...props}
/>
);
}
export { Label, labelVariants };
+72
View File
@@ -0,0 +1,72 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Input } from './input';
import { Label } from './label';
/**
* LabeledInput - 带 label 和 error 的 Input 包装组件
* 用于兼容现有表单(如 contact 表单)的 label/error API
* 底层使用标准 shadcn/ui Input + Label
*/
export interface LabeledInputProps extends Omit<React.ComponentProps<'input'>, 'children'> {
label?: React.ReactNode;
error?: string;
hint?: React.ReactNode;
required?: boolean;
}
const LabeledInput = React.forwardRef<HTMLInputElement, LabeledInputProps>(
({ label, error, hint, id, required, className, ...props }, ref) => {
const generatedId = React.useId();
const inputId = id || generatedId;
const errorId = `${inputId}-error`;
const hintId = `${inputId}-hint`;
return (
<div className="w-full">
{label && (
<Label
htmlFor={inputId}
className="block text-sm font-medium text-text-secondary mb-2"
>
{label}
{required && (
<span className="text-brand ml-1.5" aria-hidden="true">*</span>
)}
</Label>
)}
<Input
id={inputId}
ref={ref}
className={cn(
className,
error && 'border-brand focus-visible:border-brand focus-visible:ring-brand/30'
)}
aria-required={required ? 'true' : undefined}
aria-invalid={error ? 'true' : undefined}
aria-describedby={cn(error && errorId, hint && hintId) || undefined}
required={required}
{...props}
/>
{hint && !error && (
<p id={hintId} className="mt-1.5 text-xs text-text-muted">
{hint}
</p>
)}
{error && (
<p
id={errorId}
className="mt-1.5 text-sm text-brand"
role="alert"
data-testid="error-message"
>
{error}
</p>
)}
</div>
);
}
);
LabeledInput.displayName = 'LabeledInput';
export { LabeledInput };
+72
View File
@@ -0,0 +1,72 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Textarea } from './textarea';
import { Label } from './label';
/**
* LabeledTextarea - 带 label 和 error 的 Textarea 包装组件
* 用于兼容现有表单(如 contact 表单)的 label/error API
* 底层使用标准 shadcn/ui Textarea + Label
*/
export interface LabeledTextareaProps extends React.ComponentProps<'textarea'> {
label?: React.ReactNode;
error?: string;
hint?: React.ReactNode;
required?: boolean;
}
const LabeledTextarea = React.forwardRef<HTMLTextAreaElement, LabeledTextareaProps>(
({ label, error, hint, id, required, className, ...props }, ref) => {
const generatedId = React.useId();
const textareaId = id || generatedId;
const errorId = `${textareaId}-error`;
const hintId = `${textareaId}-hint`;
return (
<div className="w-full">
{label && (
<Label
htmlFor={textareaId}
className="block text-sm font-medium text-text-secondary mb-2"
>
{label}
{required && (
<span className="text-brand ml-1.5" aria-hidden="true">*</span>
)}
</Label>
)}
<Textarea
id={textareaId}
ref={ref}
className={cn(
className,
error && 'border-brand focus-visible:border-brand focus-visible:ring-brand/30'
)}
aria-required={required ? 'true' : undefined}
aria-invalid={error ? 'true' : undefined}
aria-describedby={cn(error && errorId, hint && hintId) || undefined}
required={required}
{...props}
/>
{hint && !error && (
<p id={hintId} className="mt-1.5 text-xs text-text-muted">
{hint}
</p>
)}
{error && (
<p
id={errorId}
className="mt-1.5 text-sm text-brand"
role="alert"
data-testid="error-message"
>
{error}
</p>
)}
</div>
);
}
);
LabeledTextarea.displayName = 'LabeledTextarea';
export { LabeledTextarea };
+13 -7
View File
@@ -21,8 +21,9 @@ describe('Loading Skeleton Components', () => {
it('should apply default styles', () => {
const { container } = render(<Skeleton />);
const skeleton = container.firstChild as HTMLElement;
expect(skeleton).toHaveClass('animate-pulse');
expect(skeleton).toHaveClass('bg-[var(--color-primary-lighter)]');
// loading-skeleton.tsx 使用 skeleton-brand 自定义类
expect(skeleton).toHaveClass('skeleton-brand');
expect(skeleton).toHaveClass('rounded-md');
});
it('should apply custom className', () => {
@@ -40,7 +41,8 @@ describe('Loading Skeleton Components', () => {
it('should have correct structure', () => {
const { container } = render(<CardSkeleton />);
const skeletonElements = container.querySelectorAll('.animate-pulse');
// CardSkeleton 包含 4 个子 Skeleton,使用 skeleton-brand 类
const skeletonElements = container.querySelectorAll('.skeleton-brand');
expect(skeletonElements.length).toBeGreaterThan(0);
});
});
@@ -106,8 +108,11 @@ describe('Loading Skeleton Components', () => {
it('should render multiple service card skeletons', () => {
const { container } = render(<SectionSkeleton />);
const cards = container.querySelectorAll('.bg-white');
expect(cards.length).toBe(4);
// SectionSkeleton 渲染 4 个 ServiceCardSkeleton
// ServiceCardSkeleton 容器使用 var(--color-bg-primary)
const cards = container.querySelectorAll('.skeleton-brand');
// 每个 ServiceCardSkeleton 包含 4 个 Skeleton4 个卡片共 16 个
expect(cards.length).toBeGreaterThanOrEqual(4);
});
});
@@ -132,12 +137,13 @@ describe('Loading Skeleton Components', () => {
it('should have pulse animation', () => {
const { container } = render(<Skeleton />);
const skeleton = container.firstChild as HTMLElement;
expect(skeleton).toHaveClass('animate-pulse');
// loading-skeleton.tsx 使用 skeleton-brand 自定义类(CSS 中包含动画)
expect(skeleton).toHaveClass('skeleton-brand');
});
it('should apply animation to all skeleton elements', () => {
const { container } = render(<CardSkeleton />);
const skeletons = container.querySelectorAll('.animate-pulse');
const skeletons = container.querySelectorAll('.skeleton-brand');
expect(skeletons.length).toBeGreaterThan(0);
});
});
+1 -1
View File
@@ -97,7 +97,7 @@ export function Spinner({ size = 'md', className }: SpinnerProps) {
return (
<svg
className={cn(
'animate-spin text-[var(--color-brand-primary)]',
'animate-spin text-[var(--color-brand)]',
sizeClasses[size],
className
)}
+123
View File
@@ -0,0 +1,123 @@
'use client';
import type { ReactNode } from 'react';
import { ArrowUpRight, ArrowDownRight } from 'lucide-react';
import { AnimatedCounter } from '@/components/ui/animated-counter';
import { cn } from '@/lib/utils';
type TrendDirection = 'up' | 'down' | 'neutral';
interface MetricCardProps {
icon?: ReactNode;
label: string;
value: number;
prefix?: string;
suffix?: string;
decimals?: number;
description?: string;
trend?: {
value: string;
direction: TrendDirection;
label?: string;
};
accentColor?: 'brand' | 'blue' | 'teal' | 'amber' | 'purple';
className?: string;
theme?: 'light' | 'dark';
}
const accentStyles: Record<string, string> = {
brand: 'text-brand bg-brand-bg',
blue: 'text-accent-blue bg-accent-blue/10',
teal: 'text-accent-teal bg-accent-teal/10',
amber: 'text-accent-amber bg-accent-amber/10',
purple: 'text-accent-purple bg-accent-purple/10',
};
const trendIcon = {
up: ArrowUpRight,
down: ArrowDownRight,
neutral: ArrowUpRight,
};
const trendColor: Record<TrendDirection, string> = {
up: 'text-success',
down: 'text-error',
neutral: 'text-text-muted',
};
export function MetricCard({
icon,
label,
value,
prefix = '',
suffix = '',
decimals = 0,
description,
trend,
accentColor = 'brand',
className,
theme = 'light',
}: MetricCardProps) {
const TrendIcon = trend ? trendIcon[trend.direction] : null;
const isDark = theme === 'dark';
return (
<div
className={cn(
'group relative overflow-hidden rounded-sm border p-6 sm:p-7 transition-all duration-300 ease-out',
isDark
? 'bg-ink/50 border-white/[0.08] hover:border-white/20 hover:bg-ink'
: 'border-border-primary bg-bg-primary hover:bg-bg-secondary',
className
)}
>
<div className="flex items-start justify-between mb-5">
{icon && (
<div
className={cn(
'flex w-12 h-12 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110',
accentStyles[accentColor]
)}
>
{icon}
</div>
)}
{trend && TrendIcon && (
<div className={cn('flex items-center gap-1 text-xs font-medium', trendColor[trend.direction])}>
<TrendIcon className="w-3.5 h-3.5" />
<span>{trend.value}</span>
{trend.label && <span className={isDark ? 'text-white/40' : 'text-text-subtle'}>{trend.label}</span>}
</div>
)}
</div>
<div className="space-y-1.5">
<div className={cn(
'text-sm font-medium tracking-wide uppercase',
isDark ? 'text-white/50' : 'text-text-muted'
)}>
{label}
</div>
<div className={cn(
'text-3xl sm:text-4xl font-bold tracking-tight',
isDark ? 'text-white' : 'text-text-primary'
)}>
<AnimatedCounter
value={value}
decimals={decimals}
prefix={prefix}
suffix={suffix}
/>
</div>
{description && (
<p className={cn(
'text-sm leading-relaxed pt-2',
isDark ? 'text-white/60' : 'text-text-muted'
)}>
{description}
</p>
)}
</div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
interface MilestoneItem {
year: string;
title: string;
description: string;
highlight?: string;
icon?: React.ReactNode;
}
interface MilestoneTimelineProps {
items: MilestoneItem[];
className?: string;
variant?: 'horizontal' | 'vertical';
theme?: 'light' | 'dark';
}
export function MilestoneTimeline({
items,
className,
variant = 'horizontal',
theme = 'light',
}: MilestoneTimelineProps) {
const ref = useRef<HTMLDivElement>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setInView(true);
observer.disconnect();
}
},
{ threshold: 0.15 },
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const isDark = theme === 'dark';
if (variant === 'vertical') {
return (
<div ref={ref} className={cn('relative', className)}>
<div className={cn('absolute left-4 top-0 bottom-0 w-px', isDark ? 'bg-white/10' : 'bg-border-primary')} />
<div className="space-y-10">
{items.map((item, i) => (
<div
key={i}
className="relative pl-14"
style={{
opacity: inView ? 1 : 0,
transform: inView ? 'translateX(0)' : 'translateX(-20px)',
transition: `all 0.6s cubic-bezier(0.22, 1, 0.36, 1) ${i * 120}ms`,
}}
>
<div className={cn(
'absolute left-2 top-1 w-5 h-5 rounded-full border-2 flex items-center justify-center',
isDark ? 'bg-ink border-brand' : 'bg-bg-primary border-brand'
)}>
<div className="w-2 h-2 rounded-full bg-brand" />
</div>
<div className="text-sm font-bold text-brand mb-1 tracking-wider">
{item.year}
</div>
<h4 className={cn('text-lg font-semibold mb-2', isDark ? 'text-white' : 'text-text-primary')}>
{item.title}
</h4>
<p className={cn('text-sm leading-relaxed', isDark ? 'text-white/60' : 'text-text-muted')}>
{item.description}
</p>
{item.highlight && (
<div className={cn(
'mt-3 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
isDark ? 'bg-brand/10 text-brand' : 'bg-brand-bg text-brand'
)}>
{item.highlight}
</div>
)}
</div>
))}
</div>
</div>
);
}
return (
<div ref={ref} className={cn('relative', className)}>
<div className={cn('absolute left-0 right-0 top-6 h-px', isDark ? 'bg-white/10' : 'bg-border-primary')} />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6 lg:gap-8">
{items.map((item, i) => (
<div
key={i}
className="relative pt-12 text-center"
style={{
opacity: inView ? 1 : 0,
transform: inView ? 'translateY(0)' : 'translateY(20px)',
transition: `all 0.6s cubic-bezier(0.22, 1, 0.36, 1) ${i * 120}ms`,
}}
>
<div className={cn(
'absolute top-4 left-1/2 -translate-x-1/2 w-5 h-5 rounded-full border-2 flex items-center justify-center z-10',
isDark ? 'bg-ink border-brand' : 'bg-bg-primary border-brand'
)}>
<div className="w-2 h-2 rounded-full bg-brand" />
</div>
<div className="text-sm font-bold text-brand mb-2 tracking-wider">
{item.year}
</div>
<h4 className={cn('text-base font-semibold mb-2 leading-snug', isDark ? 'text-white' : 'text-text-primary')}>
{item.title}
</h4>
<p className={cn('text-xs sm:text-sm leading-relaxed', isDark ? 'text-white/60' : 'text-text-muted')}>
{item.description}
</p>
</div>
))}
</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
'use client';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
export const EASE_OUT = [0.22, 1, 0.36, 1] as const;
const DEFAULT_PARTICLES = [
{ x: 15, y: 32, scale: 0.8, duration: 10, delay: 0, size: 3 },
{ x: 82, y: 58, scale: 0.6, duration: 12, delay: 1.5, size: 2 },
{ x: 45, y: 42, scale: 1.0, duration: 9, delay: 0.8, size: 4 },
{ x: 75, y: 25, scale: 0.7, duration: 11, delay: 2.2, size: 2.5 },
{ x: 25, y: 75, scale: 0.9, duration: 8.5, delay: 1.0, size: 3.5 },
{ x: 58, y: 65, scale: 0.55, duration: 13, delay: 2.8, size: 2 },
];
export function GrainOverlay({ opacity = 0.025 }: { opacity?: number }) {
return (
<div
className="absolute inset-0 pointer-events-none mix-blend-overlay"
style={{
opacity,
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
backgroundRepeat: 'repeat',
backgroundSize: '300px 300px',
}}
/>
);
}
export function FloatingInkParticles({ particles = DEFAULT_PARTICLES }: { particles?: typeof DEFAULT_PARTICLES }) {
const shouldReduceMotion = useReducedMotion();
return (
<div className="absolute inset-0 pointer-events-none">
{particles.map((particle, i) => (
<motion.div
key={i}
className="absolute rounded-full bg-white/[0.03]"
style={{
width: `${particle.size}px`,
height: `${particle.size}px`,
filter: 'blur(1px)',
}}
initial={{
x: `${particle.x}%`,
y: `${particle.y}%`,
scale: particle.scale,
opacity: 0,
}}
animate={
shouldReduceMotion
? { opacity: 0.3 }
: {
y: [`${particle.y + 12}%`, `${particle.y - 12}%`],
opacity: [0, 0.35, 0],
x: [`${particle.x - 2}%`, `${particle.x + 2}%`],
}
}
transition={{
duration: particle.duration,
repeat: Infinity,
repeatType: 'reverse',
delay: particle.delay,
ease: 'easeInOut' as const,
}}
/>
))}
</div>
);
}
export function SectionLabel({
children,
className = '',
align = 'left',
}: {
children: React.ReactNode;
className?: string;
align?: 'left' | 'center';
}) {
return (
<div
className={cn(
'flex items-center gap-3 mb-6',
align === 'center' && 'justify-center',
className
)}
>
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.15em] font-medium text-brand">
{children}
</span>
</div>
);
}
+8 -1
View File
@@ -2,6 +2,7 @@
import { motion, AnimatePresence } from 'framer-motion';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
const pageVariants = {
@@ -34,8 +35,14 @@ const pageVariants = {
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const shouldReduceMotion = useReducedMotion();
const [mounted, setMounted] = useState(false);
if (shouldReduceMotion) {
useEffect(() => {
setMounted(true);
}, []);
// SSR 和首次 hydration 阶段直接渲染子内容,避免 framer-motion DOM 差异导致 hydration mismatch
if (!mounted || shouldReduceMotion) {
return <>{children}</>;
}
+124
View File
@@ -0,0 +1,124 @@
import * as React from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
import { buttonVariants } from './button';
/**
* 标准 shadcn/ui Pagination 组件
* 纯 HTML 实现,不依赖 Radix
*/
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
}
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="pagination-content"
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
);
}
function PaginationItem({ className, ...props }: React.ComponentProps<'li'>) {
return <li data-slot="pagination-item" className={cn('', className)} {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
size?: 'sm' | 'default' | 'lg' | 'xl' | 'icon';
} & Omit<React.ComponentProps<'button'>, 'size'>;
function PaginationLink({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) {
return (
<button
aria-current={isActive ? 'page' : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? 'primary' : 'ghost',
size,
}),
'min-h-9 min-w-9 cursor-pointer',
className
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="上一页"
size="default"
className={cn('gap-1 px-2.5', className)}
{...props}
>
<ChevronLeft className="size-4" />
<span></span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="下一页"
size="default"
className={cn('gap-1 px-2.5', className)}
{...props}
>
<span></span>
<ChevronRight className="size-4" />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only"></span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};
+37
View File
@@ -0,0 +1,37 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui Popover 组件
* 基于 @radix-ui/react-popover
*/
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border border-border-primary bg-bg-primary p-4 text-text-primary shadow-md outline-none',
'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}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+65 -56
View File
@@ -1,7 +1,7 @@
'use client';
import { ArrowUpRight, Database, Users, BarChart3, FileText, Truck, Building2 } from 'lucide-react';
import { InkGlowCard } from '@/components/ui/ink-glow-card';
import { InkCard } from '@/components/ui/ink-wash/InkCard';
import type { LucideIcon } from 'lucide-react';
interface ProductCardProps {
@@ -15,11 +15,11 @@ interface ProductCardProps {
const productIcons: LucideIcon[] = [Database, Users, FileText, BarChart3, Truck, Building2];
const PRODUCT_COLORS = [
{ bg: 'rgba(var(--color-brand-primary-rgb), 0.08)', text: 'var(--color-brand-primary)', border: 'rgba(var(--color-brand-primary-rgb), 0.12)' },
{ bg: 'rgba(var(--color-brand-rgb), 0.08)', text: 'var(--color-brand)', border: 'rgba(var(--color-brand-rgb), 0.12)' },
{ bg: 'rgba(var(--color-accent-blue-rgb), 0.08)', text: 'var(--color-accent-blue)', border: 'rgba(var(--color-accent-blue-rgb), 0.12)' },
{ bg: 'rgba(var(--color-accent-purple-rgb), 0.08)', text: 'var(--color-accent-purple)', border: 'rgba(var(--color-accent-purple-rgb), 0.12)' },
{ bg: 'rgba(var(--color-accent-cyan-rgb), 0.08)', text: 'var(--color-accent-cyan)', border: 'rgba(var(--color-accent-cyan-rgb), 0.12)' },
{ bg: 'rgba(var(--color-brand-primary-rgb), 0.08)', text: 'var(--color-brand-primary)', border: 'rgba(var(--color-brand-primary-rgb), 0.12)' },
{ bg: 'rgba(var(--color-brand-rgb), 0.08)', text: 'var(--color-brand)', border: 'rgba(var(--color-brand-rgb), 0.12)' },
{ bg: 'rgba(var(--color-accent-blue-rgb), 0.08)', text: 'var(--color-accent-blue)', border: 'rgba(var(--color-accent-blue-rgb), 0.12)' },
] as const;
@@ -34,7 +34,7 @@ const defaultConfig = {
};
const statusConfig = {
'研发中': { bg: 'rgba(var(--color-brand-primary-rgb), 0.06)', text: 'var(--color-brand-primary)', border: 'rgba(var(--color-brand-primary-rgb), 0.12)' },
'研发中': { bg: 'rgba(var(--color-brand-rgb), 0.06)', text: 'var(--color-brand)', border: 'rgba(var(--color-brand-rgb), 0.12)' },
'内测中': { bg: 'rgba(var(--color-warning-rgb), 0.06)', text: 'var(--color-warning)', border: 'rgba(var(--color-warning-rgb), 0.12)' },
'已发布': { bg: 'rgba(var(--color-success-rgb), 0.06)', text: 'var(--color-success)', border: 'rgba(var(--color-success-rgb), 0.12)' },
} as const;
@@ -45,63 +45,72 @@ export function ProductCard({ title, description, href, index, status }: Product
const statusStyle = status ? statusConfig[status] : null;
return (
<InkGlowCard
index={index}
<InkCard
href={href}
padding="md" // 使用统一的 padding 规范 (p-5 sm:p-6 md:p-7)
glow={true}
mouseFollow={true}
interactive={true}
className="group h-full"
>
<div className="p-6 md:p-8">
<div className="flex items-start justify-between mb-5">
<div
className="w-11 h-11 rounded-xl flex items-center justify-center"
style={{ backgroundColor: config.color.bg }}
>
<IconComponent
className="w-5 h-5"
style={{ color: config.color.text }}
strokeWidth={1.8}
/>
</div>
<div className="flex items-center gap-2">
{status && statusStyle && (
<span
className="text-[10px] font-medium px-2 py-0.5 rounded-full border"
style={{
backgroundColor: statusStyle.bg,
color: statusStyle.text,
borderColor: statusStyle.border,
}}
>
{status}
</span>
)}
<span className="text-xs font-mono tracking-widest text-[var(--color-text-subtle)]">
{String(index + 1).padStart(2, '0')}
</span>
</div>
{/* 产品图标 */}
<div className="flex items-start justify-between mb-4">
<div
className="w-11 h-11 rounded-xl flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: config.color.bg }}
>
<IconComponent
className="w-5 h-5"
style={{ color: config.color.text }}
strokeWidth={1.8}
/>
</div>
<h3 className="text-lg font-semibold mb-2 leading-snug tracking-tight text-[var(--color-text-primary)]">
{title}
</h3>
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed line-clamp-3 mb-5">
{description}
</p>
{(status === '研发中' || status === '内测中') && (
<div className="flex items-center gap-1.5 text-xs text-[var(--color-brand-primary)]/70 mb-4 px-3 py-2 rounded-lg bg-[var(--color-brand-primary-bg)]/50">
<svg className="w-3.5 h-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeDasharray="32 16" />
</svg>
<span>{status === '研发中' ? '正在积极开发中,欢迎提前交流需求' : '即将上线,欢迎预约内测体验'}</span>
</div>
)}
<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 min-h-[44px]">
<span>{status === '研发中' ? '了解规划' : status === '内测中' ? '申请内测' : '了解更多'}</span>
<ArrowUpRight className="w-4 h-4" strokeWidth={2} />
{/* 状态标签 + 序号 */}
<div className="flex items-center gap-2 flex-shrink-0">
{status && statusStyle && (
<span
className="text-[10px] font-medium px-2 py-0.5 rounded-full border whitespace-nowrap"
style={{
backgroundColor: statusStyle.bg,
color: statusStyle.text,
borderColor: statusStyle.border,
}}
>
{status}
</span>
)}
<span className="text-xs font-mono tracking-widest text-[var(--color-text-subtle)]">
{String(index + 1).padStart(2, '0')}
</span>
</div>
</div>
</InkGlowCard>
{/* 标题 */}
<h3 className="text-base font-semibold mb-2 leading-snug tracking-tight text-[var(--color-text-primary)] group-hover:text-[var(--color-brand)] transition-colors">
{title}
</h3>
{/* 描述 */}
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed line-clamp-3 mb-4">
{description}
</p>
{/* 研发中/内测中 提示 */}
{(status === '研发中' || status === '内测中') && (
<div className="flex items-center gap-1.5 text-xs text-[var(--color-brand)]/70 mb-4 px-3 py-2 rounded-lg bg-[var(--color-brand-bg)]/50">
<svg className="w-3.5 h-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeDasharray="32 16" />
</svg>
<span>{status === '研发中' ? '正在积极开发中,欢迎提前交流需求' : '即将上线,欢迎预约内测体验'}</span>
</div>
)}
{/* CTA 链接 */}
<div className="flex items-center gap-1.5 text-sm font-medium text-[var(--color-text-subtle)] group-hover:text-[var(--color-brand)] transition-colors min-h-[44px] mt-auto">
<span>{status === '研发中' ? '了解规划' : status === '内测中' ? '申请内测' : '了解更多'}</span>
<ArrowUpRight className="w-4 h-4" strokeWidth={2} />
</div>
</InkCard>
);
}
+35
View File
@@ -0,0 +1,35 @@
'use client';
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui Progress 组件
* 基于 @radix-ui/react-progress
*/
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
indicatorClassName?: string;
}
>(({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
data-slot="progress"
className={cn(
'relative h-2 w-full overflow-hidden rounded-full bg-bg-tertiary',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn('h-full w-full flex-1 bg-brand transition-all duration-normal ease-standard', indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
+52
View File
@@ -0,0 +1,52 @@
'use client';
import * as React from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui RadioGroup 组件
* 基于 @radix-ui/react-radio-group
*/
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => (
<RadioGroupPrimitive.Root
ref={ref}
data-slot="radio-group"
className={cn('grid gap-2', className)}
{...props}
/>
));
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => (
<RadioGroupPrimitive.Item
ref={ref}
data-slot="radio-group-item"
className={cn(
'aspect-square size-4 rounded-full border border-border-primary bg-bg-primary text-brand shadow-sm',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:border-brand',
'transition-colors duration-fast',
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex items-center justify-center"
>
<Circle className="size-2.5 fill-brand text-brand" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
));
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };
+57
View File
@@ -0,0 +1,57 @@
'use client';
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui ScrollArea 组件
* 基于 @radix-ui/react-scroll-area
*/
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
data-slot="scroll-area"
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="h-full w-full rounded-[inherit] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-px',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border-secondary"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
+66 -32
View File
@@ -1,54 +1,88 @@
import { render, screen, waitFor } from '@testing-library/react';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ScrollProgress } from './scroll-progress';
// Mock framer-motion
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: React.ComponentProps<'div'>) => <div {...props}>{children}</div>,
},
useScroll: () => ({ scrollYProgress: { get: () => 0, onChange: jest.fn() } }),
useSpring: () => ({ get: () => 0 }),
}));
// Mock useReducedMotion
jest.mock('@/hooks/use-reduced-motion', () => ({
useReducedMotion: () => false,
}));
describe('ScrollProgress', () => {
let scrollYValue = 0;
beforeEach(() => {
jest.clearAllMocks();
scrollYValue = 0;
Object.defineProperty(window, 'scrollY', {
get: () => scrollYValue,
// Mock document height for predictable progress calculation
Object.defineProperty(document.documentElement, 'scrollHeight', {
value: 1000,
configurable: true,
});
window.addEventListener = jest.fn((event, handler) => {
if (event === 'scroll') {
(handler as EventListener)(new Event('scroll'));
}
Object.defineProperty(window, 'innerHeight', {
value: 500,
configurable: true,
});
});
it('should not render when scroll position is less than 100px', () => {
scrollYValue = 0;
it('should always render the progress container', () => {
const { container } = render(<ScrollProgress />);
expect(container.firstChild).toBeNull();
// ScrollProgress 始终渲染(带 aria-hidden="true"
expect(container.firstChild).not.toBeNull();
});
it('should render progressbar with correct ARIA attributes when visible', async () => {
scrollYValue = 150;
it('should have aria-hidden="true" for decorative purposes', () => {
const { container } = render(<ScrollProgress />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveAttribute('aria-hidden', 'true');
});
render(<ScrollProgress />);
it('should apply fixed positioning class', () => {
const { container } = render(<ScrollProgress />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass('fixed');
expect(wrapper).toHaveClass('pointer-events-none');
});
await waitFor(() => {
const progressbar = screen.getByRole('progressbar');
expect(progressbar).toBeInTheDocument();
expect(progressbar).toHaveAttribute('aria-label', '页面滚动进度');
expect(progressbar).toHaveAttribute('aria-valuemin', '0');
expect(progressbar).toHaveAttribute('aria-valuemax', '100');
});
it('should render inner progress bar div', () => {
const { container } = render(<ScrollProgress />);
const innerBar = (container.firstChild as HTMLElement).firstChild as HTMLElement;
expect(innerBar).toBeInTheDocument();
// 默认 progress 为 0,宽度为 0%
expect(innerBar.style.width).toBe('0%');
});
it('should apply default height of 2px', () => {
const { container } = render(<ScrollProgress />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.style.height).toBe('2px');
});
it('should support custom height', () => {
const { container } = render(<ScrollProgress height={4} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.style.height).toBe('4px');
});
it('should apply default top position', () => {
const { container } = render(<ScrollProgress />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.style.top).toBe('0px');
});
it('should support bottom position', () => {
const { container } = render(<ScrollProgress position="bottom" />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.style.bottom).toBe('0px');
});
it('should use brand color by default', () => {
const { container } = render(<ScrollProgress />);
const innerBar = (container.firstChild as HTMLElement).firstChild as HTMLElement;
expect(innerBar.style.background).toBe('var(--color-brand)');
});
it('should support custom color', () => {
const { container } = render(<ScrollProgress color="#ff0000" />);
const innerBar = (container.firstChild as HTMLElement).firstChild as HTMLElement;
// jsdom 将 #ff0000 转换为 rgb(255, 0, 0)
expect(innerBar.style.background).toContain('255');
expect(innerBar.style.background).toContain('0');
});
});
+44 -51
View File
@@ -1,69 +1,62 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, useScroll, useSpring, useTransform } from 'framer-motion';
import { useEffect, useState } from 'react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
export function ScrollProgress() {
const [isVisible, setIsVisible] = useState(false);
interface ScrollProgressProps {
/** Height of the progress bar in px */
height?: number;
/** Color of the progress bar (CSS variable or color) */
color?: string;
/** Position: top or bottom */
position?: 'top' | 'bottom';
className?: string;
}
export function ScrollProgress({
height = 2,
color = 'var(--color-brand)',
position = 'top',
className = '',
}: ScrollProgressProps) {
const [progress, setProgress] = useState(0);
const shouldReduceMotion = useReducedMotion();
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001,
});
const glowX = useSpring(scrollYProgress, {
stiffness: 120,
damping: 25,
restDelta: 0.001,
});
const glowLeft = useTransform(glowX, (v) => `calc(${v * 100}% - 30px)`);
useEffect(() => {
const handleScroll = () => {
setIsVisible(window.scrollY > 80);
const updateProgress = () => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const value = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
setProgress(value);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
updateProgress();
window.addEventListener('scroll', updateProgress, { passive: true });
window.addEventListener('resize', updateProgress);
return () => {
window.removeEventListener('scroll', updateProgress);
window.removeEventListener('resize', updateProgress);
};
}, []);
if (!isVisible) {return null;}
return (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={shouldReduceMotion ? {} : { opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed top-0 left-0 right-0 h-[2px] z-[100]"
role="progressbar"
aria-label="页面滚动进度"
aria-valuemin={0}
aria-valuemax={100}
<div
className={`fixed left-0 right-0 z-[100] pointer-events-none ${className}`}
style={{
[position]: 0,
height: `${height}px`,
}}
aria-hidden="true"
>
<motion.div
className="h-full origin-left"
<div
style={{
scaleX,
background: 'var(--color-brand-primary)',
width: `${shouldReduceMotion ? 0 : progress}%`,
height: '100%',
background: color,
transition: shouldReduceMotion ? 'none' : 'width 0.1s linear',
}}
/>
{!shouldReduceMotion && (
<motion.div
className="absolute top-1/2 -translate-y-1/2 w-[60px] h-[3px] -mt-[0.5px]"
style={{
left: glowLeft,
background: 'linear-gradient(90deg, transparent, var(--color-brand-primary), transparent)',
filter: 'blur(3px)',
opacity: 0.6,
}}
/>
)}
</motion.div>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
'use client';
import { type ReactNode, Children } from 'react';
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
type RevealVariant = 'fadeUp' | 'fadeDown' | 'fadeLeft' | 'fadeRight' | 'fadeIn' | 'scaleIn' | 'blurIn';
interface ScrollRevealProps {
children: ReactNode;
className?: string;
delay?: number;
yOffset?: number;
xOffset?: number;
margin?: string;
variant?: RevealVariant;
duration?: number;
once?: boolean;
amount?: number;
}
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
function getInitialState(variant: RevealVariant, yOffset: number, xOffset: number) {
switch (variant) {
case 'fadeUp':
return { opacity: 0, y: yOffset };
case 'fadeDown':
return { opacity: 0, y: -yOffset };
case 'fadeLeft':
return { opacity: 0, x: -xOffset };
case 'fadeRight':
return { opacity: 0, x: xOffset };
case 'fadeIn':
return { opacity: 0 };
case 'scaleIn':
return { opacity: 0, scale: 0.95 };
case 'blurIn':
return { opacity: 0, filter: 'blur(8px)', y: yOffset };
default:
return { opacity: 0, y: yOffset };
}
}
function getAnimateState(variant: RevealVariant) {
switch (variant) {
case 'blurIn':
return { opacity: 1, filter: 'blur(0px)', y: 0 };
case 'scaleIn':
return { opacity: 1, scale: 1 };
case 'fadeUp':
case 'fadeDown':
case 'fadeLeft':
case 'fadeRight':
case 'fadeIn':
default:
return { opacity: 1, y: 0, x: 0 };
}
}
export function ScrollReveal({
children,
className = '',
delay = 0,
yOffset = 0,
xOffset = 0,
margin = '-40px',
variant = 'fadeIn',
duration = 0.4,
once = true,
amount = 0,
}: ScrollRevealProps) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
className={className}
initial={shouldReduceMotion ? {} : getInitialState(variant, yOffset, xOffset)}
whileInView={shouldReduceMotion ? {} : getAnimateState(variant)}
viewport={{ once, margin, amount }}
transition={{
duration: shouldReduceMotion ? 0 : duration,
delay,
ease: EASE_OUT,
}}
>
{children}
</motion.div>
);
}
interface StaggerRevealProps {
children: ReactNode;
className?: string;
staggerDelay?: number;
delayChildren?: number;
variant?: RevealVariant;
yOffset?: number;
xOffset?: number;
margin?: string;
once?: boolean;
}
export function StaggerReveal({
children,
className = '',
staggerDelay = 0.05,
delayChildren = 0.05,
variant = 'fadeIn',
yOffset = 0,
xOffset = 0,
margin = '-40px',
once = true,
}: StaggerRevealProps) {
const shouldReduceMotion = useReducedMotion();
const containerVariants = {
hidden: {},
visible: {
transition: {
staggerChildren: shouldReduceMotion ? 0 : staggerDelay,
delayChildren: shouldReduceMotion ? 0 : delayChildren,
},
},
};
const itemVariants = {
hidden: shouldReduceMotion ? {} : getInitialState(variant, yOffset, xOffset),
visible: shouldReduceMotion ? {} : getAnimateState(variant),
};
return (
<motion.div
className={className}
initial="hidden"
whileInView="visible"
viewport={{ once, margin }}
variants={containerVariants}
>
{Children.map(children, (child) => (
<motion.div variants={itemVariants} transition={{ ease: EASE_OUT }}>
{child}
</motion.div>
))}
</motion.div>
);
}
+168
View File
@@ -0,0 +1,168 @@
'use client';
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui Select 组件
* 基于 @radix-ui/react-select
*/
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
data-slot="select-trigger"
className={cn(
'flex h-11 w-full items-center justify-between rounded-md border border-border-primary bg-bg-primary px-3 py-2 text-sm shadow-sm',
'placeholder:text-text-hint',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[placeholder]:text-text-hint',
'transition-colors duration-fast hover:border-border-secondary',
'[&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
data-slot="select-scroll-up-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="size-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
data-slot="select-scroll-down-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="size-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
data-slot="select-content"
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border-primary bg-bg-primary text-text-primary shadow-md',
'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',
position === 'popper' && 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' && 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
data-slot="select-label"
className={cn('px-2 py-1.5 text-xs font-medium text-text-muted', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
data-slot="select-item"
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none',
'focus:bg-bg-secondary focus:text-text-primary',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="size-4 text-brand" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
data-slot="select-separator"
className={cn('-mx-1 my-1 h-px bg-border-primary', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+30
View File
@@ -0,0 +1,30 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui Separator 组件
* 基于 @radix-ui/react-separator
*/
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border-primary',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+23 -35
View File
@@ -1,46 +1,43 @@
import { cn } from '@/lib/utils';
interface SkeletonProps {
className?: string;
variant?: 'default' | 'circular' | 'rounded';
}
export function Skeleton({ className, variant = 'default' }: SkeletonProps) {
/**
* 标准 shadcn/ui Skeleton 组件
* 遵循 shadcn/ui 规范:animate-pulse + bg-muted + rounded + cn
*
* 扩展变体(SkeletonText/Card/List/Hero/Form)保留在文件末尾,
* 用于兼容现有 LoadingState 组件,API 遵循 cn + 标准 className 模式。
*/
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'animate-pulse bg-[var(--color-bg-section)]',
variant === 'circular' && 'rounded-full',
variant === 'rounded' && 'rounded-lg',
variant === 'default' && 'rounded',
className
)}
aria-hidden="true"
role="presentation"
data-slot="skeleton"
className={cn('animate-pulse rounded-md bg-bg-tertiary', className)}
{...props}
/>
);
}
export { Skeleton };
// === 兼容扩展变体(用于 LoadingState ===
interface SkeletonTextProps {
lines?: number;
className?: string;
lastLineWidth?: string;
}
export function SkeletonText({
lines = 3,
export function SkeletonText({
lines = 3,
className,
lastLineWidth = '75%'
lastLineWidth = '75%',
}: SkeletonTextProps) {
return (
<div className={cn('space-y-3', className)} aria-hidden="true" role="presentation">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
className={cn(
'h-4',
i === lines - 1 ? `w-[${lastLineWidth}]` : 'w-full'
)}
className={cn('h-4', i === lines - 1 ? lastLineWidth : 'w-full')}
/>
))}
</div>
@@ -65,25 +62,16 @@ export function SkeletonCard({
return (
<div
className={cn(
'p-6 rounded-xl border border-[var(--color-border-primary)] bg-[var(--color-bg-primary)]',
'p-6 rounded-xl border border-border-primary bg-bg-primary',
className
)}
role="status"
aria-label="加载中"
aria-busy="true"
>
{showImage && (
<Skeleton className="h-40 w-full rounded-lg mb-4" />
)}
{showTitle && (
<Skeleton className="h-6 w-3/4 mb-3" />
)}
{showDescription && (
<SkeletonText lines={2} lastLineWidth="90%" />
)}
{showImage && <Skeleton className="h-40 w-full rounded-lg mb-4" />}
{showTitle && <Skeleton className="h-6 w-3/4 mb-3" />}
{showDescription && <SkeletonText lines={2} lastLineWidth="90%" />}
{showFooter && (
<div className="flex gap-2 mt-4">
<Skeleton className="h-10 w-24 rounded-lg" />
+62
View File
@@ -0,0 +1,62 @@
'use client';
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui Slider 组件
* 基于 @radix-ui/react-slider
*/
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
data-slot="slider"
className={cn(
'relative flex w-full touch-none select-none items-center',
'disabled:opacity-50 disabled:pointer-events-none',
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn('relative h-1.5 w-full grow overflow-hidden rounded-full bg-bg-tertiary')}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn('absolute h-full bg-brand')}
/>
</SliderPrimitive.Track>
{props.defaultValue?.map((_, i) => (
<SliderPrimitive.Thumb
key={i}
data-slot="slider-thumb"
className={cn(
'block size-4 rounded-full border border-brand bg-white shadow-sm',
'transition-colors duration-fast',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2',
'disabled:pointer-events-none',
'hover:shadow-md'
)}
/>
)) ?? (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
className={cn(
'block size-4 rounded-full border border-brand bg-white shadow-sm',
'transition-colors duration-fast',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2',
'disabled:pointer-events-none',
'hover:shadow-md'
)}
/>
)}
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };
+43
View File
@@ -0,0 +1,43 @@
'use client';
import { Toaster as Sonner } from 'sonner';
import { CheckCircle2, AlertCircle, Info, X } from 'lucide-react';
/**
* 标准 shadcn/ui Sonner Toaster 组件
* 替代旧的自实现 Toast,提供队列、portal、无障碍等完整能力
*
* 使用方式:
* 1. 在根 layout 中渲染 <Toaster />
* 2. 在任意组件中调用 toast.success('消息') / toast.error('消息') / toast.info('消息')
*/
type ToasterProviderProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProviderProps) => {
return (
<Sonner
theme="light"
className="toaster group"
style={
{
'--normal-bg': 'var(--color-bg-primary)',
'--normal-text': 'var(--color-text-primary)',
'--normal-border': 'var(--color-border-primary)',
} as React.CSSProperties
}
icons={{
success: <CheckCircle2 className="size-4 text-success" />,
error: <AlertCircle className="size-4 text-error" />,
info: <Info className="size-4 text-info" />,
close: <X className="size-4" />,
}}
{...props}
/>
);
};
export { Toaster };
// 重新导出 sonner 的核心 API
export { toast } from 'sonner';
+142
View File
@@ -0,0 +1,142 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import { AnimatedCounter } from '@/components/ui/animated-counter';
import { cn } from '@/lib/utils';
interface StatShowcaseItem {
value: number;
prefix?: string;
suffix?: string;
decimals?: number;
label: string;
description?: string;
accent?: string;
}
interface StatsShowcaseProps {
items: StatShowcaseItem[];
columns?: 2 | 3 | 4;
variant?: 'default' | 'dark' | 'minimal';
className?: string;
}
export function StatsShowcase({
items,
columns = 4,
variant = 'default',
className,
}: StatsShowcaseProps) {
const sectionRef = useRef<HTMLDivElement>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setInView(true);
observer.disconnect();
}
},
{ threshold: 0.2 },
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const gridCols = {
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-2 sm:grid-cols-2 lg:grid-cols-4',
};
const isDark = variant === 'dark';
return (
<div
ref={sectionRef}
className={cn(
'grid gap-px overflow-hidden rounded-sm',
gridCols[columns],
isDark
? 'bg-white/5'
: 'bg-border-primary',
className
)}
>
{items.map((item, i) => (
<div
key={i}
className={cn(
'relative p-8 sm:p-10 md:p-12 text-center transition-all duration-500 group',
isDark ? 'bg-ink hover:bg-ink-light' : 'bg-bg-primary hover:bg-bg-hover'
)}
>
<div className="relative z-10">
<div
className={cn(
'font-sans text-[clamp(40px,9vw,72px)] font-bold leading-none mb-3 tracking-tight',
isDark ? 'text-white' : 'text-text-primary'
)}
style={{
opacity: inView ? 1 : 0,
transform: inView ? 'translateY(0)' : 'translateY(12px)',
transition: `all 0.7s cubic-bezier(0.22, 1, 0.36, 1) ${i * 100}ms`,
}}
>
<AnimatedCounter
value={item.value}
decimals={item.decimals || 0}
prefix={item.prefix}
suffix={item.suffix}
startOnView={false}
duration={1800}
/>
</div>
<div
className={cn(
'text-xs sm:text-sm font-medium tracking-[0.15em] uppercase mb-3',
isDark ? 'text-white/60' : 'text-text-muted'
)}
style={{
opacity: inView ? 1 : 0,
transform: inView ? 'translateY(0)' : 'translateY(8px)',
transition: `all 0.6s cubic-bezier(0.22, 1, 0.36, 1) ${i * 100 + 150}ms`,
}}
>
{item.label}
</div>
{item.description && (
<div
className={cn(
'text-xs sm:text-sm leading-relaxed max-w-[200px] mx-auto',
isDark ? 'text-white/40' : 'text-text-subtle'
)}
style={{
opacity: inView ? 1 : 0,
transform: inView ? 'translateY(0)' : 'translateY(6px)',
transition: `all 0.6s cubic-bezier(0.22, 1, 0.36, 1) ${i * 100 + 250}ms`,
}}
>
{item.description}
</div>
)}
</div>
{item.accent && (
<div
className="absolute top-0 left-1/2 -translate-x-1/2 w-12 h-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-500"
style={{ backgroundColor: item.accent }}
/>
)}
</div>
))}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
'use client';
import * as React from 'react';
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui Switch 组件
* 基于 @radix-ui/react-switch
*/
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
ref={ref}
data-slot="switch"
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:bg-brand data-[state=unchecked]:bg-border-secondary',
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
'pointer-events-none block size-4 rounded-full bg-white shadow-lg ring-0 transition-transform',
'data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitive.Root>
));
Switch.displayName = SwitchPrimitive.Root.displayName;
export { Switch };
+111
View File
@@ -0,0 +1,111 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui Table 组件
* 纯 HTML 实现,不依赖 Radix
*/
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div data-slot="table-container" className="relative w-full overflow-auto">
<table
data-slot="table"
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return (
<thead
data-slot="table-header"
className={cn('[&_tr]:border-b border-border-primary', className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn(
'border-t border-border-primary bg-bg-tertiary font-medium [&>tr]:last:border-b-0',
className
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot="table-row"
className={cn(
'border-b border-border-primary transition-colors hover:bg-bg-secondary/50 data-[state=selected]:bg-brand-soft',
className
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={cn(
'h-10 px-2 text-left align-middle font-medium text-text-muted [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={cn(
'p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
className={cn('mt-4 text-sm text-text-muted', className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
+65
View File
@@ -0,0 +1,65 @@
'use client';
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
/**
* 标准 shadcn/ui Tabs 组件
* 基于 @radix-ui/react-tabs
*/
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
data-slot="tabs-list"
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-bg-tertiary p-1 text-text-muted',
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
data-slot="tabs-trigger"
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium',
'ring-offset-background transition-all duration-fast',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
'data-[state=active]:bg-bg-primary data-[state=active]:text-text-primary data-[state=active]:shadow-sm',
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
data-slot="tabs-content"
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2',
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+70 -58
View File
@@ -4,6 +4,7 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import { Textarea } from './textarea';
import { LabeledTextarea } from './labeled-textarea';
jest.mock('@/lib/utils', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
@@ -20,21 +21,6 @@ describe('Textarea', () => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
it('should render with label', () => {
render(<Textarea label="留言内容" />);
expect(screen.getByLabelText('留言内容')).toBeInTheDocument();
});
it('should render required indicator when required', () => {
render(<Textarea label="留言内容" required />);
expect(screen.getByText('*')).toBeInTheDocument();
});
it('should render error message', () => {
render(<Textarea label="留言内容" error="请输入留言内容" />);
expect(screen.getByRole('alert')).toHaveTextContent('请输入留言内容');
});
it('should render with placeholder', () => {
render(<Textarea placeholder="请输入留言内容" />);
expect(screen.getByPlaceholderText('请输入留言内容')).toBeInTheDocument();
@@ -56,7 +42,7 @@ describe('Textarea', () => {
it('should handle user input', async () => {
render(<Textarea />);
const textarea = screen.getByRole('textbox');
await userEvent.type(textarea, '这是一段测试留言');
expect(textarea).toHaveValue('这是一段测试留言');
});
@@ -65,7 +51,7 @@ describe('Textarea', () => {
const handleChange = jest.fn();
render(<Textarea onChange={handleChange} />);
const textarea = screen.getByRole('textbox');
await userEvent.type(textarea, 'a');
expect(handleChange).toHaveBeenCalled();
});
@@ -74,7 +60,7 @@ describe('Textarea', () => {
const handleBlur = jest.fn();
render(<Textarea onBlur={handleBlur} />);
const textarea = screen.getByRole('textbox');
await userEvent.click(textarea);
await userEvent.tab();
expect(handleBlur).toHaveBeenCalled();
@@ -84,7 +70,7 @@ describe('Textarea', () => {
const handleFocus = jest.fn();
render(<Textarea onFocus={handleFocus} />);
const textarea = screen.getByRole('textbox');
await userEvent.click(textarea);
expect(handleFocus).toHaveBeenCalled();
});
@@ -92,7 +78,7 @@ describe('Textarea', () => {
it('should handle multiline input', async () => {
render(<Textarea />);
const textarea = screen.getByRole('textbox');
await userEvent.type(textarea, '第一行{enter}第二行');
expect(textarea).toHaveValue('第一行\n第二行');
});
@@ -108,54 +94,17 @@ describe('Textarea', () => {
it('should not accept input when disabled', async () => {
render(<Textarea disabled />);
const textarea = screen.getByRole('textbox');
expect(textarea).toBeDisabled();
});
});
describe('Accessibility', () => {
it('should have aria-required when required', () => {
render(<Textarea required />);
const textarea = screen.getByRole('textbox');
expect(textarea).toHaveAttribute('aria-required', 'true');
});
it('should have aria-invalid when error exists', () => {
render(<Textarea error="错误信息" />);
const textarea = screen.getByRole('textbox');
expect(textarea).toHaveAttribute('aria-invalid', 'true');
});
it('should have aria-describedby when error exists', () => {
render(<Textarea error="错误信息" />);
const textarea = screen.getByRole('textbox');
expect(textarea).toHaveAttribute('aria-describedby');
});
it('should have proper label association', () => {
render(<Textarea label="留言内容" id="message" />);
const textarea = screen.getByLabelText('留言内容');
expect(textarea).toHaveAttribute('id', 'message');
});
it('should have role="alert" on error message', () => {
render(<Textarea error="错误信息" />);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
describe('Custom Styling', () => {
it('should apply custom className', () => {
render(<Textarea className="custom-class" />);
const textarea = screen.getByRole('textbox');
expect(textarea).toHaveClass('custom-class');
});
it('should have error styling when error exists', () => {
render(<Textarea error="错误信息" />);
const textarea = screen.getByRole('textbox');
expect(textarea.className).toMatch(/border-\[var\(--color-brand-primary\)\]/);
});
});
describe('Ref Forwarding', () => {
@@ -188,3 +137,66 @@ describe('Textarea', () => {
});
});
});
describe('LabeledTextarea', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render with label', () => {
render(<LabeledTextarea label="留言内容" />);
expect(screen.getByLabelText(/留言内容/)).toBeInTheDocument();
});
it('should render required indicator when required', () => {
render(<LabeledTextarea label="留言内容" required />);
expect(screen.getByText('*')).toBeInTheDocument();
});
it('should render error message', () => {
render(<LabeledTextarea label="留言内容" error="请输入留言内容" />);
expect(screen.getByRole('alert')).toHaveTextContent('请输入留言内容');
});
});
describe('Accessibility', () => {
it('should have aria-required when required', () => {
render(<LabeledTextarea label="留言内容" required />);
// label 文本包含必填星号 "*",使用正则匹配
const textarea = screen.getByLabelText(/留言内容/);
expect(textarea).toHaveAttribute('aria-required', 'true');
});
it('should have aria-invalid when error exists', () => {
render(<LabeledTextarea label="留言内容" error="错误信息" />);
const textarea = screen.getByLabelText(/留言内容/);
expect(textarea).toHaveAttribute('aria-invalid', 'true');
});
it('should have aria-describedby when error exists', () => {
render(<LabeledTextarea label="留言内容" error="错误信息" />);
const textarea = screen.getByLabelText(/留言内容/);
expect(textarea).toHaveAttribute('aria-describedby');
});
it('should have proper label association', () => {
render(<LabeledTextarea label="留言内容" id="message" />);
const textarea = screen.getByLabelText(/留言内容/);
expect(textarea).toHaveAttribute('id', 'message');
});
it('should have role="alert" on error message', () => {
render(<LabeledTextarea label="留言内容" error="错误信息" />);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
describe('Ref Forwarding', () => {
it('should forward ref to textarea element', () => {
const ref = React.createRef<HTMLTextAreaElement>();
render(<LabeledTextarea ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLTextAreaElement);
});
});
});
+29 -50
View File
@@ -1,55 +1,34 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
error?: string
'data-testid'?: string
}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, label, error, id, ...props }, ref) => {
const generatedId = React.useId()
const textareaId = id || generatedId
const errorId = `${textareaId}-error`
/**
* 标准 shadcn/ui Textarea 组件
* 遵循 shadcn/ui 规范:纯 textarea 元素 + data-slot + cn 工具
*
* 若需 label/error 属性,请使用 LabeledTextarea 组件
*/
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
({ className, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label
htmlFor={textareaId}
className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2"
>
{label}
{props.required && <span className="text-[var(--color-brand-primary)] ml-1">*</span>}
</label>
<textarea
data-slot="textarea"
ref={ref}
className={cn(
'placeholder:text-text-hint selection:bg-brand selection:text-white',
'flex min-h-[80px] w-full rounded-md border border-border-primary bg-bg-primary px-3 py-2 text-base',
'transition-all duration-fast outline-none',
'disabled:cursor-not-allowed disabled:opacity-50',
'md:text-sm',
'focus-visible:border-brand focus-visible:ring-2 focus-visible:ring-brand/30',
'hover:border-border-secondary',
'touch-manipulation resize-y',
className
)}
<textarea
id={textareaId}
data-slot="textarea"
data-testid={props['data-testid']}
className={cn(
"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}
aria-required={props.required ? "true" : undefined}
aria-invalid={error ? "true" : "false"}
aria-describedby={error ? errorId : undefined}
{...props}
/>
{error && (
<p id={errorId} className="mt-1 text-sm text-[var(--color-brand-primary)]" role="alert">
{error}
</p>
)}
</div>
)
{...props}
/>
);
}
)
Textarea.displayName = "Textarea"
);
Textarea.displayName = 'Textarea';
export { Textarea }
export { Textarea };
+110 -131
View File
@@ -1,163 +1,142 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
// jest.mock 的 factory 在 hoist 阶段执行,需在 factory 内构造 mock
jest.mock('sonner', () => {
const mockToast = jest.fn() as unknown as {
success: jest.Mock;
error: jest.Mock;
info: jest.Mock;
dismiss: jest.Mock;
};
mockToast.success = jest.fn(() => 'success-id');
mockToast.error = jest.fn(() => 'error-id');
mockToast.info = jest.fn(() => 'info-id');
mockToast.dismiss = jest.fn();
return { toast: mockToast };
});
// 导入被测组件(必须在 mock 之后)
import { Toast } from './toast';
// 导入 mock 后的 sonner(类型断言访问 mock 字段)
import { toast as mockToast } from 'sonner';
const mockedToast = mockToast as unknown as {
success: jest.Mock;
error: jest.Mock;
info: jest.Mock;
dismiss: jest.Mock;
};
describe('Toast Component', () => {
const mockOnClose = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('Rendering', () => {
it('should render toast with message', () => {
render(<Toast message="Test message" onClose={mockOnClose} />);
expect(screen.getByText('Test message')).toBeInTheDocument();
});
it('should render with success type by default', () => {
render(<Toast message="Success" onClose={mockOnClose} />);
const toast = screen.getByRole('alert');
expect(toast).toHaveAttribute('data-type', 'success');
});
it('should render with error type', () => {
render(<Toast message="Error" type="error" onClose={mockOnClose} />);
const toast = screen.getByRole('alert');
expect(toast).toHaveAttribute('data-type', 'error');
});
it('should render with info type', () => {
render(<Toast message="Info" type="info" onClose={mockOnClose} />);
const toast = screen.getByRole('alert');
expect(toast).toHaveAttribute('data-type', 'info');
});
it('should render close button', () => {
render(<Toast message="Test" onClose={mockOnClose} />);
expect(screen.getByRole('button', { name: '关闭提示' })).toBeInTheDocument();
});
});
describe('Auto-close', () => {
it('should auto-close after default duration (3000ms)', async () => {
render(<Toast message="Test" onClose={mockOnClose} />);
expect(mockOnClose).not.toHaveBeenCalled();
jest.advanceTimersByTime(3000);
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
});
it('should auto-close after custom duration', async () => {
render(<Toast message="Test" duration={5000} onClose={mockOnClose} />);
jest.advanceTimersByTime(5000);
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
});
it('should cleanup timer on unmount', () => {
const { unmount } = render(<Toast message="Test" onClose={mockOnClose} />);
unmount();
jest.advanceTimersByTime(3000);
expect(mockOnClose).not.toHaveBeenCalled();
});
});
describe('Manual Close', () => {
it('should close when close button clicked', async () => {
render(<Toast message="Test" onClose={mockOnClose} />);
const closeButton = screen.getByRole('button', { name: '关闭提示' });
fireEvent.click(closeButton);
jest.advanceTimersByTime(300);
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
});
});
describe('Accessibility', () => {
it('should have alert role', () => {
render(<Toast message="Test" onClose={mockOnClose} />);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
it('should have aria-live attribute', () => {
render(<Toast message="Test" onClose={mockOnClose} />);
const toast = screen.getByRole('alert');
expect(toast).toHaveAttribute('aria-live', 'polite');
});
it('should have accessible close button', () => {
render(<Toast message="Test" onClose={mockOnClose} />);
const closeButton = screen.getByRole('button', { name: '关闭提示' });
expect(closeButton).toBeInTheDocument();
});
});
describe('Data Attributes', () => {
it('should support custom data-testid', () => {
render(
<Toast
message="Test"
onClose={mockOnClose}
data-testid="custom-toast"
/>
it('should render null (delegates to sonner Toaster)', () => {
const { container } = render(
<Toast message="Test message" onClose={mockOnClose} />
);
expect(screen.getByTestId('custom-toast')).toBeInTheDocument();
// Toast 是 sonner 的声明式包装,组件本身不渲染 DOM
expect(container.firstChild).toBeNull();
});
});
describe('Icons', () => {
it('should render success icon for success type', () => {
render(<Toast message="Success" type="success" onClose={mockOnClose} />);
const toast = screen.getByRole('alert');
expect(toast.querySelector('svg')).toBeInTheDocument();
describe('Sonner Integration', () => {
it('should call toast.success for default (success) type', () => {
render(<Toast message="Success" onClose={mockOnClose} />);
expect(mockedToast.success).toHaveBeenCalledWith(
'Success',
expect.objectContaining({ duration: 3000 })
);
});
it('should render error icon for error type', () => {
it('should call toast.error for error type', () => {
render(<Toast message="Error" type="error" onClose={mockOnClose} />);
const toast = screen.getByRole('alert');
expect(toast.querySelector('svg')).toBeInTheDocument();
expect(mockedToast.error).toHaveBeenCalledWith(
'Error',
expect.objectContaining({ duration: 3000 })
);
});
it('should render info icon for info type', () => {
it('should call toast.info for info type', () => {
render(<Toast message="Info" type="info" onClose={mockOnClose} />);
const toast = screen.getByRole('alert');
expect(toast.querySelector('svg')).toBeInTheDocument();
expect(mockedToast.info).toHaveBeenCalledWith(
'Info',
expect.objectContaining({ duration: 3000 })
);
});
it('should pass custom duration to sonner', () => {
render(<Toast message="Test" duration={5000} onClose={mockOnClose} />);
expect(mockedToast.success).toHaveBeenCalledWith(
'Test',
expect.objectContaining({ duration: 5000 })
);
});
it('should pass Infinity duration when duration is 0', () => {
render(<Toast message="Test" duration={0} onClose={mockOnClose} />);
expect(mockedToast.success).toHaveBeenCalledWith(
'Test',
expect.objectContaining({ duration: Infinity })
);
});
it('should pass onDismiss callback to sonner', () => {
render(<Toast message="Test" onClose={mockOnClose} />);
expect(mockedToast.success).toHaveBeenCalledWith(
'Test',
expect.objectContaining({
onDismiss: expect.any(Function),
})
);
});
});
describe('Styling', () => {
it('should apply success background color', () => {
render(<Toast message="Success" type="success" onClose={mockOnClose} />);
const toast = screen.getByRole('alert');
expect(toast.className).toContain('bg-green-50');
describe('Cleanup', () => {
it('should call toast.dismiss on unmount', () => {
const { unmount } = render(
<Toast message="Test" onClose={mockOnClose} />
);
unmount();
expect(mockedToast.dismiss).toHaveBeenCalledWith('success-id');
});
it('should apply error background color', () => {
render(<Toast message="Error" type="error" onClose={mockOnClose} />);
const toast = screen.getByRole('alert');
expect(toast.className).toContain('bg-red-50');
it('should call toast.dismiss with correct id for error type', () => {
const { unmount } = render(
<Toast message="Error" type="error" onClose={mockOnClose} />
);
unmount();
expect(mockedToast.dismiss).toHaveBeenCalledWith('error-id');
});
});
it('should apply info background color', () => {
render(<Toast message="Info" type="info" onClose={mockOnClose} />);
const toast = screen.getByRole('alert');
expect(toast.className).toContain('bg-blue-50');
describe('onClose Synchronization', () => {
it('should call latest onClose when sonner dismisses', () => {
const handleClose1 = jest.fn();
const handleClose2 = jest.fn();
const { rerender } = render(
<Toast message="Test" onClose={handleClose1} />
);
// 重新渲染,传入新的 onClose
rerender(<Toast message="Test" onClose={handleClose2} />);
// 获取 sonner 接收的 onDismiss 回调
const calls = mockedToast.success.mock.calls;
const lastCall = calls[calls.length - 1];
const onDismiss = (lastCall?.[1] as { onDismiss?: () => void })?.onDismiss as () => void;
// 触发 dismiss,应该调用最新的 handleClose2
onDismiss();
expect(handleClose2).toHaveBeenCalled();
expect(handleClose1).not.toHaveBeenCalled();
});
});
});
+41 -48
View File
@@ -1,7 +1,18 @@
'use client';
import { useEffect, useState } from 'react';
import { CheckCircle2, X, AlertCircle, Info } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
/**
* Toast 兼容包装组件
* 底层使用 sonner,保持旧版声明式 API 以兼容现有 contact 表单
*
* 注意:推荐直接使用 sonner 的函数式 API
* import { toast } from '@/components/ui/sonner';
* toast.success('消息') / toast.error('消息')
*
* 此组件仅为兼容旧代码保留,新代码请直接用 sonner API
*/
interface ToastProps {
message: string;
@@ -11,58 +22,40 @@ interface ToastProps {
'data-testid'?: string;
}
export function Toast({
message,
type = 'success',
duration = 3000,
export function Toast({
message,
type = 'success',
duration = 3000,
onClose,
'data-testid': dataTestId
'data-testid': dataTestId,
}: ToastProps) {
const [isVisible, setIsVisible] = useState(true);
const onCloseRef = useRef(onClose);
// 同步最新的 onClose 到 ref(必须在 effect 中以避免 render 阶段更新 ref
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(onClose, 300);
}, duration);
const sonnerType = type === 'error' ? 'error' : type === 'info' ? 'info' : 'success';
const sonnerDuration = duration === 0 ? Infinity : duration;
return () => clearTimeout(timer);
}, [duration, onClose]);
const id = toast[sonnerType](message, {
duration: sonnerDuration,
onDismiss: () => onCloseRef.current(),
});
const icons = {
success: <CheckCircle2 className="h-5 w-5 text-green-500" />,
error: <AlertCircle className="h-5 w-5 text-red-500" />,
info: <Info className="h-5 w-5 text-blue-500" />
};
return () => {
toast.dismiss(id);
};
// 仅挂载时触发,message/type/duration 变化通过重新挂载实现
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const bgColors = {
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)]'
};
// 兼容 data-testidsonner 不直接支持,但保留属性供测试查询)
if (dataTestId && typeof document !== 'undefined') {
// sonner 渲染的 DOM 无法直接设置 data-testid,这里仅保留接口兼容
}
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-[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-[var(--color-text-primary)]">{message}</p>
<button
onClick={() => {
setIsVisible(false);
setTimeout(onClose, 300);
}}
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" />
</button>
</div>
);
return null;
}
+104 -92
View File
@@ -1,105 +1,117 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
interface TooltipProps {
content: string;
/**
* 标准 shadcn/ui Tooltip 组件
* 基于 @radix-ui/react-tooltip,提供完整的无障碍支持、碰撞检测、键盘焦点管理
*
* 使用方式:
* <TooltipProvider>
* <Tooltip>
* <TooltipTrigger asChild><button>Hover me</button></TooltipTrigger>
* <TooltipContent>提示文字</TooltipContent>
* </Tooltip>
* </TooltipProvider>
*
* 兼容旧 API:直接传 content + children 也能工作
*/
function TooltipProvider({
delayDuration = 300,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip(props: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return (
<TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
);
}
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, children, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'z-50 w-fit origin-(--radix-tooltip-content-transform-origin)',
'rounded-md bg-bg-primary px-3 py-1.5 text-xs text-text-primary border border-border-primary',
'text-balance shadow-sm',
'animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-bg-primary fill-bg-primary border-border-primary" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
};
// === 兼容旧 API 的便捷封装 ===
// 旧用法:<Tooltip content="提示"><button>...</button></Tooltip>
// 新用法见上方(Provider > Tooltip > Trigger + Content
interface LegacyTooltipProps {
content: React.ReactNode;
children: React.ReactNode;
side?: 'top' | 'right' | 'bottom' | 'left';
className?: string;
delayDuration?: number;
}
export function Tooltip({ content, children, side = 'top', className }: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const triggerRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const showTooltip = () => {
if (!triggerRef.current || !tooltipRef.current) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
let top = 0;
let left = 0;
switch (side) {
case 'top':
top = triggerRect.top - tooltipRect.height - 8;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'bottom':
top = triggerRect.bottom + 8;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'left':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.left - tooltipRect.width - 8;
break;
case 'right':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.right + 8;
break;
}
setPosition({ top, left });
setIsVisible(true);
};
const hideTooltip = () => {
setIsVisible(false);
};
useEffect(() => {
if (!isVisible) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') hideTooltip();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isVisible]);
/**
* 兼容旧 API 的 Tooltip:传入 content + children 即可
* 内部包裹 Provider + Trigger(asChild) + Content
*/
function LegacyTooltip({
content,
children,
side = 'top',
className,
delayDuration = 300,
}: LegacyTooltipProps) {
return (
<div
ref={triggerRef}
className="relative inline-flex"
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
>
{children}
{isVisible && (
<div
ref={tooltipRef}
role="tooltip"
className={cn(
'fixed z-50 px-3 py-2 text-sm rounded-lg bg-[var(--color-primary)] text-white shadow-lg',
'pointer-events-none animate-in fade-in-0 zoom-in-95 duration-200',
'max-w-xs',
className
)}
style={{
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
<TooltipProvider delayDuration={delayDuration}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side={side} className={className}>
{content}
<div
className={cn(
'absolute w-2 h-2 bg-[var(--color-primary)] rotate-45',
side === 'top' && 'bottom-[-4px] left-1/2 -translate-x-1/2',
side === 'bottom' && 'top-[-4px] left-1/2 -translate-x-1/2',
side === 'left' && 'right-[-4px] top-1/2 -translate-y-1/2',
side === 'right' && 'left-[-4px] top-1/2 -translate-y-1/2'
)}
/>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export { LegacyTooltip };