@juspay/svelte-ui-components 4.25.0 → 4.27.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.
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { onMount, tick } from 'svelte';
3
- import type { SelectItem, SelectProperties } from './properties';
3
+ import type { SelectItem, SelectPlacement, SelectProperties } from './properties';
4
4
  import Pill from '../Pill/Pill.svelte';
5
5
  import Img from '../Img/Img.svelte';
6
6
  import { computeSelectDropdownPosition } from './dropdownPosition';
@@ -33,6 +33,7 @@
33
33
  classes,
34
34
  open = $bindable(false),
35
35
  dropdownAlign = 'left',
36
+ placement,
36
37
  hierarchy = 'default',
37
38
  leftIcon,
38
39
  leftIconTestId,
@@ -62,6 +63,67 @@
62
63
  // default. The in-flow panel still honours the CSS var via its margin-top.
63
64
  const PORTAL_DROPDOWN_GAP = 4;
64
65
 
66
+ /** Viewport padding the `'auto'` placement keeps between the panel and the edges. */
67
+ const AUTO_PLACEMENT_VIEWPORT_MARGIN = 8;
68
+
69
+ /* An unset `placement` is the pre-5.0 contract, kept exactly: the in-flow panel
70
+ always opens downward and only the portaled one flips on fit. Setting it opts
71
+ both paths into one resolved corner, which is the whole point of the prop. */
72
+ let placementRequest: SelectPlacement = $derived(
73
+ placement ?? (dropdownAlign === 'right' ? 'bottom-right' : 'bottom-left')
74
+ );
75
+ let placementPinned: boolean = $derived(placement != null);
76
+
77
+ /** Corner an `'auto'` measuring pass settled on; null until it has run. */
78
+ let autoResolved: Exclude<SelectPlacement, 'auto'> | null = $state(null);
79
+
80
+ /* Derived rather than assigned when the dropdown opens: `open` is bindable and
81
+ documented as something a parent can drive, so the panel can be rendered
82
+ without `openDropdown` ever running. Assigning on the click path left an
83
+ explicitly placed Select rendering at the default corner whenever its parent
84
+ opened it. */
85
+ let resolvedPlacement: Exclude<SelectPlacement, 'auto'> = $derived(
86
+ placementRequest === 'auto' ? (autoResolved ?? 'bottom-left') : placementRequest
87
+ );
88
+ /** True while an `'auto'` open measures the hidden panel -- suppresses paint. */
89
+ let measuringPlacement: boolean = $derived(placementRequest === 'auto' && autoResolved === null);
90
+
91
+ /* `$derived.by` rather than `$derived`: a bare expression here is evaluated in
92
+ the declaration's control flow, where TypeScript has `resolvedPlacement`
93
+ still narrowed to its initialiser literal and calls the comparison
94
+ unreachable. The closure resets that narrowing. */
95
+ let placementAlign: 'left' | 'right' = $derived.by(() =>
96
+ resolvedPlacement === 'bottom-right' || resolvedPlacement === 'top-right' ? 'right' : 'left'
97
+ );
98
+
99
+ /**
100
+ * Resolves `'auto'` against live geometry, mirroring Menu's pass: the panel is
101
+ * rendered hidden at the default corner first so it has real dimensions, then
102
+ * flips right/up only where the default overflows and the opposite side fits.
103
+ */
104
+ function resolveAutoPlacement(panel: HTMLElement): Exclude<SelectPlacement, 'auto'> {
105
+ if (containerEl === null || typeof window === 'undefined') {
106
+ return 'bottom-left';
107
+ }
108
+ const containerRect = containerEl.getBoundingClientRect();
109
+ const panelRect = panel.getBoundingClientRect();
110
+ const viewportWidth = window.innerWidth;
111
+ const viewportHeight = window.innerHeight;
112
+
113
+ const overflowsRight =
114
+ containerRect.left + panelRect.width > viewportWidth - AUTO_PLACEMENT_VIEWPORT_MARGIN;
115
+ const fitsRightAnchored =
116
+ containerRect.right - panelRect.width >= AUTO_PLACEMENT_VIEWPORT_MARGIN;
117
+ const horizontal = overflowsRight && fitsRightAnchored ? 'right' : 'left';
118
+
119
+ const overflowsBottom =
120
+ containerRect.bottom + panelRect.height > viewportHeight - AUTO_PLACEMENT_VIEWPORT_MARGIN;
121
+ const fitsAbove = containerRect.top - panelRect.height >= AUTO_PLACEMENT_VIEWPORT_MARGIN;
122
+ const vertical = overflowsBottom && fitsAbove ? 'top' : 'bottom';
123
+
124
+ return `${vertical}-${horizontal}`;
125
+ }
126
+
65
127
  const instanceId = $props.id();
