@aiaiai-pt/design-system 0.51.0 → 0.51.2

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.
@@ -25,6 +25,8 @@
25
25
  * @typedef {'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'} Placement
26
26
  */
27
27
 
28
+ import { calculatePopoverPosition } from './popover-position.js';
29
+
28
30
  let {
29
31
  /** @type {boolean} */
30
32
  open = $bindable(false),
@@ -52,6 +54,7 @@
52
54
 
53
55
  let posX = $state(0);
54
56
  let posY = $state(0);
57
+ let posMaxHeight = /** @type {number | undefined} */ ($state(undefined));
55
58
  let width = $state(0);
56
59
 
57
60
  function reposition() {
@@ -59,6 +62,12 @@
59
62
 
60
63
  const rect = anchor.getBoundingClientRect();
61
64
  const popRect = popoverEl.getBoundingClientRect();
65
+ // Natural height, not the rendered box: once capped, the box reads the cap,
66
+ // which always "fits", and the cap would drop on the next reposition (the
67
+ // capture scroll listener fires when the popover's own content scrolls).
68
+ // scrollHeight is the content height; the rest of the box is its borders.
69
+ const naturalHeight =
70
+ popoverEl.scrollHeight + (popRect.height - popoverEl.clientHeight);
62
71
  const viewportH = window.innerHeight;
63
72
  const viewportW = window.innerWidth;
64
73
 
@@ -66,27 +75,19 @@
66
75
  width = rect.width;
67
76
  }
68
77
 
69
- const isTop = placement.startsWith('top');
70
- const isEnd = placement.endsWith('end');
71
-
72
- let x = isEnd ? rect.right - (matchWidth ? rect.width : popRect.width) : rect.left;
73
- let y = isTop ? rect.top - popRect.height - offset : rect.bottom + offset;
74
-
75
- // Flip vertical if out of viewport
76
- if (!isTop && y + popRect.height > viewportH) {
77
- y = rect.top - popRect.height - offset;
78
- } else if (isTop && y < 0) {
79
- y = rect.bottom + offset;
80
- }
81
-
82
- // Clamp horizontal
83
- if (x + popRect.width > viewportW) {
84
- x = viewportW - popRect.width - offset;
85
- }
86
- if (x < 0) x = offset;
78
+ const { x, y, maxHeight } = calculatePopoverPosition(
79
+ rect,
80
+ { width: popRect.width, height: naturalHeight },
81
+ viewportH,
82
+ viewportW,
83
+ placement,
84
+ offset,
85
+ matchWidth,
86
+ );
87
87
 
88
88
  posX = x;
89
89
  posY = y;
90
+ posMaxHeight = maxHeight;
90
91
  }
91
92
 
92
93
  // Position + close listeners
@@ -157,6 +158,8 @@
157
158
  style:left="{posX}px"
158
159
  style:top="{posY}px"
159
160
  style:width={matchWidth ? `${width}px` : undefined}
161
+ style:max-height={posMaxHeight != null ? `${posMaxHeight}px` : undefined}
162
+ style:overflow-y={posMaxHeight != null ? 'auto' : undefined}
160
163
  {...rest}
161
164
  >
