@juspay/svelte-ui-components 2.80.6 → 2.80.7

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.
@@ -213,6 +213,11 @@
213
213
  gap: var(--button-content-gap, 16px);
214
214
  visibility: var(--button-visibility, visible);
215
215
  box-shadow: var(--button-box-shadow, none);
216
+
217
+ /* A button label should never soft-wrap onto a second line as the label text
218
+ changes (e.g. "Select" -> "Select (1)") — that jitters the layout around it.
219
+ Consumers that genuinely want multi-line buttons opt back in via the var. */
220
+ white-space: var(--button-white-space, nowrap);
216
221
  }
217
222
 
218
223
  .button-el.disabled,
@@ -13,6 +13,7 @@
13
13
  onopen,
14
14
  onclose,
15
15
  classes,
16
+ selectedValue = null,
16
17
  role: menuRole = 'menu',
17
18
  ariaLabel: menuAriaLabel,
18
19
  id: menuId
@@ -43,12 +44,20 @@
43
44
  }
44
45
  }
45
46
 
46
- function openMenu(startIndex: number = 0) {
47
+ function openMenu(startIndex: number | null = null) {
47
48
  open = true;
48
- focusedIndex = startIndex;
49
+ // With a known selection, opening focuses the selected option (listbox
50
+ // convention) instead of always parking the focus highlight on item 0.
51
+ const selectedItem =
52
+ selectedValue != null
53
+ ? (items.find((item) => item.value === selectedValue && item.disabled !== true) ?? null)
54
+ : null;
55
+ const initialIndex =
56
+ startIndex ?? (selectedItem !== null ? getSelectableIndex(selectedItem) : 0);
57
+ focusedIndex = initialIndex;
49
58
  onopen?.();
50
59
  tick().then(() => {
51
- focusItem(startIndex);
60
+ focusItem(initialIndex);
52
61
  });
53
62
  }
54
63
 
@@ -232,12 +241,17 @@
232
241
  class="menu-item"
233
242
  class:menu-item-danger={item.danger === true}
234
243
  class:menu-item-disabled={item.disabled === true}
244
+ class:menu-item-selected={selectedValue != null && item.value === selectedValue}
235
245
  role={itemRole}
236
246
  id={item.id}
237
247
  tabindex={item.disabled === true || menuRole === 'listbox' ? -1 : 0}
238
248
  aria-disabled={item.disabled === true ? 'true' : null}
239
- aria-selected={menuRole === 'listbox' && focusedIndex === getSelectableIndex(item)
240
- ? true
249
+ aria-selected={menuRole === 'listbox'
250
+ ? selectedValue != null
251
+ ? item.value === selectedValue
252
+ : focusedIndex === getSelectableIndex(item)
253
+ ? true
254
+ : null
241
255
  : null}
242
256
  onclick={() => selectItem(item)}
243
257
  onfocus={() => {
@@ -318,6 +332,13 @@
318
332
  outline: var(--menu-item-focus-outline, none);
319
333
  }
320
334
 
335
+ /* True selection (selectedValue match) — distinct from transient focus so the
336
+ highlight follows the chosen option, not wherever keyboard focus landed. */
337
+ .menu-item-selected {
338
+ background-color: var(--menu-item-selected-background-color, transparent);
339
+ color: var(--menu-item-selected-color, inherit);
340
+ }
341
+
321
342
  .menu-item-danger {
322
343
  color: var(--menu-item-danger-color, #dc3545);
323
344
  }
@@ -17,6 +17,11 @@ export type OptionalMenuProperties = {
17
17
  testId?: string;
18
18
  trigger?: Snippet;
19
19
  classes?: string;
20
+ /** Value of the currently selected item. When set, opening the menu focuses the
21
+ * selected option instead of the first item, the matching item gets the
22
+ * `menu-item-selected` class (stylable via --menu-item-selected-*), and
23
+ * listbox aria-selected reflects the real selection. */
24
+ selectedValue?: string | null;
20
25
  role?: 'menu' | 'listbox';
21
26
  ariaLabel?: string;
22
27
  id?: string;
@@ -52,11 +52,39 @@
52
52
  let format = $derived(valueFormat ?? formatNumber);
53
53
  let isEmpty = $derived(nodes.length === 0);
54
54
  const MARGIN = 40;
55
+ const LABEL_CHAR_PX = 7.2; // ≈ 0.6em at the 12px default label size
56
+
57
+ // Final-column labels render to the RIGHT of their node; the bare 40px margin is
58
+ // nowhere near enough for real funnel labels ("PARTIALLY_FAILED (1,234)"), so they
59
+ // used to run past the svg edge and clip. Reserve a capped gutter sized from the
60
+ // sink-node labels (sinks are what land in the final column) and lay the diagram
61
+ // out in the remaining width instead.
62
+ let lastColumnLabelGutter = $derived.by(() => {
63
+ if (!showLabels || nodes.length === 0 || chartWidth <= 0) {
64
+ return 0;
65
+ }
66
+ const sourceIds = new Set(links.map((link) => link.source));
67
+ const sinkLabels = nodes
68
+ .filter((node) => !sourceIds.has(node.id))
69
+ .map((node) => node.label ?? node.id);
70
+ if (sinkLabels.length === 0) {
71
+ return 0;
72
+ }
73
+ const longestChars =
74
+ Math.max(...sinkLabels.map((label) => label.length)) + (showValues ? 9 : 0);
75
+ const wanted = longestChars * LABEL_CHAR_PX + 10 + dataLabelOffsetX;
76
+ // Cap the reservation so labels can never squeeze the diagram below 3/4 width,
77
+ // and floor at 0 — a negative dataLabelOffsetX must not inflate the plot
78
+ // past the right margin.
79
+ return Math.max(0, Math.min(chartWidth * 0.25, wanted));
80
+ });
81
+
82
+ let plotWidth = $derived(Math.max(0, chartWidth - MARGIN * 2 - lastColumnLabelGutter));
55
83
  let layout = $derived(
56
84
  computeSankeyLayout(
57
85
  nodes,
58
86
  links,
59
- Math.max(0, chartWidth - MARGIN * 2),
87
+ plotWidth,
60
88
  Math.max(0, chartHeight - MARGIN * 2),
61
89
  nodeWidth,
62
90
  nodePadding,
@@ -87,21 +115,31 @@
87
115
  layout.nodes.length > 0 ? Math.max(...layout.nodes.map((n) => n.column)) + 1 : 0
88
116
  );
89
117
  let colWidth = $derived(
90
- columnCount <= 1
91
- ? Math.max(0, chartWidth - MARGIN * 2)
92
- : (Math.max(0, chartWidth - MARGIN * 2) - nodeWidth) / (columnCount - 1)
118
+ columnCount <= 1 ? plotWidth : (plotWidth - nodeWidth) / (columnCount - 1)
93
119
  );
94
120
 
95
121
  // Node labels render alongside their bar; long labels used to overflow into the
96
122
  // next column and collide. Clip each to the horizontal room its column actually
97
123
  // has and append an ellipsis (the full text stays available via the node tooltip).
98
- const LABEL_CHAR_PX = 7.2; // 0.6em at the 12px default label size
124
+ // The final column's room is the reserved right gutter (plus the margin), NOT
125
+ // colWidth — budgeting it at colWidth is what used to let edge labels clip.
126
+ // Column headers are centred over columns that narrow as the label gutter and
127
+ // column count grow; untruncated they collide into one unreadable run. Clip to
128
+ // the column pitch with an ellipsis — the full text stays on the <title>.
129
+ 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;
135
+ };
136
+
99
137
  const truncateLabel = (text: string, column: number): string => {
100
138
  const available =
101
139
  column === 0
102
140
  ? MARGIN + 16
103
141
  : column === columnCount - 1
104
- ? Math.max(colWidth, MARGIN + 24)
142
+ ? Math.max(0, lastColumnLabelGutter + MARGIN - 6 - dataLabelOffsetX)
105
143
  : Math.max(0, colWidth - nodeWidth - 12);
106
144
  const maxChars = Math.floor(available / LABEL_CHAR_PX);
107
145
  // No usable room — hide the label rather than force text that would overflow;
@@ -308,7 +346,7 @@
308
346
  x={ci * colWidth + nodeWidth / 2}
309
347
  y={-8}
310
348
  text-anchor="middle"
311
- dominant-baseline="auto">{label}</text
349
+ dominant-baseline="auto">{truncateColumnLabel(label)}<title>{label}</title></text
312
350
  >
313
351
  {/each}
314
352
  {/if}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.80.6",
3
+ "version": "2.80.7",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",