Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Palette's Journal

Critical UX and Accessibility Learnings

## 2024-05-15 - [Loading Skeletons Lack ARIA Roles]

**Learning:** Generic `div` elements used for visual loading skeletons are invisible to screen readers, leaving users unaware of active background loading processes or structure.
**Action:** When implementing generic loading skeletons, wrap the visual primitive in a container with `role='status'` and `aria-busy='true'` to expose the loading state properly. Additionally, apply an `aria-label="Loading"` to give more context.
323 changes: 162 additions & 161 deletions src/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,216 +12,217 @@ import { cn } from '@/lib/utils'
* <Skeleton variant="rectangular" className="h-32 w-full" /> - Card/image
*/

interface SkeletonProps {
className?: string
variant?: 'text' | 'circular' | 'rectangular'
animation?: 'pulse' | 'wave' | 'none'
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string
variant?: 'text' | 'circular' | 'rectangular'
animation?: 'pulse' | 'wave' | 'none'
}

export function Skeleton({
className,
variant = 'text',
animation = 'pulse',
className,
variant = 'text',
animation = 'pulse',
...props
}: SkeletonProps) {
return (
<div
className={cn(
'bg-white/10',
animation === 'pulse' && 'animate-pulse',
animation === 'wave' && 'animate-shimmer bg-gradient-to-r from-white/5 via-white/10 to-white/5 bg-[length:400%_100%]',
variant === 'text' && 'rounded',
variant === 'circular' && 'rounded-full',
variant === 'rectangular' && 'rounded-lg',
className
)}
/>
)
return (
<div
role="status"
aria-busy="true"
aria-label="Loading"
{...props}
className={cn(
'bg-white/10',
animation === 'pulse' && 'animate-pulse',
animation === 'wave' &&
'animate-shimmer bg-gradient-to-r from-white/5 via-white/10 to-white/5 bg-[length:400%_100%]',
variant === 'text' && 'rounded',
variant === 'circular' && 'rounded-full',
variant === 'rectangular' && 'rounded-lg',
className
)}
/>
)
}

// ============================================================================
// Preset Skeleton Patterns
// ============================================================================

interface SkeletonTextProps {
lines?: number
className?: string
lastLineWidth?: string
lines?: number
className?: string
lastLineWidth?: string
}

export function SkeletonText({ lines = 3, className, lastLineWidth = '60%' }: SkeletonTextProps) {
return (
<div className={cn('space-y-2', className)}>
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
style={i === lines - 1 ? { width: lastLineWidth } : undefined}
>
<Skeleton className="h-3 w-full" />
</div>
))}
return (
<div className={cn('space-y-2', className)}>
{Array.from({ length: lines }).map((_, i) => (
<div key={i} style={i === lines - 1 ? { width: lastLineWidth } : undefined}>
<Skeleton className="h-3 w-full" />
</div>
)
))}
</div>
)
}

interface SkeletonCardProps {
showImage?: boolean
showTitle?: boolean
showDescription?: boolean
showFooter?: boolean
className?: string
showImage?: boolean
showTitle?: boolean
showDescription?: boolean
showFooter?: boolean
className?: string
}

export function SkeletonCard({
showImage = true,
showTitle = true,
showDescription = true,
showFooter = false,
className,
showImage = true,
showTitle = true,
showDescription = true,
showFooter = false,
className,
}: SkeletonCardProps) {
return (
<div className={cn('rounded-lg border border-white/10 bg-white/5 p-4', className)}>
{showImage && (
<Skeleton variant="rectangular" className="mb-4 h-32 w-full" />
)}
{showTitle && (
<Skeleton className="mb-2 h-5 w-3/4" />
)}
{showDescription && (
<div className="space-y-2">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
</div>
)}
{showFooter && (
<div className="mt-4 flex items-center justify-between">
<Skeleton className="h-8 w-24" />
<Skeleton variant="circular" className="h-8 w-8" />
</div>
)}
return (
<div className={cn('rounded-lg border border-white/10 bg-white/5 p-4', className)}>
{showImage && <Skeleton variant="rectangular" className="mb-4 h-32 w-full" />}
{showTitle && <Skeleton className="mb-2 h-5 w-3/4" />}
{showDescription && (
<div className="space-y-2">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
</div>
)
)}
{showFooter && (
<div className="mt-4 flex items-center justify-between">
<Skeleton className="h-8 w-24" />
<Skeleton variant="circular" className="h-8 w-8" />
</div>
)}
</div>
)
}

