diff --git a/web/components/ui/ErrorMessage.tsx b/web/components/ui/ErrorMessage.tsx new file mode 100644 index 0000000..3d2a9bd --- /dev/null +++ b/web/components/ui/ErrorMessage.tsx @@ -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 + * 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 ( +
+ {/* Icon */} + + + {/* Message */} +
+

{message}

+
+ + {/* Actions */} +
+ {onRetry && ( + + )} + {onDismiss && ( + + )} +
+
+ ); +} + +/** + * FullScreenError - Full page error state with icon and actions + * + * @example + * ```tsx + * if (error) { + * return ( + * 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 ( +
+ {/* Icon */} +
+ + + +
+ + {/* Title */} +

+ {title} +

+ + {/* Message */} +

+ {message} +

+ + {/* Actions */} +
+ {onRetry && ( + + )} + {onBack && ( + + )} +
+
+ ); +} + +/** + * FieldError - Inline form field validation error + * + * @example + * ```tsx + * + * {error && } + * ``` + */ +interface FieldErrorProps { + message: string; + className?: string; +} + +export function FieldError({ message, className }: FieldErrorProps) { + return ( +

+ + + + {message} +

+ ); +} + +/** + * ErrorBoundary fallback component + * + * @example + * ```tsx + * }> + * + * + * ``` + */ +interface ErrorBoundaryFallbackProps { + error?: Error; + resetError?: () => void; +} + +export function ErrorBoundaryFallback({ + error, + resetError, +}: ErrorBoundaryFallbackProps) { + return ( + + ); +} + +/** + * EmptyState - Component for empty data states (not strictly an error) + * + * @example + * ```tsx + * {items.length === 0 && ( + * 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 ( +
+ {/* Icon */} + {icon && ( +
+ {icon} +
+ )} + + {/* Title */} +

+ {title} +

+ + {/* Message */} + {message && ( +

+ {message} +

+ )} + + {/* Action */} + {actionLabel && onAction && ( + + )} +
+ ); +} diff --git a/web/components/ui/LoadingSpinner.tsx b/web/components/ui/LoadingSpinner.tsx new file mode 100644 index 0000000..c4ecc12 --- /dev/null +++ b/web/components/ui/LoadingSpinner.tsx @@ -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 + * + * + * + * ``` + */ + +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 = ( +
+ ); + + if (fullScreen) { + return ( +
+ {spinner} + {message && ( +

+ {message} +

+ )} +
+ ); + } + + if (message) { + return ( +
+ {spinner} +

+ {message} +

+
+ ); + } + + return spinner; +} + +/** + * LoadingOverlay - Full-screen loading overlay with backdrop + * + * @example + * ```tsx + * {isLoading && ( + * + * )} + * ``` + */ +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 ( +
+ +

+ {message} +

+
+ ); +} + +/** + * InlineLoader - Small inline loading indicator + * + * @example + * ```tsx + * + * ``` + */ +interface InlineLoaderProps { + className?: string; +} + +export function InlineLoader({ className }: InlineLoaderProps) { + return ( + + ); +} + +/** + * PageLoader - Full page loading state with centered spinner + * + * @example + * ```tsx + * if (isLoading) return ; + * ``` + */ +interface PageLoaderProps { + message?: string; +} + +export function PageLoader({ message }: PageLoaderProps) { + return ( +
+ + {message && ( +

{message}

+ )} +
+ ); +} diff --git a/web/components/ui/Skeleton.tsx b/web/components/ui/Skeleton.tsx new file mode 100644 index 0000000..34b40da --- /dev/null +++ b/web/components/ui/Skeleton.tsx @@ -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 ; + * } + * ``` + */ + +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 ( +