@juspay/svelte-ui-components 2.89.1 → 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
 
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.89.1",
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",