@juspay/svelte-ui-components 4.21.0 → 4.23.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
@@ -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;
@@ -1188,11 +1188,18 @@
1188
1188
  }
1189
1189
 
1190
1190
  /* ── Container ──────────────────────────────────────────────────────────── */
1191
+ /* `hidden` is the default because it is what clips the table's corners to
1192
+ this element's border-radius; without it the first and last cells square
1193
+ off over the rounded border. A consumer who needs the container itself to
1194
+ scroll sideways sets this to `auto` and accepts that trade. Note the
1195
+ scrollable variant below sets `overflow-y`, and per the CSS overflow spec
1196
+ a `visible` on one axis computes to `auto` when the other is not visible,
1197
+ so `visible` here does not survive on a scrollable table. */
1191
1198
  .table-container {
1192
1199
  border: var(--table-border, 1px solid #e5e7eb);
1193
1200
  border-radius: var(--table-border-radius, var(--radius, 4px));
1194
1201
  width: var(--table-container-width, 100%);
1195
- overflow: hidden;
1202
+ overflow: var(--table-container-overflow, hidden);
1196
1203
  }
1197
1204
 
1198
1205
  .scrollable-table {
@@ -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
+ }
@@ -126,6 +126,14 @@ export type LegendItem = {
126
126
  color: string;
127
127
  /** True when the series is toggled off via an interactive legend. */
128
128
  hidden?: boolean;
129
+ /**
130
+ * Formatted per-series aggregate (e.g. `"$1.2K"`, `"45%"`) shown beside the
131
+ * label. `null`/omitted renders nothing -- both the "no `aggregate` was
132
+ * configured for this series" and "the series had no data to aggregate"
133
+ * cases resolve here, via `computeSeriesAggregate`/`formatSeriesAggregate`
134
+ * in `_chart/aggregate.ts`.
135
+ */
136
+ aggregateLabel?: string | null;
129
137
  };
130
138
  /** Data-space anchor for point/category-anchored tooltips (coords are container px). */
131
139
  export type TooltipAnchor = {