@juspay/svelte-ui-components 2.96.0 → 2.96.1

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.
@@ -2,6 +2,7 @@
2
2
  import { tick, onMount } from 'svelte';
3
3
  import { SvelteMap } from 'svelte/reactivity';
4
4
  import Img from '../Img/Img.svelte';
5
+ import { computeMenuDropdownPosition } from './dropdownPosition';
5
6
  import type { MenuProperties, MenuItem, MenuPlacement } from './properties';
6
7
 
7
8
  let {
@@ -17,7 +18,8 @@
17
18
  role: menuRole = 'menu',
18
19
  ariaLabel: menuAriaLabel,
19
20
  id: menuId,
20
- placement = 'bottom-left'
21
+ placement = 'bottom-left',
22
+ usePortal = false
21
23
  }: MenuProperties = $props();
22
24
 
23
25
  let itemRole = $derived(menuRole === 'listbox' ? 'option' : 'menuitem');
@@ -25,6 +27,11 @@
25
27
  let menuContainerEl: HTMLDivElement | null = $state(null);
26
28
  let menuListEl: HTMLDivElement | null = $state(null);
27
29
  let triggerEl: HTMLDivElement | null = $state(null);
30
+ let dropdownWidth = $state(0);
31
+ let dropdownHeight = $state(0);
32
+ // Portal placement reads untracked DOM (container rect, viewport); bump on
33
+ // scroll/resize so the derived style re-runs while the menu is open.
34
+ let portalTick = $state(0);
28
35
  let focusedIndex: number = $state(-1);
29
36
  let typeaheadQuery: string = $state('');
30
37
  let typeaheadTimer: ReturnType<typeof setTimeout> | null = $state(null);
@@ -65,6 +72,82 @@
65
72
  return `${vertical}-${horizontal}`;
66
73
  }
67
74
 
75
+ // Gap between trigger and portaled panel, matching the --menu-margin default.
76
+ const PORTAL_MENU_GAP = 4;
77
+
78
+ // Keep the portaled panel anchored to its trigger while the page scrolls or
79
+ // resizes. Mirrors the chart-tooltip portal pattern; $effect is the sanctioned
80
+ // reactive escape hatch here for untracked window listeners. Reposition work is
81
+ // coalesced into one animation frame so fast/inertial scrolling can't thrash
82
+ // layout with a getBoundingClientRect on every event.
83
+ // eslint-disable-next-line no-restricted-syntax
84
+ $effect(() => {
85
+ if (!usePortal || !open || typeof window === 'undefined') {
86
+ return;
87
+ }
88
+ let frame: number | null = null;
89
+ const bump = (): void => {
90
+ if (frame !== null) {
91
+ return;
92
+ }
93
+ frame = requestAnimationFrame(() => {
94
+ frame = null;
95
+ portalTick += 1;
96
+ });
97
+ };
98
+ window.addEventListener('scroll', bump, { capture: true, passive: true });
99
+ window.addEventListener('resize', bump);
100
+ return () => {
101
+ window.removeEventListener('scroll', bump, { capture: true });
102
+ window.removeEventListener('resize', bump);
103
+ if (frame !== null) {
104
+ cancelAnimationFrame(frame);
105
+ }
106
+ };
107
+ });
108
+
109
+ /**
110
+ * Svelte action: relocates the dropdown to document.body when usePortal is set,
111
+ * so a position:fixed panel is never clipped by an overflow/scroll ancestor
112
+ * (e.g. a table cell). No-op otherwise; `use:` actions never run during SSR.
113
+ */
114
+ const portalToBody = (node: HTMLElement) => {
115
+ if (!usePortal) {
116
+ return;
117
+ }
118
+ document.body.appendChild(node);
119
+ return { destroy: () => node.remove() };
120
+ };
121
+
122
+ let portalStyle = $derived.by(() => {
123
+ if (!usePortal || !open || menuContainerEl === null) {
124
+ return '';
125
+ }
126
+ void portalTick;
127
+ const containerRect = menuContainerEl.getBoundingClientRect();
128
+ const viewport =
129
+ typeof window === 'undefined'
130
+ ? { width: Number.POSITIVE_INFINITY, height: Number.POSITIVE_INFINITY }
131
+ : { width: window.innerWidth, height: window.innerHeight };
132
+ const { left, top } = computeMenuDropdownPosition({
133
+ container: {
134
+ left: containerRect.left,
135
+ right: containerRect.right,
136
+ top: containerRect.top,
137
+ bottom: containerRect.bottom
138
+ },
139
+ dropdown: { width: dropdownWidth, height: dropdownHeight },
140
+ placement: resolvedPlacement,
141
+ gap: PORTAL_MENU_GAP,
142
+ viewport
143
+ });
144
+ // Inline wins over the corner-class anchoring, so the portaled panel is
145
+ // driven entirely by these fixed coordinates. Default into the top-layer
146
+ // z-index band (root stacking context competes with modals/sheets); consumers
147
+ // still override via --menu-z-index.
148
+ return `position:fixed;left:${left}px;top:${top}px;right:auto;bottom:auto;margin:0;z-index:var(--menu-z-index,1000);`;
149
+ });
150
+
68
151
  let selectableItems: MenuItem[] = $derived(
69
152
  items.filter((item) => item.separator !== true && item.disabled !== true)
70
153
  );
