@juspay/svelte-ui-components 2.80.2 → 2.80.4

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.
@@ -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,9 +28,12 @@
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,
34
+ minBarHeight = 2,
35
+ margin,
36
+ tooltipPortal = false,
33
37
  tooltipSnippet,
34
38
  onbarclick,
35
39
  testId,
@@ -44,6 +48,8 @@
44
48
  let hoveredCategoryIndex = $state<number | null>(null);
45
49
  let mouseX = $state(0);
46
50
  let mouseY = $state(0);
51
+ let mouseClientX = $state(0);
52
+ let mouseClientY = $state(0);
47
53
 
48
54
  // ── Formatters ─────────────────────────────────────────────────
49
55
 
@@ -53,7 +59,13 @@
53
59
  // ── Layout — wider right margin to accommodate right-axis labels ─
54
60
 
55
61
  const dims = $derived(
56
- computeChartDimensions(chartWidth, chartHeight, { top: 24, right: 56, bottom: 40, left: 56 })
62
+ computeChartDimensions(chartWidth, chartHeight, {
63
+ top: 24,
64
+ right: 56,
65
+ bottom: 40,
66
+ left: 56,
67
+ ...margin
68
+ })
57
69
  );
58
70
 
59
71
  // ── Scales ─────────────────────────────────────────────────────
@@ -152,7 +164,7 @@
152
164
  const barX = bandStart + subIndex * subBandWidth + barGap;
153
165
  const valueY = scale(value);
154
166
  const barY = value >= 0 ? valueY : zeroY;
155
- const barHeight = Math.max(2, Math.abs(valueY - zeroY));
167
+ const barHeight = Math.max(minBarHeight, Math.abs(valueY - zeroY));
156
168
 
157
169
  const path = roundedRectPath(barX, barY, barW, barHeight, barRadius, barRadius, 0, 0);
158
170
 
@@ -278,6 +290,22 @@
278
290
  const rect = containerEl.getBoundingClientRect();
279
291
  mouseX = event.clientX - rect.left;
280
292
  mouseY = event.clientY - rect.top;
293
+ mouseClientX = event.clientX;
294
+ mouseClientY = event.clientY;
295
+ };
296
+
297
+ /**
298
+ * Svelte action: relocates the tooltip layer to `document.body` so a `position:fixed`
299
+ * tooltip is never clipped by an `overflow`/scroll ancestor. Used only when
300
+ * `tooltipPortal` is set; `use:` actions never run during SSR.
301
+ */
302
+ const portalToBody = (node: HTMLElement) => {
303
+ document.body.appendChild(node);
304
+ return {
305
+ destroy: () => {
306
+ node.remove();
307
+ }
308
+ };
281
309
  };
282
310
 
283
311
  const handleCategoryEnter = (event: MouseEvent, catIdx: number) => {
@@ -439,12 +467,31 @@
439
467
  </ChartContainer>
440
468
 
441
469
  <!-- Tooltip -->
442
- {#if typeof tooltipSnippet === 'function' && hoveredCategoryIndex !== null}
443
- <div class="chart-tooltip-slot" style="left: {mouseX + 12}px; top: {mouseY - 12}px;">
444
- {@render tooltipSnippet(buildTooltipContext(hoveredCategoryIndex))}
445
- </div>
446
- {:else}
447
- <ChartTooltip data={tooltipData} {mouseX} {mouseY} />
470
+ {#if hoveredCategoryIndex !== null}
471
+ {#if tooltipPortal}
472
+ <!-- Portaled to <body> with fixed viewport coords so the tooltip is never
473
+ clipped by an overflow/scroll ancestor (e.g. a scrollable report sheet).
474
+ The inner elements keep their own +12/-12 offset relative to this layer. -->
475
+ <div
476
+ class="chart-tooltip-portal"
477
+ style="left: {mouseClientX}px; top: {mouseClientY}px;"
478
+ use:portalToBody
479
+ >
480
+ {#if typeof tooltipSnippet === 'function'}
481
+ <div class="chart-tooltip-slot" style="left: 12px; top: -12px;">
482
+ {@render tooltipSnippet(buildTooltipContext(hoveredCategoryIndex))}
483
+ </div>
484
+ {:else}
485
+ <ChartTooltip data={tooltipData} mouseX={0} mouseY={0} />
486
+ {/if}
487
+ </div>
488
+ {:else if typeof tooltipSnippet === 'function'}
489
+ <div class="chart-tooltip-slot" style="left: {mouseX + 12}px; top: {mouseY - 12}px;">
490
+ {@render tooltipSnippet(buildTooltipContext(hoveredCategoryIndex))}
491
+ </div>
492
+ {:else}
493
+ <ChartTooltip data={tooltipData} {mouseX} {mouseY} />
494
+ {/if}
448
495
  {/if}
449
496
  {:else}
450
497
  <div class="chart-empty">No data available.</div>
@@ -513,6 +560,12 @@
513
560
  pointer-events: none;
514
561
  }
515
562
 
563
+ .chart-tooltip-portal {
564
+ position: fixed;
565
+ z-index: var(--chart-tooltip-z-index, 10);
566
+ pointer-events: none;
567
+ }
568
+
516
569
  .chart-empty {
517
570
  padding: var(--chart-empty-padding, 32px 24px);
518
571
  color: var(--chart-empty-color, #9ca3af);
@@ -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;
@@ -95,6 +100,32 @@ export type OptionalDualAxisBarChartProperties = {
95
100
  testId?: string;
96
101
  /** Extra CSS class string on the root `<div>`. */
97
102
  classes?: string;
103
+ /**
104
+ * Minimum rendered bar height in pixels. The default `2` keeps very small non-zero
105
+ * values visible, but it also paints a 2px stub for genuine zeros — set `0` so an
106
+ * all-zero/dormant series renders nothing (letting the consumer detect it and show
107
+ * an empty state instead of a row of indistinguishable baseline stubs). Default `2`.
108
+ */
109
+ minBarHeight?: number;
110
+ /**
111
+ * Override the plot margins (px) reserved for axis titles and tick labels. Merged
112
+ * over the defaults `{ top: 24, right: 56, bottom: 40, left: 56 }`; widen `left`/`right`
113
+ * when long currency tick labels (e.g. `₹1,00,000`) would otherwise overlap the
114
+ * gridlines in a narrow container.
115
+ */
116
+ margin?: {
117
+ top?: number;
118
+ right?: number;
119
+ bottom?: number;
120
+ left?: number;
121
+ };
122
+ /**
123
+ * Render the hover tooltip on `document.body` with `position: fixed` viewport
124
+ * coordinates instead of `position: absolute` inside the chart. Set `true` when the
125
+ * chart sits in an `overflow:auto`/scrollable container that would otherwise clip the
126
+ * tooltip. Default `false` (in-chart positioning, unchanged).
127
+ */
128
+ tooltipPortal?: boolean;
98
129
  };
99
130
  export type DualAxisBarChartEventProperties = {
100
131
  /**