@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
@@ -1,15 +1,31 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import type { ChartLegendPosition } from '@classic-homes/charts-core';
3
+ import {
4
+ chartFilename,
5
+ downloadCsv,
6
+ downloadFile,
7
+ type ChartDataTable as ChartDataTableData,
8
+ type ChartExportConfig,
9
+ type ChartLegendPosition,
10
+ } from '@classic-homes/charts-core';
4
11
  import { cn } from '../../utils.js';
5
12
  import ChartSkeleton from './ChartSkeleton.svelte';
6
13
  import ChartError from './ChartError.svelte';
7
14
  import ChartEmpty from './ChartEmpty.svelte';
15
+ import ChartDataTable from './ChartDataTable.svelte';
8
16
 
9
17
  interface Props {
10
18
  title: string;
11
19
  description?: string;
12
20
  height?: number | string;
21
+ /** Minimum height in pixels */
22
+ minHeight?: number;
23
+ /** Maximum height in pixels */
24
+ maxHeight?: number;
25
+ /**
26
+ * Loading. With no data yet this shows a skeleton; over an existing chart
27
+ * it dims the chart instead, keeping its zoom and legend state.
28
+ */
13
29
  loading?: boolean;
14
30
  error?: string | null;
15
31
  empty?: boolean;
@@ -24,6 +40,16 @@
24
40
  legend?: Snippet;
25
41
  /** Where the custom legend sits (defaults to 'bottom') */
26
42
  legendPosition?: ChartLegendPosition;
43
+ /** The chart's data as a table, for screen readers, "Show data" and CSV export */
44
+ dataTable?: ChartDataTableData;
45
+ /** 'sr-only' renders the table for screen readers, true adds a visible toggle */
46
+ showDataTable?: boolean | 'sr-only';
47
+ /** Formats values in the data table */
48
+ format?: (value: number) => string;
49
+ /** Show download buttons */
50
+ exportable?: boolean | ChartExportConfig;
51
+ /** Renders the chart to an image data URL, for the PNG download */
52
+ exportImage?: () => string | null;
27
53
  children: Snippet;
28
54
  }
29
55
 
@@ -31,6 +57,8 @@
31
57
  title,
32
58
  description,
33
59
  height = 400,
60
+ minHeight,
61
+ maxHeight,
34
62
  loading,
35
63
  error,
36
64
  empty,
@@ -40,6 +68,11 @@
40
68
  wrapperClass,
41
69
  legend,
42
70
  legendPosition = 'bottom',
71
+ dataTable,
72
+ showDataTable = 'sr-only',
73
+ format,
74
+ exportable,
75
+ exportImage,
43
76
  children,
44
77
  }: Props = $props();
45
78
 
@@ -48,10 +81,39 @@
48
81
 
49
82
  const heightStyle = $derived(typeof height === 'number' ? `${height}px` : height);
50
83
  const descriptionId = $derived(description ? `${uid}-desc` : undefined);
84
+ const tableId = `${uid}-table`;
51
85
 
52
86
  // The legend's DOM order follows its visual position
53
87
  const side = $derived(legendPosition === 'left' || legendPosition === 'right');
54
88
  const leading = $derived(legendPosition === 'top' || legendPosition === 'left');
89
+
90
+ const exportConfig = $derived<ChartExportConfig | undefined>(
91
+ exportable ? (exportable === true ? {} : exportable) : undefined
92
+ );
93
+ const canExportImage = $derived(!!exportConfig && exportConfig.image !== false && !!exportImage);
94
+ const canExportCsv = $derived(!!exportConfig && exportConfig.csv !== false && !!dataTable);
95
+ const filename = $derived(exportConfig?.filename ?? chartFilename(title));
96
+
97
+ // A hidden table of thousands of rows (a dense scatter or heatmap) costs
98
+ // more than it helps; "Show data" and CSV export still offer the data
99
+ const SR_TABLE_MAX_ROWS = 500;
100
+
101
+ let tableOpen = $state(false);
102
+ const toggleTable = $derived(showDataTable === true && !!dataTable);
103
+ const toolbar = $derived(canExportImage || canExportCsv || toggleTable);
104
+ const framed = $derived(!!legend || toolbar);
105
+
106
+ function downloadImage() {
107
+ const url = exportImage?.();
108
+ if (url) downloadFile(url, `${filename}.png`);
109
+ }
110
+
111
+ function downloadData() {
112
+ if (dataTable) downloadCsv(dataTable, filename);
113
+ }
114
+
115
+ const buttonClass =
116
+ 'rounded-md px-2 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
55
117
  </script>
