Files
novalon-website/src/components/ui/loading-state.tsx
T
张翔 e78df62cd1 feat(ui): 重构核心 UI 组件库,新增 shadcn/ui 组件
- 重构 Button、Card、Badge、Input、Textarea 等基础组件
- 新增 Accordion、Alert、Dialog、Dropdown、Form 等 shadcn/ui 组件
- 新增 AnimatedCounter、StatsShowcase、MetricCard 等数据展示组件
- 新增 ScrollReveal 滚动动画组件
- 重构 Toast 通知系统与 Tooltip 提示组件
- 更新设计令牌系统,对齐新品牌视觉
2026-07-07 06:52:38 +08:00

177 lines
4.2 KiB
TypeScript

'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { cn } from '@/lib/utils';
import {
Skeleton,
SkeletonHero,
SkeletonList,
SkeletonCard,
SkeletonForm,
} from './skeleton';
type LoadingVariant = 'hero' | 'list' | 'card' | 'form' | 'custom' | 'spinner';
interface LoadingStateProps {
isLoading: boolean;
children: React.ReactNode;
variant?: LoadingVariant;
listItems?: number;
formFields?: number;
className?: string;
delayMs?: number;
customSkeleton?: React.ReactNode;
}
export function LoadingState({
isLoading,
children,
variant = 'custom',
listItems = 3,
formFields = 4,
className,
delayMs = 150,
customSkeleton,
}: LoadingStateProps) {
const [showSkeleton, setShowSkeleton] = useState(false);
const prevIsLoading = useRef(isLoading);
useEffect(() => {
if (isLoading !== prevIsLoading.current) {
prevIsLoading.current = isLoading;
if (!isLoading) {
setShowSkeleton(false);
return;
}
const timer = setTimeout(() => setShowSkeleton(true), delayMs);
return () => clearTimeout(timer);
}
return undefined;
}, [isLoading, delayMs]);
if (isLoading && showSkeleton) {
return (
<div
className={cn('animate-in fade-in duration-300', className)}
role="status"
aria-label="内容加载中"
aria-busy="true"
>
{variant === 'hero' && <SkeletonHero />}
{variant === 'list' && <SkeletonList items={listItems} />}
{variant === 'card' && (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<SkeletonCard key={i} />
))}
</div>
)}
{variant === 'form' && <SkeletonForm fields={formFields} />}
{variant === 'custom' && (customSkeleton || <Skeleton className="h-40 w-full rounded-lg" />)}
{variant === 'spinner' && (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" />
</div>
)}
</div>
);
}
return <>{children}</>;
}
interface SpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function Spinner({ size = 'md', className }: SpinnerProps) {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12',
};
return (
<svg
className={cn(
'animate-spin text-[var(--color-brand)]',
sizeClasses[size],
className
)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
role="status"
aria-label="加载中"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}
interface PageLoaderProps {
isLoading: boolean;
message?: string;
}
export function PageLoader({ isLoading, message = '正在加载...' }: PageLoaderProps) {
if (!isLoading) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--color-bg-primary)]/80 backdrop-blur-sm"
role="alertdialog"
aria-modal="true"
aria-label={message}
>
<div className="text-center space-y-4">
<Spinner size="lg" className="mx-auto" />
<p className="text-sm text-[var(--color-text-muted)] animate-pulse">
{message}
</p>
</div>
</div>
);
}
export function useLoadingState(initialState = false, delayMs = 150) {
const [isLoading, setIsLoading] = useState(initialState);
const [shouldShow, setShouldShow] = useState(false);
useEffect(() => {
if (!isLoading) {
setShouldShow(false);
return;
}
const timer = setTimeout(() => setShouldShow(true), delayMs);
return () => clearTimeout(timer);
}, [isLoading, delayMs]);
const startLoading = useCallback(() => setIsLoading(true), []);
const stopLoading = useCallback(() => setIsLoading(false), []);
return {
isLoading,
shouldShow,
startLoading,
stopLoading,
setLoading: setIsLoading,
};
}