@juspay/svelte-ui-components 2.56.0 → 2.58.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.
@@ -39,7 +39,15 @@
39
39
  ariaExpanded,
40
40
  ariaAutocomplete,
41
41
  ariaControls,
42
- ariaActivedescendant
42
+ ariaActivedescendant,
43
+ leftIcon,
44
+ rightIcon,
45
+ onLeftIconClick,
46
+ onRightIconClick,
47
+ leftIconLabel = 'Leading action',
48
+ rightIconLabel = 'Trailing action',
49
+ mandatory = false,
50
+ forceError = false
43
51
  }: InputProperties = $props();
44
52
 
45
53
  export function focus() {
@@ -90,6 +98,11 @@
90
98
  });
91
99
 
92
100
  const showErrorMessage = $derived(validationState === 'Invalid');
101
+ // forceError lets consumers drive the error border from server/runtime validation,
102
+ // independent of validationPattern.
103
+ const showError = $derived(showErrorMessage || forceError);
104
+ const hasLeftIcon = $derived(typeof leftIcon === 'function');
105
+ const hasRightIcon = $derived(typeof rightIcon === 'function');
93
106
 
94
107
  function handleOnInput(event: Event) {
95
108
  if (inputElement === null) {
@@ -192,68 +205,113 @@
192
205
  }
193
206
  </script>
194
207
 
195
- <div class="input-container {classes ?? ''}" class:input-error={showErrorMessage && !actionInput}>
208
+ <div class="input-container {classes ?? ''}" class:input-error={showError && !actionInput}>
196
209
  {#if typeof label === 'string' && label !== '' && !actionInput}
197
210
  <label class="label" for={name}>
198
- {label}
211
+ {label}{#if mandatory}<span class="input-mandatory-asterisk" aria-hidden="true">*</span>{/if}
199
212
  </label>
200
213
  {/if}
201
214
 
202
- {#if useTextArea}
203
- <!-- svelte-ignore element_invalid_self_closing_tag -->
204
- <textarea
205
- {value}
206
- {placeholder}
207
- autocomplete={autoComplete}
208
- {name}
209
- {role}
210
- aria-expanded={ariaExpanded}
211
- aria-autocomplete={ariaAutocomplete}
212
- aria-controls={ariaControls}
213
- aria-activedescendant={ariaActivedescendant}
214
- onfocus={onFocus}
215
- onfocusout={_onFocusOut}
216
- oninput={handleOnInput}
217
- onpaste={handleOnPaste}
218
- onclick={onClick}
219
- onkeydown={onKeyDown}
220
- class:action-input={actionInput}
221
- style="--focus-border: {addFocusColor ? 1 : 0}px;"
222
- disabled={disable}
223
- bind:this={inputElement}
224
- maxlength={dataType === 'tel' ? null : maxLength}
225
- minlength={minLength}
226
- />
215
+ {#snippet fieldElement()}
216
+ {#if useTextArea}
217
+ <textarea
218
+ {value}
219
+ {placeholder}
220
+ autocomplete={autoComplete}
221
+ {name}
222
+ {role}
223
+ aria-expanded={ariaExpanded}
224
+ aria-autocomplete={ariaAutocomplete}
225
+ aria-controls={ariaControls}
226
+ aria-activedescendant={ariaActivedescendant}
227
+ aria-required={mandatory || null}
228
+ required={mandatory || null}
229
+ onfocus={onFocus}
230
+ onfocusout={_onFocusOut}
231
+ oninput={handleOnInput}
232
+ onpaste={handleOnPaste}
233
+ onclick={onClick}
234
+ onkeydown={onKeyDown}
235
+ class:action-input={actionInput}
236
+ style="--focus-border: {addFocusColor ? 1 : 0}px;"
237
+ disabled={disable}
238
+ bind:this={inputElement}
239
+ maxlength={dataType === 'tel' ? null : maxLength}
240
+ minlength={minLength}
241
+ ></textarea>
242
+ {:else}
243
+ <input
244
+ type={dataType}
245
+ {value}
246
+ {placeholder}
247
+ autocomplete={autoComplete}
248
+ {name}
249
+ {role}
250
+ aria-expanded={ariaExpanded}
251
+ aria-autocomplete={ariaAutocomplete}
252
+ aria-controls={ariaControls}
253
+ aria-activedescendant={ariaActivedescendant}
254
+ aria-required={mandatory || null}
255
+ required={mandatory || null}
256
+ onfocus={onFocus}
257
+ onfocusout={_onFocusOut}
258
+ oninput={handleOnInput}
259
+ onpaste={handleOnPaste}
260
+ onclick={onClick}
261
+ onkeydown={onKeyDown}
262
+ data-pw={testId}
263
+ class:action-input={actionInput}
264
+ disabled={disable}
265
+ bind:this={inputElement}
266
+ maxlength={dataType === 'tel' ? null : maxLength}
267
+ minlength={minLength}
268
+ {min}
269
+ {max}
270
+ />
271
+ {/if}
272
+ {/snippet}
273
+
274
+ {#if hasLeftIcon || hasRightIcon}
275
+ <div
276
+ class="input-field-wrap"
277
+ class:has-left-icon={hasLeftIcon}
278
+ class:has-right-icon={hasRightIcon}
279
+ >
280
+ {#if hasLeftIcon}
281
+ {#if onLeftIconClick}
282
+ <button
283
+ type="button"
284
+ class="input-icon input-icon-left input-icon-button"
285
+ aria-label={leftIconLabel}
286
+ onclick={onLeftIconClick}
287
+ >
288
+ {@render leftIcon?.()}
289
+ </button>
290
+ {:else}
291
+ <span class="input-icon input-icon-left">{@render leftIcon?.()}</span>
292
+ {/if}
293
+ {/if}
294
+ {@render fieldElement()}
295
+ {#if hasRightIcon}
296
+ {#if onRightIconClick}
297
+ <button
298
+ type="button"
299
+ class="input-icon input-icon-right input-icon-button"
300
+ aria-label={rightIconLabel}
301
+ onclick={onRightIconClick}
302
+ >
303
+ {@render rightIcon?.()}
304
+ </button>
305
+ {:else}
306
+ <span class="input-icon input-icon-right">{@render rightIcon?.()}</span>
307
+ {/if}
308
+ {/if}
309
+ </div>
227
310
  {:else}
228
- <input
229
- type={dataType}
230
- {value}
231
- {placeholder}
232
- autocomplete={autoComplete}
233
- {name}
234
- {role}
235
- aria-expanded={ariaExpanded}
236
- aria-autocomplete={ariaAutocomplete}
237
- aria-controls={ariaControls}
238
- aria-activedescendant={ariaActivedescendant}
239
- onfocus={onFocus}
240
- onfocusout={_onFocusOut}
241
- oninput={handleOnInput}
242
- onpaste={handleOnPaste}
243
- onclick={onClick}
244
- onkeydown={onKeyDown}
245
- data-pw={testId}
246
- class:action-input={actionInput}
247
- disabled={disable}
248
- bind:this={inputElement}
249
- maxlength={dataType === 'tel' ? null : maxLength}
250
- minlength={minLength}
251
- {min}
252
- {max}
253
- />
311
+ {@render fieldElement()}
254
312
  {/if}
255
313
 
256
- {#if onErrorMessage !== '' && showErrorMessage && !actionInput}
314
+ {#if onErrorMessage !== '' && showError && !actionInput}
257
315
  <div class="error-message">
258
316
  {onErrorMessage}
259
317
  </div>
@@ -327,6 +385,71 @@
327
385
  padding: var(--input-label-msg-padding);
328
386
  }
329
387
 
388
+ .input-mandatory-asterisk {
389
+ color: var(--input-mandatory-color, var(--input-error-msg-text-color, #fa1405));
390
+ margin-left: var(--input-mandatory-gap, 2px);
391
+ }
392
+
393
+ /* Icon wrapper: only rendered when leftIcon/rightIcon is supplied, so non-icon
394
+ consumers keep the exact prior DOM. The field's bottom margin moves to the
395
+ wrap so the absolutely-positioned icons centre on the field, not the margin. */
396
+ .input-field-wrap {
397
+ position: relative;
398
+ display: block;
399
+ margin: var(--input-margin, 0px 0px 12px 0px);
400
+ }
401
+
402
+ .input-field-wrap > :global(textarea),
403
+ .input-field-wrap > :global(input) {
404
+ margin: 0 !important;
405
+ width: var(--input-width, 100%);
406
+ }
407
+
408
+ .input-icon {
409
+ position: absolute;
410
+ top: 50%;
411
+ transform: translateY(-50%);
412
+ display: inline-flex;
413
+ align-items: center;
414
+ justify-content: center;
415
+ width: var(--input-icon-size, 20px);
416
+ height: var(--input-icon-size, 20px);
417
+ color: var(--input-icon-color, inherit);
418
+ pointer-events: none;
419
+ }
420
+
421
+ .input-icon-button {
422
+ background: none;
423
+ border: none;
424
+ padding: 0;
425
+ cursor: pointer;
426
+ pointer-events: auto;
427
+ }
428
+
429
+ .input-icon-button:focus-visible {
430
+ outline: var(--input-icon-focus-outline, 2px solid var(--input-focus-border-color, #005fcc));
431
+ outline-offset: var(--input-icon-focus-outline-offset, 2px);
432
+ border-radius: var(--input-icon-focus-radius, 2px);
433
+ }
434
+
435
+ .input-icon-left {
436
+ left: var(--input-icon-gap, 12px);
437
+ }
438
+
439
+ .input-icon-right {
440
+ right: var(--input-icon-gap, 12px);
441
+ }
442
+
443
+ .input-field-wrap.has-left-icon > :global(textarea),
444
+ .input-field-wrap.has-left-icon > :global(input) {
445
+ padding-left: calc(var(--input-icon-size, 20px) + var(--input-icon-gap, 12px) * 2);
446
+ }
447
+
448
+ .input-field-wrap.has-right-icon > :global(textarea),
449
+ .input-field-wrap.has-right-icon > :global(input) {
450
+ padding-right: calc(var(--input-icon-size, 20px) + var(--input-icon-gap, 12px) * 2);
451
+ }
452
+
330
453
  .error-message {
331
454
  font-weight: var(--input-error-msg-text-weight, 400);
332
455
  font-size: var(--input-error-msg-text-size, 12px);
@@ -1,5 +1,6 @@
1
1
  import type { CustomValidator, InputDataType, TextTransformer, ValidationState } from '../types';
2
2
  import type { HTMLInputAttributes } from 'svelte/elements';
3
+ import type { Snippet } from 'svelte';
3
4
  export type InputProperties = OptionalInputProperties & InputEventProperties & MandatoryInputProperties;
4
5
  export type MandatoryInputProperties = {
5
6
  value: string;
@@ -32,6 +33,22 @@ export type OptionalInputProperties = {
32
33
  ariaAutocomplete?: 'none' | 'inline' | 'list' | 'both';
33
34
  ariaControls?: string | null;
34
35
  ariaActivedescendant?: string | null;
36
+ /** Passive/clickable icon rendered inside the field on the leading edge (e.g. a search icon). */
37
+ leftIcon?: Snippet;
38
+ /** Passive/clickable icon rendered inside the field on the trailing edge (e.g. a clear button). */
39
+ rightIcon?: Snippet;
40
+ /** When set, the leftIcon becomes a focusable button invoking this handler. */
41
+ onLeftIconClick?: () => void;
42
+ /** When set, the rightIcon becomes a focusable button invoking this handler. */
43
+ onRightIconClick?: () => void;
44
+ /** Accessible label for the clickable leftIcon button (defaults to a generic label). */
45
+ leftIconLabel?: string;
46
+ /** Accessible label for the clickable rightIcon button (defaults to a generic label). */
47
+ rightIconLabel?: string;
48
+ /** Appends a required asterisk beside the label and sets aria-required on the field. */
49
+ mandatory?: boolean;
50
+ /** Forces the error border independent of validationPattern (server/runtime-driven errors). */
51
+ forceError?: boolean;
35
52
  };
36
53
  export type InputEventProperties = {
37
54
  onInput?: (value: string, event: Event) => void;
@@ -5,7 +5,7 @@
5
5
  import { computeSankeyLayout } from '../_chart/geometry';
6
6
  import { getColor } from '../_chart/colors';
7
7
  import { formatNumber } from '../_chart/format';
8
- import { SvelteSet } from 'svelte/reactivity';
8
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
9
9
 
10
10
  // ── Props ──────────────────────────────────────────────────────
11
11
 
@@ -26,7 +26,9 @@
26
26
  onnodehover,
27
27
  onlinkhover,
28
28
  testId,
29
- classes
29
+ classes,
30
+ columnLabels,
31
+ nodeColorResolver
30
32
  }: SankeyChartProperties = $props();
31
33
 
32
34
  // ── State ──────────────────────────────────────────────────────
@@ -56,6 +58,45 @@
56
58
  )
57
59
  );
58
60
 
61
+ /**
62
+ * Pre-computed colour map for all nodes. Keyed by node id. Computed once per layout
63
+ * change rather than re-running find() + indexOf() for every link on every render.
64
+ *
65
+ * Note: nodeColorResolver also controls link stroke colours (links inherit source-node
66
+ * colour), not just node fill colours. See Props docs for full description.
67
+ */
68
+ let nodeColorMap = $derived.by(() => {
69
+ const map = new SvelteMap<string, string>();
70
+ for (let ni = 0; ni < layout.nodes.length; ni++) {
71
+ const node = layout.nodes[ni];
72
+ const color = node.color ?? nodeColorResolver?.(node.id, node.label ?? null) ?? getColor(ni);
73
+ map.set(node.id, color);
74
+ }
75
+ return map;
76
+ });
77
+
78
+ // Column count and width — used by columnLabels rendering
79
+ let columnCount = $derived(
80
+ layout.nodes.length > 0 ? Math.max(...layout.nodes.map((n) => n.column)) + 1 : 0
81
+ );
82
+ let colWidth = $derived(
83
+ columnCount <= 1
84
+ ? Math.max(0, chartWidth - MARGIN * 2)
85
+ : (Math.max(0, chartWidth - MARGIN * 2) - nodeWidth) / (columnCount - 1)
86
+ );
87
+
88
+ // ── Helpers ────────────────────────────────────────────────────
89
+
90
+ /** Percentage of source node's total value carried by a link (0–100, 2 dp). */
91
+ const computeLinkPct = (sourceId: string, linkValue: number): number => {
92
+ const sourceNode = layout.nodes.find((nd) => nd.id === sourceId);
93
+ if (!sourceNode) {
94
+ return 0;
95
+ }
96
+ const sourceTotal = sourceNode.value;
97
+ return sourceTotal > 0 ? Math.round((linkValue / sourceTotal) * 10000) / 100 : 0;
98
+ };
99
+
59
100
  let connectedNodes = $derived.by(() => {
60
101
  if (hoveredNode !== null) {
61
102
  const connected = new SvelteSet<string>([hoveredNode]);
@@ -75,6 +116,27 @@
75
116
 
76
117
  // ── Tooltip ────────────────────────────────────────────────────
77
118
 
119
+ /**
120
+ * Pre-computed link tooltip data for the currently hovered link. Computed once and shared
121
+ * by both `tooltipData` and `tooltipContext` to avoid running the O(n) percentage lookup
122
+ * twice on every hover state change.
123
+ */
124
+ let hoveredLinkCache = $derived.by(() => {
125
+ if (hoveredLink === null) {
126
+ return null;
127
+ }
128
+ const l = links.find(
129
+ (lk) => lk.source === hoveredLink!.source && lk.target === hoveredLink!.target
130
+ );
131
+ if (!l) {
132
+ return null;
133
+ }
134
+ const sourceLabelText = nodes.find((nd) => nd.id === l.source)?.label ?? l.source;
135
+ const targetLabelText = nodes.find((nd) => nd.id === l.target)?.label ?? l.target;
136
+ const pct = computeLinkPct(l.source, l.value);
137
+ return { link: l, sourceLabel: sourceLabelText, targetLabel: targetLabelText, percentage: pct };
138
+ });
139
+
78
140
  let tooltipData = $derived.by(() => {
79
141
  if (hoveredNode !== null) {
80
142
  const n = layout.nodes.find((nd) => nd.id === hoveredNode);
@@ -92,16 +154,14 @@
92
154
  ]
93
155
  };
94
156
  }
95
- if (hoveredLink !== null) {
96
- const l = links.find(
97
- (lk) => lk.source === hoveredLink!.source && lk.target === hoveredLink!.target
98
- );
99
- if (!l) {
100
- return null;
101
- }
157
+ if (hoveredLinkCache !== null) {
158
+ const { link: l, sourceLabel, targetLabel, percentage: pct } = hoveredLinkCache;
102
159
  return {
103
- title: `${nodes.find((n) => n.id === l.source)?.label ?? l.source} → ${nodes.find((n) => n.id === l.target)?.label ?? l.target}`,
104
- items: [{ label: 'Flow', value: format(l.value) }]
160
+ title: `${sourceLabel} → ${targetLabel}`,
161
+ items: [
162
+ { label: 'Flow', value: format(l.value) },
163
+ { label: 'of source', value: `${pct.toFixed(2)}%` }
164
+ ]
105
165
  };
106
166
  }
107
167
  return null;
@@ -116,12 +176,15 @@
116
176
  }
117
177
  return { type: 'node', node: n, value: computed.value };
118
178
  }
119
- if (hoveredLink !== null) {
120
- const l = findLink(hoveredLink.source, hoveredLink.target);
121
- if (!l) {
122
- return null;
123
- }
124
- return { type: 'link', link: l };
179
+ if (hoveredLinkCache !== null) {
180
+ const { link: l, sourceLabel, targetLabel, percentage: pct } = hoveredLinkCache;
181
+ return {
182
+ type: 'link',
183
+ link: l,
184
+ sourceLabel,
185
+ targetLabel,
186
+ percentage: pct
187
+ };
125
188
  }
126
189
  return null;
127
190
  });
@@ -208,6 +271,18 @@
208
271
  {:else}
209
272
  <ChartContainer bind:width={chartWidth} bind:height={chartHeight} {aspectRatio}>
210
273
  <g transform="translate({MARGIN}, {MARGIN})">
274
+ {#if columnLabels != null && columnLabels.length > 0}
275
+ {#each columnLabels as label, ci (ci)}
276
+ <text
277
+ class="sankey-col-label"
278
+ x={ci * colWidth + nodeWidth / 2}
279
+ y={-8}
280
+ text-anchor="middle"
281
+ dominant-baseline="auto">{label}</text
282
+ >
283
+ {/each}
284
+ {/if}
285
+
211
286
  {#each layout.links as link, i (i)}
212
287
  {@const highlighted = isLinkHighlighted(link.source, link.target)}
213
288
  {@const dimmed = (hoveredNode !== null || hoveredLink !== null) && !highlighted}
@@ -217,7 +292,7 @@
217
292
  class="sankey-link"
218
293
  d={link.path}
219
294
  fill="none"
220
- stroke={link.color ?? getColor(layout.nodes.findIndex((n) => n.id === link.source))}
295
+ stroke={link.color ?? nodeColorMap.get(link.source) ?? getColor(0)}
221
296
  stroke-width={Math.max(1, link.width)}
222
297
  stroke-opacity={highlighted ? 0.7 : dimmed ? 0.08 : 0.4}
223
298
  onmouseenter={(e) => handleLinkEnter(e, link.source, link.target)}
@@ -228,7 +303,7 @@
228
303
  {/each}
229
304
 
230
305
  {#each layout.nodes as node, ni (ni)}
231
- {@const color = node.color ?? getColor(ni)}
306
+ {@const color = nodeColorMap.get(node.id) ?? getColor(ni)}
232
307
  {@const dimmed = connectedNodes !== null && !connectedNodes.has(node.id)}
233
308
  <!-- svelte-ignore a11y_no_static_element_interactions -->
234
309
  <!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -296,6 +371,12 @@
296
371
  pointer-events: none;
297
372
  transition: opacity var(--chart-transition-duration, 0.2s) ease;
298
373
  }
374
+ .sankey-col-label {
375
+ fill: var(--sankey-col-label-color, #666);
376
+ font-size: var(--sankey-col-label-font-size, 11px);
377
+ font-family: var(--chart-font-family, inherit);
378
+ pointer-events: none;
379
+ }
299
380
  .sankey-label.node-dimmed {
300
381
  opacity: var(--sankey-dimmed-opacity, 0.15);
301
382
  }
@@ -10,6 +10,14 @@ export type SankeyLink = {
10
10
  value: number;
11
11
  color?: string;
12
12
  };
13
+ /**
14
+ * Context passed to the `tooltipSnippet` prop on each hover event.
15
+ *
16
+ * The `'link'` branch gained three new optional fields (`sourceLabel`, `targetLabel`,
17
+ * `percentage`) in this release. They are always populated by the chart — the fields are
18
+ * typed optional so that existing consumer code that typed a variable explicitly as
19
+ * `{ type: 'link'; link: SankeyLink }` continues to compile without changes.
20
+ */
13
21
  export type SankeyTooltipContext = {
14
22
  type: 'node';
15
23
  node: SankeyNode;
@@ -17,6 +25,12 @@ export type SankeyTooltipContext = {
17
25
  } | {
18
26
  type: 'link';
19
27
  link: SankeyLink;
28
+ /** Human-readable label of the source node (falls back to node id when label is undefined). Always present at runtime. */
29
+ sourceLabel?: string;
30
+ /** Human-readable label of the target node (falls back to node id when label is undefined). Always present at runtime. */
31
+ targetLabel?: string;
32
+ /** link.value as a percentage of the source node's total outgoing value (0–100, rounded to 2 dp). Always present at runtime. */
33
+ percentage?: number;
20
34
  };
21
35
  export type SankeyChartProperties = MandatorySankeyChartProperties & OptionalSankeyChartProperties & SankeyChartEventProperties;
22
36
  export type MandatorySankeyChartProperties = {
@@ -35,6 +49,14 @@ export type OptionalSankeyChartProperties = {
35
49
  empty?: Snippet;
36
50
  testId?: string;
37
51
  classes?: string;
52
+ /** Labels rendered above each column, indexed by column position (0-based). */
53
+ columnLabels?: string[];
54
+ /**
55
+ * Called for each node and also for link strokes (links inherit the resolved source-node
56
+ * colour). Return a CSS colour string to override the default palette, or `null` to fall
57
+ * through to the default palette colour.
58
+ */
59
+ nodeColorResolver?: (id: string, label: string | null) => string | null;
38
60
  };
39
61
  export type SankeyChartEventProperties = {
40
62
  onnodeclick?: (event: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.56.0",
3
+ "version": "2.58.0",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",