@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.
- package/dist/Menu/Menu.svelte +81 -3
- package/dist/Menu/properties.d.ts +13 -0
- package/package.json +1 -1
package/dist/Menu/Menu.svelte
CHANGED
|
@@ -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;
|