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:
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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" />
|
||||
|
||||
@@ -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-sm(shadcn/ui 规范)
|
||||
const badge = container.querySelector('.rounded-sm');
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
+28
-23
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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-sm(shadcn/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
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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="重试"
|
||||
>
|
||||
重试
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 个 Skeleton,4 个卡片共 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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-testid(sonner 不直接支持,但保留属性供测试查询)
|
||||
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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user