@juspay/svelte-ui-components 2.75.0 → 2.76.1

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.
@@ -0,0 +1,335 @@
1
+ <script lang="ts">
2
+ import type { FunnelChartProperties, FunnelStage } from './properties';
3
+ import ChartContainer from '../_chart/ChartContainer.svelte';
4
+ import ChartTooltip from '../_chart/ChartTooltip.svelte';
5
+ import { getColor } from '../_chart/colors';
6
+ import { formatNumber, formatPercent } from '../_chart/format';
7
+
8
+ // ── Props ──────────────────────────────────────────────────────
9
+
10
+ let {
11
+ data,
12
+ stageColors,
13
+ connectorColor = 'var(--funnel-chart-connector-color, #BDFFFB)',
14
+ slopeWidth = 10,
15
+ onHoverExpand = 10,
16
+ showValueLabels = true,
17
+ valueFormat,
18
+ aspectRatio = 16 / 9,
19
+ testId,
20
+ classes,
21
+ empty,
22
+ onstageclick,
23
+ onstagehover
24
+ }: FunnelChartProperties = $props();
25
+
26
+ // ── State ──────────────────────────────────────────────────────
27
+
28
+ let containerEl: HTMLDivElement | null = $state(null);
29
+ let chartWidth = $state(0);
30
+ let chartHeight = $state(0);
31
+ let hoveredIndex = $state<number | null>(null);
32
+ let mouseX = $state(0);
33
+ let mouseY = $state(0);
34
+
35
+ // ── Derived geometry ───────────────────────────────────────────
36
+
37
+ const MARGIN_TOP = 32;
38
+ const MARGIN_RIGHT = 8;
39
+ const MARGIN_BOTTOM = 8;
40
+ const MARGIN_LEFT = 8;
41
+ const LABEL_AREA_HEIGHT = 24;
42
+
43
+ let innerWidth = $derived(Math.max(0, chartWidth - MARGIN_LEFT - MARGIN_RIGHT));
44
+ let innerHeight = $derived(Math.max(0, chartHeight - MARGIN_TOP - MARGIN_BOTTOM));
45
+
46
+ let maxValue = $derived.by(() => {
47
+ if (data.length === 0) {
48
+ return 1;
49
+ }
50
+ let max = 0;
51
+ for (const stage of data) {
52
+ if (stage.value > max) {
53
+ max = stage.value;
54
+ }
55
+ }
56
+ return max === 0 ? 1 : max;
57
+ });
58
+
59
+ // The documented contract treats an all-zero dataset as empty (it would otherwise
60
+ // render meaningless min-height bars), so fold that into the empty check.
61
+ let isEmpty = $derived(data.length === 0 || data.every((stage) => stage.value === 0));
62
+
63
+ /**
64
+ * Computes the total horizontal space consumed by slope connectors.
65
+ * There are (data.length - 1) connectors, each slopeWidth wide.
66
+ */
67
+ let totalSlopeSpace = $derived(Math.max(0, data.length - 1) * slopeWidth);
68
+
69
+ /**
70
+ * Width of each stage bar column in SVG user units.
71
+ * All stages share the same column width; the visual narrowing comes from the bar
72
+ * height being proportional to value/max, not from the column width.
73
+ */
74
+ let stageColumnWidth = $derived(
75
+ data.length === 0 ? 0 : Math.max(1, (innerWidth - totalSlopeSpace) / data.length)
76
+ );
77
+
78
+ /** Available height for the bars themselves (below the category labels). */
79
+ let barAreaHeight = $derived(Math.max(0, innerHeight - LABEL_AREA_HEIGHT));
80
+
81
+ /**
82
+ * Resolve the fill color for a stage. Uses explicitly-provided `stageColors` array
83
+ * first, falls back to the shared chart palette via `getColor`.
84
+ */
85
+ function resolveStageColor(index: number): string {
86
+ const explicit = stageColors?.[index];
87
+ if (typeof explicit === 'string' && explicit.length > 0) {
88
+ return explicit;
89
+ }
90
+ return getColor(index);
91
+ }
92
+
93
+ /**
94
+ * Compute the SVG x-offset for the left edge of a stage column (including slope offsets
95
+ * for preceding connectors). Each connector occupies `slopeWidth` units horizontally so
96
+ * consecutive stage rectangles do not overlap.
97
+ */
98
+ function stageX(index: number): number {
99
+ return index * (stageColumnWidth + slopeWidth);
100
+ }
101
+
102
+ /**
103
+ * Bar height for a given stage value, proportional to value/maxValue.
104
+ * Clamped to at least 2px so zero-value stages remain visible.
105
+ */
106
+ function barHeight(stageValue: number, expandPixels: number = 0): number {
107
+ return Math.max(2, (stageValue / maxValue) * barAreaHeight + expandPixels);
108
+ }
109
+
110
+ /**
111
+ * Vertical y-offset that centres the bar vertically within the bar area.
112
+ * `expandPixels` is added symmetrically (half-top, half-bottom) on hover.
113
+ */
114
+ function barY(stageValue: number, expandPixels: number = 0): number {
115
+ const h = barHeight(stageValue, expandPixels);
116
+ return LABEL_AREA_HEIGHT + (barAreaHeight - h) / 2;
117
+ }
118
+
119
+ /**
120
+ * Build an SVG polygon `points` string for the trapezoidal connector between stage
121
+ * `index` and `index + 1`. The trapezoid fills the slopeWidth gap between two adjacent
122
+ * bars, matching the top and bottom edges of both bars.
123
+ */
124
+ function connectorPoints(index: number): string {
125
+ const currentStage = data[index] ?? null;
126
+ const nextStage = data[index + 1] ?? null;
127
+ if (currentStage === null || nextStage === null) {
128
+ return '';
129
+ }
130
+
131
+ const expandCurrent = hoveredIndex === index ? onHoverExpand : 0;
132
+ const expandNext = hoveredIndex === index + 1 ? onHoverExpand : 0;
133
+
134
+ const x1 = stageX(index) + stageColumnWidth;
135
+ const y1Top = barY(currentStage.value, expandCurrent);
136
+ const y1Bot = y1Top + barHeight(currentStage.value, expandCurrent);
137
+
138
+ const x2 = stageX(index + 1);
139
+ const y2Top = barY(nextStage.value, expandNext);
140
+ const y2Bot = y2Top + barHeight(nextStage.value, expandNext);
141
+
142
+ return `${x1},${y1Top} ${x1},${y1Bot} ${x2},${y2Bot} ${x2},${y2Top}`;
143
+ }
144
+
145
+ /**
146
+ * Format the in-bar label. Uses a consumer-supplied `valueFormat` when provided,
147
+ * otherwise renders `"<value> | <pct>%"`.
148
+ */
149
+ function formatLabel(stage: FunnelStage): string {
150
+ if (typeof valueFormat === 'function') {
151
+ return valueFormat(stage.value, maxValue);
152
+ }
153
+ const pct = formatPercent(stage.value, maxValue);
154
+ return `${formatNumber(stage.value)} | ${pct}`;
155
+ }
156
+
157
+ // ── Tooltip ────────────────────────────────────────────────────
158
+
159
+ let tooltipData = $derived.by(() => {
160
+ if (hoveredIndex === null) {
161
+ return null;
162
+ }
163
+ const stage = data[hoveredIndex] ?? null;
164
+ if (stage === null) {
165
+ return null;
166
+ }
167
+ return {
168
+ title: stage.category,
169
+ items: [
170
+ {
171
+ label: stage.category,
172
+ value: formatLabel(stage),
173
+ color: resolveStageColor(hoveredIndex)
174
+ }
175
+ ]
176
+ };
177
+ });
178
+
179
+ // ── Interaction ────────────────────────────────────────────────
180
+
181
+ function trackMouse(event: MouseEvent) {
182
+ if (containerEl === null) {
183
+ return;
184
+ }
185
+ const rect = containerEl.getBoundingClientRect();
186
+ mouseX = event.clientX - rect.left;
187
+ mouseY = event.clientY - rect.top;
188
+ }
189
+
190
+ function handleEnter(event: MouseEvent, index: number) {
191
+ hoveredIndex = index;
192
+ trackMouse(event);
193
+ const stage = data[index] ?? null;
194
+ if (stage !== null) {
195
+ onstagehover?.({ index, stage });
196
+ }
197
+ }
198
+
199
+ function handleLeave() {
200
+ hoveredIndex = null;
201
+ onstagehover?.(null);
202
+ }
203
+
204
+ function handleClick(index: number) {
205
+ const stage = data[index] ?? null;
206
+ if (stage !== null) {
207
+ onstageclick?.({ index, stage });
208
+ }
209
+ }
210
+ </script>
211
+
212
+ <div
213
+ class="funnel-chart {classes ?? ''}"
214
+ bind:this={containerEl}
215
+ data-pw={typeof testId === 'string' ? testId : null}
216
+ >
217
+ {#if isEmpty && typeof empty === 'function'}
218
+ <div class="chart-empty">{@render empty()}</div>
219
+ {:else}
220
+ <ChartContainer bind:width={chartWidth} bind:height={chartHeight} {aspectRatio}>
221
+ <g transform="translate({MARGIN_LEFT}, {MARGIN_TOP})">
222
+ <!-- Stage bars and category labels -->
223
+ {#each data as stage, index (index)}
224
+ {@const expand = hoveredIndex === index ? onHoverExpand : 0}
225
+ {@const bh = barHeight(stage.value, expand)}
226
+ {@const by = barY(stage.value, expand)}
227
+ {@const bx = stageX(index)}
228
+ {@const color = resolveStageColor(index)}
229
+ {@const labelX = bx + stageColumnWidth / 2}
230
+
231
+ <!-- Category label above the bar -->
232
+ <text
233
+ class="funnel-category-label"
234
+ x={labelX}
235
+ y={LABEL_AREA_HEIGHT - 6}
236
+ text-anchor="middle"
237
+ dominant-baseline="auto">{stage.category}</text
238
+ >
239
+
240
+ <!-- Stage bar -->
241
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
242
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
243
+ <rect
244
+ class="funnel-bar"
245
+ class:funnel-bar-hovered={hoveredIndex === index}
246
+ class:funnel-bar-dimmed={hoveredIndex !== null && hoveredIndex !== index}
247
+ x={bx}
248
+ y={by}
249
+ width={stageColumnWidth}
250
+ height={bh}
251
+ fill={color}
252
+ rx={2}
253
+ aria-label="{stage.category}: {formatLabel(stage)}"
254
+ onmouseenter={(event) => handleEnter(event, index)}
255
+ onmousemove={trackMouse}
256
+ onmouseleave={handleLeave}
257
+ onclick={() => handleClick(index)}
258
+ />
259
+
260
+ <!-- Value label centred inside the bar -->
261
+ {#if showValueLabels}
262
+ <text
263
+ class="funnel-value-label"
264
+ x={labelX}
265
+ y={by + bh / 2}
266
+ text-anchor="middle"
267
+ dominant-baseline="middle"
268
+ pointer-events="none">{formatLabel(stage)}</text
269
+ >
270
+ {/if}
271
+ {/each}
272
+
273
+ <!-- Trapezoidal connectors between stages -->
274
+ {#each data as _stage, index (index)}
275
+ {#if index < data.length - 1}
276
+ <polygon
277
+ class="funnel-connector"
278
+ points={connectorPoints(index)}
279
+ fill={connectorColor}
280
+ pointer-events="none"
281
+ />
282
+ {/if}
283
+ {/each}
284
+ </g>
285
+ </ChartContainer>
286
+
287
+ <ChartTooltip data={tooltipData} {mouseX} {mouseY} />
288
+ {/if}
289
+ </div>
290
+
291
+ <style>
292
+ .funnel-chart {
293
+ width: 100%;
294
+ position: relative;
295
+ }
296
+
297
+ .funnel-category-label {
298
+ fill: var(--funnel-chart-label-color, #666);
299
+ font-size: var(--funnel-chart-label-font-size, 11px);
300
+ font-family: var(--chart-font-family, inherit);
301
+ pointer-events: none;
302
+ }
303
+
304
+ .funnel-bar {
305
+ transition:
306
+ opacity var(--chart-transition-duration, 0.2s) ease,
307
+ y var(--chart-transition-duration, 0.2s) ease,
308
+ height var(--chart-transition-duration, 0.2s) ease;
309
+ cursor: pointer;
310
+ }
311
+
312
+ .funnel-bar-hovered {
313
+ opacity: var(--funnel-chart-bar-hover-opacity, 1);
314
+ }
315
+
316
+ .funnel-bar-dimmed {
317
+ opacity: var(--funnel-chart-bar-dimmed-opacity, 0.35);
318
+ }
319
+
320
+ .funnel-value-label {
321
+ fill: var(--funnel-chart-value-color, #fff);
322
+ font-size: var(--funnel-chart-value-font-size, 11px);
323
+ font-family: var(--chart-font-family, inherit);
324
+ }
325
+
326
+ .funnel-connector {
327
+ transition: d var(--chart-transition-duration, 0.2s) ease;
328
+ }
329
+
330
+ .chart-empty {
331
+ padding: var(--chart-empty-padding, 32px 24px);
332
+ color: var(--chart-empty-color, #9ca3af);
333
+ text-align: center;
334
+ }
335
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { FunnelChartProperties } from './properties';
2
+ declare const FunnelChart: import("svelte").Component<FunnelChartProperties, {}, "">;
3
+ type FunnelChart = ReturnType<typeof FunnelChart>;
4
+ export default FunnelChart;
@@ -0,0 +1,69 @@
1
+ import type { Snippet } from 'svelte';
2
+ export type FunnelStage = {
3
+ /** Numeric value for this stage. Used to compute bar heights and percentages. */
4
+ value: number;
5
+ /** Human-readable label displayed above the stage bar. */
6
+ category: string;
7
+ };
8
+ export type FunnelChartProperties = MandatoryFunnelChartProperties & OptionalFunnelChartProperties & FunnelChartEventProperties;
9
+ export type MandatoryFunnelChartProperties = {
10
+ /** Ordered list of funnel stages. The first stage is the widest; each subsequent stage narrows proportionally. */
11
+ data: FunnelStage[];
12
+ };
13
+ export type OptionalFunnelChartProperties = {
14
+ /**
15
+ * Fill color for each stage bar. Index-matched to `data`.
16
+ * Cycles via the shared chart palette for any stages without an explicit entry.
17
+ */
18
+ stageColors?: string[];
19
+ /**
20
+ * Fill color for the trapezoidal connector polygons drawn between consecutive stages.
21
+ * Defaults to a light-teal shared palette neutral.
22
+ */
23
+ connectorColor?: string;
24
+ /**
25
+ * Horizontal width (in SVG user units relative to total inner width) of each
26
+ * trapezoidal slope connector. Larger values produce steeper visual drops between stages.
27
+ * Default is `10`.
28
+ */
29
+ slopeWidth?: number;
30
+ /**
31
+ * Extra vertical pixels added symmetrically to the hovered stage bar (half on each edge).
32
+ * Set to `0` to disable hover expansion. Default is `10`.
33
+ */
34
+ onHoverExpand?: number;
35
+ /**
36
+ * When `true`, renders the value and percentage label centred inside each stage bar.
37
+ * Default is `true`.
38
+ */
39
+ showValueLabels?: boolean;
40
+ /**
41
+ * Custom formatter for the value portion of the in-bar label.
42
+ * Receives the stage value and the maximum value across all stages.
43
+ * The default renders `"<value> | <pct>%"`.
44
+ */
45
+ valueFormat?: (value: number, max: number) => string;
46
+ /**
47
+ * Width-to-height ratio for the chart area.
48
+ * Passed directly to `ChartContainer`. Default is `16 / 9`.
49
+ */
50
+ aspectRatio?: number;
51
+ /** Value for the `data-pw` attribute on the chart root element. */
52
+ testId?: string;
53
+ /** CSS class string applied to the chart root element. Useful for CSS-variable theming. */
54
+ classes?: string;
55
+ /** Content rendered when `data` is empty or all values are zero. */
56
+ empty?: Snippet;
57
+ };
58
+ export type FunnelChartEventProperties = {
59
+ /** Fires when the user clicks a stage bar. Receives the stage index and its data. */
60
+ onstageclick?: (event: {
61
+ index: number;
62
+ stage: FunnelStage;
63
+ }) => void;
64
+ /** Fires when the user hovers over or leaves a stage bar. `null` on leave. */
65
+ onstagehover?: (event: {
66
+ index: number;
67
+ stage: FunnelStage;
68
+ } | null) => void;
69
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -132,7 +132,7 @@
132
132
  --input-focus-border: none;
133
133
  --input-box-shadow: none;
134
134
  --input-margin: none;
135
- --input-width: fit-content;
135
+ --input-width: 100%;
136
136
  font-size: var(--input-font-size, 16px) !important;
137
137
  font-weight: var(--input-button-font-weight, 500);
138
138
  margin: var(--input-button-margin);