@juspay/svelte-ui-components 2.86.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.
- package/dist/AreaChart/AreaChart.svelte +229 -131
- package/dist/AreaChart/properties.d.ts +6 -0
- package/dist/BarChart/BarChart.svelte +421 -288
- package/dist/BarChart/properties.d.ts +6 -0
- package/dist/DualAxisBarChart/DualAxisBarChart.svelte +339 -222
- package/dist/DualAxisBarChart/properties.d.ts +4 -0
- package/dist/FunnelChart/FunnelChart.svelte +182 -83
- package/dist/FunnelChart/properties.d.ts +2 -0
- package/dist/LineChart/LineChart.svelte +396 -210
- package/dist/LineChart/properties.d.ts +9 -0
- package/dist/_chart/Axis.svelte +32 -15
- package/dist/_chart/ChartTooltip.svelte +127 -49
- package/dist/_chart/Legend.svelte +36 -6
- package/dist/_chart/geometry.d.ts +28 -18
- package/dist/_chart/geometry.js +47 -42
- package/dist/_chart/interactions.d.ts +20 -0
- package/dist/_chart/interactions.js +34 -0
- package/dist/_chart/labels.d.ts +76 -0
- package/dist/_chart/labels.js +213 -0
- package/dist/_chart/measure.d.ts +12 -0
- package/dist/_chart/measure.js +46 -0
- package/dist/_chart/scales.d.ts +1 -1
- package/dist/_chart/scales.js +7 -2
- package/dist/_chart/tooltipPosition.d.ts +22 -0
- package/dist/_chart/tooltipPosition.js +49 -0
- package/dist/_chart/types.d.ts +29 -1
- package/package.json +1 -1
|
@@ -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 -
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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:
|
|
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
|
-
<
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
{
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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-
|
|
321
|
+
class="funnel-category-label"
|
|
274
322
|
x={labelX}
|
|
275
|
-
y={
|
|
323
|
+
y={labelAreaHeight - 6}
|
|
276
324
|
text-anchor="middle"
|
|
277
|
-
dominant-baseline="
|
|
278
|
-
pointer-events="none">{formatLabel(stage)}</text
|
|
325
|
+
dominant-baseline="auto">{categoryLabel(stage)}</text
|
|
279
326
|
>
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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. */
|