@popover-kit/svelte 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -2
- package/dist/actions.js +1 -0
- package/dist/components/Popover.svelte +31 -28
- package/dist/components/PopoverContent.svelte +16 -4
- package/dist/components/PopoverTrigger.svelte +1 -1
- package/dist/controller.svelte.js +150 -142
- package/dist/dropdown-controller.svelte.js +65 -0
- package/dist/elements/PopoverArrow.svelte +5 -6
- package/dist/elements/PopoverPanel.svelte +6 -4
- package/dist/index.d.ts +163 -57
- package/dist/index.js +1 -1
- package/dist/overlay-controller-base.svelte.js +176 -0
- package/dist/types.js +0 -0
- package/dist/utils/a11y.js +1 -1
- package/dist/utils/position.js +1 -1
- package/package.json +5 -7
- /package/dist/{constants/config.js → constants.js} +0 -0
|
@@ -3,76 +3,79 @@
|
|
|
3
3
|
//
|
|
4
4
|
// File MUST be *.svelte.ts — Svelte 5 compiles $state runes only in .svelte.ts.
|
|
5
5
|
//
|
|
6
|
+
// Extends OverlayControllerBase (id, options-as-getters, click-outside/Esc/
|
|
7
|
+
// focus-trap wiring — see that file for why it's a separate base rather than
|
|
8
|
+
// this class doing everything). What's unique to PopoverController lives
|
|
9
|
+
// here as true `#private` fields, since DropdownController never needs it:
|
|
10
|
+
// the 4-state lifecycle, positioning (`computePosition`, flip hysteresis),
|
|
11
|
+
// ResizeObserver, and scroll/resize tracking.
|
|
12
|
+
//
|
|
6
13
|
// Open sequence (critical — previous versions got this wrong):
|
|
7
14
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
+
// Two DOM-mounting strategies are supported, both converging on the same
|
|
16
|
+
// #finishOpen():
|
|
17
|
+
//
|
|
18
|
+
// A) Conditionally-mounted panel (Popover.svelte's default):
|
|
19
|
+
// 1. open() sets state → 'opening' (isVisible = true) and returns —
|
|
20
|
+
// #panelEl isn't set yet, so it can't finish synchronously.
|
|
21
|
+
// 2. Svelte sees isVisible changed, mounts the panel element in the DOM.
|
|
22
|
+
// 3. Svelte runs $effects. The $effect in Popover.svelte sees panelEl is
|
|
23
|
+
// now set and calls ctrl.attachPanel(el).
|
|
24
|
+
// 4. attachPanel() → base class calls _onPanelAttached() → state is
|
|
25
|
+
// 'opening' → calls #finishOpen().
|
|
26
|
+
//
|
|
27
|
+
// B) Headless / always-mounted panel (attachTrigger + attachPanel called
|
|
28
|
+
// once up front, before open() is ever called):
|
|
29
|
+
// 1. open() sets state → 'opening', then immediately sees #panelEl is
|
|
30
|
+
// already set and calls #finishOpen() itself — there's no later
|
|
31
|
+
// attachPanel() call coming to do it.
|
|
32
|
+
//
|
|
33
|
+
// #finishOpen() reads panel dimensions (element is in DOM), computes
|
|
34
|
+
// position, sets up listeners, sets state → 'open'.
|
|
15
35
|
//
|
|
16
36
|
// This ordering guarantees:
|
|
17
|
-
// • #panelEl is always non-null when
|
|
37
|
+
// • #panelEl is always non-null when listeners are wired.
|
|
18
38
|
// • Click-outside uses the real panel element (not null).
|
|
19
39
|
// • No rAF/setTimeout hacks needed.
|
|
40
|
+
// • open() completes synchronously regardless of mounting strategy.
|
|
20
41
|
// =============================================================================
|
|
42
|
+
import { OverlayControllerBase } from "./overlay-controller-base.svelte.js";
|
|
21
43
|
import { computePosition } from "./utils/position.js";
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
export class PopoverController {
|
|
25
|
-
// ───
|
|
26
|
-
|
|
27
|
-
contentId;
|
|
28
|
-
// ─── Reactive state ($state runes — tracked by Svelte templates) ──────────────
|
|
29
|
-
#isOpen = $state(false);
|
|
44
|
+
import { announce } from "./utils/a11y.js";
|
|
45
|
+
import { DEFAULT_OPTIONS, ANIMATION_DURATION_MS } from "./constants.js";
|
|
46
|
+
export class PopoverController extends OverlayControllerBase {
|
|
47
|
+
// ─── Reactive state — private to this subclass; DropdownController has no
|
|
48
|
+
// use for any of it ────────────────────────────────────────────────────
|
|
30
49
|
#state = $state("closed");
|
|
31
50
|
#resolvedPlacement = $state("bottom");
|
|
32
51
|
#pos = $state(null);
|
|
33
|
-
// ───
|
|
34
|
-
|
|
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;
|
|
52
|
+
// ─── Positioning-only listeners (click-outside/Esc/focus-trap live in the
|
|
53
|
+
// base class) ────────────────────────────────────────────────────────────
|
|
48
54
|
#scrollResizeStop = null;
|
|
49
|
-
#escStop = null;
|
|
50
55
|
#resizeObs = null;
|
|
51
|
-
|
|
52
|
-
#subscribers = new Set();
|
|
56
|
+
#rafId = null;
|
|
53
57
|
// ─── Timers ───────────────────────────────────────────────────────────────────
|
|
54
58
|
#focusTrapTimer = null;
|
|
55
59
|
#closeTimer = null;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
this.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
this
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
this
|
|
69
|
-
this.#onClose = opts.onClose;
|
|
70
|
-
this.#onStateChange = opts.onStateChange;
|
|
71
|
-
this.#resolvedPlacement = this.#placement;
|
|
60
|
+
constructor(options = {}) {
|
|
61
|
+
super(options, "pop");
|
|
62
|
+
this.#resolvedPlacement = this.placement;
|
|
63
|
+
}
|
|
64
|
+
// ─── Options getters unique to Popover (shared ones live in the base) ────────
|
|
65
|
+
get placement() {
|
|
66
|
+
return this._options.placement ?? DEFAULT_OPTIONS.placement;
|
|
67
|
+
}
|
|
68
|
+
get offset() {
|
|
69
|
+
return this._options.offset ?? DEFAULT_OPTIONS.offset;
|
|
70
|
+
}
|
|
71
|
+
get onStateChange() {
|
|
72
|
+
return this._options.onStateChange;
|
|
72
73
|
}
|
|
73
74
|
// ─── Reactive getters ─────────────────────────────────────────────────────────
|
|
75
|
+
/** Derived from `state`, not a separately-tracked flag — there is exactly one
|
|
76
|
+
* state ('open') that means "open", so there's nothing to keep in sync. */
|
|
74
77
|
get isOpen() {
|
|
75
|
-
return this.#
|
|
78
|
+
return this.#state === "open";
|
|
76
79
|
}
|
|
77
80
|
get state() {
|
|
78
81
|
return this.#state;
|
|
@@ -89,108 +92,135 @@ export class PopoverController {
|
|
|
89
92
|
// ─── Snapshot ─────────────────────────────────────────────────────────────────
|
|
90
93
|
getSnapshot() {
|
|
91
94
|
return {
|
|
92
|
-
isOpen: this
|
|
95
|
+
isOpen: this.isOpen,
|
|
93
96
|
state: this.#state,
|
|
94
97
|
resolvedPlacement: this.#resolvedPlacement,
|
|
95
98
|
triggerId: this.triggerId,
|
|
96
99
|
contentId: this.contentId
|
|
97
100
|
};
|
|
98
101
|
}
|
|
99
|
-
// ───
|
|
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
|
-
}
|
|
102
|
+
// ─── Element wiring — base class handles attach/detach; this only reacts ─────
|
|
115
103
|
/**
|
|
116
|
-
* Called by
|
|
104
|
+
* Called by the base class's attachPanel() after the panel element mounts.
|
|
117
105
|
* If state is 'opening', we're in the open sequence → finish it now.
|
|
118
106
|
* If state is already 'open', controller was re-attached → just recompute.
|
|
119
107
|
*/
|
|
120
|
-
|
|
121
|
-
this.#panelEl = el;
|
|
108
|
+
_onPanelAttached() {
|
|
122
109
|
if (this.#state === "opening") {
|
|
123
110
|
this.#finishOpen();
|
|
124
111
|
}
|
|
125
|
-
else if (this
|
|
112
|
+
else if (this.#state === "open") {
|
|
126
113
|
this.#compute();
|
|
127
114
|
}
|
|
128
115
|
}
|
|
129
|
-
detachTrigger() {
|
|
130
|
-
this.#triggerEl = null;
|
|
131
|
-
}
|
|
132
|
-
detachPanel() {
|
|
133
|
-
this.#panelEl = null;
|
|
134
|
-
}
|
|
135
116
|
// ─── Core actions ─────────────────────────────────────────────────────────────
|
|
136
117
|
open() {
|
|
137
|
-
if (this
|
|
118
|
+
if (this.isOpen || this.#state === "opening")
|
|
138
119
|
return;
|
|
139
120
|
this.#cancelClose();
|
|
121
|
+
this.hooks.beforeOpen?.();
|
|
140
122
|
// Set 'opening' FIRST → isVisible becomes true → Svelte mounts the panel
|
|
141
|
-
// → $effect fires → attachPanel() → #finishOpen().
|
|
142
|
-
this.#setState(
|
|
123
|
+
// → $effect fires → attachPanel() → #finishOpen().
|
|
124
|
+
this.#setState("opening");
|
|
125
|
+
// Headless usage (or any consumer that keeps the panel element in the
|
|
126
|
+
// DOM at all times instead of conditionally mounting it) already has
|
|
127
|
+
// #panelEl set at this point — no attachPanel() call is coming to
|
|
128
|
+
// finish the sequence, so finish it here instead. This makes open()
|
|
129
|
+
// correct regardless of DOM-mounting strategy.
|
|
130
|
+
if (this._panelEl)
|
|
131
|
+
this.#finishOpen();
|
|
143
132
|
}
|
|
144
133
|
close() {
|
|
145
|
-
if (
|
|
134
|
+
if (this.#state === "opening") {
|
|
135
|
+
// open() was called but the panel never finished mounting (e.g. a
|
|
136
|
+
// consumer calls open() then close() synchronously, before Svelte's
|
|
137
|
+
// $effect had a chance to run attachPanel()). Nothing ever became
|
|
138
|
+
// visible, so there's no exit animation to run — cancel straight to
|
|
139
|
+
// 'closed' rather than getting stuck in 'opening' forever, since the
|
|
140
|
+
// normal close() guard below treats 'opening' as "already closed"
|
|
141
|
+
// and would otherwise silently no-op here.
|
|
142
|
+
this.#setState("closed");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!this.isOpen || this.#state === "closing")
|
|
146
146
|
return;
|
|
147
147
|
this.#cancelFocusTrapTimer();
|
|
148
|
-
this.#setState(
|
|
149
|
-
this
|
|
148
|
+
this.#setState("closing");
|
|
149
|
+
this._teardownSharedListeners();
|
|
150
|
+
this.#teardownPositionListeners();
|
|
150
151
|
this.#closeTimer = setTimeout(() => {
|
|
151
152
|
if (this.#state === "closing") {
|
|
152
|
-
this.#setState(
|
|
153
|
+
this.#setState("closed");
|
|
153
154
|
this.#pos = null;
|
|
154
155
|
announce("Popover closed");
|
|
155
|
-
this
|
|
156
|
+
this.onClose?.();
|
|
156
157
|
}
|
|
157
158
|
}, ANIMATION_DURATION_MS);
|
|
158
159
|
}
|
|
159
|
-
toggle() {
|
|
160
|
-
this.#isOpen ? this.close() : this.open();
|
|
161
|
-
}
|
|
162
160
|
setPlacement(p) {
|
|
163
|
-
this
|
|
164
|
-
if (this
|
|
165
|
-
|
|
161
|
+
this._options = { ...this._options, placement: p };
|
|
162
|
+
if (this.#state === "open") {
|
|
163
|
+
// resetHysteresis: true — the consumer just explicitly asked for a
|
|
164
|
+
// *different* preferred side. Flip hysteresis (see #compute()) exists
|
|
165
|
+
// to stop the SAME requested placement from flip-flopping across
|
|
166
|
+
// repeated recomputes (scroll/resize) right at the viewport
|
|
167
|
+
// boundary; it should not fight a genuinely new request by
|
|
168
|
+
// "sticking" to the side that was resolved under the OLD
|
|
169
|
+
// placement. Without this, e.g. setPlacement('top') right after
|
|
170
|
+
// resolving to 'bottom' could stay on 'bottom' even when 'top' now
|
|
171
|
+
// fits, because 'bottom' still technically fits too.
|
|
172
|
+
this.#compute(true);
|
|
173
|
+
// #compute() alone only updates the $state fields (which Svelte
|
|
174
|
+
// templates pick up automatically via the getters above) — headless
|
|
175
|
+
// subscribe() consumers only ever hear about changes through an
|
|
176
|
+
// explicit snapshot push, so without this they'd keep the stale
|
|
177
|
+
// resolvedPlacement/position from before setPlacement() was called.
|
|
178
|
+
this._emit();
|
|
179
|
+
}
|
|
166
180
|
}
|
|
167
181
|
// ─── Destroy ──────────────────────────────────────────────────────────────────
|
|
168
182
|
destroy() {
|
|
169
183
|
this.#cancelFocusTrapTimer();
|
|
170
184
|
this.#cancelClose();
|
|
171
|
-
this.#
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
185
|
+
this.#teardownPositionListeners();
|
|
186
|
+
super.destroy(); // tears down shared a11y listeners + subscribers + refs
|
|
187
|
+
}
|
|
188
|
+
// ─── Focus-trap timing override — see base class doc comment ─────────────────
|
|
189
|
+
_activateFocusTrap() {
|
|
190
|
+
// Delayed so it activates after the enter animation completes.
|
|
191
|
+
// DropdownController has no such animation and activates immediately.
|
|
192
|
+
this.#focusTrapTimer = setTimeout(() => this._focusTrap?.activate(), ANIMATION_DURATION_MS);
|
|
176
193
|
}
|
|
177
194
|
// ─── Private: open sequence ───────────────────────────────────────────────────
|
|
178
195
|
/**
|
|
179
|
-
* Called from
|
|
196
|
+
* Called from _onPanelAttached() when state === 'opening'.
|
|
180
197
|
* At this point the panel element is guaranteed to be in the DOM.
|
|
181
198
|
*/
|
|
182
199
|
#finishOpen() {
|
|
183
200
|
this.#compute(); // position panel (reads real dimensions)
|
|
184
|
-
this
|
|
185
|
-
this.#
|
|
201
|
+
this._setupSharedListeners(); // click-outside, Esc, focus trap (base class)
|
|
202
|
+
this.#setupPositionListeners(); // scroll, resize, ResizeObserver (this class)
|
|
203
|
+
this.#setState("open");
|
|
186
204
|
announce("Popover opened");
|
|
187
|
-
this
|
|
205
|
+
this.onOpen?.();
|
|
188
206
|
}
|
|
189
207
|
// ─── Private: positioning ─────────────────────────────────────────────────────
|
|
190
|
-
|
|
191
|
-
|
|
208
|
+
/**
|
|
209
|
+
* @param resetHysteresis - Pass `true` when the *requested* placement
|
|
210
|
+
* itself just changed (currently only `setPlacement()`), so the flip
|
|
211
|
+
* decision starts fresh instead of being biased toward whatever side
|
|
212
|
+
* was last resolved under the previous requested placement.
|
|
213
|
+
*/
|
|
214
|
+
#compute(resetHysteresis = false) {
|
|
215
|
+
if (!this._triggerEl || !this._panelEl)
|
|
192
216
|
return;
|
|
193
|
-
|
|
217
|
+
// Pass the previous resolved placement for flip hysteresis — keeps the
|
|
218
|
+
// panel on whichever side it's already showing on when that side still
|
|
219
|
+
// fits, instead of flip-flopping right at the viewport boundary. Only
|
|
220
|
+
// meaningful when recomputing the SAME requested placement (scroll,
|
|
221
|
+
// resize, a re-attached panel) — see resetHysteresis above.
|
|
222
|
+
const previousPlacement = resetHysteresis ? null : this.#resolvedPlacement;
|
|
223
|
+
const result = computePosition(this._triggerEl, this._panelEl, this.placement, this.offset, previousPlacement);
|
|
194
224
|
this.#pos = result;
|
|
195
225
|
this.#resolvedPlacement = result.placement;
|
|
196
226
|
}
|
|
@@ -199,7 +229,14 @@ export class PopoverController {
|
|
|
199
229
|
return;
|
|
200
230
|
this.#rafId = requestAnimationFrame(() => {
|
|
201
231
|
this.#rafId = null;
|
|
202
|
-
|
|
232
|
+
// Guaranteed 'open' in practice — the scroll/resize/ResizeObserver
|
|
233
|
+
// listeners that call this are only ever wired up in
|
|
234
|
+
// #setupPositionListeners() (called from #finishOpen()) and torn
|
|
235
|
+
// down synchronously the moment close() runs, so no stale rAF can
|
|
236
|
+
// land here mid-'closing'. Checked anyway as defense-in-depth
|
|
237
|
+
// against exactly the class of "recomputed mid-exit-animation" bug
|
|
238
|
+
// that causes a panel to visibly jump/flip while fading out.
|
|
239
|
+
if (this.#state === "open")
|
|
203
240
|
this.#compute();
|
|
204
241
|
});
|
|
205
242
|
}
|
|
@@ -209,32 +246,10 @@ export class PopoverController {
|
|
|
209
246
|
this.#rafId = null;
|
|
210
247
|
}
|
|
211
248
|
}
|
|
212
|
-
// ─── Private: listeners
|
|
213
|
-
#
|
|
214
|
-
const trigger = this
|
|
215
|
-
const panel = this
|
|
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
|
-
}
|
|
249
|
+
// ─── Private: positioning-only listeners ─────────────────────────────────────
|
|
250
|
+
#setupPositionListeners() {
|
|
251
|
+
const trigger = this._triggerEl;
|
|
252
|
+
const panel = this._panelEl;
|
|
238
253
|
// Scroll + resize → rAF-coalesced recompute (max one per frame)
|
|
239
254
|
const recompute = () => this.#scheduleCompute();
|
|
240
255
|
window.addEventListener("scroll", recompute, { passive: true, capture: true });
|
|
@@ -251,25 +266,18 @@ export class PopoverController {
|
|
|
251
266
|
this.#resizeObs.observe(panel);
|
|
252
267
|
}
|
|
253
268
|
}
|
|
254
|
-
#
|
|
255
|
-
this.#focusTrap?.deactivate();
|
|
256
|
-
this.#clickOutsideStop?.();
|
|
269
|
+
#teardownPositionListeners() {
|
|
257
270
|
this.#scrollResizeStop?.();
|
|
258
|
-
this.#escStop?.();
|
|
259
271
|
this.#resizeObs?.disconnect();
|
|
260
272
|
this.#cancelRaf();
|
|
261
|
-
this.#focusTrap = null;
|
|
262
|
-
this.#clickOutsideStop = null;
|
|
263
273
|
this.#scrollResizeStop = null;
|
|
264
|
-
this.#escStop = null;
|
|
265
274
|
this.#resizeObs = null;
|
|
266
275
|
}
|
|
267
276
|
// ─── Private: state ───────────────────────────────────────────────────────────
|
|
268
|
-
#setState(
|
|
269
|
-
this.#isOpen = isOpen;
|
|
277
|
+
#setState(state) {
|
|
270
278
|
this.#state = state;
|
|
271
|
-
this
|
|
272
|
-
this
|
|
279
|
+
this.onStateChange?.(state);
|
|
280
|
+
this._emit();
|
|
273
281
|
}
|
|
274
282
|
#cancelFocusTrapTimer() {
|
|
275
283
|
if (this.#focusTrapTimer !== null) {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Popover Micro-Plugin — DropdownController
|
|
3
|
+
//
|
|
4
|
+
// File MUST be *.svelte.ts — Svelte 5 compiles $state runes only in .svelte.ts.
|
|
5
|
+
//
|
|
6
|
+
// For CSS-anchored panels (account switchers, inline menus, anything placed
|
|
7
|
+
// with plain CSS rather than `computePosition`) that only need: open/close
|
|
8
|
+
// state, click-outside, Escape, a focus trap, and a `beforeOpen` hook to
|
|
9
|
+
// refresh data — without any positioning engine, ResizeObserver, or
|
|
10
|
+
// scroll/resize tracking.
|
|
11
|
+
//
|
|
12
|
+
// Extends OverlayControllerBase, which owns everything that's identical to
|
|
13
|
+
// PopoverController (id, options-as-getters, click-outside/Esc/focus-trap
|
|
14
|
+
// wiring). All this class adds is the plain boolean `isOpen` state and the
|
|
15
|
+
// synchronous open()/close() sequence — no 4-state lifecycle, because
|
|
16
|
+
// there's no positioning to keep settled during an exit animation. Svelte's
|
|
17
|
+
// own `{#if isOpen}` + `transition:`/`in:`/`out:` already keeps a panel
|
|
18
|
+
// mounted through its exit transition natively.
|
|
19
|
+
// =============================================================================
|
|
20
|
+
import { OverlayControllerBase } from "./overlay-controller-base.svelte.js";
|
|
21
|
+
export class DropdownController extends OverlayControllerBase {
|
|
22
|
+
#isOpen = $state(false);
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
super(options, "dd");
|
|
25
|
+
}
|
|
26
|
+
get isOpen() {
|
|
27
|
+
return this.#isOpen;
|
|
28
|
+
}
|
|
29
|
+
get onStateChange() {
|
|
30
|
+
return this._options.onStateChange;
|
|
31
|
+
}
|
|
32
|
+
_onPanelAttached() {
|
|
33
|
+
// Panel (re)attached while already open — e.g. re-rendered. Wire
|
|
34
|
+
// listeners against the new element. If we're not open yet, open()
|
|
35
|
+
// itself will wire them once it runs.
|
|
36
|
+
if (this.#isOpen)
|
|
37
|
+
this._setupSharedListeners();
|
|
38
|
+
}
|
|
39
|
+
open() {
|
|
40
|
+
if (this.#isOpen)
|
|
41
|
+
return;
|
|
42
|
+
this.hooks.beforeOpen?.();
|
|
43
|
+
this.#isOpen = true;
|
|
44
|
+
// Headless / already-mounted panel: no later attachPanel() call is
|
|
45
|
+
// coming, so wire listeners now. Conditionally-mounted panel: wired
|
|
46
|
+
// from _onPanelAttached() once it mounts.
|
|
47
|
+
if (this._panelEl)
|
|
48
|
+
this._setupSharedListeners();
|
|
49
|
+
this.onOpen?.();
|
|
50
|
+
this.onStateChange?.(true);
|
|
51
|
+
this._emit();
|
|
52
|
+
}
|
|
53
|
+
close() {
|
|
54
|
+
if (!this.#isOpen)
|
|
55
|
+
return;
|
|
56
|
+
this.#isOpen = false;
|
|
57
|
+
this._teardownSharedListeners();
|
|
58
|
+
this.onClose?.();
|
|
59
|
+
this.onStateChange?.(false);
|
|
60
|
+
this._emit();
|
|
61
|
+
}
|
|
62
|
+
getSnapshot() {
|
|
63
|
+
return { isOpen: this.#isOpen, triggerId: this.triggerId, contentId: this.contentId };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* A border on two sides of the square, combined with the clip, produces a
|
|
19
19
|
* bordered triangle that matches the card's border colour.
|
|
20
20
|
*/
|
|
21
|
-
import type { Placement } from '../types
|
|
21
|
+
import type { Placement } from '../types.js';
|
|
22
|
+
import { parsePlacement } from '../utils/position.js';
|
|
22
23
|
|
|
23
24
|
interface Props {
|
|
24
25
|
placement: Placement;
|
|
@@ -27,11 +28,9 @@
|
|
|
27
28
|
|
|
28
29
|
const { placement, class: cls = '' }: Props = $props();
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
: (placement.split('-')[0] as 'top' | 'bottom' | 'left' | 'right')
|
|
34
|
-
);
|
|
31
|
+
// Same parsing PopoverPanel.svelte uses for its own transform-origin —
|
|
32
|
+
// shared via parsePlacement rather than duplicated inline.
|
|
33
|
+
const side = $derived(parsePlacement(placement).side);
|
|
35
34
|
</script>
|
|
36
35
|
|
|
37
36
|
<span class="pop-arrow pop-arrow--{side} {cls}" aria-hidden="true"></span>
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* Pure CSS handles animations — no Svelte transitions needed.
|
|
17
17
|
*/
|
|
18
18
|
import type { Snippet } from 'svelte';
|
|
19
|
-
import type { Placement, ResolvedPosition, PopoverAnimation } from '../types
|
|
19
|
+
import type { Placement, ResolvedPosition, PopoverAnimation } from '../types.js';
|
|
20
|
+
import { parsePlacement } from '../utils/position.js';
|
|
20
21
|
import PopoverArrow from './PopoverArrow.svelte';
|
|
21
22
|
|
|
22
23
|
interface Props {
|
|
@@ -64,10 +65,11 @@
|
|
|
64
65
|
/**
|
|
65
66
|
* Primary side — drives animation transform-origin and direction.
|
|
66
67
|
* autoVertical resolves to 'bottom' until the controller flips it.
|
|
68
|
+
* Reuses the same parsing `computePosition` itself uses, rather than
|
|
69
|
+
* re-deriving it with a separate inline ternary (PopoverArrow.svelte
|
|
70
|
+
* needs the identical side, so this is shared, not duplicated).
|
|
67
71
|
*/
|
|
68
|
-
const side = $derived(
|
|
69
|
-
resolvedPlacement === 'autoVertical' ? 'bottom' : (resolvedPlacement.split('-')[0] as string)
|
|
70
|
-
);
|
|
72
|
+
const side = $derived(parsePlacement(resolvedPlacement).side);
|
|
71
73
|
</script>
|
|
72
74
|
|
|
73
75
|
<div
|