66
128
  const listboxId = `select-listbox-${instanceId}`;
67
129
  const errorMessageId = `${listboxId}-error`;
@@ -195,9 +257,9 @@
195
257
  return { destroy: () => node.remove() };
196
258
  };
197
259
 
198
- let portalStyle = $derived.by(() => {
260
+ let portalPlacement = $derived.by(() => {
199
261
  if (!usePortal || !open || triggerEl === null) {
200
- return '';
262
+ return null;
201
263
  }
202
264
  void portalTick;
203
265
  const rect = triggerEl.getBoundingClientRect();
@@ -215,13 +277,62 @@
215
277
  },
216
278
  dropdown: { width: dropdownWidth, height: dropdownHeight },
217
279
  viewport,
218
- align: dropdownAlign,
280
+ align: placementAlign,
281
+ /* Unset `placement` keeps the fit-based flip this path always had; a pinned
282
+ corner is honoured on both axes instead. */
283
+ vertical: placementPinned
284
+ ? resolvedPlacement === 'top-left' || resolvedPlacement === 'top-right'
285
+ ? 'top'
286
+ : 'bottom'
287
+ : 'auto',
219
288
  gap: PORTAL_DROPDOWN_GAP
220
289
  });
290
+ return placement;
291
+ });
292
+
293
+ let portalStyle: string = $derived.by(() => {
294
+ const placement = portalPlacement;
295
+ if (placement === null) {
296
+ return '';
297
+ }
221
298
  const widthRule = placement.width === null ? '' : `width:${placement.width}px;`;
222
299
  return `top:${placement.top}px;left:${placement.left}px;min-width:${placement.minWidth}px;${widthRule}`;
223
300
  });
224
301
 
