@classic-homes/charts-svelte 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/lib/components/base/ChartContainer.svelte +141 -14
  2. package/dist/lib/components/base/ChartContainer.svelte.d.ts +19 -1
  3. package/dist/lib/components/base/ChartDataTable.svelte +59 -0
  4. package/dist/lib/components/base/ChartDataTable.svelte.d.ts +15 -0
  5. package/dist/lib/components/base/ChartEmpty.svelte +1 -0
  6. package/dist/lib/components/base/ChartSkeleton.svelte +7 -2
  7. package/dist/lib/components/base/ChartSkeleton.svelte.d.ts +2 -0
  8. package/dist/lib/components/base/index.d.ts +3 -0
  9. package/dist/lib/components/base/index.js +1 -0
  10. package/dist/lib/components/core/AreaChart.svelte +115 -70
  11. package/dist/lib/components/core/BarChart.svelte +134 -62
  12. package/dist/lib/components/core/DonutChart.svelte +41 -22
  13. package/dist/lib/components/core/LineChart.svelte +104 -73
  14. package/dist/lib/components/core/PieChart.svelte +41 -22
  15. package/dist/lib/components/core/ScatterChart.svelte +72 -68
  16. package/dist/lib/components/extended/CandlestickChart.svelte +67 -44
  17. package/dist/lib/components/extended/FunnelChart.svelte +41 -27
  18. package/dist/lib/components/extended/GaugeChart.svelte +61 -10
  19. package/dist/lib/components/extended/HeatmapChart.svelte +62 -33
  20. package/dist/lib/components/extended/RadarChart.svelte +39 -19
  21. package/dist/lib/components/extended/SankeyChart.svelte +50 -20
  22. package/dist/lib/components/extended/TreemapChart.svelte +53 -21
  23. package/dist/lib/components/index.d.ts +2 -1
  24. package/dist/lib/components/index.js +3 -1
  25. package/dist/lib/components/report/BoxPlotChart.svelte +253 -0
  26. package/dist/lib/components/report/BoxPlotChart.svelte.d.ts +7 -0
  27. package/dist/lib/components/report/BulletChart.svelte +231 -0
  28. package/dist/lib/components/report/BulletChart.svelte.d.ts +7 -0
  29. package/dist/lib/components/report/ComboChart.svelte +327 -0
  30. package/dist/lib/components/report/ComboChart.svelte.d.ts +8 -0
  31. package/dist/lib/components/report/DotPlotChart.svelte +325 -0
  32. package/dist/lib/components/report/DotPlotChart.svelte.d.ts +8 -0
  33. package/dist/lib/components/report/HistogramChart.svelte +226 -0
  34. package/dist/lib/components/report/HistogramChart.svelte.d.ts +7 -0
  35. package/dist/lib/components/report/Sparkline.svelte +181 -0
  36. package/dist/lib/components/report/Sparkline.svelte.d.ts +4 -0
  37. package/dist/lib/components/report/StatCard.svelte +146 -0
  38. package/dist/lib/components/report/StatCard.svelte.d.ts +4 -0
  39. package/dist/lib/components/report/StatGroup.svelte +35 -0
  40. package/dist/lib/components/report/StatGroup.svelte.d.ts +12 -0
  41. package/dist/lib/components/report/WaterfallChart.svelte +271 -0
  42. package/dist/lib/components/report/WaterfallChart.svelte.d.ts +7 -0
  43. package/dist/lib/components/report/index.d.ts +12 -0
  44. package/dist/lib/components/report/index.js +9 -0
  45. package/dist/lib/composables/index.d.ts +1 -1
  46. package/dist/lib/composables/index.js +1 -1
  47. package/dist/lib/composables/useChart.svelte.d.ts +34 -0
  48. package/dist/lib/composables/useChart.svelte.js +121 -9
  49. package/dist/lib/composables/useChartTheme.svelte.js +14 -11
  50. package/dist/lib/events.d.ts +15 -0
  51. package/dist/lib/events.js +56 -0
  52. package/dist/lib/index.d.ts +4 -3
  53. package/dist/lib/index.js +7 -3
  54. package/package.json +10 -4
