@popover-kit/svelte 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -2
- package/dist/actions.js +1 -0
- package/dist/components/Popover.svelte +31 -28
- package/dist/components/PopoverContent.svelte +16 -4
- package/dist/components/PopoverTrigger.svelte +1 -1
- package/dist/controller.svelte.js +150 -142
- package/dist/dropdown-controller.svelte.js +65 -0
- package/dist/elements/PopoverArrow.svelte +5 -6
- package/dist/elements/PopoverPanel.svelte +6 -4
- package/dist/index.d.ts +163 -57
- package/dist/index.js +1 -1
- package/dist/overlay-controller-base.svelte.js +176 -0
- package/dist/types.js +0 -0
- package/dist/utils/a11y.js +1 -1
- package/dist/utils/position.js +1 -1
- package/package.json +5 -7
- /package/dist/{constants/config.js → constants.js} +0 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { Snippet } from 'svelte';
|
|
2
2
|
|
|
3
|
-
// =============================================================================
|
|
4
|
-
// Popover Micro-Plugin — Type Definitions (single source of truth)
|
|
5
|
-
// =============================================================================
|
|
6
|
-
// ─── Placement ────────────────────────────────────────────────────────────────
|
|
7
3
|
/**
|
|
8
4
|
* Where the panel appears relative to the trigger.
|
|
9
5
|
*
|
|
@@ -14,7 +10,6 @@ import { Snippet } from 'svelte';
|
|
|
14
10
|
export type Side = "top" | "bottom" | "left" | "right";
|
|
15
11
|
export type Alignment = "start" | "end";
|
|
16
12
|
export type Placement = "top" | "top-start" | "top-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end" | "right" | "right-start" | "right-end" | "autoVertical";
|
|
17
|
-
// ─── Computed position ────────────────────────────────────────────────────────
|
|
18
13
|
/**
|
|
19
14
|
* Output of the positioning engine.
|
|
20
15
|
* x / y are viewport-relative px values suitable for `position: fixed`.
|
|
@@ -22,18 +17,42 @@ export type Placement = "top" | "top-start" | "top-end" | "bottom" | "bottom-sta
|
|
|
22
17
|
export interface ResolvedPosition {
|
|
23
18
|
x: number;
|
|
24
19
|
y: number;
|
|
25
|
-
placement: Placement;
|
|
20
|
+
placement: Placement;
|
|
26
21
|
}
|
|
27
|
-
// ─── State ────────────────────────────────────────────────────────────────────
|
|
28
22
|
/** Fine-grained lifecycle state — drives CSS enter/exit transitions. */
|
|
29
23
|
export type PopoverState = "closed" | "opening" | "open" | "closing";
|
|
30
|
-
|
|
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
|
+
}
|
|
31
51
|
/**
|
|
32
52
|
* Public surface of PopoverController.
|
|
33
53
|
* All readonly getters are $state runes — Svelte templates track them.
|
|
34
54
|
*/
|
|
35
|
-
export interface IPopoverController {
|
|
36
|
-
// Reactive getters
|
|
55
|
+
export interface IPopoverController extends AttachableController {
|
|
37
56
|
readonly isOpen: boolean;
|
|
38
57
|
readonly state: PopoverState;
|
|
39
58
|
readonly resolvedPlacement: Placement;
|
|
@@ -42,16 +61,10 @@ export interface IPopoverController {
|
|
|
42
61
|
readonly isVisible: boolean;
|
|
43
62
|
readonly triggerId: string;
|
|
44
63
|
readonly contentId: string;
|
|
45
|
-
// Core actions
|
|
46
64
|
open(): void;
|
|
47
65
|
close(): void;
|
|
48
66
|
toggle(): void;
|
|
49
67
|
setPlacement(p: Placement): void;
|
|
50
|
-
// Element wiring (called by Popover.svelte $effects and headless consumers)
|
|
51
|
-
attachTrigger(el: HTMLElement): void;
|
|
52
|
-
attachPanel(el: HTMLElement): void;
|
|
53
|
-
detachTrigger(): void;
|
|
54
|
-
detachPanel(): void;
|
|
55
68
|
getSnapshot(): PopoverSnapshot;
|
|
56
69
|
subscribe(cb: SubscriberFn): UnsubscribeFn;
|
|
57
70
|
destroy(): void;
|
|
@@ -65,22 +78,13 @@ export interface PopoverSnapshot {
|
|
|
65
78
|
}
|
|
66
79
|
export type SubscriberFn = (snap: PopoverSnapshot) => void;
|
|
67
80
|
export type UnsubscribeFn = () => void;
|
|
68
|
-
|
|
69
|
-
export interface ControllerOptions {
|
|
70
|
-
/** ID prefix for generated ARIA ids. Auto-generated if omitted. */
|
|
71
|
-
id?: string;
|
|
81
|
+
export interface ControllerOptions extends BaseOverlayOptions {
|
|
72
82
|
/** Preferred placement. Default: `bottom`. */
|
|
73
83
|
placement?: Placement;
|
|
74
84
|
/** Gap between trigger and panel in px. Default: 8. */
|
|
75
85
|
offset?: number;
|
|
76
|
-
trapFocus?: boolean;
|
|
77
|
-
closeOnOutsideClick?: boolean;
|
|
78
|
-
closeOnEsc?: boolean;
|
|
79
|
-
onOpen?: () => void;
|
|
80
|
-
onClose?: () => void;
|
|
81
86
|
onStateChange?: (state: PopoverState) => void;
|
|
82
87
|
}
|
|
83
|
-
// ─── Component props ──────────────────────────────────────────────────────────
|
|
84
88
|
export type PopoverAnimation = "scale" | "slide" | "fade" | "none";
|
|
85
89
|
export type TriggerInteraction = "click" | "hover" | "focus" | "manual";
|
|
86
90
|
export interface PopoverProps extends ControllerOptions {
|
|
@@ -98,7 +102,6 @@ export interface PopoverProps extends ControllerOptions {
|
|
|
98
102
|
/** Inject an existing controller; this component will not destroy it. */
|
|
99
103
|
controller?: IPopoverController;
|
|
100
104
|
}
|
|
101
|
-
// ─── Snippet contexts (Bits UI style — fully typed parameters) ────────────────
|
|
102
105
|
/** Passed into `{#snippet trigger(ctx)}`. Spread these onto your trigger element. */
|
|
103
106
|
export interface TriggerSnippetCtx {
|
|
104
107
|
id: string;
|
|
@@ -118,13 +121,11 @@ export interface ContentSnippetCtx {
|
|
|
118
121
|
resolvedPlacement: Placement;
|
|
119
122
|
state: PopoverState;
|
|
120
123
|
}
|
|
121
|
-
// ─── Accessibility handles ────────────────────────────────────────────────────
|
|
122
124
|
export interface FocusTrapHandle {
|
|
123
125
|
activate(): void;
|
|
124
126
|
deactivate(): void;
|
|
125
127
|
destroy(): void;
|
|
126
128
|
}
|
|
127
|
-
// ─── Plugin factory ───────────────────────────────────────────────────────────
|
|
128
129
|
export interface ThemeTokens {
|
|
129
130
|
borderRadius: string;
|
|
130
131
|
backdropBlur: string;
|
|
@@ -137,32 +138,115 @@ export interface ThemeTokens {
|
|
|
137
138
|
darkText: string;
|
|
138
139
|
accentColor: string;
|
|
139
140
|
}
|
|
140
|
-
export
|
|
141
|
-
|
|
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;
|
|
142
155
|
readonly triggerId: string;
|
|
143
156
|
readonly contentId: string;
|
|
144
|
-
|
|
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. */
|
|
145
233
|
get isOpen(): boolean;
|
|
146
234
|
get state(): PopoverState;
|
|
147
235
|
get resolvedPlacement(): Placement;
|
|
148
236
|
get position(): ResolvedPosition | null;
|
|
149
237
|
get isVisible(): boolean;
|
|
150
238
|
getSnapshot(): PopoverSnapshot;
|
|
151
|
-
subscribe(cb: SubscriberFn): UnsubscribeFn;
|
|
152
|
-
attachTrigger(el: HTMLElement): void;
|
|
153
239
|
/**
|
|
154
|
-
* Called by
|
|
240
|
+
* Called by the base class's attachPanel() after the panel element mounts.
|
|
155
241
|
* If state is 'opening', we're in the open sequence → finish it now.
|
|
156
242
|
* If state is already 'open', controller was re-attached → just recompute.
|
|
157
243
|
*/
|
|
158
|
-
|
|
159
|
-
detachTrigger(): void;
|
|
160
|
-
detachPanel(): void;
|
|
244
|
+
protected _onPanelAttached(): void;
|
|
161
245
|
open(): void;
|
|
162
246
|
close(): void;
|
|
163
|
-
toggle(): void;
|
|
164
247
|
setPlacement(p: Placement): void;
|
|
165
248
|
destroy(): void;
|
|
249
|
+
protected _activateFocusTrap(): void;
|
|
166
250
|
}
|
|
167
251
|
export interface Props extends PopoverProps {
|
|
168
252
|
trigger?: Snippet<[
|
|
@@ -204,6 +288,14 @@ interface Props$2 {
|
|
|
204
288
|
children?: Snippet<[
|
|
205
289
|
ContentSnippetCtx
|
|
206
290
|
]>;
|
|
291
|
+
/**
|
|
292
|
+
* Overrides the default close-button glyph (a plain "X" character —
|
|
293
|
+
* no icon library dependency shipped by this package). Pass e.g. a
|
|
294
|
+
* `lucide-svelte` icon snippet from your own app to swap it in:
|
|
295
|
+
*
|
|
296
|
+
* {#snippet iconSnippet()}<X class="h-3 w-3" />{/snippet}
|
|
297
|
+
*/
|
|
298
|
+
iconSnippet?: Snippet;
|
|
207
299
|
}
|
|
208
300
|
export declare const PopoverContent: import("svelte").Component<Props$2, {}, "">;
|
|
209
301
|
export type PopoverContent = ReturnType<typeof PopoverContent>;
|
|
@@ -230,6 +322,26 @@ interface Props$4 {
|
|
|
230
322
|
}
|
|
231
323
|
export declare const PopoverArrow: import("svelte").Component<Props$4, {}, "">;
|
|
232
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>;
|
|
233
345
|
/** Split "bottom-start" → { side: 'bottom', alignment: 'start' } */
|
|
234
346
|
export declare function parsePlacement(p: Placement): {
|
|
235
347
|
side: Side;
|
|
@@ -241,31 +353,18 @@ export declare function getDirection(el: HTMLElement): "ltr" | "rtl";
|
|
|
241
353
|
* Compute the `position:fixed` coordinates for the popover panel.
|
|
242
354
|
*
|
|
243
355
|
* The panel element MUST already be in the DOM (even if visibility:hidden)
|
|
244
|
-
* so its dimensions can be read
|
|
356
|
+
* so its dimensions can be read.
|
|
245
357
|
*
|
|
246
358
|
* @param triggerEl - The trigger / reference element.
|
|
247
359
|
* @param panelEl - The panel element to position.
|
|
248
360
|
* @param placement - Requested placement (may be overridden by auto-flip).
|
|
249
361
|
* @param offset - Gap between trigger edge and panel in px.
|
|
362
|
+
* @param previousPlacement - The last resolved placement, if any. Used for
|
|
363
|
+
* flip hysteresis (see `resolveAutoSide`) — pass the previous call's
|
|
364
|
+
* `result.placement` on recomputes; omit on the first compute.
|
|
250
365
|
* @returns { x, y, placement } — coordinates and the actual placement used.
|
|
251
366
|
*/
|
|
252
|
-
export declare function computePosition(triggerEl: HTMLElement, panelEl: HTMLElement, placement: Placement, offset: number): ResolvedPosition;
|
|
253
|
-
/**
|
|
254
|
-
* Keeps keyboard focus inside `container` while active.
|
|
255
|
-
* Activation is deferred so it can be timed to the enter animation.
|
|
256
|
-
*
|
|
257
|
-
* @param container - The panel element to trap focus within.
|
|
258
|
-
* @param returnTo - Element that receives focus when the trap deactivates.
|
|
259
|
-
*/
|
|
260
|
-
export declare function createFocusTrap(container: HTMLElement, returnTo?: HTMLElement): FocusTrapHandle;
|
|
261
|
-
/** Announce a message to screen readers via the shared polite live region. */
|
|
262
|
-
export declare function announce(message: string, delayMs?: number): void;
|
|
263
|
-
/**
|
|
264
|
-
* Calls `callback` when a pointer-down event occurs outside all `elements`.
|
|
265
|
-
* Uses capture phase so stopPropagation inside the panel cannot suppress it.
|
|
266
|
-
* Returns a cleanup function.
|
|
267
|
-
*/
|
|
268
|
-
export declare function onClickOutside(elements: HTMLElement[], callback: (e: PointerEvent) => void): () => void;
|
|
367
|
+
export declare function computePosition(triggerEl: HTMLElement, panelEl: HTMLElement, placement: Placement, offset: number, previousPlacement?: Placement | null): ResolvedPosition;
|
|
269
368
|
export declare const DEFAULT_OPTIONS: {
|
|
270
369
|
readonly placement: "bottom";
|
|
271
370
|
readonly offset: 8;
|
|
@@ -273,12 +372,19 @@ export declare const DEFAULT_OPTIONS: {
|
|
|
273
372
|
readonly closeOnOutsideClick: true;
|
|
274
373
|
readonly closeOnEsc: true;
|
|
275
374
|
};
|
|
375
|
+
export declare const DEFAULT_ANIMATION: "scale";
|
|
376
|
+
export declare const DEFAULT_TRIGGER: "click";
|
|
377
|
+
export declare const DEFAULT_OPEN_DELAY: number;
|
|
378
|
+
export declare const DEFAULT_CLOSE_DELAY: number;
|
|
379
|
+
export declare const DEFAULT_SHOW_ARROW: boolean;
|
|
276
380
|
/**
|
|
277
381
|
* Must match the longest `transition-duration` in PopoverPanel's CSS.
|
|
278
382
|
* The controller waits this long after close() before setting state → 'closed'
|
|
279
383
|
* so the exit animation completes before the panel is unmounted.
|
|
280
384
|
*/
|
|
281
385
|
export declare const ANIMATION_DURATION_MS = 200;
|
|
386
|
+
export declare const FOCUSABLE_SELECTOR: string;
|
|
387
|
+
export declare const LIVE_REGION_ID = "pop-live-region";
|
|
282
388
|
export declare const DEFAULT_THEME_TOKENS: ThemeTokens;
|
|
283
389
|
/**
|
|
284
390
|
* Generates a stable, unique DOM id.
|
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/dist/types.js
ADDED
|
File without changes
|
package/dist/utils/a11y.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{FOCUSABLE_SELECTOR as v,LIVE_REGION_ID as m}from"../constants
|
|
1
|
+
import{FOCUSABLE_SELECTOR as v,LIVE_REGION_ID as m}from"../constants.js";function y(o,r){let t=!1,i=document.activeElement;function u(){return Array.from(o.querySelectorAll(v)).filter(e=>!e.hasAttribute("disabled")&&e.offsetParent!==null)}function c(e){if(!t||e.key!=="Tab")return;const s=u();if(!s.length){e.preventDefault();return}const d=s[0],l=s[s.length-1],a=document.activeElement;e.shiftKey?(a===d||!o.contains(a))&&(e.preventDefault(),l.focus()):(a===l||!o.contains(a))&&(e.preventDefault(),d.focus())}function p(){if(t)return;t=!0,i=document.activeElement,document.addEventListener("keydown",c,!0);const e=u()[0]??o;requestAnimationFrame(()=>e.focus())}function f(){if(!t)return;t=!1,document.removeEventListener("keydown",c,!0);const e=r??i;e&&typeof e.focus=="function"&&requestAnimationFrame(()=>e.focus())}return{activate:p,deactivate:f,destroy:f}}let n=null;function g(){return n||(n=document.getElementById(m),n||(n=document.createElement("div"),n.id=m,n.setAttribute("aria-live","polite"),n.setAttribute("aria-atomic","true"),Object.assign(n.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(n)),n)}function h(o,r=80){const t=g();t.textContent="",setTimeout(()=>{t.textContent=o},r)}function b(o,r){function t(i){const u=i.target;o.some(c=>c.contains(u))||r(i)}return document.addEventListener("pointerdown",t,!0),()=>document.removeEventListener("pointerdown",t,!0)}export{h as announce,y as createFocusTrap,b as onClickOutside};
|
package/dist/utils/position.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
function
|
|
1
|
+
function w(t){if(t==="autoVertical")return{side:"bottom",alignment:null};const[o,i=null]=t.split("-");return{side:o,alignment:i}}function P(t,o,i,l,u,c,e){let n,s;switch(l){case"bottom":s=t.bottom+c;break;case"top":s=t.top-i-c;break;case"left":n=t.left-o-c;break;case"right":n=t.right+c;break}if(l==="top"||l==="bottom"){const h=e?t.right-o:t.left,b=e?t.left:t.right-o;u==="start"?n=h:u==="end"?n=b:n=t.left+(t.width-o)/2}else u==="start"?s=t.top:u==="end"?s=t.bottom-i:s=t.top+(t.height-i)/2;return{x:n,y:s}}function k(t){return getComputedStyle(t).direction==="rtl"?"rtl":"ltr"}function p(t,o,i,l=6){return Math.max(l,Math.min(i-o-l,t))}const v={top:"bottom",bottom:"top",left:"right",right:"left"};function y(t,o,i,l,u,c,e,n=0){switch(t){case"bottom":return o.bottom+u+i+n<=e;case"top":return o.top-u-i-n>=0;case"right":return o.right+u+l+n<=c;case"left":return o.left-u-l-n>=0}}const A=24;function I(t,o,i,l,u,c,e,n){const s=v[t],h=(b,f=0)=>y(b,o,i,l,u,c,e,f);return n===s?h(t,A)?t:h(s)?s:t:h(t)?t:h(s)?s:n??t}function _(t,o,i,l,u){const c=t.getBoundingClientRect(),e=o.offsetWidth,n=o.offsetHeight,s=window.innerWidth,h=window.innerHeight,b=k(t)==="rtl";let{side:f,alignment:m}=w(i);const x=u?w(u).side:null,a=I(f,c,n,e,l,s,h,x),S=i==="autoVertical"?a==="top"?"top":"bottom":a===f?i:m?`${a}-${m}`:a;let{x:d,y:r}=P(c,e,n,a,m,l,b);return a==="top"||a==="bottom"?d=p(d,e,s):r=p(r,n,h),{x:d,y:r,placement:S}}export{_ as computePosition,k as getDirection,w as parsePlacement};
|
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",
|
|
@@ -34,9 +36,6 @@
|
|
|
34
36
|
"peerDependencies": {
|
|
35
37
|
"svelte": "^5.0.0"
|
|
36
38
|
},
|
|
37
|
-
"dependencies": {
|
|
38
|
-
"lucide-svelte": "^0.462.0"
|
|
39
|
-
},
|
|
40
39
|
"devDependencies": {
|
|
41
40
|
"@sveltejs/package": "^2.3.0",
|
|
42
41
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
|
@@ -63,7 +62,6 @@
|
|
|
63
62
|
"typecheck": "svelte-check --tsconfig ./tsconfig.json",
|
|
64
63
|
"typecheck:test": "tsc -p tsconfig.test.json",
|
|
65
64
|
"test": "vitest run",
|
|
66
|
-
"test:watch": "vitest"
|
|
67
|
-
"prepublishOnlyx": "pnpm run typecheck && pnpm run test && pnpm run build"
|
|
65
|
+
"test:watch": "vitest"
|
|
68
66
|
}
|
|
69
67
|
}
|
|
File without changes
|