@juspay/svelte-ui-components 2.85.0 → 2.87.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.
@@ -133,6 +133,10 @@ export type OptionalDualAxisBarChartProperties = {
133
133
  * tooltip. Default `false` (in-chart positioning, unchanged).
134
134
  */
135
135
  tooltipPortal?: boolean;
136
+ /** Legend items become click/keyboard toggles for series visibility. */
137
+ interactiveLegend?: boolean;
138
+ /** Hide the legend when the measured chart width is below this px value; 0 disables. */
139
+ hideLegendBelow?: number;
136
140
  };
137
141
  export type DualAxisBarChartEventProperties = {
138
142
  /**
@@ -1,9 +1,13 @@
1
1
  <script lang="ts">
2
+ import { onMount } from 'svelte';
2
3
  import type { FunnelChartProperties, FunnelStage } from './properties';
3
4
  import ChartContainer from '../_chart/ChartContainer.svelte';
4
5
  import ChartTooltip from '../_chart/ChartTooltip.svelte';
5
- import { getColor } from '../_chart/colors';
6
+ import { getColor, getContrastColor } from '../_chart/colors';
6
7
  import { formatNumber, formatPercent } from '../_chart/format';
8
+ import { measureText, readCssVarPx } from '../_chart/measure';
9
+ import { truncateToWidth } from '../_chart/labels';
10
+ import { pointerPositionIn, dismissOnOutsidePointerDown } from '../_chart/interactions';
7
11
  import { DEFAULT_CHART_CORNER_RADIUS, DEFAULT_CHART_MAX_HEIGHT } from '../_chart/types';
8
12
 
9
13
  // ── Props ──────────────────────────────────────────────────────
@@ -11,7 +15,7 @@
11
15
  let {
12
16
  data,
13
17
  stageColors,
14
- connectorColor = 'var(--funnel-chart-connector-color, #BDFFFB)',
18
+ connectorColor = 'var(--funnel-chart-connector-color, light-dark(#BDFFFB, #164e4a))',
15
19
  slopeWidth = 10,
16
20
  onHoverExpand = 10,
17
21
  showValueLabels = true,
@@ -20,6 +24,7 @@
20
24
  maxHeight = DEFAULT_CHART_MAX_HEIGHT,
21
25
  minHeight = 0,
22
26
  radius = DEFAULT_CHART_CORNER_RADIUS,
27
+ tooltipPortal = false,
23
28
  testId,
24
29
  classes,
25
30
  empty,
@@ -30,11 +35,19 @@
30
35
  // ── State ──────────────────────────────────────────────────────
31
36
 
32
37
  let containerEl: HTMLDivElement | null = $state(null);
38
+ let plotEl: HTMLDivElement | null = $state(null);
33
39
  let chartWidth = $state(0);
34
40
  let chartHeight = $state(0);
35
41
  let hoveredIndex = $state<number | null>(null);
36
42
  let mouseX = $state(0);
37
43
  let mouseY = $state(0);
44
+ let labelFontSize = $state(11);
45
+ let valueFontSize = $state(11);
46
+
47
+ onMount(() => {
48
+ labelFontSize = readCssVarPx(containerEl, '--funnel-chart-label-font-size', 11);
49
+ valueFontSize = readCssVarPx(containerEl, '--funnel-chart-value-font-size', 11);
50
+ });
38
51
 
39
52
  // ── Derived geometry ───────────────────────────────────────────
40
53
 
@@ -42,7 +55,6 @@
42
55
  const MARGIN_RIGHT = 8;
43
56
  const MARGIN_BOTTOM = 8;
44
57
  const MARGIN_LEFT = 8;
45
- const LABEL_AREA_HEIGHT = 24;
46
58
 
47
59
  let innerWidth = $derived(Math.max(0, chartWidth - MARGIN_LEFT - MARGIN_RIGHT));
48
60
  let innerHeight = $derived(Math.max(0, chartHeight - MARGIN_TOP - MARGIN_BOTTOM));
@@ -79,8 +91,11 @@
79
91
  data.length === 0 ? 0 : Math.max(1, (innerWidth - totalSlopeSpace) / data.length)
80
92
  );
81
93
 
94
+ /** Vertical band reserved above the bars for the category label, sized to the actual font. */
95
+ let labelAreaHeight = $derived(Math.ceil(labelFontSize * 1.2) + 10);
96
+
82
97
  /** Available height for the bars themselves (below the category labels). */
83
- let barAreaHeight = $derived(Math.max(0, innerHeight - LABEL_AREA_HEIGHT));
98
+ let barAreaHeight = $derived(Math.max(0, innerHeight - labelAreaHeight));
84
99
 
85
100
  /**
86
101
  * Resolve the fill color for a stage. Uses explicitly-provided `stageColors` array
@@ -117,7 +132,7 @@
117
132
  */
118
133
  function barY(stageValue: number, expandPixels: number = 0): number {
119
134
  const h = barHeight(stageValue, expandPixels);
120
- return LABEL_AREA_HEIGHT + (barAreaHeight - h) / 2;
135
+ return labelAreaHeight + (barAreaHeight - h) / 2;
121
136
  }
122
137
 
123
138
  /**
@@ -158,6 +173,33 @@
158
173
  return `${formatNumber(stage.value)} | ${pct}`;
159
174
  }
160
175
 
176
+ // ── Label fitting ──────────────────────────────────────────────
177
+
178
+ let labelFont = $derived({ size: labelFontSize });
179
+ let valueFont = $derived({ size: valueFontSize });
180
+
181
+ function categoryLabel(stage: FunnelStage): string {
182
+ return truncateToWidth(stage.category, Math.max(0, stageColumnWidth - 4), labelFont);
183
+ }
184
+
185
+ /** Highcharts crop chain for in-bar labels: full "value | pct" → "pct" → hidden. */
186
+ function valueLabel(stage: FunnelStage, bh: number): string {
187
+ const full = formatLabel(stage);
188
+ const fullSize = measureText(full, valueFont);
189
+ if (bh >= fullSize.height + 4 && stageColumnWidth >= fullSize.width + 8) {
190
+ return full;
191
+ }
192
+ const compact = formatPercent(stage.value, maxValue);
193
+ const compactSize = measureText(compact, valueFont);
194
+ if (bh >= compactSize.height + 4 && stageColumnWidth >= compactSize.width + 8) {
195
+ return compact;
196
+ }
197
+ return '';
198
+ }
199
+
200
+ const contrastOutline = (fill: string): string =>
201
+ getContrastColor(fill) === '#000000' ? '#ffffff' : '#000000';
202
+
161
203
  // ── Tooltip ────────────────────────────────────────────────────
162
204
 
163
205
  let tooltipData = $derived.by(() => {
@@ -182,16 +224,20 @@
182
224
 
183
225
  // ── Interaction ────────────────────────────────────────────────
184
226
 
185
- function trackMouse(event: MouseEvent) {
186
- if (containerEl === null) {
187
- return;
227
+ // Narrows an event's currentTarget to Element without an `as` cast (repo
228
+ // lint bans type assertions outside test files).
229
+ const targetElement = (e: Event): Element | null =>
230
+ e.currentTarget instanceof Element ? e.currentTarget : null;
231
+
232
+ function trackMouse(event: PointerEvent) {
233
+ const position = pointerPositionIn(plotEl, event);
234
+ if (position !== null) {
235
+ mouseX = position.x;
236
+ mouseY = position.y;
188
237
  }
189
- const rect = containerEl.getBoundingClientRect();
190
- mouseX = event.clientX - rect.left;
191
- mouseY = event.clientY - rect.top;
192
238
  }
193
239
 
194
- function handleEnter(event: MouseEvent, index: number) {
240
+ function handleEnter(event: PointerEvent, index: number) {
195
241
  hoveredIndex = index;
196
242
  trackMouse(event);
197
243
  const stage = data[index] ?? null;
@@ -200,11 +246,42 @@
200
246
  }
201
247
  }
202
248
 
249
+ function handleFocus(e: FocusEvent, index: number) {
250
+ hoveredIndex = index;
251
+ const el = targetElement(e);
252
+ if (plotEl !== null && el !== null) {
253
+ const r = el.getBoundingClientRect();
254
+ const c = plotEl.getBoundingClientRect();
255
+ mouseX = r.left + r.width / 2 - c.left;
256
+ mouseY = r.top - c.top;
257
+ }
258
+ const stage = data[index] ?? null;
259
+ if (stage !== null) {
260
+ onstagehover?.({ index, stage });
261
+ }
262
+ }
263
+
203
264
  function handleLeave() {
204
265
  hoveredIndex = null;
205
266
  onstagehover?.(null);
206
267
  }
207
268
 
269
+ function handleKeydown(e: KeyboardEvent, index: number) {
270
+ if (e.key === 'Enter' || e.key === ' ') {
271
+ e.preventDefault();
272
+ handleClick(index);
273
+ }
274
+ }
275
+
276
+ // Touch taps have no pointerleave: dismiss when a pointerdown lands outside.
277
+ // eslint-disable-next-line no-restricted-syntax
278
+ $effect(() => {
279
+ if (hoveredIndex === null) {
280
+ return;
281
+ }
282
+ return dismissOnOutsidePointerDown(containerEl, handleLeave);
283
+ });
284
+
208
285
  function handleClick(index: number) {
209
286
  const stage = data[index] ?? null;
210
287
  if (stage !== null) {
@@ -221,91 +298,105 @@
221
298
  {#if isEmpty && typeof empty === 'function'}
222
299
  <div class="chart-empty">{@render empty()}</div>
223
300
  {:else}
224
- <ChartContainer
225
- bind:width={chartWidth}
226
- bind:height={chartHeight}
227
- {aspectRatio}
228
- {maxHeight}
229
- {minHeight}
230
- >
231
- <g transform="translate({MARGIN_LEFT}, {MARGIN_TOP})">
232
- <!-- Stage bars and category labels -->
233
- {#each data as stage, index (index)}
234
- {@const expand = hoveredIndex === index ? onHoverExpand : 0}
235
- {@const bh = barHeight(stage.value, expand)}
236
- {@const by = barY(stage.value, expand)}
237
- {@const bx = stageX(index)}
238
- {@const color = resolveStageColor(index)}
239
- {@const labelX = bx + stageColumnWidth / 2}
240
-
241
- <!-- Category label above the bar -->
242
- <text
243
- class="funnel-category-label"
244
- x={labelX}
245
- y={LABEL_AREA_HEIGHT - 6}
246
- text-anchor="middle"
247
- dominant-baseline="auto">{stage.category}</text
248
- >
249
-
250
- <!-- Stage bar -->
251
- <!-- svelte-ignore a11y_no_static_element_interactions -->
252
- <!-- svelte-ignore a11y_click_events_have_key_events -->
253
- <rect
254
- class="funnel-bar"
255
- class:funnel-bar-hovered={hoveredIndex === index}
256
- class:funnel-bar-dimmed={hoveredIndex !== null && hoveredIndex !== index}
257
- x={bx}
258
- y={by}
259
- width={stageColumnWidth}
260
- height={bh}
261
- fill={color}
262
- rx={radius}
263
- aria-label="{stage.category}: {formatLabel(stage)}"
264
- onmouseenter={(event) => handleEnter(event, index)}
265
- onmousemove={trackMouse}
266
- onmouseleave={handleLeave}
267
- onclick={() => handleClick(index)}
268
- />
269
-
270
- <!-- Value label centred inside the bar -->
271
- {#if showValueLabels}
301
+ <div class="chart-plot" bind:this={plotEl}>
302
+ <ChartContainer
303
+ bind:width={chartWidth}
304
+ bind:height={chartHeight}
305
+ {aspectRatio}
306
+ {maxHeight}
307
+ {minHeight}
308
+ >
309
+ <g transform="translate({MARGIN_LEFT}, {MARGIN_TOP})">
310
+ <!-- Stage bars and category labels -->
311
+ {#each data as stage, index (index)}
312
+ {@const expand = hoveredIndex === index ? onHoverExpand : 0}
313
+ {@const bh = barHeight(stage.value, expand)}
314
+ {@const by = barY(stage.value, expand)}
315
+ {@const bx = stageX(index)}
316
+ {@const color = resolveStageColor(index)}
317
+ {@const labelX = bx + stageColumnWidth / 2}
318
+
319
+ <!-- Category label above the bar -->
272
320
  <text
273
- class="funnel-value-label"
321
+ class="funnel-category-label"
274
322
  x={labelX}
275
- y={by + bh / 2}
323
+ y={labelAreaHeight - 6}
276
324
  text-anchor="middle"
277
- dominant-baseline="middle"
278
- pointer-events="none">{formatLabel(stage)}</text
325
+ dominant-baseline="auto">{categoryLabel(stage)}</text
279
326
  >
280
- {/if}
281
- {/each}
282
-
283
- <!-- Trapezoidal connectors between stages -->
284
- {#each data as _stage, index (index)}
285
- {#if index < data.length - 1}
286
- <polygon
287
- class="funnel-connector"
288
- points={connectorPoints(index)}
289
- fill={connectorColor}
290
- pointer-events="none"
327
+
328
+ <!-- Stage bar -->
329
+ <rect
330
+ class="funnel-bar"
331
+ class:funnel-bar-hovered={hoveredIndex === index}
332
+ class:funnel-bar-dimmed={hoveredIndex !== null && hoveredIndex !== index}
333
+ x={bx}
334
+ y={by}
335
+ width={stageColumnWidth}
336
+ height={bh}
337
+ fill={color}
338
+ rx={radius}
339
+ aria-label="{stage.category}: {formatLabel(stage)}"
340
+ onpointerenter={(event) => handleEnter(event, index)}
341
+ onpointermove={trackMouse}
342
+ onpointerleave={handleLeave}
343
+ onfocus={(e) => handleFocus(e, index)}
344
+ onblur={handleLeave}
345
+ onkeydown={(e) => handleKeydown(e, index)}
346
+ onclick={() => handleClick(index)}
347
+ tabindex="0"
348
+ role="button"
291
349
  />
292
- {/if}
293
- {/each}
294
- </g>
295
- </ChartContainer>
296
350
 
297
- <ChartTooltip data={tooltipData} {mouseX} {mouseY} />
351
+ <!-- Value label centred inside the bar -->
352
+ {#if showValueLabels}
353
+ {@const vl = valueLabel(stage, bh)}
354
+ {#if vl !== ''}
355
+ <text
356
+ class="funnel-value-label"
357
+ x={labelX}
358
+ y={by + bh / 2}
359
+ text-anchor="middle"
360
+ dominant-baseline="middle"
361
+ style="fill: var(--funnel-chart-value-color, {getContrastColor(
362
+ color
363
+ )}); stroke: {contrastOutline(color)};"
364
+ pointer-events="none">{vl}</text
365
+ >
366
+ {/if}
367
+ {/if}
368
+ {/each}
369
+
370
+ <!-- Trapezoidal connectors between stages -->
371
+ {#each data as _stage, index (index)}
372
+ {#if index < data.length - 1}
373
+ <polygon
374
+ class="funnel-connector"
375
+ points={connectorPoints(index)}
376
+ style="fill: {connectorColor}"
377
+ pointer-events="none"
378
+ />
379
+ {/if}
380
+ {/each}
381
+ </g>
382
+ </ChartContainer>
383
+
384
+ <ChartTooltip data={tooltipData} {mouseX} {mouseY} portal={tooltipPortal} originEl={plotEl} />
385
+ </div>
298
386
  {/if}
299
387
  </div>
300
388
 
301
389
  <style>
302
390
  .funnel-chart {
303
391
  width: 100%;
392
+ }
393
+
394
+ .chart-plot {
304
395
  position: relative;
305
396
  }
306
397
 
307
398
  .funnel-category-label {
308
- fill: var(--funnel-chart-label-color, #666);
399
+ fill: var(--funnel-chart-label-color, light-dark(#666, #9ca3af));
309
400
  font-size: var(--funnel-chart-label-font-size, 11px);
310
401
  font-family: var(--chart-font-family, inherit);
311
402
  pointer-events: none;
@@ -327,10 +418,18 @@
327
418
  opacity: var(--funnel-chart-bar-dimmed-opacity, 0.35);
328
419
  }
329
420
 
421
+ .funnel-bar:focus-visible {
422
+ outline: 2px solid var(--chart-axis-label-color, light-dark(#333, #e5e7eb));
423
+ outline-offset: 1px;
424
+ }
425
+
330
426
  .funnel-value-label {
331
- fill: var(--funnel-chart-value-color, #fff);
332
427
  font-size: var(--funnel-chart-value-font-size, 11px);
333
428
  font-family: var(--chart-font-family, inherit);
429
+ paint-order: stroke;
430
+ stroke-width: 2px;
431
+ stroke-opacity: 0.35;
432
+ stroke-linejoin: round;
334
433
  }
335
434
 
336
435
  .funnel-connector {
@@ -339,7 +438,7 @@
339
438
 
340
439
  .chart-empty {
341
440
  padding: var(--chart-empty-padding, 32px 24px);
342
- color: var(--chart-empty-color, #9ca3af);
441
+ color: var(--chart-empty-color, light-dark(#9ca3af, #6b7280));
343
442
  text-align: center;
344
443
  }
345
444
  </style>
@@ -62,6 +62,8 @@ export type OptionalFunnelChartProperties = {
62
62
  maxHeight?: number;
63
63
  /** Lower bound (px) on the rendered chart height (defaults to `0`). */
64
64
  minHeight?: number;
65
+ /** Render the tooltip into document.body (position:fixed) so scroll/overflow ancestors never clip it. */
66
+ tooltipPortal?: boolean;
65
67
  /** Value for the `data-pw` attribute on the chart root element. */
66
68
  testId?: string;
67
69
  /** CSS class string applied to the chart root element. Useful for CSS-variable theming. */