@popover-kit/svelte 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.
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/components/Popover.svelte +285 -0
- package/dist/components/PopoverContent.svelte +75 -0
- package/dist/components/PopoverTrigger.svelte +43 -0
- package/dist/constants/config.js +1 -0
- package/dist/controller.svelte.js +286 -0
- package/dist/elements/PopoverArrow.svelte +95 -0
- package/dist/elements/PopoverPanel.svelte +188 -0
- package/dist/index.d.ts +313 -0
- package/dist/index.js +1 -0
- package/dist/utils/a11y.js +1 -0
- package/dist/utils/position.js +1 -0
- package/package.json +69 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Popover Micro-Plugin — PopoverController
|
|
3
|
+
//
|
|
4
|
+
// File MUST be *.svelte.ts — Svelte 5 compiles $state runes only in .svelte.ts.
|
|
5
|
+
//
|
|
6
|
+
// Open sequence (critical — previous versions got this wrong):
|
|
7
|
+
//
|
|
8
|
+
// 1. open() sets state → 'opening' (isVisible = true) and returns.
|
|
9
|
+
// 2. Svelte sees isVisible changed, mounts the panel element in the DOM.
|
|
10
|
+
// 3. Svelte runs $effects. The $effect in Popover.svelte sees panelEl is
|
|
11
|
+
// now set and calls ctrl.attachPanel(el).
|
|
12
|
+
// 4. attachPanel() detects state === 'opening' → calls #finishOpen().
|
|
13
|
+
// 5. #finishOpen() reads panel dimensions (element is in DOM), computes
|
|
14
|
+
// position, sets up listeners, sets state → 'open'.
|
|
15
|
+
//
|
|
16
|
+
// This ordering guarantees:
|
|
17
|
+
// • #panelEl is always non-null when #setupListeners() runs.
|
|
18
|
+
// • Click-outside uses the real panel element (not null).
|
|
19
|
+
// • No rAF/setTimeout hacks needed.
|
|
20
|
+
// =============================================================================
|
|
21
|
+
import { computePosition } from "./utils/position.js";
|
|
22
|
+
import { createFocusTrap, onClickOutside, announce } from "./utils/a11y.js";
|
|
23
|
+
import { uid, DEFAULT_OPTIONS, ANIMATION_DURATION_MS } from "./constants/config.js";
|
|
24
|
+
export class PopoverController {
|
|
25
|
+
// ─── Stable IDs ───────────────────────────────────────────────────────────────
|
|
26
|
+
triggerId;
|
|
27
|
+
contentId;
|
|
28
|
+
// ─── Reactive state ($state runes — tracked by Svelte templates) ──────────────
|
|
29
|
+
#isOpen = $state(false);
|
|
30
|
+
#state = $state("closed");
|
|
31
|
+
#resolvedPlacement = $state("bottom");
|
|
32
|
+
#pos = $state(null);
|
|
33
|
+
// ─── DOM refs ─────────────────────────────────────────────────────────────────
|
|
34
|
+
#triggerEl = null;
|
|
35
|
+
#panelEl = null;
|
|
36
|
+
// ─── Options (set at construction, immutable after) ───────────────────────────
|
|
37
|
+
#placement; // mutable via setPlacement()
|
|
38
|
+
#offset;
|
|
39
|
+
#trapFocus;
|
|
40
|
+
#closeOnOutsideClick;
|
|
41
|
+
#closeOnEsc;
|
|
42
|
+
#onOpen;
|
|
43
|
+
#onClose;
|
|
44
|
+
#onStateChange;
|
|
45
|
+
// ─── Lazy resources (null while closed — zero allocation at rest) ──────────────
|
|
46
|
+
#focusTrap = null;
|
|
47
|
+
#clickOutsideStop = null;
|
|
48
|
+
#scrollResizeStop = null;
|
|
49
|
+
#escStop = null;
|
|
50
|
+
#resizeObs = null;
|
|
51
|
+
// ─── Headless subscribers ─────────────────────────────────────────────────────
|
|
52
|
+
#subscribers = new Set();
|
|
53
|
+
// ─── Timers ───────────────────────────────────────────────────────────────────
|
|
54
|
+
#focusTrapTimer = null;
|
|
55
|
+
#closeTimer = null;
|
|
56
|
+
// ─── rAF handle — coalesces scroll/resize into one recompute per frame ─────────
|
|
57
|
+
#rafId = null;
|
|
58
|
+
// =============================================================================
|
|
59
|
+
constructor(opts = {}) {
|
|
60
|
+
const id = opts.id ?? uid();
|
|
61
|
+
this.triggerId = `${id}-trigger`;
|
|
62
|
+
this.contentId = `${id}-content`;
|
|
63
|
+
this.#placement = opts.placement ?? DEFAULT_OPTIONS.placement;
|
|
64
|
+
this.#offset = opts.offset ?? DEFAULT_OPTIONS.offset;
|
|
65
|
+
this.#trapFocus = opts.trapFocus ?? DEFAULT_OPTIONS.trapFocus;
|
|
66
|
+
this.#closeOnOutsideClick = opts.closeOnOutsideClick ?? DEFAULT_OPTIONS.closeOnOutsideClick;
|
|
67
|
+
this.#closeOnEsc = opts.closeOnEsc ?? DEFAULT_OPTIONS.closeOnEsc;
|
|
68
|
+
this.#onOpen = opts.onOpen;
|
|
69
|
+
this.#onClose = opts.onClose;
|
|
70
|
+
this.#onStateChange = opts.onStateChange;
|
|
71
|
+
this.#resolvedPlacement = this.#placement;
|
|
72
|
+
}
|
|
73
|
+
// ─── Reactive getters ─────────────────────────────────────────────────────────
|
|
74
|
+
get isOpen() {
|
|
75
|
+
return this.#isOpen;
|
|
76
|
+
}
|
|
77
|
+
get state() {
|
|
78
|
+
return this.#state;
|
|
79
|
+
}
|
|
80
|
+
get resolvedPlacement() {
|
|
81
|
+
return this.#resolvedPlacement;
|
|
82
|
+
}
|
|
83
|
+
get position() {
|
|
84
|
+
return this.#pos;
|
|
85
|
+
}
|
|
86
|
+
get isVisible() {
|
|
87
|
+
return this.#state !== "closed";
|
|
88
|
+
}
|
|
89
|
+
// ─── Snapshot ─────────────────────────────────────────────────────────────────
|
|
90
|
+
getSnapshot() {
|
|
91
|
+
return {
|
|
92
|
+
isOpen: this.#isOpen,
|
|
93
|
+
state: this.#state,
|
|
94
|
+
resolvedPlacement: this.#resolvedPlacement,
|
|
95
|
+
triggerId: this.triggerId,
|
|
96
|
+
contentId: this.contentId
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// ─── Subscriptions ────────────────────────────────────────────────────────────
|
|
100
|
+
subscribe(cb) {
|
|
101
|
+
this.#subscribers.add(cb);
|
|
102
|
+
cb(this.getSnapshot());
|
|
103
|
+
return () => this.#subscribers.delete(cb);
|
|
104
|
+
}
|
|
105
|
+
#emit() {
|
|
106
|
+
if (!this.#subscribers.size)
|
|
107
|
+
return;
|
|
108
|
+
const snap = this.getSnapshot();
|
|
109
|
+
this.#subscribers.forEach((cb) => cb(snap));
|
|
110
|
+
}
|
|
111
|
+
// ─── Element wiring ───────────────────────────────────────────────────────────
|
|
112
|
+
attachTrigger(el) {
|
|
113
|
+
this.#triggerEl = el;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Called by Popover.svelte's $effect after the panel element mounts.
|
|
117
|
+
* If state is 'opening', we're in the open sequence → finish it now.
|
|
118
|
+
* If state is already 'open', controller was re-attached → just recompute.
|
|
119
|
+
*/
|
|
120
|
+
attachPanel(el) {
|
|
121
|
+
this.#panelEl = el;
|
|
122
|
+
if (this.#state === "opening") {
|
|
123
|
+
this.#finishOpen();
|
|
124
|
+
}
|
|
125
|
+
else if (this.isVisible) {
|
|
126
|
+
this.#compute();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
detachTrigger() {
|
|
130
|
+
this.#triggerEl = null;
|
|
131
|
+
}
|
|
132
|
+
detachPanel() {
|
|
133
|
+
this.#panelEl = null;
|
|
134
|
+
}
|
|
135
|
+
// ─── Core actions ─────────────────────────────────────────────────────────────
|
|
136
|
+
open() {
|
|
137
|
+
if (this.#isOpen || this.#state === "opening")
|
|
138
|
+
return;
|
|
139
|
+
this.#cancelClose();
|
|
140
|
+
// Set 'opening' FIRST → isVisible becomes true → Svelte mounts the panel
|
|
141
|
+
// → $effect fires → attachPanel() → #finishOpen(). Nothing else happens here.
|
|
142
|
+
this.#setState(false, "opening");
|
|
143
|
+
}
|
|
144
|
+
close() {
|
|
145
|
+
if (!this.#isOpen || this.#state === "closing")
|
|
146
|
+
return;
|
|
147
|
+
this.#cancelFocusTrapTimer();
|
|
148
|
+
this.#setState(false, "closing");
|
|
149
|
+
this.#teardownListeners();
|
|
150
|
+
this.#closeTimer = setTimeout(() => {
|
|
151
|
+
if (this.#state === "closing") {
|
|
152
|
+
this.#setState(false, "closed");
|
|
153
|
+
this.#pos = null;
|
|
154
|
+
announce("Popover closed");
|
|
155
|
+
this.#onClose?.();
|
|
156
|
+
}
|
|
157
|
+
}, ANIMATION_DURATION_MS);
|
|
158
|
+
}
|
|
159
|
+
toggle() {
|
|
160
|
+
this.#isOpen ? this.close() : this.open();
|
|
161
|
+
}
|
|
162
|
+
setPlacement(p) {
|
|
163
|
+
this.#placement = p;
|
|
164
|
+
if (this.isVisible)
|
|
165
|
+
this.#compute();
|
|
166
|
+
}
|
|
167
|
+
// ─── Destroy ──────────────────────────────────────────────────────────────────
|
|
168
|
+
destroy() {
|
|
169
|
+
this.#cancelFocusTrapTimer();
|
|
170
|
+
this.#cancelClose();
|
|
171
|
+
this.#cancelRaf();
|
|
172
|
+
this.#teardownListeners();
|
|
173
|
+
this.#subscribers.clear();
|
|
174
|
+
this.#triggerEl = null;
|
|
175
|
+
this.#panelEl = null;
|
|
176
|
+
}
|
|
177
|
+
// ─── Private: open sequence ───────────────────────────────────────────────────
|
|
178
|
+
/**
|
|
179
|
+
* Called from attachPanel() when state === 'opening'.
|
|
180
|
+
* At this point the panel element is guaranteed to be in the DOM.
|
|
181
|
+
*/
|
|
182
|
+
#finishOpen() {
|
|
183
|
+
this.#compute(); // position panel (reads real dimensions)
|
|
184
|
+
this.#setupListeners(); // wire click-outside, Esc, scroll, resize
|
|
185
|
+
this.#setState(true, "open");
|
|
186
|
+
announce("Popover opened");
|
|
187
|
+
this.#onOpen?.();
|
|
188
|
+
}
|
|
189
|
+
// ─── Private: positioning ─────────────────────────────────────────────────────
|
|
190
|
+
#compute() {
|
|
191
|
+
if (!this.#triggerEl || !this.#panelEl)
|
|
192
|
+
return;
|
|
193
|
+
const result = computePosition(this.#triggerEl, this.#panelEl, this.#placement, this.#offset);
|
|
194
|
+
this.#pos = result;
|
|
195
|
+
this.#resolvedPlacement = result.placement;
|
|
196
|
+
}
|
|
197
|
+
#scheduleCompute() {
|
|
198
|
+
if (this.#rafId !== null)
|
|
199
|
+
return;
|
|
200
|
+
this.#rafId = requestAnimationFrame(() => {
|
|
201
|
+
this.#rafId = null;
|
|
202
|
+
if (this.isVisible)
|
|
203
|
+
this.#compute();
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
#cancelRaf() {
|
|
207
|
+
if (this.#rafId !== null) {
|
|
208
|
+
cancelAnimationFrame(this.#rafId);
|
|
209
|
+
this.#rafId = null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// ─── Private: listeners ───────────────────────────────────────────────────────
|
|
213
|
+
#setupListeners() {
|
|
214
|
+
const trigger = this.#triggerEl;
|
|
215
|
+
const panel = this.#panelEl;
|
|
216
|
+
// Click outside — uses pointerdown capture phase.
|
|
217
|
+
// Does NOT call preventDefault/stopPropagation → background elements stay
|
|
218
|
+
// fully interactive. The popover simply closes on the next tick.
|
|
219
|
+
if (this.#closeOnOutsideClick && trigger && panel) {
|
|
220
|
+
this.#clickOutsideStop = onClickOutside([trigger, panel], () => this.close());
|
|
221
|
+
}
|
|
222
|
+
// Esc key
|
|
223
|
+
if (this.#closeOnEsc) {
|
|
224
|
+
const handler = (e) => {
|
|
225
|
+
if (e.key === "Escape") {
|
|
226
|
+
e.stopPropagation();
|
|
227
|
+
this.close();
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
document.addEventListener("keydown", handler, true);
|
|
231
|
+
this.#escStop = () => document.removeEventListener("keydown", handler, true);
|
|
232
|
+
}
|
|
233
|
+
// Focus trap — delayed so it activates after enter animation completes
|
|
234
|
+
if (this.#trapFocus && panel) {
|
|
235
|
+
this.#focusTrap = createFocusTrap(panel, trigger ?? undefined);
|
|
236
|
+
this.#focusTrapTimer = setTimeout(() => this.#focusTrap?.activate(), ANIMATION_DURATION_MS);
|
|
237
|
+
}
|
|
238
|
+
// Scroll + resize → rAF-coalesced recompute (max one per frame)
|
|
239
|
+
const recompute = () => this.#scheduleCompute();
|
|
240
|
+
window.addEventListener("scroll", recompute, { passive: true, capture: true });
|
|
241
|
+
window.addEventListener("resize", recompute, { passive: true });
|
|
242
|
+
this.#scrollResizeStop = () => {
|
|
243
|
+
window.removeEventListener("scroll", recompute, { capture: true });
|
|
244
|
+
window.removeEventListener("resize", recompute);
|
|
245
|
+
};
|
|
246
|
+
// ResizeObserver: trigger or panel dimensions changing also needs a recompute
|
|
247
|
+
if (trigger) {
|
|
248
|
+
this.#resizeObs = new ResizeObserver(recompute);
|
|
249
|
+
this.#resizeObs.observe(trigger);
|
|
250
|
+
if (panel)
|
|
251
|
+
this.#resizeObs.observe(panel);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
#teardownListeners() {
|
|
255
|
+
this.#focusTrap?.deactivate();
|
|
256
|
+
this.#clickOutsideStop?.();
|
|
257
|
+
this.#scrollResizeStop?.();
|
|
258
|
+
this.#escStop?.();
|
|
259
|
+
this.#resizeObs?.disconnect();
|
|
260
|
+
this.#cancelRaf();
|
|
261
|
+
this.#focusTrap = null;
|
|
262
|
+
this.#clickOutsideStop = null;
|
|
263
|
+
this.#scrollResizeStop = null;
|
|
264
|
+
this.#escStop = null;
|
|
265
|
+
this.#resizeObs = null;
|
|
266
|
+
}
|
|
267
|
+
// ─── Private: state ───────────────────────────────────────────────────────────
|
|
268
|
+
#setState(isOpen, state) {
|
|
269
|
+
this.#isOpen = isOpen;
|
|
270
|
+
this.#state = state;
|
|
271
|
+
this.#onStateChange?.(state);
|
|
272
|
+
this.#emit();
|
|
273
|
+
}
|
|
274
|
+
#cancelFocusTrapTimer() {
|
|
275
|
+
if (this.#focusTrapTimer !== null) {
|
|
276
|
+
clearTimeout(this.#focusTrapTimer);
|
|
277
|
+
this.#focusTrapTimer = null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
#cancelClose() {
|
|
281
|
+
if (this.#closeTimer !== null) {
|
|
282
|
+
clearTimeout(this.#closeTimer);
|
|
283
|
+
this.#closeTimer = null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* PopoverArrow — decorative caret pointing from the panel toward the trigger.
|
|
4
|
+
*
|
|
5
|
+
* Uses a single rotated square clipped to a triangle via CSS clip-path.
|
|
6
|
+
* The square is large enough (12×12px) that the rotated tip clearly protrudes
|
|
7
|
+
* beyond the panel edge when the wrapper is placed at the correct offset.
|
|
8
|
+
*
|
|
9
|
+
* Panel side Panel edge Arrow points
|
|
10
|
+
* ────────── ────────── ────────────
|
|
11
|
+
* bottom top up ▲
|
|
12
|
+
* top bottom down ▼
|
|
13
|
+
* right left left ◄
|
|
14
|
+
* left right right ►
|
|
15
|
+
*
|
|
16
|
+
* The wrapper is 12×12px positioned so its center sits on the panel edge.
|
|
17
|
+
* clip-path cuts the square into the half that protrudes outward.
|
|
18
|
+
* A border on two sides of the square, combined with the clip, produces a
|
|
19
|
+
* bordered triangle that matches the card's border colour.
|
|
20
|
+
*/
|
|
21
|
+
import type { Placement } from '../types/types.js';
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
placement: Placement;
|
|
25
|
+
class?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { placement, class: cls = '' }: Props = $props();
|
|
29
|
+
|
|
30
|
+
const side = $derived(
|
|
31
|
+
placement === 'autoVertical'
|
|
32
|
+
? 'bottom'
|
|
33
|
+
: (placement.split('-')[0] as 'top' | 'bottom' | 'left' | 'right')
|
|
34
|
+
);
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<span class="pop-arrow pop-arrow--{side} {cls}" aria-hidden="true"></span>
|
|
38
|
+
|
|
39
|
+
<style>
|
|
40
|
+
/*
|
|
41
|
+
* Single element arrow — no pseudo-element needed.
|
|
42
|
+
* The element itself is rotated 45° to create a diamond, then clip-path
|
|
43
|
+
* cuts it to a triangle. The border is applied before rotation so the
|
|
44
|
+
* visible edges match the panel's card border.
|
|
45
|
+
*
|
|
46
|
+
* Size: 12px × 12px. Rotated 45° → diagonal = 17px.
|
|
47
|
+
* The half that protrudes = ~8.5px, which is enough to be visible.
|
|
48
|
+
*/
|
|
49
|
+
.pop-arrow {
|
|
50
|
+
position: absolute;
|
|
51
|
+
display: block;
|
|
52
|
+
width: 12px;
|
|
53
|
+
height: 12px;
|
|
54
|
+
background: oklch(var(--color-base-100));
|
|
55
|
+
border: 1px solid oklch(var(--color-base-300));
|
|
56
|
+
pointer-events: none;
|
|
57
|
+
z-index: 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* ── Panel below trigger → arrow at top of panel, pointing up ──────────── */
|
|
61
|
+
.pop-arrow--bottom {
|
|
62
|
+
top: -7px; /* (12px / 2) + 1px for border overlap */
|
|
63
|
+
left: 1.25rem;
|
|
64
|
+
transform: rotate(45deg);
|
|
65
|
+
/* Keep only the top-left triangle (the outward-pointing half) */
|
|
66
|
+
clip-path: polygon(0% 0%, 100% 0%, 0% 100%);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ── Panel above trigger → arrow at bottom of panel, pointing down ─────── */
|
|
70
|
+
.pop-arrow--top {
|
|
71
|
+
bottom: -7px;
|
|
72
|
+
left: 1.25rem;
|
|
73
|
+
transform: rotate(45deg);
|
|
74
|
+
/* Keep only the bottom-right triangle */
|
|
75
|
+
clip-path: polygon(100% 0%, 100% 100%, 0% 100%);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ── Panel to the right of trigger → arrow at left of panel, pointing left */
|
|
79
|
+
.pop-arrow--right {
|
|
80
|
+
left: -7px;
|
|
81
|
+
top: 1.25rem;
|
|
82
|
+
transform: rotate(45deg);
|
|
83
|
+
/* Keep only the left triangle */
|
|
84
|
+
clip-path: polygon(0% 0%, 0% 100%, 100% 100%);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ── Panel to the left of trigger → arrow at right of panel, pointing right */
|
|
88
|
+
.pop-arrow--left {
|
|
89
|
+
right: -7px;
|
|
90
|
+
top: 1.25rem;
|
|
91
|
+
transform: rotate(45deg);
|
|
92
|
+
/* Keep only the right triangle */
|
|
93
|
+
clip-path: polygon(0% 0%, 100% 0%, 100% 100%);
|
|
94
|
+
}
|
|
95
|
+
</style>
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* PopoverPanel — atomic floating panel element.
|
|
4
|
+
*
|
|
5
|
+
* Uses `position:fixed` with coordinates from the controller.
|
|
6
|
+
* Parked off-screen when coordinates are not yet computed (position=null)
|
|
7
|
+
* so the controller can measure its dimensions via getBoundingClientRect().
|
|
8
|
+
*
|
|
9
|
+
* Animation direction CSS classes (pop-side-*) are derived from resolvedPlacement
|
|
10
|
+
* so the scale/slide transform-origin and direction are always correct.
|
|
11
|
+
*
|
|
12
|
+
* The `el` prop is `$bindable` — Popover.svelte passes panelEl through it
|
|
13
|
+
* so the controller's $effect can call attachPanel() once the element mounts.
|
|
14
|
+
*
|
|
15
|
+
* daisyUI `card` provides the surface (bg, border, radius, shadow).
|
|
16
|
+
* Pure CSS handles animations — no Svelte transitions needed.
|
|
17
|
+
*/
|
|
18
|
+
import type { Snippet } from 'svelte';
|
|
19
|
+
import type { Placement, ResolvedPosition, PopoverAnimation } from '../types/types.js';
|
|
20
|
+
import PopoverArrow from './PopoverArrow.svelte';
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
id: string;
|
|
24
|
+
triggerId: string;
|
|
25
|
+
isOpen: boolean;
|
|
26
|
+
position: ResolvedPosition | null;
|
|
27
|
+
resolvedPlacement: Placement;
|
|
28
|
+
showArrow: boolean;
|
|
29
|
+
animation: PopoverAnimation;
|
|
30
|
+
role?: 'dialog' | 'listbox' | 'tooltip' | 'menu' | 'tree' | 'grid';
|
|
31
|
+
class?: string;
|
|
32
|
+
'aria-label'?: string;
|
|
33
|
+
/** $bindable — Popover.svelte reads this for the controller's attachPanel(). */
|
|
34
|
+
el?: HTMLElement | null;
|
|
35
|
+
children?: Snippet;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let {
|
|
39
|
+
id,
|
|
40
|
+
triggerId,
|
|
41
|
+
isOpen,
|
|
42
|
+
position,
|
|
43
|
+
resolvedPlacement,
|
|
44
|
+
showArrow,
|
|
45
|
+
animation,
|
|
46
|
+
role = 'dialog',
|
|
47
|
+
class: cls = '',
|
|
48
|
+
'aria-label': ariaLabel,
|
|
49
|
+
el = $bindable(null),
|
|
50
|
+
children
|
|
51
|
+
}: Props = $props();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* position:fixed inline style.
|
|
55
|
+
* null → parked at -9999px so dimensions are measurable without visual flash.
|
|
56
|
+
* set → placed at computed coordinates.
|
|
57
|
+
*/
|
|
58
|
+
const panelStyle = $derived(
|
|
59
|
+
position
|
|
60
|
+
? `position:fixed;left:${position.x}px;top:${position.y}px;z-index:9000;`
|
|
61
|
+
: `position:fixed;top:-9999px;left:-9999px;visibility:hidden;pointer-events:none;z-index:9000;`
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Primary side — drives animation transform-origin and direction.
|
|
66
|
+
* autoVertical resolves to 'bottom' until the controller flips it.
|
|
67
|
+
*/
|
|
68
|
+
const side = $derived(
|
|
69
|
+
resolvedPlacement === 'autoVertical' ? 'bottom' : (resolvedPlacement.split('-')[0] as string)
|
|
70
|
+
);
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<div
|
|
74
|
+
bind:this={el}
|
|
75
|
+
{id}
|
|
76
|
+
class="pop-panel card card-border bg-base-100 shadow-xl pop-side-{side} pop-anim-{animation} {cls}"
|
|
77
|
+
class:pop-panel--open={isOpen}
|
|
78
|
+
{role}
|
|
79
|
+
aria-modal={role === 'dialog' ? 'true' : undefined}
|
|
80
|
+
aria-labelledby={ariaLabel ? undefined : triggerId}
|
|
81
|
+
aria-label={ariaLabel}
|
|
82
|
+
tabindex="-1"
|
|
83
|
+
style={panelStyle}
|
|
84
|
+
>
|
|
85
|
+
{#if showArrow}
|
|
86
|
+
<PopoverArrow placement={resolvedPlacement} />
|
|
87
|
+
{/if}
|
|
88
|
+
|
|
89
|
+
<div class="card-body relative z-10 p-0">
|
|
90
|
+
{@render children?.()}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<style>
|
|
95
|
+
/* ── Base: hidden until open ─────────────────────────────────────────────── */
|
|
96
|
+
.pop-panel {
|
|
97
|
+
position: fixed;
|
|
98
|
+
z-index: 9000;
|
|
99
|
+
min-width: 12rem;
|
|
100
|
+
max-width: min(22.5rem, calc(100vw - 1rem));
|
|
101
|
+
outline: none;
|
|
102
|
+
opacity: 0;
|
|
103
|
+
visibility: hidden;
|
|
104
|
+
pointer-events: none;
|
|
105
|
+
transition:
|
|
106
|
+
opacity 200ms ease,
|
|
107
|
+
transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
|
108
|
+
visibility 0s linear 200ms;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ── Open ────────────────────────────────────────────────────────────────── */
|
|
112
|
+
.pop-panel--open {
|
|
113
|
+
opacity: 1;
|
|
114
|
+
visibility: visible;
|
|
115
|
+
pointer-events: auto;
|
|
116
|
+
transition:
|
|
117
|
+
opacity 200ms ease,
|
|
118
|
+
transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1),
|
|
119
|
+
visibility 0s linear 0s;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* ── Scale: transform-origin matches which side the trigger is on ─────────── */
|
|
123
|
+
.pop-anim-scale.pop-side-bottom {
|
|
124
|
+
transform-origin: top center;
|
|
125
|
+
}
|
|
126
|
+
.pop-anim-scale.pop-side-top {
|
|
127
|
+
transform-origin: bottom center;
|
|
128
|
+
}
|
|
129
|
+
.pop-anim-scale.pop-side-left {
|
|
130
|
+
transform-origin: right center;
|
|
131
|
+
}
|
|
132
|
+
.pop-anim-scale.pop-side-right {
|
|
133
|
+
transform-origin: left center;
|
|
134
|
+
}
|
|
135
|
+
.pop-anim-scale:not(.pop-panel--open) {
|
|
136
|
+
transform: scale(0.92);
|
|
137
|
+
}
|
|
138
|
+
.pop-anim-scale.pop-panel--open {
|
|
139
|
+
transform: scale(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ── Slide ────────────────────────────────────────────────────────────────── */
|
|
143
|
+
.pop-anim-slide.pop-side-bottom:not(.pop-panel--open) {
|
|
144
|
+
transform: translateY(-6px);
|
|
145
|
+
}
|
|
146
|
+
.pop-anim-slide.pop-side-top:not(.pop-panel--open) {
|
|
147
|
+
transform: translateY(6px);
|
|
148
|
+
}
|
|
149
|
+
.pop-anim-slide.pop-side-left:not(.pop-panel--open) {
|
|
150
|
+
transform: translateX(6px);
|
|
151
|
+
}
|
|
152
|
+
.pop-anim-slide.pop-side-right:not(.pop-panel--open) {
|
|
153
|
+
transform: translateX(-6px);
|
|
154
|
+
}
|
|
155
|
+
.pop-anim-slide.pop-panel--open {
|
|
156
|
+
transform: none;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* ── Fade: opacity only ───────────────────────────────────────────────────── */
|
|
160
|
+
.pop-anim-fade:not(.pop-panel--open) {
|
|
161
|
+
transform: none;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ── None ─────────────────────────────────────────────────────────────────── */
|
|
165
|
+
.pop-anim-none {
|
|
166
|
+
transition:
|
|
167
|
+
opacity 0s,
|
|
168
|
+
transform 0s,
|
|
169
|
+
visibility 0s;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* ── Reduced motion ───────────────────────────────────────────────────────── */
|
|
173
|
+
@media (prefers-reduced-motion: reduce) {
|
|
174
|
+
.pop-panel {
|
|
175
|
+
transition:
|
|
176
|
+
opacity 0s,
|
|
177
|
+
transform 0s,
|
|
178
|
+
visibility 0s;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ── Mobile ───────────────────────────────────────────────────────────────── */
|
|
183
|
+
@media (max-width: 480px) {
|
|
184
|
+
.pop-panel {
|
|
185
|
+
max-width: calc(100vw - 1.25rem);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
</style>
|