@juspay/svelte-ui-components 4.14.0 → 4.15.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.
@@ -40,6 +40,9 @@
40
40
  classes,
41
41
  semiCircle = false,
42
42
  legendShowValues = false,
43
+ legendPosition = 'bottom',
44
+ legendMaxItems,
45
+ onlegendmore,
43
46
  percentDecimals = 0,
44
47
  onchartready,
45
48
  highlightedIndex = null,
@@ -49,6 +52,7 @@
49
52
 
50
53
  // ── State ──────────────────────────────────────────────────────
51
54
 
55
+ let legendExpanded = $state(false);
52
56
  let containerEl: HTMLDivElement | null = $state(null);
53
57
  let chartWidth = $state(0);
54
58
  let chartHeight = $state(0);
@@ -153,6 +157,57 @@
153
157
  });
154
158
  });
155
159
 
160
+ /* A cap only counts when it actually hides something: a legendMaxItems of 5
161
+ over 5 rows must render no control, since a control that reveals nothing
162
+ is worse than none. Non-positive and non-finite values are ignored rather
163
+ than clamping the list to empty -- a plain-JS or web-component caller can
164
+ hand this anything. */
165
+ const legendCap = $derived(
166
+ typeof legendMaxItems === 'number' && Number.isFinite(legendMaxItems) && legendMaxItems > 0
167
+ ? Math.max(1, Math.floor(legendMaxItems))
168
+ : null
169
+ );
170
+ /* A changed list or cap starts a new collapsed view, not a stale expansion.
171
+ This has to be an effect rather than a derived key: collapsing depends on
172
+ the TRANSITION, not on the current values. A list that shrinks below the
173
+ cap and then returns to its former length must stay collapsed, and a key
174
+ built from (cap, length) would match its earlier self and silently
175
+ re-expand -- which is what the regression tests here caught when this was
176
+ written that way. The two values are passed as ordinary arguments so the
177
+ dependencies are real reads rather than `void` expressions. */
178
+ const collapseLegend = (_cap: number | null, _rowCount: number): void => {
179
+ legendExpanded = false;
180
+ };
181
+ // eslint-disable-next-line no-restricted-syntax
182
+ $effect(() => {
183
+ collapseLegend(legendCap, data.length);
184
+ });
185
+ const legendHiddenCount = $derived(legendCap === null ? 0 : Math.max(0, data.length - legendCap));
186
+ const legendRows = $derived(
187
+ legendCap === null || legendExpanded ? data : data.slice(0, legendCap)
188
+ );
189
+
190
+ /* Excludes the empty state: with no rows there is no legend column to sit
191
+ beside, and turning the root into a flex row would make the empty message
192
+ a flex item rather than the full-width block it is documented to be. */
193
+ const legendBesideChart = $derived(
194
+ !isEmpty && showLegend && legendShowValues && legendPosition === 'right'
195
+ );
196
+
197
+ const instanceId = $props.id();
198
+ const legendListId = `pie-legend-${instanceId}`;
199
+
200
+ const handleLegendMore = (): void => {
201
+ /* A consumer opening their own modal must not also get the list expanding
202
+ underneath it, so the callback replaces the built-in behaviour rather
203
+ than firing alongside it. */
204
+ if (typeof onlegendmore === 'function') {
205
+ onlegendmore();
206
+ return;
207
+ }
208
+ legendExpanded = !legendExpanded;
209
+ };
210
+
156
211
  let legendItems = $derived<LegendItem[]>(
157
212
  data.map((d, i) => ({ label: d.label, color: d.color ?? getColor(i) }))
158
213
  );
@@ -290,6 +345,7 @@
290
345
 
291
346
  <div
292
347
  class="pie-chart {classes ?? ''}"
348
+ class:legend-right={legendBesideChart}
293
349
  bind:this={containerEl}
294
350
  data-pw={typeof testId === 'string' ? testId : null}
295
351
  testID={typeof testId === 'string' ? testId : null}
@@ -358,17 +414,30 @@
358
414
  {/if}
359
415
 