@@ -231,11 +314,15 @@
231
314
  }
232
315
 
233
316
  function handleClickOutside(event: Event) {
317
+ // A portaled panel lives outside menuContainerEl, so a click on a separator
318
+ // or padding inside it is not contained — treat the panel node as "inside"
319
+ // too, matching the in-flow behaviour of not closing on such clicks.
234
320
  if (
235
321
  open &&
236
322
  event.target instanceof Node &&
237
323
  menuContainerEl !== null &&
238
- !menuContainerEl.contains(event.target)
324
+ !menuContainerEl.contains(event.target) &&
325
+ !(menuListEl !== null && menuListEl.contains(event.target))
239
326
  ) {
240
327
  close();
241
328
  }
@@ -277,11 +364,15 @@
277
364
  class="menu-dropdown menu-dropdown-{placement === 'auto' ? resolvedPlacement : placement}"
278
365
  class:menu-dropdown-measuring={measuringPlacement}
279
366
  bind:this={menuListEl}
367
+ bind:clientWidth={dropdownWidth}
368
+ bind:clientHeight={dropdownHeight}
280
369
  role={menuRole}
281
370
  id={menuId}
282
371
  aria-label={menuAriaLabel}
283
372
  tabindex="-1"
373
+ style={portalStyle}
284
374
  onkeydown={handleMenuKeydown}
375
+ use:portalToBody
285
376
  >
286
377
  {#each items as item (item.value)}
287
378
  {#if item.separator === true}
@@ -0,0 +1,31 @@
1
+ type Rect = {
2
+ left: number;
3
+ right: number;
4
+ top: number;
5
+ bottom: number;
6
+ };
7
+ type Size = {
8
+ width: number;
9
+ height: number;
10
+ };
11
+ export type MenuDropdownCorner = 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';
12
+ /**
13
+ * Pure placement for a portaled Menu dropdown. Returns viewport coordinates for
14
+ * a `position: fixed` panel anchored to `container` at the resolved corner,
15
+ * reproducing the in-flow CSS anchoring: `*-left` aligns the panel's left edge
16
+ * to the container's left, `*-right` aligns its right edge to the container's
17
+ * right; `bottom-*` sits below the container, `top-*` above it. A `gap` is added
18
+ * between panel and container, and the result is clamped to a viewport margin.
19
+ */
20
+ export declare function computeMenuDropdownPosition(opts: {
21
+ container: Rect;
22
+ dropdown: Size;
23
+ placement: MenuDropdownCorner;
24
+ gap: number;
25
+ viewport: Size;
26
+ margin?: number;
27
+ }): {
28
+ left: number;
29
+ top: number;
30
+ };
31
+ export {};
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Pure placement for a portaled Menu dropdown. Returns viewport coordinates for
3
+ * a `position: fixed` panel anchored to `container` at the resolved corner,
4
+ * reproducing the in-flow CSS anchoring: `*-left` aligns the panel's left edge
5
+ * to the container's left, `*-right` aligns its right edge to the container's
6
+ * right; `bottom-*` sits below the container, `top-*` above it. A `gap` is added
7
+ * between panel and container, and the result is clamped to a viewport margin.
8
+ */
9
+ export function computeMenuDropdownPosition(opts) {
10
+ const margin = opts.margin ?? 8;
11
+ const { container, dropdown, placement, gap, viewport } = opts;
12
+ const anchorsRight = placement === 'bottom-right' || placement === 'top-right';
13
+ const anchorsTop = placement === 'top-left' || placement === 'top-right';
14
+ let left = anchorsRight ? container.right - dropdown.width : container.left;
15
+ let top = anchorsTop ? container.top - dropdown.height - gap : container.bottom + gap;
16
+ if (Number.isFinite(viewport.width)) {
17
+ left = Math.max(margin, Math.min(left, viewport.width - dropdown.width - margin));
18
+ }
19
+ if (Number.isFinite(viewport.height)) {
20
+ top = Math.max(margin, Math.min(top, viewport.height - dropdown.height - margin));
21
+ }
22
+ return { left, top };
23
+ }
@@ -38,6 +38,19 @@ export type OptionalMenuProperties = {
38
38
  * / `--menu-dropdown-left` consumer tokens). Fixed corners anchor statically;
39
39
  * `'auto'` resolves the best-fitting corner against the viewport on open. */
40
40
  placement?: MenuPlacement;
41
+ /**
42
+ * When `true`, the dropdown panel is portaled to `document.body` and positioned
43
+ * `fixed` at the resolved `placement` corner, so an ancestor with
44
+ * `overflow: hidden` or a scroll container (e.g. a table cell) cannot clip it.
45
+ * Placement follows the trigger on scroll/resize. Defaults to `false` (in-flow
46
+ * `position: absolute`), which preserves the existing behaviour — including any
47
+ * consumer CSS that targets `.menu-dropdown` via an ancestor selector, since
48
+ * that only resolves while the panel stays inside the `.menu-container`. Opt in
49
+ * for Menus rendered inside clipping containers. When portaled the panel defaults
50
+ * to `z-index: 1000` (top-layer band); raise `--menu-z-index` if it must sit
51
+ * above an even higher overlay.
52
+ */
53
+ usePortal?: boolean;
41
54
  };
42
55
  export type MenuEventProperties = {
43
56
  onselect?: (item: MenuItem) => void;
@@ -3,6 +3,7 @@
3
3
  import type { SelectItem, SelectProperties } from './properties';
4
4
  import Pill from '../Pill/Pill.svelte';
5
5
  import Img from '../Img/Img.svelte';
6
+ import { computeSelectDropdownPosition } from './dropdownPosition';
6
7
  import chevronDownSvg from '../assets/chevron-down.svg?raw';
7
8
  import checkmarkSvg from '../assets/checkmark.svg?raw';
8
9
 
@@ -29,7 +30,8 @@
29
30
  dropdownAlign = 'left',
30
31
  hierarchy = 'default',
31
32
  leftIcon,
32
- leftIconTestId
33
+ leftIconTestId,
34
+ usePortal = false
33
35
  }: SelectProperties = $props();
34
36
 
35
37
  function normalizeItems(source: SelectItem[] | string[]): SelectItem[] {
@@ -42,6 +44,16 @@
42
44
  let containerEl: HTMLDivElement | null = $state(null);
43
45
  let searchInputEl: HTMLInputElement | null = $state(null);
44
46
  let triggerEl: HTMLDivElement | null = $state(null);
47
+ let dropdownEl: HTMLDivElement | null = $state(null);
48
+ let dropdownWidth = $state(0);
49
+ let dropdownHeight = $state(0);
50
+ // Portal placement reads untracked DOM (trigger rect, viewport size); bump on
51
+ // scroll/resize so the derived style re-runs while the dropdown is open.
52
+ let portalTick = $state(0);
53
+
54
+ // Gap between trigger and portaled panel, matching the --select-dropdown-gap
55
+ // default. The in-flow panel still honours the CSS var via its margin-top.
56
+ const PORTAL_DROPDOWN_GAP = 4;
45
57
 
46
58
  const listboxId = `select-listbox-${Math.random().toString(36).slice(2, 9)}`;
47
59
 
@@ -95,6 +107,77 @@
95
107
 
96
108
  let searchPlaceholder = $derived(open && displayText.length > 0 ? displayText : placeholder);
97
109
 
110
+ // Keep the portaled panel anchored to its trigger while the page scrolls or
111
+ // resizes. Mirrors the chart-tooltip portal pattern; $effect is the sanctioned
112
+ // reactive escape hatch here for untracked window listeners. Reposition work is
113
+ // coalesced into one animation frame so fast/inertial scrolling can't thrash
114
+ // layout with a getBoundingClientRect on every event.
115
+ // eslint-disable-next-line no-restricted-syntax
116
+ $effect(() => {
117
+ if (!usePortal || !open || typeof window === 'undefined') {
118
+ return;
119
+ }
120
+ let frame: number | null = null;
121
+ const bump = (): void => {
122
+ if (frame !== null) {
123
+ return;
124
+ }
125
+ frame = requestAnimationFrame(() => {
126
+ frame = null;
127
+ portalTick += 1;
128
+ });
129
+ };
130
+ window.addEventListener('scroll', bump, { capture: true, passive: true });
131
+ window.addEventListener('resize', bump);
132
+ return () => {
133
+ window.removeEventListener('scroll', bump, { capture: true });
134
+ window.removeEventListener('resize', bump);
135
+ if (frame !== null) {
136
+ cancelAnimationFrame(frame);
137
+ }
138
+ };
139
+ });
140
+
141
+ /**
142
+ * Svelte action: relocates the dropdown to document.body when usePortal is set,
143
+ * so a position:fixed panel is never clipped by an overflow/scroll ancestor
144
+ * (e.g. a table cell). No-op otherwise; `use:` actions never run during SSR.
145
+ */
146
+ const portalToBody = (node: HTMLElement) => {
147
+ if (!usePortal) {
148
+ return;
149
+ }
150
+ document.body.appendChild(node);
151
+ return { destroy: () => node.remove() };
152
+ };
153
+
154
+ let portalStyle = $derived.by(() => {
155
+ if (!usePortal || !open || triggerEl === null) {
156
+ return '';
157
+ }
158
+ void portalTick;
159
+ const rect = triggerEl.getBoundingClientRect();
160
+ const viewport =
161
+ typeof window === 'undefined'
162
+ ? { width: Number.POSITIVE_INFINITY, height: Number.POSITIVE_INFINITY }
163
+ : { width: window.innerWidth, height: window.innerHeight };
164
+ const placement = computeSelectDropdownPosition({
165
+ trigger: {
166
+ left: rect.left,
167
+ right: rect.right,
168
+ top: rect.top,
169
+ bottom: rect.bottom,
170
+ width: rect.width
171
+ },
172
+ dropdown: { width: dropdownWidth, height: dropdownHeight },
173
+ viewport,
174
+ align: dropdownAlign,
175
+ gap: PORTAL_DROPDOWN_GAP
176
+ });
177
+ const widthRule = placement.width === null ? '' : `width:${placement.width}px;`;
178
+ return `top:${placement.top}px;left:${placement.left}px;min-width:${placement.minWidth}px;${widthRule}`;
179
+ });
180
+
98
181
  async function openDropdown(): Promise<void> {
99
182
  if (disabled || open) {
100
183
  return;
@@ -173,8 +256,10 @@
173
256
  }
174
257
  highlightedIndex = next;
175
258
  await tick();
176
- if (containerEl !== null) {
177
- const el = containerEl.querySelector('.select-option.highlighted');
259
+ // Query the dropdown node itself, not containerEl, so highlight-scrolling
260
+ // keeps working once the panel is portaled out to <body>.
261
+ if (dropdownEl !== null) {
262
+ const el = dropdownEl.querySelector('.select-option.highlighted');
178
263
  if (el instanceof HTMLElement) {
179
264
  el.scrollIntoView({ block: 'nearest' });
180
265
  }
@@ -275,10 +360,14 @@
275
360
  }
276
361
 
277
362
  function handleClickOutside(event: Event): void {
363
+ // A portaled dropdown lives outside containerEl, so a click on an option is
364
+ // not contained by it — treat the dropdown node as "inside" too, otherwise a
365
+ // multi-select would close on every pick.
278
366
  if (
279
367
  event.target instanceof Node &&
280
368
  containerEl !== null &&
281
- !containerEl.contains(event.target)
369
+ !containerEl.contains(event.target) &&
370
+ !(dropdownEl !== null && dropdownEl.contains(event.target))
282
371
  ) {
283
372
  close();
284
373
  }
@@ -393,9 +482,15 @@
393
482
  <div
394
483
  class="select-dropdown"
395
484
  class:select-dropdown-right={dropdownAlign === 'right'}
485
+ class:select-dropdown-portal={usePortal}
486
+ bind:this={dropdownEl}
487
+ bind:clientWidth={dropdownWidth}
488
+ bind:clientHeight={dropdownHeight}
396
489
  role="listbox"
397
490
  id={listboxId}
398
491
  aria-multiselectable={multiple}
492
+ style={portalStyle}
493
+ use:portalToBody
399
494
  >
400
495
  {#if filteredItems.length === 0}
401
496
  <div class="select-empty">No results</div>
@@ -626,6 +721,19 @@
626
721
  width: var(--select-dropdown-width, max-content);
627
722
  }
628
723
 
724
+ .select-dropdown.select-dropdown-portal {
725
+ /* Portaled to <body>: fixed positioning escapes overflow/scroll ancestors
726
+ (e.g. a table cell). Placement (top/left/width/min-width) is set inline
727
+ from the trigger rect, so neutralise the in-flow anchoring here. */
728
+ position: fixed;
729
+ right: auto;
730
+ margin-top: 0;
731
+ /* In the root stacking context the panel competes with modals/sheets rather
732
+ than painting above same-container siblings, so default it into the
733
+ top-layer band (consumers still override via --select-dropdown-z-index). */
734
+ z-index: var(--select-dropdown-z-index, 1000);
735
+ }
736
+
629
737
  .select-option {
630
738
  padding: var(--select-option-padding, 8px 12px);
631
739
  color: var(--select-option-color, #333333);
@@ -0,0 +1,42 @@
1
+ type TriggerRect = {
2
+ left: number;
3
+ right: number;
4
+ top: number;
5
+ bottom: number;
6
+ width: number;
7
+ };
8
+ type Size = {
9
+ width: number;
10
+ height: number;
11
+ };
12
+ export type SelectDropdownPlacement = {
13
+ left: number;
14
+ top: number;
15
+ /** Floor width for the panel — always at least the trigger width. */
16
+ minWidth: number;
17
+ /**
18
+ * Explicit width (px) for a left-aligned panel so it matches the trigger,
19
+ * reproducing the in-flow `left:0; right:0` default. `null` for a
20
+ * right-aligned panel, which keeps its content/`max-content` width.
21
+ */
22
+ width: number | null;
23
+ /** True when the panel was flipped above the trigger for lack of room below. */
24
+ flippedUp: boolean;
25
+ };
26
+ /**
27
+ * Pure placement for a portaled Select dropdown. Coordinates are viewport
28
+ * coordinates for a `position: fixed` panel anchored to `trigger`. Left-aligned
29
+ * panels match the trigger width; right-aligned panels hang leftward from the
30
+ * trigger's right edge using their measured content width. The panel flips above
31
+ * the trigger only when it cannot fit below and there is more room above, and is
32
+ * clamped horizontally to a viewport margin.
33
+ */
34
+ export declare function computeSelectDropdownPosition(opts: {
35
+ trigger: TriggerRect;
36
+ dropdown: Size;
37
+ viewport: Size;
38
+ align: 'left' | 'right';
39
+ gap: number;
40
+ margin?: number;
41
+ }): SelectDropdownPlacement;
42
+ export {};
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Pure placement for a portaled Select dropdown. Coordinates are viewport
3
+ * coordinates for a `position: fixed` panel anchored to `trigger`. Left-aligned
4
+ * panels match the trigger width; right-aligned panels hang leftward from the
5
+ * trigger's right edge using their measured content width. The panel flips above
6
+ * the trigger only when it cannot fit below and there is more room above, and is
7
+ * clamped horizontally to a viewport margin.
8
+ */
9
+ export function computeSelectDropdownPosition(opts) {
10
+ const margin = opts.margin ?? 8;
11
+ const { trigger, dropdown, viewport, align, gap } = opts;
12
+ const minWidth = trigger.width;
13
+ const width = align === 'left' ? trigger.width : null;
14
+ const effectiveWidth = Math.max(align === 'right' ? dropdown.width : trigger.width, trigger.width);
15
+ let left = align === 'right' ? trigger.right - effectiveWidth : trigger.left;
16
+ if (Number.isFinite(viewport.width)) {
17
+ const maxLeft = viewport.width - effectiveWidth - margin;
18
+ left = Math.max(margin, Math.min(left, maxLeft));
19
+ }
20
+ const spaceBelow = viewport.height - trigger.bottom;
21
+ const spaceAbove = trigger.top;
22
+ const flippedUp = dropdown.height > 0 && spaceBelow < dropdown.height + gap && spaceAbove > spaceBelow;
23
+ const top = flippedUp ? trigger.top - gap - dropdown.height : trigger.bottom + gap;
24
+ return { left, top, minWidth, width, flippedUp };
25
+ }
@@ -66,6 +66,19 @@ export type OptionalSelectProperties = {
66
66
  leftIcon?: string;
67
67
  /** `data-pw` test id forwarded to the leading icon `<Img>` element. */
68
68
  leftIconTestId?: string;
69
+ /**
70
+ * When `true`, the dropdown panel is portaled to `document.body` and positioned
71
+ * `fixed` relative to the trigger, so an ancestor with `overflow: hidden` or a
72
+ * scroll container (e.g. a table cell) cannot clip it. Placement follows the
73
+ * trigger on scroll/resize and flips above the trigger when there is no room
74
+ * below. Defaults to `false` (in-flow `position: absolute`), which preserves the
75
+ * existing behaviour — including any consumer CSS that targets `.select-dropdown`
76
+ * via an ancestor selector, since that only resolves while the panel stays inside
77
+ * the `.select` container. Opt in for Selects rendered inside clipping containers.
78
+ * When portaled the panel defaults to `z-index: 1000` (top-layer band); raise
79
+ * `--select-dropdown-z-index` if it must sit above an even higher overlay.
80
+ */
81
+ usePortal?: boolean;
69
82
  };
70
83
  export type SelectEventProperties = {
71
84
  onchange?: (value: string[]) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.96.0",
3
+ "version": "2.96.1",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",