@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.
@@ -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
- // 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
+ // 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 #setupListeners() runs.
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 { 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);
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
- // ─── 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;
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
- // ─── Headless subscribers ─────────────────────────────────────────────────────
52
- #subscribers = new Set();
56
+ #rafId = null;
53
57
  // ─── Timers ───────────────────────────────────────────────────────────────────
54
58
  #focusTrapTimer = null;
55
59
  #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;
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.#isOpen;
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.#isOpen,
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
- // ─── 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
- }
102
+ // ─── Element wiring — base class handles attach/detach; this only reacts ─────
115
103
  /**
116
- * Called by Popover.svelte's $effect after the panel element mounts.
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
- attachPanel(el) {
121
- this.#panelEl = el;
108
+ _onPanelAttached() {
122
109
  if (this.#state === "opening") {
123
110
  this.#finishOpen();
124
111
  }
125
- else if (this.isVisible) {
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.#isOpen || this.#state === "opening")
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(). Nothing else happens here.
142
- this.#setState(false, "opening");
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 (!this.#isOpen || this.#state === "closing")
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(false, "closing");
149
- this.#teardownListeners();
148
+ this.#setState("closing");
149
+ this._teardownSharedListeners();
150
+ this.#teardownPositionListeners();
150
151
  this.#closeTimer = setTimeout(() => {
151
152
  if (this.#state === "closing") {
152
- this.#setState(false, "closed");
153
+ this.#setState("closed");
153
154
  this.#pos = null;
154
155
  announce("Popover closed");
155
- this.#onClose?.();
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.#placement = p;
164
- if (this.isVisible)
165
- this.#compute();
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.#cancelRaf();
172
- this.#teardownListeners();
173
- this.#subscribers.clear();
174
- this.#triggerEl = null;
175
- this.#panelEl = null;
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 attachPanel() when state === 'opening'.
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.#setupListeners(); // wire click-outside, Esc, scroll, resize
185
- this.#setState(true, "open");
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.#onOpen?.();
205
+ this.onOpen?.();
188
206
  }
189
207
  // ─── Private: positioning ─────────────────────────────────────────────────────
190
- #compute() {
191
- if (!this.#triggerEl || !this.#panelEl)
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
- const result = computePosition(this.#triggerEl, this.#panelEl, this.#placement, this.#offset);
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
- if (this.isVisible)
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
- #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
- }
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
- #teardownListeners() {
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(isOpen, state) {
269
- this.#isOpen = isOpen;
277
+ #setState(state) {
270
278
  this.#state = state;
271
- this.#onStateChange?.(state);
272
- this.#emit();
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/types.js';
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
- const side = $derived(
31
- placement === 'autoVertical'
32
- ? 'bottom'
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/types.js';
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