@juspay/svelte-ui-components 2.80.3 → 2.80.5

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.
@@ -114,7 +114,7 @@
114
114
  line-height: var(--banner-line-height, 1.3);
115
115
  border-bottom: var(--banner-border-bottom, none);
116
116
  border: var(--banner-border);
117
- border-radius: var(--banner-border-radius, 0);
117
+ border-radius: var(--banner-border-radius, var(--radius, 4px));
118
118
  cursor: var(--banner-cursor, pointer);
119
119
  position: var(--banner-position, sticky);
120
120
  top: var(--banner-top, 0);
@@ -18,6 +18,7 @@
18
18
  import { formatNumber } from '../_chart/format';
19
19
  import { roundedRectPath } from '../_chart/paths';
20
20
  import type { LegendItem, BarRect } from '../_chart/types';
21
+ import { DEFAULT_CHART_CORNER_RADIUS } from '../_chart/types';
21
22
  import { SvelteMap } from 'svelte/reactivity';
22
23
 
23
24
  // ── Per-instance uid prefix for <defs> ids (A1-3) ─────────────
@@ -52,7 +53,7 @@
52
53
  showYAxis = true,
53
54
  showLegend = false,
54
55
  barPadding = 0.2,
55
- barRadius = 4,
56
+ barRadius = DEFAULT_CHART_CORNER_RADIUS,
56
57
  aspectRatio = 16 / 9,
57
58
  xAxisLabel,
58
59
  yAxisLabel,
@@ -754,7 +755,12 @@
754
755
  onclick={() => handleClick(bar)}
755
756
  />
756
757
  {/if}
