@popover-kit/svelte 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 popover-kit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # @popover-kit/svelte
2
+
3
+ A primitive, headless-friendly popover/tooltip/menu building block for
4
+ **Svelte 5** (runes), with SSR-safe internals, first-class **dark theme**
5
+ and **native RTL** support via Tailwind CSS v4 + daisyUI v5, and optional
6
+ [Lucide](https://lucide.dev) icons.
7
+
8
+ - **Pluggable** — use the batteries-included `<Popover>` component, or go
9
+ fully headless and wire `PopoverController` to your own markup.
10
+ - **Configurable** — placement (12 positions + `autoVertical`), offset,
11
+ animation, trigger interaction (click/hover/focus/manual), focus trap,
12
+ click-outside, Esc-to-close — all optional props with sane defaults.
13
+ - **Overridable** — every visual surface is a plain `.svelte` component
14
+ shipped as source, styled with daisyUI's `card`/`btn` primitives and a
15
+ handful of scoped CSS classes (`pop-*`) you can target or replace.
16
+ - **Zero-cost at rest** — no listeners, observers, or focus traps are
17
+ created until the popover actually opens.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pnpm add @popover-kit/svelte
23
+ ```
24
+
25
+ Peer dependency: `svelte@^5`. Ships with `lucide-svelte` as a regular
26
+ dependency (used for the built-in close icon).
27
+
28
+ ## Quick start
29
+
30
+ ```svelte
31
+ <script lang="ts">
32
+ import { Popover, PopoverTrigger, PopoverContent } from '@popover-kit/svelte';
33
+ </script>
34
+
35
+ <Popover placement="bottom-start" showArrow>
36
+ {#snippet trigger(ctx)}
37
+ <PopoverTrigger {ctx} class="btn-primary">Open</PopoverTrigger>
38
+ {/snippet}
39
+ {#snippet content(ctx)}
40
+ <PopoverContent {ctx} title="Hello 👋" showClose>
41
+ {#snippet body(ctx)}
42
+ <p class="text-sm">Placement: {ctx.resolvedPlacement}</p>
43
+ {/snippet}
44
+ </PopoverContent>
45
+ {/snippet}
46
+ </Popover>
47
+ ```
48
+
49
+ ## Dark theme & RTL
50
+
51
+ - **Dark theme**: the panel uses daisyUI's `card bg-base-100` tokens, so it
52
+ follows whatever `data-theme` you set on an ancestor — no extra config.
53
+ - **RTL**: set `dir="rtl"` on any ancestor (or the trigger's own element).
54
+ Logical alignments (`*-start` / `*-end`) automatically resolve to the
55
+ correct physical side based on computed writing direction; `left`/`right`
56
+ placements stay physical, matching how most positioning libraries (and
57
+ CSS itself) draw the distinction.
58
+
59
+ ## Headless usage
60
+
61
+ ```ts
62
+ import { PopoverController } from '@popover-kit/svelte';
63
+
64
+ const ctrl = new PopoverController({ placement: 'bottom' });
65
+ ctrl.attachTrigger(triggerEl);
66
+ ctrl.attachPanel(panelEl);
67
+ ctrl.open();
68
+ ```
69
+
70
+ `PopoverController` is pure TypeScript (a `.svelte.ts` module using runes
71
+ for its reactive state) — no component required.
72
+
73
+
74
+ ## License
75
+
76
+ MIT
@@ -0,0 +1,285 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Popover — lightweight composition component.
4
+ *
5
+ * Architecture:
6
+ * • Creates or accepts an injected PopoverController.
7
+ * • Wires trigger and panel DOM refs via $effect (required — bind:this is async).
8
+ * • Passes inline snippet contexts (no $derived — each used once, caching wastes).
9
+ * • Panel renders only while ctrl.isVisible (open + exit-animation).
10
+ *
11
+ * $effect usage (justified):
12
+ * 1. triggerWrapEl wiring — bind:this is async; must react to ref being set.
13
+ * 2. panelEl wiring — panel is conditionally mounted; $effect detects mount.
14
+ * 3. controlled-mode sync — external prop can change at any time.
15
+ *
16
+ * Snippets (Bits UI style):
17
+ * {#snippet trigger(ctx: TriggerSnippetCtx)} — render the trigger element
18
+ * {#snippet content(ctx: ContentSnippetCtx)} — render the panel body
19
+ * {#snippet children(ctrl: IPopoverController)} — shorthand: default <button>
20
+ */
21
+ import { onDestroy, type Snippet } from 'svelte';
22
+ import type {
23
+ PopoverProps,
24
+ IPopoverController,
25
+ TriggerSnippetCtx,
26
+ ContentSnippetCtx
27
+ } from '../types/types.js';
28
+
29
+ import { PopoverController } from '../controller.svelte';
30
+ import PopoverPanel from '../elements/PopoverPanel.svelte';
31
+ import {
32
+ DEFAULT_OPTIONS,
33
+ DEFAULT_ANIMATION,
34
+ DEFAULT_TRIGGER,
35
+ DEFAULT_OPEN_DELAY,
36
+ DEFAULT_CLOSE_DELAY,
37
+ DEFAULT_SHOW_ARROW
38
+ } from '../constants/config.js';
39
+
40
+ // ── Props ────────────────────────────────────────────────────────────────────
41
+
42
+ interface Props extends PopoverProps {
43
+ trigger?: Snippet<[TriggerSnippetCtx]>;
44
+ content?: Snippet<[ContentSnippetCtx]>;
45
+ children?: Snippet<[IPopoverController]>;
46
+ }
47
+
48
+ let {
49
+ controller: injectedCtrl,
50
+ id,
51
+ placement = DEFAULT_OPTIONS.placement,
52
+ offset = DEFAULT_OPTIONS.offset,
53
+ trapFocus = DEFAULT_OPTIONS.trapFocus,
54
+ closeOnOutsideClick = DEFAULT_OPTIONS.closeOnOutsideClick,
55
+ closeOnEsc = DEFAULT_OPTIONS.closeOnEsc,
56
+ onOpen,
57
+ onClose,
58
+ onStateChange,
59
+ animation = DEFAULT_ANIMATION,
60
+ showArrow = DEFAULT_SHOW_ARROW,
61
+ class: cls = '',
62
+ 'aria-label': ariaLabel,
63
+ triggerInteraction = DEFAULT_TRIGGER,
64
+ openDelay = DEFAULT_OPEN_DELAY,
65
+ closeDelay = DEFAULT_CLOSE_DELAY,
66
+ open: controlledOpen,
67
+ trigger: triggerSnippet,
68
+ content: contentSnippet,
69
+ children
70
+ }: Props = $props();
71
+
72
+ // ── Controller ────────────────────────────────────────────────────────────────
73
+ // owned = we created it → destroy on unmount.
74
+ // injected = caller's lifecycle → only wire refs, never destroy.
75
+
76
+ let owned = false;
77
+ let ctrl: PopoverController;
78
+
79
+ if (injectedCtrl instanceof PopoverController) {
80
+ ctrl = injectedCtrl;
81
+ } else {
82
+ owned = true;
83
+ ctrl = new PopoverController({
84
+ id,
85
+ placement,
86
+ offset,
87
+ trapFocus,
88
+ closeOnOutsideClick,
89
+ closeOnEsc,
90
+ onOpen,
91
+ onClose,
92
+ onStateChange
93
+ });
94
+ }
95
+
96
+ // ── DOM refs ─────────────────────────────────────────────────────────────────
97
+ //
98
+ // triggerWrapEl: root <span> — the position anchor for getBoundingClientRect().
99
+ // panelEl: $bindable from PopoverPanel — wired to ctrl.attachPanel().
100
+ //
101
+ // Both use $effect because bind:this is asynchronous in Svelte 5 — the ref
102
+ // is set after the element is mounted, so there's no synchronous alternative.
103
+
104
+ let triggerWrapEl = $state<HTMLElement | null>(null);
105
+ let panelEl = $state<HTMLElement | null>(null);
106
+
107
+ $effect(() => {
108
+ if (triggerWrapEl) ctrl.attachTrigger(triggerWrapEl);
109
+ else ctrl.detachTrigger();
110
+ });
111
+
112
+ $effect(() => {
113
+ if (panelEl) ctrl.attachPanel(panelEl);
114
+ else ctrl.detachPanel();
115
+ });
116
+
117
+ // ── Controlled mode ───────────────────────────────────────────────────────────
118
+ // Genuinely needs $effect — reacts to an external boolean prop changing over time.
119
+
120
+ $effect(() => {
121
+ if (controlledOpen === undefined) return;
122
+ if (controlledOpen && !ctrl.isOpen) ctrl.open();
123
+ else if (!controlledOpen && ctrl.isOpen) ctrl.close();
124
+ });
125
+
126
+ // ── Hover / focus delay timers ────────────────────────────────────────────────
127
+
128
+ let openTimer: ReturnType<typeof setTimeout> | null = null;
129
+ let closeTimer: ReturnType<typeof setTimeout> | null = null;
130
+
131
+ function scheduleOpen(): void {
132
+ if (closeTimer) {
133
+ clearTimeout(closeTimer);
134
+ closeTimer = null;
135
+ }
136
+ if (ctrl.isOpen) return;
137
+ if (openDelay > 0) openTimer = setTimeout(() => ctrl.open(), openDelay);
138
+ else ctrl.open();
139
+ }
140
+
141
+ function scheduleClose(): void {
142
+ if (openTimer) {
143
+ clearTimeout(openTimer);
144
+ openTimer = null;
145
+ }
146
+ if (!ctrl.isOpen) return;
147
+ if (closeDelay > 0) closeTimer = setTimeout(() => ctrl.close(), closeDelay);
148
+ else ctrl.close();
149
+ }
150
+
151
+ // ── Interaction handlers ──────────────────────────────────────────────────────
152
+
153
+ function handleClick(): void {
154
+ if (triggerInteraction === 'click' || triggerInteraction === 'manual') ctrl.toggle();
155
+ }
156
+
157
+ function handleKeydown(e: KeyboardEvent): void {
158
+ if (e.key === 'Enter' || e.key === ' ') {
159
+ e.preventDefault();
160
+ ctrl.toggle();
161
+ }
162
+ }
163
+
164
+ function handleMouseEnter(): void {
165
+ if (triggerInteraction === 'hover') scheduleOpen();
166
+ }
167
+ function handleMouseLeave(): void {
168
+ if (triggerInteraction === 'hover') scheduleClose();
169
+ }
170
+
171
+ function handleFocus(): void {
172
+ if (triggerInteraction === 'focus' || triggerInteraction === 'hover') scheduleOpen();
173
+ }
174
+ function handleBlur(): void {
175
+ if (triggerInteraction === 'focus' || triggerInteraction === 'hover') scheduleClose();
176
+ }
177
+
178
+ // ── Lifecycle ─────────────────────────────────────────────────────────────────
179
+
180
+ onDestroy(() => {
181
+ if (openTimer) clearTimeout(openTimer);
182
+ if (closeTimer) clearTimeout(closeTimer);
183
+ if (owned) ctrl.destroy();
184
+ else {
185
+ ctrl.detachTrigger();
186
+ ctrl.detachPanel();
187
+ }
188
+ });
189
+
190
+ // ── Public API (bind:this on <Popover>) ───────────────────────────────────────
191
+
192
+ export function open(): void {
193
+ ctrl.open();
194
+ }
195
+ export function close(): void {
196
+ ctrl.close();
197
+ }
198
+ export function toggle(): void {
199
+ ctrl.toggle();
200
+ }
201
+ export function getController(): PopoverController {
202
+ return ctrl;
203
+ }
204
+ </script>
205
+
206
+ <!--
207
+ Root wrapper: inline-block keeps it tight around the trigger so that
208
+ getBoundingClientRect() on triggerWrapEl returns the trigger's actual rect.
209
+
210
+ Snippet contexts are passed INLINE — each is used exactly once in the template,
211
+ so storing them in $derived would add a reactive subscription with zero benefit.
212
+ -->
213
+ <span class="pop-root" bind:this={triggerWrapEl}>
214
+ <!-- ── Trigger ─────────────────────────────────────────────────────────── -->
215
+ {#if triggerSnippet}
216
+ {@render triggerSnippet({
217
+ id: ctrl.triggerId,
218
+ controls: ctrl.contentId,
219
+ expanded: ctrl.isOpen,
220
+ onclick: handleClick,
221
+ onkeydown: handleKeydown,
222
+ onmouseenter: handleMouseEnter,
223
+ onmouseleave: handleMouseLeave,
224
+ onfocus: handleFocus,
225
+ onblur: handleBlur,
226
+ ctrl
227
+ })}
228
+ {:else}
229
+ <button
230
+ id={ctrl.triggerId}
231
+ type="button"
232
+ class="btn"
233
+ aria-haspopup="dialog"
234
+ aria-expanded={ctrl.isOpen}
235
+ aria-controls={ctrl.contentId}
236
+ onclick={handleClick}
237
+ onkeydown={handleKeydown}
238
+ onmouseenter={handleMouseEnter}
239
+ onmouseleave={handleMouseLeave}
240
+ onfocus={handleFocus}
241
+ onblur={handleBlur}
242
+ >
243
+ {@render children?.(ctrl)}
244
+ </button>
245
+ {/if}
246
+
247
+ <!-- ── Panel ───────────────────────────────────────────────────────────── -->
248
+ <!--
249
+ Mounted only while ctrl.isVisible (open OR exit-animating).
250
+ When it mounts, the $effect above fires → ctrl.attachPanel(panelEl)
251
+ → controller detects state==='opening' → runs #finishOpen().
252
+ Click-outside: pointerdown fires on the clicked element BEFORE this close()
253
+ runs, so background elements remain fully interactive.
254
+ -->
255
+ {#if ctrl.isVisible}
256
+ <PopoverPanel
257
+ id={ctrl.contentId}
258
+ triggerId={ctrl.triggerId}
259
+ isOpen={ctrl.isOpen}
260
+ position={ctrl.position}
261
+ resolvedPlacement={ctrl.resolvedPlacement}
262
+ {showArrow}
263
+ {animation}
264
+ class={cls}
265
+ aria-label={ariaLabel}
266
+ bind:el={panelEl}
267
+ >
268
+ {#snippet children()}
269
+ {#if contentSnippet}
270
+ {@render contentSnippet({
271
+ ctrl,
272
+ resolvedPlacement: ctrl.resolvedPlacement,
273
+ state: ctrl.state
274
+ })}
275
+ {/if}
276
+ {/snippet}
277
+ </PopoverPanel>
278
+ {/if}
279
+ </span>
280
+
281
+ <style>
282
+ .pop-root {
283
+ display: inline-block;
284
+ }
285
+ </style>
@@ -0,0 +1,75 @@
1
+ <script lang="ts">
2
+ /**
3
+ * PopoverContent — padded panel body wrapper.
4
+ *
5
+ * Provides optional title, description, and close button chrome.
6
+ * All snippets receive `ContentSnippetCtx` so nested components can reach
7
+ * the controller without additional prop drilling.
8
+ *
9
+ * Styling: daisyUI card-title, card-body, btn-ghost; Tailwind for spacing.
10
+ *
11
+ * @example
12
+ * {#snippet content(ctx)}
13
+ * <PopoverContent {ctx} title="Settings" showClose>
14
+ * {#snippet body(ctx)}
15
+ * <MySettings ctrl={ctx.ctrl} />
16
+ * {/snippet}
17
+ * </PopoverContent>
18
+ * {/snippet}
19
+ */
20
+ import type { Snippet } from 'svelte';
21
+ import { X } from 'lucide-svelte';
22
+ import type { ContentSnippetCtx } from '../types/types.js';
23
+
24
+ interface Props {
25
+ ctx: ContentSnippetCtx;
26
+ title?: string;
27
+ description?: string;
28
+ showClose?: boolean;
29
+ class?: string;
30
+ /** Primary body slot — receives ctx for controller access. */
31
+ body?: Snippet<[ContentSnippetCtx]>;
32
+ /** Fallback when no body snippet is provided. */
33
+ children?: Snippet<[ContentSnippetCtx]>;
34
+ }
35
+
36
+ const {
37
+ ctx,
38
+ title,
39
+ description,
40
+ showClose = false,
41
+ class: cls = '',
42
+ body,
43
+ children
44
+ }: Props = $props();
45
+ </script>
46
+
47
+ <div class="flex flex-col gap-3 p-4 {cls}">
48
+ {#if title || showClose}
49
+ <div class="flex items-center justify-between gap-2">
50
+ {#if title}
51
+ <h3 class="card-title text-sm">{title}</h3>
52
+ {/if}
53
+ {#if showClose}
54
+ <button
55
+ type="button"
56
+ class="btn btn-circle btn-ghost btn-xs ms-auto"
57
+ aria-label="Close"
58
+ onclick={() => ctx.ctrl.close()}
59
+ >
60
+ <X class="h-3 w-3" aria-hidden="true" />
61
+ </button>
62
+ {/if}
63
+ </div>
64
+ {/if}
65
+
66
+ {#if description}
67
+ <p class="text-xs leading-relaxed opacity-60">{description}</p>
68
+ {/if}
69
+
70
+ {#if body}
71
+ {@render body(ctx)}
72
+ {:else}
73
+ {@render children?.(ctx)}
74
+ {/if}
75
+ </div>
@@ -0,0 +1,43 @@
1
+ <script lang="ts">
2
+ /**
3
+ * PopoverTrigger — pre-wired ARIA trigger button.
4
+ *
5
+ * Accepts the full `TriggerSnippetCtx` as `ctx` and wires all ARIA attributes
6
+ * and interaction handlers automatically. Consumers only need to provide the
7
+ * visual classes (daisyUI btn variants).
8
+ *
9
+ * @example
10
+ * {#snippet trigger(ctx)}
11
+ * <PopoverTrigger {ctx} class="btn btn-primary">Open</PopoverTrigger>
12
+ * {/snippet}
13
+ */
14
+ import type { Snippet } from 'svelte';
15
+ import type { TriggerSnippetCtx } from '../types/types.js';
16
+
17
+ interface Props {
18
+ ctx: TriggerSnippetCtx;
19
+ disabled?: boolean;
20
+ class?: string;
21
+ children?: Snippet;
22
+ }
23
+
24
+ const { ctx, disabled = false, class: cls = '', children }: Props = $props();
25
+ </script>
26
+
27
+ <button
28
+ id={ctx.id}
29
+ type="button"
30
+ class="btn {cls}"
31
+ aria-haspopup="dialog"
32
+ aria-expanded={ctx.expanded}
33
+ aria-controls={ctx.controls}
34
+ {disabled}
35
+ onclick={ctx.onclick}
36
+ onkeydown={ctx.onkeydown}
37
+ onmouseenter={ctx.onmouseenter}
38
+ onmouseleave={ctx.onmouseleave}
39
+ onfocus={ctx.onfocus}
40
+ onblur={ctx.onblur}
41
+ >
42
+ {@render children?.()}
43
+ </button>
@@ -0,0 +1 @@
1
+ const e={placement:"bottom",offset:8,trapFocus:!0,closeOnOutsideClick:!0,closeOnEsc:!0},r="scale",c="click",a=0,l=100,n=!1,s=200,d=["a[href]","button:not([disabled])","input:not([disabled])","select:not([disabled])","textarea:not([disabled])",'[tabindex]:not([tabindex="-1"])','[contenteditable="true"]',"details > summary"].join(", "),i="pop-live-region",p={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))"};let t=0;function E(o="pop"){return`${o}-${++t}`}export{s as ANIMATION_DURATION_MS,r as DEFAULT_ANIMATION,l as DEFAULT_CLOSE_DELAY,a as DEFAULT_OPEN_DELAY,e as DEFAULT_OPTIONS,n as DEFAULT_SHOW_ARROW,p as DEFAULT_THEME_TOKENS,c as DEFAULT_TRIGGER,d as FOCUSABLE_SELECTOR,i as LIVE_REGION_ID,E as uid};