56
118
 
57
119
  {#snippet chart(chartClass: string)}
@@ -59,15 +121,31 @@
59
121
  role="img"
60
122
  aria-label={title}
61
123
  aria-describedby={descriptionId}
124
+ aria-busy={loading || undefined}
62
125
  data-testid="chart-container"
63
- class={chartClass}
126
+ class={cn('relative', chartClass)}
64
127
  >
65
128
  {#if description}
66
129
  <span id={descriptionId} class="sr-only">{description}</span>
67
130
  {/if}
68
- <div class={cn('h-full w-full', className)} style:height={heightStyle}>
131
+ <div
132
+ class={cn('h-full w-full', className)}
133
+ style:height={heightStyle}
134
+ style:min-height={minHeight !== undefined ? `${minHeight}px` : undefined}
135
+ style:max-height={maxHeight !== undefined ? `${maxHeight}px` : undefined}
136
+ >
69
137
  {@render children()}
70
138
  </div>
139
+ {#if loading}
140
+ <div
141
+ data-testid="chart-loading-overlay"
142
+ class="absolute inset-0 flex items-center justify-center rounded-lg bg-card/60"
143
+ >
144
+ <span
145
+ class="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground motion-reduce:animate-none"
146
+ ></span>
147
+ </div>
148
+ {/if}
71
149
  </div>
72
150
  {/snippet}
73
151
 
@@ -80,24 +158,73 @@
80
158
  </div>
81
159
  {/snippet}
82
160
 
83
- {#if loading}
84
- <ChartSkeleton {height} class={wrapperClass} />
161
+ {#snippet srTable()}
162
+ {#if dataTable && showDataTable === 'sr-only' && dataTable.rows.length <= SR_TABLE_MAX_ROWS}
163
+ <ChartDataTable table={dataTable} caption={`${title} data`} {format} srOnly />
164
+ {/if}
165
+ {/snippet}
166
+
167
+ {#if loading && empty}
168
+ <ChartSkeleton {height} label={`Loading ${title}`} class={wrapperClass} />
85
169
  {:else if error}
86
170
  <ChartError message={error} {height} {onRetry} class={wrapperClass} />
87
171
  {:else if empty}
88
172
  <ChartEmpty message={emptyMessage} {height} class={wrapperClass} />
89
- {:else if legend}
173
+ {:else if framed}
90
174
  <div
91
- class={cn(
92
- 'flex rounded-lg border border-border bg-card',
93
- side ? 'flex-row' : 'flex-col',
94
- wrapperClass
95
- )}
175
+ data-testid="chart-frame"
176
+ class={cn('flex flex-col rounded-lg border border-border bg-card', wrapperClass)}
96
177
  >
97
- {#if leading}{@render legendArea(legend)}{/if}
98
- {@render chart('min-h-0 min-w-0 flex-1')}
99
- {#if !leading}{@render legendArea(legend)}{/if}
178
+ {#if toolbar}
179
+ <div data-testid="chart-toolbar" class="flex justify-end gap-1 px-2 pt-2 print:hidden">
180
+ {#if toggleTable}
181
+ <button
182
+ type="button"
183
+ class={buttonClass}
184
+ aria-expanded={tableOpen}
185
+ aria-controls={tableId}
186
+ onclick={() => (tableOpen = !tableOpen)}
187
+ >
188
+ {tableOpen ? 'Hide data' : 'Show data'}
189
+ </button>
190
+ {/if}
191
+ {#if canExportImage}
192
+ <button
193
+ type="button"
194
+ class={buttonClass}
195
+ aria-label={`Download ${title} as PNG`}
196
+ onclick={downloadImage}
197
+ >
198
+ PNG
199
+ </button>
200
+ {/if}
201
+ {#if canExportCsv}
202
+ <button
203
+ type="button"
204
+ class={buttonClass}
205
+ aria-label={`Download ${title} data as CSV`}
206
+ onclick={downloadData}
207
+ >
208
+ CSV
209
+ </button>
210
+ {/if}
211
+ </div>
212
+ {/if}
213
+ <div class={cn('flex min-h-0 flex-1', side ? 'flex-row' : 'flex-col')}>
214
+ {#if legend && leading}{@render legendArea(legend)}{/if}
215
+ {@render chart('min-h-0 min-w-0 flex-1')}
216
+ {#if legend && !leading}{@render legendArea(legend)}{/if}
217
+ </div>
218
+ {#if toggleTable && dataTable}
219
+ <div id={tableId} hidden={!tableOpen} class="border-t border-border p-2">
220
+ {#if tableOpen}
221
+ <ChartDataTable table={dataTable} caption={`${title} data`} {format} />
222
+ {/if}
223
+ </div>
224
+ {/if}
225
+ {@render srTable()}
100
226
  </div>
101
227
  {:else}
102
228
  {@render chart(cn('rounded-lg border border-border bg-card', wrapperClass))}
229
+ {@render srTable()}
103
230
  {/if}
@@ -1,9 +1,17 @@
1
1
  import type { Snippet } from 'svelte';
2
- import type { ChartLegendPosition } from '@classic-homes/charts-core';
2
+ import { type ChartDataTable as ChartDataTableData, type ChartExportConfig, type ChartLegendPosition } from '@classic-homes/charts-core';
3
3
  interface Props {
4
4
  title: string;
5
5
  description?: string;
6
6
  height?: number | string;
7
+ /** Minimum height in pixels */
8
+ minHeight?: number;
9
+ /** Maximum height in pixels */
10
+ maxHeight?: number;
11
+ /**
12
+ * Loading. With no data yet this shows a skeleton; over an existing chart
13
+ * it dims the chart instead, keeping its zoom and legend state.
14
+ */
7
15
  loading?: boolean;
8
16
  error?: string | null;
9
17
  empty?: boolean;
@@ -18,6 +26,16 @@ interface Props {
18
26
  legend?: Snippet;
19
27
  /** Where the custom legend sits (defaults to 'bottom') */
20
28
  legendPosition?: ChartLegendPosition;
29
+ /** The chart's data as a table, for screen readers, "Show data" and CSV export */
30
+ dataTable?: ChartDataTableData;
31
+ /** 'sr-only' renders the table for screen readers, true adds a visible toggle */
32
+ showDataTable?: boolean | 'sr-only';
33
+ /** Formats values in the data table */
34
+ format?: (value: number) => string;
35
+ /** Show download buttons */
36
+ exportable?: boolean | ChartExportConfig;
37
+ /** Renders the chart to an image data URL, for the PNG download */
38
+ exportImage?: () => string | null;
21
39
  children: Snippet;
22
40
  }
23
41
  declare const ChartContainer: import("svelte").Component<Props, {}, "">;
@@ -0,0 +1,59 @@
1
+ <script lang="ts">
2
+ import { formatTableCell, type ChartDataTable } from '@classic-homes/charts-core';
3
+ import { cn } from '../../utils.js';
4
+
5
+ interface Props {
6
+ /** Table contents */
7
+ table: ChartDataTable;
8
+ /** Caption naming the chart the data belongs to */
9
+ caption: string;
10
+ /** Formats 'value' columns */
11
+ format?: (value: number) => string;
12
+ /** Hide visually, leaving the table to screen readers */
13
+ srOnly?: boolean;
14
+ class?: string;
15
+ }
16
+
17
+ let { table, caption, format, srOnly = false, class: className }: Props = $props();
18
+ </script>
19
+
20
+ <div
21
+ data-testid="chart-data-table"
22
+ class={cn(srOnly ? 'sr-only' : 'max-h-80 overflow-auto', className)}
23
+ >
24
+ <table class="w-full border-collapse text-sm">
25
+ <caption class={srOnly ? undefined : 'sr-only'}>{caption}</caption>
26
+ <thead>
27
+ <tr>
28
+ {#each table.headers as header, column (column)}
29
+ <th
30
+ scope="col"
31
+ class={cn(
32
+ 'sticky top-0 border-b border-border bg-card px-3 py-2 font-medium text-muted-foreground',
33
+ column === 0 ? 'text-left' : 'text-right'
34
+ )}
35
+ >
36
+ {header}
37
+ </th>
38
+ {/each}
39
+ </tr>
40
+ </thead>
41
+ <tbody>
42
+ {#each table.rows as row, rowIndex (rowIndex)}
43
+ <tr class="border-b border-border last:border-0">
44
+ {#each row as cell, column (column)}
45
+ {#if column === 0}
46
+ <th scope="row" class="px-3 py-1.5 text-left font-normal text-foreground">
47
+ {formatTableCell(table, cell, column, format)}
48
+ </th>
49
+ {:else}
50
+ <td class="px-3 py-1.5 text-right tabular-nums text-foreground">
51
+ {formatTableCell(table, cell, column, format)}
52
+ </td>
53
+ {/if}
54
+ {/each}
55
+ </tr>
56
+ {/each}
57
+ </tbody>
58
+ </table>
59
+ </div>
@@ -0,0 +1,15 @@
1
+ import { type ChartDataTable } from '@classic-homes/charts-core';
2
+ interface Props {
3
+ /** Table contents */
4
+ table: ChartDataTable;
5
+ /** Caption naming the chart the data belongs to */
6
+ caption: string;
7
+ /** Formats 'value' columns */
8
+ format?: (value: number) => string;
9
+ /** Hide visually, leaving the table to screen readers */
10
+ srOnly?: boolean;
11
+ class?: string;
12
+ }
13
+ declare const ChartDataTable: import("svelte").Component<Props, {}, "">;
14
+ type ChartDataTable = ReturnType<typeof ChartDataTable>;
15
+ export default ChartDataTable;
@@ -14,6 +14,7 @@
14
14
 
15
15
  <div
16
16
  data-testid="chart-empty"
17
+ role="status"
17
18
  class={cn(
18
19
  'flex flex-col items-center justify-center rounded-lg border border-border bg-muted/50',
19
20
  className
@@ -3,20 +3,25 @@
3
3
 
4
4
  interface Props {
5
5
  height?: number | string;
6
+ /** Accessible name announced while loading (defaults to 'Loading chart') */
7
+ label?: string;
6
8
  class?: string;
7
9
  }
8
10
 
9
- let { height = 400, class: className }: Props = $props();
11
+ let { height = 400, label = 'Loading chart', class: className }: Props = $props();
10
12
 
11
13
  const heightStyle = $derived(typeof height === 'number' ? `${height}px` : height);
12
14
  </script>
13
15
 
14
16
  <div
15
17
  data-testid="chart-skeleton"
18
+ role="status"
19
+ aria-busy="true"
20
+ aria-label={label}
16
21
  class={cn('animate-pulse rounded-lg border border-border bg-muted', className)}
17
22
  style:height={heightStyle}
18
23
  >
19
- <div class="flex h-full flex-col p-4">
24
+ <div class="flex h-full flex-col p-4" aria-hidden="true">
20
25
  <!-- Title skeleton -->
21
26
  <div class="mb-4 h-5 w-1/3 rounded bg-muted-foreground/20"></div>
22
27
 
@@ -1,5 +1,7 @@
1
1
  interface Props {
2
2
  height?: number | string;
3
+ /** Accessible name announced while loading (defaults to 'Loading chart') */
4
+ label?: string;
3
5
  class?: string;
4
6
  }
5
7
  declare const ChartSkeleton: import("svelte").Component<Props, {}, "">;
@@ -3,14 +3,17 @@ export { default as ChartSkeleton } from './ChartSkeleton.svelte';
3
3
  export { default as ChartError } from './ChartError.svelte';
4
4
  export { default as ChartEmpty } from './ChartEmpty.svelte';
5
5
  export { default as ChartLegend } from './ChartLegend.svelte';
6
+ export { default as ChartDataTable } from './ChartDataTable.svelte';
6
7
  import type { ComponentProps } from 'svelte';
7
8
  import type ChartContainer from './ChartContainer.svelte';
8
9
  import type ChartSkeleton from './ChartSkeleton.svelte';
9
10
  import type ChartError from './ChartError.svelte';
10
11
  import type ChartEmpty from './ChartEmpty.svelte';
11
12
  import type ChartLegend from './ChartLegend.svelte';
13
+ import type ChartDataTable from './ChartDataTable.svelte';
12
14
  export type ChartContainerProps = ComponentProps<typeof ChartContainer>;
13
15
  export type ChartSkeletonProps = ComponentProps<typeof ChartSkeleton>;
14
16
  export type ChartErrorProps = ComponentProps<typeof ChartError>;
15
17
  export type ChartEmptyProps = ComponentProps<typeof ChartEmpty>;
16
18
  export type ChartLegendProps = ComponentProps<typeof ChartLegend>;
19
+ export type ChartDataTableProps = ComponentProps<typeof ChartDataTable>;
@@ -3,3 +3,4 @@ export { default as ChartSkeleton } from './ChartSkeleton.svelte';
3
3
  export { default as ChartError } from './ChartError.svelte';
4
4
  export { default as ChartEmpty } from './ChartEmpty.svelte';
5
5
  export { default as ChartLegend } from './ChartLegend.svelte';
6
+ export { default as ChartDataTable } from './ChartDataTable.svelte';