141 lines
4.8 KiB
JavaScript
141 lines
4.8 KiB
JavaScript
import * as React from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/**
|
|
* Skeleton — a loading placeholder.
|
|
*
|
|
* Uses a travelling shimmer rather than a pulse: on the app's translucent glass
|
|
* surfaces a pulsing block reads as a rendering glitch, while a shimmer reads as
|
|
* progress. The shimmer is disabled automatically under
|
|
* `prefers-reduced-motion` (see index.css).
|
|
*/
|
|
export function Skeleton({ className, radius = 'md', ...props }) {
|
|
return (
|
|
<div
|
|
aria-hidden="true"
|
|
className={cn(
|
|
'relative overflow-hidden bg-surface-sunken',
|
|
radius === 'full' ? 'rounded-full' : radius === 'lg' ? 'rounded-xl' : 'rounded-md',
|
|
'after:absolute after:inset-0 after:shimmer',
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The composed patterns below exist so pages never hand-assemble skeletons.
|
|
* Each mirrors the real component's geometry, which is what makes the swap from
|
|
* loading to loaded invisible.
|
|
*/
|
|
|
|
/** Paragraph placeholder. The last line is short, as real text is. */
|
|
export function SkeletonText({ lines = 3, className }) {
|
|
return (
|
|
<div className={cn('space-y-2', className)} role="status" aria-label="Loading content">
|
|
{Array.from({ length: lines }).map((_, i) => (
|
|
<Skeleton
|
|
key={i}
|
|
className={cn('h-3', i === lines - 1 && lines > 1 ? 'w-2/3' : 'w-full')}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Matches KpiCard: icon tile, big number, caption. */
|
|
export function SkeletonKpi({ className }) {
|
|
return (
|
|
<div className={cn('glass-card border border-white/60 rounded-2xl p-4 shadow-sm', className)}>
|
|
<Skeleton className="w-10 h-10 rounded-xl mb-3" />
|
|
<Skeleton className="h-7 w-14 mb-2" />
|
|
<Skeleton className="h-2.5 w-20" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Matches a list row: avatar, two lines of text, trailing value. */
|
|
export function SkeletonRow({ className }) {
|
|
return (
|
|
<div className={cn('flex items-center gap-3 py-3', className)}>
|
|
<Skeleton className="w-9 h-9" radius="full" />
|
|
<div className="flex-1 space-y-2">
|
|
<Skeleton className="h-3 w-40" />
|
|
<Skeleton className="h-2.5 w-24" />
|
|
</div>
|
|
<Skeleton className="h-6 w-10" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Matches a card in a grid: title, meta, stat row. */
|
|
export function SkeletonCard({ className }) {
|
|
return (
|
|
<div className={cn('glass-card border border-white/60 rounded-2xl p-5 shadow-sm', className)}>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<Skeleton className="w-10 h-10 rounded-xl" />
|
|
<div className="flex-1 space-y-2">
|
|
<Skeleton className="h-3.5 w-32" />
|
|
<Skeleton className="h-2.5 w-20" />
|
|
</div>
|
|
</div>
|
|
<Skeleton className="h-2.5 w-28 mb-4" />
|
|
<div className="grid grid-cols-3 gap-3 pt-4 border-t border-border">
|
|
{[0, 1, 2].map((i) => (
|
|
<div key={i} className="space-y-2">
|
|
<Skeleton className="h-5 w-8" />
|
|
<Skeleton className="h-2 w-12" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Matches DataTable: header row plus n body rows. */
|
|
export function SkeletonTable({ rows = 5, columns = 4, className }) {
|
|
return (
|
|
<div className={cn('overflow-hidden', className)} role="status" aria-label="Loading table">
|
|
<div className="flex items-center gap-4 px-4 py-3 border-b border-border bg-surface-subtle">
|
|
{Array.from({ length: columns }).map((_, i) => (
|
|
<Skeleton key={i} className={cn('h-2.5', i === 0 ? 'w-32' : 'flex-1 max-w-24')} />
|
|
))}
|
|
</div>
|
|
{Array.from({ length: rows }).map((_, r) => (
|
|
<div key={r} className="flex items-center gap-4 px-4 py-3.5 border-b border-border last:border-0">
|
|
{Array.from({ length: columns }).map((_, c) => (
|
|
<Skeleton key={c} className={cn('h-3', c === 0 ? 'w-32' : 'flex-1 max-w-24')} />
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Matches ChartContainer: title, legend, plot area. */
|
|
export function SkeletonChart({ className, height = 240 }) {
|
|
return (
|
|
<div className={cn('glass-card border border-white/60 rounded-2xl p-6 shadow-sm', className)}>
|
|
<Skeleton className="h-3.5 w-40 mb-2" />
|
|
<Skeleton className="h-2.5 w-56 mb-6" />
|
|
<div className="flex items-end gap-3" style={{ height }}>
|
|
{[0.5, 0.8, 0.35, 0.95, 0.6, 0.75].map((h, i) => (
|
|
<Skeleton key={i} className="flex-1" style={{ height: `${h * 100}%` }} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** n SkeletonRows inside a surface — the common "loading a list" case. */
|
|
export function SkeletonList({ rows = 5, className }) {
|
|
return (
|
|
<div className={cn('divide-y divide-border', className)} role="status" aria-label="Loading list">
|
|
{Array.from({ length: rows }).map((_, i) => (
|
|
<SkeletonRow key={i} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|