@pienter/ui 0.2.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 (227) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/CONVENTIONS.md +1499 -0
  3. package/LICENSE +21 -0
  4. package/README.md +71 -0
  5. package/components/action/button/Button.astro +95 -0
  6. package/components/action/button/Button.vue +92 -0
  7. package/components/action/button/IconButton.astro +86 -0
  8. package/components/action/button/IconButton.vue +85 -0
  9. package/components/action/button/button.css +194 -0
  10. package/components/action/toggle/Toggle.vue +38 -0
  11. package/components/action/toggle/toggle.css +77 -0
  12. package/components/action/toggle-group/ToggleGroup.vue +67 -0
  13. package/components/action/toggle-group/toggle-group.css +78 -0
  14. package/components/display/avatar/Avatar.astro +17 -0
  15. package/components/display/avatar/Avatar.vue +30 -0
  16. package/components/display/avatar/AvatarStack.astro +9 -0
  17. package/components/display/avatar/AvatarStack.vue +11 -0
  18. package/components/display/avatar/avatar.css +58 -0
  19. package/components/display/badge/Badge.astro +15 -0
  20. package/components/display/badge/Badge.vue +23 -0
  21. package/components/display/badge/badge.css +56 -0
  22. package/components/display/empty/Empty.astro +9 -0
  23. package/components/display/empty/Empty.vue +11 -0
  24. package/components/display/empty/empty.css +39 -0
  25. package/components/display/icon/Icon.astro +52 -0
  26. package/components/display/icon/Icon.vue +57 -0
  27. package/components/display/icon/icon.css +47 -0
  28. package/components/feedback/alert/Alert.astro +52 -0
  29. package/components/feedback/alert/Alert.vue +60 -0
  30. package/components/feedback/alert/alert.css +78 -0
  31. package/components/feedback/progress/Progress.astro +68 -0
  32. package/components/feedback/progress/Progress.vue +82 -0
  33. package/components/feedback/progress/progress.css +68 -0
  34. package/components/feedback/skeleton/Skeleton.astro +32 -0
  35. package/components/feedback/skeleton/Skeleton.vue +39 -0
  36. package/components/feedback/skeleton/skeleton.css +56 -0
  37. package/components/feedback/spinner/Spinner.astro +25 -0
  38. package/components/feedback/spinner/Spinner.vue +36 -0
  39. package/components/feedback/spinner/spinner.css +91 -0
  40. package/components/feedback/toast/Toast.astro +50 -0
  41. package/components/feedback/toast/Toast.vue +74 -0
  42. package/components/feedback/toast/toast.css +128 -0
  43. package/components/form/checkbox/Checkbox.astro +79 -0
  44. package/components/form/checkbox/Checkbox.vue +95 -0
  45. package/components/form/checkbox/checkbox.css +59 -0
  46. package/components/form/combobox/Combobox.vue +508 -0
  47. package/components/form/combobox/combobox.css +110 -0
  48. package/components/form/date-input/DateInput.astro +105 -0
  49. package/components/form/date-input/DateInput.vue +121 -0
  50. package/components/form/date-input/date-input.css +19 -0
  51. package/components/form/form/Form.astro +106 -0
  52. package/components/form/form/Form.vue +181 -0
  53. package/components/form/form/form.css +46 -0
  54. package/components/form/input-otp/InputOTP.astro +147 -0
  55. package/components/form/input-otp/InputOTP.vue +209 -0
  56. package/components/form/input-otp/input-otp.css +52 -0
  57. package/components/form/label/Label.astro +13 -0
  58. package/components/form/label/Label.vue +20 -0
  59. package/components/form/label/label.css +11 -0
  60. package/components/form/number-field/NumberField.astro +142 -0
  61. package/components/form/number-field/NumberField.vue +155 -0
  62. package/components/form/number-field/number-field.css +115 -0
  63. package/components/form/radio-group/RadioGroup.astro +105 -0
  64. package/components/form/radio-group/RadioGroup.vue +110 -0
  65. package/components/form/radio-group/radio-group.css +114 -0
  66. package/components/form/radio-group/types.ts +14 -0
  67. package/components/form/select/Segmented.vue +36 -0
  68. package/components/form/select/Select.astro +105 -0
  69. package/components/form/select/Select.vue +109 -0
  70. package/components/form/select/select.css +96 -0
  71. package/components/form/slider/Slider.astro +205 -0
  72. package/components/form/slider/Slider.vue +321 -0
  73. package/components/form/slider/slider.css +115 -0
  74. package/components/form/switch/Switch.astro +75 -0
  75. package/components/form/switch/Switch.vue +89 -0
  76. package/components/form/switch/switch.css +64 -0
  77. package/components/form/tags-input/TagsInput.astro +153 -0
  78. package/components/form/tags-input/TagsInput.vue +207 -0
  79. package/components/form/tags-input/tags-input.css +128 -0
  80. package/components/form/text-input/TextInput.astro +84 -0
  81. package/components/form/text-input/TextInput.vue +99 -0
  82. package/components/form/text-input/text-input.css +165 -0
  83. package/components/form/textarea/Textarea.astro +86 -0
  84. package/components/form/textarea/Textarea.vue +102 -0
  85. package/components/form/textarea/textarea.css +25 -0
  86. package/components/layout/accordion/Accordion.vue +59 -0
  87. package/components/layout/accordion/accordion.css +87 -0
  88. package/components/layout/card/Card.astro +13 -0
  89. package/components/layout/card/Card.vue +20 -0
  90. package/components/layout/card/card.css +55 -0
  91. package/components/layout/collapsible/Collapsible.vue +77 -0
  92. package/components/layout/collapsible/collapsible.css +76 -0
  93. package/components/layout/separator/Separator.astro +31 -0
  94. package/components/layout/separator/Separator.vue +33 -0
  95. package/components/layout/separator/separator.css +27 -0
  96. package/components/layout/table/DataTable.vue +127 -0
  97. package/components/layout/table/Table.astro +116 -0
  98. package/components/layout/table/Table.vue +146 -0
  99. package/components/layout/table/TableRow.vue +59 -0
  100. package/components/layout/table/table.css +201 -0
  101. package/components/layout/table/types.ts +35 -0
  102. package/components/layout/table/useTable.ts +7 -0
  103. package/components/navigation/breadcrumb/Breadcrumb.astro +36 -0
  104. package/components/navigation/breadcrumb/Breadcrumb.vue +36 -0
  105. package/components/navigation/breadcrumb/breadcrumb.css +37 -0
  106. package/components/navigation/navbar/Navbar.astro +62 -0
  107. package/components/navigation/navbar/Navbar.vue +50 -0
  108. package/components/navigation/navbar/navbar.css +77 -0
  109. package/components/navigation/pagination/Pagination.vue +107 -0
  110. package/components/navigation/pagination/pagination.css +53 -0
  111. package/components/navigation/sidebar/Sidebar.astro +132 -0
  112. package/components/navigation/sidebar/Sidebar.vue +174 -0
  113. package/components/navigation/sidebar/SidebarItemRender.astro +83 -0
  114. package/components/navigation/sidebar/SidebarItemRender.vue +98 -0
  115. package/components/navigation/sidebar/sidebar.css +303 -0
  116. package/components/navigation/sidebar/types.ts +72 -0
  117. package/components/navigation/tabs/Tabs.vue +84 -0
  118. package/components/navigation/tabs/tabs.css +39 -0
  119. package/components/overlay/alert-dialog/AlertDialog.astro +112 -0
  120. package/components/overlay/alert-dialog/AlertDialog.vue +117 -0
  121. package/components/overlay/alert-dialog/alert-dialog.css +57 -0
  122. package/components/overlay/command/Command.vue +356 -0
  123. package/components/overlay/command/command.css +179 -0
  124. package/components/overlay/dropdown-menu/DropdownMenu.vue +143 -0
  125. package/components/overlay/dropdown-menu/dropdown-menu.css +120 -0
  126. package/components/overlay/modal/Modal.astro +66 -0
  127. package/components/overlay/modal/Modal.vue +85 -0
  128. package/components/overlay/modal/modal.css +60 -0
  129. package/components/overlay/popover/Popover.vue +113 -0
  130. package/components/overlay/popover/popover.css +53 -0
  131. package/components/overlay/sheet/Sheet.vue +88 -0
  132. package/components/overlay/sheet/sheet.css +108 -0
  133. package/components/overlay/tooltip/Tooltip.vue +210 -0
  134. package/components/overlay/tooltip/tooltip.css +50 -0
  135. package/composables/useUrlSort.ts +48 -0
  136. package/icons/alert-triangle.ts +1 -0
  137. package/icons/arrow-down.ts +1 -0
  138. package/icons/arrow-up-down.ts +5 -0
  139. package/icons/arrow-up.ts +1 -0
  140. package/icons/bell.ts +1 -0
  141. package/icons/check.ts +1 -0
  142. package/icons/chevron-down.ts +1 -0
  143. package/icons/chevron-left.ts +1 -0
  144. package/icons/chevron-right.ts +1 -0
  145. package/icons/chevron-up-down.ts +5 -0
  146. package/icons/chevron-up.ts +1 -0
  147. package/icons/circle-alert.ts +1 -0
  148. package/icons/circle-check.ts +1 -0
  149. package/icons/clipboard.ts +1 -0
  150. package/icons/download.ts +1 -0
  151. package/icons/edit.ts +1 -0
  152. package/icons/external-link.ts +1 -0
  153. package/icons/eye.ts +1 -0
  154. package/icons/file.ts +1 -0
  155. package/icons/filter.ts +1 -0
  156. package/icons/folder.ts +1 -0
  157. package/icons/image.ts +1 -0
  158. package/icons/inbox.ts +1 -0
  159. package/icons/index.ts +91 -0
  160. package/icons/info.ts +1 -0
  161. package/icons/layers.ts +1 -0
  162. package/icons/link-2.ts +1 -0
  163. package/icons/list.ts +1 -0
  164. package/icons/loader.ts +3 -0
  165. package/icons/menu.ts +1 -0
  166. package/icons/more-horizontal.ts +1 -0
  167. package/icons/more-vertical.ts +1 -0
  168. package/icons/plus-circle.ts +1 -0
  169. package/icons/plus.ts +1 -0
  170. package/icons/save.ts +1 -0
  171. package/icons/search.ts +1 -0
  172. package/icons/send.ts +1 -0
  173. package/icons/settings.ts +1 -0
  174. package/icons/tool.ts +1 -0
  175. package/icons/trash-2.ts +1 -0
  176. package/icons/trash.ts +1 -0
  177. package/icons/upload-cloud.ts +1 -0
  178. package/icons/upload.ts +1 -0
  179. package/icons/x.ts +1 -0
  180. package/package.json +150 -0
  181. package/styles/0-settings/colors.css +241 -0
  182. package/styles/0-settings/index.css +5 -0
  183. package/styles/0-settings/layout.css +52 -0
  184. package/styles/0-settings/motion.css +11 -0
  185. package/styles/0-settings/spacing.css +15 -0
  186. package/styles/0-settings/typography.css +37 -0
  187. package/styles/0-utils/index.css +1 -0
  188. package/styles/1-reset/index.css +1 -0
  189. package/styles/1-reset/reset.css +26 -0
  190. package/styles/2-base/base.css +42 -0
  191. package/styles/2-base/forms.css +23 -0
  192. package/styles/2-base/index.css +2 -0
  193. package/styles/3-layout/container.css +57 -0
  194. package/styles/3-layout/index.css +2 -0
  195. package/styles/3-layout/section.css +17 -0
  196. package/styles/5-utilities/accessibility.css +13 -0
  197. package/styles/5-utilities/index.css +2 -0
  198. package/styles/5-utilities/text.css +5 -0
  199. package/styles/main.css +8 -0
  200. package/styles/styles.d.ts +6 -0
  201. package/utils/a11y/focus.ts +68 -0
  202. package/utils/a11y/id.ts +10 -0
  203. package/utils/a11y/index.ts +9 -0
  204. package/utils/a11y/keyboard.ts +32 -0
  205. package/utils/a11y/live-region.ts +36 -0
  206. package/utils/controllers/dialog.ts +205 -0
  207. package/utils/controllers/disclosure.ts +117 -0
  208. package/utils/controllers/form.ts +524 -0
  209. package/utils/controllers/index.ts +39 -0
  210. package/utils/controllers/menu.ts +255 -0
  211. package/utils/controllers/number-field.ts +103 -0
  212. package/utils/controllers/otp.ts +252 -0
  213. package/utils/controllers/popover.ts +434 -0
  214. package/utils/controllers/sidebar.ts +610 -0
  215. package/utils/controllers/slider.ts +336 -0
  216. package/utils/controllers/tags-input.ts +255 -0
  217. package/utils/controllers/toast.ts +426 -0
  218. package/utils/dom/index.ts +1 -0
  219. package/utils/dom/scroll-lock.ts +48 -0
  220. package/utils/index.ts +3 -0
  221. package/utils/sort/index.ts +3 -0
  222. package/utils/sort/serialize.ts +19 -0
  223. package/utils/sort/state.ts +11 -0
  224. package/utils/sort/types.ts +15 -0
  225. package/utils/validation/form.ts +93 -0
  226. package/utils/validation/index.ts +13 -0
  227. package/utils/validation/rules.ts +31 -0
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Popover controller — shared by Popover, Tooltip, DropdownMenu,
3
+ * Combobox listbox, and HoverCard per the locked controller-sharing
4
+ * mapping in `CONVENTIONS.md`.
5
+ *
6
+ * The v1 popover family is built on top of the **native HTML Popover
7
+ * API** per the locked "Use native APIs where applicable" rule. The
8
+ * browser already handles:
9
+ *
10
+ * - Top-layer rendering (popover escapes any ancestor stacking
11
+ * context / `overflow: hidden`)
12
+ * - Light-dismiss in `popover="auto"` mode (click outside, focus
13
+ * outside, Escape all close the popover)
14
+ * - The `:popover-open` CSS pseudo-class for open-state styling
15
+ * - The `toggle` event (fires on every open/close transition with
16
+ * `event.newState === 'open' | 'closed'`)
17
+ * - Focus management (the popover is rendered in the top-layer; the
18
+ * browser does NOT auto-trap focus, but light-dismiss closes the
19
+ * popover when focus moves outside in `auto` mode)
20
+ *
21
+ * What the browser does NOT provide and we layer on top:
22
+ *
23
+ * - **Anchor positioning** — CSS anchor positioning (`anchor-name`
24
+ * + `position-anchor`) is the modern declarative approach but
25
+ * Firefox support is incomplete in 2026. We compute position in
26
+ * JS from the anchor's `getBoundingClientRect()` + popover
27
+ * dimensions, applied via inline `style.left` / `style.top`. This
28
+ * keeps the support floor at Chrome 114+ / Safari 17+ / Firefox
29
+ * 125+ (the Popover API floor itself).
30
+ * - **Placement-flip on viewport collision** — if `bottom-start`
31
+ * overflows the bottom edge, we flip to `top-start`. Cross-axis
32
+ * overflow is handled by shifting along that axis to keep the
33
+ * popover inside the viewport.
34
+ * - **Reposition-on-scroll/resize** — while the popover is open,
35
+ * `scroll` (capture) and `resize` listeners recompute the
36
+ * position so the popover tracks its anchor.
37
+ * - A **callback** so framework code can reflect open/close state
38
+ * back into reactive state (`update:open`).
39
+ * - **Anchor auto-detection** — if the consumer wires a trigger
40
+ * button declaratively via `popovertarget="<id>"`, the controller
41
+ * finds it via `document.querySelector('[popovertarget="<id>"]')`
42
+ * on mount. An explicit anchor element overrides the lookup.
43
+ *
44
+ * Mount on:
45
+ * - The popover element itself (the element that carries the
46
+ * `popover` attribute). The controller reads / sets the attribute
47
+ * based on `config.modal`.
48
+ */
49
+
50
+ /**
51
+ * Where the popover sits relative to its anchor.
52
+ *
53
+ * - The first segment (`top` / `right` / `bottom` / `left`) is the
54
+ * side of the anchor the popover attaches to.
55
+ * - The optional second segment (`start` / `end`) aligns the
56
+ * popover's leading or trailing edge with the anchor's
57
+ * corresponding edge along the cross-axis. Without a second
58
+ * segment, the popover is centered along the cross-axis.
59
+ *
60
+ * Placement flips automatically on viewport collision: a
61
+ * `bottom`-side placement that overflows flips to `top`, and
62
+ * vice-versa. Cross-axis overflow is shifted (the popover slides
63
+ * along the cross-axis to stay inside the viewport) rather than
64
+ * flipped.
65
+ */
66
+ export type Placement =
67
+ | 'top'
68
+ | 'top-start'
69
+ | 'top-end'
70
+ | 'right'
71
+ | 'right-start'
72
+ | 'right-end'
73
+ | 'bottom'
74
+ | 'bottom-start'
75
+ | 'bottom-end'
76
+ | 'left'
77
+ | 'left-start'
78
+ | 'left-end';
79
+
80
+ /**
81
+ * Imperative control surface returned by `mountPopover`. The
82
+ * controller owns the popover's runtime state (open/closed,
83
+ * positioning listeners) and lets the native Popover API handle
84
+ * top-layer rendering and light-dismiss. Frameworks call
85
+ * `show()` / `hide()` and watch incoming prop changes; they never
86
+ * own state directly.
87
+ */
88
+ export interface PopoverControl {
89
+ /** Open the popover and position it against its anchor. */
90
+ show(): void;
91
+ /** Close the popover and tear down positioning listeners. */
92
+ hide(): void;
93
+ /** Toggle between open and closed. */
94
+ toggle(): void;
95
+ /** Whether the popover is currently open (DOM-truth). */
96
+ isOpen(): boolean;
97
+ /** Recompute position. Called automatically on scroll / resize while open. */
98
+ reposition(): void;
99
+ /** Release listeners and reset attributes. */
100
+ teardown(): void;
101
+ }
102
+
103
+ export interface PopoverConfig {
104
+ /**
105
+ * The element that anchors positioning (typically the trigger
106
+ * button). When `null`, the controller looks for a trigger via
107
+ * `document.querySelector('[popovertarget="<el.id>"]')`. If
108
+ * neither is available the popover still opens but is positioned
109
+ * at the viewport origin (0,0) — caller is responsible for
110
+ * supplying an anchor before `show()` is meaningful.
111
+ */
112
+ anchor?: HTMLElement | null;
113
+ /**
114
+ * Initial placement. The controller flips to the opposite side
115
+ * automatically on viewport collision and shifts along the
116
+ * cross-axis to keep the popover inside the viewport.
117
+ * Default: `'bottom-start'`.
118
+ */
119
+ placement?: Placement;
120
+ /**
121
+ * Offset from the anchor edge in pixels. The popover is pushed
122
+ * away from the anchor by this many pixels along the placement
123
+ * axis. Default: `8`.
124
+ */
125
+ offset?: number;
126
+ /**
127
+ * When `true`, the popover uses `popover="manual"` — the user
128
+ * cannot dismiss via outside click / focus loss / Escape; the
129
+ * popover stays open until programmatically hidden via
130
+ * `hide()`. When `false` (default), uses `popover="auto"` which
131
+ * provides browser-native light-dismiss.
132
+ */
133
+ modal?: boolean;
134
+ /**
135
+ * Called after the browser fires the `toggle` event with
136
+ * `newState === 'open'`. The framework reflects this into
137
+ * reactive state (e.g. `emit('update:open', true)`).
138
+ */
139
+ onOpen?: () => void;
140
+ /**
141
+ * Called after the browser fires the `toggle` event with
142
+ * `newState === 'closed'` — including light-dismiss closures
143
+ * the controller did not initiate. The framework reflects this
144
+ * into reactive state (e.g. `emit('update:open', false)`).
145
+ */
146
+ onClose?: () => void;
147
+ }
148
+
149
+ /**
150
+ * The DOM CSSStyleDeclaration assignment shape we use for
151
+ * positioning. Inline styles win over the popover's own CSS for
152
+ * `left` / `top`, which is what we want — the CSS supplies the
153
+ * surface look, the controller supplies the position.
154
+ */
155
+ interface ComputedPosition {
156
+ left: number;
157
+ top: number;
158
+ /** The placement actually used after flip resolution. */
159
+ resolvedPlacement: Placement;
160
+ }
161
+
162
+ /**
163
+ * Pure helper — compute where the popover should be placed
164
+ * relative to the anchor, accounting for viewport collision via
165
+ * placement flip + cross-axis shift.
166
+ *
167
+ * Algorithm:
168
+ * 1. Compute the preferred (`left`, `top`) for the requested
169
+ * placement.
170
+ * 2. If the popover overflows along the placement axis, flip to
171
+ * the opposite side and recompute. (e.g., `bottom-start` →
172
+ * `top-start` if there is more room above than below.)
173
+ * 3. Clamp the cross-axis position so the popover stays inside the
174
+ * viewport (shift, not flip — flipping the cross-axis on a
175
+ * `top-end` popover would re-flow the layout in a way that
176
+ * breaks the user's mental model of the placement).
177
+ *
178
+ * Borrowed from Floating UI's basic algorithm without the dep.
179
+ */
180
+ function computePosition(
181
+ anchor: HTMLElement,
182
+ popoverEl: HTMLElement,
183
+ placement: Placement,
184
+ offset: number,
185
+ ): ComputedPosition {
186
+ const anchorRect = anchor.getBoundingClientRect();
187
+ // The popover may not yet be laid out when we first compute, so
188
+ // reading `offsetWidth` / `offsetHeight` is safe only after
189
+ // `showPopover()` has been called (the browser sizes the popover
190
+ // before firing `toggle`). For first-call safety we fall back to
191
+ // `getBoundingClientRect()`.
192
+ const popoverRect = popoverEl.getBoundingClientRect();
193
+ const popoverW = popoverRect.width || popoverEl.offsetWidth;
194
+ const popoverH = popoverRect.height || popoverEl.offsetHeight;
195
+
196
+ const viewportW = document.documentElement.clientWidth;
197
+ const viewportH = document.documentElement.clientHeight;
198
+
199
+ function compute(p: Placement): { left: number; top: number } {
200
+ const [side, align] = p.split('-') as [
201
+ 'top' | 'right' | 'bottom' | 'left',
202
+ 'start' | 'end' | undefined,
203
+ ];
204
+
205
+ let left = 0;
206
+ let top = 0;
207
+
208
+ if (side === 'top') {
209
+ top = anchorRect.top - popoverH - offset;
210
+ } else if (side === 'bottom') {
211
+ top = anchorRect.bottom + offset;
212
+ } else if (side === 'left') {
213
+ left = anchorRect.left - popoverW - offset;
214
+ } else {
215
+ // right
216
+ left = anchorRect.right + offset;
217
+ }
218
+
219
+ // Cross-axis: align start/end/center against anchor's
220
+ // perpendicular dimension.
221
+ if (side === 'top' || side === 'bottom') {
222
+ if (align === 'start') left = anchorRect.left;
223
+ else if (align === 'end') left = anchorRect.right - popoverW;
224
+ else left = anchorRect.left + (anchorRect.width - popoverW) / 2;
225
+ } else {
226
+ // left | right
227
+ if (align === 'start') top = anchorRect.top;
228
+ else if (align === 'end') top = anchorRect.bottom - popoverH;
229
+ else top = anchorRect.top + (anchorRect.height - popoverH) / 2;
230
+ }
231
+
232
+ return { left, top };
233
+ }
234
+
235
+ function oppositeSide(p: Placement): Placement {
236
+ if (p.startsWith('top')) return p.replace('top', 'bottom') as Placement;
237
+ if (p.startsWith('bottom'))
238
+ return p.replace('bottom', 'top') as Placement;
239
+ if (p.startsWith('left'))
240
+ return p.replace('left', 'right') as Placement;
241
+ return p.replace('right', 'left') as Placement;
242
+ }
243
+
244
+ function overflowsMainAxis(
245
+ pos: { left: number; top: number },
246
+ p: Placement,
247
+ ): boolean {
248
+ if (p.startsWith('top')) return pos.top < 0;
249
+ if (p.startsWith('bottom')) return pos.top + popoverH > viewportH;
250
+ if (p.startsWith('left')) return pos.left < 0;
251
+ return pos.left + popoverW > viewportW;
252
+ }
253
+
254
+ let resolvedPlacement = placement;
255
+ let pos = compute(resolvedPlacement);
256
+
257
+ // Flip if the preferred placement overflows along the main axis,
258
+ // BUT only when the opposite side has more room (avoid flipping
259
+ // into worse overflow).
260
+ if (overflowsMainAxis(pos, resolvedPlacement)) {
261
+ const flipped = oppositeSide(resolvedPlacement);
262
+ const flippedPos = compute(flipped);
263
+ if (!overflowsMainAxis(flippedPos, flipped)) {
264
+ resolvedPlacement = flipped;
265
+ pos = flippedPos;
266
+ }
267
+ }
268
+
269
+ // Cross-axis clamp (shift, not flip). Keep at least 4px from the
270
+ // viewport edge so the popover never visually clips against the
271
+ // edge.
272
+ const margin = 4;
273
+ pos.left = Math.max(
274
+ margin,
275
+ Math.min(pos.left, viewportW - popoverW - margin),
276
+ );
277
+ pos.top = Math.max(
278
+ margin,
279
+ Math.min(pos.top, viewportH - popoverH - margin),
280
+ );
281
+
282
+ return { left: pos.left, top: pos.top, resolvedPlacement };
283
+ }
284
+
285
+ /**
286
+ * Mount the popover controller on `el`, which is the popover
287
+ * element itself (the element carrying the `popover` attribute).
288
+ * The native Popover API provides top-layer rendering, light
289
+ * dismiss, Escape handling, and the `:popover-open` pseudo-class;
290
+ * this controller layers on anchor positioning, placement flip on
291
+ * viewport collision, scroll/resize tracking, and `data-state`
292
+ * reflection so the existing CSS conventions keep working.
293
+ *
294
+ * Lifecycle:
295
+ * 1. On mount: set the `popover` attribute (`'auto'` for light
296
+ * dismiss; `'manual'` for explicit-close-only). Wire the
297
+ * `toggle` event so the framework hears about open/close
298
+ * transitions. Resolve the anchor (explicit `config.anchor`,
299
+ * else `document.querySelector('[popovertarget="<el.id>"]')`).
300
+ * 2. `show()`: call `el.showPopover()` (native top-layer + light
301
+ * dismiss). Compute position via `computePosition` and apply
302
+ * inline `style.left` / `style.top`. Wire scroll (capture) and
303
+ * resize listeners that call `reposition()`.
304
+ * 3. `hide()`: call `el.hidePopover()` (native focus restoration
305
+ * if focus was inside the popover). Remove scroll/resize
306
+ * listeners.
307
+ * 4. `teardown()`: ensures hide ran, removes the `toggle`
308
+ * listener.
309
+ *
310
+ * The controller does NOT decide when to close — `onOpen` /
311
+ * `onClose` are invoked when the browser fires the native `toggle`
312
+ * event. The caller is responsible for reflecting that into
313
+ * framework state. The framework's reactive prop is the source of
314
+ * truth for the open/closed boolean; the controller is the source
315
+ * of truth for runtime side-effects (positioning, listeners).
316
+ */
317
+ export function mountPopover(
318
+ el: HTMLElement,
319
+ config: PopoverConfig = {},
320
+ ): PopoverControl {
321
+ const placement: Placement = config.placement ?? 'bottom-start';
322
+ const offset = config.offset ?? 8;
323
+ const modal = config.modal ?? false;
324
+
325
+ // `popover="auto"` opts into native light-dismiss: outside click,
326
+ // focus loss, and Escape all close the popover. `popover="manual"`
327
+ // means the popover stays until explicitly hidden via JS — used
328
+ // for non-dismissable surfaces (e.g., a modal-style popover that
329
+ // requires a button click to close).
330
+ el.setAttribute('popover', modal ? 'manual' : 'auto');
331
+ el.setAttribute('data-state', 'closed');
332
+
333
+ // Anchor resolution. Explicit `config.anchor` wins; otherwise
334
+ // look up the trigger via the native `popovertarget` attribute.
335
+ // Resolved lazily on each show() so the consumer can swap the
336
+ // trigger between mount and first open without re-mounting.
337
+ function resolveAnchor(): HTMLElement | null {
338
+ if (config.anchor) return config.anchor;
339
+ if (!el.id) return null;
340
+ return document.querySelector<HTMLElement>(
341
+ `[popovertarget="${CSS.escape(el.id)}"]`,
342
+ );
343
+ }
344
+
345
+ let scrollListenersWired = false;
346
+
347
+ function reposition(): void {
348
+ const anchor = resolveAnchor();
349
+ if (!anchor) return;
350
+ const { left, top, resolvedPlacement } = computePosition(
351
+ anchor,
352
+ el,
353
+ placement,
354
+ offset,
355
+ );
356
+ el.style.left = `${left}px`;
357
+ el.style.top = `${top}px`;
358
+ el.setAttribute('data-placement', resolvedPlacement);
359
+ }
360
+
361
+ function wireScrollListeners(): void {
362
+ if (scrollListenersWired) return;
363
+ // Capture phase so we catch scrolls inside any ancestor that
364
+ // might affect the anchor's viewport position. The listener is
365
+ // passive — we only read positions, never preventDefault.
366
+ window.addEventListener('scroll', reposition, {
367
+ capture: true,
368
+ passive: true,
369
+ });
370
+ window.addEventListener('resize', reposition, { passive: true });
371
+ scrollListenersWired = true;
372
+ }
373
+
374
+ function unwireScrollListeners(): void {
375
+ if (!scrollListenersWired) return;
376
+ window.removeEventListener('scroll', reposition, { capture: true });
377
+ window.removeEventListener('resize', reposition);
378
+ scrollListenersWired = false;
379
+ }
380
+
381
+ // Native `toggle` event fires on every open/close transition
382
+ // with `event.newState` set to `'open'` or `'closed'`. We use it
383
+ // as the single source of truth for `data-state` reflection and
384
+ // for invoking the framework callbacks — this catches both
385
+ // controller-initiated transitions (show/hide) and
386
+ // browser-initiated ones (light-dismiss, declarative
387
+ // `popovertarget` button click).
388
+ function handleToggle(event: Event): void {
389
+ const evt = event as ToggleEvent;
390
+ if (evt.newState === 'open') {
391
+ el.setAttribute('data-state', 'open');
392
+ // First reposition runs after the popover has been laid out
393
+ // by the browser (toggle fires after layout). Wire the
394
+ // scroll/resize listeners now so subsequent movement
395
+ // tracks.
396
+ reposition();
397
+ wireScrollListeners();
398
+ config.onOpen?.();
399
+ } else {
400
+ el.setAttribute('data-state', 'closed');
401
+ unwireScrollListeners();
402
+ config.onClose?.();
403
+ }
404
+ }
405
+
406
+ el.addEventListener('toggle', handleToggle);
407
+
408
+ function show(): void {
409
+ if (el.matches(':popover-open')) return; // idempotent
410
+ el.showPopover();
411
+ }
412
+
413
+ function hide(): void {
414
+ if (!el.matches(':popover-open')) return; // idempotent
415
+ el.hidePopover();
416
+ }
417
+
418
+ function toggle(): void {
419
+ if (el.matches(':popover-open')) hide();
420
+ else show();
421
+ }
422
+
423
+ function isOpen(): boolean {
424
+ return el.matches(':popover-open');
425
+ }
426
+
427
+ function teardown(): void {
428
+ if (isOpen()) hide();
429
+ unwireScrollListeners();
430
+ el.removeEventListener('toggle', handleToggle);
431
+ }
432
+
433
+ return { show, hide, toggle, isOpen, reposition, teardown };
434
+ }