@juspay/svelte-ui-components 2.95.1 → 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.
- package/dist/ColorPicker/ColorPicker.svelte +3 -1
- package/dist/Input/Input.svelte +1 -0
- package/dist/Menu/Menu.svelte +93 -2
- package/dist/Menu/dropdownPosition.d.ts +31 -0
- package/dist/Menu/dropdownPosition.js +23 -0
- package/dist/Menu/properties.d.ts +13 -0
- package/dist/Select/Select.svelte +112 -4
- package/dist/Select/dropdownPosition.d.ts +42 -0
- package/dist/Select/dropdownPosition.js +25 -0
- package/dist/Select/properties.d.ts +13 -0
- package/package.json +1 -1
|
@@ -401,7 +401,8 @@
|
|
|
401
401
|
);
|
|
402
402
|
--input-box-shadow: none;
|
|
403
403
|
--input-width: 100%;
|
|
404
|
-
--input-height: 38px;
|
|
404
|
+
--input-height: var(--color-picker-input-height, 38px);
|
|
405
|
+
--input-text-transform: var(--color-picker-value-text-transform, none);
|
|
405
406
|
margin-left: -1px;
|
|
406
407
|
}
|
|
407
408
|
|
|
@@ -538,6 +539,7 @@
|
|
|
538
539
|
--input-height: 32px;
|
|
539
540
|
--input-text-align: center;
|
|
540
541
|
--input-focus-border: 1px solid var(--color-picker-field-focus-border, #3b82f6);
|
|
542
|
+
--input-text-transform: var(--color-picker-value-text-transform, none);
|
|
541
543
|
}
|
|
542
544
|
|
|
543
545
|
.cp-split-input {
|
package/dist/Input/Input.svelte
CHANGED
package/dist/Menu/Menu.svelte
CHANGED
|
@@ -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
|
-
|
|
177
|
-
|
|
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;
|