WellNuo/web/components/ui/Skeleton.tsx
Sergei 71f194cc4d Add Loading & Error UI components for web application
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>
2026-01-31 18:26:28 -08:00

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>
);
}