Spaces:
Paused
Paused
File size: 5,598 Bytes
963e554 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | /**
* Loading / empty-state feedback components (Part 3 polish primitives).
*
* PanelSkeleton replaces spinner-style loading with content-shaped skeletons;
* IllustratedEmpty renders a small inline-SVG illustration + a "how to
* produce data" action so empty screens teach instead of dead-ending.
*/
import type { ReactNode } from 'react';
import Box from '@mui/material/Box';
import Skeleton from '@mui/material/Skeleton';
import Typography from '@mui/material/Typography';
import { tokens } from '../../theme';
// ---------------------------------------------------------------- skeletons
export function PanelSkeleton({ rows = 4, height = 22, header = true }: { rows?: number; height?: number; header?: boolean }) {
return (
<Box sx={{ p: 1 }} aria-busy="true" aria-label="Loading">
{header ? <Skeleton variant="text" width="35%" height={26} sx={{ mb: 1 }} /> : null}
{Array.from({ length: rows }, (_, i) => (
<Skeleton key={i} variant="rounded" height={height} sx={{ mb: 0.75, opacity: 1 - i * 0.12 }} />
))}
</Box>
);
}
export function ChartSkeleton({ height = 240 }: { height?: number }) {
return (
<Box sx={{ display: 'flex', alignItems: 'flex-end', gap: 1, height, p: 2 }} aria-busy="true" aria-label="Loading chart">
{[0.5, 0.8, 0.35, 0.65, 0.9, 0.45, 0.7, 0.55].map((f, i) => (
<Skeleton key={i} variant="rounded" width="100%" height={`${f * 100}%`} />
))}
</Box>
);
}
export function TileSkeleton({ n = 4 }: { n?: number }) {
return (
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }} aria-busy="true">
{Array.from({ length: n }, (_, i) => (
<Skeleton key={i} variant="rounded" height={84} sx={{ flex: '1 1 150px' }} />
))}
</Box>
);
}
// ---------------------------------------------------------------- empty states
type EmptyArt = 'data' | 'search' | 'canvas' | 'gauge' | 'map';
function Art({ variant }: { variant: EmptyArt }) {
const stroke = tokens.color.textFaint;
const accent = tokens.color.info;
const common = { fill: 'none', strokeWidth: 1.6, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
switch (variant) {
case 'search':
return (
<svg width="120" height="86" viewBox="0 0 120 86">
<rect x="14" y="12" width="72" height="10" rx="5" stroke={stroke} {...common} />
<rect x="14" y="30" width="52" height="10" rx="5" stroke={stroke} {...common} opacity={0.6} />
<rect x="14" y="48" width="62" height="10" rx="5" stroke={stroke} {...common} opacity={0.35} />
<circle cx="88" cy="52" r="16" stroke={accent} {...common} />
<line x1="100" y1="64" x2="110" y2="74" stroke={accent} {...common} />
</svg>
);
case 'canvas':
return (
<svg width="120" height="86" viewBox="0 0 120 86">
<rect x="10" y="10" width="100" height="66" rx="6" stroke={stroke} {...common} strokeDasharray="5 4" />
<rect x="26" y="26" width="26" height="16" rx="3" stroke={accent} {...common} />
<circle cx="82" cy="52" r="9" stroke={stroke} {...common} />
<path d="M 52 34 C 62 34, 66 52, 73 52" stroke={stroke} {...common} strokeDasharray="3 3" />
<path d="M 62 66 l 6 -6 m -6 6 l 8 -2 m -8 2 l 2 -8" stroke={accent} {...common} />
</svg>
);
case 'gauge':
return (
<svg width="120" height="86" viewBox="0 0 120 86">
<path d="M 24 70 A 40 40 0 0 1 96 70" stroke={stroke} {...common} />
<path d="M 24 70 A 40 40 0 0 1 52 33" stroke={accent} {...common} strokeWidth={3} />
<line x1="60" y1="70" x2="44" y2="46" stroke={accent} {...common} />
<circle cx="60" cy="70" r="4" fill={accent} stroke="none" />
</svg>
);
case 'map':
return (
<svg width="120" height="86" viewBox="0 0 120 86">
<path d="M 14 22 L 44 12 L 76 22 L 106 12 L 106 64 L 76 74 L 44 64 L 14 74 Z" stroke={stroke} {...common} />
<line x1="44" y1="12" x2="44" y2="64" stroke={stroke} {...common} opacity={0.4} />
<line x1="76" y1="22" x2="76" y2="74" stroke={stroke} {...common} opacity={0.4} />
<circle cx="60" cy="40" r="6" stroke={accent} {...common} />
<path d="M 60 46 L 60 56" stroke={accent} {...common} />
</svg>
);
case 'data':
default:
return (
<svg width="120" height="86" viewBox="0 0 120 86">
<ellipse cx="60" cy="20" rx="34" ry="9" stroke={stroke} {...common} />
<path d="M 26 20 V 62 C 26 67, 41 71, 60 71 C 79 71, 94 67, 94 62 V 20" stroke={stroke} {...common} />
<path d="M 26 41 C 26 46, 41 50, 60 50 C 79 50, 94 46, 94 41" stroke={stroke} {...common} opacity={0.5} />
<circle cx="92" cy="62" r="13" {...common} fill={tokens.color.canvas} stroke={accent} />
<path d="M 92 56 v 12 m -6 -6 h 12" stroke={accent} {...common} />
</svg>
);
}
}
export function IllustratedEmpty({
art = 'data',
title,
message,
action,
}: {
art?: EmptyArt;
title: string;
message: string;
action?: ReactNode;
}) {
return (
<Box sx={{ textAlign: 'center', py: 5, px: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<Art variant={art} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: tokens.color.neutral, maxWidth: 520 }}>
{message}
</Typography>
{action ? <Box sx={{ mt: 1 }}>{action}</Box> : null}
</Box>
);
}
|