export function SkeletonAvatar({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' | 'xl' }) {
const sizeClasses = {
sm: 'h-6 w-6',
md: 'h-10 w-10',
lg: 'h-12 w-12',
xl: 'h-16 w-16',
}
return <Skeleton variant="circular" className={sizeClasses[size]} />
const sizeClasses = {
sm: 'h-6 w-6',
md: 'h-10 w-10',
lg: 'h-12 w-12',
xl: 'h-16 w-16',
}
return <Skeleton variant="circular" className={sizeClasses[size]} />
}

export function SkeletonButton({ variant = 'md' }: { variant?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'h-8 w-20',
md: 'h-10 w-24',
lg: 'h-12 w-32',
}
return <Skeleton variant="rectangular" className={cn('rounded-lg', sizeClasses[variant])} />
const sizeClasses = {
sm: 'h-8 w-20',
md: 'h-10 w-24',
lg: 'h-12 w-32',
}
return <Skeleton variant="rectangular" className={cn('rounded-lg', sizeClasses[variant])} />
}

// ============================================================================
// Dashboard-specific Skeletons
// ============================================================================

export function SkeletonMetricCard({ className }: { className?: string }) {
return (
<div className={cn('rounded-xl border border-white/10 bg-white/5 p-4', className)}>
<Skeleton className="mb-3 h-3 w-20" />
<Skeleton className="mb-2 h-8 w-16" />
<Skeleton className="h-3 w-24" />
</div>
)
return (
<div className={cn('rounded-xl border border-white/10 bg-white/5 p-4', className)}>
<Skeleton className="mb-3 h-3 w-20" />
<Skeleton className="mb-2 h-8 w-16" />
<Skeleton className="h-3 w-24" />
</div>
)
}

export function SkeletonEventRow({ className }: { className?: string }) {
return (
<div className={cn('flex items-center gap-3 rounded-lg border border-white/10 bg-white/5 p-3', className)}>
<SkeletonAvatar size="sm" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-2.5 w-1/2" />
</div>
<Skeleton className="h-6 w-16 rounded-full" />
</div>
)
return (
<div
className={cn(
'flex items-center gap-3 rounded-lg border border-white/10 bg-white/5 p-3',
className
)}
>
<SkeletonAvatar size="sm" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-2.5 w-1/2" />
</div>
<Skeleton className="h-6 w-16 rounded-full" />
</div>
)
}

export function SkeletonChart({ className }: { className?: string }) {
return (
<div className={cn('rounded-xl border border-white/10 bg-white/5 p-4', className)}>
<div className="mb-4 flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<div className="flex gap-2">
<Skeleton className="h-6 w-6 rounded" />
<Skeleton className="h-6 w-6 rounded" />
</div>
</div>
<Skeleton variant="rectangular" className="h-48 w-full" />
return (
<div className={cn('rounded-xl border border-white/10 bg-white/5 p-4', className)}>
<div className="mb-4 flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<div className="flex gap-2">
<Skeleton className="h-6 w-6 rounded" />
<Skeleton className="h-6 w-6 rounded" />
</div>
)
</div>
<Skeleton variant="rectangular" className="h-48 w-full" />
</div>
)
}

export function SkeletonTable({ rows = 5, cols = 4 }: { rows?: number; cols?: number }) {
return (
<div className="rounded-xl border border-white/10 bg-white/5 overflow-hidden">
{/* Header */}
<div className="flex gap-4 border-b border-white/10 bg-white/5 p-3">
{Array.from({ length: cols }).map((_, i) => (
<Skeleton key={i} className="h-3 flex-1" />
))}
</div>
{/* Rows */}
{Array.from({ length: rows }).map((_, rowIdx) => (
<div key={rowIdx} className="flex gap-4 border-b border-white/5 p-3 last:border-b-0">
{Array.from({ length: cols }).map((_, colIdx) => (
<Skeleton
key={colIdx}
className={cn('h-3 flex-1', colIdx === 0 && 'w-1/4')}
/>
))}
</div>
))}
return (
<div className="rounded-xl border border-white/10 bg-white/5 overflow-hidden">
{/* Header */}
<div className="flex gap-4 border-b border-white/10 bg-white/5 p-3">
{Array.from({ length: cols }).map((_, i) => (
<Skeleton key={i} className="h-3 flex-1" />
))}
</div>
{/* Rows */}
{Array.from({ length: rows }).map((_, rowIdx) => (
<div key={rowIdx} className="flex gap-4 border-b border-white/5 p-3 last:border-b-0">
{Array.from({ length: cols }).map((_, colIdx) => (
<Skeleton key={colIdx} className={cn('h-3 flex-1', colIdx === 0 && 'w-1/4')} />
))}
</div>
)
))}
</div>
)
}

export function SkeletonDashboard() {
return (
<div className="space-y-6 p-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<Skeleton className="mb-2 h-6 w-48" />
<Skeleton className="h-3 w-32" />
</div>
<div className="flex gap-2">
<SkeletonButton variant="sm" />
<SkeletonButton variant="sm" />
</div>
</div>

{/* Metrics Row */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-4">
<SkeletonMetricCard />
<SkeletonMetricCard />
<SkeletonMetricCard />
<SkeletonMetricCard className="hidden lg:block" />
</div>

{/* Main Content */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<SkeletonChart className="h-80" />
</div>
<div className="space-y-4">
<SkeletonEventRow />
<SkeletonEventRow />
<SkeletonEventRow />
<SkeletonEventRow />
</div>
</div>
return (
<div className="space-y-6 p-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<Skeleton className="mb-2 h-6 w-48" />
<Skeleton className="h-3 w-32" />
</div>
<div className="flex gap-2">
<SkeletonButton variant="sm" />
<SkeletonButton variant="sm" />
</div>
</div>

{/* Metrics Row */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-4">
<SkeletonMetricCard />
<SkeletonMetricCard />
<SkeletonMetricCard />
<SkeletonMetricCard className="hidden lg:block" />
</div>

{/* Main Content */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<SkeletonChart className="h-80" />
</div>
<div className="space-y-4">
<SkeletonEventRow />
<SkeletonEventRow />
<SkeletonEventRow />
<SkeletonEventRow />
</div>
)
</div>
</div>
)
}
Loading