Files
gridpilot.gg/apps/website/ui/LoadingWrapper.tsx
2026-01-15 17:12:24 +01:00

229 lines
5.9 KiB
TypeScript

import { Box } from './Box';
import { Stack } from './Stack';
import { LoadingWrapperProps } from './state-types';
import { Text } from './Text';
/**
* LoadingWrapper Component
*
* Provides consistent loading states with multiple variants:
* - spinner: Traditional loading spinner (default)
* - skeleton: Skeleton screens for better UX
* - full-screen: Centered in viewport
* - inline: Compact inline loading
* - card: Loading card placeholders
*
* All variants are fully accessible with ARIA labels and keyboard support.
*/
export function LoadingWrapper({
variant = 'spinner',
message = 'Loading...',
className = '',
size = 'md',
skeletonCount = 3,
cardConfig,
ariaLabel = 'Loading content',
}: LoadingWrapperProps) {
// Size mappings for different variants
const sizeClasses = {
sm: {
spinner: 'w-4 h-4 border-2',
inline: 'xs' as const,
card: 'h-24',
},
md: {
spinner: 'w-10 h-10 border-2',
inline: 'sm' as const,
card: 'h-32',
},
lg: {
spinner: 'w-16 h-16 border-4',
inline: 'base' as const,
card: 'h-40',
},
};
const spinnerSize = sizeClasses[size].spinner;
const inlineSize = sizeClasses[size].inline;
const cardHeight = cardConfig?.height || sizeClasses[size].card;
// Render different variants
switch (variant) {
case 'spinner':
return (
<Box
display="flex"
alignItems="center"
justifyContent="center"
minHeight="200px"
className={className}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<Stack align="center" gap={3}>
<Box
className={`${spinnerSize} border-primary-blue border-t-transparent rounded-full animate-spin`}
/>
<Text color="text-gray-400" size="sm">{message}</Text>
</Stack>
</Box>
);
case 'skeleton':
return (
<Stack
gap={3}
className={className}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
{Array.from({ length: skeletonCount }).map((_, index) => (
<Box
key={index}
fullWidth
bg="bg-iron-gray/40"
rounded="lg"
animate="pulse"
style={{ height: cardHeight }}
/>
))}
</Stack>
);
case 'full-screen':
return (
<Box
position="fixed"
inset="0"
zIndex={50}
bg="bg-deep-graphite/90"
blur="sm"
display="flex"
alignItems="center"
justifyContent="center"
p={4}
className={className}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<Box textAlign="center" maxWidth="md">
<Stack align="center" gap={4}>
<Box className="w-16 h-16 border-4 border-primary-blue border-t-transparent rounded-full animate-spin" />
<Text color="text-white" size="lg" weight="medium">{message}</Text>
<Text color="text-gray-400" size="sm">This may take a moment...</Text>
</Stack>
</Box>
</Box>
);
case 'inline':
return (
<Box
display="inline-flex"
alignItems="center"
gap={2}
className={className}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<Box className="w-4 h-4 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
<Text color="text-gray-400" size={inlineSize}>{message}</Text>
</Box>
);
case 'card':
const cardCount = cardConfig?.count || 3;
const cardClassName = cardConfig?.className || '';
return (
<Box
display="grid"
gap={4}
className={className}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
{Array.from({ length: cardCount }).map((_, index) => (
<Box
key={index}
bg="bg-iron-gray/40"
rounded="xl"
overflow="hidden"
border
borderColor="border-charcoal-outline/50"
className={cardClassName}
style={{ height: cardHeight }}
>
<Box h="full" w="full" display="flex" alignItems="center" justifyContent="center">
<Box className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
</Box>
</Box>
))}
</Box>
);
default:
return null;
}
}
/**
* Convenience component for full-screen loading
*/
export function FullScreenLoading({ message = 'Loading...', className = '' }: Pick<LoadingWrapperProps, 'message' | 'className'>) {
return (
<LoadingWrapper
variant="full-screen"
message={message}
className={className}
/>
);
}
/**
* Convenience component for inline loading
*/
export function InlineLoading({ message = 'Loading...', size = 'sm', className = '' }: Pick<LoadingWrapperProps, 'message' | 'size' | 'className'>) {
return (
<LoadingWrapper
variant="inline"
message={message}
size={size}
className={className}
/>
);
}
/**
* Convenience component for skeleton loading
*/
export function SkeletonLoading({ skeletonCount = 3, className = '' }: Pick<LoadingWrapperProps, 'skeletonCount' | 'className'>) {
return (
<LoadingWrapper
variant="skeleton"
skeletonCount={skeletonCount}
className={className}
/>
);
}
/**
* Convenience component for card loading
*/
export function CardLoading({ cardConfig, className = '' }: Pick<LoadingWrapperProps, 'cardConfig' | 'className'>) {
return (
<LoadingWrapper
variant="card"
cardConfig={cardConfig}
className={className}
/>
);
}