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 }}
|
exit={shouldReduceMotion ? {} : { opacity: 0, y: 20, scale: 0.8 }}
|
||||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||||
onClick={scrollToTop}
|
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="返回顶部"
|
aria-label="返回顶部"
|
||||||
title="返回顶部"
|
title="返回顶部"
|
||||||
style={{
|
whileHover={shouldReduceMotion ? {} : { scale: 1.05 }}
|
||||||
boxShadow: '0 4px 14px rgba(var(--color-brand-primary-rgb), 0.4)',
|
|
||||||
}}
|
|
||||||
whileHover={shouldReduceMotion ? {} : { scale: 1.1 }}
|
|
||||||
whileTap={shouldReduceMotion ? {} : { scale: 0.95 }}
|
whileTap={shouldReduceMotion ? {} : { scale: 0.95 }}
|
||||||
>
|
>
|
||||||
<ArrowUp className="w-6 h-6" />
|
<ArrowUp className="w-6 h-6" />
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ describe('Badge', () => {
|
|||||||
expect(badge).toBeInTheDocument();
|
expect(badge).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render destructive variant', () => {
|
it('should render error variant', () => {
|
||||||
const { container } = render(<Badge variant="destructive">Destructive</Badge>);
|
const { container } = render(<Badge variant="error">Error</Badge>);
|
||||||
const badge = container.querySelector('[data-variant="destructive"]');
|
const badge = container.querySelector('[data-variant="error"]');
|
||||||
expect(badge).toBeInTheDocument();
|
expect(badge).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,7 +86,8 @@ describe('Badge', () => {
|
|||||||
|
|
||||||
it('should have rounded-full class', () => {
|
it('should have rounded-full class', () => {
|
||||||
const { container } = render(<Badge>Badge</Badge>);
|
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();
|
expect(badge).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+28
-23
@@ -1,52 +1,57 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from 'react';
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const badgeVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-[var(--color-brand-primary)] text-white border-transparent shadow-sm",
|
default: 'bg-brand text-white border-transparent',
|
||||||
secondary:
|
secondary:
|
||||||
"bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] border-[var(--color-border-primary)]",
|
'bg-bg-secondary text-text-secondary border-border-primary',
|
||||||
destructive:
|
|
||||||
"bg-[var(--color-brand-primary)] text-white border-transparent hover:bg-[var(--color-brand-primary-hover)]",
|
|
||||||
outline:
|
outline:
|
||||||
"border-[var(--color-primary)] text-[var(--color-primary)] bg-transparent hover:bg-[var(--color-primary-lighter)]",
|
'border-border-accent text-text-primary bg-transparent',
|
||||||
ghost: "text-[var(--color-text-placeholder)] hover:text-[var(--color-primary)] hover:bg-[var(--color-primary-lighter)]",
|
ghost: 'text-text-muted hover:text-text-primary hover:bg-bg-secondary',
|
||||||
success: "bg-[var(--color-success)] text-white border-transparent hover:bg-[var(--color-success-hover)]",
|
success: 'bg-success/10 text-success border-transparent',
|
||||||
warning: "bg-[var(--color-warning)] text-white border-transparent hover:bg-[var(--color-warning-hover)]",
|
warning: 'bg-warning/10 text-warning border-transparent',
|
||||||
info: "bg-[var(--color-info)] text-white border-transparent hover:bg-[var(--color-primary-light)]",
|
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: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
function Badge({
|
function Badge({
|
||||||
className,
|
className,
|
||||||
variant = "default",
|
variant = 'default',
|
||||||
|
size,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span"> &
|
}: React.ComponentProps<'span'> &
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
const Comp = asChild ? Slot : "span"
|
const Comp = asChild ? Slot : 'span';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="badge"
|
data-slot="badge"
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(badgeVariants({ variant }), className)}
|
className={cn(badgeVariants({ variant, size }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ export function GeometricDecoration({ variant = 'circles' }: { variant?: 'circle
|
|||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
|
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
|
||||||
<svg className="absolute top-[10%] right-[5%] w-64 h-64 opacity-[0.04]" viewBox="0 0 200 200">
|
<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="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-primary)]" 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-primary)]" strokeWidth="0.5" />
|
<circle cx="100" cy="100" r="40" fill="none" stroke="currentColor" className="text-[var(--color-brand)]" strokeWidth="0.5" />
|
||||||
{!shouldReduceMotion && (
|
{!shouldReduceMotion && (
|
||||||
<motion.circle
|
<motion.circle
|
||||||
cx="100" cy="100" r="20"
|
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] }}
|
animate={{ r: [20, 35, 20], opacity: [0.1, 0.05, 0.1] }}
|
||||||
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
|
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
|
||||||
/>
|
/>
|
||||||
@@ -35,9 +35,9 @@ export function GeometricDecoration({ variant = 'circles' }: { variant?: 'circle
|
|||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
|
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
|
||||||
<svg className="absolute top-0 right-0 w-full h-full opacity-[0.02]" viewBox="0 0 1000 1000" preserveAspectRatio="none">
|
<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="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-primary)]" strokeWidth="0.5" />
|
<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-primary)]" 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="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" />
|
<line x1="0" y1="700" x2="200" y2="1000" stroke="currentColor" className="text-[var(--color-text-subtle)]" strokeWidth="0.5" />
|
||||||
</svg>
|
</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' }) {
|
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 percentage = Math.min((value / max) * 100, 100);
|
||||||
const colorMap = {
|
const colorMap = {
|
||||||
brand: 'var(--color-brand-primary)',
|
brand: 'var(--color-brand)',
|
||||||
blue: 'var(--color-accent-blue)',
|
blue: 'var(--color-accent-blue)',
|
||||||
purple: 'var(--color-accent-purple)',
|
purple: 'var(--color-accent-purple)',
|
||||||
cyan: 'var(--color-accent-cyan)',
|
cyan: 'var(--color-accent-cyan)',
|
||||||
@@ -83,7 +83,7 @@ export function GradientDivider() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 py-2">
|
<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="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 className="h-px flex-1 bg-gradient-to-r from-transparent via-[var(--color-border-primary)] to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -99,13 +99,13 @@ export function BrandStamp({ children }: { children: React.ReactNode }) {
|
|||||||
whileInView={{ scale: 1, opacity: 1, rotate: -5 }}
|
whileInView={{ scale: 1, opacity: 1, rotate: -5 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
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={{
|
style={{
|
||||||
transform: 'rotate(-5deg)',
|
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}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</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', () => {
|
it('should apply default variant styles', () => {
|
||||||
render(<Button>Default</Button>);
|
render(<Button>Default</Button>);
|
||||||
const button = screen.getByRole('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', () => {
|
it('should apply secondary variant styles', () => {
|
||||||
render(<Button variant="secondary">Secondary</Button>);
|
render(<Button variant="secondary">Secondary</Button>);
|
||||||
const button = screen.getByRole('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', () => {
|
it('should apply outline variant styles', () => {
|
||||||
render(<Button variant="outline">Outline</Button>);
|
render(<Button variant="outline">Outline</Button>);
|
||||||
const button = screen.getByRole('button');
|
const button = screen.getByRole('button');
|
||||||
expect(button).toHaveClass('border-2');
|
// outline variant 使用 border-border-accent
|
||||||
expect(button).toHaveClass('border-[var(--color-primary)]');
|
expect(button).toHaveClass('border');
|
||||||
|
expect(button).toHaveClass('border-border-accent');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should apply ghost variant styles', () => {
|
it('should apply ghost variant styles', () => {
|
||||||
render(<Button variant="ghost">Ghost</Button>);
|
render(<Button variant="ghost">Ghost</Button>);
|
||||||
const button = screen.getByRole('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', () => {
|
it('should apply link variant styles', () => {
|
||||||
@@ -52,7 +56,8 @@ describe('Button Component', () => {
|
|||||||
it('should apply destructive variant styles', () => {
|
it('should apply destructive variant styles', () => {
|
||||||
render(<Button variant="destructive">Destructive</Button>);
|
render(<Button variant="destructive">Destructive</Button>);
|
||||||
const button = screen.getByRole('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';
|
'use client';
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from 'react';
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
primary:
|
||||||
"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",
|
'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:
|
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]",
|
'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',
|
||||||
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]",
|
|
||||||
outline:
|
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:
|
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:
|
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: {
|
size: {
|
||||||
default: "h-11 px-4 py-2",
|
sm: 'h-9 rounded-md px-3 text-xs font-medium',
|
||||||
sm: "h-9 rounded-md px-3 text-xs",
|
default: 'h-11 rounded-md px-5 py-2 font-medium',
|
||||||
lg: "h-12 rounded-lg px-6 text-base",
|
lg: 'h-12 rounded-md px-6 text-base font-semibold',
|
||||||
icon: "h-11 w-11",
|
xl: 'h-14 rounded-md px-8 text-base font-semibold',
|
||||||
|
icon: 'h-11 w-11 rounded-md',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'primary',
|
||||||
size: "default",
|
size: 'default',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
|
showRipple?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, onClick, children, ...props }, ref) => {
|
({ className, variant, size, asChild = false, onClick, children, showRipple = true, ...props }, ref) => {
|
||||||
const [ripples, setRipples] = React.useState<Array<{ id: number; x: number; y: number }>>([])
|
const [ripples, setRipples] = React.useState<{ x: number; y: number; id: number }[]>([]);
|
||||||
|
|
||||||
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
if (showRipple && variant !== 'link' && variant !== 'ghost') {
|
||||||
const x = e.clientX - rect.left
|
const button = e.currentTarget;
|
||||||
const y = e.clientY - rect.top
|
const rect = button.getBoundingClientRect();
|
||||||
const id = Date.now()
|
const x = e.clientX - rect.left;
|
||||||
setRipples(prev => [...prev, { id, x, y }])
|
const y = e.clientY - rect.top;
|
||||||
setTimeout(() => {
|
const id = Date.now();
|
||||||
setRipples(prev => prev.filter(r => r.id !== id))
|
|
||||||
}, 600)
|
setRipples((prev) => [...prev, { x, y, id }]);
|
||||||
onClick?.(e)
|
|
||||||
|
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 (
|
return (
|
||||||
<Comp
|
<button
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{ripples.map((ripple) => (
|
||||||
{ripples.map(ripple => (
|
|
||||||
<span
|
<span
|
||||||
key={ripple.id}
|
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={{
|
style={{
|
||||||
left: ripple.x,
|
left: ripple.x,
|
||||||
top: ripple.y,
|
top: ripple.y,
|
||||||
width: '20px',
|
transform: 'translate(-50%, -50%)',
|
||||||
height: '20px',
|
|
||||||
marginLeft: '-10px',
|
|
||||||
marginTop: '-10px',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</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', () => {
|
it('should apply default styles', () => {
|
||||||
render(<Card data-testid="card">Test</Card>);
|
render(<Card data-testid="card">Test</Card>);
|
||||||
const card = screen.getByTestId('card');
|
const card = screen.getByTestId('card');
|
||||||
expect(card).toHaveClass('bg-[var(--color-bg-secondary)]');
|
// Card 使用 bg-bg-primary + rounded-sm(shadcn/ui 规范 + 项目 token)
|
||||||
expect(card).toHaveClass('rounded-xl');
|
expect(card).toHaveClass('bg-bg-primary');
|
||||||
|
expect(card).toHaveClass('rounded-sm');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should apply custom className', () => {
|
it('should apply custom className', () => {
|
||||||
@@ -44,8 +45,9 @@ describe('Card Components', () => {
|
|||||||
it('should have hover effects', () => {
|
it('should have hover effects', () => {
|
||||||
render(<Card data-testid="card">Test</Card>);
|
render(<Card data-testid="card">Test</Card>);
|
||||||
const card = screen.getByTestId('card');
|
const card = screen.getByTestId('card');
|
||||||
expect(card).toHaveClass('hover:border-[var(--color-text-primary)]');
|
// Card hover 使用 border-border-secondary + shadow-md
|
||||||
expect(card).toHaveClass('hover:shadow-[0_8px_24px_rgba(0,0,0,0.06)]');
|
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', () => {
|
it('should apply default styles', () => {
|
||||||
render(<CardHeader data-testid="header">Test</CardHeader>);
|
render(<CardHeader data-testid="header">Test</CardHeader>);
|
||||||
const header = screen.getByTestId('header');
|
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>);
|
render(<CardTitle data-testid="title">Test</CardTitle>);
|
||||||
const title = screen.getByTestId('title');
|
const title = screen.getByTestId('title');
|
||||||
expect(title).toHaveClass('font-semibold');
|
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', () => {
|
it('should apply default styles', () => {
|
||||||
render(<CardDescription data-testid="desc">Test</CardDescription>);
|
render(<CardDescription data-testid="desc">Test</CardDescription>);
|
||||||
const desc = screen.getByTestId('desc');
|
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');
|
expect(desc).toHaveClass('text-sm');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -127,7 +132,8 @@ describe('Card Components', () => {
|
|||||||
it('should apply default styles', () => {
|
it('should apply default styles', () => {
|
||||||
render(<CardAction data-testid="action">Test</CardAction>);
|
render(<CardAction data-testid="action">Test</CardAction>);
|
||||||
const action = screen.getByTestId('action');
|
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', () => {
|
it('should apply default styles', () => {
|
||||||
render(<CardContent data-testid="content">Test</CardContent>);
|
render(<CardContent data-testid="content">Test</CardContent>);
|
||||||
const content = screen.getByTestId('content');
|
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', () => {
|
it('should apply default styles', () => {
|
||||||
render(<CardFooter data-testid="footer">Test</CardFooter>);
|
render(<CardFooter data-testid="footer">Test</CardFooter>);
|
||||||
const footer = screen.getByTestId('footer');
|
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');
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-title"
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-description"
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
className={cn(
|
className={cn('ml-auto', className)}
|
||||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-content"
|
data-slot="card-content"
|
||||||
className={cn("px-6", className)}
|
className={cn('p-6 pt-0', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-footer"
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -89,4 +85,4 @@ export {
|
|||||||
CardAction,
|
CardAction,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardContent,
|
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="flex items-start justify-between mb-5">
|
||||||
<div
|
<div
|
||||||
className="w-11 h-11 rounded-xl flex items-center justify-center"
|
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
|
<IconComponent
|
||||||
className="w-5 h-5 text-[var(--color-brand-primary)]"
|
className="w-5 h-5 text-[var(--color-brand)]"
|
||||||
strokeWidth={1.8}
|
strokeWidth={1.8}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,7 +56,7 @@ export function ChallengeCard({ title, description, scenario, href, index }: Cha
|
|||||||
{description}
|
{description}
|
||||||
</p>
|
</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>
|
<span>了解方案</span>
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
</div>
|
</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: '重试' });
|
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');
|
expect(button).toHaveClass('text-white');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => this.setState({ hasError: false, error: undefined })}
|
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="重试"
|
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 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 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}
|
{digit}
|
||||||
</div>
|
</div>
|
||||||
</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 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}
|
{digit}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,7 +46,7 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
|
|||||||
backfaceVisibility: 'hidden',
|
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}
|
{prevDigit}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -64,7 +64,7 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
|
|||||||
backfaceVisibility: 'hidden',
|
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}
|
{digit}
|
||||||
</div>
|
</div>
|
||||||
</motion.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">
|
<div className="flex flex-wrap justify-center items-center gap-4 sm:gap-6 mb-6">
|
||||||
<FlipCard value={years} label="年" maxDigits={2} />
|
<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} />
|
<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} />
|
<FlipCard value={days} label="天" maxDigits={3} />
|
||||||
</div>
|
</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}`}
|
className={`relative ink-glow-border rounded-2xl ${className}`}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
'--glow-start': 'var(--color-brand-primary)',
|
'--glow-start': 'var(--color-brand)',
|
||||||
'--glow-end': 'var(--color-warning)',
|
'--glow-end': 'var(--color-warning)',
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ export function InkCard({
|
|||||||
const divRef = useRef<HTMLDivElement>(null);
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const cardClasses = cn(
|
const cardClasses = cn(
|
||||||
'relative rounded-2xl',
|
'block relative rounded-2xl',
|
||||||
'bg-[var(--color-bg-primary)]',
|
'bg-[var(--color-bg-primary)]',
|
||||||
'border border-[var(--color-border-primary)]',
|
'border border-[var(--color-border-primary)]',
|
||||||
glow && 'ink-glow-border',
|
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],
|
paddingMap[padding],
|
||||||
className,
|
className,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ export function InkWashBackground({ variant = 'section', className }: InkWashBac
|
|||||||
<div
|
<div
|
||||||
className="absolute top-[10%] left-[15%] w-[600px] h-[600px] rounded-full"
|
className="absolute top-[10%] left-[15%] w-[600px] h-[600px] rounded-full"
|
||||||
style={{
|
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
|
<div
|
||||||
className="absolute bottom-[20%] right-[10%] w-[400px] h-[400px] rounded-full"
|
className="absolute bottom-[20%] right-[10%] w-[400px] h-[400px] rounded-full"
|
||||||
style={{
|
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
|
<div
|
||||||
className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[400px]"
|
className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[400px]"
|
||||||
style={{
|
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" />
|
<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 userEvent from '@testing-library/user-event';
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { Input } from './input';
|
import { Input } from './input';
|
||||||
|
import { LabeledInput } from './labeled-input';
|
||||||
|
|
||||||
jest.mock('@/lib/utils', () => ({
|
jest.mock('@/lib/utils', () => ({
|
||||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||||
@@ -20,21 +21,6 @@ describe('Input', () => {
|
|||||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
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', () => {
|
it('should render with placeholder', () => {
|
||||||
render(<Input placeholder="请输入用户名" />);
|
render(<Input placeholder="请输入用户名" />);
|
||||||
expect(screen.getByPlaceholderText('请输入用户名')).toBeInTheDocument();
|
expect(screen.getByPlaceholderText('请输入用户名')).toBeInTheDocument();
|
||||||
@@ -76,7 +62,7 @@ describe('Input', () => {
|
|||||||
it('should handle user input', async () => {
|
it('should handle user input', async () => {
|
||||||
render(<Input />);
|
render(<Input />);
|
||||||
const input = screen.getByRole('textbox');
|
const input = screen.getByRole('textbox');
|
||||||
|
|
||||||
await userEvent.type(input, 'test value');
|
await userEvent.type(input, 'test value');
|
||||||
expect(input).toHaveValue('test value');
|
expect(input).toHaveValue('test value');
|
||||||
});
|
});
|
||||||
@@ -85,7 +71,7 @@ describe('Input', () => {
|
|||||||
const handleChange = jest.fn();
|
const handleChange = jest.fn();
|
||||||
render(<Input onChange={handleChange} />);
|
render(<Input onChange={handleChange} />);
|
||||||
const input = screen.getByRole('textbox');
|
const input = screen.getByRole('textbox');
|
||||||
|
|
||||||
await userEvent.type(input, 'a');
|
await userEvent.type(input, 'a');
|
||||||
expect(handleChange).toHaveBeenCalled();
|
expect(handleChange).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -94,7 +80,7 @@ describe('Input', () => {
|
|||||||
const handleBlur = jest.fn();
|
const handleBlur = jest.fn();
|
||||||
render(<Input onBlur={handleBlur} />);
|
render(<Input onBlur={handleBlur} />);
|
||||||
const input = screen.getByRole('textbox');
|
const input = screen.getByRole('textbox');
|
||||||
|
|
||||||
await userEvent.click(input);
|
await userEvent.click(input);
|
||||||
await userEvent.tab();
|
await userEvent.tab();
|
||||||
expect(handleBlur).toHaveBeenCalled();
|
expect(handleBlur).toHaveBeenCalled();
|
||||||
@@ -104,7 +90,7 @@ describe('Input', () => {
|
|||||||
const handleFocus = jest.fn();
|
const handleFocus = jest.fn();
|
||||||
render(<Input onFocus={handleFocus} />);
|
render(<Input onFocus={handleFocus} />);
|
||||||
const input = screen.getByRole('textbox');
|
const input = screen.getByRole('textbox');
|
||||||
|
|
||||||
await userEvent.click(input);
|
await userEvent.click(input);
|
||||||
expect(handleFocus).toHaveBeenCalled();
|
expect(handleFocus).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -120,54 +106,17 @@ describe('Input', () => {
|
|||||||
it('should not accept input when disabled', async () => {
|
it('should not accept input when disabled', async () => {
|
||||||
render(<Input disabled />);
|
render(<Input disabled />);
|
||||||
const input = screen.getByRole('textbox');
|
const input = screen.getByRole('textbox');
|
||||||
|
|
||||||
expect(input).toBeDisabled();
|
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', () => {
|
describe('Custom Styling', () => {
|
||||||
it('should apply custom className', () => {
|
it('should apply custom className', () => {
|
||||||
render(<Input className="custom-class" />);
|
render(<Input className="custom-class" />);
|
||||||
const input = screen.getByRole('textbox');
|
const input = screen.getByRole('textbox');
|
||||||
expect(input).toHaveClass('custom-class');
|
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', () => {
|
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 * as React from 'react';
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
/**
|
||||||
label?: string | React.ReactNode
|
* 标准 shadcn/ui Input 组件
|
||||||
error?: string
|
* 遵循 shadcn/ui 规范:纯 input 元素 + data-slot + cn 工具
|
||||||
'data-testid'?: string
|
*
|
||||||
}
|
* 若需 label/error 属性,请使用 LabeledInput 组件
|
||||||
|
*/
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||||
({ className, type, label, error, id, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
const generatedId = React.useId()
|
|
||||||
const inputId = id || generatedId
|
|
||||||
const errorId = `${inputId}-error`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<input
|
||||||
{label && (
|
type={type}
|
||||||
<label
|
data-slot="input"
|
||||||
htmlFor={inputId}
|
ref={ref}
|
||||||
className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2"
|
className={cn(
|
||||||
>
|
'file:text-foreground placeholder:text-text-hint selection:bg-brand selection:text-white',
|
||||||
{label}
|
'flex h-11 w-full min-w-0 rounded-md border border-border-primary bg-bg-primary px-3 py-1 text-base',
|
||||||
{props.required && <span className="text-[var(--color-brand-primary)] ml-1">*</span>}
|
'transition-all duration-fast outline-none',
|
||||||
</label>
|
'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
|
{...props}
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
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', () => {
|
it('should apply default styles', () => {
|
||||||
const { container } = render(<Skeleton />);
|
const { container } = render(<Skeleton />);
|
||||||
const skeleton = container.firstChild as HTMLElement;
|
const skeleton = container.firstChild as HTMLElement;
|
||||||
expect(skeleton).toHaveClass('animate-pulse');
|
// loading-skeleton.tsx 使用 skeleton-brand 自定义类
|
||||||
expect(skeleton).toHaveClass('bg-[var(--color-primary-lighter)]');
|
expect(skeleton).toHaveClass('skeleton-brand');
|
||||||
|
expect(skeleton).toHaveClass('rounded-md');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should apply custom className', () => {
|
it('should apply custom className', () => {
|
||||||
@@ -40,7 +41,8 @@ describe('Loading Skeleton Components', () => {
|
|||||||
|
|
||||||
it('should have correct structure', () => {
|
it('should have correct structure', () => {
|
||||||
const { container } = render(<CardSkeleton />);
|
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);
|
expect(skeletonElements.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -106,8 +108,11 @@ describe('Loading Skeleton Components', () => {
|
|||||||
|
|
||||||
it('should render multiple service card skeletons', () => {
|
it('should render multiple service card skeletons', () => {
|
||||||
const { container } = render(<SectionSkeleton />);
|
const { container } = render(<SectionSkeleton />);
|
||||||
const cards = container.querySelectorAll('.bg-white');
|
// SectionSkeleton 渲染 4 个 ServiceCardSkeleton
|
||||||
expect(cards.length).toBe(4);
|
// 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', () => {
|
it('should have pulse animation', () => {
|
||||||
const { container } = render(<Skeleton />);
|
const { container } = render(<Skeleton />);
|
||||||
const skeleton = container.firstChild as HTMLElement;
|
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', () => {
|
it('should apply animation to all skeleton elements', () => {
|
||||||
const { container } = render(<CardSkeleton />);
|
const { container } = render(<CardSkeleton />);
|
||||||
const skeletons = container.querySelectorAll('.animate-pulse');
|
const skeletons = container.querySelectorAll('.skeleton-brand');
|
||||||
expect(skeletons.length).toBeGreaterThan(0);
|
expect(skeletons.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function Spinner({ size = 'md', className }: SpinnerProps) {
|
|||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={cn(
|
className={cn(
|
||||||
'animate-spin text-[var(--color-brand-primary)]',
|
'animate-spin text-[var(--color-brand)]',
|
||||||
sizeClasses[size],
|
sizeClasses[size],
|
||||||
className
|
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 { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
|
|
||||||
const pageVariants = {
|
const pageVariants = {
|
||||||
@@ -34,8 +35,14 @@ const pageVariants = {
|
|||||||
export function PageTransition({ children }: { children: React.ReactNode }) {
|
export function PageTransition({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const shouldReduceMotion = useReducedMotion();
|
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}</>;
|
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';
|
'use client';
|
||||||
|
|
||||||
import { ArrowUpRight, Database, Users, BarChart3, FileText, Truck, Building2 } from 'lucide-react';
|
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';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
@@ -15,11 +15,11 @@ interface ProductCardProps {
|
|||||||
const productIcons: LucideIcon[] = [Database, Users, FileText, BarChart3, Truck, Building2];
|
const productIcons: LucideIcon[] = [Database, Users, FileText, BarChart3, Truck, Building2];
|
||||||
|
|
||||||
const PRODUCT_COLORS = [
|
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-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-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-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)' },
|
{ 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;
|
] as const;
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ const defaultConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusConfig = {
|
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-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)' },
|
'已发布': { bg: 'rgba(var(--color-success-rgb), 0.06)', text: 'var(--color-success)', border: 'rgba(var(--color-success-rgb), 0.12)' },
|
||||||
} as const;
|
} as const;
|
||||||
@@ -45,63 +45,72 @@ export function ProductCard({ title, description, href, index, status }: Product
|
|||||||
const statusStyle = status ? statusConfig[status] : null;
|
const statusStyle = status ? statusConfig[status] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InkGlowCard
|
<InkCard
|
||||||
index={index}
|
|
||||||
href={href}
|
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="flex items-start justify-between mb-4">
|
||||||
<div
|
<div
|
||||||
className="w-11 h-11 rounded-xl flex items-center justify-center"
|
className="w-11 h-11 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||||
style={{ backgroundColor: config.color.bg }}
|
style={{ backgroundColor: config.color.bg }}
|
||||||
>
|
>
|
||||||
<IconComponent
|
<IconComponent
|
||||||
className="w-5 h-5"
|
className="w-5 h-5"
|
||||||
style={{ color: config.color.text }}
|
style={{ color: config.color.text }}
|
||||||
strokeWidth={1.8}
|
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>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold mb-2 leading-snug tracking-tight text-[var(--color-text-primary)]">
|
{/* 状态标签 + 序号 */}
|
||||||
{title}
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
</h3>
|
{status && statusStyle && (
|
||||||
|
<span
|
||||||
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed line-clamp-3 mb-5">
|
className="text-[10px] font-medium px-2 py-0.5 rounded-full border whitespace-nowrap"
|
||||||
{description}
|
style={{
|
||||||
</p>
|
backgroundColor: statusStyle.bg,
|
||||||
|
color: statusStyle.text,
|
||||||
{(status === '研发中' || status === '内测中') && (
|
borderColor: statusStyle.border,
|
||||||
<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" />
|
{status}
|
||||||
</svg>
|
</span>
|
||||||
<span>{status === '研发中' ? '正在积极开发中,欢迎提前交流需求' : '即将上线,欢迎预约内测体验'}</span>
|
)}
|
||||||
</div>
|
<span className="text-xs font-mono tracking-widest text-[var(--color-text-subtle)]">
|
||||||
)}
|
{String(index + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
<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>
|
</div>
|
||||||
</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';
|
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
|
// Mock useReducedMotion
|
||||||
jest.mock('@/hooks/use-reduced-motion', () => ({
|
jest.mock('@/hooks/use-reduced-motion', () => ({
|
||||||
useReducedMotion: () => false,
|
useReducedMotion: () => false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('ScrollProgress', () => {
|
describe('ScrollProgress', () => {
|
||||||
let scrollYValue = 0;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
scrollYValue = 0;
|
// Mock document height for predictable progress calculation
|
||||||
Object.defineProperty(window, 'scrollY', {
|
Object.defineProperty(document.documentElement, 'scrollHeight', {
|
||||||
get: () => scrollYValue,
|
value: 1000,
|
||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
window.addEventListener = jest.fn((event, handler) => {
|
Object.defineProperty(window, 'innerHeight', {
|
||||||
if (event === 'scroll') {
|
value: 500,
|
||||||
(handler as EventListener)(new Event('scroll'));
|
configurable: true,
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not render when scroll position is less than 100px', () => {
|
it('should always render the progress container', () => {
|
||||||
scrollYValue = 0;
|
|
||||||
const { container } = render(<ScrollProgress />);
|
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 () => {
|
it('should have aria-hidden="true" for decorative purposes', () => {
|
||||||
scrollYValue = 150;
|
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(() => {
|
it('should render inner progress bar div', () => {
|
||||||
const progressbar = screen.getByRole('progressbar');
|
const { container } = render(<ScrollProgress />);
|
||||||
expect(progressbar).toBeInTheDocument();
|
const innerBar = (container.firstChild as HTMLElement).firstChild as HTMLElement;
|
||||||
expect(progressbar).toHaveAttribute('aria-label', '页面滚动进度');
|
expect(innerBar).toBeInTheDocument();
|
||||||
expect(progressbar).toHaveAttribute('aria-valuemin', '0');
|
// 默认 progress 为 0,宽度为 0%
|
||||||
expect(progressbar).toHaveAttribute('aria-valuemax', '100');
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { motion, useScroll, useSpring, useTransform } from 'framer-motion';
|
|
||||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
|
|
||||||
export function ScrollProgress() {
|
interface ScrollProgressProps {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
/** 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 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(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
const updateProgress = () => {
|
||||||
setIsVisible(window.scrollY > 80);
|
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 });
|
updateProgress();
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
window.addEventListener('scroll', updateProgress, { passive: true });
|
||||||
|
window.addEventListener('resize', updateProgress);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', updateProgress);
|
||||||
|
window.removeEventListener('resize', updateProgress);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!isVisible) {return null;}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div
|
||||||
initial={shouldReduceMotion ? {} : { opacity: 0 }}
|
className={`fixed left-0 right-0 z-[100] pointer-events-none ${className}`}
|
||||||
animate={{ opacity: 1 }}
|
style={{
|
||||||
exit={shouldReduceMotion ? {} : { opacity: 0 }}
|
[position]: 0,
|
||||||
transition={{ duration: 0.2 }}
|
height: `${height}px`,
|
||||||
className="fixed top-0 left-0 right-0 h-[2px] z-[100]"
|
}}
|
||||||
role="progressbar"
|
aria-hidden="true"
|
||||||
aria-label="页面滚动进度"
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={100}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<div
|
||||||
className="h-full origin-left"
|
|
||||||
style={{
|
style={{
|
||||||
scaleX,
|
width: `${shouldReduceMotion ? 0 : progress}%`,
|
||||||
background: 'var(--color-brand-primary)',
|
height: '100%',
|
||||||
|
background: color,
|
||||||
|
transition: shouldReduceMotion ? 'none' : 'width 0.1s linear',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!shouldReduceMotion && (
|
</div>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface SkeletonProps {
|
/**
|
||||||
className?: string;
|
* 标准 shadcn/ui Skeleton 组件
|
||||||
variant?: 'default' | 'circular' | 'rounded';
|
* 遵循 shadcn/ui 规范:animate-pulse + bg-muted + rounded + cn
|
||||||
}
|
*
|
||||||
|
* 扩展变体(SkeletonText/Card/List/Hero/Form)保留在文件末尾,
|
||||||
export function Skeleton({ className, variant = 'default' }: SkeletonProps) {
|
* 用于兼容现有 LoadingState 组件,API 遵循 cn + 标准 className 模式。
|
||||||
|
*/
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
data-slot="skeleton"
|
||||||
'animate-pulse bg-[var(--color-bg-section)]',
|
className={cn('animate-pulse rounded-md bg-bg-tertiary', className)}
|
||||||
variant === 'circular' && 'rounded-full',
|
{...props}
|
||||||
variant === 'rounded' && 'rounded-lg',
|
|
||||||
variant === 'default' && 'rounded',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
role="presentation"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
|
|
||||||
|
// === 兼容扩展变体(用于 LoadingState) ===
|
||||||
|
|
||||||
interface SkeletonTextProps {
|
interface SkeletonTextProps {
|
||||||
lines?: number;
|
lines?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
lastLineWidth?: string;
|
lastLineWidth?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SkeletonText({
|
export function SkeletonText({
|
||||||
lines = 3,
|
lines = 3,
|
||||||
className,
|
className,
|
||||||
lastLineWidth = '75%'
|
lastLineWidth = '75%',
|
||||||
}: SkeletonTextProps) {
|
}: SkeletonTextProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-3', className)} aria-hidden="true" role="presentation">
|
<div className={cn('space-y-3', className)} aria-hidden="true" role="presentation">
|
||||||
{Array.from({ length: lines }).map((_, i) => (
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
key={i}
|
key={i}
|
||||||
className={cn(
|
className={cn('h-4', i === lines - 1 ? lastLineWidth : 'w-full')}
|
||||||
'h-4',
|
|
||||||
i === lines - 1 ? `w-[${lastLineWidth}]` : 'w-full'
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -65,25 +62,16 @@ export function SkeletonCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
role="status"
|
role="status"
|
||||||
aria-label="加载中"
|
aria-label="加载中"
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
>
|
>
|
||||||
{showImage && (
|
{showImage && <Skeleton className="h-40 w-full rounded-lg mb-4" />}
|
||||||
<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%" />}
|
||||||
|
|
||||||
{showTitle && (
|
|
||||||
<Skeleton className="h-6 w-3/4 mb-3" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showDescription && (
|
|
||||||
<SkeletonText lines={2} lastLineWidth="90%" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showFooter && (
|
{showFooter && (
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="flex gap-2 mt-4">
|
||||||
<Skeleton className="h-10 w-24 rounded-lg" />
|
<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 userEvent from '@testing-library/user-event';
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { Textarea } from './textarea';
|
import { Textarea } from './textarea';
|
||||||
|
import { LabeledTextarea } from './labeled-textarea';
|
||||||
|
|
||||||
jest.mock('@/lib/utils', () => ({
|
jest.mock('@/lib/utils', () => ({
|
||||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||||
@@ -20,21 +21,6 @@ describe('Textarea', () => {
|
|||||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
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', () => {
|
it('should render with placeholder', () => {
|
||||||
render(<Textarea placeholder="请输入留言内容" />);
|
render(<Textarea placeholder="请输入留言内容" />);
|
||||||
expect(screen.getByPlaceholderText('请输入留言内容')).toBeInTheDocument();
|
expect(screen.getByPlaceholderText('请输入留言内容')).toBeInTheDocument();
|
||||||
@@ -56,7 +42,7 @@ describe('Textarea', () => {
|
|||||||
it('should handle user input', async () => {
|
it('should handle user input', async () => {
|
||||||
render(<Textarea />);
|
render(<Textarea />);
|
||||||
const textarea = screen.getByRole('textbox');
|
const textarea = screen.getByRole('textbox');
|
||||||
|
|
||||||
await userEvent.type(textarea, '这是一段测试留言');
|
await userEvent.type(textarea, '这是一段测试留言');
|
||||||
expect(textarea).toHaveValue('这是一段测试留言');
|
expect(textarea).toHaveValue('这是一段测试留言');
|
||||||
});
|
});
|
||||||
@@ -65,7 +51,7 @@ describe('Textarea', () => {
|
|||||||
const handleChange = jest.fn();
|
const handleChange = jest.fn();
|
||||||
render(<Textarea onChange={handleChange} />);
|
render(<Textarea onChange={handleChange} />);
|
||||||
const textarea = screen.getByRole('textbox');
|
const textarea = screen.getByRole('textbox');
|
||||||
|
|
||||||
await userEvent.type(textarea, 'a');
|
await userEvent.type(textarea, 'a');
|
||||||
expect(handleChange).toHaveBeenCalled();
|
expect(handleChange).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -74,7 +60,7 @@ describe('Textarea', () => {
|
|||||||
const handleBlur = jest.fn();
|
const handleBlur = jest.fn();
|
||||||
render(<Textarea onBlur={handleBlur} />);
|
render(<Textarea onBlur={handleBlur} />);
|
||||||
const textarea = screen.getByRole('textbox');
|
const textarea = screen.getByRole('textbox');
|
||||||
|
|
||||||
await userEvent.click(textarea);
|
await userEvent.click(textarea);
|
||||||
await userEvent.tab();
|
await userEvent.tab();
|
||||||
expect(handleBlur).toHaveBeenCalled();
|
expect(handleBlur).toHaveBeenCalled();
|
||||||
@@ -84,7 +70,7 @@ describe('Textarea', () => {
|
|||||||
const handleFocus = jest.fn();
|
const handleFocus = jest.fn();
|
||||||
render(<Textarea onFocus={handleFocus} />);
|
render(<Textarea onFocus={handleFocus} />);
|
||||||
const textarea = screen.getByRole('textbox');
|
const textarea = screen.getByRole('textbox');
|
||||||
|
|
||||||
await userEvent.click(textarea);
|
await userEvent.click(textarea);
|
||||||
expect(handleFocus).toHaveBeenCalled();
|
expect(handleFocus).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -92,7 +78,7 @@ describe('Textarea', () => {
|
|||||||
it('should handle multiline input', async () => {
|
it('should handle multiline input', async () => {
|
||||||
render(<Textarea />);
|
render(<Textarea />);
|
||||||
const textarea = screen.getByRole('textbox');
|
const textarea = screen.getByRole('textbox');
|
||||||
|
|
||||||
await userEvent.type(textarea, '第一行{enter}第二行');
|
await userEvent.type(textarea, '第一行{enter}第二行');
|
||||||
expect(textarea).toHaveValue('第一行\n第二行');
|
expect(textarea).toHaveValue('第一行\n第二行');
|
||||||
});
|
});
|
||||||
@@ -108,54 +94,17 @@ describe('Textarea', () => {
|
|||||||
it('should not accept input when disabled', async () => {
|
it('should not accept input when disabled', async () => {
|
||||||
render(<Textarea disabled />);
|
render(<Textarea disabled />);
|
||||||
const textarea = screen.getByRole('textbox');
|
const textarea = screen.getByRole('textbox');
|
||||||
|
|
||||||
expect(textarea).toBeDisabled();
|
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', () => {
|
describe('Custom Styling', () => {
|
||||||
it('should apply custom className', () => {
|
it('should apply custom className', () => {
|
||||||
render(<Textarea className="custom-class" />);
|
render(<Textarea className="custom-class" />);
|
||||||
const textarea = screen.getByRole('textbox');
|
const textarea = screen.getByRole('textbox');
|
||||||
expect(textarea).toHaveClass('custom-class');
|
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', () => {
|
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 * as React from 'react';
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
/**
|
||||||
label?: string
|
* 标准 shadcn/ui Textarea 组件
|
||||||
error?: string
|
* 遵循 shadcn/ui 规范:纯 textarea 元素 + data-slot + cn 工具
|
||||||
'data-testid'?: string
|
*
|
||||||
}
|
* 若需 label/error 属性,请使用 LabeledTextarea 组件
|
||||||
|
*/
|
||||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
|
||||||
({ className, label, error, id, ...props }, ref) => {
|
({ className, ...props }, ref) => {
|
||||||
const generatedId = React.useId()
|
|
||||||
const textareaId = id || generatedId
|
|
||||||
const errorId = `${textareaId}-error`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<textarea
|
||||||
{label && (
|
data-slot="textarea"
|
||||||
<label
|
ref={ref}
|
||||||
htmlFor={textareaId}
|
className={cn(
|
||||||
className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2"
|
'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',
|
||||||
{label}
|
'transition-all duration-fast outline-none',
|
||||||
{props.required && <span className="text-[var(--color-brand-primary)] ml-1">*</span>}
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
</label>
|
'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
|
{...props}
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
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 { 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';
|
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';
|
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', () => {
|
describe('Toast Component', () => {
|
||||||
const mockOnClose = jest.fn();
|
const mockOnClose = jest.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
jest.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it('should render toast with message', () => {
|
it('should render null (delegates to sonner Toaster)', () => {
|
||||||
render(<Toast message="Test message" onClose={mockOnClose} />);
|
const { container } = render(
|
||||||
expect(screen.getByText('Test message')).toBeInTheDocument();
|
<Toast message="Test message" onClose={mockOnClose} />
|
||||||
});
|
|
||||||
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId('custom-toast')).toBeInTheDocument();
|
// Toast 是 sonner 的声明式包装,组件本身不渲染 DOM
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Icons', () => {
|
describe('Sonner Integration', () => {
|
||||||
it('should render success icon for success type', () => {
|
it('should call toast.success for default (success) type', () => {
|
||||||
render(<Toast message="Success" type="success" onClose={mockOnClose} />);
|
render(<Toast message="Success" onClose={mockOnClose} />);
|
||||||
const toast = screen.getByRole('alert');
|
expect(mockedToast.success).toHaveBeenCalledWith(
|
||||||
expect(toast.querySelector('svg')).toBeInTheDocument();
|
'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} />);
|
render(<Toast message="Error" type="error" onClose={mockOnClose} />);
|
||||||
const toast = screen.getByRole('alert');
|
expect(mockedToast.error).toHaveBeenCalledWith(
|
||||||
expect(toast.querySelector('svg')).toBeInTheDocument();
|
'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} />);
|
render(<Toast message="Info" type="info" onClose={mockOnClose} />);
|
||||||
const toast = screen.getByRole('alert');
|
expect(mockedToast.info).toHaveBeenCalledWith(
|
||||||
expect(toast.querySelector('svg')).toBeInTheDocument();
|
'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', () => {
|
describe('Cleanup', () => {
|
||||||
it('should apply success background color', () => {
|
it('should call toast.dismiss on unmount', () => {
|
||||||
render(<Toast message="Success" type="success" onClose={mockOnClose} />);
|
const { unmount } = render(
|
||||||
const toast = screen.getByRole('alert');
|
<Toast message="Test" onClose={mockOnClose} />
|
||||||
expect(toast.className).toContain('bg-green-50');
|
);
|
||||||
|
unmount();
|
||||||
|
expect(mockedToast.dismiss).toHaveBeenCalledWith('success-id');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should apply error background color', () => {
|
it('should call toast.dismiss with correct id for error type', () => {
|
||||||
render(<Toast message="Error" type="error" onClose={mockOnClose} />);
|
const { unmount } = render(
|
||||||
const toast = screen.getByRole('alert');
|
<Toast message="Error" type="error" onClose={mockOnClose} />
|
||||||
expect(toast.className).toContain('bg-red-50');
|
);
|
||||||
|
unmount();
|
||||||
|
expect(mockedToast.dismiss).toHaveBeenCalledWith('error-id');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should apply info background color', () => {
|
describe('onClose Synchronization', () => {
|
||||||
render(<Toast message="Info" type="info" onClose={mockOnClose} />);
|
it('should call latest onClose when sonner dismisses', () => {
|
||||||
const toast = screen.getByRole('alert');
|
const handleClose1 = jest.fn();
|
||||||
expect(toast.className).toContain('bg-blue-50');
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { CheckCircle2, X, AlertCircle, Info } from 'lucide-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 {
|
interface ToastProps {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -11,58 +22,40 @@ interface ToastProps {
|
|||||||
'data-testid'?: string;
|
'data-testid'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toast({
|
export function Toast({
|
||||||
message,
|
message,
|
||||||
type = 'success',
|
type = 'success',
|
||||||
duration = 3000,
|
duration = 3000,
|
||||||
onClose,
|
onClose,
|
||||||
'data-testid': dataTestId
|
'data-testid': dataTestId,
|
||||||
}: ToastProps) {
|
}: ToastProps) {
|
||||||
const [isVisible, setIsVisible] = useState(true);
|
const onCloseRef = useRef(onClose);
|
||||||
|
|
||||||
|
// 同步最新的 onClose 到 ref(必须在 effect 中以避免 render 阶段更新 ref)
|
||||||
|
useEffect(() => {
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const sonnerType = type === 'error' ? 'error' : type === 'info' ? 'info' : 'success';
|
||||||
setIsVisible(false);
|
const sonnerDuration = duration === 0 ? Infinity : duration;
|
||||||
setTimeout(onClose, 300);
|
|
||||||
}, duration);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
const id = toast[sonnerType](message, {
|
||||||
}, [duration, onClose]);
|
duration: sonnerDuration,
|
||||||
|
onDismiss: () => onCloseRef.current(),
|
||||||
|
});
|
||||||
|
|
||||||
const icons = {
|
return () => {
|
||||||
success: <CheckCircle2 className="h-5 w-5 text-green-500" />,
|
toast.dismiss(id);
|
||||||
error: <AlertCircle className="h-5 w-5 text-red-500" />,
|
};
|
||||||
info: <Info className="h-5 w-5 text-blue-500" />
|
// 仅挂载时触发,message/type/duration 变化通过重新挂载实现
|
||||||
};
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const bgColors = {
|
// 兼容 data-testid(sonner 不直接支持,但保留属性供测试查询)
|
||||||
success: 'bg-[var(--color-toast-success-bg)] border-[var(--color-toast-success-border)]',
|
if (dataTestId && typeof document !== 'undefined') {
|
||||||
error: 'bg-[var(--color-toast-error-bg)] border-[var(--color-toast-error-border)]',
|
// sonner 渲染的 DOM 无法直接设置 data-testid,这里仅保留接口兼容
|
||||||
info: 'bg-[var(--color-toast-info-bg)] border-[var(--color-toast-info-border)]'
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return null;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-92
@@ -1,105 +1,117 @@
|
|||||||
'use client';
|
'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';
|
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;
|
children: React.ReactNode;
|
||||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
className?: string;
|
className?: string;
|
||||||
|
delayDuration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Tooltip({ content, children, side = 'top', className }: TooltipProps) {
|
/**
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
* 兼容旧 API 的 Tooltip:传入 content + children 即可
|
||||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
* 内部包裹 Provider + Trigger(asChild) + Content
|
||||||
const triggerRef = useRef<HTMLDivElement>(null);
|
*/
|
||||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
function LegacyTooltip({
|
||||||
|
content,
|
||||||
const showTooltip = () => {
|
children,
|
||||||
if (!triggerRef.current || !tooltipRef.current) return;
|
side = 'top',
|
||||||
|
className,
|
||||||
const triggerRect = triggerRef.current.getBoundingClientRect();
|
delayDuration = 300,
|
||||||
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
}: LegacyTooltipProps) {
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<TooltipProvider delayDuration={delayDuration}>
|
||||||
ref={triggerRef}
|
<Tooltip>
|
||||||
className="relative inline-flex"
|
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||||
onMouseEnter={showTooltip}
|
<TooltipContent side={side} className={className}>
|
||||||
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`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{content}
|
{content}
|
||||||
<div
|
</TooltipContent>
|
||||||
className={cn(
|
</Tooltip>
|
||||||
'absolute w-2 h-2 bg-[var(--color-primary)] rotate-45',
|
</TooltipProvider>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { LegacyTooltip };
|
||||||
|
|||||||
Reference in New Issue
Block a user