@delightstack/components 0.1.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.
Files changed (195) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/SKILL.md +149 -0
  4. package/bin/agents.js +63 -0
  5. package/dist/actions/Alert.svelte +202 -0
  6. package/dist/actions/Alert.svelte.d.ts +36 -0
  7. package/dist/actions/Alert.svelte.d.ts.map +1 -0
  8. package/dist/actions/Button.svelte +1450 -0
  9. package/dist/actions/Button.svelte.d.ts +56 -0
  10. package/dist/actions/Button.svelte.d.ts.map +1 -0
  11. package/dist/actions/ButtonGroup.svelte +111 -0
  12. package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
  13. package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
  14. package/dist/actions/CommandPalette.svelte +939 -0
  15. package/dist/actions/CommandPalette.svelte.d.ts +37 -0
  16. package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
  17. package/dist/actions/ContextMenu.svelte +138 -0
  18. package/dist/actions/ContextMenu.svelte.d.ts +54 -0
  19. package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
  20. package/dist/actions/Modal.svelte +474 -0
  21. package/dist/actions/Modal.svelte.d.ts +28 -0
  22. package/dist/actions/Modal.svelte.d.ts.map +1 -0
  23. package/dist/actions/Popover.svelte +1214 -0
  24. package/dist/actions/Popover.svelte.d.ts +31 -0
  25. package/dist/actions/Popover.svelte.d.ts.map +1 -0
  26. package/dist/actions/Portal.svelte +80 -0
  27. package/dist/actions/Portal.svelte.d.ts +17 -0
  28. package/dist/actions/Portal.svelte.d.ts.map +1 -0
  29. package/dist/actions/ThemeToggle.svelte +345 -0
  30. package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
  31. package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
  32. package/dist/actions/index.d.ts +13 -0
  33. package/dist/actions/index.d.ts.map +1 -0
  34. package/dist/actions/index.js +10 -0
  35. package/dist/actions/scrollbar.d.ts +48 -0
  36. package/dist/actions/scrollbar.d.ts.map +1 -0
  37. package/dist/actions/scrollbar.js +404 -0
  38. package/dist/display/Accordion.svelte +586 -0
  39. package/dist/display/Accordion.svelte.d.ts +41 -0
  40. package/dist/display/Accordion.svelte.d.ts.map +1 -0
  41. package/dist/display/Avatar.svelte +527 -0
  42. package/dist/display/Avatar.svelte.d.ts +22 -0
  43. package/dist/display/Avatar.svelte.d.ts.map +1 -0
  44. package/dist/display/AvatarGroup.svelte +298 -0
  45. package/dist/display/AvatarGroup.svelte.d.ts +31 -0
  46. package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
  47. package/dist/display/Calendar.svelte +1366 -0
  48. package/dist/display/Calendar.svelte.d.ts +58 -0
  49. package/dist/display/Calendar.svelte.d.ts.map +1 -0
  50. package/dist/display/Chart.svelte +1426 -0
  51. package/dist/display/Chart.svelte.d.ts +35 -0
  52. package/dist/display/Chart.svelte.d.ts.map +1 -0
  53. package/dist/display/Code.svelte +780 -0
  54. package/dist/display/Code.svelte.d.ts +19 -0
  55. package/dist/display/Code.svelte.d.ts.map +1 -0
  56. package/dist/display/Comparison.svelte +686 -0
  57. package/dist/display/Comparison.svelte.d.ts +22 -0
  58. package/dist/display/Comparison.svelte.d.ts.map +1 -0
  59. package/dist/display/Counter.svelte +285 -0
  60. package/dist/display/Counter.svelte.d.ts +21 -0
  61. package/dist/display/Counter.svelte.d.ts.map +1 -0
  62. package/dist/display/Expand.svelte +48 -0
  63. package/dist/display/Expand.svelte.d.ts +9 -0
  64. package/dist/display/Expand.svelte.d.ts.map +1 -0
  65. package/dist/display/List.svelte +294 -0
  66. package/dist/display/List.svelte.d.ts +40 -0
  67. package/dist/display/List.svelte.d.ts.map +1 -0
  68. package/dist/display/ListContextReset.svelte +19 -0
  69. package/dist/display/ListContextReset.svelte.d.ts +7 -0
  70. package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
  71. package/dist/display/ListItem.svelte +834 -0
  72. package/dist/display/ListItem.svelte.d.ts +22 -0
  73. package/dist/display/ListItem.svelte.d.ts.map +1 -0
  74. package/dist/display/QR.svelte +1193 -0
  75. package/dist/display/QR.svelte.d.ts +23 -0
  76. package/dist/display/QR.svelte.d.ts.map +1 -0
  77. package/dist/display/SplitPane.svelte +744 -0
  78. package/dist/display/SplitPane.svelte.d.ts +25 -0
  79. package/dist/display/SplitPane.svelte.d.ts.map +1 -0
  80. package/dist/display/Stat.svelte +439 -0
  81. package/dist/display/Stat.svelte.d.ts +24 -0
  82. package/dist/display/Stat.svelte.d.ts.map +1 -0
  83. package/dist/display/Table.svelte +4654 -0
  84. package/dist/display/Table.svelte.d.ts +249 -0
  85. package/dist/display/Table.svelte.d.ts.map +1 -0
  86. package/dist/display/TableCellEditor.svelte +935 -0
  87. package/dist/display/TableCellEditor.svelte.d.ts +58 -0
  88. package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
  89. package/dist/display/Timeline.svelte +1258 -0
  90. package/dist/display/Timeline.svelte.d.ts +43 -0
  91. package/dist/display/Timeline.svelte.d.ts.map +1 -0
  92. package/dist/display/Tree.svelte +1740 -0
  93. package/dist/display/Tree.svelte.d.ts +74 -0
  94. package/dist/display/Tree.svelte.d.ts.map +1 -0
  95. package/dist/display/Typewriter.svelte +338 -0
  96. package/dist/display/Typewriter.svelte.d.ts +22 -0
  97. package/dist/display/Typewriter.svelte.d.ts.map +1 -0
  98. package/dist/display/index.d.ts +24 -0
  99. package/dist/display/index.d.ts.map +1 -0
  100. package/dist/display/index.js +18 -0
  101. package/dist/feedback/Callout.svelte +529 -0
  102. package/dist/feedback/Callout.svelte.d.ts +24 -0
  103. package/dist/feedback/Callout.svelte.d.ts.map +1 -0
  104. package/dist/feedback/Confetti.svelte +631 -0
  105. package/dist/feedback/Confetti.svelte.d.ts +90 -0
  106. package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
  107. package/dist/feedback/Progress.svelte +382 -0
  108. package/dist/feedback/Progress.svelte.d.ts +25 -0
  109. package/dist/feedback/Progress.svelte.d.ts.map +1 -0
  110. package/dist/feedback/Toast.svelte +967 -0
  111. package/dist/feedback/Toast.svelte.d.ts +54 -0
  112. package/dist/feedback/Toast.svelte.d.ts.map +1 -0
  113. package/dist/feedback/index.d.ts +7 -0
  114. package/dist/feedback/index.d.ts.map +1 -0
  115. package/dist/feedback/index.js +4 -0
  116. package/dist/form/Checkbox.svelte +449 -0
  117. package/dist/form/Checkbox.svelte.d.ts +27 -0
  118. package/dist/form/Checkbox.svelte.d.ts.map +1 -0
  119. package/dist/form/Fieldset.svelte +410 -0
  120. package/dist/form/Fieldset.svelte.d.ts +22 -0
  121. package/dist/form/Fieldset.svelte.d.ts.map +1 -0
  122. package/dist/form/FileUpload.svelte +934 -0
  123. package/dist/form/FileUpload.svelte.d.ts +41 -0
  124. package/dist/form/FileUpload.svelte.d.ts.map +1 -0
  125. package/dist/form/Form.svelte +530 -0
  126. package/dist/form/Form.svelte.d.ts +120 -0
  127. package/dist/form/Form.svelte.d.ts.map +1 -0
  128. package/dist/form/Input.svelte +2858 -0
  129. package/dist/form/Input.svelte.d.ts +66 -0
  130. package/dist/form/Input.svelte.d.ts.map +1 -0
  131. package/dist/form/Radio.svelte +507 -0
  132. package/dist/form/Radio.svelte.d.ts +39 -0
  133. package/dist/form/Radio.svelte.d.ts.map +1 -0
  134. package/dist/form/Range.svelte +912 -0
  135. package/dist/form/Range.svelte.d.ts +33 -0
  136. package/dist/form/Range.svelte.d.ts.map +1 -0
  137. package/dist/form/Rating.svelte +429 -0
  138. package/dist/form/Rating.svelte.d.ts +28 -0
  139. package/dist/form/Rating.svelte.d.ts.map +1 -0
  140. package/dist/form/Select.svelte +1933 -0
  141. package/dist/form/Select.svelte.d.ts +54 -0
  142. package/dist/form/Select.svelte.d.ts.map +1 -0
  143. package/dist/form/Toggle.svelte +645 -0
  144. package/dist/form/Toggle.svelte.d.ts +50 -0
  145. package/dist/form/Toggle.svelte.d.ts.map +1 -0
  146. package/dist/form/index.d.ts +15 -0
  147. package/dist/form/index.d.ts.map +1 -0
  148. package/dist/form/index.js +10 -0
  149. package/dist/index.d.ts +7 -0
  150. package/dist/index.d.ts.map +1 -0
  151. package/dist/index.js +6 -0
  152. package/dist/layout/README.md +172 -0
  153. package/dist/media/Carousel.svelte +2424 -0
  154. package/dist/media/Carousel.svelte.d.ts +47 -0
  155. package/dist/media/Carousel.svelte.d.ts.map +1 -0
  156. package/dist/media/Gallery.svelte +2881 -0
  157. package/dist/media/Gallery.svelte.d.ts +82 -0
  158. package/dist/media/Gallery.svelte.d.ts.map +1 -0
  159. package/dist/media/Image.svelte +389 -0
  160. package/dist/media/Image.svelte.d.ts +33 -0
  161. package/dist/media/Image.svelte.d.ts.map +1 -0
  162. package/dist/media/PDF.svelte +1793 -0
  163. package/dist/media/PDF.svelte.d.ts +44 -0
  164. package/dist/media/PDF.svelte.d.ts.map +1 -0
  165. package/dist/media/Panorama.svelte +1391 -0
  166. package/dist/media/Panorama.svelte.d.ts +47 -0
  167. package/dist/media/Panorama.svelte.d.ts.map +1 -0
  168. package/dist/media/Video.svelte +2501 -0
  169. package/dist/media/Video.svelte.d.ts +58 -0
  170. package/dist/media/Video.svelte.d.ts.map +1 -0
  171. package/dist/media/carousel.d.ts +211 -0
  172. package/dist/media/carousel.d.ts.map +1 -0
  173. package/dist/media/carousel.js +408 -0
  174. package/dist/media/index.d.ts +11 -0
  175. package/dist/media/index.d.ts.map +1 -0
  176. package/dist/media/index.js +5 -0
  177. package/dist/navigation/BottomSheet.svelte +636 -0
  178. package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
  179. package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
  180. package/dist/navigation/Breadcrumbs.svelte +611 -0
  181. package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
  182. package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
  183. package/dist/navigation/Pagination.svelte +641 -0
  184. package/dist/navigation/Pagination.svelte.d.ts +27 -0
  185. package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
  186. package/dist/navigation/Steps.svelte +965 -0
  187. package/dist/navigation/Steps.svelte.d.ts +43 -0
  188. package/dist/navigation/Steps.svelte.d.ts.map +1 -0
  189. package/dist/navigation/Tabs.svelte +698 -0
  190. package/dist/navigation/Tabs.svelte.d.ts +41 -0
  191. package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
  192. package/dist/navigation/index.d.ts +8 -0
  193. package/dist/navigation/index.d.ts.map +1 -0
  194. package/dist/navigation/index.js +5 -0
  195. package/package.json +139 -0
