@juspay/svelte-ui-components 2.89.2 → 2.90.0

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,7 +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 type { MenuProperties, MenuItem } from './properties';
5
+ import type { MenuProperties, MenuItem, MenuPlacement } from './properties';
6
6
 
7
7
  let {
8
8
  items,
@@ -16,7 +16,8 @@
16
16
  selectedValue = null,
17
17
  role: menuRole = 'menu',
18
18
  ariaLabel: menuAriaLabel,
19
- id: menuId
19
+ id: menuId,
20
+ placement = 'bottom-left'
20
21
  }: MenuProperties = $props();
21
22
 
22
23
  let itemRole = $derived(menuRole === 'listbox' ? 'option' : 'menuitem');
@@ -28,6 +29,42 @@
28
29
  let typeaheadQuery: string = $state('');
29
30
  let typeaheadTimer: ReturnType<typeof setTimeout> | null = $state(null);
30
31
 
32
+ /** Fixed corner the dropdown is currently anchored to (resolved from `placement`). */
33
+ let resolvedPlacement: Exclude<MenuPlacement, 'auto'> = $state('bottom-left');
34
+ /** True while an `'auto'` open is measuring the hidden panel — suppresses paint. */
35
+ let measuringPlacement: boolean = $state(false);
36
+
37
+ /** Viewport padding the auto placement keeps between the panel and the edges. */
38
+ const AUTO_PLACEMENT_VIEWPORT_MARGIN = 8;
39
+
40
+ /**
41
+ * Resolves `'auto'` against the live geometry: the panel renders hidden at the
42
+ * default corner first, then flips right/up only when the default overflows
43
+ * the viewport and the opposite side actually has room for the panel.
44
+ */
45
+ function resolveAutoPlacement(): Exclude<MenuPlacement, 'auto'> {
46
+ if (menuContainerEl === null || menuListEl === null) {
47
+ return 'bottom-left';
48
+ }
49
+ const containerRect = menuContainerEl.getBoundingClientRect();
50
+ const panelRect = menuListEl.getBoundingClientRect();
51
+ const viewportWidth = window.innerWidth;
52
+ const viewportHeight = window.innerHeight;
53
+
54
+ const overflowsRight =
55
+ containerRect.left + panelRect.width > viewportWidth - AUTO_PLACEMENT_VIEWPORT_MARGIN;
56
+ const fitsRightAnchored =
57
+ containerRect.right - panelRect.width >= AUTO_PLACEMENT_VIEWPORT_MARGIN;
58
+ const horizontal = overflowsRight && fitsRightAnchored ? 'right' : 'left';
59
+
60
+ const overflowsBottom =
61
+ containerRect.bottom + panelRect.height > viewportHeight - AUTO_PLACEMENT_VIEWPORT_MARGIN;
62
+ const fitsAbove = containerRect.top - panelRect.height >= AUTO_PLACEMENT_VIEWPORT_MARGIN;
63
+ const vertical = overflowsBottom && fitsAbove ? 'top' : 'bottom';
64
+
65
+ return `${vertical}-${horizontal}`;
66
+ }
67
+
31
68
  let selectableItems: MenuItem[] = $derived(
32
69
  items.filter((item) => item.separator !== true && item.disabled !== true)
33
70
  );
@@ -46,6 +83,15 @@
46
83
 
47
84
  function openMenu(startIndex: number | null = null) {
48
85
  open = true;
86
+ if (placement === 'auto') {
87
+ // Render the panel hidden at the default corner for one tick so it has
88
+ // real dimensions to measure, then anchor it to the resolved corner.
89
+ resolvedPlacement = 'bottom-left';
90
+ measuringPlacement = true;
91
+ } else {
92
+ resolvedPlacement = placement;
93
+ measuringPlacement = false;
94
+ }
49
95
  // With a known selection, opening focuses the selected option (listbox
50
96
  // convention) instead of always parking the focus highlight on item 0.
51
97
  const selectedItem =
@@ -57,6 +103,10 @@
57
103
  focusedIndex = initialIndex;
58
104
  onopen?.();
59
105
  tick().then(() => {
106
+ if (placement === 'auto') {
107
+ resolvedPlacement = resolveAutoPlacement();
108
+ measuringPlacement = false;
109
+ }
60
110
  focusItem(initialIndex);
61
111
  });
62
112
  }
@@ -224,7 +274,8 @@
224
274
 
225
275
  {#if open}
226
276
  <div
227
- class="menu-dropdown"
277
+ class="menu-dropdown menu-dropdown-{placement === 'auto' ? resolvedPlacement : placement}"
278
+ class:menu-dropdown-measuring={measuringPlacement}
228
279
  bind:this={menuListEl}
229
280
  role={menuRole}
230
281
  id={menuId}
@@ -310,6 +361,33 @@
310
361
  margin: var(--menu-margin, 4px 0);
311
362
  }
312
363
 
364
+ /* Placement corners — `bottom-left` is the base rule above (and stays fully
365
+ driven by the --menu-dropdown-top/left consumer tokens); the other corners
366
+ override the anchoring sides. The chained selector outweighs the base rule
367
+ regardless of source order. */
368
+ .menu-dropdown.menu-dropdown-bottom-right {
369
+ left: auto;
370
+ right: 0;
371
+ }
372
+
373
+ .menu-dropdown.menu-dropdown-top-left {
374
+ top: auto;
375
+ bottom: 100%;
376
+ }
377
+
378
+ .menu-dropdown.menu-dropdown-top-right {
379
+ top: auto;
380
+ bottom: 100%;
381
+ left: auto;
382
+ right: 0;
383
+ }
384
+
385
+ /* One-tick measuring pass for placement="auto": the panel needs rendered
386
+ dimensions before the corner is chosen, without a visible flash. */
387
+ .menu-dropdown.menu-dropdown-measuring {
388
+ visibility: hidden;
389
+ }
390
+
313
391
  .menu-item {
314
392
  display: flex;
315
393
  align-items: center;
@@ -8,6 +8,14 @@ export type MenuItem = {
8
8
  separator?: boolean;
9
9
  id?: string;
10
10
  };
11
+ /**
12
+ * Corner of the trigger the dropdown anchors to. The four fixed corners map to
13
+ * static CSS anchoring; `'auto'` measures the rendered panel on every open and
14
+ * picks the corner that keeps it inside the viewport — right-anchoring when the
15
+ * panel would overflow the right edge, flipping above the trigger when there is
16
+ * not enough room below but enough above.
17
+ */
18
+ export type MenuPlacement = 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right' | 'auto';
11
19
  export type MenuProperties = MandatoryMenuProperties & OptionalMenuProperties & MenuEventProperties;
12
20
  export type MandatoryMenuProperties = {
13
21
  items: MenuItem[];
@@ -25,6 +33,11 @@ export type OptionalMenuProperties = {
25
33
  role?: 'menu' | 'listbox';
26
34
  ariaLabel?: string;
27
35
  id?: string;
36
+ /** Dropdown anchoring relative to the trigger. Defaults to `'bottom-left'`,
37
+ * which preserves the existing behavior (including the `--menu-dropdown-top`
38
+ * / `--menu-dropdown-left` consumer tokens). Fixed corners anchor statically;
39
+ * `'auto'` resolves the best-fitting corner against the viewport on open. */
40
+ placement?: MenuPlacement;
28
41
  };
29
42
  export type MenuEventProperties = {
30
43
  onselect?: (item: MenuItem) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.89.2",
3
+ "version": "2.90.0",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",