302
+ /* A portaled panel's side comes from the geometry, which still flips on fit
303
+ when `placement` is unset. Reporting the requested corner there would make
304
+ `data-placement` disagree with where the panel actually is -- the exact
305
+ thing the attribute exists to tell a consumer. */
306
+ let effectivePlacement: Exclude<SelectPlacement, 'auto'> = $derived.by(() => {
307
+ const placement = portalPlacement;
308
+ if (placement === null) {
309
+ return resolvedPlacement;
310
+ }
311
+ if (placement.flippedUp) {
312
+ return placementAlign === 'right' ? 'top-right' : 'top-left';
313
+ }
314
+ return placementAlign === 'right' ? 'bottom-right' : 'bottom-left';
315
+ });
316
+
317
+ /**
318
+ * Svelte action on the panel itself: `openDropdown` is not the only way the
319
+ * panel appears, so the `'auto'` measuring pass is driven by the panel's own
320
+ * lifecycle rather than by the open handler. Resetting on destroy is what
321
+ * makes a reopen re-measure against the trigger's current position.
322
+ */
323
+ const resolveAutoPlacementOnMount = (node: HTMLElement) => {
324
+ if (placementRequest === 'auto') {
325
+ void tick().then(() => {
326
+ autoResolved = resolveAutoPlacement(node);
327
+ });
328
+ }
329
+ return {
330
+ destroy: () => {
331
+ autoResolved = null;
332
+ }
333
+ };
334
+ };
335
+
225
336
  async function openDropdown(): Promise<void> {
226
337
  if (disabled || open) {
227
338
  return;
@@ -792,8 +903,13 @@
792
903
  the inner element that actually holds the options. -->
793
904
  <div
794
905
  class="select-dropdown"
795
- class:select-dropdown-right={dropdownAlign === 'right'}
906
+ class:select-dropdown-right={placementAlign === 'right'}
907
+ class:select-dropdown-up={!usePortal &&
908
+ (effectivePlacement === 'top-left' || effectivePlacement === 'top-right')}
909
+ class:select-dropdown-measuring={measuringPlacement}
796
910
  class:select-dropdown-portal={usePortal}
911
+ data-placement={effectivePlacement}
912
+ use:resolveAutoPlacementOnMount
797
913
  bind:this={dropdownEl}
798
914
  bind:clientWidth={dropdownWidth}
799
915
  bind:clientHeight={dropdownHeight}
@@ -841,8 +957,13 @@
841
957
  {:else}
842
958
  <div
843
959
  class="select-dropdown"
844
- class:select-dropdown-right={dropdownAlign === 'right'}
960
+ class:select-dropdown-right={placementAlign === 'right'}
961
+ class:select-dropdown-up={!usePortal &&
962
+ (effectivePlacement === 'top-left' || effectivePlacement === 'top-right')}
963
+ class:select-dropdown-measuring={measuringPlacement}
845
964
  class:select-dropdown-portal={usePortal}
965
+ data-placement={effectivePlacement}
966
+ use:resolveAutoPlacementOnMount
846
967
  bind:this={dropdownEl}
847
968
  bind:clientWidth={dropdownWidth}
848
969
  bind:clientHeight={dropdownHeight}
@@ -1045,6 +1166,23 @@
1045
1166
  height: 100%;
1046
1167
  }
1047
1168
 
1169
+ /* Anchors the in-flow panel above the trigger. `margin-top` is what the
1170
+ downward panel uses for the gap, so it has to be unset here or the panel
1171
+ sits a gap's width too high. */
1172
+ .select-dropdown.select-dropdown-up {
1173
+ top: auto;
1174
+ bottom: 100%;
1175
+ margin-top: 0;
1176
+ margin-bottom: var(--select-dropdown-gap, 4px);
1177
+ }
1178
+
1179
+ /* One-tick measuring pass for placement="auto": the panel needs rendered
1180
+ dimensions before the corner is chosen, without a visible flash at the
1181
+ wrong corner. */
1182
+ .select-dropdown.select-dropdown-measuring {
1183
+ visibility: hidden;
1184
+ }
1185
+
1048
1186
  .select-dropdown {
1049
1187
  position: absolute;
1050
1188
  top: 100%;
@@ -9,6 +9,12 @@ type Size = {
9
9
  width: number;
10
10
  height: number;
11
11
  };
12
+ /**
13
+ * Which side of the trigger the panel opens on. `'auto'` picks the side that
14
+ * fits, which is what a portaled Select did before `placement` existed and so
15
+ * stays the default when the option is omitted.
16
+ */
17
+ export type SelectVerticalPlacement = 'bottom' | 'top' | 'auto';
12
18
  export type SelectDropdownPlacement = {
13
19
  left: number;
14
20
  top: number;
@@ -28,14 +34,16 @@ export type SelectDropdownPlacement = {
28
34
  * coordinates for a `position: fixed` panel anchored to `trigger`. Left-aligned
29
35
  * panels match the trigger width; right-aligned panels hang leftward from the
30
36
  * 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.
37
+ * the trigger only when it cannot fit below and there is more room above — or
38
+ * unconditionally when `vertical` pins a side — and is clamped horizontally to a
39
+ * viewport margin.
33
40
  */
34
41
  export declare function computeSelectDropdownPosition(opts: {
35
42
  trigger: TriggerRect;
36
43
  dropdown: Size;
37
44
  viewport: Size;
38
45
  align: 'left' | 'right';
46
+ vertical?: SelectVerticalPlacement;
39
47
  gap: number;
40
48
  margin?: number;
41
49
  }): SelectDropdownPlacement;
@@ -3,11 +3,13 @@
3
3
  * coordinates for a `position: fixed` panel anchored to `trigger`. Left-aligned
4
4
  * panels match the trigger width; right-aligned panels hang leftward from the
5
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.
6
+ * the trigger only when it cannot fit below and there is more room above — or
7
+ * unconditionally when `vertical` pins a side — and is clamped horizontally to a
8
+ * viewport margin.
8
9
  */
9
10
  export function computeSelectDropdownPosition(opts) {
10
11
  const margin = opts.margin ?? 8;
12
+ const vertical = opts.vertical ?? 'auto';
11
13
  const { trigger, dropdown, viewport, align, gap } = opts;
12
14
  const minWidth = trigger.width;
13
15
  const width = align === 'left' ? trigger.width : null;
@@ -19,7 +21,30 @@ export function computeSelectDropdownPosition(opts) {
19
21
  }
20
22
  const spaceBelow = viewport.height - trigger.bottom;
21
23
  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
+ /* A pinned side is honoured even where the panel will not fit: the caller
25
+ asked for that side, and silently overriding it is the behaviour
26
+ `placement` exists to escape. Only `'auto'` consults the geometry. */
27
+ const fitsBelow = !(dropdown.height > 0 && spaceBelow < dropdown.height + gap);
28
+ const flippedUp = vertical === 'auto' ? !fitsBelow && spaceAbove > spaceBelow : vertical === 'top';
29
+ const unclamped = flippedUp ? trigger.top - gap - dropdown.height : trigger.bottom + gap;
30
+ /* The panel is `position: fixed` when portaled, so whatever a viewport edge
31
+ cuts off cannot be scrolled back into view -- hence clamping at all. The two
32
+ edges are not symmetric, though:
33
+
34
+ The top edge is always held. A negative top is unreachable however the side
35
+ was chosen.
36
+
37
+ The bottom edge is held only for an auto-resolved side. `'auto'` picks the
38
+ side with more room and can still overflow in a short viewport, where
39
+ pulling the panel up is right. A *pinned* side is the caller overriding the
40
+ fit, and dragging it back would park the panel over the very trigger it was
41
+ pinned below -- the silent override `placement` exists to escape. */
42
+ const bottomLimit = viewport.height - dropdown.height - margin;
43
+ const withinViewport = vertical === 'auto' && Number.isFinite(viewport.height)
44
+ ? Math.min(unclamped, bottomLimit)
45
+ : unclamped;
46
+ const top = Number.isFinite(viewport.height) && dropdown.height > 0
47
+ ? Math.max(margin, withinViewport)
48
+ : unclamped;
24
49
  return { left, top, minWidth, width, flippedUp };
25
50
  }
@@ -1,4 +1,9 @@
1
1
  import type { Snippet } from 'svelte';
2
+ /**
3
+ * Corner the dropdown anchors to. Mirrors `MenuPlacement` so the two components
4
+ * that open a panel from a trigger describe placement the same way.
5
+ */
6
+ export type SelectPlacement = 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right' | 'auto';
2
7
  export type SelectProperties = MandatorySelectProperties & OptionalSelectProperties & SelectEventProperties;
3
8
  export type SelectItem = {
4
9
  id: string;
@@ -103,8 +108,23 @@ export type OptionalSelectProperties = {
103
108
  classes?: string;
104
109
  /** Bindable. Controls whether the dropdown is open; the component writes back on open/close so parents can `bind:open` to observe or drive it. Unbound, the component manages its own state. */
105
110
  open?: boolean;
106
- /** Horizontal anchor of the dropdown panel. `'left'` (default) anchors to the trigger's left edge; `'right'` anchors to the right edge so a content-wider panel hangs leftward instead of overflowing. */
111
+ /**
112
+ * @deprecated Use `placement`, which controls both axes. `dropdownAlign: 'left'`
113
+ * is `placement: 'bottom-left'` and `'right'` is `'bottom-right'`. Still honoured
114
+ * when `placement` is not set; scheduled for removal in 5.0.0.
115
+ */
107
116
  dropdownAlign?: 'left' | 'right';
117
+ /**
118
+ * Corner the dropdown panel anchors to, matching `Menu`'s prop of the same name.
119
+ * `'auto'` resolves the best-fitting corner against the viewport on open, so a
120
+ * Select sitting just above a sticky footer opens upward instead of into it.
121
+ *
122
+ * Unset is the pre-5.0 contract and is what `dropdownAlign` still drives: the
123
+ * in-flow panel always opens downward, and a portaled one flips up only when it
124
+ * cannot fit below. Setting `placement` opts both paths into the same resolved
125
+ * corner, so an in-flow Select can finally open upward.
126
+ */
127
+ placement?: SelectPlacement;
108
128
  /** Snippet for rendering a compact trigger summary in multiple mode instead of one Pill per selected value. Receives `{ value, items }` so the consumer can compute e.g. "All" / "3 selected". When not provided the default Pill-per-value behaviour is used. */
109
129
  triggerSummary?: import('svelte').Snippet<[{
110
130
  value: string[];