@@ -0,0 +1,226 @@
1
+ <script lang="ts">
2
+ import * as echarts from 'echarts/core';
3
+ import { BarChart as EChartsBarChart } from 'echarts/charts';
4
+ import { GridComponent, TooltipComponent } from 'echarts/components';
5
+ import type { EChartsOption } from 'echarts';
6
+
7
+ import type { HistogramChartProps } from '@classic-homes/charts-core';
8
+ import {
9
+ binValues,
10
+ buildAxis,
11
+ buildDataLabel,
12
+ buildTooltip,
13
+ escapeHtml,
14
+ generateHistogramDescription,
15
+ getChartColors,
16
+ gridInset,
17
+ histogramTable,
18
+ mergeChartOption,
19
+ resolveValueFormatter,
20
+ } from '@classic-homes/charts-core';
21
+
22
+ import { cn } from '../../utils.js';
23
+ import { chartEvents, toDataPointParams } from '../../events.js';
24
+ import { useChart } from '../../composables/useChart.svelte.js';
25
+ import ChartContainer from '../base/ChartContainer.svelte';
26
+
27
+ // Register required ECharts modules
28
+ echarts.use([EChartsBarChart, GridComponent, TooltipComponent]);
29
+
30
+ interface Props extends Omit<HistogramChartProps, 'class'> {
31
+ class?: string;
32
+ }
33
+
34
+ let {
35
+ title,
36
+ description,
37
+ values,
38
+ bins,
39
+ binWidth,
40
+ seriesName = 'Count',
41
+ color,
42
+ height = 400,
43
+ minHeight,
44
+ maxHeight,
45
+ loading,
46
+ error,
47
+ onRetry,
48
+ emptyMessage,
49
+ theme = 'auto',
50
+ animation = true,
51
+ showTooltip = true,
52
+ showGrid = true,
53
+ showLabels = false,
54
+ valueFormat,
55
+ locale,
56
+ labelFormatter,
57
+ xAxisConfig,
58
+ yAxisConfig,
59
+ referenceLines,
60
+ referenceBands,
61
+ tooltipFormatter,
62
+ tooltipValueFormatter,
63
+ option: optionOverride,
64
+ onReady,
65
+ events,
66
+ showDataTable,
67
+ exportable,
68
+ patterns,
69
+ onClick,
70
+ class: className,
71
+ }: Props = $props();
72
+
73
+ // `valueFormat` writes the bucket bounds; counts are plain numbers
74
+ const boundFormat = $derived(resolveValueFormatter(valueFormat, locale));
75
+
76
+ const buckets = $derived(binValues(values ?? [], { bins, binWidth, format: boundFormat }));
77
+ const total = $derived(buckets.reduce((sum, bucket) => sum + bucket.count, 0));
78
+
79
+ const isEmpty = $derived(!buckets.length || total === 0);
80
+
81
+ // Per-element colours that follow the resolved chart theme
82
+ const chartColors = $derived.by(() => getChartColors(chart.theme));
83
+
84
+ const option: EChartsOption = $derived.by(() => {
85
+ if (isEmpty) return {};
86
+
87
+ const series: Record<string, unknown> = {
88
+ name: seriesName,
89
+ type: 'bar',
90
+ // Adjacent buckets touch: a histogram's bars are a continuous range
91
+ barCategoryGap: '2%',
92
+ // Lift the theme's bar width cap so buckets fill their range
93
+ barMaxWidth: 1000,
94
+ data: buckets.map((bucket) => bucket.count),
95
+ itemStyle: { color: color ?? chartColors.series[0], borderRadius: [2, 2, 0, 0] },
96
+ label: buildDataLabel({ show: true, position: 'top' }, undefined, {
97
+ show: showLabels,
98
+ formatter: labelFormatter,
99
+ }),
100
+ };
101
+
102
+ // Reference lines mark a count (e.g. a capacity), not a bucket
103
+ if (referenceLines?.length || referenceBands?.length) {
104
+ series.markLine = referenceLines?.length
105
+ ? {
106
+ silent: true,
107
+ symbol: 'none',
108
+ animation: false,
109
+ data: referenceLines.map((line) => ({
110
+ ...(typeof line.value === 'number'
111
+ ? { yAxis: line.value }
112
+ : { type: line.value, valueDim: 'y' }),
113
+ lineStyle: {
114
+ type: line.style ?? 'dashed',
115
+ color: line.color ?? chartColors.reference,
116
+ },
117
+ label: {
118
+ position: 'insideEndTop',
119
+ color: line.color ?? chartColors.reference,
120
+ formatter: line.label ?? '{c}',
121
+ },
122
+ })),
123
+ }
124
+ : undefined;
125
+ series.markArea = referenceBands?.length
126
+ ? {
127
+ silent: true,
128
+ data: referenceBands.map((band) => [
129
+ {
130
+ yAxis: band.from,
131
+ name: band.label,
132
+ itemStyle: { color: band.color ?? chartColors.referenceBand },
133
+ },
134
+ { yAxis: band.to },
135
+ ]),
136
+ }
137
+ : undefined;
138
+ }
139
+
140
+ return mergeChartOption(
141
+ {
142
+ grid: {
143
+ left: '3%',
144
+ right: '4%',
145
+ bottom: '3%',
146
+ containLabel: true,
147
+ ...gridInset(undefined, false, xAxisConfig, yAxisConfig),
148
+ },
149
+ tooltip: buildTooltip(
150
+ {
151
+ trigger: 'axis',
152
+ axisPointer: { type: 'shadow' },
153
+ formatter: (params: unknown) => {
154
+ const list = (Array.isArray(params) ? params : [params]) as {
155
+ dataIndex?: number;
156
+ marker?: string;
157
+ }[];
158
+ const bucket = buckets[list[0]?.dataIndex ?? 0];
159
+ if (!bucket) return '';
160
+ const share = total ? ((bucket.count / total) * 100).toFixed(1) : '0';
161
+ const count = tooltipValueFormatter
162
+ ? tooltipValueFormatter(bucket.count)
163
+ : bucket.count.toLocaleString(locale ?? 'en-US');
164
+ return `${escapeHtml(bucket.label)}<br/>${list[0]?.marker ?? ''}${escapeHtml(seriesName)}: <strong>${escapeHtml(count)}</strong> (${share}%)`;
165
+ },
166
+ },
167
+ { show: showTooltip, formatter: tooltipFormatter }
168
+ ),
169
+ xAxis: buildAxis(
170
+ {
171
+ type: 'category',
172
+ data: buckets.map((bucket) => bucket.label),
173
+ axisTick: { alignWithLabel: true },
174
+ },
175
+ xAxisConfig
176
+ ),
177
+ yAxis: buildAxis(
178
+ { type: 'value', minInterval: 1, splitLine: { show: showGrid } },
179
+ yAxisConfig
180
+ ),
181
+ series: [series],
182
+ } as EChartsOption,
183
+ optionOverride
184
+ );
185
+ });
186
+
187
+ const accessibilityDescription = $derived(
188
+ description || (!isEmpty ? generateHistogramDescription(title, buckets, total) : undefined)
189
+ );
190
+
191
+ const dataTable = $derived(isEmpty ? undefined : histogramTable(buckets));
192
+
193
+ // Manage ECharts instance lifecycle (init, theme, resize, option updates)
194
+ const chart = useChart({
195
+ option: () => option,
196
+ theme: () => theme,
197
+ animation: () => animation,
198
+ isEmpty: () => isEmpty,
199
+ patterns: () => patterns,
200
+ events: () =>
201
+ chartEvents(events, {
202
+ click: onClick && ((params) => onClick(toDataPointParams(params, 'click'))),
203
+ }),
204
+ onCreated: () => onReady,
205
+ });
206
+ </script>
207
+
208
+ <ChartContainer
209
+ {title}
210
+ description={accessibilityDescription}
211
+ {height}
212
+ {minHeight}
213
+ {maxHeight}
214
+ {loading}
215
+ {error}
216
+ {onRetry}
217
+ empty={isEmpty}
218
+ {emptyMessage}
219
+ class={cn(className)}
220
+ {dataTable}
221
+ {showDataTable}
222
+ {exportable}
223
+ exportImage={chart.exportImage}
224
+ >
225
+ <div bind:this={chart.container} class="h-full w-full"></div>
226
+ </ChartContainer>
@@ -0,0 +1,7 @@
1
+ import type { HistogramChartProps } from '@classic-homes/charts-core';
2
+ interface Props extends Omit<HistogramChartProps, 'class'> {
3
+ class?: string;
4
+ }
5
+ declare const HistogramChart: import("svelte").Component<Props, {}, "">;
6
+ type HistogramChart = ReturnType<typeof HistogramChart>;
7
+ export default HistogramChart;
@@ -0,0 +1,181 @@
1
+ <script lang="ts">
2
+ import type { SparklineProps } from '@classic-homes/charts-core';
3
+ import { cn } from '../../utils.js';
4
+
5
+ let {
6
+ data,
7
+ variant = 'line',
8
+ width = '100%',
9
+ height = 32,
10
+ color,
11
+ showLastPoint,
12
+ referenceLine,
13
+ min,
14
+ max,
15
+ label,
16
+ class: className,
17
+ }: SparklineProps = $props();
18
+
19
+ // Drawn in a 100-wide viewBox stretched to the element's width, with the
20
+ // real pixel height, so strokes keep their width at any size
21
+ const VIEW_WIDTH = 100;
22
+ const PAD = 2;
23
+
24
+ const values = $derived(
25
+ (data ?? []).map((value) =>
26
+ typeof value === 'number' && Number.isFinite(value) ? value : null
27
+ )
28
+ );
29
+ const present = $derived(values.filter((value): value is number => value !== null));
30
+
31
+ const scale = $derived.by(() => {
32
+ let lo = min ?? Math.min(...present, variant === 'bar' ? 0 : Infinity);
33
+ let hi = max ?? Math.max(...present);
34
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) return null;
35
+ if (hi === lo) {
36
+ // A flat series sits in the middle
37
+ lo -= 1;
38
+ hi += 1;
39
+ }
40
+ return { lo, hi };
41
+ });
42
+
43
+ const y = (value: number) => {
44
+ if (!scale) return height / 2;
45
+ const ratio = (value - scale.lo) / (scale.hi - scale.lo);
46
+ return PAD + (1 - Math.min(Math.max(ratio, 0), 1)) * (height - PAD * 2);
47
+ };
48
+ const x = (index: number) =>
49
+ values.length <= 1 ? VIEW_WIDTH / 2 : (index / (values.length - 1)) * VIEW_WIDTH;
50
+
51
+ // Runs of consecutive present values; gaps break the line
52
+ const segments = $derived.by(() => {
53
+ const runs: [number, number][][] = [];
54
+ let run: [number, number][] = [];
55
+ values.forEach((value, index) => {
56
+ if (value === null) {
57
+ if (run.length) runs.push(run);
58
+ run = [];
59
+ } else {
60
+ run.push([x(index), y(value)]);
61
+ }
62
+ });
63
+ if (run.length) runs.push(run);
64
+ return runs;
65
+ });
66
+
67
+ const linePath = $derived(
68
+ segments
69
+ .map((run) =>
70
+ run.map(([px, py], i) => `${i ? 'L' : 'M'}${px.toFixed(2)},${py.toFixed(2)}`).join('')
71
+ )
72
+ .join('')
73
+ );
74
+
75
+ const baseline = $derived(scale ? y(Math.max(scale.lo, Math.min(0, scale.hi))) : height - PAD);
76
+
77
+ const areaPath = $derived(
78
+ segments
79
+ .map((run) => {
80
+ if (run.length < 2) return '';
81
+ const top = run.map(([px, py], i) => `${i ? 'L' : 'M'}${px.toFixed(2)},${py.toFixed(2)}`);
82
+ const first = run[0][0].toFixed(2);
83
+ const last = run[run.length - 1][0].toFixed(2);
84
+ return `${top.join('')}L${last},${baseline.toFixed(2)}L${first},${baseline.toFixed(2)}Z`;
85
+ })
86
+ .join('')
87
+ );
88
+
89
+ const bars = $derived.by(() => {
90
+ const slot = VIEW_WIDTH / Math.max(values.length, 1);
91
+ return values.flatMap((value, index) => {
92
+ if (value === null) return [];
93
+ const top = Math.min(y(value), baseline);
94
+ return [
95
+ {
96
+ x: index * slot + slot * 0.15,
97
+ width: slot * 0.7,
98
+ y: top,
99
+ height: Math.max(Math.abs(baseline - y(value)), 1),
100
+ },
101
+ ];
102
+ });
103
+ });
104
+
105
+ const lastIndex = $derived.by(() => {
106
+ for (let index = values.length - 1; index >= 0; index--) {
107
+ if (values[index] !== null) return index;
108
+ }
109
+ return -1;
110
+ });
111
+ const showDot = $derived((showLastPoint ?? variant !== 'bar') && lastIndex !== -1);
112
+
113
+ const referenceY = $derived.by(() => {
114
+ if (referenceLine === undefined || !present.length) return null;
115
+ const value =
116
+ referenceLine === 'average'
117
+ ? present.reduce((sum, v) => sum + v, 0) / present.length
118
+ : referenceLine;
119
+ return y(value);
120
+ });
121
+
122
+ const widthStyle = $derived(typeof width === 'number' ? `${width}px` : width);
123
+ </script>
124
+
125
+ <span
126
+ data-testid="sparkline"
127
+ class={cn('relative inline-block align-middle text-chart-1', className)}
128
+ style:width={widthStyle}
129
+ style:height="{height}px"
130
+ style:color
131
+ role={label ? 'img' : undefined}
132
+ aria-label={label}
133
+ aria-hidden={label ? undefined : 'true'}
134
+ >
135
+ <svg
136
+ class="absolute inset-0 h-full w-full overflow-visible"
137
+ viewBox="0 0 {VIEW_WIDTH} {height}"
138
+ preserveAspectRatio="none"
139
+ aria-hidden="true"
140
+ >
141
+ {#if referenceY !== null}
142
+ <line
143
+ x1="0"
144
+ x2={VIEW_WIDTH}
145
+ y1={referenceY}
146
+ y2={referenceY}
147
+ class="stroke-muted-foreground"
148
+ stroke-width="1"
149
+ stroke-dasharray="3 3"
150
+ vector-effect="non-scaling-stroke"
151
+ opacity="0.6"
152
+ />
153
+ {/if}
154
+ {#if variant === 'bar'}
155
+ {#each bars as bar, index (index)}
156
+ <rect x={bar.x} y={bar.y} width={bar.width} height={bar.height} fill="currentColor" />
157
+ {/each}
158
+ {:else}
159
+ {#if variant === 'area' && areaPath}
160
+ <path d={areaPath} fill="currentColor" opacity="0.15" />
161
+ {/if}
162
+ <path
163
+ d={linePath}
164
+ fill="none"
165
+ stroke="currentColor"
166
+ stroke-width="1.5"
167
+ stroke-linejoin="round"
168
+ stroke-linecap="round"
169
+ vector-effect="non-scaling-stroke"
170
+ />
171
+ {/if}
172
+ </svg>
173
+ {#if showDot && values[lastIndex] !== null}
174
+ <!-- HTML, not SVG: a circle in the stretched viewBox would be an ellipse -->
175
+ <span
176
+ class="absolute h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-current"
177
+ style:left="{(x(lastIndex) / VIEW_WIDTH) * 100}%"
178
+ style:top="{y(values[lastIndex] as number)}px"
179
+ ></span>
180
+ {/if}
181
+ </span>
@@ -0,0 +1,4 @@
1
+ import type { SparklineProps } from '@classic-homes/charts-core';
2
+ declare const Sparkline: import("svelte").Component<SparklineProps, {}, "">;
3
+ type Sparkline = ReturnType<typeof Sparkline>;
4
+ export default Sparkline;
@@ -0,0 +1,146 @@
1
+ <script lang="ts">
2
+ import type { StatCardProps } from '@classic-homes/charts-core';
3
+ import { computeDelta, formatValue, resolveValueFormatter } from '@classic-homes/charts-core';
4
+ import { cn } from '../../utils.js';
5
+ import Sparkline from './Sparkline.svelte';
6
+
7
+ let {
8
+ label,
9
+ value,
10
+ valueFormat,
11
+ locale = 'en-US',
12
+ delta,
13
+ hint,
14
+ definition,
15
+ href,
16
+ trend,
17
+ trendVariant = 'line',
18
+ tone = 'default',
19
+ emptyValue = '—',
20
+ loading = false,
21
+ class: className,
22
+ }: StatCardProps = $props();
23
+
24
+ const format = $derived(resolveValueFormatter(valueFormat, locale));
25
+
26
+ const display = $derived(
27
+ typeof value === 'string' ? value : formatValue(value ?? null, format, emptyValue)
28
+ );
29
+
30
+ const change = $derived(
31
+ delta
32
+ ? computeDelta(delta.current ?? (typeof value === 'number' ? value : null), delta.previous)
33
+ : null
34
+ );
35
+
36
+ const percentFormat = $derived(
37
+ new Intl.NumberFormat(locale, {
38
+ style: 'percent',
39
+ maximumFractionDigits: 1,
40
+ signDisplay: 'exceptZero',
41
+ })
42
+ );
43
+
44
+ const changeText = $derived.by(() => {
45
+ if (!change || !delta) return '';
46
+ const sign = change.change > 0 ? '+' : change.change < 0 ? '−' : '';
47
+ const absolute = `${sign}${formatValue(Math.abs(change.change), format)}`;
48
+ const percent = change.ratio === null ? null : percentFormat.format(change.ratio);
49
+ switch (delta.display ?? 'percent') {
50
+ case 'absolute':
51
+ return absolute;
52
+ case 'both':
53
+ return percent ? `${absolute} (${percent})` : absolute;
54
+ default:
55
+ // A change from zero has no percentage
56
+ return percent ?? absolute;
57
+ }
58
+ });
59
+
60
+ const changeTone = $derived.by(() => {
61
+ if (!change || change.direction === 'flat' || delta?.goodWhen === 'none') {
62
+ return 'text-muted-foreground';
63
+ }
64
+ const good = (delta?.goodWhen ?? 'up') === change.direction;
65
+ return good ? 'text-success' : 'text-destructive';
66
+ });
67
+
68
+ const previousText = $derived(
69
+ delta && typeof delta.previous === 'number' ? formatValue(delta.previous, format) : ''
70
+ );
71
+
72
+ const valueTone = $derived(
73
+ {
74
+ default: 'text-foreground',
75
+ success: 'text-success',
76
+ warning: 'text-warning',
77
+ destructive: 'text-destructive',
78
+ }[tone]
79
+ );
80
+ </script>
81
+
82
+ <svelte:element
83
+ this={href ? 'a' : 'div'}
84
+ {href}
85
+ data-testid="stat-card"
86
+ aria-busy={loading || undefined}
87
+ class={cn(
88
+ 'flex flex-col rounded-lg border border-border bg-card p-4 text-card-foreground',
89
+ href &&
90
+ 'hover:border-ring hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
91
+ className
92
+ )}
93
+ >
94
+ <div class="flex items-center gap-1 text-sm font-medium text-muted-foreground">
95
+ <span>{label}</span>
96
+ {#if definition}
97
+ <span class="inline-flex cursor-help" title={definition} aria-hidden="true">
98
+ <svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
99
+ <path
100
+ fill-rule="evenodd"
101
+ d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
102
+ clip-rule="evenodd"
103
+ />
104
+ </svg>
105
+ </span>
106
+ <span class="sr-only">({definition})</span>
107
+ {/if}
108
+ </div>
109
+
110
+ {#if loading}
111
+ <div class="mt-2 h-8 w-24 animate-pulse rounded bg-muted"></div>
112
+ <div class="mt-2 h-3 w-32 animate-pulse rounded bg-muted"></div>
113
+ {:else}
114
+ <div class="mt-1 flex items-end justify-between gap-3">
115
+ <div class="min-w-0">
116
+ <div class={cn('text-2xl font-semibold tabular-nums', valueTone)}>{display}</div>
117
+ {#if changeText}
118
+ <div class="mt-1 flex flex-wrap items-baseline gap-x-1 text-xs">
119
+ <span class={cn('font-medium tabular-nums', changeTone)}>
120
+ <span aria-hidden="true">
121
+ {change?.direction === 'up' ? '▲' : change?.direction === 'down' ? '▼' : ''}
122
+ </span>
123
+ {changeText}
124
+ </span>
125
+ <span class="text-muted-foreground">
126
+ {delta?.label ?? (previousText ? `vs ${previousText}` : '')}
127
+ </span>
128
+ </div>
129
+ {/if}
130
+ {#if hint}
131
+ <p class="mt-1 text-xs text-muted-foreground">{hint}</p>
132
+ {/if}
133
+ </div>
134
+ {#if trend?.length}
135
+ <Sparkline
136
+ data={trend}
137
+ variant={trendVariant}
138
+ width={96}
139
+ height={32}
140
+ class="shrink-0"
141
+ label={`${label} trend`}
142
+ />
143
+ {/if}
144
+ </div>
145
+ {/if}
146
+ </svelte:element>
@@ -0,0 +1,4 @@
1
+ import type { StatCardProps } from '@classic-homes/charts-core';
2
+ declare const StatCard: import("svelte").Component<StatCardProps, {}, "">;
3
+ type StatCard = ReturnType<typeof StatCard>;
4
+ export default StatCard;
@@ -0,0 +1,35 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../../utils.js';
4
+
5
+ interface Props {
6
+ /** Columns at the widest breakpoint (defaults to 4) */
7
+ columns?: 2 | 3 | 4 | 5 | 6;
8
+ /** Accessible name for the group, e.g. 'Key metrics' */
9
+ label?: string;
10
+ class?: string;
11
+ children: Snippet;
12
+ }
13
+
14
+ let { columns = 4, label, class: className, children }: Props = $props();
15
+
16
+ // Full class names, so Tailwind's scanner finds them
17
+ const columnClass = $derived(
18
+ {
19
+ 2: 'sm:grid-cols-2',
20
+ 3: 'sm:grid-cols-2 lg:grid-cols-3',
21
+ 4: 'sm:grid-cols-2 lg:grid-cols-4',
22
+ 5: 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',
23
+ 6: 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6',
24
+ }[columns]
25
+ );
26
+ </script>
27
+
28
+ <div
29
+ data-testid="stat-group"
30
+ role={label ? 'group' : undefined}
31
+ aria-label={label}
32
+ class={cn('grid grid-cols-1 gap-4', columnClass, className)}
33
+ >
34
+ {@render children()}
35
+ </div>
@@ -0,0 +1,12 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ /** Columns at the widest breakpoint (defaults to 4) */
4
+ columns?: 2 | 3 | 4 | 5 | 6;
5
+ /** Accessible name for the group, e.g. 'Key metrics' */
6
+ label?: string;
7
+ class?: string;
8
+ children: Snippet;
9
+ }
10
+ declare const StatGroup: import("svelte").Component<Props, {}, "">;
11
+ type StatGroup = ReturnType<typeof StatGroup>;
12
+ export default StatGroup;