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>
This commit is contained in:
parent
0962b5e35b
commit
71f194cc4d
399
web/components/ui/ErrorMessage.tsx
Normal file
399
web/components/ui/ErrorMessage.tsx
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
/**
|
||||||
|
* ErrorMessage - Error display components for web
|
||||||
|
*
|
||||||
|
* Components:
|
||||||
|
* - ErrorMessage: Inline error with optional retry
|
||||||
|
* - FullScreenError: Full page error state
|
||||||
|
* - ErrorToast: Dismissible error notification
|
||||||
|
* - FieldError: Form field validation error
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Multiple severity levels
|
||||||
|
* - Retry and dismiss actions
|
||||||
|
* - Accessible ARIA labels
|
||||||
|
* - Tailwind CSS styling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Error severity types
|
||||||
|
export type ErrorSeverity = 'error' | 'warning' | 'info';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ErrorMessage - Inline error message with icon and optional actions
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <ErrorMessage
|
||||||
|
* message="Failed to load data"
|
||||||
|
* onRetry={() => refetch()}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface ErrorMessageProps {
|
||||||
|
message: string;
|
||||||
|
severity?: ErrorSeverity;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const severityStyles = {
|
||||||
|
error: {
|
||||||
|
container: 'bg-red-50 border-red-200 text-red-800',
|
||||||
|
icon: 'text-red-500',
|
||||||
|
button: 'text-red-600 hover:text-red-700 hover:bg-red-100',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
container: 'bg-yellow-50 border-yellow-200 text-yellow-800',
|
||||||
|
icon: 'text-yellow-500',
|
||||||
|
button: 'text-yellow-600 hover:text-yellow-700 hover:bg-yellow-100',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
container: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||||
|
icon: 'text-blue-500',
|
||||||
|
button: 'text-blue-600 hover:text-blue-700 hover:bg-blue-100',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ErrorMessage({
|
||||||
|
message,
|
||||||
|
severity = 'error',
|
||||||
|
onRetry,
|
||||||
|
onDismiss,
|
||||||
|
className,
|
||||||
|
}: ErrorMessageProps) {
|
||||||
|
const styles = severityStyles[severity];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 rounded-lg border p-4',
|
||||||
|
styles.container,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<svg
|
||||||
|
className={cn('h-5 w-5 flex-shrink-0', styles.icon)}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<div className="flex-1 text-sm">
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors',
|
||||||
|
styles.button
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDismiss && (
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className={cn(
|
||||||
|
'rounded p-1 transition-colors',
|
||||||
|
styles.button
|
||||||
|
)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FullScreenError - Full page error state with icon and actions
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* if (error) {
|
||||||
|
* return (
|
||||||
|
* <FullScreenError
|
||||||
|
* title="Failed to Load"
|
||||||
|
* message={error.message}
|
||||||
|
* onRetry={() => refetch()}
|
||||||
|
* />
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface FullScreenErrorProps {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
icon?: 'error' | 'offline' | 'notFound';
|
||||||
|
onRetry?: () => void;
|
||||||
|
onBack?: () => void;
|
||||||
|
retryLabel?: string;
|
||||||
|
backLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconPaths = {
|
||||||
|
error: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
|
||||||
|
offline: 'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z',
|
||||||
|
notFound: 'M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 12h.01M12 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FullScreenError({
|
||||||
|
title = 'Something went wrong',
|
||||||
|
message,
|
||||||
|
icon = 'error',
|
||||||
|
onRetry,
|
||||||
|
onBack,
|
||||||
|
retryLabel = 'Try Again',
|
||||||
|
backLabel = 'Go Back',
|
||||||
|
}: FullScreenErrorProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center p-8 text-center">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-red-100">
|
||||||
|
<svg
|
||||||
|
className="h-10 w-10 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d={iconPaths[icon]}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h2 className="mb-2 text-2xl font-semibold text-slate-900">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<p className="mb-8 max-w-md text-slate-600">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{retryLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="rounded-lg border border-slate-300 bg-white px-6 py-3 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
{backLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FieldError - Inline form field validation error
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <input type="email" aria-invalid={!!error} />
|
||||||
|
* {error && <FieldError message={error} />}
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface FieldErrorProps {
|
||||||
|
message: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldError({ message, className }: FieldErrorProps) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={cn('mt-1 flex items-center gap-1 text-sm text-red-600', className)}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{message}</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ErrorBoundary fallback component
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||||
|
* <App />
|
||||||
|
* </ErrorBoundary>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface ErrorBoundaryFallbackProps {
|
||||||
|
error?: Error;
|
||||||
|
resetError?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBoundaryFallback({
|
||||||
|
error,
|
||||||
|
resetError,
|
||||||
|
}: ErrorBoundaryFallbackProps) {
|
||||||
|
return (
|
||||||
|
<FullScreenError
|
||||||
|
title="Application Error"
|
||||||
|
message={
|
||||||
|
error?.message ||
|
||||||
|
'An unexpected error occurred. Please refresh the page.'
|
||||||
|
}
|
||||||
|
onRetry={resetError}
|
||||||
|
retryLabel="Reload Page"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EmptyState - Component for empty data states (not strictly an error)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* {items.length === 0 && (
|
||||||
|
* <EmptyState
|
||||||
|
* title="No items found"
|
||||||
|
* message="Get started by adding your first item"
|
||||||
|
* actionLabel="Add Item"
|
||||||
|
* onAction={() => setShowModal(true)}
|
||||||
|
* />
|
||||||
|
* )}
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface EmptyStateProps {
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
icon,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
className,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center p-8 text-center',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
{icon && (
|
||||||
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-100">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
{message && (
|
||||||
|
<p className="mb-6 max-w-sm text-sm text-slate-600">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
{actionLabel && onAction && (
|
||||||
|
<button
|
||||||
|
onClick={onAction}
|
||||||
|
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
web/components/ui/LoadingSpinner.tsx
Normal file
180
web/components/ui/LoadingSpinner.tsx
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* LoadingSpinner - Animated loading spinner for web
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Multiple sizes (sm, md, lg, xl)
|
||||||
|
* - Optional loading message
|
||||||
|
* - Full-screen mode option
|
||||||
|
* - Tailwind CSS styling
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <LoadingSpinner size="md" />
|
||||||
|
* <LoadingSpinner size="lg" message="Loading data..." />
|
||||||
|
* <LoadingSpinner fullScreen message="Please wait..." />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
color?: 'primary' | 'white' | 'gray';
|
||||||
|
message?: string;
|
||||||
|
fullScreen?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size mapping for spinner
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-4 w-4 border-2',
|
||||||
|
md: 'h-8 w-8 border-2',
|
||||||
|
lg: 'h-12 w-12 border-3',
|
||||||
|
xl: 'h-16 w-16 border-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Color mapping for spinner
|
||||||
|
const colorClasses = {
|
||||||
|
primary: 'border-blue-600 border-t-transparent',
|
||||||
|
white: 'border-white border-t-transparent',
|
||||||
|
gray: 'border-gray-400 border-t-transparent',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Text size mapping
|
||||||
|
const textSizeClasses = {
|
||||||
|
sm: 'text-sm',
|
||||||
|
md: 'text-base',
|
||||||
|
lg: 'text-lg',
|
||||||
|
xl: 'text-xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoadingSpinner({
|
||||||
|
size = 'md',
|
||||||
|
color = 'primary',
|
||||||
|
message,
|
||||||
|
fullScreen = false,
|
||||||
|
className,
|
||||||
|
}: LoadingSpinnerProps) {
|
||||||
|
const spinner = (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'animate-spin rounded-full',
|
||||||
|
sizeClasses[size],
|
||||||
|
colorClasses[color],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="status"
|
||||||
|
aria-label={message || 'Loading'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fullScreen) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-white">
|
||||||
|
{spinner}
|
||||||
|
{message && (
|
||||||
|
<p className={cn('mt-4 text-slate-600', textSizeClasses[size])}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 p-6">
|
||||||
|
{spinner}
|
||||||
|
<p className={cn('text-slate-600', textSizeClasses[size])}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return spinner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LoadingOverlay - Full-screen loading overlay with backdrop
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* {isLoading && (
|
||||||
|
* <LoadingOverlay message="Saving changes..." />
|
||||||
|
* )}
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface LoadingOverlayProps {
|
||||||
|
message?: string;
|
||||||
|
backdrop?: 'light' | 'dark' | 'blur';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingOverlay({
|
||||||
|
message = 'Loading...',
|
||||||
|
backdrop = 'blur'
|
||||||
|
}: LoadingOverlayProps) {
|
||||||
|
const backdropClasses = {
|
||||||
|
light: 'bg-white/80',
|
||||||
|
dark: 'bg-slate-900/80',
|
||||||
|
blur: 'bg-white/80 backdrop-blur-sm',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
'fixed inset-0 z-50 flex flex-col items-center justify-center',
|
||||||
|
backdropClasses[backdrop]
|
||||||
|
)}>
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
<p className="mt-4 text-lg font-medium text-slate-700">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InlineLoader - Small inline loading indicator
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <button disabled={isLoading}>
|
||||||
|
* {isLoading ? <InlineLoader /> : 'Save'}
|
||||||
|
* </button>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface InlineLoaderProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineLoader({ className }: InlineLoaderProps) {
|
||||||
|
return (
|
||||||
|
<LoadingSpinner
|
||||||
|
size="sm"
|
||||||
|
className={cn('inline-block', className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PageLoader - Full page loading state with centered spinner
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* if (isLoading) return <PageLoader message="Loading dashboard..." />;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface PageLoaderProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageLoader({ message }: PageLoaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
{message && (
|
||||||
|
<p className="mt-4 text-lg text-slate-600">{message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
319
web/components/ui/Skeleton.tsx
Normal file
319
web/components/ui/Skeleton.tsx
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
web/components/ui/__tests__/ErrorMessage.test.tsx
Normal file
206
web/components/ui/__tests__/ErrorMessage.test.tsx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import {
|
||||||
|
ErrorMessage,
|
||||||
|
FullScreenError,
|
||||||
|
FieldError,
|
||||||
|
EmptyState,
|
||||||
|
} from '../ErrorMessage';
|
||||||
|
|
||||||
|
describe('ErrorMessage Component', () => {
|
||||||
|
it('renders error message', () => {
|
||||||
|
render(<ErrorMessage message="Something went wrong" />);
|
||||||
|
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with different severity levels', () => {
|
||||||
|
const { rerender, container } = render(
|
||||||
|
<ErrorMessage message="Error" severity="error" />
|
||||||
|
);
|
||||||
|
let alert = container.querySelector('[role="alert"]');
|
||||||
|
expect(alert).toHaveClass('bg-red-50', 'border-red-200');
|
||||||
|
|
||||||
|
rerender(<ErrorMessage message="Warning" severity="warning" />);
|
||||||
|
alert = container.querySelector('[role="alert"]');
|
||||||
|
expect(alert).toHaveClass('bg-yellow-50', 'border-yellow-200');
|
||||||
|
|
||||||
|
rerender(<ErrorMessage message="Info" severity="info" />);
|
||||||
|
alert = container.querySelector('[role="alert"]');
|
||||||
|
expect(alert).toHaveClass('bg-blue-50', 'border-blue-200');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onRetry when retry button is clicked', () => {
|
||||||
|
const handleRetry = jest.fn();
|
||||||
|
render(<ErrorMessage message="Failed to load" onRetry={handleRetry} />);
|
||||||
|
|
||||||
|
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||||
|
fireEvent.click(retryButton);
|
||||||
|
|
||||||
|
expect(handleRetry).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onDismiss when dismiss button is clicked', () => {
|
||||||
|
const handleDismiss = jest.fn();
|
||||||
|
render(<ErrorMessage message="Error" onDismiss={handleDismiss} />);
|
||||||
|
|
||||||
|
const dismissButton = screen.getByLabelText('Dismiss');
|
||||||
|
fireEvent.click(dismissButton);
|
||||||
|
|
||||||
|
expect(handleDismiss).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders without action buttons', () => {
|
||||||
|
render(<ErrorMessage message="Error" />);
|
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has proper ARIA attributes', () => {
|
||||||
|
render(<ErrorMessage message="Error occurred" />);
|
||||||
|
const alert = screen.getByRole('alert');
|
||||||
|
expect(alert).toHaveAttribute('aria-live', 'polite');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FullScreenError Component', () => {
|
||||||
|
it('renders full screen error with title and message', () => {
|
||||||
|
render(
|
||||||
|
<FullScreenError
|
||||||
|
title="Page Not Found"
|
||||||
|
message="The page you are looking for does not exist."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Page Not Found')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('The page you are looking for does not exist.')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default title when not provided', () => {
|
||||||
|
render(<FullScreenError message="An error occurred" />);
|
||||||
|
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onRetry when retry button is clicked', () => {
|
||||||
|
const handleRetry = jest.fn();
|
||||||
|
render(
|
||||||
|
<FullScreenError
|
||||||
|
message="Failed to load"
|
||||||
|
onRetry={handleRetry}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const retryButton = screen.getByRole('button', { name: /try again/i });
|
||||||
|
fireEvent.click(retryButton);
|
||||||
|
|
||||||
|
expect(handleRetry).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onBack when back button is clicked', () => {
|
||||||
|
const handleBack = jest.fn();
|
||||||
|
render(
|
||||||
|
<FullScreenError
|
||||||
|
message="Error"
|
||||||
|
onBack={handleBack}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const backButton = screen.getByRole('button', { name: /go back/i });
|
||||||
|
fireEvent.click(backButton);
|
||||||
|
|
||||||
|
expect(handleBack).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with custom button labels', () => {
|
||||||
|
render(
|
||||||
|
<FullScreenError
|
||||||
|
message="Error"
|
||||||
|
onRetry={() => {}}
|
||||||
|
onBack={() => {}}
|
||||||
|
retryLabel="Reload"
|
||||||
|
backLabel="Return"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /reload/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /return/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders different icon types', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<FullScreenError message="Error" icon="offline" />
|
||||||
|
);
|
||||||
|
// Check that SVG is rendered
|
||||||
|
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FieldError Component', () => {
|
||||||
|
it('renders field error message', () => {
|
||||||
|
render(<FieldError message="Email is required" />);
|
||||||
|
expect(screen.getByText('Email is required')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has proper ARIA role', () => {
|
||||||
|
render(<FieldError message="Invalid input" />);
|
||||||
|
const alert = screen.getByRole('alert');
|
||||||
|
expect(alert).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with icon', () => {
|
||||||
|
const { container } = render(<FieldError message="Error" />);
|
||||||
|
const svg = container.querySelector('svg');
|
||||||
|
expect(svg).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
render(<FieldError message="Error" className="mb-4" />);
|
||||||
|
const alert = screen.getByRole('alert');
|
||||||
|
expect(alert).toHaveClass('mb-4');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EmptyState Component', () => {
|
||||||
|
it('renders empty state with title', () => {
|
||||||
|
render(<EmptyState title="No items found" />);
|
||||||
|
expect(screen.getByText('No items found')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with message', () => {
|
||||||
|
render(
|
||||||
|
<EmptyState
|
||||||
|
title="No data"
|
||||||
|
message="Get started by adding your first item"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText('Get started by adding your first item')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders action button when provided', () => {
|
||||||
|
const handleAction = jest.fn();
|
||||||
|
render(
|
||||||
|
<EmptyState
|
||||||
|
title="Empty"
|
||||||
|
actionLabel="Add Item"
|
||||||
|
onAction={handleAction}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /add item/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(handleAction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders custom icon', () => {
|
||||||
|
const icon = <div data-testid="custom-icon">Icon</div>;
|
||||||
|
render(<EmptyState title="Empty" icon={icon} />);
|
||||||
|
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render action button when not provided', () => {
|
||||||
|
render(<EmptyState title="Empty" />);
|
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
114
web/components/ui/__tests__/LoadingSpinner.test.tsx
Normal file
114
web/components/ui/__tests__/LoadingSpinner.test.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import {
|
||||||
|
LoadingSpinner,
|
||||||
|
LoadingOverlay,
|
||||||
|
InlineLoader,
|
||||||
|
PageLoader,
|
||||||
|
} from '../LoadingSpinner';
|
||||||
|
|
||||||
|
describe('LoadingSpinner Component', () => {
|
||||||
|
it('renders basic spinner', () => {
|
||||||
|
render(<LoadingSpinner />);
|
||||||
|
const spinner = screen.getByRole('status');
|
||||||
|
expect(spinner).toBeInTheDocument();
|
||||||
|
expect(spinner).toHaveAttribute('aria-label', 'Loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with custom message', () => {
|
||||||
|
render(<LoadingSpinner message="Loading data..." />);
|
||||||
|
expect(screen.getByText('Loading data...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders different sizes', () => {
|
||||||
|
const { rerender } = render(<LoadingSpinner size="sm" />);
|
||||||
|
let spinner = screen.getByRole('status');
|
||||||
|
expect(spinner).toHaveClass('h-4', 'w-4');
|
||||||
|
|
||||||
|
rerender(<LoadingSpinner size="lg" />);
|
||||||
|
spinner = screen.getByRole('status');
|
||||||
|
expect(spinner).toHaveClass('h-12', 'w-12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders in full screen mode', () => {
|
||||||
|
render(<LoadingSpinner fullScreen message="Please wait..." />);
|
||||||
|
const container = screen.getByRole('status').parentElement;
|
||||||
|
expect(container).toHaveClass('fixed', 'inset-0', 'z-50');
|
||||||
|
expect(screen.getByText('Please wait...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
render(<LoadingSpinner className="custom-class" />);
|
||||||
|
const spinner = screen.getByRole('status');
|
||||||
|
expect(spinner).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with different colors', () => {
|
||||||
|
const { rerender } = render(<LoadingSpinner color="primary" />);
|
||||||
|
let spinner = screen.getByRole('status');
|
||||||
|
expect(spinner).toHaveClass('border-blue-600');
|
||||||
|
|
||||||
|
rerender(<LoadingSpinner color="white" />);
|
||||||
|
spinner = screen.getByRole('status');
|
||||||
|
expect(spinner).toHaveClass('border-white');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LoadingOverlay Component', () => {
|
||||||
|
it('renders overlay with message', () => {
|
||||||
|
render(<LoadingOverlay message="Saving changes..." />);
|
||||||
|
expect(screen.getByText('Saving changes...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with different backdrop styles', () => {
|
||||||
|
const { rerender, container } = render(<LoadingOverlay backdrop="light" />);
|
||||||
|
let overlay = container.querySelector('.fixed');
|
||||||
|
expect(overlay).toHaveClass('bg-white/80');
|
||||||
|
|
||||||
|
rerender(<LoadingOverlay backdrop="dark" />);
|
||||||
|
overlay = container.querySelector('.fixed');
|
||||||
|
expect(overlay).toHaveClass('bg-slate-900/80');
|
||||||
|
|
||||||
|
rerender(<LoadingOverlay backdrop="blur" />);
|
||||||
|
overlay = container.querySelector('.fixed');
|
||||||
|
expect(overlay).toHaveClass('backdrop-blur-sm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders as fixed overlay', () => {
|
||||||
|
const { container } = render(<LoadingOverlay />);
|
||||||
|
const overlay = container.querySelector('.fixed');
|
||||||
|
expect(overlay).toHaveClass('inset-0', 'z-50');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('InlineLoader Component', () => {
|
||||||
|
it('renders inline spinner', () => {
|
||||||
|
render(<InlineLoader />);
|
||||||
|
const spinner = screen.getByRole('status');
|
||||||
|
expect(spinner).toHaveClass('inline-block');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
render(<InlineLoader className="ml-2" />);
|
||||||
|
const spinner = screen.getByRole('status');
|
||||||
|
expect(spinner).toHaveClass('ml-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PageLoader Component', () => {
|
||||||
|
it('renders page loader', () => {
|
||||||
|
render(<PageLoader />);
|
||||||
|
const spinner = screen.getByRole('status');
|
||||||
|
expect(spinner).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with message', () => {
|
||||||
|
render(<PageLoader message="Loading dashboard..." />);
|
||||||
|
expect(screen.getByText('Loading dashboard...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has minimum height', () => {
|
||||||
|
const { container } = render(<PageLoader />);
|
||||||
|
const wrapper = container.firstChild as HTMLElement;
|
||||||
|
expect(wrapper).toHaveClass('min-h-[400px]');
|
||||||
|
});
|
||||||
|
});
|
||||||
178
web/components/ui/__tests__/Skeleton.test.tsx
Normal file
178
web/components/ui/__tests__/Skeleton.test.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import {
|
||||||
|
Skeleton,
|
||||||
|
SkeletonAvatar,
|
||||||
|
SkeletonText,
|
||||||
|
SkeletonCard,
|
||||||
|
SkeletonList,
|
||||||
|
SkeletonTable,
|
||||||
|
SkeletonForm,
|
||||||
|
} from '../Skeleton';
|
||||||
|
|
||||||
|
describe('Skeleton Component', () => {
|
||||||
|
it('renders basic skeleton', () => {
|
||||||
|
const { container } = render(<Skeleton />);
|
||||||
|
const skeleton = container.firstChild;
|
||||||
|
expect(skeleton).toHaveClass('animate-pulse', 'bg-slate-200');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with different variants', () => {
|
||||||
|
const { rerender, container } = render(<Skeleton variant="rectangular" />);
|
||||||
|
let skeleton = container.firstChild;
|
||||||
|
expect(skeleton).toHaveClass('rounded');
|
||||||
|
|
||||||
|
rerender(<Skeleton variant="circular" />);
|
||||||
|
skeleton = container.firstChild;
|
||||||
|
expect(skeleton).toHaveClass('rounded-full');
|
||||||
|
|
||||||
|
rerender(<Skeleton variant="text" />);
|
||||||
|
skeleton = container.firstChild;
|
||||||
|
expect(skeleton).toHaveClass('h-4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom className', () => {
|
||||||
|
const { container } = render(<Skeleton className="w-full h-20" />);
|
||||||
|
const skeleton = container.firstChild;
|
||||||
|
expect(skeleton).toHaveClass('w-full', 'h-20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has aria-hidden attribute', () => {
|
||||||
|
const { container } = render(<Skeleton />);
|
||||||
|
const skeleton = container.firstChild;
|
||||||
|
expect(skeleton).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SkeletonAvatar Component', () => {
|
||||||
|
it('renders circular skeleton', () => {
|
||||||
|
const { container } = render(<SkeletonAvatar />);
|
||||||
|
const avatar = container.firstChild;
|
||||||
|
expect(avatar).toHaveClass('rounded-full');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with different sizes', () => {
|
||||||
|
const { rerender, container } = render(<SkeletonAvatar size="sm" />);
|
||||||
|
let avatar = container.firstChild;
|
||||||
|
expect(avatar).toHaveClass('h-8', 'w-8');
|
||||||
|
|
||||||
|
rerender(<SkeletonAvatar size="lg" />);
|
||||||
|
avatar = container.firstChild;
|
||||||
|
expect(avatar).toHaveClass('h-12', 'w-12');
|
||||||
|
|
||||||
|
rerender(<SkeletonAvatar size="xl" />);
|
||||||
|
avatar = container.firstChild;
|
||||||
|
expect(avatar).toHaveClass('h-16', 'w-16');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SkeletonText Component', () => {
|
||||||
|
it('renders default number of lines', () => {
|
||||||
|
const { container } = render(<SkeletonText />);
|
||||||
|
const lines = container.querySelectorAll('.h-4');
|
||||||
|
expect(lines).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders custom number of lines', () => {
|
||||||
|
const { container } = render(<SkeletonText lines={5} />);
|
||||||
|
const lines = container.querySelectorAll('.h-4');
|
||||||
|
expect(lines).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes last line shorter', () => {
|
||||||
|
const { container } = render(<SkeletonText lines={2} />);
|
||||||
|
const lines = container.querySelectorAll('.h-4');
|
||||||
|
expect(lines[1]).toHaveClass('w-3/4');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SkeletonCard Component', () => {
|
||||||
|
it('renders card with avatar by default', () => {
|
||||||
|
const { container } = render(<SkeletonCard />);
|
||||||
|
const avatar = container.querySelector('.rounded-full');
|
||||||
|
expect(avatar).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders without avatar when specified', () => {
|
||||||
|
const { container } = render(<SkeletonCard showAvatar={false} />);
|
||||||
|
const avatar = container.querySelector('.rounded-full');
|
||||||
|
expect(avatar).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders custom number of text lines', () => {
|
||||||
|
const { container } = render(<SkeletonCard lines={5} />);
|
||||||
|
const textContainer = container.querySelector('.space-y-2');
|
||||||
|
const lines = textContainer?.querySelectorAll('.h-4');
|
||||||
|
expect(lines).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has card styling', () => {
|
||||||
|
const { container } = render(<SkeletonCard />);
|
||||||
|
const card = container.firstChild;
|
||||||
|
expect(card).toHaveClass('rounded-lg', 'border', 'bg-white', 'p-6');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SkeletonList Component', () => {
|
||||||
|
it('renders default number of cards', () => {
|
||||||
|
const { container } = render(<SkeletonList />);
|
||||||
|
const cards = container.querySelectorAll('.rounded-lg.border');
|
||||||
|
expect(cards).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders custom number of cards', () => {
|
||||||
|
const { container } = render(<SkeletonList count={5} />);
|
||||||
|
const cards = container.querySelectorAll('.rounded-lg.border');
|
||||||
|
expect(cards).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes showAvatar prop to cards', () => {
|
||||||
|
const { container } = render(<SkeletonList showAvatar={false} />);
|
||||||
|
const avatars = container.querySelectorAll('.rounded-full');
|
||||||
|
expect(avatars).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SkeletonTable Component', () => {
|
||||||
|
it('renders table with header by default', () => {
|
||||||
|
const { container } = render(<SkeletonTable />);
|
||||||
|
const header = container.querySelector('.bg-slate-50');
|
||||||
|
expect(header).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders without header when specified', () => {
|
||||||
|
const { container } = render(<SkeletonTable showHeader={false} />);
|
||||||
|
const header = container.querySelector('.bg-slate-50');
|
||||||
|
expect(header).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders correct number of rows and columns', () => {
|
||||||
|
const { container } = render(<SkeletonTable rows={3} columns={4} />);
|
||||||
|
const rows = container.querySelectorAll('.divide-y > div');
|
||||||
|
expect(rows).toHaveLength(3);
|
||||||
|
|
||||||
|
const firstRowCells = rows[0].querySelectorAll('.h-4');
|
||||||
|
expect(firstRowCells).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SkeletonForm Component', () => {
|
||||||
|
it('renders default number of fields', () => {
|
||||||
|
const { container } = render(<SkeletonForm />);
|
||||||
|
const fields = container.querySelectorAll('.space-y-2');
|
||||||
|
expect(fields).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders custom number of fields', () => {
|
||||||
|
const { container } = render(<SkeletonForm fields={5} />);
|
||||||
|
const fields = container.querySelectorAll('.space-y-2');
|
||||||
|
expect(fields).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes submit button skeleton', () => {
|
||||||
|
const { container } = render(<SkeletonForm />);
|
||||||
|
// Count all h-10 elements (fields + button)
|
||||||
|
const elements = container.querySelectorAll('.h-10');
|
||||||
|
// Should have fields + 1 submit button
|
||||||
|
expect(elements.length).toBeGreaterThan(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
35
web/components/ui/index.ts
Normal file
35
web/components/ui/index.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* UI Components Export
|
||||||
|
*
|
||||||
|
* Centralized export for all UI components
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Loading components
|
||||||
|
export {
|
||||||
|
LoadingSpinner,
|
||||||
|
LoadingOverlay,
|
||||||
|
InlineLoader,
|
||||||
|
PageLoader,
|
||||||
|
} from './LoadingSpinner';
|
||||||
|
|
||||||
|
// Error components
|
||||||
|
export {
|
||||||
|
ErrorMessage,
|
||||||
|
FullScreenError,
|
||||||
|
FieldError,
|
||||||
|
ErrorBoundaryFallback,
|
||||||
|
EmptyState,
|
||||||
|
type ErrorSeverity,
|
||||||
|
} from './ErrorMessage';
|
||||||
|
|
||||||
|
// Skeleton components
|
||||||
|
export {
|
||||||
|
Skeleton,
|
||||||
|
SkeletonAvatar,
|
||||||
|
SkeletonText,
|
||||||
|
SkeletonCard,
|
||||||
|
SkeletonList,
|
||||||
|
SkeletonTable,
|
||||||
|
SkeletonDashboard,
|
||||||
|
SkeletonForm,
|
||||||
|
} from './Skeleton';
|
||||||
21
web/lib/utils.ts
Normal file
21
web/lib/utils.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for the web application
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge Tailwind CSS classes with proper precedence
|
||||||
|
*
|
||||||
|
* Uses clsx for conditional classes and tailwind-merge to handle conflicts
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* cn('bg-red-500', isActive && 'bg-blue-500') // 'bg-blue-500' if active
|
||||||
|
* cn('p-4', className) // Merges with user-provided className
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
21
web/package-lock.json
generated
21
web/package-lock.json
generated
@ -8,9 +8,11 @@
|
|||||||
"name": "wellnuo-web",
|
"name": "wellnuo-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -4044,6 +4046,15 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/co": {
|
"node_modules/co": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||||
@ -9759,6 +9770,16 @@
|
|||||||
"url": "https://opencollective.com/synckit"
|
"url": "https://opencollective.com/synckit"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwind-merge": {
|
||||||
|
"version": "3.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||||
|
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/dcastil"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.19",
|
"version": "3.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
|
|||||||
@ -13,9 +13,11 @@
|
|||||||
"test:watch": "jest --watch"
|
"test:watch": "jest --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user