@popover-kit/svelte 0.1.1 → 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 CHANGED
@@ -78,14 +78,57 @@ ctrl.open();
78
78
  `PopoverController` is pure TypeScript (a `.svelte.ts` module using runes
79
79
  for its reactive state) — no component required.
80
80
 
81
- ## Scripts
81
+ ## `DropdownController` — for CSS-anchored panels (no floating positioning)
82
+
83
+ `PopoverController` computes `position: fixed` coordinates, which is more
84
+ than you need for a panel that's already positioned with plain CSS (e.g.
85
+ `position: relative` on a wrapper + `top-full` on the panel) — an account
86
+ switcher, an inline menu, anything anchored by layout rather than JS.
87
+
88
+ `DropdownController` is a separate, smaller primitive for exactly that case:
89
+ open/close state, click-outside, Escape, and a focus trap — no
90
+ `computePosition`, no `ResizeObserver`, no scroll/resize tracking, and no
91
+ 4-state animation lifecycle (Svelte's own `{#if isOpen}` + `transition:`
92
+ already keeps a panel mounted through its exit transition natively, so
93
+ there's nothing for the controller to coordinate there).
94
+
95
+ `PopoverController` and `DropdownController` both extend
96
+ `OverlayControllerBase`, which owns everything genuinely identical between
97
+ them — id generation, options-as-getters, click-outside/Esc/focus-trap
98
+ wiring — so the a11y behavior isn't duplicated or able to drift between the
99
+ two. `OverlayControllerBase` is exported too, for a third overlay type
100
+ (context menu, tooltip, ...) to extend the same way.
101
+
102
+ Wire either controller to real DOM elements with `use:overlayTrigger` /
103
+ `use:overlayPanel` — plain Svelte actions, no added DOM, no styling. This is
104
+ the same mechanism `<Popover>`'s own trigger wiring uses internally, so it's
105
+ exercised by the package's own components, not just offered for headless use:
82
106
 
83
- ```bash
84
- pnpm build # bundles src/ -> dist/index.js + dist/index.d.ts
85
- pnpm test # vitest (position engine, a11y utils, controller)
86
- pnpm typecheck # svelte-check
107
+ ```svelte
108
+ <script lang="ts">
109
+ import { DropdownController, overlayTrigger, overlayPanel } from '@popover-kit/svelte';
110
+
111
+ const dropdown = new DropdownController({
112
+ hooks: {
113
+ // Runs right before the panel opens — e.g. refresh data on open.
114
+ beforeOpen: () => refreshList()
115
+ }
116
+ });
117
+ </script>
118
+
119
+ <button use:overlayTrigger={dropdown} onclick={() => dropdown.toggle()}>
120
+ Toggle
121
+ </button>
122
+
123
+ {#if dropdown.isOpen}
124
+ <div use:overlayPanel={dropdown} transition:fly={{ y: -5 }}>...</div>
125
+ {/if}
87
126
  ```
88
127
 
128
+ No `$effect`/`bind:this` wiring needed — the actions attach on mount and
129
+ detach on unmount by themselves. `PopoverController` accepts the same
130
+ `hooks.beforeOpen` option and works with the same two actions.
131
+
89
132
  ## License
90
133
 
91
134
  MIT
@@ -0,0 +1 @@
1
+ function i(e,a){return(r,t)=>(e(t,r),{update(g){g!==t&&(a(t),t=g,e(t,r))},destroy(){a(t)}})}const n=i((e,a)=>e.attachTrigger(a),e=>e.detachTrigger()),u=i((e,a)=>e.attachPanel(a),e=>e.detachPanel());export{u as overlayPanel,n as overlayTrigger};
@@ -4,14 +4,17 @@
4
4
  *
5
5
  * Architecture:
6
6
  * • Creates or accepts an injected PopoverController.
7
- * • Wires trigger and panel DOM refs via $effect (required — bind:this is async).
7
+ * • Trigger DOM ref: `use:overlayTrigger` (a plain Svelte action, no
8
+ * $effect needed — see actions.ts). Panel DOM ref: still $effect,
9
+ * since it's a $bindable prop from a child component, not a node in
10
+ * this component's own template (actions can only attach to elements
11
+ * in the same template).
8
12
  * • Passes inline snippet contexts (no $derived — each used once, caching wastes).
9
13
  * • Panel renders only while ctrl.isVisible (open + exit-animation).
10
14
  *
11
15
  * $effect usage (justified):
12
- * 1. triggerWrapEl wiring — bind:this is async; must react to ref being set.
13
- * 2. panelEl wiring — panel is conditionally mounted; $effect detects mount.
14
- * 3. controlled-mode sync — external prop can change at any time.
16
+ * 1. panelEl wiring — panel is conditionally mounted; $effect detects mount.
17
+ * 2. controlled-mode sync — external prop can change at any time.
15
18
  *
16
19
  * Snippets (Bits UI style):
17
20
  * {#snippet trigger(ctx: TriggerSnippetCtx)} — render the trigger element
@@ -27,6 +30,7 @@
27
30
  } from '../types.js';
28
31
 
29
32
  import { PopoverController } from '../controller.svelte.js';
33
+ import { overlayTrigger } from '../actions.js';
30
34
  import PopoverPanel from '../elements/PopoverPanel.svelte';
31
35
  import {
32
36
  DEFAULT_OPTIONS,
@@ -53,6 +57,7 @@
53
57
  trapFocus = DEFAULT_OPTIONS.trapFocus,
54
58
  closeOnOutsideClick = DEFAULT_OPTIONS.closeOnOutsideClick,
55
59
  closeOnEsc = DEFAULT_OPTIONS.closeOnEsc,
60
+ hooks,
56
61
  onOpen,
57
62
  onClose,
58
63
  onStateChange,
@@ -92,6 +97,7 @@
92
97
  trapFocus,
93
98
  closeOnOutsideClick,
94
99
  closeOnEsc,
100
+ hooks,
95
101
  onOpen,
96
102
  onClose,
97
103
  onStateChange
@@ -100,20 +106,12 @@
100
106
 
101
107
  // ── DOM refs ─────────────────────────────────────────────────────────────────
102
108
  //
103
- // triggerWrapEl: root <span> — the position anchor for getBoundingClientRect().
104
- // panelEl: $bindable from PopoverPanel — wired to ctrl.attachPanel().
105
- //
106
- // Both use $effect because bind:this is asynchronous in Svelte 5 — the ref
107
- // is set after the element is mounted, so there's no synchronous alternative.
109
+ // panelEl: $bindable from PopoverPanel — wired to ctrl.attachPanel() via
110
+ // $effect (bind:this is async, and the node lives in a child component's
111
+ // template, so an action can't attach to it directly from here).
108
112
 
109
- let triggerWrapEl = $state<HTMLElement | null>(null);
110
113
  let panelEl = $state<HTMLElement | null>(null);
111
114
 
112
- $effect(() => {
113
- if (triggerWrapEl) ctrl.attachTrigger(triggerWrapEl);
114
- else ctrl.detachTrigger();
115
- });
116
-
117
115
  $effect(() => {
118
116
  if (panelEl) ctrl.attachPanel(panelEl);
119
117
  else ctrl.detachPanel();
@@ -210,12 +208,12 @@
210
208
 
211
209
  <!--
212
210
  Root wrapper: inline-block keeps it tight around the trigger so that
213
- getBoundingClientRect() on triggerWrapEl returns the trigger's actual rect.
211
+ getBoundingClientRect() on it (wired via use:overlayTrigger) returns the trigger's actual rect.
214
212
 
215
213
  Snippet contexts are passed INLINE — each is used exactly once in the template,
216
214
  so storing them in $derived would add a reactive subscription with zero benefit.
217
215
  -->
218
- <span class="pop-root" bind:this={triggerWrapEl}>
216
+ <span class="pop-root" use:overlayTrigger={ctrl}>
219
217
  <!-- ── Trigger ─────────────────────────────────────────────────────────── -->
220
218
  {#if triggerSnippet}
221
219
  {@render triggerSnippet({
@@ -3,6 +3,13 @@
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
15
  // Two DOM-mounting strategies are supported, both converging on the same
@@ -14,7 +21,8 @@
14
21
  // 2. Svelte sees isVisible changed, mounts the panel element in the DOM.
15
22
  // 3. Svelte runs $effects. The $effect in Popover.svelte sees panelEl is
16
23
  // now set and calls ctrl.attachPanel(el).
17
- // 4. attachPanel() detects state === 'opening' → calls #finishOpen().
24
+ // 4. attachPanel() → base class calls _onPanelAttached() → state is
25
+ // 'opening' → calls #finishOpen().
18
26
  //
19
27
  // B) Headless / always-mounted panel (attachTrigger + attachPanel called
20
28
  // once up front, before open() is ever called):
@@ -26,66 +34,48 @@
26
34
  // position, sets up listeners, sets state → 'open'.
27
35
  //
28
36
  // This ordering guarantees:
29
- // • #panelEl is always non-null when #setupListeners() runs.
37
+ // • #panelEl is always non-null when listeners are wired.
30
38
  // • Click-outside uses the real panel element (not null).
31
39
  // • No rAF/setTimeout hacks needed.
32
40
  // • open() completes synchronously regardless of mounting strategy.
33
41
  // =============================================================================
42
+ import { OverlayControllerBase } from "./overlay-controller-base.svelte.js";
34
43
  import { computePosition } from "./utils/position.js";
35
- import { createFocusTrap, onClickOutside, announce } from "./utils/a11y.js";
36
- import { uid, DEFAULT_OPTIONS, ANIMATION_DURATION_MS } from "./constants.js";
37
- export class PopoverController {
38
- // ─── Stable IDs ───────────────────────────────────────────────────────────────
39
- triggerId;
40
- contentId;
41
- // ─── Reactive state ($state runes — tracked by Svelte templates) ──────────────
42
- #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 ────────────────────────────────────────────────────
43
49
  #state = $state("closed");
44
50
  #resolvedPlacement = $state("bottom");
45
51
  #pos = $state(null);
46
- // ─── DOM refs ─────────────────────────────────────────────────────────────────
47
- #triggerEl = null;
48
- #panelEl = null;
49
- // ─── Options (set at construction, immutable after) ───────────────────────────
50
- #placement; // mutable via setPlacement()
51
- #offset;
52
- #trapFocus;
53
- #closeOnOutsideClick;
54
- #closeOnEsc;
55
- #onOpen;
56
- #onClose;
57
- #onStateChange;
58
- // ─── Lazy resources (null while closed — zero allocation at rest) ──────────────
59
- #focusTrap = null;
60
- #clickOutsideStop = null;
52
+ // ─── Positioning-only listeners (click-outside/Esc/focus-trap live in the
53
+ // base class) ────────────────────────────────────────────────────────────
61
54
  #scrollResizeStop = null;
62
- #escStop = null;
63
55
  #resizeObs = null;
64
- // ─── Headless subscribers ─────────────────────────────────────────────────────
65
- #subscribers = new Set();
56
+ #rafId = null;
66
57
  // ─── Timers ───────────────────────────────────────────────────────────────────
67
58
  #focusTrapTimer = null;
68
59
  #closeTimer = null;
69
- // ─── rAF handle — coalesces scroll/resize into one recompute per frame ─────────
70
- #rafId = null;
71
- // =============================================================================
72
- constructor(opts = {}) {
73
- const id = opts.id ?? uid();
74
- this.triggerId = `${id}-trigger`;
75
- this.contentId = `${id}-content`;
76
- this.#placement = opts.placement ?? DEFAULT_OPTIONS.placement;
77
- this.#offset = opts.offset ?? DEFAULT_OPTIONS.offset;
78
- this.#trapFocus = opts.trapFocus ?? DEFAULT_OPTIONS.trapFocus;
79
- this.#closeOnOutsideClick = opts.closeOnOutsideClick ?? DEFAULT_OPTIONS.closeOnOutsideClick;
80
- this.#closeOnEsc = opts.closeOnEsc ?? DEFAULT_OPTIONS.closeOnEsc;
81
- this.#onOpen = opts.onOpen;
82
- this.#onClose = opts.onClose;
83
- this.#onStateChange = opts.onStateChange;
84
- 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;
85
73
  }
86
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. */
87
77
  get isOpen() {
88
- return this.#isOpen;
78
+ return this.#state === "open";
89
79
  }
90
80
  get state() {
91
81
  return this.#state;
@@ -102,36 +92,20 @@ export class PopoverController {
102
92
  // ─── Snapshot ─────────────────────────────────────────────────────────────────
103
93
  getSnapshot() {
104
94
  return {
105
- isOpen: this.#isOpen,
95
+ isOpen: this.isOpen,
106
96
  state: this.#state,
107
97
  resolvedPlacement: this.#resolvedPlacement,
108
98
  triggerId: this.triggerId,
109
99
  contentId: this.contentId
110
100
  };
111
101
  }
112
- // ─── Subscriptions ────────────────────────────────────────────────────────────
113
- subscribe(cb) {
114
- this.#subscribers.add(cb);
115
- cb(this.getSnapshot());
116
- return () => this.#subscribers.delete(cb);
117
- }
118
- #emit() {
119
- if (!this.#subscribers.size)
120
- return;
121
- const snap = this.getSnapshot();
122
- this.#subscribers.forEach((cb) => cb(snap));
123
- }
124
- // ─── Element wiring ───────────────────────────────────────────────────────────
125
- attachTrigger(el) {
126
- this.#triggerEl = el;
127
- }
102
+ // ─── Element wiring — base class handles attach/detach; this only reacts ─────
128
103
  /**
129
- * Called by Popover.svelte's $effect after the panel element mounts.
104
+ * Called by the base class's attachPanel() after the panel element mounts.
130
105
  * If state is 'opening', we're in the open sequence → finish it now.
131
106
  * If state is already 'open', controller was re-attached → just recompute.
132
107
  */
133
- attachPanel(el) {
134
- this.#panelEl = el;
108
+ _onPanelAttached() {
135
109
  if (this.#state === "opening") {
136
110
  this.#finishOpen();
137
111
  }
@@ -139,81 +113,114 @@ export class PopoverController {
139
113
  this.#compute();
140
114
  }
141
115
  }
142
- detachTrigger() {
143
- this.#triggerEl = null;
144
- }
145
- detachPanel() {
146
- this.#panelEl = null;
147
- }
148
116
  // ─── Core actions ─────────────────────────────────────────────────────────────
149
117
  open() {
150
- if (this.#isOpen || this.#state === "opening")
118
+ if (this.isOpen || this.#state === "opening")
151
119
  return;
152
120
  this.#cancelClose();
121
+ this.hooks.beforeOpen?.();
153
122
  // Set 'opening' FIRST → isVisible becomes true → Svelte mounts the panel
154
123
  // → $effect fires → attachPanel() → #finishOpen().
155
- this.#setState(false, "opening");
124
+ this.#setState("opening");
156
125
  // Headless usage (or any consumer that keeps the panel element in the
157
126
  // DOM at all times instead of conditionally mounting it) already has
158
127
  // #panelEl set at this point — no attachPanel() call is coming to
159
128
  // finish the sequence, so finish it here instead. This makes open()
160
129
  // correct regardless of DOM-mounting strategy.
161
- if (this.#panelEl)
130
+ if (this._panelEl)
162
131
  this.#finishOpen();
163
132
  }
164
133
  close() {
165
- 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")
166
146
  return;
167
147
  this.#cancelFocusTrapTimer();
168
- this.#setState(false, "closing");
169
- this.#teardownListeners();
148
+ this.#setState("closing");
149
+ this._teardownSharedListeners();
150
+ this.#teardownPositionListeners();
170
151
  this.#closeTimer = setTimeout(() => {
171
152
  if (this.#state === "closing") {
172
- this.#setState(false, "closed");
153
+ this.#setState("closed");
173
154
  this.#pos = null;
174
155
  announce("Popover closed");
175
- this.#onClose?.();
156
+ this.onClose?.();
176
157
  }
177
158
  }, ANIMATION_DURATION_MS);
178
159
  }
179
- toggle() {
180
- this.#isOpen ? this.close() : this.open();
181
- }
182
160
  setPlacement(p) {
183
- this.#placement = p;
184
- if (this.#state === "open")
185
- 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
+ }
186
180
  }
187
181
  // ─── Destroy ──────────────────────────────────────────────────────────────────
188
182
  destroy() {
189
183
  this.#cancelFocusTrapTimer();
190
184
  this.#cancelClose();
191
- this.#cancelRaf();
192
- this.#teardownListeners();
193
- this.#subscribers.clear();
194
- this.#triggerEl = null;
195
- 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);
196
193
  }
197
194
  // ─── Private: open sequence ───────────────────────────────────────────────────
198
195
  /**
199
- * Called from attachPanel() when state === 'opening'.
196
+ * Called from _onPanelAttached() when state === 'opening'.
200
197
  * At this point the panel element is guaranteed to be in the DOM.
201
198
  */
202
199
  #finishOpen() {
203
200
  this.#compute(); // position panel (reads real dimensions)
204
- this.#setupListeners(); // wire click-outside, Esc, scroll, resize
205
- 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");
206
204
  announce("Popover opened");
207
- this.#onOpen?.();
205
+ this.onOpen?.();
208
206
  }
209
207
  // ─── Private: positioning ─────────────────────────────────────────────────────
210
- #compute() {
211
- 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)
212
216
  return;
213
217
  // Pass the previous resolved placement for flip hysteresis — keeps the
214
218
  // panel on whichever side it's already showing on when that side still
215
- // fits, instead of flip-flopping right at the viewport boundary.
216
- const result = computePosition(this.#triggerEl, this.#panelEl, this.#placement, this.#offset, this.#resolvedPlacement);
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);
217
224
  this.#pos = result;
218
225
  this.#resolvedPlacement = result.placement;
219
226
  }
@@ -224,11 +231,11 @@ export class PopoverController {
224
231
  this.#rafId = null;
225
232
  // Guaranteed 'open' in practice — the scroll/resize/ResizeObserver
226
233
  // listeners that call this are only ever wired up in
227
- // #setupListeners() (called from #finishOpen()) and torn down
228
- // synchronously the moment close() runs, so no stale rAF can land
229
- // here mid-'closing'. Checked anyway as defense-in-depth against
230
- // exactly the class of "recomputed mid-exit-animation" bug that
231
- // causes a panel to visibly jump/flip while fading out.
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.
232
239
  if (this.#state === "open")
233
240
  this.#compute();
234
241
  });
@@ -239,32 +246,10 @@ export class PopoverController {
239
246
  this.#rafId = null;
240
247
  }
241
248
  }
242
- // ─── Private: listeners ───────────────────────────────────────────────────────
243
- #setupListeners() {
244
- const trigger = this.#triggerEl;
245
- const panel = this.#panelEl;
246
- // Click outside — uses pointerdown capture phase.
247
- // Does NOT call preventDefault/stopPropagation → background elements stay
248
- // fully interactive. The popover simply closes on the next tick.
249
- if (this.#closeOnOutsideClick && trigger && panel) {
250
- this.#clickOutsideStop = onClickOutside([trigger, panel], () => this.close());
251
- }
252
- // Esc key
253
- if (this.#closeOnEsc) {
254
- const handler = (e) => {
255
- if (e.key === "Escape") {
256
- e.stopPropagation();
257
- this.close();
258
- }
259
- };
260
- document.addEventListener("keydown", handler, true);
261
- this.#escStop = () => document.removeEventListener("keydown", handler, true);
262
- }
263
- // Focus trap — delayed so it activates after enter animation completes
264
- if (this.#trapFocus && panel) {
265
- this.#focusTrap = createFocusTrap(panel, trigger ?? undefined);
266
- this.#focusTrapTimer = setTimeout(() => this.#focusTrap?.activate(), ANIMATION_DURATION_MS);
267
- }
249
+ // ─── Private: positioning-only listeners ─────────────────────────────────────
250
+ #setupPositionListeners() {
251
+ const trigger = this._triggerEl;
252
+ const panel = this._panelEl;
268
253
  // Scroll + resize → rAF-coalesced recompute (max one per frame)
269
254
  const recompute = () => this.#scheduleCompute();
270
255
  window.addEventListener("scroll", recompute, { passive: true, capture: true });
@@ -281,25 +266,18 @@ export class PopoverController {
281
266
  this.#resizeObs.observe(panel);
282
267
  }
283
268
  }
284
- #teardownListeners() {
285
- this.#focusTrap?.deactivate();
286
- this.#clickOutsideStop?.();
269
+ #teardownPositionListeners() {
287
270
  this.#scrollResizeStop?.();
288
- this.#escStop?.();
289
271
  this.#resizeObs?.disconnect();
290
272
  this.#cancelRaf();
291
- this.#focusTrap = null;
292
- this.#clickOutsideStop = null;
293
273
  this.#scrollResizeStop = null;
294
- this.#escStop = null;
295
274
  this.#resizeObs = null;
296
275
  }
297
276
  // ─── Private: state ───────────────────────────────────────────────────────────
298
- #setState(isOpen, state) {
299
- this.#isOpen = isOpen;
277
+ #setState(state) {
300
278
  this.#state = state;
301
- this.#onStateChange?.(state);
302
- this.#emit();
279
+ this.onStateChange?.(state);
280
+ this._emit();
303
281
  }
304
282
  #cancelFocusTrapTimer() {
305
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
+ }
@@ -19,6 +19,7 @@
19
19
  * bordered triangle that matches the card's border colour.
20
20
  */
21
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>
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import type { Snippet } from 'svelte';
19
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
package/dist/index.d.ts CHANGED
@@ -21,11 +21,38 @@ export interface ResolvedPosition {
21
21
  }
22
22
  /** Fine-grained lifecycle state — drives CSS enter/exit transitions. */
23
23
  export type PopoverState = "closed" | "opening" | "open" | "closing";
24
+ /**
25
+ * Optional lifecycle hooks, scoped under `hooks` so the option list doesn't
26
+ * balloon as more get added. Start minimal: just `beforeOpen`. Signature is
27
+ * `void`-returning (fire-and-forget) for now — widening it to
28
+ * `void | Promise<void>` later (e.g. to await a data fetch before opening)
29
+ * is a non-breaking change for every existing synchronous hook.
30
+ */
31
+ export interface OverlayHooks {
32
+ /** Called right before the overlay opens (before any DOM/listener setup). */
33
+ beforeOpen?: () => void;
34
+ }
35
+ export interface BaseOverlayOptions {
36
+ /** ID prefix for generated ARIA ids. Auto-generated if omitted. */
37
+ id?: string;
38
+ trapFocus?: boolean;
39
+ closeOnOutsideClick?: boolean;
40
+ closeOnEsc?: boolean;
41
+ hooks?: OverlayHooks;
42
+ onOpen?: () => void;
43
+ onClose?: () => void;
44
+ }
45
+ export interface AttachableController {
46
+ attachTrigger(el: HTMLElement): void;
47
+ detachTrigger(): void;
48
+ attachPanel(el: HTMLElement): void;
49
+ detachPanel(): void;
50
+ }
24
51
  /**
25
52
  * Public surface of PopoverController.
26
53
  * All readonly getters are $state runes — Svelte templates track them.
27
54
  */
28
- export interface IPopoverController {
55
+ export interface IPopoverController extends AttachableController {
29
56
  readonly isOpen: boolean;
30
57
  readonly state: PopoverState;
31
58
  readonly resolvedPlacement: Placement;
@@ -38,10 +65,6 @@ export interface IPopoverController {
38
65
  close(): void;
39
66
  toggle(): void;
40
67
  setPlacement(p: Placement): void;
41
- attachTrigger(el: HTMLElement): void;
42
- attachPanel(el: HTMLElement): void;
43
- detachTrigger(): void;
44
- detachPanel(): void;
45
68
  getSnapshot(): PopoverSnapshot;
46
69
  subscribe(cb: SubscriberFn): UnsubscribeFn;
47
70
  destroy(): void;
@@ -55,18 +78,11 @@ export interface PopoverSnapshot {
55
78
  }
56
79
  export type SubscriberFn = (snap: PopoverSnapshot) => void;
57
80
  export type UnsubscribeFn = () => void;
58
- export interface ControllerOptions {
59
- /** ID prefix for generated ARIA ids. Auto-generated if omitted. */
60
- id?: string;
81
+ export interface ControllerOptions extends BaseOverlayOptions {
61
82
  /** Preferred placement. Default: `bottom`. */
62
83
  placement?: Placement;
63
84
  /** Gap between trigger and panel in px. Default: 8. */
64
85
  offset?: number;
65
- trapFocus?: boolean;
66
- closeOnOutsideClick?: boolean;
67
- closeOnEsc?: boolean;
68
- onOpen?: () => void;
69
- onClose?: () => void;
70
86
  onStateChange?: (state: PopoverState) => void;
71
87
  }
72
88
  export type PopoverAnimation = "scale" | "slide" | "fade" | "none";
@@ -122,32 +138,115 @@ export interface ThemeTokens {
122
138
  darkText: string;
123
139
  accentColor: string;
124
140
  }
125
- export declare class PopoverController implements IPopoverController {
126
- #private;
141
+ export interface DropdownControllerOptions extends BaseOverlayOptions {
142
+ /** Fires on every open AND close, with the new `isOpen` value. For "run
143
+ * this only when opening" (e.g. refresh data), prefer `onOpen` (inherited
144
+ * from `BaseOverlayOptions`) or `hooks.beforeOpen`, both of which only
145
+ * fire on open. */
146
+ onStateChange?: (isOpen: boolean) => void;
147
+ }
148
+ export interface DropdownSnapshot {
149
+ readonly isOpen: boolean;
150
+ readonly triggerId: string;
151
+ readonly contentId: string;
152
+ }
153
+ export interface IDropdownController extends AttachableController {
154
+ readonly isOpen: boolean;
127
155
  readonly triggerId: string;
128
156
  readonly contentId: string;
129
- constructor(opts?: ControllerOptions);
157
+ open(): void;
158
+ close(): void;
159
+ toggle(): void;
160
+ getSnapshot(): DropdownSnapshot;
161
+ subscribe(cb: (snap: DropdownSnapshot) => void): UnsubscribeFn;
162
+ destroy(): void;
163
+ }
164
+ /**
165
+ * Keeps keyboard focus inside `container` while active.
166
+ * Activation is deferred so it can be timed to the enter animation.
167
+ *
168
+ * @param container - The panel element to trap focus within.
169
+ * @param returnTo - Element that receives focus when the trap deactivates.
170
+ */
171
+ export declare function createFocusTrap(container: HTMLElement, returnTo?: HTMLElement): FocusTrapHandle;
172
+ /** Announce a message to screen readers via the shared polite live region. */
173
+ export declare function announce(message: string, delayMs?: number): void;
174
+ /**
175
+ * Calls `callback` when a pointer-down event occurs outside all `elements`.
176
+ * Uses capture phase so stopPropagation inside the panel cannot suppress it.
177
+ * Returns a cleanup function.
178
+ */
179
+ export declare function onClickOutside(elements: HTMLElement[], callback: (e: PointerEvent) => void): () => void;
180
+ export declare abstract class OverlayControllerBase<TOptions extends BaseOverlayOptions, TSnapshot> implements AttachableController {
181
+ #private;
182
+ protected _options: TOptions;
183
+ /** Fallback id, generated once — NOT regenerated on every `id` read. */
184
+ protected readonly _generatedId: string;
185
+ protected _triggerEl: HTMLElement | null;
186
+ protected _panelEl: HTMLElement | null;
187
+ protected _focusTrap: ReturnType<typeof createFocusTrap> | null;
188
+ protected _clickOutsideStop: (() => void) | null;
189
+ protected _escStop: (() => void) | null;
190
+ constructor(options: TOptions, idPrefix: string);
191
+ get id(): string;
192
+ get triggerId(): string;
193
+ get contentId(): string;
194
+ get trapFocus(): boolean;
195
+ get closeOnOutsideClick(): boolean;
196
+ get closeOnEsc(): boolean;
197
+ get hooks(): OverlayHooks;
198
+ get onOpen(): (() => void) | undefined;
199
+ get onClose(): (() => void) | undefined;
200
+ abstract get isOpen(): boolean;
201
+ abstract open(): void;
202
+ abstract close(): void;
203
+ abstract getSnapshot(): TSnapshot;
204
+ /** Called from attachPanel() once the panel element is known. Each
205
+ * subclass decides what "the panel is available" means for its own
206
+ * open sequence (finish an in-progress open, or just recompute). */
207
+ protected abstract _onPanelAttached(el: HTMLElement): void;
208
+ toggle(): void;
209
+ attachTrigger(el: HTMLElement): void;
210
+ detachTrigger(): void;
211
+ attachPanel(el: HTMLElement): void;
212
+ detachPanel(): void;
213
+ subscribe(cb: (snap: TSnapshot) => void): () => void;
214
+ protected _emit(): void;
215
+ destroy(): void;
216
+ protected _setupSharedListeners(): void;
217
+ /** Extension point: PopoverController delays activation until its enter
218
+ * animation settles; DropdownController (no such animation) activates
219
+ * immediately. Overridden, not parameterized, since the two timings
220
+ * aren't expressible as a simple option without leaking animation
221
+ * details into the base class. */
222
+ protected _activateFocusTrap(): void;
223
+ protected _teardownSharedListeners(): void;
224
+ }
225
+ export declare class PopoverController extends OverlayControllerBase<ControllerOptions, PopoverSnapshot> implements IPopoverController {
226
+ #private;
227
+ constructor(options?: ControllerOptions);
228
+ get placement(): Placement;
229
+ get offset(): number;
230
+ get onStateChange(): ((s: PopoverState) => void) | undefined;
231
+ /** Derived from `state`, not a separately-tracked flag — there is exactly one
232
+ * state ('open') that means "open", so there's nothing to keep in sync. */
130
233
  get isOpen(): boolean;
131
234
  get state(): PopoverState;
132
235
  get resolvedPlacement(): Placement;
133
236
  get position(): ResolvedPosition | null;
134
237
  get isVisible(): boolean;
135
238
  getSnapshot(): PopoverSnapshot;
136
- subscribe(cb: SubscriberFn): UnsubscribeFn;
137
- attachTrigger(el: HTMLElement): void;
138
239
  /**
139
- * Called by Popover.svelte's $effect after the panel element mounts.
240
+ * Called by the base class's attachPanel() after the panel element mounts.
140
241
  * If state is 'opening', we're in the open sequence → finish it now.
141
242
  * If state is already 'open', controller was re-attached → just recompute.
142
243
  */
143
- attachPanel(el: HTMLElement): void;
144
- detachTrigger(): void;
145
- detachPanel(): void;
244
+ protected _onPanelAttached(): void;
146
245
  open(): void;
147
246
  close(): void;
148
- toggle(): void;
149
247
  setPlacement(p: Placement): void;
150
248
  destroy(): void;
249
+ protected _activateFocusTrap(): void;
151
250
  }
152
251
  export interface Props extends PopoverProps {
153
252
  trigger?: Snippet<[
@@ -223,6 +322,26 @@ interface Props$4 {
223
322
  }
224
323
  export declare const PopoverArrow: import("svelte").Component<Props$4, {}, "">;
225
324
  export type PopoverArrow = ReturnType<typeof PopoverArrow>;
325
+ export declare class DropdownController extends OverlayControllerBase<DropdownControllerOptions, DropdownSnapshot> implements IDropdownController {
326
+ #private;
327
+ constructor(options?: DropdownControllerOptions);
328
+ get isOpen(): boolean;
329
+ get onStateChange(): ((isOpen: boolean) => void) | undefined;
330
+ protected _onPanelAttached(): void;
331
+ open(): void;
332
+ close(): void;
333
+ getSnapshot(): DropdownSnapshot;
334
+ }
335
+ export interface ActionReturn<T> {
336
+ update?(next: T): void;
337
+ destroy?(): void;
338
+ }
339
+ /** Wires an element as a controller's trigger. Attaches on mount, detaches on
340
+ * destroy, re-wires if the controller instance itself changes. */
341
+ export declare const overlayTrigger: (node: HTMLElement, controller: AttachableController) => ActionReturn<AttachableController>;
342
+ /** Wires an element as a controller's panel. Attaches on mount, detaches on
343
+ * destroy, re-wires if the controller instance itself changes. */
344
+ export declare const overlayPanel: (node: HTMLElement, controller: AttachableController) => ActionReturn<AttachableController>;
226
345
  /** Split "bottom-start" → { side: 'bottom', alignment: 'start' } */
227
346
  export declare function parsePlacement(p: Placement): {
228
347
  side: Side;
@@ -246,22 +365,6 @@ export declare function getDirection(el: HTMLElement): "ltr" | "rtl";
246
365
  * @returns { x, y, placement } — coordinates and the actual placement used.
247
366
  */
248
367
  export declare function computePosition(triggerEl: HTMLElement, panelEl: HTMLElement, placement: Placement, offset: number, previousPlacement?: Placement | null): ResolvedPosition;
249
- /**
250
- * Keeps keyboard focus inside `container` while active.
251
- * Activation is deferred so it can be timed to the enter animation.
252
- *
253
- * @param container - The panel element to trap focus within.
254
- * @param returnTo - Element that receives focus when the trap deactivates.
255
- */
256
- export declare function createFocusTrap(container: HTMLElement, returnTo?: HTMLElement): FocusTrapHandle;
257
- /** Announce a message to screen readers via the shared polite live region. */
258
- export declare function announce(message: string, delayMs?: number): void;
259
- /**
260
- * Calls `callback` when a pointer-down event occurs outside all `elements`.
261
- * Uses capture phase so stopPropagation inside the panel cannot suppress it.
262
- * Returns a cleanup function.
263
- */
264
- export declare function onClickOutside(elements: HTMLElement[], callback: (e: PointerEvent) => void): () => void;
265
368
  export declare const DEFAULT_OPTIONS: {
266
369
  readonly placement: "bottom";
267
370
  readonly offset: 8;
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- import{default as Q}from"./components/Popover.svelte";import{default as tt}from"./components/PopoverTrigger.svelte";import{default as ot}from"./components/PopoverContent.svelte";import{default as rt}from"./elements/PopoverPanel.svelte";import{default as st}from"./elements/PopoverArrow.svelte";import{PopoverController as ut}from"./controller.svelte.js";function b(t){if(t==="autoVertical")return{side:"bottom",alignment:null};let[n,o=null]=t.split("-");return{side:n,alignment:o}}function D(t,n,o,i,s,c,a){let r,e;switch(i){case"bottom":e=t.bottom+c;break;case"top":e=t.top-o-c;break;case"left":r=t.left-n-c;break;case"right":r=t.right+c;break}if(i==="top"||i==="bottom"){let u=a?t.right-n:t.left,d=a?t.left:t.right-n;s==="start"?r=u:s==="end"?r=d:r=t.left+(t.width-n)/2}else s==="start"?e=t.top:s==="end"?e=t.bottom-o:e=t.top+(t.height-o)/2;return{x:r,y:e}}function v(t){return getComputedStyle(t).direction==="rtl"?"rtl":"ltr"}function T(t,n,o,i=6){return Math.max(i,Math.min(o-n-i,t))}var I={top:"bottom",bottom:"top",left:"right",right:"left"};function F(t,n,o,i,s,c,a,r=0){switch(t){case"bottom":return n.bottom+s+o+r<=a;case"top":return n.top-s-o-r>=0;case"right":return n.right+s+i+r<=c;case"left":return n.left-s-i-r>=0}}var S=24;function k(t,n,o,i,s,c,a,r){let e=I[t],u=(d,p=0)=>F(d,n,o,i,s,c,a,p);return r===e?u(t,S)?t:u(e)?e:t:u(t)?t:u(e)?e:r??t}function C(t,n,o,i,s){let c=t.getBoundingClientRect(),a=n.offsetWidth,r=n.offsetHeight,e=window.innerWidth,u=window.innerHeight,d=v(t)==="rtl",{side:p,alignment:f}=b(o),L=s?b(s).side:null,m=k(p,c,r,a,i,e,u,L),O=o==="autoVertical"?m==="top"?"top":"bottom":m===p?o:f?`${m}-${f}`:m,{x:A,y:h}=D(c,a,r,m,f,i,d);return m==="top"||m==="bottom"?A=T(A,a,e):h=T(h,r,u),{x:A,y:h,placement:O}}var N={placement:"bottom",offset:8,trapFocus:!0,closeOnOutsideClick:!0,closeOnEsc:!0},P="scale",U="click",w=0,y=100,M=!1,B=200,x=["a[href]","button:not([disabled])","input:not([disabled])","select:not([disabled])","textarea:not([disabled])",'[tabindex]:not([tabindex="-1"])','[contenteditable="true"]',"details > summary"].join(", "),E="pop-live-region",_={borderRadius:"0.75rem",backdropBlur:"12px",shadowColor:"rgba(0,0,0,0.15)",lightBg:"oklch(var(--color-base-100))",darkBg:"oklch(var(--color-base-200))",lightBorder:"oklch(var(--color-base-300))",darkBorder:"oklch(var(--color-base-300))",lightText:"oklch(var(--color-base-content))",darkText:"oklch(var(--color-base-content))",accentColor:"oklch(var(--color-primary))"},G=0;function R(t="pop"){return`${t}-${++G}`}function K(t,n){let o=!1,i=document.activeElement;function s(){return Array.from(t.querySelectorAll(x)).filter(e=>!e.hasAttribute("disabled")&&e.offsetParent!==null)}function c(e){if(!o||e.key!=="Tab")return;let u=s();if(!u.length){e.preventDefault();return}let d=u[0],p=u[u.length-1],f=document.activeElement;e.shiftKey?(f===d||!t.contains(f))&&(e.preventDefault(),p.focus()):(f===p||!t.contains(f))&&(e.preventDefault(),d.focus())}function a(){if(o)return;o=!0,i=document.activeElement,document.addEventListener("keydown",c,!0);let e=s()[0]??t;requestAnimationFrame(()=>e.focus())}function r(){if(!o)return;o=!1,document.removeEventListener("keydown",c,!0);let e=n??i;e&&typeof e.focus=="function"&&requestAnimationFrame(()=>e.focus())}return{activate:a,deactivate:r,destroy:r}}var l=null;function g(){return l||(l=document.getElementById(E),l||(l=document.createElement("div"),l.id=E,l.setAttribute("aria-live","polite"),l.setAttribute("aria-atomic","true"),Object.assign(l.style,{position:"absolute",width:"1px",height:"1px",overflow:"hidden",clip:"rect(0 0 0 0)",whiteSpace:"nowrap",border:"0",padding:"0",margin:"-1px"}),document.body.appendChild(l)),l)}function H(t,n=80){let o=g();o.textContent="",setTimeout(()=>{o.textContent=t},n)}function V(t,n){function o(i){let s=i.target;t.some(c=>c.contains(s))||n(i)}return document.addEventListener("pointerdown",o,!0),()=>document.removeEventListener("pointerdown",o,!0)}function X(t={}){return{defaults:t.defaults??{},tokens:{..._,...t.theme}}}export{B as ANIMATION_DURATION_MS,P as DEFAULT_ANIMATION,y as DEFAULT_CLOSE_DELAY,w as DEFAULT_OPEN_DELAY,N as DEFAULT_OPTIONS,M as DEFAULT_SHOW_ARROW,_ as DEFAULT_THEME_TOKENS,U as DEFAULT_TRIGGER,x as FOCUSABLE_SELECTOR,E as LIVE_REGION_ID,Q as Popover,st as PopoverArrow,ot as PopoverContent,ut as PopoverController,rt as PopoverPanel,tt as PopoverTrigger,H as announce,C as computePosition,K as createFocusTrap,X as createPopoverPlugin,v as getDirection,V as onClickOutside,b as parsePlacement,R as uid};
1
+ import{default as ot}from"./components/Popover.svelte";import{default as rt}from"./components/PopoverTrigger.svelte";import{default as st}from"./components/PopoverContent.svelte";import{default as ct}from"./elements/PopoverPanel.svelte";import{default as lt}from"./elements/PopoverArrow.svelte";import{PopoverController as ft}from"./controller.svelte.js";import{DropdownController as mt}from"./dropdown-controller.svelte.js";import{OverlayControllerBase as At}from"./overlay-controller-base.svelte.js";function b(t,e){return(o,r)=>(t(r,o),{update(i){i!==r&&(e(r),r=i,t(r,o))},destroy(){e(r)}})}var I=b((t,e)=>t.attachTrigger(e),t=>t.detachTrigger()),F=b((t,e)=>t.attachPanel(e),t=>t.detachPanel());function x(t){if(t==="autoVertical")return{side:"bottom",alignment:null};let[e,o=null]=t.split("-");return{side:e,alignment:o}}function S(t,e,o,r,i,a,u){let s,n;switch(r){case"bottom":n=t.bottom+a;break;case"top":n=t.top-o-a;break;case"left":s=t.left-e-a;break;case"right":s=t.right+a;break}if(r==="top"||r==="bottom"){let c=u?t.right-e:t.left,d=u?t.left:t.right-e;i==="start"?s=c:i==="end"?s=d:s=t.left+(t.width-e)/2}else i==="start"?n=t.top:i==="end"?n=t.bottom-o:n=t.top+(t.height-o)/2;return{x:s,y:n}}function O(t){return getComputedStyle(t).direction==="rtl"?"rtl":"ltr"}function _(t,e,o,r=6){return Math.max(r,Math.min(o-e-r,t))}var P={top:"bottom",bottom:"top",left:"right",right:"left"};function y(t,e,o,r,i,a,u,s=0){switch(t){case"bottom":return e.bottom+i+o+s<=u;case"top":return e.top-i-o-s>=0;case"right":return e.right+i+r+s<=a;case"left":return e.left-i-r-s>=0}}var C=24;function k(t,e,o,r,i,a,u,s){let n=P[t],c=(d,p=0)=>y(d,e,o,r,i,a,u,p);return s===n?c(t,C)?t:c(n)?n:t:c(t)?t:c(n)?n:s??t}function N(t,e,o,r,i){let a=t.getBoundingClientRect(),u=e.offsetWidth,s=e.offsetHeight,n=window.innerWidth,c=window.innerHeight,d=O(t)==="rtl",{side:p,alignment:f}=x(o),L=i?x(i).side:null,m=k(p,a,s,u,r,n,c,L),D=o==="autoVertical"?m==="top"?"top":"bottom":m===p?o:f?`${m}-${f}`:m,{x:A,y:h}=S(a,u,s,m,f,r,d);return m==="top"||m==="bottom"?A=_(A,u,n):h=_(h,s,c),{x:A,y:h,placement:D}}var w={placement:"bottom",offset:8,trapFocus:!0,closeOnOutsideClick:!0,closeOnEsc:!0},U="scale",g="click",B=0,M=100,G=!1,R=200,v=["a[href]","button:not([disabled])","input:not([disabled])","select:not([disabled])","textarea:not([disabled])",'[tabindex]:not([tabindex="-1"])','[contenteditable="true"]',"details > summary"].join(", "),E="pop-live-region",T={borderRadius:"0.75rem",backdropBlur:"12px",shadowColor:"rgba(0,0,0,0.15)",lightBg:"oklch(var(--color-base-100))",darkBg:"oklch(var(--color-base-200))",lightBorder:"oklch(var(--color-base-300))",darkBorder:"oklch(var(--color-base-300))",lightText:"oklch(var(--color-base-content))",darkText:"oklch(var(--color-base-content))",accentColor:"oklch(var(--color-primary))"},K=0;function H(t="pop"){return`${t}-${++K}`}function V(t,e){let o=!1,r=document.activeElement;function i(){return Array.from(t.querySelectorAll(v)).filter(n=>!n.hasAttribute("disabled")&&n.offsetParent!==null)}function a(n){if(!o||n.key!=="Tab")return;let c=i();if(!c.length){n.preventDefault();return}let d=c[0],p=c[c.length-1],f=document.activeElement;n.shiftKey?(f===d||!t.contains(f))&&(n.preventDefault(),p.focus()):(f===p||!t.contains(f))&&(n.preventDefault(),d.focus())}function u(){if(o)return;o=!0,r=document.activeElement,document.addEventListener("keydown",a,!0);let n=i()[0]??t;requestAnimationFrame(()=>n.focus())}function s(){if(!o)return;o=!1,document.removeEventListener("keydown",a,!0);let n=e??r;n&&typeof n.focus=="function"&&requestAnimationFrame(()=>n.focus())}return{activate:u,deactivate:s,destroy:s}}var l=null;function Y(){return l||(l=document.getElementById(E),l||(l=document.createElement("div"),l.id=E,l.setAttribute("aria-live","polite"),l.setAttribute("aria-atomic","true"),Object.assign(l.style,{position:"absolute",width:"1px",height:"1px",overflow:"hidden",clip:"rect(0 0 0 0)",whiteSpace:"nowrap",border:"0",padding:"0",margin:"-1px"}),document.body.appendChild(l)),l)}function q(t,e=80){let o=Y();o.textContent="",setTimeout(()=>{o.textContent=t},e)}function $(t,e){function o(r){let i=r.target;t.some(a=>a.contains(i))||e(r)}return document.addEventListener("pointerdown",o,!0),()=>document.removeEventListener("pointerdown",o,!0)}function Z(t={}){return{defaults:t.defaults??{},tokens:{...T,...t.theme}}}export{R as ANIMATION_DURATION_MS,U as DEFAULT_ANIMATION,M as DEFAULT_CLOSE_DELAY,B as DEFAULT_OPEN_DELAY,w as DEFAULT_OPTIONS,G as DEFAULT_SHOW_ARROW,T as DEFAULT_THEME_TOKENS,g as DEFAULT_TRIGGER,mt as DropdownController,v as FOCUSABLE_SELECTOR,E as LIVE_REGION_ID,At as OverlayControllerBase,ot as Popover,lt as PopoverArrow,st as PopoverContent,ft as PopoverController,ct as PopoverPanel,rt as PopoverTrigger,q as announce,N as computePosition,V as createFocusTrap,Z as createPopoverPlugin,O as getDirection,$ as onClickOutside,F as overlayPanel,I as overlayTrigger,x as parsePlacement,H as uid};
@@ -0,0 +1,176 @@
1
+ // =============================================================================
2
+ // Popover Micro-Plugin — OverlayControllerBase
3
+ //
4
+ // File MUST be *.svelte.ts — Svelte 5 compiles $state runes only in .svelte.ts.
5
+ //
6
+ // Shared logic between PopoverController and DropdownController, so the two
7
+ // don't duplicate: id generation, options-as-getters, DOM ref storage,
8
+ // subscribe/emit, and — the biggest chunk — click-outside + Escape + focus
9
+ // trap wiring/teardown.
10
+ //
11
+ // What's NOT here, deliberately: `isOpen`, `open()`, `close()`, and
12
+ // `getSnapshot()` are abstract. PopoverController's "open" is a multi-phase
13
+ // sequence (needs panel dimensions, positions the panel, waits out an exit
14
+ // animation before fully closing); DropdownController's is a single
15
+ // synchronous boolean flip. Forcing both through one concrete open()/close()
16
+ // would mean one of them fighting the other's assumptions. What genuinely IS
17
+ // identical — the a11y wiring — lives here as `_setupSharedListeners()` /
18
+ // `_teardownSharedListeners()`, which each subclass calls at the point in
19
+ // its own open/close sequence where "the panel is now in the DOM and about
20
+ // to be interactive" / "the panel is no longer interactive" respectively.
21
+ //
22
+ // Uses `protected` (not `#private`) fields for anything a subclass needs to
23
+ // reach — JS `#private` fields are lexically scoped to the declaring class
24
+ // and literally cannot be accessed from a subclass, even via `super`.
25
+ // =============================================================================
26
+ import { createFocusTrap, onClickOutside } from "./utils/a11y.js";
27
+ import { uid, DEFAULT_OPTIONS } from "./constants.js";
28
+ export class OverlayControllerBase {
29
+ // ─── Options — a plain object, deliberately NOT a rune ───────────────────────
30
+ //
31
+ // The controller doesn't own a reactivity model for its config — it just
32
+ // reads whatever `_options` currently holds through the getters below. If
33
+ // a caller wants a particular option to stay live (e.g. a Svelte
34
+ // component passing a reactive prop through), they pass a *getter* for
35
+ // it in the options object, backed by their own $state:
36
+ //
37
+ // const myPlacement = $state('bottom');
38
+ // new PopoverController({ get placement() { return myPlacement; } });
39
+ //
40
+ // Reading `this._options.placement` inside a tracked context (a template,
41
+ // a $derived) then invokes the caller's getter and correctly registers a
42
+ // dependency on THEIR $state — no rune needed on `_options` itself. This
43
+ // is the same reason `#pos`/`#resolvedPlacement`/`#state`/`#isOpen`
44
+ // staying real $state fields in the subclasses is what actually drives
45
+ // template reactivity for position/open-state; `_options` reassignment
46
+ // (e.g. setPlacement()) only needs to affect the NEXT read of a getter,
47
+ // not be tracked as a signal itself.
48
+ _options;
49
+ /** Fallback id, generated once — NOT regenerated on every `id` read. */
50
+ _generatedId;
51
+ // ─── DOM refs ─────────────────────────────────────────────────────────────────
52
+ _triggerEl = null;
53
+ _panelEl = null;
54
+ // ─── Lazy a11y resources (null while closed) ─────────────────────────────────
55
+ _focusTrap = null;
56
+ _clickOutsideStop = null;
57
+ _escStop = null;
58
+ // ─── Headless subscribers ───────────────────────────────────────────────────
59
+ #subscribers = new Set();
60
+ constructor(options, idPrefix) {
61
+ this._generatedId = uid(idPrefix);
62
+ this._options = options;
63
+ }
64
+ // ─── Shared option getters ────────────────────────────────────────────────────
65
+ get id() {
66
+ return this._options.id ?? this._generatedId;
67
+ }
68
+ get triggerId() {
69
+ return `${this.id}-trigger`;
70
+ }
71
+ get contentId() {
72
+ return `${this.id}-content`;
73
+ }
74
+ get trapFocus() {
75
+ return this._options.trapFocus ?? DEFAULT_OPTIONS.trapFocus;
76
+ }
77
+ get closeOnOutsideClick() {
78
+ return this._options.closeOnOutsideClick ?? DEFAULT_OPTIONS.closeOnOutsideClick;
79
+ }
80
+ get closeOnEsc() {
81
+ return this._options.closeOnEsc ?? DEFAULT_OPTIONS.closeOnEsc;
82
+ }
83
+ get hooks() {
84
+ return this._options.hooks ?? {};
85
+ }
86
+ get onOpen() {
87
+ return this._options.onOpen;
88
+ }
89
+ get onClose() {
90
+ return this._options.onClose;
91
+ }
92
+ toggle() {
93
+ this.isOpen ? this.close() : this.open();
94
+ }
95
+ // ─── Element wiring ───────────────────────────────────────────────────────────
96
+ attachTrigger(el) {
97
+ this._triggerEl = el;
98
+ }
99
+ detachTrigger() {
100
+ this._triggerEl = null;
101
+ }
102
+ attachPanel(el) {
103
+ this._panelEl = el;
104
+ this._onPanelAttached(el);
105
+ }
106
+ detachPanel() {
107
+ this._panelEl = null;
108
+ }
109
+ // ─── Subscriptions ────────────────────────────────────────────────────────────
110
+ subscribe(cb) {
111
+ this.#subscribers.add(cb);
112
+ cb(this.getSnapshot());
113
+ return () => this.#subscribers.delete(cb);
114
+ }
115
+ _emit() {
116
+ if (!this.#subscribers.size)
117
+ return;
118
+ const snap = this.getSnapshot();
119
+ this.#subscribers.forEach((cb) => cb(snap));
120
+ }
121
+ // ─── Destroy ──────────────────────────────────────────────────────────────────
122
+ destroy() {
123
+ this._teardownSharedListeners();
124
+ this.#subscribers.clear();
125
+ this._triggerEl = null;
126
+ this._panelEl = null;
127
+ }
128
+ // ─── Shared a11y wiring (click-outside / Escape / focus trap) ─────────────────
129
+ _setupSharedListeners() {
130
+ const trigger = this._triggerEl;
131
+ const panel = this._panelEl;
132
+ if (!panel)
133
+ return;
134
+ // Idempotent: if this is called a second time while already wired up
135
+ // (e.g. the panel element is re-attached while still open — a keyed
136
+ // #each remount, or a consumer calling attachPanel() again), tear
137
+ // down the previous listeners first. Without this, the old
138
+ // _clickOutsideStop/_escStop/_focusTrap references are silently
139
+ // overwritten and can never be cleaned up — a real listener leak.
140
+ this._teardownSharedListeners();
141
+ if (this.closeOnOutsideClick) {
142
+ const watch = trigger ? [trigger, panel] : [panel];
143
+ this._clickOutsideStop = onClickOutside(watch, () => this.close());
144
+ }
145
+ if (this.closeOnEsc) {
146
+ const handler = (e) => {
147
+ if (e.key === "Escape") {
148
+ e.stopPropagation();
149
+ this.close();
150
+ }
151
+ };
152
+ document.addEventListener("keydown", handler, true);
153
+ this._escStop = () => document.removeEventListener("keydown", handler, true);
154
+ }
155
+ if (this.trapFocus) {
156
+ this._focusTrap = createFocusTrap(panel, trigger ?? undefined);
157
+ this._activateFocusTrap();
158
+ }
159
+ }
160
+ /** Extension point: PopoverController delays activation until its enter
161
+ * animation settles; DropdownController (no such animation) activates
162
+ * immediately. Overridden, not parameterized, since the two timings
163
+ * aren't expressible as a simple option without leaking animation
164
+ * details into the base class. */
165
+ _activateFocusTrap() {
166
+ this._focusTrap?.activate();
167
+ }
168
+ _teardownSharedListeners() {
169
+ this._focusTrap?.deactivate();
170
+ this._clickOutsideStop?.();
171
+ this._escStop?.();
172
+ this._focusTrap = null;
173
+ this._clickOutsideStop = null;
174
+ this._escStop = null;
175
+ }
176
+ }
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "@popover-kit/svelte",
3
- "version": "0.1.1",
4
- "description": "",
3
+ "version": "0.1.2",
4
+ "description": "A primitive, headless-friendly popover and dropdown toolkit for Svelte 5 — positioning engine, accessibility (focus trap, click-outside, Escape), dark theme and RTL support.",
5
5
  "keywords": [
6
6
  "svelte",
7
7
  "svelte5",
8
8
  "popover",
9
+ "dropdown",
10
+ "overlay",
9
11
  "modal",
10
12
  "drawer",
11
13
  "daisyui",