@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 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`. Ships with `lucide-svelte` as a regular
26
- dependency (used for the built-in close icon).
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
 
@@ -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
- * • Wires trigger and panel DOM refs via $effect (required — bind:this is async).
7
+ * • Trigger DOM ref: `use:overlayTrigger` (a plain Svelte action, no
8
+ * $effect needed — see actions.ts). Panel DOM ref: still $effect,
9
+ * since it's a $bindable prop from a child component, not a node in
10
+ * this component's own template (actions can only attach to elements
11
+ * in the same template).
8
12
  * • Passes inline snippet contexts (no $derived — each used once, caching wastes).
9
13
  * • Panel renders only while ctrl.isVisible (open + exit-animation).
10
14
  *
11
15
  * $effect usage (justified):
12
- * 1. triggerWrapEl wiring — bind:this is async; must react to ref being set.
13
- * 2. panelEl wiring — panel is conditionally mounted; $effect detects mount.
14
- * 3. controlled-mode sync — external prop can change at any time.
16
+ * 1. panelEl wiring — panel is conditionally mounted; $effect detects mount.
17
+ * 2. controlled-mode sync — external prop can change at any time.
15
18
  *
16
19
  * Snippets (Bits UI style):
17
20
  * {#snippet trigger(ctx: TriggerSnippetCtx)} — render the trigger element
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/types.js';
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/config.js';
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
- let ctrl: PopoverController;
78
-
79
- if (injectedCtrl instanceof PopoverController) {
80
- ctrl = injectedCtrl;
81
- } else {
88
+ const ctrl: PopoverController = untrack(() => {
89
+ if (injectedCtrl instanceof PopoverController) {
90
+ return injectedCtrl;
91
+ }
82
92
  owned = true;
83
- ctrl = new PopoverController({
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
- // 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.
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 triggerWrapEl returns the trigger's actual rect.
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" bind:this={triggerWrapEl}>
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 { X } from 'lucide-svelte';
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
- <X class="h-3 w-3" aria-hidden="true" />
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>
@@ -12,7 +12,7 @@
12
12
  * {/snippet}
13
13
  */
14
14
  import type { Snippet } from 'svelte';
15
- import type { TriggerSnippetCtx } from '../types/types.js';
15
+ import type { TriggerSnippetCtx } from '../types.js';
16
16
 
17
17
  interface Props {
18
18
  ctx: TriggerSnippetCtx;