757
- {#if showValues && !isStackedMode}
758
+ <!-- Suppress the horizontal value label when its sub-band is thinner
759
+ than the ~11px label, so cramped multi-series charts hide labels
760
+ instead of overlapping them into an unreadable cluster. Consumers
761
+ restore labels by giving the chart more height (aspectRatio /
762
+ scrollable / minBandWidth). -->
763
+ {#if showValues && !isStackedMode && (isVertical || bar.height >= 13)}
758
764
  <text
759
765
  class="bar-value"
760
766
  x={isVertical ? bar.x + bar.width / 2 : bar.x + bar.width + 4}
@@ -76,6 +76,12 @@ export type OptionalBarChartProperties = {
76
76
  showXAxis?: boolean;
77
77
  showYAxis?: boolean;
78
78
  barPadding?: number;
79
+ /**
80
+ * Corner radius on bar/column shapes in pixels. Defaults to
81
+ * `DEFAULT_CHART_CORNER_RADIUS` (4), mirroring the design system's base
82
+ * `--radius` token. SVG `rx`/`ry` cannot read CSS `var()`, so pass this
83
+ * prop explicitly to track a changed `--radius` at runtime.
84
+ */
79
85
  barRadius?: number;
80
86
  aspectRatio?: number;
81
87
  xAxisLabel?: string;
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { onMount, tick } from 'svelte';
3
3
  import Input from '../Input/Input.svelte';
4
+ import Pill from '../Pill/Pill.svelte';
4
5
  import type { ComboboxItem, ComboboxProperties } from './properties';
5
6
 
6
7
  function defaultFilter(item: ComboboxItem, query: string): boolean {
@@ -29,13 +30,27 @@
29
30
  inputSuffix,
30
31
  dropdownHeader,
31
32
  dropdownFooter,
33
+ // multi-select + create/action
34
+ multiple = false,
35
+ selected = $bindable([]),
36
+ maxSelected,
37
+ maxSelectedText,
38
+ pillSnippet,
39
+ allowCreate = false,
40
+ createLabel = (query: string) => `Create "${query}"`,
41
+ action,
42
+ actionIcon,
32
43
  onselect,
33
44
  oninput,
34
45
  onopen,
35
46
  onclose,
36
47
  onkeydown,
37
48
  onfocus,
38
- onblur
49
+ onblur,
50
+ onchange,
51
+ onadd,
52
+ onremove,
53
+ oncreate
39
54
  }: ComboboxProperties = $props();
40
55
 
41
56
  let containerEl: HTMLDivElement | null = $state(null);
@@ -45,20 +60,63 @@
45
60
  return inputRef?.getInputRef() ?? null;
46
61
  }
47
62
 
63
+ function focusInput(): void {
64
+ inputRef?.getInputRef()?.focus();
65
+ }
66
+
48
67
  const listboxId = `combobox-listbox-${Math.random().toString(36).slice(2, 9)}`;
49
68
 
69
+ let selectedSet = $derived(new Set(selected));
70
+ let trimmedQuery = $derived(inputValue.trim());
71
+
50
72
  let filteredItems: ComboboxItem[] = $derived(
51
- inputValue.length > 0 ? items.filter((item) => filterFn(item, inputValue)) : items
73
+ items.filter((item) => {
74
+ if (multiple && selectedSet.has(item.id)) {
75
+ return false;
76
+ }
77
+ return inputValue.length > 0 ? filterFn(item, inputValue) : true;
78
+ })
52
79
  );
53
80
 
54
81
  let selectableItems: ComboboxItem[] = $derived(
55
82
  filteredItems.filter((item) => item.disabled !== true)
56
83
  );
57
84
 
85
+ let exactMatch: ComboboxItem | null = $derived(
86
+ items.find((item) => item.label.toLowerCase() === trimmedQuery.toLowerCase()) ?? null
87
+ );
88
+
89
+ let atLimit = $derived(
90
+ multiple && typeof maxSelected === 'number' && selected.length >= maxSelected
91
+ );
92
+
93
+ let limitText = $derived(
94
+ maxSelectedText ??
95
+ (typeof maxSelected === 'number'
96
+ ? `You can select up to ${maxSelected}.`
97
+ : 'Selection limit reached.')
98
+ );
99
+
100
+ let showCreate = $derived(
101
+ allowCreate &&
102
+ !atLimit &&
103
+ trimmedQuery !== '' &&
104
+ exactMatch === null &&
105
+ !(multiple && selectedSet.has(trimmedQuery))
106
+ );
107
+
108
+ // Navigable rows: selectable options (hidden at the limit) → create → action.
109
+ let navItemCount = $derived(atLimit ? 0 : selectableItems.length);
110
+ let createNavIndex = $derived(showCreate ? navItemCount : -1);
111
+ let actionNavIndex = $derived(action ? navItemCount + (showCreate ? 1 : 0) : -1);
112
+ let navCount = $derived(navItemCount + (showCreate ? 1 : 0) + (action ? 1 : 0));
113
+
58
114
  let highlightedOptionId: string | null = $derived(
59
115
  highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : null
60
116
  );
61
117
 
118
+ const labelOf = (id: string): string => items.find((item) => item.id === id)?.label ?? id;
119
+
62
120
  function openDropdown() {
63
121
  if (disabled || open) {
64
122
  return;
@@ -77,37 +135,75 @@
77
135
  onclose?.();
78
136
  }
79
137
 
138
+ function emitChange(): void {
139
+ onchange?.([...selected]);
140
+ }
141
+
142
+ function addValue(id: string): void {
143
+ if (atLimit || selectedSet.has(id)) {
144
+ return;
145
+ }
146
+ selected = [...selected, id];
147
+ inputValue = '';
148
+ highlightedIndex = -1;
149
+ onadd?.(id);
150
+ emitChange();
151
+ focusInput();
152
+ }
153
+
154
+ function removeValue(id: string): void {
155
+ if (!selectedSet.has(id)) {
156
+ return;
157
+ }
158
+ selected = selected.filter((current) => current !== id);
159
+ onremove?.(id);
160
+ emitChange();
161
+ }
162
+
80
163
  function selectItem(item: ComboboxItem) {
81
164
  if (item.disabled === true) {
82
165
  return;
83
166
  }
167
+ onselect?.(item);
168
+ if (multiple) {
169
+ addValue(item.id);
170
+ return;
171
+ }
84
172
  value = item.id;
85
173
  inputValue = item.label;
86
- onselect?.(item);
87
174
  closeDropdown();
88
175
  }
89
176
 
90
- function getFilteredSelectableIndex(item: ComboboxItem): number {
91
- let selectableIdx = 0;
92
- for (let i = 0; i < filteredItems.length; i++) {
93
- if (filteredItems[i] === item) {
94
- return filteredItems[i].disabled === true ? -1 : selectableIdx;
95
- }
96
- if (filteredItems[i].disabled !== true) {
97
- selectableIdx++;
98
- }
177
+ function create(): void {
178
+ const created = trimmedQuery;
179
+ if (created === '') {
180
+ return;
181
+ }
182
+ oncreate?.(created);
183
+ if (multiple) {
184
+ addValue(created);
185
+ return;
186
+ }
187
+ value = created;
188
+ inputValue = created;
189
+ closeDropdown();
190
+ }
191
+
192
+ function runAction(): void {
193
+ action?.onClick();
194
+ if (!action?.keepOpen) {
195
+ closeDropdown();
99
196
  }
100
- return -1;
101
197
  }
102
198
 
103
199
  async function moveHighlight(delta: number): Promise<void> {
104
- if (selectableItems.length === 0) {
200
+ if (navCount === 0) {
105
201
  return;
106
202
  }
107
203
  let next = highlightedIndex + delta;
108
204
  if (next < 0) {
109
- next = selectableItems.length - 1;
110
- } else if (next >= selectableItems.length) {
205
+ next = navCount - 1;
206
+ } else if (next >= navCount) {
111
207
  next = 0;
112
208
  }
113
209
  highlightedIndex = next;
@@ -121,19 +217,38 @@
121
217
  }
122
218
 
123
219
  function selectHighlighted() {
124
- if (highlightedIndex < 0 || highlightedIndex >= selectableItems.length) {
220
+ if (highlightedIndex < 0 || highlightedIndex >= navCount) {
125
221
  return;
126
222
  }
127
- const item = selectableItems.at(highlightedIndex);
128
- if (typeof item === 'object' && item !== null) {
129
- selectItem(item);
223
+ if (highlightedIndex < navItemCount) {
224
+ const item = selectableItems.at(highlightedIndex);
225
+ if (item) {
226
+ selectItem(item);
227
+ }
228
+ } else if (highlightedIndex === createNavIndex) {
229
+ create();
230
+ } else if (highlightedIndex === actionNavIndex) {
231
+ runAction();
232
+ }
233
+ }
234
+
235
+ function getFilteredSelectableIndex(item: ComboboxItem): number {
236
+ let selectableIdx = 0;
237
+ for (let i = 0; i < filteredItems.length; i++) {
238
+ if (filteredItems[i] === item) {
239
+ return filteredItems[i].disabled === true ? -1 : selectableIdx;
240
+ }
241
+ if (filteredItems[i].disabled !== true) {
242
+ selectableIdx++;
243
+ }
130
244
  }
245
+ return -1;
131
246
  }
132
247
 
133
- function handleInput(val: string, _event: Event) {
248
+ function handleInput(val: string, event: Event) {
134
249
  inputValue = val;
135
250
  oninput?.(val);
136
- inputEventProperties?.onInput?.(val, _event);
251
+ inputEventProperties?.onInput?.(val, event);
137
252
  if (!open) {
138
253
  openDropdown();
139
254
  }
@@ -167,6 +282,17 @@
167
282
  if (open && highlightedIndex >= 0) {
168
283
  event.preventDefault();
169
284
  selectHighlighted();
285
+ } else if (exactMatch && !(multiple && selectedSet.has(exactMatch.id))) {
286
+ event.preventDefault();
287
+ selectItem(exactMatch);
288
+ } else if (showCreate) {
289
+ event.preventDefault();
290
+ create();
291
+ }
292
+ break;
293
+ case 'Backspace':
294
+ if (multiple && inputValue === '' && selected.length > 0) {
295
+ removeValue(selected[selected.length - 1]);
170
296
  }
171
297
  break;
172
298
  case 'Escape':
@@ -194,6 +320,13 @@
194
320
  inputEventProperties?.onBlur?.(event);
195
321
  }
196
322
 
323
+ function handleControlClick() {
324
+ if (multiple && !disabled) {
325
+ focusInput();
326
+ openDropdown();
327
+ }
328
+ }
329
+
197
330
  function handleClickOutside(event: Event) {
198
331
  if (
199
332
  event.target instanceof Node &&
@@ -213,16 +346,26 @@
213
346
  </script>
214
347
 
215
348
  <div class="combobox {classes ?? ''}" class:disabled bind:this={containerEl} data-pw={testId}>
216
- <div class="combobox-input-wrapper">
349
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
350
+ <div class="combobox-input-wrapper" class:multiple onclick={handleControlClick}>
217
351
  {#if typeof inputPrefix === 'function'}
218
352
  <div class="combobox-input-prefix">{@render inputPrefix()}</div>
219
353
  {/if}
354
+ {#if multiple}
355
+ {#each selected as id (id)}
356
+ {#if typeof pillSnippet === 'function'}
357
+ {@render pillSnippet(id, () => !disabled && removeValue(id), disabled)}
358
+ {:else}
359
+ <Pill text={labelOf(id)} dismissible {disabled} ondismiss={() => removeValue(id)} />
360
+ {/if}
361
+ {/each}
362
+ {/if}
220
363
  <div class="combobox-input">
221
364
  <Input
222
365
  {...inputProperties}
223
366
  bind:value={inputValue}
224
367
  bind:this={inputRef}
225
- {placeholder}
368
+ placeholder={multiple && selected.length > 0 ? '' : placeholder}
226
369
  {name}
227
370
  disable={disabled}
228
371
  autoComplete="off"
@@ -249,7 +392,10 @@
249
392
  {#if typeof dropdownHeader === 'function'}
250
393
  <div class="combobox-dropdown-header">{@render dropdownHeader()}</div>
251
394
  {/if}
252
- {#if filteredItems.length === 0}
395
+
396
+ {#if atLimit}
397
+ <div class="combobox-limit" role="alert">{limitText}</div>
398
+ {:else if filteredItems.length === 0 && !showCreate && !action}
253
399
  {#if typeof emptySnippet === 'function'}
254
400
  {@render emptySnippet()}
255
401
  {:else}
@@ -263,11 +409,11 @@
263
409
  <div
264
410
  class="combobox-option"
265
411
  class:highlighted={isHighlighted}
266
- class:selected={item.id === value}
412
+ class:selected={!multiple && item.id === value}
267
413
  class:combobox-option-disabled={item.disabled === true}
268
414
  role="option"
269
415
  id={`${listboxId}-option-${selectableIndex}`}
270
- aria-selected={item.id === value}
416
+ aria-selected={!multiple && item.id === value}
271
417
  aria-disabled={item.disabled === true ? 'true' : null}
272
418
  tabindex="-1"
273
419
  onclick={() => selectItem(item)}
@@ -286,6 +432,56 @@
286
432
  </div>
287
433
  {/each}
288
434
  {/if}
435
+
436
+ {#if showCreate}
437
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
438
+ <div
439
+ class="combobox-option combobox-create"
440
+ class:highlighted={highlightedIndex === createNavIndex}
441
+ class:with-divider={navItemCount > 0}
442
+ role="option"
443
+ id={`${listboxId}-option-${createNavIndex}`}
444
+ aria-selected="false"
445
+ tabindex="-1"
446
+ onclick={() => create()}
447
+ onmouseenter={() => (highlightedIndex = createNavIndex)}
448
+ data-pw={typeof testId === 'string' ? `${testId}-create` : null}
449
+ >
450
+ <span class="combobox-create-icon" aria-hidden="true">
451
+ <svg viewBox="0 0 20 20" width="14" height="14" fill="none">
452
+ <path
453
+ d="M10 4v12M4 10h12"
454
+ stroke="currentColor"
455
+ stroke-width="1.7"
456
+ stroke-linecap="round"
457
+ />
458
+ </svg>
459
+ </span>
460
+ {createLabel(trimmedQuery)}
461
+ </div>
462
+ {/if}
463
+
464
+ {#if action}
465
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
466
+ <div
467
+ class="combobox-option combobox-action"
468
+ class:highlighted={highlightedIndex === actionNavIndex}
469
+ class:with-divider={navItemCount > 0 || showCreate}
470
+ role="option"
471
+ id={`${listboxId}-option-${actionNavIndex}`}
472
+ aria-selected="false"
473
+ tabindex="-1"
474
+ onclick={() => runAction()}
475
+ onmouseenter={() => (highlightedIndex = actionNavIndex)}
476
+ data-pw={typeof testId === 'string' ? `${testId}-action` : null}
477
+ >
478
+ {#if typeof actionIcon === 'function'}
479
+ <span class="combobox-action-icon" aria-hidden="true">{@render actionIcon()}</span>
480
+ {/if}
481
+ {action.label}
482
+ </div>
483
+ {/if}
484
+
289
485
  {#if typeof dropdownFooter === 'function'}
290
486
  <div class="combobox-dropdown-footer">{@render dropdownFooter()}</div>
291
487
  {/if}
@@ -317,6 +513,14 @@
317
513
  transition: var(--combobox-input-transition, border-color 0.15s, box-shadow 0.15s);
318
514
  }
319
515
 
516
+ /* Multi-select control: pills wrap above the typeahead input. */
517
+ .combobox-input-wrapper.multiple {
518
+ flex-wrap: wrap;
519
+ gap: var(--combobox-pill-gap, 4px);
520
+ padding: var(--combobox-multiple-padding, 4px 6px);
521
+ cursor: text;
522
+ }
523
+
320
524
  .combobox-input-wrapper:hover {
321
525
  border-color: var(--combobox-input-hover-border-color, #999999);
322
526
  }
@@ -357,6 +561,12 @@
357
561
  --input-radius: 0;
358
562
  }
359
563
 
564
+ .combobox-input-wrapper.multiple .combobox-input {
565
+ flex: 1 1 60px;
566
+ min-width: 60px;
567
+ --input-padding: var(--combobox-multiple-input-padding, 2px 4px);
568
+ }
569
+
360
570
  .combobox-input::placeholder {
361
571
  color: var(--combobox-placeholder-color, #999999);
362
572
  }
@@ -378,6 +588,9 @@
378
588
  }
379
589
 
380
590
  .combobox-option {
591
+ display: flex;
592
+ align-items: center;
593
+ gap: var(--combobox-option-gap, 8px);
381
594
  padding: var(--combobox-option-padding, 8px 12px);
382
595
  color: var(--combobox-option-color, #333333);
383
596
  font-size: var(--combobox-option-font-size, inherit);
@@ -414,6 +627,41 @@
414
627
  pointer-events: none;
415
628
  }
416
629
 
630
+ .combobox-create {
631
+ color: var(--combobox-create-color, #2563eb);
632
+ }
633
+
634
+ .combobox-action {
635
+ color: var(--combobox-action-color, #374151);
636
+ }
637
+
638
+ .combobox-option.with-divider {
639
+ border-top: var(--combobox-divider, 1px solid #e5e7eb);
640
+ }
641
+
642
+ .combobox-create-icon,
643
+ .combobox-action-icon {
644
+ display: inline-flex;
645
+ align-items: center;
646
+ flex-shrink: 0;
647
+ }
648
+
649
+ .combobox-action-icon :global(svg) {
650
+ width: 14px;
651
+ height: 14px;
652
+ }
653
+
654
+ .combobox-limit {
655
+ display: flex;
656
+ align-items: center;
657
+ gap: 6px;
658
+ padding: var(--combobox-limit-padding, 8px 12px);
659
+ font-size: var(--combobox-limit-font-size, 13px);
660
+ font-weight: 500;
661
+ color: var(--combobox-limit-color, #b45309);
662
+ background: var(--combobox-limit-background, #fffbeb);
663
+ }
664
+
417
665
  .combobox-dropdown-header {
418
666
  border-bottom: var(--combobox-dropdown-header-border, none);
419
667
  padding: var(--combobox-dropdown-header-padding, 0);
@@ -1,6 +1,6 @@
1
1
  import type { ComboboxProperties } from './properties';
2
2
  declare const Combobox: import("svelte").Component<ComboboxProperties, {
3
3
  getInputRef: () => HTMLInputElement | HTMLTextAreaElement | null;
4
- }, "value" | "open" | "inputValue" | "highlightedIndex">;
4
+ }, "value" | "open" | "selected" | "inputValue" | "highlightedIndex">;
5
5
  type Combobox = ReturnType<typeof Combobox>;
6
6
  export default Combobox;
@@ -5,6 +5,13 @@ export type ComboboxItem = {
5
5
  label: string;
6
6
  disabled?: boolean;
7
7
  };
8
+ /** A persistent custom action row rendered at the foot of the dropdown. */
9
+ export type ComboboxAction = {
10
+ label: string;
11
+ onClick: () => void;
12
+ /** Keep the dropdown open after the action runs. Defaults to `false`. */
13
+ keepOpen?: boolean;
14
+ };
8
15
  export type ComboboxProperties = MandatoryComboboxProperties & OptionalComboboxProperties & ComboboxEventProperties;
9
16
  export type MandatoryComboboxProperties = {
10
17
  items: ComboboxItem[];
@@ -30,6 +37,27 @@ export type OptionalComboboxProperties = {
30
37
  inputSuffix?: Snippet;
31
38
  dropdownHeader?: Snippet;
32
39
  dropdownFooter?: Snippet;
40
+ /** Enable multi-select: picked options become removable pills inside the control. */
41
+ multiple?: boolean;
42
+ /** Bindable array of selected ids (multi-select mode). */
43
+ selected?: string[];
44
+ /** Cap the number of selections (multi-select mode). */
45
+ maxSelected?: number;
46
+ /**
47
+ * Message shown in the dropdown once `maxSelected` is reached (option/create rows are hidden).
48
+ * Defaults to "You can select up to {maxSelected}.".
49
+ */
50
+ maxSelectedText?: string;
51
+ /** Custom pill renderer; receives `(value, remove, disabled)`. */
52
+ pillSnippet?: Snippet<[string, () => void, boolean]>;
53
+ /** Show a "Create …" row when the query has no exact match. Default `false`. */
54
+ allowCreate?: boolean;
55
+ /** Build the create-row label from the current query. */
56
+ createLabel?: (query: string) => string;
57
+ /** A persistent custom action row shown at the foot of the dropdown. */
58
+ action?: ComboboxAction;
59
+ /** Custom leading icon for the persistent action row. */
60
+ actionIcon?: Snippet;
33
61
  };
34
62
  export type ComboboxEventProperties = {
35
63
  onselect?: (item: ComboboxItem) => void;
@@ -39,4 +67,12 @@ export type ComboboxEventProperties = {
39
67
  onkeydown?: (event: KeyboardEvent) => void;
40
68
  onfocus?: (event: FocusEvent) => void;
41
69
  onblur?: (event: FocusEvent) => void;
70
+ /** Multi-select: fires whenever the selection changes (add, remove, or create). */
71
+ onchange?: (selected: string[]) => void;
72
+ /** Multi-select: fires when a value is added. */
73
+ onadd?: (value: string) => void;
74
+ /** Multi-select: fires when a value is removed. */
75
+ onremove?: (value: string) => void;
76
+ /** Fires when the create row is chosen, with the trimmed query. */
77
+ oncreate?: (value: string) => void;
42
78
  };
@@ -14,6 +14,7 @@
14
14
  import { formatNumber } from '../_chart/format';
15
15
  import { roundedRectPath, linePath } from '../_chart/paths';
16
16
  import type { LegendItem, TooltipData, LinearScale, BandScale, Point } from '../_chart/types';
17
+ import { DEFAULT_CHART_CORNER_RADIUS } from '../_chart/types';
17
18
 
18
19
  // ── Per-instance uid for SVG <defs> ids ────────────────────────
19
20
  const uid = Math.random().toString(36).slice(2, 9);
@@ -27,7 +28,7 @@
27
28
  rightAxis = {},
28
29
  showGridlines = true,
29
30
  showLegend = true,
30
- barRadius = 3,
31
+ barRadius = DEFAULT_CHART_CORNER_RADIUS,
31
32
  barPadding = 0.25,
32
33
  aspectRatio = 16 / 9,
33
34
  minBarHeight = 2,
@@ -77,7 +77,12 @@ export type OptionalDualAxisBarChartProperties = {
77
77
  showGridlines?: boolean;
78
78
  /** Whether to render the shared legend below the chart. Default `true`. */
79
79
  showLegend?: boolean;
80
- /** Corner radius on column/bar shapes in pixels. Default `3`. */
80
+ /**
81
+ * Corner radius on column/bar shapes in pixels. Defaults to
82
+ * `DEFAULT_CHART_CORNER_RADIUS` (4), mirroring the design system's base
83
+ * `--radius` token. SVG `rx`/`ry` cannot read CSS `var()`, so pass this
84
+ * prop explicitly to track a changed `--radius` at runtime.
85
+ */
81
86
  barRadius?: number;
82
87
  /** Padding between category bands as a fraction of band width (0–1). Default `0.25`. */
83
88
  barPadding?: number;
@@ -4,6 +4,7 @@
4
4
  import ChartTooltip from '../_chart/ChartTooltip.svelte';
5
5
  import { getColor } from '../_chart/colors';
6
6
  import { formatNumber, formatPercent } from '../_chart/format';
7
+ import { DEFAULT_CHART_CORNER_RADIUS } from '../_chart/types';
7
8
 
8
9
  // ── Props ──────────────────────────────────────────────────────
9
10
 
@@ -16,6 +17,7 @@
16
17
  showValueLabels = true,
17
18
  valueFormat,
18
19
  aspectRatio = 16 / 9,
20
+ radius = DEFAULT_CHART_CORNER_RADIUS,
19
21
  testId,
20
22
  classes,
21
23
  empty,
@@ -249,7 +251,7 @@
249
251
  width={stageColumnWidth}
250
252
  height={bh}
251
253
  fill={color}
252
- rx={2}
254
+ rx={radius}
253
255
  aria-label="{stage.category}: {formatLabel(stage)}"
254
256
  onmouseenter={(event) => handleEnter(event, index)}
255
257
  onmousemove={trackMouse}
@@ -21,6 +21,13 @@ export type OptionalFunnelChartProperties = {
21
21
  * Defaults to a light-teal shared palette neutral.
22
22
  */
23
23
  connectorColor?: string;
24
+ /**
25
+ * Corner radius on each stage bar in pixels. Defaults to
26
+ * `DEFAULT_CHART_CORNER_RADIUS` (4), mirroring the design system's base
27
+ * `--radius` token. SVG `rx`/`ry` cannot read CSS `var()`, so pass this
28
+ * prop explicitly to track a changed `--radius` at runtime.
29
+ */
30
+ radius?: number;
24
31
  /**
25
32
  * Horizontal width (in SVG user units relative to total inner width) of each
26
33
  * trapezoidal slope connector. Larger values produce steeper visual drops between stages.
@@ -47,7 +47,13 @@
47
47
  leftIconLabel = 'Leading action',
48
48
  rightIconLabel = 'Trailing action',
49
49
  mandatory = false,
50
- forceError = false
50
+ forceError = false,
51
+ rows,
52
+ autoResize = false,
53
+ minRows,
54
+ maxRows,
55
+ resize = 'none',
56
+ showCount = false
51
57
  }: InputProperties = $props();
52
58
 
53
59
  export function focus() {
@@ -104,6 +110,38 @@
104
110
  const hasLeftIcon = $derived(typeof leftIcon === 'function');
105
111
  const hasRightIcon = $derived(typeof rightIcon === 'function');
106
112
 
113
+ const charCount = $derived(value?.length ?? 0);
114
+ const effectiveResize = $derived(autoResize ? 'none' : resize);
115
+
116
+ // Grow the textarea to fit its content between minRows and maxRows.
117
+ function adjustTextAreaHeight(): void {
118
+ const el = inputElement;
119
+ if (!el || !useTextArea || !autoResize) {
120
+ return;
121
+ }
122
+ el.style.height = 'auto';
123
+ const styles = window.getComputedStyle(el);
124
+ const lineHeight = parseFloat(styles.lineHeight) || 20;
125
+ const verticalPadding = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
126
+ const border = parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
127
+ const lower = minRows ?? rows ?? 2;
128
+ const minHeight = lower * lineHeight + verticalPadding + border;
129
+ const maxHeight =
130
+ maxRows != null ? maxRows * lineHeight + verticalPadding + border : Number.POSITIVE_INFINITY;
131
+ const nextHeight = Math.min(Math.max(el.scrollHeight, minHeight), maxHeight);
132
+ el.style.height = `${nextHeight}px`;
133
+ el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden';
134
+ }
135
+
136
+ // eslint-disable-next-line no-restricted-syntax
137
+ $effect(() => {
138
+ // Re-run on every value change (and on mount) while auto-resize is enabled.
139
+ void value;
140
+ if (useTextArea && autoResize) {
141
+ adjustTextAreaHeight();
142
+ }
143
+ });
144
+
107
145
  function handleOnInput(event: Event) {
108
146
  if (inputElement === null) {
109
147
  return;
@@ -232,8 +270,11 @@
232
270
  onpaste={handleOnPaste}
233
271
  onclick={onClick}
234
272
  onkeydown={onKeyDown}
273
+ data-pw={testId}
235
274
  class:action-input={actionInput}
236
275
  style="--focus-border: {addFocusColor ? 1 : 0}px;"
276
+ style:resize={effectiveResize}
277
+ rows={rows ?? null}
237
278
  disabled={disable}
238
279
  bind:this={inputElement}
239
280
  maxlength={dataType === 'tel' ? null : maxLength}
@@ -321,6 +362,11 @@
321
362
  {infoMessage}
322
363
  </div>
323
364
  {/if}
365
+ {#if useTextArea && showCount && !actionInput}
366
+ <div class="input-char-count" class:at-limit={charCount >= maxLength}>
367
+ {charCount}/{maxLength}
368
+ </div>
369
+ {/if}
324
370
  </div>
325
371
 
326
372
  <style>
@@ -466,6 +512,18 @@
466
512
  padding: var(--input-info-msg-padding);
467
513
  }
468
514
 
515
+ .input-char-count {
516
+ align-self: flex-end;
517
+ font-size: var(--input-char-count-size, 12px);
518
+ color: var(--input-char-count-color, #98a2b3);
519
+ margin: var(--input-char-count-margin, 4px 0 0);
520
+ font-variant-numeric: tabular-nums;
521
+ }
522
+
523
+ .input-char-count.at-limit {
524
+ color: var(--input-char-count-limit-color, var(--input-error-msg-text-color, #fa1405));
525
+ }
526
+
469
527
  ::placeholder {
470
528
  color: var(--input-placeholder-color);
471
529
  }
@@ -22,6 +22,24 @@ export type OptionalInputProperties = {
22
22
  max?: number;
23
23
  actionInput?: boolean;
24
24
  useTextArea?: boolean;
25
+ /** Initial visible rows for the textarea (only applies when `useTextArea`). */
26
+ rows?: number;
27
+ /**
28
+ * Grow/shrink the textarea to fit its content between `minRows` and `maxRows`
29
+ * (only when `useTextArea`). Disables manual resizing while active.
30
+ */
31
+ autoResize?: boolean;
32
+ /** Lower bound (in rows) when `autoResize` is on. Defaults to `rows`. */
33
+ minRows?: number;
34
+ /** Upper bound (in rows) when `autoResize` is on; beyond this the textarea scrolls. */
35
+ maxRows?: number;
36
+ /**
37
+ * Manual resize-handle behaviour for the textarea. Defaults to `'none'` (unchanged from
38
+ * before); forced to `'none'` when `autoResize` is on.
39
+ */
40
+ resize?: 'none' | 'vertical' | 'horizontal' | 'both';
41
+ /** Show a live `current / maxLength` character counter beneath the field. */
42
+ showCount?: boolean;
25
43
  autoComplete?: HTMLInputAttributes['autocomplete'];
26
44
  name?: string;
27
45
  textTransformers?: TextTransformer[];
@@ -44,6 +44,8 @@
44
44
  xTickFormat,
45
45
  yTickFormat,
46
46
  aspectRatio = 16 / 9,
47
+ minHeight = 0,
48
+ maxHeight = Infinity,
47
49
  tooltipSnippet,
48
50
  empty,
49
51
  highlightedIndex = null,
@@ -352,7 +354,13 @@
352
354
  <Legend items={legendItems} position="top" />
353
355
  {/if}
354
356
 
355
- <ChartContainer bind:width={chartWidth} bind:height={chartHeight} {aspectRatio}>
357
+ <ChartContainer
358
+ bind:width={chartWidth}
359
+ bind:height={chartHeight}
360
+ {aspectRatio}
361
+ {minHeight}
362
+ {maxHeight}
363
+ >
356
364
  {#if gradientFill || showArea}
357
365
  <defs>
358
366
  {#each lines as line, si (si)}
@@ -95,6 +95,10 @@ export type OptionalLineChartProperties = {
95
95
  yTickFormat?: (value: number | string) => string;
96
96
  /** Width-to-height ratio for the chart. */
97
97
  aspectRatio?: number;
98
+ /** Minimum chart height in pixels, regardless of computed aspect-ratio height. */
99
+ minHeight?: number;
100
+ /** Maximum chart height in pixels, regardless of computed aspect-ratio height. */
101
+ maxHeight?: number;
98
102
  /** Custom tooltip. Receives `{x, points: [{name, y, color, label?}]}`. */
99
103
  tooltipSnippet?: Snippet<[LineChartTooltipContext]>;
100
104
  /** Content rendered when all series are empty. */
@@ -107,7 +107,7 @@
107
107
  .pill-text {
108
108
  overflow: hidden;
109
109
  text-overflow: var(--pill-text-overflow, ellipsis);
110
- white-space: nowrap;
110
+ white-space: var(--pill-text-white-space, nowrap);
111
111
  }
112
112
 
113
113
  .pill-leading-icon {
@@ -5,6 +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 { DEFAULT_CHART_CORNER_RADIUS } from '../_chart/types';
8
9
  import { SvelteMap, SvelteSet } from 'svelte/reactivity';
9
10
 
10
11
  // ── Props ──────────────────────────────────────────────────────
@@ -18,6 +19,7 @@
18
19
  showValues = false,
19
20
  showLabels = true,
20
21
  aspectRatio = 16 / 9,
22
+ radius = DEFAULT_CHART_CORNER_RADIUS,
21
23
  maxHeight = Infinity,
22
24
  valueFormat,
23
25
  tooltipSnippet,
@@ -343,8 +345,8 @@
343
345
  y={node.y}
344
346
  width={node.width}
345
347
  height={node.height}
346
- rx={2}
347
- ry={2}
348
+ rx={radius}
349
+ ry={radius}
348
350
  fill={color}
349
351
  onmouseenter={(e) => handleNodeEnter(e, node.id)}
350
352
  onmousemove={trackMouse}
@@ -41,6 +41,13 @@ export type OptionalSankeyChartProperties = {
41
41
  nodeWidth?: number;
42
42
  nodePadding?: number;
43
43
  iterations?: number;
44
+ /**
45
+ * Corner radius on each node rect in pixels. Defaults to
46
+ * `DEFAULT_CHART_CORNER_RADIUS` (4), mirroring the design system's base
47
+ * `--radius` token. SVG `rx`/`ry` cannot read CSS `var()`, so pass this
48
+ * prop explicitly to track a changed `--radius` at runtime.
49
+ */
50
+ radius?: number;
44
51
  showValues?: boolean;
45
52
  showLabels?: boolean;
46
53
  aspectRatio?: number;
@@ -17,6 +17,7 @@
17
17
  optionIndicator,
18
18
  showSelectAll = false,
19
19
  selectAllLabel = 'Select all',
20
+ showSelectedTick = false,
20
21
  triggerSummary,
21
22
  testId,
22
23
  itemTestId,
@@ -443,6 +444,7 @@
443
444
  <div
444
445
  class="select-option"
445
446
  class:multi={multiple}
447
+ class:tickable={showSelectedTick && !multiple}
446
448
  class:selected={value.includes(row.item.id)}
447
449
  class:highlighted={index === highlightedIndex}
448
450
  role="option"
@@ -478,7 +480,11 @@
478
480
  </span>
479
481
  {/if}
480
482
  {/if}
481
- {row.item.label}
483
+ <span class="select-option-label">{row.item.label}</span>
484
+ {#if showSelectedTick && !multiple && value.includes(row.item.id)}
485
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
486
+ <span class="select-option-tick" aria-hidden="true">{@html checkmarkSvg}</span>
487
+ {/if}
482
488
  </div>
483
489
  {/if}
484
490
  {/each}
@@ -740,4 +746,31 @@
740
746
  background: var(--select-ghost-trigger-open-background, rgba(0, 0, 0, 0.06));
741
747
  box-shadow: none;
742
748
  }
749
+
750
+ /* Single-select right-edge tick (showSelectedTick) */
751
+ .select-option.tickable {
752
+ display: flex;
753
+ align-items: center;
754
+ gap: 8px;
755
+ }
756
+
757
+ .select-option.tickable .select-option-label {
758
+ flex: 1 1 auto;
759
+ min-width: 0;
760
+ }
761
+
762
+ .select-option-tick {
763
+ display: inline-flex;
764
+ align-items: center;
765
+ justify-content: center;
766
+ flex-shrink: 0;
767
+ width: var(--select-option-tick-size, 16px);
768
+ height: var(--select-option-tick-size, 16px);
769
+ color: var(--select-option-tick-color, #2563eb);
770
+ }
771
+
772
+ .select-option-tick :global(svg) {
773
+ width: 100%;
774
+ height: 100%;
775
+ }
743
776
  </style>
@@ -40,6 +40,13 @@ export type OptionalSelectProperties = {
40
40
  showSelectAll?: boolean;
41
41
  /** Label for the `showSelectAll` row. Defaults to `'Select all'`. */
42
42
  selectAllLabel?: string;
43
+ /**
44
+ * Single-select only: when `true`, the currently selected option shows a
45
+ * checkmark at its right edge. No effect in `multiple` mode (which already
46
+ * renders a checkbox indicator). Themeable via `--select-option-tick-size`
47
+ * and `--select-option-tick-color`. Defaults to `false`.
48
+ */
49
+ showSelectedTick?: boolean;
43
50
  testId?: string;
44
51
  /** Fallback per-option test id prefix. Each option emits `data-pw="{itemTestId}-{id}"` when its own `item.testId` is not set. */
45
52
  itemTestId?: string;
@@ -2,10 +2,47 @@
2
2
  import type { ChartTooltipProperties } from './types';
3
3
 
4
4
  let { data, mouseX = 0, mouseY = 0, customSnippet, classes }: ChartTooltipProperties = $props();
5
+
6
+ const OFFSET = 12;
7
+
8
+ let tooltipEl = $state<HTMLDivElement | null>(null);
9
+ let tooltipWidth = $state(0);
10
+ let tooltipHeight = $state(0);
11
+
12
+ // Clamp against the positioned chart container so the tooltip never spills past
13
+ // (and gets clipped by) an overflow:hidden edge. Re-read on each measure/move.
14
+ const containerWidth = $derived(tooltipEl?.offsetParent?.clientWidth ?? Number.POSITIVE_INFINITY);
15
+ const containerHeight = $derived(
16
+ tooltipEl?.offsetParent?.clientHeight ?? Number.POSITIVE_INFINITY
17
+ );
18
+
19
+ // Horizontal: flip to the left of the cursor when it would overflow the right edge.
20
+ const left = $derived.by(() => {
21
+ let value = mouseX + OFFSET;
22
+ if (value + tooltipWidth > containerWidth) {
23
+ value = mouseX - tooltipWidth - OFFSET;
24
+ }
25
+ return Math.max(0, value);
26
+ });
27
+
28
+ // Vertical: keep the tooltip within the container's top and bottom edges.
29
+ const top = $derived.by(() => {
30
+ let value = mouseY - OFFSET;
31
+ if (value + tooltipHeight > containerHeight) {
32
+ value = containerHeight - tooltipHeight;
33
+ }
34
+ return Math.max(0, value);
35
+ });
5
36
  </script>
6
37
 
7
38
  {#if data !== null}
8
- <div class="chart-tooltip {classes ?? ''}" style="left: {mouseX + 12}px; top: {mouseY - 12}px;">
39
+ <div
40
+ bind:this={tooltipEl}
41
+ bind:clientWidth={tooltipWidth}
42
+ bind:clientHeight={tooltipHeight}
43
+ class="chart-tooltip {classes ?? ''}"
44
+ style="left: {left}px; top: {top}px;"
45
+ >
9
46
  {#if typeof customSnippet === 'function'}
10
47
  {@render customSnippet(data)}
11
48
  {:else}
@@ -60,7 +97,7 @@
60
97
  display: inline-block;
61
98
  width: 8px;
62
99
  height: 8px;
63
- border-radius: 2px;
100
+ border-radius: var(--chart-swatch-radius, 2px);
64
101
  flex-shrink: 0;
65
102
  }
66
103
 
@@ -48,7 +48,7 @@
48
48
  display: inline-block;
49
49
  width: var(--chart-legend-swatch-size, 12px);
50
50
  height: var(--chart-legend-swatch-size, 12px);
51
- border-radius: 2px;
51
+ border-radius: var(--chart-swatch-radius, 2px);
52
52
  flex-shrink: 0;
53
53
  }
54
54
 
@@ -128,7 +128,7 @@ export function computeSankeyLayout(nodes, links, width, height, nodeWidth = 16,
128
128
  nodeY.set(id, Math.max(0, weightedY - (nodeH.get(id) ?? 0) / 2));
129
129
  }
130
130
  }
131
- // Resolve overlaps
131
+ // Resolve overlaps: push down from the top.
132
132
  ids.sort((a, b) => (nodeY.get(a) ?? 0) - (nodeY.get(b) ?? 0));
133
133
  let y = 0;
134
134
  for (const id of ids) {
@@ -138,6 +138,37 @@ export function computeSankeyLayout(nodes, links, width, height, nodeWidth = 16,
138
138
  }
139
139
  y = (nodeY.get(id) ?? 0) + (nodeH.get(id) ?? 0) + nodePadding;
140
140
  }
141
+ // Resolve overlaps: pull back up from the bottom. The push-down pass
142
+ // above only ever grows a column's block downward, so a fan-out whose
143
+ // members share a weighted target centre drifts past the column's
144
+ // height budget column-to-column instead of staying level. Sweep from
145
+ // the last node up, clamping each node's bottom edge to the running
146
+ // boundary, mirroring d3-sankey's bidirectional resolveCollisions.
147
+ let bottomBoundary = height;
148
+ for (let index = ids.length - 1; index >= 0; index--) {
149
+ const id = ids[index];
150
+ const nodeBottom = (nodeY.get(id) ?? 0) + (nodeH.get(id) ?? 0);
151
+ if (nodeBottom > bottomBoundary) {
152
+ nodeY.set(id, bottomBoundary - (nodeH.get(id) ?? 0));
153
+ }
154
+ bottomBoundary = (nodeY.get(id) ?? 0) - nodePadding;
155
+ }
156
+ // Re-centre the column's node group within [0, height]: once overlaps
157
+ // are resolved, anchor the block at the midpoint of its remaining
158
+ // slack rather than leaving it wherever the top-down/bottom-up sweeps
159
+ // happened to land it, so a cluster sharing a weighted centre reads as
160
+ // centred on that target instead of stacked toward one edge.
161
+ const firstId = ids[0];
162
+ const lastId = ids[ids.length - 1];
163
+ const groupTop = nodeY.get(firstId) ?? 0;
164
+ const groupBottom = (nodeY.get(lastId) ?? 0) + (nodeH.get(lastId) ?? 0);
165
+ const idealGroupTop = Math.max(0, (height - (groupBottom - groupTop)) / 2);
166
+ const recentreShift = idealGroupTop - groupTop;
167
+ if (recentreShift !== 0) {
168
+ for (const id of ids) {
169
+ nodeY.set(id, (nodeY.get(id) ?? 0) + recentreShift);
170
+ }
171
+ }
141
172
  }
142
173
  }
143
174
  // Build computed nodes
@@ -1,5 +1,15 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { BarChartDataPoint } from '../BarChart/properties';
3
+ /**
4
+ * Corner radius (px) shared by every chart shape (bar/column rects, funnel
5
+ * stage bars, sankey nodes). Mirrors Lighthouse's `--radius` design token
6
+ * (0.25rem = 4px at the 16px root). SVG `rx`/`ry` attributes and the
7
+ * `roundedRectPath()` curve builder in `_chart/paths.ts` consume plain JS
8
+ * numbers, so this constant is the chart-layer equivalent of `var(--radius)`
9
+ * for surfaces CSS cannot reach. Consumers who need runtime sync to a
10
+ * *changed* `--radius` should pass the corresponding radius prop explicitly.
11
+ */
12
+ export declare const DEFAULT_CHART_CORNER_RADIUS = 4;
3
13
  export type Margin = {
4
14
  top: number;
5
15
  right: number;
@@ -1 +1,11 @@
1
- export {};
1
+ // ── Shared constants ──────────────────────────────────────────
2
+ /**
3
+ * Corner radius (px) shared by every chart shape (bar/column rects, funnel
4
+ * stage bars, sankey nodes). Mirrors Lighthouse's `--radius` design token
5
+ * (0.25rem = 4px at the 16px root). SVG `rx`/`ry` attributes and the
6
+ * `roundedRectPath()` curve builder in `_chart/paths.ts` consume plain JS
7
+ * numbers, so this constant is the chart-layer equivalent of `var(--radius)`
8
+ * for surfaces CSS cannot reach. Consumers who need runtime sync to a
9
+ * *changed* `--radius` should pass the corresponding radius prop explicitly.
10
+ */
11
+ export const DEFAULT_CHART_CORNER_RADIUS = 4;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.80.3",
3
+ "version": "2.80.5",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",