@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.
- package/CHANGELOG.md +64 -0
- package/CONVENTIONS.md +1499 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/components/action/button/Button.astro +95 -0
- package/components/action/button/Button.vue +92 -0
- package/components/action/button/IconButton.astro +86 -0
- package/components/action/button/IconButton.vue +85 -0
- package/components/action/button/button.css +194 -0
- package/components/action/toggle/Toggle.vue +38 -0
- package/components/action/toggle/toggle.css +77 -0
- package/components/action/toggle-group/ToggleGroup.vue +67 -0
- package/components/action/toggle-group/toggle-group.css +78 -0
- package/components/display/avatar/Avatar.astro +17 -0
- package/components/display/avatar/Avatar.vue +30 -0
- package/components/display/avatar/AvatarStack.astro +9 -0
- package/components/display/avatar/AvatarStack.vue +11 -0
- package/components/display/avatar/avatar.css +58 -0
- package/components/display/badge/Badge.astro +15 -0
- package/components/display/badge/Badge.vue +23 -0
- package/components/display/badge/badge.css +56 -0
- package/components/display/empty/Empty.astro +9 -0
- package/components/display/empty/Empty.vue +11 -0
- package/components/display/empty/empty.css +39 -0
- package/components/display/icon/Icon.astro +52 -0
- package/components/display/icon/Icon.vue +57 -0
- package/components/display/icon/icon.css +47 -0
- package/components/feedback/alert/Alert.astro +52 -0
- package/components/feedback/alert/Alert.vue +60 -0
- package/components/feedback/alert/alert.css +78 -0
- package/components/feedback/progress/Progress.astro +68 -0
- package/components/feedback/progress/Progress.vue +82 -0
- package/components/feedback/progress/progress.css +68 -0
- package/components/feedback/skeleton/Skeleton.astro +32 -0
- package/components/feedback/skeleton/Skeleton.vue +39 -0
- package/components/feedback/skeleton/skeleton.css +56 -0
- package/components/feedback/spinner/Spinner.astro +25 -0
- package/components/feedback/spinner/Spinner.vue +36 -0
- package/components/feedback/spinner/spinner.css +91 -0
- package/components/feedback/toast/Toast.astro +50 -0
- package/components/feedback/toast/Toast.vue +74 -0
- package/components/feedback/toast/toast.css +128 -0
- package/components/form/checkbox/Checkbox.astro +79 -0
- package/components/form/checkbox/Checkbox.vue +95 -0
- package/components/form/checkbox/checkbox.css +59 -0
- package/components/form/combobox/Combobox.vue +508 -0
- package/components/form/combobox/combobox.css +110 -0
- package/components/form/date-input/DateInput.astro +105 -0
- package/components/form/date-input/DateInput.vue +121 -0
- package/components/form/date-input/date-input.css +19 -0
- package/components/form/form/Form.astro +106 -0
- package/components/form/form/Form.vue +181 -0
- package/components/form/form/form.css +46 -0
- package/components/form/input-otp/InputOTP.astro +147 -0
- package/components/form/input-otp/InputOTP.vue +209 -0
- package/components/form/input-otp/input-otp.css +52 -0
- package/components/form/label/Label.astro +13 -0
- package/components/form/label/Label.vue +20 -0
- package/components/form/label/label.css +11 -0
- package/components/form/number-field/NumberField.astro +142 -0
- package/components/form/number-field/NumberField.vue +155 -0
- package/components/form/number-field/number-field.css +115 -0
- package/components/form/radio-group/RadioGroup.astro +105 -0
- package/components/form/radio-group/RadioGroup.vue +110 -0
- package/components/form/radio-group/radio-group.css +114 -0
- package/components/form/radio-group/types.ts +14 -0
- package/components/form/select/Segmented.vue +36 -0
- package/components/form/select/Select.astro +105 -0
- package/components/form/select/Select.vue +109 -0
- package/components/form/select/select.css +96 -0
- package/components/form/slider/Slider.astro +205 -0
- package/components/form/slider/Slider.vue +321 -0
- package/components/form/slider/slider.css +115 -0
- package/components/form/switch/Switch.astro +75 -0
- package/components/form/switch/Switch.vue +89 -0
- package/components/form/switch/switch.css +64 -0
- package/components/form/tags-input/TagsInput.astro +153 -0
- package/components/form/tags-input/TagsInput.vue +207 -0
- package/components/form/tags-input/tags-input.css +128 -0
- package/components/form/text-input/TextInput.astro +84 -0
- package/components/form/text-input/TextInput.vue +99 -0
- package/components/form/text-input/text-input.css +165 -0
- package/components/form/textarea/Textarea.astro +86 -0
- package/components/form/textarea/Textarea.vue +102 -0
- package/components/form/textarea/textarea.css +25 -0
- package/components/layout/accordion/Accordion.vue +59 -0
- package/components/layout/accordion/accordion.css +87 -0
- package/components/layout/card/Card.astro +13 -0
- package/components/layout/card/Card.vue +20 -0
- package/components/layout/card/card.css +55 -0
- package/components/layout/collapsible/Collapsible.vue +77 -0
- package/components/layout/collapsible/collapsible.css +76 -0
- package/components/layout/separator/Separator.astro +31 -0
- package/components/layout/separator/Separator.vue +33 -0
- package/components/layout/separator/separator.css +27 -0
- package/components/layout/table/DataTable.vue +127 -0
- package/components/layout/table/Table.astro +116 -0
- package/components/layout/table/Table.vue +146 -0
- package/components/layout/table/TableRow.vue +59 -0
- package/components/layout/table/table.css +201 -0
- package/components/layout/table/types.ts +35 -0
- package/components/layout/table/useTable.ts +7 -0
- package/components/navigation/breadcrumb/Breadcrumb.astro +36 -0
- package/components/navigation/breadcrumb/Breadcrumb.vue +36 -0
- package/components/navigation/breadcrumb/breadcrumb.css +37 -0
- package/components/navigation/navbar/Navbar.astro +62 -0
- package/components/navigation/navbar/Navbar.vue +50 -0
- package/components/navigation/navbar/navbar.css +77 -0
- package/components/navigation/pagination/Pagination.vue +107 -0
- package/components/navigation/pagination/pagination.css +53 -0
- package/components/navigation/sidebar/Sidebar.astro +132 -0
- package/components/navigation/sidebar/Sidebar.vue +174 -0
- package/components/navigation/sidebar/SidebarItemRender.astro +83 -0
- package/components/navigation/sidebar/SidebarItemRender.vue +98 -0
- package/components/navigation/sidebar/sidebar.css +303 -0
- package/components/navigation/sidebar/types.ts +72 -0
- package/components/navigation/tabs/Tabs.vue +84 -0
- package/components/navigation/tabs/tabs.css +39 -0
- package/components/overlay/alert-dialog/AlertDialog.astro +112 -0
- package/components/overlay/alert-dialog/AlertDialog.vue +117 -0
- package/components/overlay/alert-dialog/alert-dialog.css +57 -0
- package/components/overlay/command/Command.vue +356 -0
- package/components/overlay/command/command.css +179 -0
- package/components/overlay/dropdown-menu/DropdownMenu.vue +143 -0
- package/components/overlay/dropdown-menu/dropdown-menu.css +120 -0
- package/components/overlay/modal/Modal.astro +66 -0
- package/components/overlay/modal/Modal.vue +85 -0
- package/components/overlay/modal/modal.css +60 -0
- package/components/overlay/popover/Popover.vue +113 -0
- package/components/overlay/popover/popover.css +53 -0
- package/components/overlay/sheet/Sheet.vue +88 -0
- package/components/overlay/sheet/sheet.css +108 -0
- package/components/overlay/tooltip/Tooltip.vue +210 -0
- package/components/overlay/tooltip/tooltip.css +50 -0
- package/composables/useUrlSort.ts +48 -0
- package/icons/alert-triangle.ts +1 -0
- package/icons/arrow-down.ts +1 -0
- package/icons/arrow-up-down.ts +5 -0
- package/icons/arrow-up.ts +1 -0
- package/icons/bell.ts +1 -0
- package/icons/check.ts +1 -0
- package/icons/chevron-down.ts +1 -0
- package/icons/chevron-left.ts +1 -0
- package/icons/chevron-right.ts +1 -0
- package/icons/chevron-up-down.ts +5 -0
- package/icons/chevron-up.ts +1 -0
- package/icons/circle-alert.ts +1 -0
- package/icons/circle-check.ts +1 -0
- package/icons/clipboard.ts +1 -0
- package/icons/download.ts +1 -0
- package/icons/edit.ts +1 -0
- package/icons/external-link.ts +1 -0
- package/icons/eye.ts +1 -0
- package/icons/file.ts +1 -0
- package/icons/filter.ts +1 -0
- package/icons/folder.ts +1 -0
- package/icons/image.ts +1 -0
- package/icons/inbox.ts +1 -0
- package/icons/index.ts +91 -0
- package/icons/info.ts +1 -0
- package/icons/layers.ts +1 -0
- package/icons/link-2.ts +1 -0
- package/icons/list.ts +1 -0
- package/icons/loader.ts +3 -0
- package/icons/menu.ts +1 -0
- package/icons/more-horizontal.ts +1 -0
- package/icons/more-vertical.ts +1 -0
- package/icons/plus-circle.ts +1 -0
- package/icons/plus.ts +1 -0
- package/icons/save.ts +1 -0
- package/icons/search.ts +1 -0
- package/icons/send.ts +1 -0
- package/icons/settings.ts +1 -0
- package/icons/tool.ts +1 -0
- package/icons/trash-2.ts +1 -0
- package/icons/trash.ts +1 -0
- package/icons/upload-cloud.ts +1 -0
- package/icons/upload.ts +1 -0
- package/icons/x.ts +1 -0
- package/package.json +150 -0
- package/styles/0-settings/colors.css +241 -0
- package/styles/0-settings/index.css +5 -0
- package/styles/0-settings/layout.css +52 -0
- package/styles/0-settings/motion.css +11 -0
- package/styles/0-settings/spacing.css +15 -0
- package/styles/0-settings/typography.css +37 -0
- package/styles/0-utils/index.css +1 -0
- package/styles/1-reset/index.css +1 -0
- package/styles/1-reset/reset.css +26 -0
- package/styles/2-base/base.css +42 -0
- package/styles/2-base/forms.css +23 -0
- package/styles/2-base/index.css +2 -0
- package/styles/3-layout/container.css +57 -0
- package/styles/3-layout/index.css +2 -0
- package/styles/3-layout/section.css +17 -0
- package/styles/5-utilities/accessibility.css +13 -0
- package/styles/5-utilities/index.css +2 -0
- package/styles/5-utilities/text.css +5 -0
- package/styles/main.css +8 -0
- package/styles/styles.d.ts +6 -0
- package/utils/a11y/focus.ts +68 -0
- package/utils/a11y/id.ts +10 -0
- package/utils/a11y/index.ts +9 -0
- package/utils/a11y/keyboard.ts +32 -0
- package/utils/a11y/live-region.ts +36 -0
- package/utils/controllers/dialog.ts +205 -0
- package/utils/controllers/disclosure.ts +117 -0
- package/utils/controllers/form.ts +524 -0
- package/utils/controllers/index.ts +39 -0
- package/utils/controllers/menu.ts +255 -0
- package/utils/controllers/number-field.ts +103 -0
- package/utils/controllers/otp.ts +252 -0
- package/utils/controllers/popover.ts +434 -0
- package/utils/controllers/sidebar.ts +610 -0
- package/utils/controllers/slider.ts +336 -0
- package/utils/controllers/tags-input.ts +255 -0
- package/utils/controllers/toast.ts +426 -0
- package/utils/dom/index.ts +1 -0
- package/utils/dom/scroll-lock.ts +48 -0
- package/utils/index.ts +3 -0
- package/utils/sort/index.ts +3 -0
- package/utils/sort/serialize.ts +19 -0
- package/utils/sort/state.ts +11 -0
- package/utils/sort/types.ts +15 -0
- package/utils/validation/form.ts +93 -0
- package/utils/validation/index.ts +13 -0
- 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
|
+
}
|