@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 +48 -5
- package/dist/actions.js +1 -0
- package/dist/components/Popover.svelte +15 -17
- package/dist/controller.svelte.js +118 -140
- package/dist/dropdown-controller.svelte.js +65 -0
- package/dist/elements/PopoverArrow.svelte +4 -5
- package/dist/elements/PopoverPanel.svelte +5 -3
- package/dist/index.d.ts +142 -39
- package/dist/index.js +1 -1
- package/dist/overlay-controller-base.svelte.js +176 -0
- package/package.json +4 -2
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
|
-
##
|
|
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
|
-
```
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
package/dist/actions.js
ADDED
|
@@ -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
|
-
* •
|
|
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.
|
|
13
|
-
* 2.
|
|
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
|
-
//
|
|
104
|
-
//
|
|
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
|
|
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"
|
|
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()
|
|
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
|
|
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 {
|
|
36
|
-
import {
|
|
37
|
-
export class PopoverController {
|
|
38
|
-
// ───
|
|
39
|
-
|
|
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
|
-
// ───
|
|
47
|
-
|
|
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
|
-
|
|
65
|
-
#subscribers = new Set();
|
|
56
|
+
#rafId = null;
|
|
66
57
|
// ─── Timers ───────────────────────────────────────────────────────────────────
|
|
67
58
|
#focusTrapTimer = null;
|
|
68
59
|
#closeTimer = null;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
this.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
this
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
this
|
|
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.#
|
|
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
|
|
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
|
-
// ───
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
130
|
+
if (this._panelEl)
|
|
162
131
|
this.#finishOpen();
|
|
163
132
|
}
|
|
164
133
|
close() {
|
|
165
|
-
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")
|
|
166
146
|
return;
|
|
167
147
|
this.#cancelFocusTrapTimer();
|
|
168
|
-
this.#setState(
|
|
169
|
-
this
|
|
148
|
+
this.#setState("closing");
|
|
149
|
+
this._teardownSharedListeners();
|
|
150
|
+
this.#teardownPositionListeners();
|
|
170
151
|
this.#closeTimer = setTimeout(() => {
|
|
171
152
|
if (this.#state === "closing") {
|
|
172
|
-
this.#setState(
|
|
153
|
+
this.#setState("closed");
|
|
173
154
|
this.#pos = null;
|
|
174
155
|
announce("Popover closed");
|
|
175
|
-
this
|
|
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
|
|
184
|
-
if (this.#state === "open")
|
|
185
|
-
|
|
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.#
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
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
|
|
205
|
-
this.#
|
|
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
|
|
205
|
+
this.onOpen?.();
|
|
208
206
|
}
|
|
209
207
|
// ─── Private: positioning ─────────────────────────────────────────────────────
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
// #
|
|
228
|
-
// synchronously the moment close() runs, so no stale rAF can
|
|
229
|
-
// here mid-'closing'. Checked anyway as defense-in-depth
|
|
230
|
-
// exactly the class of "recomputed mid-exit-animation" bug
|
|
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
|
-
#
|
|
244
|
-
const trigger = this
|
|
245
|
-
const panel = this
|
|
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
|
-
#
|
|
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(
|
|
299
|
-
this.#isOpen = isOpen;
|
|
277
|
+
#setState(state) {
|
|
300
278
|
this.#state = state;
|
|
301
|
-
this
|
|
302
|
-
this
|
|
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
|
-
|
|
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>
|
|
@@ -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
|
|
126
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|