360
416
  {#if showLegend && legendShowValues}
361
- <ul class="pie-legend-values">
362
- {#each data as d, i (i)}
363
- <li class="pie-legend-row">
364
- <span class="pie-legend-swatch" style="background: {d.color ?? getColor(i)}"></span>
365
- <span class="pie-legend-label">{d.label}</span>
366
- <span class="pie-legend-value">
367
- {format(d.value)}&nbsp;{pctFormat(d.value)}
368
- </span>
369
- </li>
370
- {/each}
371
- </ul>
417
+ <div class="pie-legend-column">
418
+ <ul class="pie-legend-values" id={legendListId}>
419
+ {#each legendRows as d, i (i)}
420
+ <li class="pie-legend-row">
421
+ <span class="pie-legend-swatch" style="background: {d.color ?? getColor(i)}"></span>
422
+ <span class="pie-legend-label">{d.label}</span>
423
+ <span class="pie-legend-value">
424
+ {format(d.value)}&nbsp;{pctFormat(d.value)}
425
+ </span>
426
+ </li>
427
+ {/each}
428
+ </ul>
429
+ {#if legendHiddenCount > 0}
430
+ <button
431
+ type="button"
432
+ class="pie-legend-more"
433
+ aria-expanded={typeof onlegendmore === 'function' ? null : legendExpanded}
434
+ {...typeof onlegendmore === 'function' ? {} : { 'aria-controls': legendListId }}
435
+ onclick={handleLegendMore}
436
+ >
437
+ {legendExpanded ? 'Show less' : `+${legendHiddenCount} more`}
438
+ </button>
439
+ {/if}
440
+ </div>
372
441
  {/if}
373
442
 
374
443
  {#if typeof tooltipSnippet === 'function' && hoveredIndex !== null && data[hoveredIndex]}
@@ -469,4 +538,60 @@
469
538
  min-width: var(--piechart-legend-value-min-width, 60px);
470
539
  text-align: right;
471
540
  }
541
+
542
+ /* The column wrapper is present in both placements so the markup does not
543
+ fork; as a plain block it changes nothing about the default below-chart
544
+ rendering. */
545
+ .pie-legend-column {
546
+ display: flex;
547
+ flex-direction: column;
548
+ }
549
+
550
+ /* Only the values legend moves beside the chart -- the plain top legend is
551
+ mutually exclusive with it, and the delta badge and tooltip are both
552
+ absolutely positioned, so they stay out of this flex row entirely. */
553
+ .pie-chart.legend-right {
554
+ display: flex;
555
+ flex-direction: row;
556
+ align-items: center;
557
+ gap: var(--piechart-legend-column-gap, 16px);
558
+ }
559
+
560
+ .pie-chart.legend-right :global(.chart-container) {
561
+ /* flex-basis 0 rather than auto: the container carries width:100%, which
562
+ as a flex item would otherwise claim the whole row and squeeze the
563
+ legend to nothing. */
564
+ flex: 1 1 0;
565
+ min-width: 0;
566
+ }
567
+
568
+ .pie-chart.legend-right .pie-legend-column {
569
+ flex: 0 0 auto;
570
+ max-width: var(--piechart-legend-column-max-width, 50%);
571
+ }
572
+
573
+ .pie-chart.legend-right .pie-legend-values {
574
+ padding: var(--piechart-legend-column-padding, 0);
575
+ }
576
+
577
+ .pie-legend-more {
578
+ align-self: flex-start;
579
+ margin-top: var(--piechart-legend-more-margin-top, 8px);
580
+ padding: var(--piechart-legend-more-padding, 2px 4px);
581
+ border: none;
582
+ background: none;
583
+ cursor: var(--cursor, pointer);
584
+ font-size: var(--piechart-legend-more-font-size, 12px);
585
+ font-family: inherit;
586
+ color: var(--piechart-legend-more-color, #2563eb);
587
+ }
588
+
589
+ .pie-legend-more:hover {
590
+ text-decoration: underline;
591
+ }
592
+
593
+ .pie-legend-more:focus-visible {
594
+ outline: var(--piechart-legend-more-focus-outline, 2px solid currentColor);
595
+ outline-offset: 2px;
596
+ }
472
597
  </style>
@@ -5,6 +5,8 @@ export type PieChartSlice = {
5
5
  value: number;
6
6
  color?: string;
7
7
  };
8
+ /** Placement of the `legendShowValues` list. See `legendPosition`. */
9
+ export type PieChartLegendPosition = 'bottom' | 'right';
8
10
  export type PieChartProperties = MandatoryPieChartProperties & OptionalPieChartProperties & PieChartEventProperties;
9
11
  export type MandatoryPieChartProperties = {
10
12
  data: PieChartSlice[];
@@ -33,6 +35,28 @@ export type OptionalPieChartProperties = {
33
35
  classes?: string;
34
36
  semiCircle?: boolean;
35
37
  legendShowValues?: boolean;
38
+ /**
39
+ * Where the `legendShowValues` list sits relative to the chart. `'bottom'`
40
+ * (the default) keeps today's below-chart placement; `'right'` renders it as
41
+ * a column beside the chart, which is the shape a donut legend usually
42
+ * wants once the labels carry values. Only affects the values legend —
43
+ * `showLegend` without `legendShowValues` still renders the plain top
44
+ * legend.
45
+ */
46
+ legendPosition?: PieChartLegendPosition;
47
+ /**
48
+ * Show at most this many legend rows, followed by a `+N more` control. Omit
49
+ * it (the default) to show every row with no control at all. A cap that is
50
+ * not exceeded renders no control either, since it would do nothing.
51
+ */
52
+ legendMaxItems?: number;
53
+ /**
54
+ * Called when the `+N more` control is activated. Providing it SUPPRESSES
55
+ * the built-in in-place expansion, so a consumer opening their own modal or
56
+ * drawer does not have the legend expand underneath it as well. Without it,
57
+ * the control toggles the hidden rows in place.
58
+ */
59
+ onlegendmore?: () => void;
36
60
  percentDecimals?: number;
37
61
  /**
38
62
  * Called once on mount with the imperative highlight API. Pass the returned
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "4.14.0",
3
+ "version": "4.15.0",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",