@@ -0,0 +1,1214 @@
1
+ <script lang="ts" module>
2
+ export type PopoverPlacement =
3
+ | 'top'
4
+ | 'top-start'
5
+ | 'top-end'
6
+ | 'bottom'
7
+ | 'bottom-start'
8
+ | 'bottom-end'
9
+ | 'left'
10
+ | 'left-start'
11
+ | 'left-end'
12
+ | 'right'
13
+ | 'right-start'
14
+ | 'right-end';
15
+ export type PopoverStrategy = 'fixed' | 'absolute';
16
+ type Corner = 'tl' | 'tr' | 'br' | 'bl';
17
+ </script>
18
+
19
+ <script lang="ts">
20
+ import { scale } from 'svelte/transition';
21
+ import { backOut, backIn } from 'svelte/easing';
22
+ import { focusTrap } from '@delightstack/utilities';
23
+ import { tick, untrack, type Snippet } from 'svelte';
24
+ import Portal from './Portal.svelte';
25
+ import { scrollbar } from './scrollbar';
26
+
27
+ const propId = $props.id();
28
+ let {
29
+ /** The HTML element that the popover will be attached to */
30
+ ref_element = $bindable() as HTMLElement | undefined,
31
+
32
+ /** Whether the popover is currently open */
33
+ opened = $bindable(false) as boolean,
34
+
35
+ /** Where the popover should be attempted to be placed (it can move to fit on screen) */
36
+ placement = 'bottom' as PopoverPlacement,
37
+
38
+ /** How the item should be placed with css */
39
+ strategy = 'fixed' as PopoverStrategy,
40
+
41
+ /** Whether the 'arrow' pointing to the target element should be shown */
42
+ arrow = true,
43
+
44
+ /** The x position (in px) where the popover's ref_element/target is. This is used for context menu (right click) */
45
+ x = undefined as number | undefined,
46
+
47
+ /** The y position (in px) where the popover's ref_element/target is. This is used for context menu (right click) */
48
+ y = undefined as number | undefined,
49
+
50
+ /** Whether the popover should open when the ref element is hover overed */
51
+ open_on_hover = false,
52
+
53
+ /** Whether the popover should open when the ref element is clicked */
54
+ open_on_click = false,
55
+
56
+ /** Whether the popover should open when the ref element is focused (closes on blur) */
57
+ open_on_focus = false,
58
+
59
+ /** Whether the popover should close when clicked outside of the popover */
60
+ close_on_outside_click = true,
61
+
62
+ /** Whether the popover should close when a button like element is clicked inside of the popover */
63
+ close_on_inside_click = false,
64
+
65
+ /** Whether the popover should close when the escape key is pressed */
66
+ close_on_escape_key = true,
67
+
68
+ /** Whether the intial focus should not be set automatically when opening the popover */
69
+ disable_initial_focus = false,
70
+
71
+ /** The number of milliseconds that the popover wait before popping up. Only applies when 'open_on_hover' is true */
72
+ hover_delay = 100,
73
+
74
+ /**
75
+ * Whether the popover panel should be transparent — removes the background,
76
+ * border, shadow, padding, and arrow so inner content (e.g. a List) provides
77
+ * its own surface.
78
+ */
79
+ transparent = false,
80
+
81
+ /** Whether the popover should have less padding */
82
+ dense = false,
83
+
84
+ /** Whether the popover should have more padding */
85
+ comfortable = false,
86
+
87
+ /** The border radius of the popover */
88
+ radius = undefined as string | undefined,
89
+
90
+ /** The content shown in the element */
91
+ children = undefined as undefined | Snippet,
92
+
93
+ /** The id of the popover element */
94
+ id = propId,
95
+
96
+ /** Specifies a custom class name for the container element */
97
+ class: class_name = '',
98
+
99
+ /** The css style string added to the component from the parent */
100
+ style = '',
101
+ } = $props();
102
+
103
+ const ARROW_SIZE = 20;
104
+ /** Half the arrow's visible base (element + fillet shoulders) plus breathing room */
105
+ const ARROW_HALF_BASE = ARROW_SIZE / 2 + 4;
106
+ /** Minimum gap kept between the popover and the viewport edges when shifting */
107
+ const VIEWPORT_MARGIN = 8;
108
+ const OFFSET = 4;
109
+ const OFFSET_WITH_ARROW = 12;
110
+ const TRANSITION_IN_DURATION = 200;
111
+ const TRANSITION_OUT_DURATION = 150;
112
+
113
+ let popoverElement = $state<HTMLElement | undefined>(undefined);
114
+ let arrowElement = $state<HTMLElement | undefined>(undefined);
115
+ let left = $state('0px');
116
+ let top = $state(strategy === 'fixed' ? '-1000px' : '0px');
117
+ let hitBoxLength = $state(0); // the total length of the hit box
118
+ let hitBoxLengthA = $state(0); // the length of the long side of the trapezoid
119
+ let hitBoxLengthB = $state(0); // the length of the short side of the trapezoid
120
+ let hitBoxLengthZ = $state(0); // the length of the 'height' of the trapezoid
121
+ let hitBoxOffsetA = $state(0); // How far the long side of the trapezoid is from the edge of the hit box
122
+ let hitBoxOffsetB = $state(0); // How far the short side of the trapezoid is from the edge of the hit box
123
+ let transformOrigin = $state(`top center`);
124
+ let arrowX = $state('');
125
+ let arrowY = $state('');
126
+ let realPlacement = $state(placement);
127
+ let forcedOpened = $state(false);
128
+ let positioned = $state(false);
129
+ let popoverIndex = $state(0);
130
+ let shiftX = $state(0); // px the popover is nudged along x so the arrow can point at the target
131
+ let shiftY = $state(0); // px the popover is nudged along y so the arrow can point at the target
132
+ let cornerRadii = $state<Partial<Record<Corner, string>>>({}); // per-corner radius overrides (corner flattening)
133
+ let baseRadiusCache: Partial<Record<Corner, number>> = {};
134
+
135
+ const anchorOffset = $derived(arrow ? OFFSET_WITH_ARROW : OFFSET);
136
+
137
+ const hitBoxShape = $derived.by(() => {
138
+ const points: string[] = [];
139
+ if (realPlacement.startsWith('bottom')) {
140
+ points.push(`M${hitBoxOffsetA},${hitBoxLengthZ}`);
141
+ points.push(`L${hitBoxOffsetB},0`);
142
+ points.push(`l${hitBoxLengthB},0`);
143
+ points.push(`L${hitBoxOffsetA + hitBoxLengthA},${hitBoxLengthZ}`);
144
+ points.push(`L${hitBoxOffsetA},${hitBoxLengthZ}`);
145
+ } else if (realPlacement.startsWith('top')) {
146
+ points.push(`M${hitBoxOffsetA},0`);
147
+ points.push(`L${hitBoxOffsetB},${hitBoxLengthZ}`);
148
+ points.push(`l${hitBoxLengthB},0`);
149
+ points.push(`L${hitBoxOffsetA + hitBoxLengthA},0`);
150
+ points.push(`L${hitBoxOffsetA},0`);
151
+ } else if (realPlacement.startsWith('left')) {
152
+ points.push(`M0,${hitBoxOffsetA}`);
153
+ points.push(`L${hitBoxLengthZ},${hitBoxOffsetB}`);
154
+ points.push(`l0,${hitBoxLengthB}`);
155
+ points.push(`L0,${hitBoxOffsetA + hitBoxLengthA}`);
156
+ points.push(`L0,${hitBoxOffsetA}`);
157
+ } else if (realPlacement.startsWith('right')) {
158
+ points.push(`M${hitBoxLengthZ},${hitBoxOffsetA}`);
159
+ points.push(`L0,${hitBoxOffsetB}`);
160
+ points.push(`l0,${hitBoxLengthB}`);
161
+ points.push(`L${hitBoxLengthZ},${hitBoxOffsetA + hitBoxLengthA}`);
162
+ points.push(`L${hitBoxLengthZ},${hitBoxOffsetA}`);
163
+ }
164
+ return points.join(' ');
165
+ });
166
+
167
+ // Determine when the portal component should be shown
168
+ // This is necessary because the portal component needs to exist while the popover is animating away
169
+ let portalOpened = $state(false);
170
+ let portalOpenedTimeout: ReturnType<typeof setTimeout> | undefined;
171
+ $effect(() => {
172
+ clearTimeout(portalOpenedTimeout);
173
+ if (opened) {
174
+ portalOpened = true;
175
+ } else {
176
+ portalOpenedTimeout = setTimeout(() => {
177
+ if (untrack(() => !opened)) portalOpened = false;
178
+ }, TRANSITION_OUT_DURATION);
179
+ }
180
+ });
181
+
182
+ // Show the popover with a small delay to trigger the intro animation to play
183
+ let shown = $state(false);
184
+ $effect(() => {
185
+ if (opened) {
186
+ // Reset arrow/corner state from a previous open before measuring anew
187
+ baseRadiusCache = {};
188
+ cornerRadii = {};
189
+ shiftX = 0;
190
+ shiftY = 0;
191
+ tick().then(() => (shown = true));
192
+ } else {
193
+ shown = false;
194
+ }
195
+ });
196
+
197
+ // Set anchor-name on ref_element before DOM update so CSS anchor positioning resolves on first paint
198
+ $effect.pre(() => {
199
+ if (shown && ref_element) {
200
+ const el = ref_element;
201
+ (el.style as any).anchorName = `--popover-anchor-${id}`;
202
+ }
203
+ });
204
+
205
+ // CSS anchor positioning style for real element path
206
+ const anchorPositionStyle = $derived.by(() => {
207
+ if (!ref_element) return '';
208
+ const anchor = `--popover-anchor-${id}`;
209
+ const parts = [`position: ${strategy}`, `position-anchor: ${anchor}`, 'inset: auto'];
210
+ switch (placement) {
211
+ case 'bottom':
212
+ parts.push(
213
+ `top: anchor(bottom)`,
214
+ `justify-self: anchor-center`,
215
+ `margin-top: ${anchorOffset}px`,
216
+ `position-try-fallbacks: flip-block`,
217
+ );
218
+ break;
219
+ case 'bottom-start':
220
+ parts.push(
221
+ `top: anchor(bottom)`,
222
+ `left: anchor(left)`,
223
+ `margin-top: ${anchorOffset}px`,
224
+ `position-try-fallbacks: flip-block`,
225
+ );
226
+ break;
227
+ case 'bottom-end':
228
+ parts.push(
229
+ `top: anchor(bottom)`,
230
+ `right: anchor(right)`,
231
+ `margin-top: ${anchorOffset}px`,
232
+ `position-try-fallbacks: flip-block`,
233
+ );
234
+ break;
235
+ case 'top':
236
+ parts.push(
237
+ `bottom: anchor(top)`,
238
+ `justify-self: anchor-center`,
239
+ `margin-bottom: ${anchorOffset}px`,
240
+ `position-try-fallbacks: flip-block`,
241
+ );
242
+ break;
243
+ case 'top-start':
244
+ parts.push(
245
+ `bottom: anchor(top)`,
246
+ `left: anchor(left)`,
247
+ `margin-bottom: ${anchorOffset}px`,
248
+ `position-try-fallbacks: flip-block`,
249
+ );
250
+ break;
251
+ case 'top-end':
252
+ parts.push(
253
+ `bottom: anchor(top)`,
254
+ `right: anchor(right)`,
255
+ `margin-bottom: ${anchorOffset}px`,
256
+ `position-try-fallbacks: flip-block`,
257
+ );
258
+ break;
259
+ case 'left':
260
+ parts.push(
261
+ `right: anchor(left)`,
262
+ `align-self: anchor-center`,
263
+ `margin-right: ${anchorOffset}px`,
264
+ `position-try-fallbacks: flip-inline`,
265
+ );
266
+ break;
267
+ case 'left-start':
268
+ parts.push(
269
+ `right: anchor(left)`,
270
+ `top: anchor(top)`,
271
+ `margin-right: ${anchorOffset}px`,
272
+ `position-try-fallbacks: flip-inline`,
273
+ );
274
+ break;
275
+ case 'left-end':
276
+ parts.push(
277
+ `right: anchor(left)`,
278
+ `bottom: anchor(bottom)`,
279
+ `margin-right: ${anchorOffset}px`,
280
+ `position-try-fallbacks: flip-inline`,
281
+ );
282
+ break;
283
+ case 'right':
284
+ parts.push(
285
+ `left: anchor(right)`,
286
+ `align-self: anchor-center`,
287
+ `margin-left: ${anchorOffset}px`,
288
+ `position-try-fallbacks: flip-inline`,
289
+ );
290
+ break;
291
+ case 'right-start':
292
+ parts.push(
293
+ `left: anchor(right)`,
294
+ `top: anchor(top)`,
295
+ `margin-left: ${anchorOffset}px`,
296
+ `position-try-fallbacks: flip-inline`,
297
+ );
298
+ break;
299
+ case 'right-end':
300
+ parts.push(
301
+ `left: anchor(right)`,
302
+ `bottom: anchor(bottom)`,
303
+ `margin-left: ${anchorOffset}px`,
304
+ `position-try-fallbacks: flip-inline`,
305
+ );
306
+ break;
307
+ }
308
+ return parts.join('; ') + ';';
309
+ });
310
+
311
+ // Computed position style (anchor positioning for real elements, left/top for virtual)
312
+ const positionStyle = $derived.by(() => {
313
+ if (ref_element) return anchorPositionStyle;
314
+ return `position: ${strategy}; left: ${left}; top: ${top};`;
315
+ });
316
+
317
+ const CORNER_PROPS: Record<Corner, string> = {
318
+ tl: 'border-top-left-radius',
319
+ tr: 'border-top-right-radius',
320
+ br: 'border-bottom-right-radius',
321
+ bl: 'border-bottom-left-radius',
322
+ };
323
+
324
+ /**
325
+ * Reads the popover's rendered corner radius in px (this includes the
326
+ * squircle doubling when `corner-shape: squircle` is supported). Cached per
327
+ * open: once a corner is flattened (or mid radius-transition) its computed
328
+ * value no longer reflects the base radius the clamp math needs.
329
+ */
330
+ function readCornerRadius(corner: Corner): number {
331
+ const cached = baseRadiusCache[corner];
332
+ if (cached !== undefined) return cached;
333
+ if (!popoverElement) return 0;
334
+ const value = parseFloat(
335
+ getComputedStyle(popoverElement).getPropertyValue(CORNER_PROPS[corner]),
336
+ );
337
+ const radius = Number.isFinite(value) ? value : 0;
338
+ baseRadiusCache[corner] = radius;
339
+ return radius;
340
+ }
341
+
342
+ /** The two popover corners on the edge the arrow sits on (start = top/left side first) */
343
+ function edgeCornersFor(p: string): [Corner, Corner] {
344
+ if (p.startsWith('bottom')) return ['tl', 'tr']; // arrow on the popover's top edge
345
+ if (p.startsWith('top')) return ['bl', 'br']; // arrow on the bottom edge
346
+ if (p.startsWith('right')) return ['tl', 'bl']; // arrow on the left edge
347
+ return ['tr', 'br']; // placement 'left*' → arrow on the right edge
348
+ }
349
+
350
+ /**
351
+ * Resolves where the arrow may sit along a popover edge without its base
352
+ * invading the rounded corners. Preference order:
353
+ * 1. Leave the arrow at `ideal` (pointing at the target) when it already
354
+ * sits on the straight segment of the edge.
355
+ * 2. Otherwise shift the whole popover along the edge (bounded by the
356
+ * viewport) so the arrow clears the corner AND still points at the target.
357
+ * 3. If the viewport blocks shifting far enough, shave the invaded corner's
358
+ * radius down exactly as much as needed so the arrow blends into a
359
+ * flatter corner instead of overlapping the curve.
360
+ *
361
+ * Returns the arrow center in final (shifted) popover coordinates, the
362
+ * popover shift, and per-corner radius overrides (undefined = keep base).
363
+ */
364
+ function resolveArrowAlongEdge(
365
+ ideal: number, // ideal arrow center relative to the unshifted popover start edge
366
+ length: number, // popover size along the edge
367
+ startRadius: number,
368
+ endRadius: number,
369
+ shiftMin: number, // most negative popover shift the viewport allows
370
+ shiftMax: number, // most positive popover shift the viewport allows
371
+ ): {
372
+ arrow: number;
373
+ shift: number;
374
+ start_radius: number | undefined;
375
+ end_radius: number | undefined;
376
+ } {
377
+ const minStart = startRadius + ARROW_HALF_BASE;
378
+ const minEnd = length - endRadius - ARROW_HALF_BASE;
379
+ let arrow = ideal;
380
+ let shift = 0;
381
+ let start_radius: number | undefined;
382
+ let end_radius: number | undefined;
383
+ if (ideal < minStart) {
384
+ shift = Math.max(ideal - minStart, Math.min(shiftMin, 0));
385
+ arrow = ideal - shift;
386
+ if (arrow < minStart) {
387
+ start_radius = Math.max(0, arrow - ARROW_HALF_BASE);
388
+ arrow = Math.max(ARROW_HALF_BASE, arrow);
389
+ }
390
+ } else if (ideal > minEnd) {
391
+ shift = Math.min(ideal - minEnd, Math.max(shiftMax, 0));
392
+ arrow = ideal - shift;
393
+ if (arrow > minEnd) {
394
+ end_radius = Math.max(0, length - arrow - ARROW_HALF_BASE);
395
+ arrow = Math.min(length - ARROW_HALF_BASE, arrow);
396
+ }
397
+ }
398
+ return { arrow, shift, start_radius, end_radius };
399
+ }
400
+
401
+ /** Builds the per-corner radius override map from a resolveArrowAlongEdge result */
402
+ function cornerOverrides(
403
+ start: Corner,
404
+ end: Corner,
405
+ startRadius: number | undefined,
406
+ endRadius: number | undefined,
407
+ ): Partial<Record<Corner, string>> {
408
+ const overrides: Partial<Record<Corner, string>> = {};
409
+ if (startRadius !== undefined) overrides[start] = `${startRadius}px`;
410
+ if (endRadius !== undefined) overrides[end] = `${endRadius}px`;
411
+ return overrides;
412
+ }
413
+
414
+ function getTransformOrigin(p: string): string {
415
+ if (p.startsWith('top')) return 'bottom center';
416
+ if (p.startsWith('bottom')) return 'top center';
417
+ if (p.startsWith('left')) return 'center right';
418
+ if (p.startsWith('right')) return 'center left';
419
+ return 'top center';
420
+ }
421
+
422
+ /** Detects actual placement after CSS anchor positioning resolves, then updates arrow/transform-origin/hit-box */
423
+ function detectAndUpdate() {
424
+ if (!popoverElement || !ref_element) return;
425
+ const popRect = popoverElement.getBoundingClientRect();
426
+ const refRect = ref_element.getBoundingClientRect();
427
+
428
+ // Detect primary axis based on which side of the ref the popover ended up
429
+ const suffix = placement.includes('-') ? '-' + placement.split('-')[1] : '';
430
+ if (placement.startsWith('bottom') || placement.startsWith('top')) {
431
+ const popMidY = (popRect.top + popRect.bottom) / 2;
432
+ const refMidY = (refRect.top + refRect.bottom) / 2;
433
+ realPlacement = ((popMidY > refMidY ? 'bottom' : 'top') +
434
+ suffix) as PopoverPlacement;
435
+ } else {
436
+ const popMidX = (popRect.left + popRect.right) / 2;
437
+ const refMidX = (refRect.left + refRect.right) / 2;
438
+ realPlacement = ((popMidX > refMidX ? 'right' : 'left') +
439
+ suffix) as PopoverPlacement;
440
+ }
441
+
442
+ transformOrigin = getTransformOrigin(realPlacement);
443
+
444
+ const [edgeStart, edgeEnd] = edgeCornersFor(realPlacement);
445
+
446
+ // Arrow positioning — keep the arrow base on the straight segment of the
447
+ // edge (clear of the rounded corners): point at the target → shift the
448
+ // popover so the arrow can point while clearing the corner → flatten the
449
+ // invaded corner when the viewport blocks shifting.
450
+ if (arrow && !transparent) {
451
+ const startRadius = readCornerRadius(edgeStart);
452
+ const endRadius = readCornerRadius(edgeEnd);
453
+ if (realPlacement.startsWith('top') || realPlacement.startsWith('bottom')) {
454
+ // Subtract the current shift so the math always works from the
455
+ // popover's unshifted (CSS anchor) position and stays stable.
456
+ const baseLeft = popRect.left - shiftX;
457
+ const resolved = resolveArrowAlongEdge(
458
+ refRect.left + refRect.width / 2 - baseLeft,
459
+ popRect.width,
460
+ startRadius,
461
+ endRadius,
462
+ VIEWPORT_MARGIN - baseLeft,
463
+ window.innerWidth - VIEWPORT_MARGIN - popRect.width - baseLeft,
464
+ );
465
+ shiftX = resolved.shift;
466
+ shiftY = 0;
467
+ // The arrow element is ARROW_SIZE / 2 wide; offset so its visual
468
+ // center (the tip) sits at the resolved coordinate.
469
+ arrowX = `${resolved.arrow - ARROW_SIZE / 4}px`;
470
+ arrowY = '';
471
+ cornerRadii = cornerOverrides(
472
+ edgeStart,
473
+ edgeEnd,
474
+ resolved.start_radius,
475
+ resolved.end_radius,
476
+ );
477
+ } else {
478
+ const baseTop = popRect.top - shiftY;
479
+ const resolved = resolveArrowAlongEdge(
480
+ refRect.top + refRect.height / 2 - baseTop,
481
+ popRect.height,
482
+ startRadius,
483
+ endRadius,
484
+ VIEWPORT_MARGIN - baseTop,
485
+ window.innerHeight - VIEWPORT_MARGIN - popRect.height - baseTop,
486
+ );
487
+ shiftY = resolved.shift;
488
+ shiftX = 0;
489
+ arrowY = `${resolved.arrow - ARROW_SIZE / 4}px`;
490
+ arrowX = '';
491
+ cornerRadii = cornerOverrides(
492
+ edgeStart,
493
+ edgeEnd,
494
+ resolved.start_radius,
495
+ resolved.end_radius,
496
+ );
497
+ }
498
+ }
499
+
500
+ // Hit box for hover popovers
501
+ if (open_on_hover && !untrack(() => forcedOpened)) {
502
+ const borderRadius = Math.max(
503
+ readCornerRadius(edgeStart),
504
+ readCornerRadius(edgeEnd),
505
+ );
506
+ if (realPlacement.startsWith('top') || realPlacement.startsWith('bottom')) {
507
+ hitBoxLengthZ = Math.min(16, refRect.height / 2) + anchorOffset;
508
+ hitBoxLength = popoverElement.clientWidth;
509
+ hitBoxLengthA = popoverElement.clientWidth - borderRadius * 2;
510
+ hitBoxLengthB = refRect.width;
511
+ hitBoxOffsetA = borderRadius;
512
+ hitBoxOffsetB = refRect.x - popRect.x;
513
+ } else {
514
+ hitBoxLengthZ = Math.min(16, refRect.width / 2) + anchorOffset;
515
+ hitBoxLength = popoverElement.clientHeight;
516
+ hitBoxLengthA = popoverElement.clientHeight - borderRadius * 2;
517
+ hitBoxLengthB = refRect.height;
518
+ hitBoxOffsetA = borderRadius;
519
+ hitBoxOffsetB = refRect.y - popRect.y;
520
+ }
521
+ }
522
+
523
+ positioned = true;
524
+ }
525
+
526
+ /** Position calculation for virtual reference (context menu) — one-shot, no autoUpdate */
527
+ function calculateVirtualPosition() {
528
+ if (!popoverElement || typeof x !== 'number' || typeof y !== 'number') return;
529
+
530
+ const popRect = popoverElement.getBoundingClientRect();
531
+ const vw = window.innerWidth;
532
+ const vh = window.innerHeight;
533
+
534
+ let calcX = x;
535
+ let calcY = y + anchorOffset;
536
+ let calcPlacement = placement;
537
+
538
+ // Flip vertically if overflows bottom
539
+ if (calcY + popRect.height > vh && y - popRect.height - anchorOffset > 0) {
540
+ calcY = y - popRect.height - anchorOffset;
541
+ const suffix = placement.includes('-') ? '-' + placement.split('-')[1] : '';
542
+ calcPlacement = ('top' + suffix) as PopoverPlacement;
543
+ }
544
+
545
+ // Clamp to viewport
546
+ calcX = Math.max(
547
+ VIEWPORT_MARGIN,
548
+ Math.min(calcX, vw - popRect.width - VIEWPORT_MARGIN),
549
+ );
550
+ calcY = Math.max(
551
+ VIEWPORT_MARGIN,
552
+ Math.min(calcY, vh - popRect.height - VIEWPORT_MARGIN),
553
+ );
554
+
555
+ realPlacement = calcPlacement;
556
+ transformOrigin = getTransformOrigin(calcPlacement);
557
+
558
+ // Arrow positioning — same clamp → shift → corner-flatten cascade as the
559
+ // real-element path, except the shift is folded straight into the
560
+ // popover's position (this path is one-shot, no translate needed).
561
+ if (arrow && !transparent) {
562
+ const [edgeStart, edgeEnd] = edgeCornersFor(realPlacement);
563
+ if (realPlacement.startsWith('top') || realPlacement.startsWith('bottom')) {
564
+ const resolved = resolveArrowAlongEdge(
565
+ x - calcX,
566
+ popRect.width,
567
+ readCornerRadius(edgeStart),
568
+ readCornerRadius(edgeEnd),
569
+ VIEWPORT_MARGIN - calcX,
570
+ vw - VIEWPORT_MARGIN - popRect.width - calcX,
571
+ );
572
+ calcX += resolved.shift;
573
+ arrowX = `${resolved.arrow - ARROW_SIZE / 4}px`;
574
+ arrowY = '';
575
+ cornerRadii = cornerOverrides(
576
+ edgeStart,
577
+ edgeEnd,
578
+ resolved.start_radius,
579
+ resolved.end_radius,
580
+ );
581
+ } else {
582
+ const resolved = resolveArrowAlongEdge(
583
+ y - calcY,
584
+ popRect.height,
585
+ readCornerRadius(edgeStart),
586
+ readCornerRadius(edgeEnd),
587
+ VIEWPORT_MARGIN - calcY,
588
+ vh - VIEWPORT_MARGIN - popRect.height - calcY,
589
+ );
590
+ calcY += resolved.shift;
591
+ arrowY = `${resolved.arrow - ARROW_SIZE / 4}px`;
592
+ arrowX = '';
593
+ cornerRadii = cornerOverrides(
594
+ edgeStart,
595
+ edgeEnd,
596
+ resolved.start_radius,
597
+ resolved.end_radius,
598
+ );
599
+ }
600
+ }
601
+
602
+ left = `${calcX}px`;
603
+ top = `${calcY}px`;
604
+ positioned = true;
605
+ }
606
+
607
+ // Positioning effect — detects actual placement for arrow/transform-origin/hit-box
608
+ $effect(() => {
609
+ if (!shown || !popoverElement) return;
610
+ // oxlint-disable-next-line no-unused-expressions
611
+ placement; // re-run when placement changes (so the arrow location gets update dynamically)
612
+
613
+ if (ref_element) {
614
+ // Real element path: CSS anchor positioning handles layout, we just detect the result
615
+ const el = ref_element;
616
+ const popEl = popoverElement;
617
+ const onUpdate = () => untrack(() => detectAndUpdate());
618
+ const rafId = requestAnimationFrame(onUpdate);
619
+ // Re-measure once the intro scale transition settles — rects read while
620
+ // the popover is still scaling are smaller than the final layout box.
621
+ const settleTimeout = setTimeout(onUpdate, TRANSITION_IN_DURATION + 50);
622
+
623
+ window.addEventListener('scroll', onUpdate, true);
624
+ window.addEventListener('resize', onUpdate);
625
+
626
+ const resizeObserver = new ResizeObserver(onUpdate);
627
+ resizeObserver.observe(popEl);
628
+ resizeObserver.observe(el);
629
+
630
+ return () => {
631
+ cancelAnimationFrame(rafId);
632
+ clearTimeout(settleTimeout);
633
+ window.removeEventListener('scroll', onUpdate, true);
634
+ window.removeEventListener('resize', onUpdate);
635
+ resizeObserver.disconnect();
636
+ };
637
+ } else {
638
+ // Virtual reference path (context menu) — one-shot JS positioning
639
+ untrack(() => calculateVirtualPosition());
640
+ }
641
+ });
642
+
643
+ // Close the popover when clicked outside (fallback for when focus-trap doesn't activate)
644
+ $effect(() => {
645
+ if (!opened || !close_on_outside_click) return;
646
+ function onDocumentPointerDown(e: PointerEvent) {
647
+ if (!popoverElement || !opened) return;
648
+ let el = e.target as HTMLElement | null | undefined;
649
+ while (el) {
650
+ if (
651
+ el === popoverElement ||
652
+ el === ref_element ||
653
+ (el.classList.contains('portal') && el.id === 'portal_' + id)
654
+ ) {
655
+ return;
656
+ }
657
+ el = el.parentElement;
658
+ }
659
+ let highestPopoverIndex = -1;
660
+ document.querySelectorAll('[data-popover-index]').forEach((el) => {
661
+ highestPopoverIndex = Math.max(
662
+ highestPopoverIndex,
663
+ +((el as HTMLElement).dataset.popoverIndex || '0'),
664
+ );
665
+ });
666
+ if (highestPopoverIndex <= popoverIndex) {
667
+ opened = false;
668
+ forcedOpened = false;
669
+ }
670
+ }
671
+ // Delay adding the listener to avoid catching the click that opened the popover
672
+ const timeout = setTimeout(() => {
673
+ document.addEventListener('pointerdown', onDocumentPointerDown);
674
+ }, 0);
675
+ return () => {
676
+ clearTimeout(timeout);
677
+ document.removeEventListener('pointerdown', onDocumentPointerDown);
678
+ };
679
+ });
680
+
681
+ // Handle the popover opening and closing when the ref element is hovered over
682
+ let stopMouseDownListener = () => {};
683
+ let stopMouseMoveListener = () => {};
684
+ let refListeners: Array<() => void> = [];
685
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
686
+ function stopRefListeners() {
687
+ refListeners.forEach((destroy) => destroy());
688
+ refListeners = [];
689
+ }
690
+ function stopListeners() {
691
+ stopRefListeners();
692
+ stopMouseDownListener();
693
+ stopMouseMoveListener();
694
+ }
695
+ let willOpen = false;
696
+
697
+ /** Handles when a mouse moves (after the popoever has been opened on hover). Used to close popover when moved off */
698
+ function onMouseMove(e: MouseEvent) {
699
+ if (!ref_element) return;
700
+ if (untrack(() => forcedOpened)) return stopMouseMoveListener();
701
+ let el = e.target as HTMLElement | null | undefined;
702
+ let isHoveringOverPopover = false;
703
+ if (el && !el.classList.contains('popover-hit-box')) {
704
+ while (el) {
705
+ if (
706
+ el === popoverElement ||
707
+ el === ref_element ||
708
+ el.classList.contains('popover-hit-shape')
709
+ ) {
710
+ isHoveringOverPopover = true;
711
+ break;
712
+ }
713
+ el = el?.parentElement;
714
+ }
715
+ }
716
+ if (isHoveringOverPopover) {
717
+ if (!willOpen) willOpen = true;
718
+ return;
719
+ }
720
+ if (untrack(() => !opened)) portalOpened = false;
721
+ opened = false;
722
+ willOpen = false;
723
+ stopMouseMoveListener();
724
+ clearTimeout(debounceTimer);
725
+ }
726
+
727
+ /** Handles when the user clicks outside the popover (and thus is should close) */
728
+ function onPointerDown(e: MouseEvent) {
729
+ let el = e.target as HTMLElement | null | undefined;
730
+ let isOutsideClick = true;
731
+ while (el) {
732
+ if (
733
+ el === popoverElement ||
734
+ el === ref_element ||
735
+ (el.classList.contains('portal') && el.id === 'portal_' + id)
736
+ ) {
737
+ isOutsideClick = false;
738
+ break;
739
+ }
740
+ el = el?.parentElement;
741
+ }
742
+ let highestPopoverIndex = -1;
743
+ document.querySelectorAll('[data-popover-index]').forEach((el) => {
744
+ highestPopoverIndex = Math.max(
745
+ highestPopoverIndex,
746
+ +((el as HTMLElement).dataset.popoverIndex || '0'),
747
+ );
748
+ });
749
+ if (isOutsideClick && highestPopoverIndex <= popoverIndex) {
750
+ opened = false;
751
+ willOpen = false;
752
+ forcedOpened = false;
753
+ stopMouseDownListener();
754
+ }
755
+ }
756
+
757
+ /** Handles when the mouse enters the popover's target/trigger element. Used to open the popover on hover */
758
+ function onRefElementMouseEnter(e: MouseEvent) {
759
+ willOpen = false;
760
+ clearTimeout(debounceTimer);
761
+ if (untrack(() => forcedOpened)) return stopMouseMoveListener();
762
+ if (!ref_element) {
763
+ opened = false;
764
+ forcedOpened = false;
765
+ return;
766
+ }
767
+ debounceTimer = setTimeout(() => {
768
+ if (untrack(() => forcedOpened)) return;
769
+ if (untrack(() => opened)) return;
770
+ if (!willOpen) return;
771
+ opened = true;
772
+ }, hover_delay);
773
+ portalOpened = true;
774
+ willOpen = true;
775
+ document.addEventListener('mousemove', onMouseMove);
776
+ stopMouseMoveListener = () => {
777
+ document.removeEventListener('mousemove', onMouseMove);
778
+ };
779
+ }
780
+
781
+ /**
782
+ * Prevents the pointer down event from propagating
783
+ * This prevents other mousedown events like the ripple effect from firing on a parent element
784
+ */
785
+ function onRefElementPointerUp(e: PointerEvent) {
786
+ e.preventDefault();
787
+ e.stopPropagation();
788
+ }
789
+
790
+ /** Handles when the user clicks on the popover's target/trigger element. Used to force open the popover */
791
+ function onRefElementClick(e: MouseEvent) {
792
+ let el = e.target as HTMLElement | null | undefined;
793
+
794
+ // Check if the hit box shape was clicked. If so, we need to check if the click would have hit the trigger element
795
+ if (el && el.classList.contains('popover-hit-shape')) {
796
+ el.style.pointerEvents = 'none';
797
+ let triggerEl = document.elementFromPoint(e.clientX, e.clientY);
798
+ let isRefElement = false;
799
+ while (triggerEl) {
800
+ if (triggerEl === ref_element) {
801
+ isRefElement = true;
802
+ break;
803
+ }
804
+ triggerEl = triggerEl.parentElement;
805
+ }
806
+ el.style.removeProperty('pointer-events');
807
+ if (!isRefElement) return;
808
+ }
809
+ e.preventDefault();
810
+ e.stopPropagation();
811
+ e.stopImmediatePropagation();
812
+
813
+ if (untrack(() => opened) && untrack(() => forcedOpened)) {
814
+ willOpen = false;
815
+ opened = false;
816
+ forcedOpened = false;
817
+ stopMouseDownListener();
818
+ return;
819
+ }
820
+ forcedOpened = true;
821
+ willOpen = true;
822
+ opened = true;
823
+ document.addEventListener('pointerdown', onPointerDown);
824
+ stopMouseDownListener = () => {
825
+ document.removeEventListener('pointerdown', onPointerDown);
826
+ };
827
+ }
828
+
829
+ /** Handles when the user presses enter/escape when the trigger element is focused */
830
+ function onRefElementKeyUp(e: KeyboardEvent) {
831
+ if (e.key === 'Escape') {
832
+ opened = false;
833
+ forcedOpened = false;
834
+ e.preventDefault();
835
+ e.stopPropagation();
836
+ }
837
+ // We should ignore events that are on button like elements
838
+ // because they will trigger the click event (which will toggle the popover)
839
+ // If this also runs, it will toggle the popover twice
840
+ const isButtonLike =
841
+ e.target instanceof HTMLButtonElement || e.target instanceof HTMLAnchorElement;
842
+ if (!isButtonLike && (e.key === 'Enter' || e.key === ' ')) {
843
+ if (untrack(() => forcedOpened)) {
844
+ opened = !untrack(() => opened);
845
+ forcedOpened = !untrack(() => opened);
846
+ } else {
847
+ forcedOpened = true;
848
+ }
849
+ e.preventDefault();
850
+ e.stopPropagation();
851
+ }
852
+ }
853
+
854
+ /** Handles when the trigger element is no longer in focus and thus should be closed (if open) */
855
+ function onRefElementBlur(e: FocusEvent) {
856
+ if (!ref_element) return;
857
+ ref_element.removeEventListener('blur', onRefElementBlur);
858
+ ref_element.removeEventListener('keyup', onRefElementKeyUp);
859
+ if (untrack(() => forcedOpened)) return;
860
+ if (!untrack(() => opened)) return;
861
+ opened = false;
862
+ }
863
+
864
+ /** Handles when the trigger element is focused. If so, the panel will be opened if open_on_focus is true */
865
+ function onRefElementFocus(e: FocusEvent) {
866
+ if (!ref_element) return;
867
+ ref_element.addEventListener('blur', onRefElementBlur);
868
+ if (close_on_escape_key) {
869
+ ref_element.addEventListener('keyup', onRefElementKeyUp);
870
+ }
871
+ if (untrack(() => forcedOpened)) return;
872
+ if (untrack(() => opened)) return;
873
+ portalOpened = true;
874
+ tick().then(() => {
875
+ // Delay opening the popover by a frame so the the animation in effect will work
876
+ opened = true;
877
+ });
878
+ }
879
+
880
+ /** Handles when the user presses escape when the portal element is focused */
881
+ function onPortalElementKeyUp(e: KeyboardEvent) {
882
+ if (e.key === 'Escape') {
883
+ opened = false;
884
+ forcedOpened = false;
885
+ e.preventDefault();
886
+ e.stopPropagation();
887
+ }
888
+ }
889
+
890
+ // Add event listeners when the ref_element is set
891
+ $effect(() => {
892
+ if (!ref_element || (!open_on_hover && !open_on_click && !open_on_focus)) return;
893
+ stopListeners();
894
+ if (open_on_hover) {
895
+ ref_element.addEventListener('mouseenter', onRefElementMouseEnter);
896
+ } else {
897
+ ref_element.removeEventListener('mouseenter', onRefElementMouseEnter);
898
+ }
899
+ if (open_on_click) {
900
+ ref_element.addEventListener('click', onRefElementClick);
901
+ ref_element.addEventListener('pointerup', onRefElementPointerUp);
902
+ } else {
903
+ ref_element.removeEventListener('click', onRefElementClick);
904
+ }
905
+ if (open_on_focus) {
906
+ ref_element.addEventListener('focus', onRefElementFocus);
907
+ ref_element.tabIndex = 0;
908
+ } else {
909
+ if (close_on_escape_key) {
910
+ ref_element.addEventListener('keyup', onRefElementKeyUp);
911
+ } else {
912
+ ref_element.removeEventListener('keyup', onRefElementKeyUp);
913
+ }
914
+ ref_element.removeEventListener('focus', onRefElementFocus);
915
+ ref_element.removeAttribute('tabindex');
916
+ }
917
+ refListeners.push(
918
+ () => ref_element?.removeEventListener('mouseenter', onRefElementMouseEnter),
919
+ () => ref_element?.removeEventListener('click', onRefElementClick),
920
+ () => ref_element?.removeEventListener('pointerup', onRefElementPointerUp),
921
+ () => ref_element?.removeEventListener('focus', onRefElementFocus),
922
+ () => ref_element?.removeEventListener('blur', onRefElementBlur),
923
+ () => ref_element?.removeEventListener('keyup', onRefElementKeyUp),
924
+ );
925
+ return () => stopListeners();
926
+ });
927
+
928
+ // Remove any remaining document listeners and pending timers on unmount
929
+ $effect(() => {
930
+ return () => {
931
+ clearTimeout(portalOpenedTimeout);
932
+ clearTimeout(debounceTimer);
933
+ stopListeners();
934
+ };
935
+ });
936
+
937
+ $effect.pre(() => {
938
+ if (!portalOpened || !shown) return;
939
+ let highestIndex = -1;
940
+ document.querySelectorAll('[data-popover-index]').forEach((el) => {
941
+ highestIndex = Math.max(
942
+ highestIndex,
943
+ +((el as HTMLElement).dataset.popoverIndex || '0'),
944
+ );
945
+ });
946
+ popoverIndex = highestIndex + 1;
947
+ });
948
+ </script>
949
+
950
+ {#snippet popover()}
951
+ {#if shown}
952
+ <div
953
+ class="popover {class_name}"
954
+ class:positioned
955
+ class:transparent
956
+ class:dense
957
+ class:comfortable
958
+ bind:this={popoverElement}
959
+ style="{style}; {positionStyle}"
960
+ style:--popover-radius={radius}
961
+ style:transform-origin={transformOrigin}
962
+ style:translate={shiftX || shiftY ? `${shiftX}px ${shiftY}px` : undefined}
963
+ style:border-top-left-radius={cornerRadii.tl}
964
+ style:border-top-right-radius={cornerRadii.tr}
965
+ style:border-bottom-right-radius={cornerRadii.br}
966
+ style:border-bottom-left-radius={cornerRadii.bl}
967
+ {id}
968
+ data-popover-index={popoverIndex}
969
+ role="presentation"
970
+ {@attach focusTrap({
971
+ enabled: !open_on_focus,
972
+ initialFocus: disable_initial_focus ? false : undefined,
973
+ clickOutsideDeactivates: (e) => {
974
+ if ((e as MouseEvent).button === 2) return false; // Ignore right clicks
975
+ if (!close_on_outside_click) return false;
976
+ if (open_on_click) return false;
977
+ let el = e.target as HTMLElement | null | undefined;
978
+ while (el) {
979
+ if ((ref_element && el === ref_element) || el === popoverElement)
980
+ return false;
981
+ el = el.parentElement;
982
+ }
983
+ return true;
984
+ },
985
+ escapeDeactivates: false,
986
+ returnFocusOnDeactivate: true,
987
+ allowOutsideClick: true,
988
+ onDeactivate: () => {
989
+ if (opened) opened = false;
990
+ if (forcedOpened) forcedOpened = false;
991
+ stopMouseDownListener();
992
+ },
993
+ })}
994
+ onkeyup={onPortalElementKeyUp}
995
+ onclick={(e) => {
996
+ if (!close_on_inside_click) return;
997
+ let element = e.target as HTMLElement;
998
+ let isButtonLike = false;
999
+ while (element) {
1000
+ if (element.tagName === 'BUTTON' || element.tagName === 'A') {
1001
+ isButtonLike = true;
1002
+ break;
1003
+ }
1004
+ element = element.parentElement as HTMLElement;
1005
+ }
1006
+ if (isButtonLike && opened) opened = false;
1007
+ }}
1008
+ in:scale={{ start: 0.7, easing: backOut, duration: TRANSITION_IN_DURATION }}
1009
+ out:scale={{ start: 0.7, easing: backIn, duration: TRANSITION_OUT_DURATION }}
1010
+ onoutroend={() => {
1011
+ if (ref_element) (ref_element.style as any).anchorName = '';
1012
+ }}>
1013
+ <div class="content" {@attach scrollbar()}>
1014
+ {#if children}{@render children()}{/if}
1015
+ </div>
1016
+ {#if arrow}
1017
+ <div
1018
+ class="arrow"
1019
+ class:bottom={realPlacement.startsWith('top')}
1020
+ class:top={realPlacement.startsWith('bottom')}
1021
+ class:left={realPlacement.startsWith('right')}
1022
+ class:right={realPlacement.startsWith('left')}
1023
+ bind:this={arrowElement}
1024
+ style:--arrow-size={`${ARROW_SIZE}px`}
1025
+ style:left={arrowX}
1026
+ style:top={arrowY}>
1027
+ </div>
1028
+ {/if}
1029
+ {#if open_on_hover && !forcedOpened}
1030
+ <svg
1031
+ xmlns="http://www.w3.org/2000/svg"
1032
+ class="popover-hit-box"
1033
+ width={realPlacement.startsWith('bottom') || realPlacement.startsWith('top')
1034
+ ? hitBoxLength
1035
+ : hitBoxLengthZ}
1036
+ height={realPlacement.startsWith('bottom') || realPlacement.startsWith('top')
1037
+ ? hitBoxLengthZ
1038
+ : hitBoxLength}
1039
+ viewBox={realPlacement.startsWith('bottom') || realPlacement.startsWith('top')
1040
+ ? `0 0 ${hitBoxLength} ${hitBoxLengthZ}`
1041
+ : `0 0 ${hitBoxLengthZ} ${hitBoxLength}`}
1042
+ style:pointer-events="none"
1043
+ style:position="absolute"
1044
+ style:top={realPlacement.startsWith('top')
1045
+ ? '100%'
1046
+ : realPlacement.startsWith('bottom')
1047
+ ? ''
1048
+ : '0px'}
1049
+ style:bottom={realPlacement.startsWith('bottom') ? '100%' : ''}
1050
+ style:left={realPlacement.startsWith('left')
1051
+ ? '100%'
1052
+ : realPlacement.startsWith('right')
1053
+ ? ''
1054
+ : '0px'}
1055
+ style:right={realPlacement.startsWith('right') ? '100%' : ''}>
1056
+ <path
1057
+ class="popover-hit-shape"
1058
+ onclick={onRefElementClick}
1059
+ role="presentation"
1060
+ d={hitBoxShape}
1061
+ fill="transparent">
1062
+ </path>
1063
+ </svg>
1064
+ {/if}
1065
+ </div>
1066
+ {/if}
1067
+ {/snippet}
1068
+
1069
+ {#if strategy === 'fixed'}
1070
+ {#if portalOpened}
1071
+ <Portal id="portal_{id}">{@render popover()}</Portal>
1072
+ {/if}
1073
+ {:else}
1074
+ {@render popover()}
1075
+ {/if}
1076
+
1077
+ <style>
1078
+ .popover-hit-box {
1079
+ pointer-events: none;
1080
+ }
1081
+ .popover-hit-shape {
1082
+ pointer-events: all;
1083
+ }
1084
+ .popover {
1085
+ --color-bg: var(--color-surface);
1086
+ --layer: var(--layer-popover);
1087
+ --easing: var(--ease-spring);
1088
+ /* Clamp the radius so an over-rounded --popover-radius (or --radius-xl)
1089
+ can't turn the panel into a blob — see --radius-cap. The corner
1090
+ flattening reads the computed corner radius, so it picks this up too. */
1091
+ --_radius: min(var(--popover-radius, var(--radius-xl)), var(--radius-cap, 40px));
1092
+ z-index: var(--layer);
1093
+ background-color: var(--color-bg);
1094
+ border: 1px solid var(--color-border, transparent);
1095
+ border-radius: var(--_radius);
1096
+ @supports (corner-shape: squircle) {
1097
+ corner-shape: squircle;
1098
+ border-radius: calc(var(--_radius) * var(--squircle-ratio, 2));
1099
+ }
1100
+ /* Light mode: a real drop shadow lifts the panel off the page. Dark mode:
1101
+ * the --shadow-md token resolves to transparent (dark shadows are
1102
+ * invisible on dark surfaces), so the border above is what separates the
1103
+ * panel from the page. */
1104
+ box-shadow: var(--shadow-md);
1105
+ max-width: calc(100vw - 1rem);
1106
+ max-height: calc(100vh - 1rem);
1107
+ /* Smooth the dynamic corner flattening (arrow-near-corner fallback) so a
1108
+ * radius change while open never pops. Everything else stays untransitioned. */
1109
+ transition: border-radius 150ms ease;
1110
+ overflow: visible;
1111
+ .content {
1112
+ padding: 1rem 1.25rem;
1113
+ overflow: auto;
1114
+ overscroll-behavior: contain;
1115
+ max-height: inherit;
1116
+ max-width: inherit;
1117
+ border-radius: inherit;
1118
+ @supports (corner-shape: squircle) {
1119
+ corner-shape: inherit;
1120
+ }
1121
+ }
1122
+ &.dense .content {
1123
+ padding: 0.5rem 0.75rem;
1124
+ }
1125
+ &.comfortable .content {
1126
+ padding: 1.5rem 2rem;
1127
+ }
1128
+ /* When the popover hosts a top-level list, drop the inner padding so its
1129
+ * items run edge-to-edge (a list reads as the menu, not content inside a
1130
+ * padded card). The list comes from another component → matched with
1131
+ * :global. */
1132
+ &:has(> .content > :global(ul.list:only-child)) .content {
1133
+ padding: 0;
1134
+ }
1135
+ /* If that list provides its OWN surface (`filled`/`outline`), also drop
1136
+ * the popover's background + border so the list surface takes over
1137
+ * (otherwise a double surface). A plain (transparent) list instead keeps
1138
+ * the popover's surface as its background. */
1139
+ &:has(> .content > :global(ul.list:is(.filled, .outline):only-child)) {
1140
+ background-color: transparent;
1141
+ border-color: transparent;
1142
+ }
1143
+ /* Transparent panel: hand the surface entirely to the inner content. */
1144
+ &.transparent {
1145
+ background-color: transparent;
1146
+ border-color: transparent;
1147
+ box-shadow: none;
1148
+ }
1149
+ &.transparent .content {
1150
+ padding: 0;
1151
+ }
1152
+ &.transparent .arrow {
1153
+ display: none;
1154
+ }
1155
+ .arrow {
1156
+ position: absolute;
1157
+ pointer-events: none;
1158
+ background-color: var(--color-bg);
1159
+ width: calc(var(--arrow-size) / 2);
1160
+ height: calc(var(--arrow-size) / 2);
1161
+ top: calc(var(--arrow-size) / -2);
1162
+ /* Stroke the arrow's outline so it carries the same border as the
1163
+ * panel — otherwise, with no shadow and a surface color near the page
1164
+ * color (especially in dark mode), the arrow is invisible. Three 1px
1165
+ * drop-shadows trace the two slanted sides + the rounded tip/shoulders
1166
+ * (built by the ::before/::after fillets, which the filter includes);
1167
+ * the base needs no stroke since it sits on the panel's own border.
1168
+ * Directions are in the arrow's local "points up" space — the
1169
+ * rotate() on .bottom/.left/.right carries them to the right edges. */
1170
+ filter: drop-shadow(0 -1px 0 var(--color-border, transparent))
1171
+ drop-shadow(-1px 0 0 var(--color-border, transparent))
1172
+ drop-shadow(1px 0 0 var(--color-border, transparent));
1173
+ /* The fillets reach below the panel edge (into the panel) to blend the
1174
+ * shoulders; the filter would stroke the side-edges of that hidden
1175
+ * extension, drawing a border line across the arrow's mouth ("floor").
1176
+ * Clip everything past the base (`bottom: 0` = the panel edge) so the
1177
+ * mouth stays open and the panel's own border draws the shoulders. The
1178
+ * negative insets leave the tip, slants, and stroke unclipped. */
1179
+ clip-path: inset(-100px -100px 0 -100px);
1180
+ &.bottom {
1181
+ top: 100%;
1182
+ transform: rotate(180deg);
1183
+ }
1184
+ &.left {
1185
+ right: 100%;
1186
+ transform: rotate(270deg);
1187
+ }
1188
+ &.right {
1189
+ left: 100%;
1190
+ transform: rotate(90deg);
1191
+ }
1192
+ &::before,
1193
+ &::after {
1194
+ content: '';
1195
+ position: absolute;
1196
+ height: var(--arrow-size);
1197
+ width: var(--arrow-size);
1198
+ bottom: 0;
1199
+ }
1200
+
1201
+ &::after {
1202
+ right: calc(var(--arrow-size) * -1 + 3px);
1203
+ border-radius: 0 0 0 var(--arrow-size);
1204
+ box-shadow: min(-2px, calc(var(--arrow-size) / -2 + 8px)) 8px 0 0 var(--color-bg);
1205
+ }
1206
+
1207
+ &::before {
1208
+ left: calc(var(--arrow-size) * -1 + 3px);
1209
+ border-radius: 0px 0px var(--arrow-size) 0;
1210
+ box-shadow: max(2px, calc(var(--arrow-size) / 2 - 8px)) 8px 0 0 var(--color-bg);
1211
+ }
1212
+ }
1213
+ }
1214
+ </style>