162
165
  {#if children}{@render children()}{/if}
@@ -1,4 +1,5 @@
1
1
  export default Popover;
2
+ export type Placement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
2
3
  type Popover = {
3
4
  $on?(type: string, callback: (e: any) => void): () => void;
4
5
  $set?(props: Partial<$$ComponentProps>): void;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * calculatePopoverPosition — pure positioning algorithm for Popover.svelte.
3
+ *
4
+ * Returns the fixed-position coordinate (x, y) for the popover, plus an
5
+ * optional maxHeight cap (in px) when neither the preferred nor the
6
+ * opposite side fits and we must scroll the popover content in-place.
7
+ *
8
+ * Preference order:
9
+ * 1. Preferred side (from `placement`) if it fits.
10
+ * 2. Opposite side if that fits.
11
+ * 3. Side with more room — capped to that room, overflow-y scrollable.
12
+ *
13
+ * @param {{ top: number, bottom: number, left: number, right: number, width: number }} anchorRect
14
+ * @param {{ width: number, height: number }} popRect
15
+ * @param {number} viewportH
16
+ * @param {number} viewportW
17
+ * @param {string} placement — 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end'
18
+ * @param {number} offset — gap between anchor and popover (px)
19
+ * @param {boolean} [matchWidth]
20
+ * @returns {{ x: number, y: number, maxHeight: number | undefined }}
21
+ */
22
+ export function calculatePopoverPosition(anchorRect: {
23
+ top: number;
24
+ bottom: number;
25
+ left: number;
26
+ right: number;
27
+ width: number;
28
+ }, popRect: {
29
+ width: number;
30
+ height: number;
31
+ }, viewportH: number, viewportW: number, placement: string, offset: number, matchWidth?: boolean): {
32
+ x: number;
33
+ y: number;
34
+ maxHeight: number | undefined;
35
+ };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * calculatePopoverPosition — pure positioning algorithm for Popover.svelte.
3
+ *
4
+ * Returns the fixed-position coordinate (x, y) for the popover, plus an
5
+ * optional maxHeight cap (in px) when neither the preferred nor the
6
+ * opposite side fits and we must scroll the popover content in-place.
7
+ *
8
+ * Preference order:
9
+ * 1. Preferred side (from `placement`) if it fits.
10
+ * 2. Opposite side if that fits.
11
+ * 3. Side with more room — capped to that room, overflow-y scrollable.
12
+ *
13
+ * @param {{ top: number, bottom: number, left: number, right: number, width: number }} anchorRect
14
+ * @param {{ width: number, height: number }} popRect
15
+ * @param {number} viewportH
16
+ * @param {number} viewportW
17
+ * @param {string} placement — 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end'
18
+ * @param {number} offset — gap between anchor and popover (px)
19
+ * @param {boolean} [matchWidth]
20
+ * @returns {{ x: number, y: number, maxHeight: number | undefined }}
21
+ */
22
+ export function calculatePopoverPosition(
23
+ anchorRect,
24
+ popRect,
25
+ viewportH,
26
+ viewportW,
27
+ placement,
28
+ offset,
29
+ matchWidth = false,
30
+ ) {
31
+ const isTop = placement.startsWith("top");
32
+ const isEnd = placement.endsWith("end");
33
+
34
+ // Horizontal: preserve matchWidth behaviour for end-aligned placement
35
+ let x = isEnd
36
+ ? anchorRect.right - (matchWidth ? anchorRect.width : popRect.width)
37
+ : anchorRect.left;
38
+
39
+ // Available room on each side (gap to viewport edge after the offset)
40
+ const roomAbove = anchorRect.top - offset;
41
+ const roomBelow = viewportH - anchorRect.bottom - offset;
42
+
43
+ const fitsAbove = popRect.height <= roomAbove;
44
+ const fitsBelow = popRect.height <= roomBelow;
45
+
46
+ let y;
47
+ let maxHeight = undefined;
48
+
49
+ if (isTop) {
50
+ if (fitsAbove) {
51
+ // Preferred side fits
52
+ y = anchorRect.top - popRect.height - offset;
53
+ } else if (fitsBelow) {
54
+ // Flip to below
55
+ y = anchorRect.bottom + offset;
56
+ } else {
57
+ // Neither fits: open on the side with more room and cap
58
+ if (roomAbove >= roomBelow) {
59
+ maxHeight = roomAbove;
60
+ y = anchorRect.top - maxHeight - offset; // = anchorRect.top - (anchorRect.top - offset) - offset = 0
61
+ } else {
62
+ y = anchorRect.bottom + offset;
63
+ maxHeight = roomBelow;
64
+ }
65
+ }
66
+ } else {
67
+ // Bottom placement
68
+ if (fitsBelow) {
69
+ // Preferred side fits
70
+ y = anchorRect.bottom + offset;
71
+ } else if (fitsAbove) {
72
+ // Flip to above
73
+ y = anchorRect.top - popRect.height - offset;
74
+ } else {
75
+ // Neither fits: open on the side with more room and cap
76
+ if (roomBelow >= roomAbove) {
77
+ y = anchorRect.bottom + offset;
78
+ maxHeight = roomBelow;
79
+ } else {
80
+ maxHeight = roomAbove;
81
+ y = anchorRect.top - maxHeight - offset; // = 0
82
+ }
83
+ }
84
+ }
85
+
86
+ // Clamp horizontal
87
+ if (x + popRect.width > viewportW) {
88
+ x = viewportW - popRect.width - offset;
89
+ }
90
+ if (x < 0) x = offset;
91
+
92
+ return { x, y, maxHeight };
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.51.0",
3
+ "version": "0.51.2",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",