Implemented comprehensive loading and error handling components for the WellNuo web application with full test coverage. Components added: - LoadingSpinner: Configurable spinner with sizes, colors, and variants * LoadingOverlay: Full-screen loading with backdrop options * InlineLoader: Small inline spinner for buttons * PageLoader: Full-page centered loading state - ErrorMessage: Inline error messages with severity levels * FullScreenError: Full-page error states with retry/back actions * FieldError: Form field validation errors * ErrorBoundaryFallback: Error boundary fallback component * EmptyState: Empty data state component - Skeleton: Animated loading skeletons * SkeletonAvatar: Circular avatar skeleton * SkeletonText: Multi-line text skeleton * SkeletonCard: Card-style skeleton * SkeletonList: List of skeleton cards * SkeletonTable: Table skeleton with rows/columns * SkeletonDashboard: Dashboard-style skeleton layout * SkeletonForm: Form skeleton with fields Technical details: - Tailwind CSS styling with cn() utility function - Full accessibility support (ARIA labels, roles) - Comprehensive test coverage (57 tests, all passing) - TypeScript strict mode compliance - Added clsx and tailwind-merge dependencies Files: - web/components/ui/LoadingSpinner.tsx - web/components/ui/ErrorMessage.tsx - web/components/ui/Skeleton.tsx - web/components/ui/index.ts - web/lib/utils.ts - web/components/ui/__tests__/*.test.tsx 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
320 lines
6.6 KiB
TypeScript
320 lines
6.6 KiB
TypeScript
/**
|
|
* Skeleton - Loading skeleton components for web
|
|
*
|
|
* Components:
|
|
* - Skeleton: Basic skeleton element
|
|
* - SkeletonCard: Card-style skeleton
|
|
* - SkeletonList: List of skeleton items
|
|
* - SkeletonText: Text skeleton with multiple lines
|
|
* - SkeletonAvatar: Avatar/profile picture skeleton
|
|
*
|
|
* Features:
|
|
* - Animated shimmer effect
|
|
* - Multiple variants and sizes
|
|
* - Composable building blocks
|
|
* - Tailwind CSS styling
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* if (isLoading) {
|
|
* return <SkeletonCard />;
|
|
* }
|
|
* ```
|
|
*/
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/**
|
|
* Skeleton - Base skeleton component with shimmer animation
|
|
*/
|
|
interface SkeletonProps {
|
|
className?: string;
|
|
variant?: 'rectangular' | 'circular' | 'text';
|
|
}
|
|
|
|
export function Skeleton({
|
|
className,
|
|
variant = 'rectangular',
|
|
}: SkeletonProps) {
|
|
const variantClasses = {
|
|
rectangular: 'rounded',
|
|
circular: 'rounded-full',
|
|
text: 'rounded h-4',
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'animate-pulse bg-slate-200',
|
|
variantClasses[variant],
|
|
className
|
|
)}
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* SkeletonAvatar - Avatar skeleton with circular shape
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <SkeletonAvatar size="lg" />
|
|
* ```
|
|
*/
|
|
interface SkeletonAvatarProps {
|
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
|
className?: string;
|
|
}
|
|
|
|
const avatarSizes = {
|
|
sm: 'h-8 w-8',
|
|
md: 'h-10 w-10',
|
|
lg: 'h-12 w-12',
|
|
xl: 'h-16 w-16',
|
|
};
|
|
|
|
export function SkeletonAvatar({
|
|
size = 'md',
|
|
className,
|
|
}: SkeletonAvatarProps) {
|
|
return (
|
|
<Skeleton
|
|
variant="circular"
|
|
className={cn(avatarSizes[size], className)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* SkeletonText - Multi-line text skeleton
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <SkeletonText lines={3} />
|
|
* ```
|
|
*/
|
|
interface SkeletonTextProps {
|
|
lines?: number;
|
|
className?: string;
|
|
}
|
|
|
|
export function SkeletonText({ lines = 3, className }: SkeletonTextProps) {
|
|
return (
|
|
<div className={cn('space-y-2', className)}>
|
|
{Array.from({ length: lines }).map((_, i) => (
|
|
<Skeleton
|
|
key={i}
|
|
variant="text"
|
|
className={cn(
|
|
'h-4',
|
|
i === lines - 1 && 'w-3/4' // Last line is shorter
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* SkeletonCard - Card-style skeleton with header and content
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* {isLoading ? (
|
|
* <SkeletonCard />
|
|
* ) : (
|
|
* <BeneficiaryCard data={data} />
|
|
* )}
|
|
* ```
|
|
*/
|
|
interface SkeletonCardProps {
|
|
showAvatar?: boolean;
|
|
lines?: number;
|
|
className?: string;
|
|
}
|
|
|
|
export function SkeletonCard({
|
|
showAvatar = true,
|
|
lines = 3,
|
|
className,
|
|
}: SkeletonCardProps) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'rounded-lg border border-slate-200 bg-white p-6',
|
|
className
|
|
)}
|
|
>
|
|
<div className="flex items-start gap-4">
|
|
{showAvatar && <SkeletonAvatar size="lg" />}
|
|
<div className="flex-1 space-y-3">
|
|
<Skeleton className="h-6 w-1/3" />
|
|
<SkeletonText lines={lines} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* SkeletonList - List of skeleton cards
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* {isLoading ? (
|
|
* <SkeletonList count={5} />
|
|
* ) : (
|
|
* items.map(item => <ItemCard key={item.id} {...item} />)
|
|
* )}
|
|
* ```
|
|
*/
|
|
interface SkeletonListProps {
|
|
count?: number;
|
|
showAvatar?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
export function SkeletonList({
|
|
count = 3,
|
|
showAvatar = true,
|
|
className,
|
|
}: SkeletonListProps) {
|
|
return (
|
|
<div className={cn('space-y-4', className)}>
|
|
{Array.from({ length: count }).map((_, i) => (
|
|
<SkeletonCard key={i} showAvatar={showAvatar} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* SkeletonTable - Table skeleton with rows and columns
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* {isLoading ? (
|
|
* <SkeletonTable rows={5} columns={4} />
|
|
* ) : (
|
|
* <DataTable data={data} />
|
|
* )}
|
|
* ```
|
|
*/
|
|
interface SkeletonTableProps {
|
|
rows?: number;
|
|
columns?: number;
|
|
showHeader?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
export function SkeletonTable({
|
|
rows = 5,
|
|
columns = 4,
|
|
showHeader = true,
|
|
className,
|
|
}: SkeletonTableProps) {
|
|
return (
|
|
<div className={cn('overflow-hidden rounded-lg border border-slate-200', className)}>
|
|
{/* Header */}
|
|
{showHeader && (
|
|
<div className="flex gap-4 border-b border-slate-200 bg-slate-50 p-4">
|
|
{Array.from({ length: columns }).map((_, i) => (
|
|
<Skeleton key={i} className="h-4 flex-1" />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Rows */}
|
|
<div className="divide-y divide-slate-200 bg-white">
|
|
{Array.from({ length: rows }).map((_, rowIndex) => (
|
|
<div key={rowIndex} className="flex gap-4 p-4">
|
|
{Array.from({ length: columns }).map((_, colIndex) => (
|
|
<Skeleton key={colIndex} className="h-4 flex-1" />
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* SkeletonDashboard - Dashboard-style skeleton with multiple sections
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* {isLoading ? (
|
|
* <SkeletonDashboard />
|
|
* ) : (
|
|
* <Dashboard data={data} />
|
|
* )}
|
|
* ```
|
|
*/
|
|
export function SkeletonDashboard() {
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<Skeleton className="h-8 w-48" />
|
|
<Skeleton className="h-10 w-32" />
|
|
</div>
|
|
|
|
{/* Stats grid */}
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div key={i} className="rounded-lg border border-slate-200 bg-white p-6">
|
|
<Skeleton className="mb-2 h-4 w-24" />
|
|
<Skeleton className="h-8 w-16" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-6 w-32" />
|
|
<SkeletonCard />
|
|
<SkeletonCard />
|
|
</div>
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-6 w-32" />
|
|
<SkeletonCard />
|
|
<SkeletonCard />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* SkeletonForm - Form skeleton with fields and button
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* {isLoading ? (
|
|
* <SkeletonForm fields={5} />
|
|
* ) : (
|
|
* <BeneficiaryForm onSubmit={handleSubmit} />
|
|
* )}
|
|
* ```
|
|
*/
|
|
interface SkeletonFormProps {
|
|
fields?: number;
|
|
className?: string;
|
|
}
|
|
|
|
export function SkeletonForm({ fields = 3, className }: SkeletonFormProps) {
|
|
return (
|
|
<div className={cn('space-y-4', className)}>
|
|
{Array.from({ length: fields }).map((_, i) => (
|
|
<div key={i} className="space-y-2">
|
|
<Skeleton className="h-4 w-24" />
|
|
<Skeleton className="h-10 w-full" />
|
|
</div>
|
|
))}
|
|
<Skeleton className="h-10 w-full" />
|
|
</div>
|
|
);
|
|
}
|