@juspay/svelte-ui-components 4.20.0 → 4.22.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.
@@ -13,6 +13,7 @@
13
13
  import { measureText } from '../_chart/measure';
14
14
  import { resolvePointLabels } from '../_chart/labels';
15
15
  import { pointerPositionIn, dismissOnOutsidePointerDown } from '../_chart/interactions';
16
+ import { formatSeriesAggregate } from '../_chart/aggregate';
16
17
  import type { LegendItem, Point } from '../_chart/types';
17
18
  import { DEFAULT_CHART_MAX_HEIGHT } from '../_chart/types';
18
19
 
@@ -171,7 +172,15 @@
171
172
  });
172
173
 
173
174
  let legendItems = $derived<LegendItem[]>(
174
- series.map((s, i) => ({ label: s.name, color: s.color ?? getColor(i) }))
175
+ series.map((s, i) => ({
176
+ label: s.name,
177
+ color: s.color ?? getColor(i),
178
+ aggregateLabel: formatSeriesAggregate(
179
+ s.data.map((d) => d.y),
180
+ s.aggregate ?? 'none',
181
+ s.aggregateFormat ?? yTickFormat ?? formatNumber
182
+ )
183
+ }))
175
184
  );
176
185
 
177
186
  // ── Tooltip ────────────────────────────────────────────────────
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { CurveType } from '../_chart/types';
3
+ import type { SeriesAggregate } from '../_chart/aggregate';
3
4
  export type AreaChartDataPoint = {
4
5
  x: number;
5
6
  y: number;
@@ -9,6 +10,20 @@ export type AreaChartSeries = {
9
10
  name: string;
10
11
  data: AreaChartDataPoint[];
11
12
  color?: string;
13
+ /**
14
+ * Shows an aggregate of this series' `y` values beside its legend label
15
+ * (e.g. "Revenue $128K"). Aggregates that series' own values only -- when
16
+ * `stacked` is true this is never the per-category stack total, which is a
17
+ * separate, existing computation. Omit (or `'none'`, the default) to show
18
+ * no aggregate; every chart renders unchanged without it.
19
+ */
20
+ aggregate?: SeriesAggregate;
21
+ /**
22
+ * Formatter for this series' legend aggregate. Defaults to the chart's own
23
+ * `yTickFormat` (or the built-in number formatter) -- set this only when
24
+ * the aggregate needs a different unit than the axis.
25
+ */
26
+ aggregateFormat?: (value: number) => string;
12
27
  };
13
28
  export type AreaChartTooltipContext = {
14
29
  x: number;
@@ -29,6 +29,7 @@
29
29
  placedLabelRect,
30
30
  dropOverlapping
31
31
  } from '../_chart/labels';
32
+ import { formatSeriesAggregate } from '../_chart/aggregate';
32
33
  import type { LegendItem, BarRect } from '../_chart/types';
33
34
  import { DEFAULT_CHART_CORNER_RADIUS, DEFAULT_CHART_MAX_HEIGHT } from '../_chart/types';
34
35
  import type { TooltipAnchor } from '../_chart/types';
@@ -583,10 +584,22 @@
583
584
 
584
585
  let legendItems = $derived<LegendItem[]>(
585
586
  isMulti
586
- ? resolvedSeries.map((s, i) => ({
587
+ ? rawSeries.map((s, i) => ({
587
588
  label: s.name,
588
589
  color: fallbackColor(s.color ?? getColor(i), i),
589
- hidden: hiddenSeries.has(i)
590
+ hidden: hiddenSeries.has(i),
591
+ // Aggregates this series' own raw `value`s, from `rawSeries` rather
592
+ // than `resolvedSeries`: the latter may hold normaliseToFirstPoint
593
+ // percentages or topN-clipped/overflow values, neither of which is
594
+ // the series' real data (never the cross-series stack total either,
595
+ // which is an unrelated existing computation). Defaults to the
596
+ // chart's own `valueFormat`/number formatter, matching the "reuse
597
+ // the axis/tooltip formatting mechanism" rule.
598
+ aggregateLabel: formatSeriesAggregate(
599
+ s.data.map((d) => d.value),
600
+ s.aggregate ?? 'none',
601
+ s.aggregateFormat ?? format
602
+ )
590
603
  }))
591
604
  : []
592
605
  );
@@ -1,5 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { ChartHighlightAPI } from '../_chart/highlight';
3
+ import type { SeriesAggregate } from '../_chart/aggregate';
3
4
  export type BarFillPattern = {
4
5
  pattern: {
5
6
  /** SVG pattern element type: 'lines' | 'dots' | 'crosshatch' */
@@ -52,6 +53,21 @@ export type BarChartSeries = {
52
53
  data: BarChartDataPoint[];
53
54
  /** Series-level fill: plain color, pattern, or gradient. */
54
55
  color?: BarFill;
56
+ /**
57
+ * Shows an aggregate of this series' `value`s beside its legend label (e.g.
58
+ * "Revenue $128K"). Aggregates that series' own values only -- in
59
+ * `groupMode="stacked"` this is never the per-category stack total, which
60
+ * is a separate, existing computation. Omit (or `'none'`, the default) to
61
+ * show no aggregate; every chart renders unchanged without it.
62
+ */
63
+ aggregate?: SeriesAggregate;
64
+ /**
65
+ * Formatter for this series' legend aggregate. Defaults to the chart's own
66
+ * `valueFormat` (or the built-in number formatter) -- set this only when
67
+ * the aggregate needs a different unit than the axis, e.g. a percentage
68
+ * average alongside a currency-formatted axis.
69
+ */
70
+ aggregateFormat?: (value: number) => string;
55
71
  };
56
72
  /**
57
73
  * Geometry for a single rendered bar, in inner (post-margin) coordinates — the
@@ -570,6 +570,11 @@
570
570
  opacity: var(--chat-message-blockquote-opacity, 0.85);
571
571
  }
572
572
 
573
+ /* Fallback for a bare <table> in caller-supplied `html`, which has no scroll
574
+ container of its own: it keeps the block treatment so a wide table scrolls
575
+ rather than overflowing the message. A table that came from `markdown` (or
576
+ from `renderMarkdown`) arrives inside `.markdown-table-wrapper`, and the
577
+ rules below take it back out of block layout. */
573
578
  .body :global(table) {
574
579
  display: block;
575
580
  max-width: 100%;
@@ -578,6 +583,40 @@
578
583
  margin: 0.5em 0;
579
584
  }
580
585
 
586
+ /* The wrapper scrolls, not the table. `display: block` on a <table> is what
587
+ makes `overflow-x` work on the element itself, but it strips the table's
588
+ semantics for assistive technology -- a screen reader loses row and column
589
+ navigation. MarkdownText separates the scroll container from the table for
590
+ exactly that reason; this component rendered the same wrapper markup and
591
+ styled the table instead, so a chat table was scrollable and no longer a
592
+ table. Chat is where markdown tables overwhelmingly land. */
593
+ .body :global(.markdown-table-wrapper) {
594
+ display: block;
595
+ max-width: 100%;
596
+ overflow-x: auto;
597
+ margin: 0.5em 0;
598
+ }
599
+
600
+ /* The wrapper is a tab stop (the renderer emits tabindex="0" so the scroll is
601
+ reachable by keyboard). The browser's default ring already applies; this
602
+ replaces it with the same 2px treatment the rest of the library uses, since
603
+ the 1px UA default is easy to miss against a message bubble. */
604
+ .body :global(.markdown-table-wrapper:focus-visible) {
605
+ outline: 2px solid var(--chat-message-table-focus-outline-color, #3b82f6);
606
+ outline-offset: 2px;
607
+ }
608
+
609
+ /* Without an intrinsic width the table is exactly its container's width and a
610
+ wide table compresses instead of scrolling. `max-content` supplies it;
611
+ `min-width` keeps a narrow table filling the bubble. */
612
+ .body :global(.markdown-table-wrapper table) {
613
+ display: table;
614
+ width: max-content;
615
+ min-width: 100%;
616
+ overflow-x: visible;
617
+ margin: 0;
618
+ }
619
+
581
620
  .body :global(th),
582
621
  .body :global(td) {
583
622
  padding: 5px 10px;
@@ -18,6 +18,7 @@
18
18
  import { getColor } from '../_chart/colors';
19
19
  import { formatNumber } from '../_chart/format';
20
20
  import { roundedRectPath, linePath } from '../_chart/paths';
21
+ import { formatSeriesAggregate } from '../_chart/aggregate';
21
22
  import type {
22
23
  LegendItem,
23
24
  TooltipData,
@@ -280,7 +281,17 @@
280
281
  series.map((s, si) => ({
281
282
  label: s.name,
282
283
  color: resolvedColor(s, si),
283
- hidden: hiddenSeries.has(si)
284
+ hidden: hiddenSeries.has(si),
285
+ // Defaults to the same per-axis formatter (leftFormat/rightFormat, used
286
+ // for ticks/tooltip above) as whichever axis this series is plotted
287
+ // against, so a left-axis currency series and a right-axis percentage
288
+ // series each get a sensible default without either specifying
289
+ // `aggregateFormat`.
290
+ aggregateLabel: formatSeriesAggregate(
291
+ s.data,
292
+ s.aggregate ?? 'none',
293
+ s.aggregateFormat ?? (s.yAxisIndex === 0 ? leftFormat : rightFormat)
294
+ )
284
295
  }))
285
296
  );
286
297
 
@@ -1,4 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { SeriesAggregate } from '../_chart/aggregate';
2
3
  /**
3
4
  * Configuration for one of the two independent Y-axes.
4
5
  */
@@ -37,6 +38,20 @@ export type DualAxisSeries = {
37
38
  * `'line'` — line with optional dots drawn over the column layer.
38
39
  */
39
40
  type?: 'column' | 'line';
41
+ /**
42
+ * Shows an aggregate of this series' values beside its legend label (e.g.
43
+ * "Revenue $128K"). Omit (or `'none'`, the default) to show no aggregate;
44
+ * every chart renders unchanged without it.
45
+ */
46
+ aggregate?: SeriesAggregate;
47
+ /**
48
+ * Formatter for this series' legend aggregate. Defaults to the `valueFormat`
49
+ * of whichever axis (`leftAxis`/`rightAxis`) this series' `yAxisIndex`
50
+ * points at -- reusing the same per-axis formatting the chart already has,
51
+ * rather than a parallel mechanism. Set this only when the aggregate needs
52
+ * a different unit than its axis.
53
+ */
54
+ aggregateFormat?: (value: number) => string;
40
55
  };
41
56
  /**
42
57
  * Data passed to `tooltipSnippet` when the user hovers a category.
@@ -14,6 +14,7 @@
14
14
  import { formatNumber, defaultTickFormat } from '../_chart/format';
15
15
  import { measureText } from '../_chart/measure';
16
16
  import { resolvePointLabels } from '../_chart/labels';
17
+ import { formatSeriesAggregate } from '../_chart/aggregate';
17
18
  import type { LegendItem, Point, TooltipAnchor } from '../_chart/types';
18
19
  import { pointerPositionIn, dismissOnOutsidePointerDown } from '../_chart/interactions';
19
20
  import { SvelteSet } from 'svelte/reactivity';
@@ -236,7 +237,12 @@
236
237
  series.map((s, i) => ({
237
238
  label: s.name,
238
239
  color: s.color ?? getColor(i),
239
- hidden: hiddenSeries.has(i)
240
+ hidden: hiddenSeries.has(i),
241
+ aggregateLabel: formatSeriesAggregate(
242
+ s.data.map((d) => d.y),
243
+ s.aggregate ?? 'none',
244
+ s.aggregateFormat ?? yTickFormat ?? formatNumber
245
+ )
240
246
  }))
241
247
  );
242
248
 
@@ -1,6 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { CurveType } from '../_chart/types';
3
3
  import type { ChartHighlightAPI } from '../_chart/highlight';
4
+ import type { SeriesAggregate } from '../_chart/aggregate';
4
5
  export type LineChartDataPoint = {
5
6
  x: number;
6
7
  y: number;
@@ -16,6 +17,19 @@ export type LineChartSeries = {
16
17
  * custom SVG `stroke-dasharray`. Omit for a solid line.
17
18
  */
18
19
  dash?: boolean | string;
20
+ /**
21
+ * Shows an aggregate of this series' `y` values beside its legend label
22
+ * (e.g. "Revenue $128K"). Omit (or `'none'`, the default) to show no
23
+ * aggregate; every chart renders unchanged without it.
24
+ */
25
+ aggregate?: SeriesAggregate;
26
+ /**
27
+ * Formatter for this series' legend aggregate. Defaults to the chart's own
28
+ * `yTickFormat` (or the built-in number formatter) -- set this only when
29
+ * the aggregate needs a different unit than the axis, e.g. a percentage
30
+ * average alongside a count-formatted axis.
31
+ */
32
+ aggregateFormat?: (value: number) => string;
19
33
  };
20
34
  export type LineChartTooltipContext = {
21
35
  x: number;
@@ -8,10 +8,13 @@
8
8
  testId,
9
9
  classes,
10
10
  tableLabel,
11
+ tableWrapperClass,
11
12
  sanitize
12
13
  }: MarkdownTextProperties = $props();
13
14
 
14
- let html = $derived(renderMarkdown(markdown, { breaks, tableLabel, sanitize }));
15
+ let html = $derived(
16
+ renderMarkdown(markdown, { breaks, tableLabel, tableWrapperClass, sanitize })
17
+ );
15
18
  </script>
16
19
 
17
20
  <div
@@ -245,14 +245,32 @@ function annotateExternalLinks(html) {
245
245
  scrollable region with a tabindex. */
246
246
  const TABLE_OPEN = /<table>/g;
247
247
  const TABLE_CLOSE = /<\/table>/g;
248
- function wrapTables(html, label) {
248
+ const DEFAULT_TABLE_WRAPPER_CLASS = 'markdown-table-wrapper';
249
+ function wrapTables(html, label, wrapperClass) {
249
250
  /* `role="region"` without an accessible name announces a landmark the user
250
251
  cannot identify, which is worse than no landmark, so the role appears only
251
252
  when the caller supplies a name. The tabindex does not depend on it:
252
253
  keyboard scrolling should work either way. */
254
+ /* A caller's class is ADDED, never substituted. The wrapper's own styling --
255
+ `overflow-x: auto`, the `:focus-visible` ring, and the table's max-content
256
+ width -- is scoped to `.markdown-table-wrapper` in MarkdownText.svelte, and
257
+ a Svelte scoped selector cannot be templated to a name supplied at runtime.
258
+ Substituting the class would leave a `tabindex="0"` element that cannot
259
+ scroll and shows no focus ring, undoing the fix that made a wide table
260
+ keyboard-scrollable in the first place. Adding costs a consumer nothing:
261
+ their own selector still matches, and they can still override by
262
+ specificity. */
263
+ const custom = typeof wrapperClass === 'string' ? wrapperClass.trim() : '';
264
+ // The caller's value lands inside a double-quoted HTML attribute, same as
265
+ // `label` below -- escaping it the same way is what stops a value like
266
+ // `" onmouseover="alert(1)` from closing the attribute early and injecting a
267
+ // new one. The built-in name is a literal and needs no escaping.
268
+ const classAttr = custom.length > 0
269
+ ? `${DEFAULT_TABLE_WRAPPER_CLASS} ${escapeHtml(custom)}`
270
+ : DEFAULT_TABLE_WRAPPER_CLASS;
253
271
  const open = typeof label === 'string' && label.length > 0
254
- ? `<div class="markdown-table-wrapper" tabindex="0" role="region" aria-label="${escapeHtml(label)}">`
255
- : '<div class="markdown-table-wrapper" tabindex="0">';
272
+ ? `<div class="${classAttr}" tabindex="0" role="region" aria-label="${escapeHtml(label)}">`
273
+ : `<div class="${classAttr}" tabindex="0">`;
256
274
  return html.replace(TABLE_OPEN, `${open}<table>`).replace(TABLE_CLOSE, '</table></div>');
257
275
  }
258
276
  function instanceFor(options) {
@@ -292,5 +310,7 @@ export function renderMarkdown(markdown, options = {}) {
292
310
  }
293
311
  /* Inline parsing produces no block elements, so there is no table to wrap. */
294
312
  const linked = annotateExternalLinks(output);
295
- return options.inline === true ? linked : wrapTables(linked, options.tableLabel);
313
+ return options.inline === true
314
+ ? linked
315
+ : wrapTables(linked, options.tableLabel, options.tableWrapperClass);
296
316
  }
@@ -20,6 +20,15 @@ export type OptionalMarkdownTextProperties = {
20
20
  * worse than none.
21
21
  */
22
22
  tableLabel?: string;
23
+ /**
24
+ * Extra class name added to the scrollable region wrapping each table,
25
+ * alongside the built-in `markdown-table-wrapper` rather than replacing it
26
+ * (the wrapper's scroll/focus behaviour is scoped to that class and cannot
27
+ * be retargeted). Lets a consumer whose own stylesheet targets a different
28
+ * class keep that styling too. Omitting it, or passing an empty or
29
+ * whitespace-only string, keeps only `markdown-table-wrapper`.
30
+ */
31
+ tableWrapperClass?: string;
23
32
  /**
24
33
  * Narrow which URL protocols survive on rendered links/images. See
25
34
  * `MarkdownSanitizeOptions`.
@@ -104,6 +113,8 @@ export type RenderMarkdownOptions = {
104
113
  inline?: boolean;
105
114
  /** Accessible name for the scroll region wrapping each table. See `MarkdownTextProperties.tableLabel`. */
106
115
  tableLabel?: string;
116
+ /** Class name for the scroll region wrapping each table. See `MarkdownTextProperties.tableWrapperClass`. */
117
+ tableWrapperClass?: string;
107
118
  /** Narrow the protocol allow-list. See `MarkdownSanitizeOptions`. */
108
119
  sanitize?: MarkdownSanitizeOptions;
109
120
  };
@@ -51,7 +51,8 @@
51
51
  summaryRowIndex = null,
52
52
  headerTooltipIcon,
53
53
  headerTooltipPosition,
54
- usePortal = false
54
+ usePortal = false,
55
+ labels
55
56
  }: TableProperties = $props();
56
57
 
57
58
  // ─── Keyed column model → positional projection ─────────────────────────────
@@ -646,7 +647,7 @@
646
647
  class="table-search-clear"
647
648
  type="button"
648
649
  onclick={clearSearch}
649
- aria-label="Clear search"
650
+ aria-label={labels?.clearSearch ?? 'Clear search'}
650
651
  >
651
652
  <!-- eslint-disable svelte/no-at-html-tags -->
652
653
  {@html closeSvg}
@@ -688,7 +689,7 @@
688
689
  so the tri-state is computed here and never flipped locally. -->
689
690
  <Checkbox
690
691
  text=""
691
- ariaLabel="Select all rows"
692
+ ariaLabel={labels?.selectAllRows ?? 'Select all rows'}
692
693
  controlled
693
694
  checked={headerCheckboxState === 'all'}
694
695
  indeterminate={headerCheckboxState === 'some'}
@@ -773,7 +774,7 @@
773
774
  >
774
775
  <Button
775
776
  {...triggerProps}
776
- ariaLabel="Filter by {header}"
777
+ ariaLabel={labels?.filterBy?.(header) ?? `Filter by ${header}`}
777
778
  testId={headerColumn.testId &&
778
779
  `${headerColumn.testId}-filter-trigger`}
779
780
  >
@@ -791,7 +792,10 @@
791
792
  {/if}
792
793
  {#if isColumnSortable(colIndex)}
793
794
  <div class="sort-button">
794
- <Button onclick={() => handleSort(colIndex)} ariaLabel="Sort by {header}">
795
+ <Button
796
+ onclick={() => handleSort(colIndex)}
797
+ ariaLabel={labels?.sortBy?.(header) ?? `Sort by ${header}`}
798
+ >
795
799
  {#if sortColumn === colIndex && sortDirection === 'asc'}
796
800
  {#if typeof sortAscIcon === 'function'}
797
801
  {@render sortAscIcon()}
@@ -840,7 +844,7 @@
840
844
  <Button
841
845
  variant="ghost"
842
846
  iconOnly={true}
843
- ariaLabel="Close search"
847
+ ariaLabel={labels?.closeSearch ?? 'Close search'}
844
848
  onclick={clearSearch}
845
849
  >
846
850
  {#snippet icon()}
@@ -921,7 +925,8 @@
921
925
  >
922
926
  <Checkbox
923
927
  text=""
924
- ariaLabel={`Select row ${rowId || 'non-selectable'}`}
928
+ ariaLabel={labels?.selectRow?.(rowId) ??
929
+ `Select row ${rowId || 'non-selectable'}`}
925
930
  controlled
926
931
  checked={rowSelected}
927
932
  disabled={rowDisabled}
@@ -434,7 +434,39 @@ export type TableSearchConfig = {
434
434
  displayMode?: 'toolbar' | 'inline';
435
435
  };
436
436
  export type TableProperties = OptionalTableProperties & TableEventProperties;
437
+ /**
438
+ * Accessible names Table generates for its own controls, for callers who do
439
+ * not ship an English UI.
440
+ *
441
+ * Every member is optional and falls back to the English string Table has
442
+ * always used, so adding this prop changes nothing for existing consumers.
443
+ *
444
+ * The two that depend on a column, and the one that depends on a row, are
445
+ * functions rather than templates with a placeholder: a translator needs to
446
+ * put the header where their grammar wants it, not where an English sentence
447
+ * happens to leave a slot.
448
+ *
449
+ * `Search` is deliberately absent -- `searchConfig.placeholder` already names
450
+ * the search input and has since search shipped.
451
+ */
452
+ export type TableLabels = {
453
+ /** Sort button on a column header. Default: `Sort by ${header}`. */
454
+ sortBy?: (header: string) => string;
455
+ /** Filter trigger on a column header. Default: `Filter by ${header}`. */
456
+ filterBy?: (header: string) => string;
457
+ /** Per-row selection checkbox. Receives the row id, or `''` when the row
458
+ * cannot be selected -- the default spells that case out rather than
459
+ * naming an empty row. Default: `Select row ${rowId || 'non-selectable'}`. */
460
+ selectRow?: (rowId: string) => string;
461
+ /** Header checkbox that selects every row on the page. Default: `Select all rows`. */
462
+ selectAllRows?: string;
463
+ /** Button that empties the search box. Default: `Clear search`. */
464
+ clearSearch?: string;
465
+ /** Button that dismisses the search box. Default: `Close search`. */
466
+ closeSearch?: string;
467
+ };
437
468
  export type OptionalTableProperties = {
469
+ labels?: TableLabels;
438
470
  tableTitle?: string | null;
439
471
  tableHeaders?: string[];
440
472
  tableData?: Array<JSONValue[]>;
@@ -11,22 +11,38 @@
11
11
  {:else}
12
12
  {#each items as item, i (i)}
13
13
  {#if typeof onToggle === 'function'}
14
- <button
15
- type="button"
16
- class="legend-item legend-toggle"
17
- class:legend-hidden={item.hidden}
18
- aria-pressed={!item.hidden}
19
- onclick={() => onToggle(i)}
20
- data-pw={`legend-toggle-${i}`}
21
- testID={`legend-toggle-${i}`}
22
- >
23
- <span class="legend-swatch" style="background: {item.color}"></span>
24
- <span class="legend-label">{item.label}</span>
25
- </button>
14
+ <!-- The aggregate sits OUTSIDE the button deliberately. Every bit of
15
+ text inside a button becomes part of its accessible name, so an
16
+ aggregate inside it would make the control's name change whenever
17
+ the data does -- "Revenue $6,500" today, something else tomorrow.
18
+ A name that moves breaks voice-control targeting and any consumer
19
+ test matching it exactly, and announces a figure as the control's
20
+ identity rather than as information about the series. It also
21
+ stops a number being a click target for toggling a series, which
22
+ it never should have been. -->
23
+ <div class="legend-item" class:legend-hidden={item.hidden}>
24
+ <button
25
+ type="button"
26
+ class="legend-toggle"
27
+ aria-pressed={!item.hidden}
28
+ onclick={() => onToggle(i)}
29
+ data-pw={`legend-toggle-${i}`}
30
+ testID={`legend-toggle-${i}`}
31
+ >
32
+ <span class="legend-swatch" style="background: {item.color}"></span>
33
+ <span class="legend-label">{item.label}</span>
34
+ </button>
35
+ {#if item.aggregateLabel}
36
+ <span class="legend-aggregate">{item.aggregateLabel}</span>
37
+ {/if}
38
+ </div>
26
39
  {:else}
27
40
  <div class="legend-item">
28
41
  <span class="legend-swatch" style="background: {item.color}"></span>
29
42
  <span class="legend-label">{item.label}</span>
43
+ {#if item.aggregateLabel}
44
+ <span class="legend-aggregate">{item.aggregateLabel}</span>
45
+ {/if}
30
46
  </div>
31
47
  {/if}
32
48
  {/each}
@@ -72,7 +88,18 @@
72
88
  color: var(--chart-legend-color, light-dark(#333, #e5e7eb));
73
89
  }
74
90
 
91
+ .legend-aggregate {
92
+ font-size: var(--chart-legend-font-size, 12px);
93
+ font-weight: 600;
94
+ color: var(--chart-legend-aggregate-color, light-dark(#111, #f5f5f5));
95
+ }
96
+
75
97
  .legend-toggle {
98
+ /* The button no longer IS the row -- it holds the swatch and label, and the
99
+ aggregate is its sibling -- so it carries its own flex layout. */
100
+ display: flex;
101
+ align-items: center;
102
+ gap: 6px;
76
103
  background: none;
77
104
  border: none;
78
105
  padding: 0;
@@ -85,7 +112,8 @@
85
112
  opacity: 0.25;
86
113
  }
87
114
 
88
- .legend-hidden .legend-label {
115
+ .legend-hidden .legend-label,
116
+ .legend-hidden .legend-aggregate {
89
117
  color: var(--chart-legend-hidden-color, light-dark(#bbb, #555));
90
118
  }
91
119
  </style>
@@ -0,0 +1,35 @@
1
+ /**
2
+ * How a chart legend summarises a series' values into one number shown
3
+ * beside its label. `'none'` (the default for every series that doesn't set
4
+ * this) shows nothing -- the feature is purely additive.
5
+ */
6
+ export type SeriesAggregate = 'sum' | 'average' | 'min' | 'max' | 'none';
7
+ /**
8
+ * Reduces a series' values to a single number for its legend entry.
9
+ *
10
+ * Null and non-finite entries (a missing point, or `NaN`/`Infinity` slipping
11
+ * through past the `number` type at runtime) are treated as ABSENT rather
12
+ * than as zero -- they are skipped entirely, not summed/counted as 0. This
13
+ * only changes the result for `average` (skipping shrinks the denominator)
14
+ * and `min`/`max` (an absent point can never set an extreme); `sum` is
15
+ * identical either way since 0 is the additive identity.
16
+ *
17
+ * Zero remaining values after skipping -- an empty series, or one whose
18
+ * points are all absent -- resolves to `null` for EVERY kind, `sum`
19
+ * included: a "total" of 0 for a series with no real data would be a
20
+ * fabricated number, not an observed one, so the legend renders nothing
21
+ * rather than a misleading 0.
22
+ *
23
+ * Aggregation is always over one series' own values, never across a stack --
24
+ * a stacked bar/area chart's per-category stack total is an unrelated,
25
+ * existing computation (the y-domain sum) that this function does not touch.
26
+ */
27
+ export declare function computeSeriesAggregate(values: ReadonlyArray<number | null>, aggregate: SeriesAggregate): number | null;
28
+ /**
29
+ * Computes and formats a series aggregate for legend display in one step,
30
+ * reusing the chart's own value formatter (the same one driving its
31
+ * axis/tooltip) rather than a parallel formatting path. Returns `null` --
32
+ * render nothing -- whenever there is no aggregate to show, so the formatter
33
+ * is never handed `NaN` and a caller never needs its own null guard.
34
+ */
35
+ export declare function formatSeriesAggregate(values: ReadonlyArray<number | null>, aggregate: SeriesAggregate, format: (value: number) => string): string | null;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Reduces a series' values to a single number for its legend entry.
3
+ *
4
+ * Null and non-finite entries (a missing point, or `NaN`/`Infinity` slipping
5
+ * through past the `number` type at runtime) are treated as ABSENT rather
6
+ * than as zero -- they are skipped entirely, not summed/counted as 0. This
7
+ * only changes the result for `average` (skipping shrinks the denominator)
8
+ * and `min`/`max` (an absent point can never set an extreme); `sum` is
9
+ * identical either way since 0 is the additive identity.
10
+ *
11
+ * Zero remaining values after skipping -- an empty series, or one whose
12
+ * points are all absent -- resolves to `null` for EVERY kind, `sum`
13
+ * included: a "total" of 0 for a series with no real data would be a
14
+ * fabricated number, not an observed one, so the legend renders nothing
15
+ * rather than a misleading 0.
16
+ *
17
+ * Aggregation is always over one series' own values, never across a stack --
18
+ * a stacked bar/area chart's per-category stack total is an unrelated,
19
+ * existing computation (the y-domain sum) that this function does not touch.
20
+ */
21
+ export function computeSeriesAggregate(values, aggregate) {
22
+ if (aggregate === 'none') {
23
+ return null;
24
+ }
25
+ let sum = 0;
26
+ let count = 0;
27
+ let min = Infinity;
28
+ let max = -Infinity;
29
+ for (const value of values) {
30
+ if (value === null || !Number.isFinite(value)) {
31
+ continue;
32
+ }
33
+ sum += value;
34
+ count += 1;
35
+ if (value < min) {
36
+ min = value;
37
+ }
38
+ if (value > max) {
39
+ max = value;
40
+ }
41
+ }
42
+ if (count === 0) {
43
+ return null;
44
+ }
45
+ switch (aggregate) {
46
+ case 'sum':
47
+ return sum;
48
+ case 'average':
49
+ return sum / count;
50
+ case 'min':
51
+ return min;
52
+ case 'max':
53
+ return max;
54
+ default:
55
+ return null;
56
+ }
57
+ }
58
+ /**
59
+ * Computes and formats a series aggregate for legend display in one step,
60
+ * reusing the chart's own value formatter (the same one driving its
61
+ * axis/tooltip) rather than a parallel formatting path. Returns `null` --
62
+ * render nothing -- whenever there is no aggregate to show, so the formatter
63
+ * is never handed `NaN` and a caller never needs its own null guard.
64
+ */
65
+ export function formatSeriesAggregate(values, aggregate, format) {
66
+ const value = computeSeriesAggregate(values, aggregate);
67
+ return value === null ? null : format(value);
68
+ }