@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/README.md
CHANGED
|
@@ -22,8 +22,9 @@ and **native RTL** support via Tailwind CSS v4 + daisyUI v5, and optional
|
|
|
22
22
|
pnpm add @popover-kit/svelte
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Peer dependency: `svelte@^5`.
|
|
26
|
-
|
|
25
|
+
Peer dependency: `svelte@^5`. No icon library dependency — the built-in
|
|
26
|
+
close button renders a plain "✕" character. Pass your own `iconSnippet`
|
|
27
|
+
prop to `<PopoverContent>` (e.g. a `lucide-svelte` icon) to override it.
|
|
27
28
|
|
|
28
29
|
## Quick start
|
|
29
30
|
|
|
@@ -46,6 +47,13 @@ dependency (used for the built-in close icon).
|
|
|
46
47
|
</Popover>
|
|
47
48
|
```
|
|
48
49
|
|
|
50
|
+
See `demos/PageDemo.svelte` for 12 runnable scenarios (placements,
|
|
51
|
+
alignments, `autoVertical`, hover/focus triggers, controlled mode, a
|
|
52
|
+
shared/injected controller, a fully headless example, and three
|
|
53
|
+
booking/e-commerce-flavored patterns: a Kayak-style search bar, a checkout
|
|
54
|
+
confirmation, and a product-discovery tooltip). Run it in a browser via
|
|
55
|
+
`examples/svelte` (see its README).
|
|
56
|
+
|
|
49
57
|
## Dark theme & RTL
|
|
50
58
|
|
|
51
59
|
- **Dark theme**: the panel uses daisyUI's `card bg-base-100` tokens, so it
|
|
@@ -70,6 +78,56 @@ ctrl.open();
|
|
|
70
78
|
`PopoverController` is pure TypeScript (a `.svelte.ts` module using runes
|
|
71
79
|
for its reactive state) — no component required.
|
|
72
80
|
|
|
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:
|
|
106
|
+
|
|
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}
|
|
126
|
+
```
|
|
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.
|
|
73
131
|
|
|
74
132
|
## License
|
|
75
133
|
|
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,29 +4,33 @@
|
|
|
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
|
|
18
21
|
* {#snippet content(ctx: ContentSnippetCtx)} — render the panel body
|
|
19
22
|
* {#snippet children(ctrl: IPopoverController)} — shorthand: default <button>
|
|
20
23
|
*/
|
|
21
|
-
import { onDestroy, type Snippet } from 'svelte';
|
|
24
|
+
import { onDestroy, untrack, type Snippet } from 'svelte';
|
|
22
25
|
import type {
|
|
23
26
|
PopoverProps,
|
|
24
27
|
IPopoverController,
|
|
25
28
|
TriggerSnippetCtx,
|
|
26
29
|
ContentSnippetCtx
|
|
27
|
-
} from '../types
|
|
30
|
+
} from '../types.js';
|
|
28
31
|
|
|
29
|
-
import { PopoverController } from '../controller.svelte';
|
|
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,
|
|
@@ -35,7 +39,7 @@
|
|
|
35
39
|
DEFAULT_OPEN_DELAY,
|
|
36
40
|
DEFAULT_CLOSE_DELAY,
|
|
37
41
|
DEFAULT_SHOW_ARROW
|
|
38
|
-
} from '../constants
|
|
42
|
+
} from '../constants.js';
|
|
39
43
|
|
|
40
44
|
// ── Props ────────────────────────────────────────────────────────────────────
|
|
41
45
|
|
|
@@ -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,
|
|
@@ -72,43 +77,41 @@
|
|
|
72
77
|
// ── Controller ────────────────────────────────────────────────────────────────
|
|
73
78
|
// owned = we created it → destroy on unmount.
|
|
74
79
|
// injected = caller's lifecycle → only wire refs, never destroy.
|
|
80
|
+
//
|
|
81
|
+
// Deliberately constructed ONCE from the props' initial values — `id`,
|
|
82
|
+
// `placement`, etc. configure the controller at creation time only; later
|
|
83
|
+
// prop changes are intentionally not reactive here; `open` is the one
|
|
84
|
+
// exception, handled by its own $effect below. `untrack` tells the
|
|
85
|
+
// compiler this one-time read is intentional (not a missed dependency).
|
|
75
86
|
|
|
76
87
|
let owned = false;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
} else {
|
|
88
|
+
const ctrl: PopoverController = untrack(() => {
|
|
89
|
+
if (injectedCtrl instanceof PopoverController) {
|
|
90
|
+
return injectedCtrl;
|
|
91
|
+
}
|
|
82
92
|
owned = true;
|
|
83
|
-
|
|
93
|
+
return new PopoverController({
|
|
84
94
|
id,
|
|
85
95
|
placement,
|
|
86
96
|
offset,
|
|
87
97
|
trapFocus,
|
|
88
98
|
closeOnOutsideClick,
|
|
89
99
|
closeOnEsc,
|
|
100
|
+
hooks,
|
|
90
101
|
onOpen,
|
|
91
102
|
onClose,
|
|
92
103
|
onStateChange
|
|
93
104
|
});
|
|
94
|
-
}
|
|
105
|
+
});
|
|
95
106
|
|
|
96
107
|
// ── DOM refs ─────────────────────────────────────────────────────────────────
|
|
97
108
|
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
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.
|
|
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).
|
|
103
112
|
|
|
104
|
-
let triggerWrapEl = $state<HTMLElement | null>(null);
|
|
105
113
|
let panelEl = $state<HTMLElement | null>(null);
|
|
106
114
|
|
|
107
|
-
$effect(() => {
|
|
108
|
-
if (triggerWrapEl) ctrl.attachTrigger(triggerWrapEl);
|
|
109
|
-
else ctrl.detachTrigger();
|
|
110
|
-
});
|
|
111
|
-
|
|
112
115
|
$effect(() => {
|
|
113
116
|
if (panelEl) ctrl.attachPanel(panelEl);
|
|
114
117
|
else ctrl.detachPanel();
|
|
@@ -205,12 +208,12 @@
|
|
|
205
208
|
|
|
206
209
|
<!--
|
|
207
210
|
Root wrapper: inline-block keeps it tight around the trigger so that
|
|
208
|
-
getBoundingClientRect() on
|
|
211
|
+
getBoundingClientRect() on it (wired via use:overlayTrigger) returns the trigger's actual rect.
|
|
209
212
|
|
|
210
213
|
Snippet contexts are passed INLINE — each is used exactly once in the template,
|
|
211
214
|
so storing them in $derived would add a reactive subscription with zero benefit.
|
|
212
215
|
-->
|
|
213
|
-
<span class="pop-root"
|
|
216
|
+
<span class="pop-root" use:overlayTrigger={ctrl}>
|
|
214
217
|
<!-- ── Trigger ─────────────────────────────────────────────────────────── -->
|
|
215
218
|
{#if triggerSnippet}
|
|
216
219
|
{@render triggerSnippet({
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
* {/snippet}
|
|
19
19
|
*/
|
|
20
20
|
import type { Snippet } from 'svelte';
|
|
21
|
-
import {
|
|
22
|
-
import type { ContentSnippetCtx } from '../types/types.js';
|
|
21
|
+
import type { ContentSnippetCtx } from '../types.js';
|
|
23
22
|
|
|
24
23
|
interface Props {
|
|
25
24
|
ctx: ContentSnippetCtx;
|
|
@@ -31,6 +30,14 @@
|
|
|
31
30
|
body?: Snippet<[ContentSnippetCtx]>;
|
|
32
31
|
/** Fallback when no body snippet is provided. */
|
|
33
32
|
children?: Snippet<[ContentSnippetCtx]>;
|
|
33
|
+
/**
|
|
34
|
+
* Overrides the default close-button glyph (a plain "X" character —
|
|
35
|
+
* no icon library dependency shipped by this package). Pass e.g. a
|
|
36
|
+
* `lucide-svelte` icon snippet from your own app to swap it in:
|
|
37
|
+
*
|
|
38
|
+
* {#snippet iconSnippet()}<X class="h-3 w-3" />{/snippet}
|
|
39
|
+
*/
|
|
40
|
+
iconSnippet?: Snippet;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
const {
|
|
@@ -40,7 +47,8 @@
|
|
|
40
47
|
showClose = false,
|
|
41
48
|
class: cls = '',
|
|
42
49
|
body,
|
|
43
|
-
children
|
|
50
|
+
children,
|
|
51
|
+
iconSnippet
|
|
44
52
|
}: Props = $props();
|
|
45
53
|
</script>
|
|
46
54
|
|
|
@@ -57,7 +65,11 @@
|
|
|
57
65
|
aria-label="Close"
|
|
58
66
|
onclick={() => ctx.ctrl.close()}
|
|
59
67
|
>
|
|
60
|
-
|
|
68
|
+
{#if iconSnippet}
|
|
69
|
+
{@render iconSnippet()}
|
|
70
|
+
{:else}
|
|
71
|
+
<span class="text-xs leading-none" aria-hidden="true">✕</span>
|
|
72
|
+
{/if}
|
|
61
73
|
</button>
|
|
62
74
|
{/if}
|
|
63
75
|
</div>
|