@juspay/svelte-ui-components 2.89.0 → 2.89.2

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.
@@ -263,10 +263,23 @@
263
263
  border-radius: var(--modal-border-radius, var(--radius, 4px));
264
264
  overflow: var(--modal-content-overflow, auto);
265
265
  border-top: var(--modal-content-border-top);
266
+ /* Viewport containment for every size class: only .fit-content used to carry
267
+ a max-height, so a size whose height var is overridden to fit-content (or
268
+ anything taller than the screen) grew past the viewport and pushed its
269
+ footer and bottom rounding off-screen. dvh tracks the real visible
270
+ viewport on mobile; the vh line is the fallback for engines without dvh. */
271
+ max-height: var(--modal-max-height, calc(100vh - 32px));
272
+ max-height: var(--modal-max-height, calc(100dvh - 32px));
266
273
  }
267
274
 
268
275
  .slot-content {
269
276
  display: var(--modal-display, flex);
277
+ /* Flex children default to min-height: auto and refuse to shrink below their
278
+ content, which defeats the overflow-y scroll once modal-content is
279
+ height-capped — the content spills instead of scrolling and the footer is
280
+ pushed out. 0 lets the slot shrink so its own scrollbar engages and the
281
+ header/footer stay pinned inside the viewport. */
282
+ min-height: var(--modal-slot-content-min-height, 0);
270
283
  overflow-y: var(--modal-overflow-y, scroll);
271
284
  scrollbar-width: var(--modal-scrollbar-width, none);
272
285
  padding: var(--modal-content-padding, 0);
@@ -53,6 +53,53 @@
53
53
  let isEmpty = $derived(nodes.length === 0);
54
54
  const MARGIN = 40;
55
55
  const LABEL_CHAR_PX = 7.2; // ≈ 0.6em at the 12px default label size
56
+ // A 12px label's rendered line box measures ~16px (≈1.33em) across common
57
+ // font stacks; two label centres closer than this overlap visibly.
58
+ const LABEL_LINE_PX = 16;
59
+
60
+ // Per-character width estimate at the 12px default label size. A flat
61
+ // 7.2px/char average underestimates uppercase-heavy labels ("OTP SKIPPED
62
+ // (1,234)" is ~8.2px/char), so "truncated" labels still overflowed their
63
+ // budget and slid under the next column's node bar.
64
+ const estimateCharWidth = (ch: string): number => {
65
+ if (/[mwMW@]/.test(ch)) {
66
+ return 10.6;
67
+ }
68
+ if (/[A-Z0-9_#%&]/.test(ch)) {
69
+ return 8.2;
70
+ }
71
+ if (/[iljtfr.,:;'’()[\]!|]/.test(ch)) {
72
+ return 3.6;
73
+ }
74
+ if (ch === ' ') {
75
+ return 3.8;
76
+ }
77
+ return 6.6;
78
+ };
79
+
80
+ const estimateTextWidth = (text: string): number => {
81
+ let width = 0;
82
+ for (const ch of text) {
83
+ width += estimateCharWidth(ch);
84
+ }
85
+ return width;
86
+ };
87
+
88
+ // Trim `text` (appending an ellipsis) until its estimated width fits
89
+ // `available` px. Returns '' when even 3 chars + ellipsis cannot fit —
90
+ // callers hide the label and rely on the <title> tooltip instead.
91
+ const fitTextToWidth = (text: string, available: number): string => {
92
+ if (estimateTextWidth(text) <= available) {
93
+ return text;
94
+ }
95
+ for (let keep = text.length - 1; keep >= 3; keep--) {
96
+ const candidate = text.slice(0, keep) + '…';
97
+ if (estimateTextWidth(candidate) <= available) {
98
+ return candidate;
99
+ }
100
+ }
101
+ return '';
102
+ };
56
103
 
57
104
  // Final-column labels render to the RIGHT of their node; the bare 40px margin is
58
105
  // nowhere near enough for real funnel labels ("PARTIALLY_FAILED (1,234)"), so they
@@ -70,9 +117,10 @@
70
117
  if (sinkLabels.length === 0) {
71
118
  return 0;
72
119
  }
73
- const longestChars =
74
- Math.max(...sinkLabels.map((label) => label.length)) + (showValues ? 9 : 0);
75
- const wanted = longestChars * LABEL_CHAR_PX + 10 + dataLabelOffsetX;
120
+ const longestPx =
121
+ Math.max(...sinkLabels.map((label) => estimateTextWidth(label))) +
122
+ (showValues ? 9 * LABEL_CHAR_PX : 0);
123
+ const wanted = longestPx + 10 + dataLabelOffsetX;
76
124
  // Cap the reservation so labels can never squeeze the diagram below 3/4 width,
77
125
  // and floor at 0 — a negative dataLabelOffsetX must not inflate the plot
78
126
  // past the right margin.
@@ -127,29 +175,71 @@
127
175
  // column count grow; untruncated they collide into one unreadable run. Clip to
128
176
  // the column pitch with an ellipsis — the full text stays on the <title>.
129
177
  const truncateColumnLabel = (text: string): string => {
130
- const maxChars = Math.floor(Math.max(0, colWidth - 6) / LABEL_CHAR_PX);
131
- if (maxChars < 3) {
132
- return '';
133
- }
134
- return text.length > maxChars ? text.slice(0, maxChars - 1) + '…' : text;
178
+ return fitTextToWidth(text, Math.max(0, colWidth - 6));
135
179
  };
136
180
 
137
181
  const truncateLabel = (text: string, column: number): string => {
182
+ // Middle columns must budget for dataLabelOffsetX too: the label starts at
183
+ // node.x + nodeWidth + 6 + dataLabelOffsetX, so the room before the next
184
+ // column's bar shrinks by the same offset. Omitting it let "fitting"
185
+ // labels run under the neighbouring column's node rect.
186
+ // First-column labels anchor `end` at node.x - 6 - offset with node.x = 0,
187
+ // so their room is exactly the left margin minus that inset — budgeting
188
+ // more pushes long labels past the SVG's left edge, where they clip.
138
189
  const available =
139
190
  column === 0
140
- ? MARGIN + 16
191
+ ? Math.max(0, MARGIN - 6 - dataLabelOffsetX)
141
192
  : column === columnCount - 1
142
193
  ? Math.max(0, lastColumnLabelGutter + MARGIN - 6 - dataLabelOffsetX)
143
- : Math.max(0, colWidth - nodeWidth - 12);
144
- const maxChars = Math.floor(available / LABEL_CHAR_PX);
194
+ : Math.max(0, colWidth - nodeWidth - 12 - dataLabelOffsetX);
145
195
  // No usable room — hide the label rather than force text that would overflow;
146
196
  // the full text is still reachable via the node's <title> on hover.
147
- if (maxChars < 3) {
148
- return '';
149
- }
150
- return text.length > maxChars ? text.slice(0, maxChars - 1) + '…' : text;
197
+ return fitTextToWidth(text, available);
151
198
  };
152
199
 
200
+ // Vertical label de-collision: labels sit at each node's centre-y, so two
201
+ // small stacked nodes in a crowded column render their 12px labels on top of
202
+ // each other. Per column, walk labels top-to-bottom and drop the label of
203
+ // the smaller-value node whenever two centres come closer than one label
204
+ // line — the hidden label's text stays reachable via the node's <title>.
205
+ let collidingLabels = $derived.by(() => {
206
+ const hidden = new SvelteSet<string>();
207
+ if (!showLabels) {
208
+ return hidden;
209
+ }
210
+ const byColumn = new SvelteMap<number, typeof layout.nodes>();
211
+ for (const node of layout.nodes) {
212
+ const bucket = byColumn.get(node.column);
213
+ if (bucket) {
214
+ bucket.push(node);
215
+ } else {
216
+ byColumn.set(node.column, [node]);
217
+ }
218
+ }
219
+ for (const columnNodes of byColumn.values()) {
220
+ const sorted = [...columnNodes].sort((a, b) => a.y + a.height / 2 - (b.y + b.height / 2));
221
+ let lastKept: (typeof sorted)[number] | null = null;
222
+ for (const node of sorted) {
223
+ if (lastKept === null) {
224
+ lastKept = node;
225
+ continue;
226
+ }
227
+ const centerGap = node.y + node.height / 2 - (lastKept.y + lastKept.height / 2);
228
+ if (centerGap < LABEL_LINE_PX) {
229
+ if (node.value > lastKept.value) {
230
+ hidden.add(lastKept.id);
231
+ lastKept = node;
232
+ } else {
233
+ hidden.add(node.id);
234
+ }
235
+ } else {
236
+ lastKept = node;
237
+ }
238
+ }
239
+ }
240
+ return hidden;
241
+ });
242
+
153
243
  // ── Helpers ────────────────────────────────────────────────────
154
244
 
155
245
  /** Percentage of source node's total value carried by a link (0–100, 2 dp). */
@@ -391,24 +481,33 @@
391
481
  onmouseleave={handleNodeLeave}
392
482
  onclick={() => handleNodeClick(node.id)}
393
483
  />
394
- {#if showLabels}
395
- <text
396
- class="sankey-label"
397
- class:node-dimmed={dimmed}
398
- x={node.column === 0
399
- ? node.x - 6 - dataLabelOffsetX
400
- : node.x + node.width + 6 + dataLabelOffsetX}
401
- y={node.y + node.height / 2}
402
- text-anchor={node.column === 0 ? 'end' : 'start'}
403
- dominant-baseline="middle"
404
- >{truncateLabel(
405
- showValues ? `${node.label} (${format(node.value)})` : node.label,
406
- node.column
407
- )}<title>{showValues ? `${node.label} (${format(node.value)})` : node.label}</title
408
- ></text
409
- >
410
- {/if}
411
484
  {/each}
485
+
486
+ <!-- Labels render in a second pass, after every node rect: within one
487
+ interleaved loop a label could be over-painted by a later column's
488
+ bar whenever the width estimate ran short. -->
489
+ {#if showLabels}
490
+ {#each layout.nodes as node, ni (ni)}
491
+ {@const dimmed = connectedNodes !== null && !connectedNodes.has(node.id)}
492
+ {#if !collidingLabels.has(node.id)}
493
+ <text
494
+ class="sankey-label"
495
+ class:node-dimmed={dimmed}
496
+ x={node.column === 0
497
+ ? node.x - 6 - dataLabelOffsetX
498
+ : node.x + node.width + 6 + dataLabelOffsetX}
499
+ y={node.y + node.height / 2}
500
+ text-anchor={node.column === 0 ? 'end' : 'start'}
501
+ dominant-baseline="middle"
502
+ >{truncateLabel(
503
+ showValues ? `${node.label} (${format(node.value)})` : node.label,
504
+ node.column
505
+ )}<title>{showValues ? `${node.label} (${format(node.value)})` : node.label}</title
506
+ ></text
507
+ >
508
+ {/if}
509
+ {/each}
510
+ {/if}
412
511
  </g>
413
512
  </ChartContainer>
414
513
 
@@ -551,6 +551,7 @@
551
551
  white-space: nowrap;
552
552
  overflow: hidden;
553
553
  text-overflow: ellipsis;
554
+ text-align: var(--select-value-align, left);
554
555
  }
555
556
 
556
557
  .select-placeholder {
@@ -559,6 +560,7 @@
559
560
  white-space: nowrap;
560
561
  overflow: hidden;
561
562
  text-overflow: ellipsis;
563
+ text-align: var(--select-value-align, left);
562
564
  }
563
565
 
564
566
  .select-search {
@@ -543,6 +543,7 @@
543
543
  display: inline-flex;
544
544
  align-items: center;
545
545
  min-width: 0;
546
+ width: var(--table-interactive-width, auto);
546
547
  }
547
548
 
548
549
  .builtin-action-group {
@@ -49,7 +49,10 @@
49
49
  onSearchChange,
50
50
  pagination,
51
51
  toolbarSlot,
52
- rowNumberColumn = false
52
+ rowNumberColumn = false,
53
+ rowNumberLabel = '#',
54
+ headerTooltipIcon,
55
+ headerTooltipPosition
53
56
  }: TableProperties = $props();
54
57
 
55
58
  // ─── Keyed column model → positional projection ─────────────────────────────
@@ -612,7 +615,7 @@
612
615
  {/if}
613
616
  {#if rowNumberColumn}
614
617
  <th class="table-header table-row-number-col" class:table-header-sticky={isStickyHeader}
615
- >#</th
618
+ >{rowNumberLabel}</th
616
619
  >
617
620
  {/if}
618
621
  {#each effectiveHeaders as header, colIndex (colIndex)}
@@ -633,8 +636,16 @@
633
636
  : null}
634
637
  >
635
638
  {#if headerColumn?.tooltip}
636
- <Tooltip text={headerColumn.tooltip}>
637
- <span class="table-header-label">{header}</span>
639
+ <Tooltip
640
+ text={headerColumn.tooltip}
641
+ position={headerTooltipPosition}
642
+ icon={headerTooltipIcon}
643
+ iconPosition="trailing"
644
+ >
645
+ <span
646
+ class="table-header-label"
647
+ class:table-header-label-plain={headerTooltipIcon}>{header}</span
648
+ >
638
649
  </Tooltip>
639
650
  {:else}
640
651
  {header}
@@ -990,12 +1001,14 @@
990
1001
  letter-spacing: var(--table-header-letter-spacing, 0.02em);
991
1002
  text-transform: var(--table-header-text-transform);
992
1003
  color: var(--table-header-color, var(--table-header-font-color, #6b7280));
1004
+ border-bottom: var(--table-header-border, var(--table-inner-border, none));
993
1005
  }
994
1006
 
995
1007
  .table-header-content {
996
1008
  display: flex;
997
1009
  align-items: center;
998
1010
  gap: 4px;
1011
+ justify-content: var(--table-header-justify, flex-start);
999
1012
  }
1000
1013
 
1001
1014
  .table-header-label {
@@ -1004,6 +1017,10 @@
1004
1017
  cursor: help;
1005
1018
  }
1006
1019
 
1020
+ .table-header-label-plain {
1021
+ text-decoration: none;
1022
+ }
1023
+
1007
1024
  .table-header-filter {
1008
1025
  display: inline-flex;
1009
1026
  align-items: center;
@@ -1256,6 +1273,7 @@
1256
1273
  width: var(--table-row-number-col-width, 48px);
1257
1274
  color: var(--table-row-number-color, #6b7280);
1258
1275
  font-variant-numeric: tabular-nums;
1276
+ text-align: var(--table-row-number-align, var(--table-text-align, left));
1259
1277
  }
1260
1278
 
1261
1279
  /* ── Accessibility ──────────────────────────────────────────────────────── */
@@ -1,5 +1,6 @@
1
1
  import type { JSONValue } from 'type-decoder';
2
2
  import type { Snippet } from 'svelte';
3
+ import type { TooltipPosition } from '../Tooltip/properties';
3
4
  export type SortDirection = 'asc' | 'desc';
4
5
  /**
5
6
  * Built-in cell renderer vocabulary for the keyed column model.
@@ -335,6 +336,16 @@ export type OptionalTableProperties = {
335
336
  }]>;
336
337
  /** Prepends a sequential row-number column (1-based, pagination-aware). */
337
338
  rowNumberColumn?: boolean;
339
+ /** Header label for the row-number column. Defaults to `'#'`. */
340
+ rowNumberLabel?: string;
341
+ /**
342
+ * Icon snippet shown after each header label that has a `tooltip` — the
343
+ * consumer supplies the glyph; the table places it trailing inside the tooltip
344
+ * trigger. When set, the default underline affordance on those labels is dropped.
345
+ */
346
+ headerTooltipIcon?: Snippet;
347
+ /** Placement of every header tooltip bubble. Defaults to `'top'`. */
348
+ headerTooltipPosition?: TooltipPosition;
338
349
  sortable?: boolean;
339
350
  sortableColumns?: number[];
340
351
  stickyHeader?: boolean;
@@ -252,30 +252,51 @@
252
252
  display: none;
253
253
  }
254
254
 
255
+ /* Each fade holds FULLY transparent for the first --tabs-fade-solid px before
256
+ ramping to opaque: a plain 0→fade-size ramp still renders the clipped tab
257
+ label at ~20% opacity a few px from the edge, which reads as a stray glyph
258
+ fragment beside the scroll arrow. The solid zone guarantees nothing is
259
+ perceptible there. */
255
260
  .tabs-bar.fade-left {
256
- mask-image: linear-gradient(to right, transparent, black var(--tabs-fade-size, 32px));
257
- -webkit-mask-image: linear-gradient(to right, transparent, black var(--tabs-fade-size, 32px));
261
+ mask-image: linear-gradient(
262
+ to right,
263
+ transparent var(--tabs-fade-solid, 8px),
264
+ black var(--tabs-fade-size, 32px)
265
+ );
266
+ -webkit-mask-image: linear-gradient(
267
+ to right,
268
+ transparent var(--tabs-fade-solid, 8px),
269
+ black var(--tabs-fade-size, 32px)
270
+ );
258
271
  }
259
272
 
260
273
  .tabs-bar.fade-right {
261
- mask-image: linear-gradient(to left, transparent, black var(--tabs-fade-size, 32px));
262
- -webkit-mask-image: linear-gradient(to left, transparent, black var(--tabs-fade-size, 32px));
274
+ mask-image: linear-gradient(
275
+ to left,
276
+ transparent var(--tabs-fade-solid, 8px),
277
+ black var(--tabs-fade-size, 32px)
278
+ );
279
+ -webkit-mask-image: linear-gradient(
280
+ to left,
281
+ transparent var(--tabs-fade-solid, 8px),
282
+ black var(--tabs-fade-size, 32px)
283
+ );
263
284
  }
264
285
 
265
286
  .tabs-bar.fade-left.fade-right {
266
287
  mask-image: linear-gradient(
267
288
  to right,
268
- transparent,
289
+ transparent var(--tabs-fade-solid, 8px),
269
290
  black var(--tabs-fade-size, 32px),
270
291
  black calc(100% - var(--tabs-fade-size, 32px)),
271
- transparent
292
+ transparent calc(100% - var(--tabs-fade-solid, 8px))
272
293
  );
273
294
  -webkit-mask-image: linear-gradient(
274
295
  to right,
275
- transparent,
296
+ transparent var(--tabs-fade-solid, 8px),
276
297
  black var(--tabs-fade-size, 32px),
277
298
  black calc(100% - var(--tabs-fade-size, 32px)),
278
- transparent
299
+ transparent calc(100% - var(--tabs-fade-solid, 8px))
279
300
  );
280
301
  }
281
302
 
@@ -11,6 +11,7 @@
11
11
  classes,
12
12
  children,
13
13
  icon,
14
+ iconPosition = 'leading',
14
15
  content,
15
16
  usePortal = false
16
17
  }: TooltipProperties = $props();
@@ -355,10 +356,13 @@
355
356
  onfocusout={hideTooltip}
356
357
  data-pw={testId}
357
358
  >
358
- {#if typeof icon === 'function'}
359
+ {#if typeof icon === 'function' && iconPosition === 'leading'}
359
360
  <span class="tooltip-icon" aria-hidden="true">{@render icon()}</span>
360
361
  {/if}
361
362
  {@render children()}
363
+ {#if typeof icon === 'function' && iconPosition === 'trailing'}
364
+ <span class="tooltip-icon" aria-hidden="true">{@render icon()}</span>
365
+ {/if}
362
366
  {#if visible && !usePortal}
363
367
  <div
364
368
  use:clampInlineBubble
@@ -9,8 +9,10 @@ export type OptionalTooltipProperties = {
9
9
  delay?: number;
10
10
  testId?: string | null;
11
11
  classes?: string;
12
- /** Snippet rendered as a leading icon in the trigger wrapper. No default glyph is provided — consumers supply their own. */
12
+ /** Snippet rendered as an icon in the trigger wrapper, beside the content. No default glyph is provided — consumers supply their own. Placement is controlled by `iconPosition`. */
13
13
  icon?: Snippet;
14
+ /** Which side of the trigger content the `icon` sits on. Defaults to `'leading'`. */
15
+ iconPosition?: 'leading' | 'trailing';
14
16
  /** Snippet rendered as the bubble body. When provided, replaces the plain `text` string inside the tooltip bubble. */
15
17
  content?: Snippet;
16
18
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.89.0",
3
+ "version": "2.89.2",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",