@wentools/overlay 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 wentools
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,95 @@
1
+ # @wentools/overlay
2
+
3
+ Svelte 5 popover, dropdown menu, and tooltip components with CSS custom property theming.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @wentools/overlay
9
+ ```
10
+
11
+ Peer dependencies: `svelte >=5.0.0`, `@wentools/spatial >=0.2.0`, `@wentools/simmer >=0.1.0`
12
+
13
+ ## Components
14
+
15
+ | Component | Description |
16
+ | ---------------------- | -------------------------------------------------- |
17
+ | `Popover` | Base positioned overlay (positioning only) |
18
+ | `ClickPopover` | Click-triggered popover with toggle |
19
+ | `HoverPopover` | Hover/focus-triggered tooltip with debounce |
20
+ | `DropdownMenu` | Accessible menu with keyboard navigation |
21
+ | `AutocompletePopover` | Input-based suggestions popover |
22
+ | `MenuItem` | Menu item for DropdownMenu/AutocompletePopover |
23
+ | `MenuSeparator` | Divider line between menu items |
24
+
25
+ ## Usage
26
+
27
+ ```svelte
28
+ <script>
29
+ import { DropdownMenu, MenuItem, MenuSeparator } from '@wentools/overlay'
30
+ </script>
31
+
32
+ <DropdownMenu>
33
+ {#snippet trigger({ toggle, open })}
34
+ <button onclick={toggle}>Menu</button>
35
+ {/snippet}
36
+
37
+ {#snippet children({ close })}
38
+ <MenuItem onclick={() => { doAction(); close(); }}>Action</MenuItem>
39
+ <MenuSeparator />
40
+ <MenuItem onclick={close}>Cancel</MenuItem>
41
+ {/snippet}
42
+ </DropdownMenu>
43
+ ```
44
+
45
+ ## CSS Custom Properties
46
+
47
+ All components use a three-tier CSS fallback pattern:
48
+
49
+ ```
50
+ var(--popover-*, var(--design-system-*, hardcoded-fallback))
51
+ ```
52
+
53
+ This means:
54
+ 1. **Library tokens** (`--popover-*`) — set these to theme the library
55
+ 2. **Design system tokens** (`--color-bg-elevated`, etc.) — automatically picked up if your app defines them
56
+ 3. **Hardcoded fallbacks** — sensible defaults when nothing else is set
57
+
58
+ ### Token Reference
59
+
60
+ | Token | Fallback chain | Used by |
61
+ | ---------------------------- | --------------------------------------------------- | -------------------------- |
62
+ | `--popover-z-index` | `--layer-tooltip` → `1000` | Popover |
63
+ | `--popover-bg` | `--color-bg-elevated` → `#fff` | Click, Hover, Dropdown, AC |
64
+ | `--popover-color` | `--color-text-primary` → `inherit` | Hover |
65
+ | `--popover-border-width` | `--border-thickness-m` → `1px` | Click, Hover, Dropdown, AC |
66
+ | `--popover-border-color` | `--color-border-default` → `#e0e0e0` | Click, Hover, Dropdown, AC |
67
+ | `--popover-radius` | `--radius-m` → `8px` | Click, Dropdown, AC |
68
+ | `--popover-radius-sm` | `--radius-s` → `6px` | Hover, menu items |
69
+ | `--popover-padding` | `--space-xs` → `4px` | Hover, Dropdown, AC |
70
+ | `--popover-padding-lg` | `--space-s` → `8px` | Click, Hover, Dropdown, AC |
71
+ | `--popover-shadow` | `--shadow-layer-2` → `0 4px 12px rgba(0,0,0,0.12)` | Click, Dropdown, AC |
72
+ | `--popover-shadow-sm` | `--shadow-layer-1` → `0 2px 8px rgba(0,0,0,0.08)` | Hover |
73
+ | `--popover-item-hover-bg` | `--color-surface-hover` → `rgba(0,0,0,0.04)` | Dropdown, AC |
74
+ | `--popover-separator-color` | `--color-border-subtle` → `#e0e0e0` | Dropdown, MenuSeparator |
75
+
76
+ ### Consumer Scenarios
77
+
78
+ **No config needed** — hardcoded fallbacks provide a clean default look.
79
+
80
+ **Design system app** — if your app already defines `--color-bg-elevated`, `--radius-m`, etc., the components pick them up automatically.
81
+
82
+ **Custom theme** — set `--popover-*` tokens on a parent element:
83
+
84
+ ```css
85
+ :root {
86
+ --popover-bg: #1a1a2e;
87
+ --popover-border-color: #333;
88
+ --popover-radius: 12px;
89
+ --popover-shadow: 0 8px 24px rgba(0,0,0,0.3);
90
+ }
91
+ ```
92
+
93
+ ## License
94
+
95
+ MIT
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@wentools/overlay",
3
+ "version": "0.1.0",
4
+ "description": "Svelte 5 popover, dropdown menu, and tooltip components with CSS custom property theming",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://gitlab.com/wentools/overlay"
9
+ },
10
+ "keywords": [
11
+ "svelte",
12
+ "popover",
13
+ "dropdown",
14
+ "menu",
15
+ "tooltip",
16
+ "overlay",
17
+ "typescript"
18
+ ],
19
+ "type": "module",
20
+ "svelte": "./src/mod.ts",
21
+ "exports": {
22
+ ".": {
23
+ "svelte": "./src/mod.ts",
24
+ "types": "./src/mod.ts",
25
+ "default": "./src/mod.ts"
26
+ }
27
+ },
28
+ "peerDependencies": {
29
+ "svelte": ">=5.0.0",
30
+ "@wentools/spatial": ">=0.1.0",
31
+ "@wentools/simmer": ">=0.1.0"
32
+ }
33
+ }
@@ -0,0 +1,187 @@
1
+ <!--
2
+ @component
3
+ AutocompletePopover - A popover for input-based suggestions
4
+
5
+ Provides positioned suggestions below an input field with keyboard navigation support.
6
+ The input remains the primary focus while suggestions enhance the experience.
7
+
8
+ Perfect for: search autocomplete, tag pickers, user selectors, command palettes.
9
+
10
+ @example
11
+ ```svelte
12
+ <AutocompletePopover
13
+ open={showSuggestions}
14
+ onNavigate={handleNavigate}
15
+ onSelect={handleSelect}
16
+ >
17
+ {#snippet input()}
18
+ <TextInput bind:value={searchTerm} />
19
+ {/snippet}
20
+
21
+ {#snippet suggestions()}
22
+ {#each results as item}
23
+ <MenuItem onclick={() => selectItem(item)}>
24
+ {item.name}
25
+ </MenuItem>
26
+ {/each}
27
+ {/snippet}
28
+ </AutocompletePopover>
29
+ ```
30
+ -->
31
+ <script lang="ts">
32
+ import type { Snippet } from 'svelte'
33
+ import Popover from './popover.svelte'
34
+ import type { Position } from './types'
35
+
36
+ /**
37
+ * AutocompletePopover component props
38
+ */
39
+ interface Props {
40
+ /** Whether the suggestions popover is open */
41
+ open?: boolean
42
+ /** Position of the popover relative to input */
43
+ position?: Position
44
+ /** Offset from the input element in pixels */
45
+ offset?: number
46
+ /** Maximum width of suggestions content (scales with typography) */
47
+ maxWidth?: string
48
+ /** Minimum width of suggestions content (scales with typography) */
49
+ minWidth?: string
50
+ /** Maximum height of suggestions content (scales with typography) */
51
+ maxHeight?: string
52
+ /** CSS class for styling the popover */
53
+ class?: string
54
+ /** Display type for trigger wrapper - useful for flex layouts */
55
+ triggerDisplay?: 'inline-block' | 'block' | 'flex' | 'inline-flex'
56
+ /** Whether to close on click outside (default true) */
57
+ closeOnClickOutside?: boolean
58
+ /** Whether to close on escape key (default true) */
59
+ closeOnEscape?: boolean
60
+ /** Callback when open state changes */
61
+ onOpenChange?: (open: boolean) => void
62
+ /** Callback when user navigates with arrow keys */
63
+ onNavigate?: (direction: 'up' | 'down', event: KeyboardEvent) => void
64
+ /** Callback when user presses enter to select */
65
+ onSelect?: (event: KeyboardEvent) => void
66
+ /** Input element that triggers the suggestions */
67
+ input: Snippet
68
+ /** Suggestions content - typically MenuItem components */
69
+ suggestions: Snippet<[{ close: () => void }]>
70
+ }
71
+
72
+ let {
73
+ open = $bindable(false),
74
+ position = 'bottom-start',
75
+ offset = 4,
76
+ maxWidth = '60ch',
77
+ minWidth = '30ch',
78
+ maxHeight = '20ch',
79
+ class: className = '',
80
+ triggerDisplay = 'inline-block',
81
+ closeOnClickOutside = true,
82
+ closeOnEscape = true,
83
+ onOpenChange,
84
+ onNavigate,
85
+ onSelect,
86
+ input,
87
+ suggestions,
88
+ }: Props = $props()
89
+
90
+ let inputContainer: HTMLElement | undefined
91
+
92
+ const close = () => {
93
+ open = false
94
+ onOpenChange?.(false)
95
+ }
96
+
97
+ const handleKeydown = (event: KeyboardEvent) => {
98
+ if (!open) return
99
+
100
+ switch (event.key) {
101
+ case 'ArrowDown':
102
+ event.preventDefault()
103
+ onNavigate?.('down', event)
104
+ break
105
+ case 'ArrowUp':
106
+ event.preventDefault()
107
+ onNavigate?.('up', event)
108
+ break
109
+ case 'Enter':
110
+ if (open) {
111
+ event.preventDefault()
112
+ onSelect?.(event)
113
+ }
114
+ break
115
+ case 'Escape':
116
+ if (open && closeOnEscape) {
117
+ event.preventDefault()
118
+ close()
119
+ }
120
+ break
121
+ }
122
+ }
123
+
124
+ // Listen for keydown events on the input container
125
+ $effect(() => {
126
+ if (!inputContainer) return
127
+
128
+ inputContainer.addEventListener('keydown', handleKeydown)
129
+ return () => {
130
+ inputContainer?.removeEventListener('keydown', handleKeydown)
131
+ }
132
+ })
133
+ </script>
134
+
135
+ <Popover
136
+ bind:open
137
+ {position}
138
+ {offset}
139
+ {triggerDisplay}
140
+ {closeOnClickOutside}
141
+ {closeOnEscape}
142
+ onOpenChange={(newOpen) => {
143
+ open = newOpen
144
+ onOpenChange?.(newOpen)
145
+ }}
146
+ role="combobox"
147
+ ariaLabel="Autocomplete suggestions"
148
+ class="autocomplete-popover {className}"
149
+ >
150
+ {#snippet trigger()}
151
+ <div bind:this={inputContainer} class="autocomplete-input">
152
+ {@render input()}
153
+ </div>
154
+ {/snippet}
155
+
156
+ {#snippet content({ close })}
157
+ <div
158
+ class="autocomplete-suggestions"
159
+ style:max-width={maxWidth}
160
+ style:min-width={minWidth}
161
+ style:max-height={maxHeight}
162
+ >
163
+ {@render suggestions({ close })}
164
+ </div>
165
+ {/snippet}
166
+ </Popover>
167
+
168
+ <style>
169
+ .autocomplete-input {
170
+ /* Ensure input container is inline-block for proper positioning */
171
+ display: inline-block;
172
+ width: 100%;
173
+ }
174
+
175
+ .autocomplete-suggestions {
176
+ --menu-item-padding: var(--popover-padding, var(--space-xs, 4px)) var(--popover-padding-lg, var(--space-s, 8px));
177
+ --menu-item-hover-bg: var(--popover-item-hover-bg, var(--color-surface-hover, rgba(0,0,0,0.04)));
178
+ --menu-item-border-radius: var(--popover-radius-sm, var(--radius-s, 6px));
179
+
180
+ background: var(--popover-bg, var(--color-bg-elevated, #fff));
181
+ border: var(--popover-border-width, var(--border-thickness-m, 1px)) solid var(--popover-border-color, var(--color-border-default, #e0e0e0));
182
+ border-radius: var(--popover-radius, var(--radius-m, 8px));
183
+ padding: var(--popover-padding, var(--space-xs, 4px));
184
+ box-shadow: var(--popover-shadow, var(--shadow-layer-2, 0 4px 12px rgba(0,0,0,0.12)));
185
+ overflow-y: auto;
186
+ }
187
+ </style>
@@ -0,0 +1,127 @@
1
+ <!--
2
+ @component
3
+ ClickPopover - Click-triggered popover component
4
+
5
+ Provides a popover that opens/closes when the trigger is clicked.
6
+ Handles focus management and provides toggle functionality.
7
+
8
+ Perfect for: action menus, info popovers, quick settings panels.
9
+
10
+ @example
11
+ ```svelte
12
+ <ClickPopover>
13
+ {#snippet children({ toggle, open })}
14
+ <button onclick={toggle}>
15
+ Options {open ? '▼' : '▶'}
16
+ </button>
17
+ {/snippet}
18
+
19
+ {#snippet content({ close })}
20
+ <div>
21
+ <button onclick={close}>Action 1</button>
22
+ <button onclick={close}>Action 2</button>
23
+ </div>
24
+ {/snippet}
25
+ </ClickPopover>
26
+ ```
27
+ -->
28
+ <script lang="ts">
29
+ import type { Snippet } from 'svelte'
30
+ import Popover from './popover.svelte'
31
+ import type { Position } from './types'
32
+
33
+ interface Props {
34
+ /** Position of the popover */
35
+ position?: Position
36
+ /** Offset from trigger element */
37
+ offset?: number
38
+ /** Maximum width of popover content */
39
+ maxWidth?: string
40
+ /** CSS class for styling the popover */
41
+ class?: string
42
+ /** Whether the popover is disabled */
43
+ disabled?: boolean
44
+ /** Whether to close on click outside (default true) */
45
+ closeOnClickOutside?: boolean
46
+ /** Whether to close on escape key (default true) */
47
+ closeOnEscape?: boolean
48
+ /** Callback when open state changes */
49
+ onOpenChange?: (open: boolean) => void
50
+ /** Trigger element (what you click) */
51
+ children: Snippet<[{ toggle: () => void; open: boolean }]>
52
+ /** Popover content */
53
+ content: Snippet<[{ close: () => void; open: boolean }]>
54
+ }
55
+
56
+ let {
57
+ position = 'bottom-start',
58
+ offset = 4,
59
+ maxWidth = '50ch',
60
+ class: className = '',
61
+ disabled = false,
62
+ closeOnClickOutside = true,
63
+ closeOnEscape = true,
64
+ onOpenChange,
65
+ children,
66
+ content: _content,
67
+ }: Props = $props()
68
+
69
+ let open = $state(false)
70
+
71
+ const toggle = () => {
72
+ if (disabled) return
73
+ open = !open
74
+ onOpenChange?.(open)
75
+ }
76
+
77
+ const close = () => {
78
+ open = false
79
+ onOpenChange?.(false)
80
+ }
81
+ </script>
82
+
83
+ <Popover
84
+ bind:open
85
+ {position}
86
+ {offset}
87
+ {closeOnClickOutside}
88
+ {closeOnEscape}
89
+ onOpenChange={(newOpen) => {
90
+ open = newOpen
91
+ onOpenChange?.(newOpen)
92
+ }}
93
+ class="click-popover {className}"
94
+ >
95
+ {#snippet trigger()}
96
+ <div
97
+ onclick={toggle}
98
+ onkeydown={(e) => {
99
+ if (e.key === 'Enter' || e.key === ' ') {
100
+ e.preventDefault()
101
+ toggle()
102
+ }
103
+ }}
104
+ tabindex="0"
105
+ role="button"
106
+ aria-expanded={open}
107
+ >
108
+ {@render children({ toggle, open })}
109
+ </div>
110
+ {/snippet}
111
+
112
+ {#snippet content()}
113
+ <div class="click-popover-content" style:max-width={maxWidth}>
114
+ {@render _content({ close, open })}
115
+ </div>
116
+ {/snippet}
117
+ </Popover>
118
+
119
+ <style>
120
+ .click-popover-content {
121
+ background: var(--popover-bg, var(--color-bg-elevated, #fff));
122
+ border: var(--popover-border-width, var(--border-thickness-m, 1px)) solid var(--popover-border-color, var(--color-border-default, #e0e0e0));
123
+ border-radius: var(--popover-radius, var(--radius-m, 8px));
124
+ padding: var(--popover-padding-lg, var(--space-s, 8px));
125
+ box-shadow: var(--popover-shadow, var(--shadow-layer-2, 0 4px 12px rgba(0,0,0,0.12)));
126
+ }
127
+ </style>
@@ -0,0 +1,226 @@
1
+ <!--
2
+ @component
3
+ DropdownMenu - Accessible dropdown menu with keyboard navigation
4
+
5
+ Provides a click-triggered menu with full keyboard navigation (arrow keys, home/end, enter).
6
+ Follows ARIA menu patterns and manages focus automatically.
7
+
8
+ Perfect for: context menus, action menus, navigation dropdowns.
9
+
10
+ @example
11
+ ```svelte
12
+ <DropdownMenu>
13
+ {#snippet trigger({ toggle, open })}
14
+ <button onclick={toggle}>
15
+ Menu {open ? '▼' : '▶'}
16
+ </button>
17
+ {/snippet}
18
+
19
+ {#snippet children({ close })}
20
+ <MenuItem onclick={() => { action1(); close(); }}>
21
+ Action 1
22
+ </MenuItem>
23
+ <MenuItem onclick={() => { action2(); close(); }}>
24
+ Action 2
25
+ </MenuItem>
26
+ {/snippet}
27
+ </DropdownMenu>
28
+ ```
29
+ -->
30
+ <script lang="ts">
31
+ import type { Snippet } from 'svelte'
32
+ import Popover from './popover.svelte'
33
+ import type { Position } from './types'
34
+
35
+ interface Props {
36
+ /** Position of the dropdown */
37
+ position?: Position
38
+ /** Offset from trigger element */
39
+ offset?: number
40
+ /** Maximum width of dropdown content */
41
+ maxWidth?: string
42
+ /** CSS class for styling the dropdown */
43
+ class?: string
44
+ /** Whether the dropdown is disabled */
45
+ disabled?: boolean
46
+ /** Index of item to focus when opening. Defaults to 0 (first item). */
47
+ initialFocusIndex?: number
48
+ /** Callback when open state changes */
49
+ onOpenChange?: (open: boolean) => void
50
+ /** Trigger element (what you click) */
51
+ trigger: Snippet<[{ toggle: () => void; open: boolean }]>
52
+ /** Dropdown content - should contain MenuItem components */
53
+ children: Snippet<[{ close: () => void; open: boolean }]>
54
+ }
55
+
56
+ let {
57
+ position = 'bottom-start',
58
+ offset = 4,
59
+ maxWidth = '60ch',
60
+ class: className = '',
61
+ disabled = false,
62
+ initialFocusIndex = 0,
63
+ onOpenChange,
64
+ trigger: triggerSnippet,
65
+ children,
66
+ }: Props = $props()
67
+
68
+ let open = $state(false)
69
+ let menuElement: HTMLElement | undefined = undefined
70
+ let focusedIndex = $state(-1)
71
+
72
+ const toggle = () => {
73
+ if (disabled) return
74
+ open = !open
75
+ onOpenChange?.(open)
76
+
77
+ if (open) {
78
+ // Focus specified item when opening (defaults to first item)
79
+ focusedIndex = initialFocusIndex
80
+ setTimeout(() => focusItem(initialFocusIndex), 0)
81
+ } else {
82
+ focusedIndex = -1
83
+ }
84
+ }
85
+
86
+ const close = () => {
87
+ open = false
88
+ onOpenChange?.(false)
89
+ focusedIndex = -1
90
+ }
91
+
92
+ const getMenuItems = (): HTMLElement[] => {
93
+ if (!menuElement) return []
94
+ return Array.from(menuElement.querySelectorAll('[role="menuitem"]:not([disabled])'))
95
+ }
96
+
97
+ const focusItem = (index: number) => {
98
+ const items = getMenuItems()
99
+ if (items[index]) {
100
+ items[index].focus()
101
+ focusedIndex = index
102
+ }
103
+ }
104
+
105
+ const handleKeydown = (event: KeyboardEvent) => {
106
+ if (!open) return
107
+
108
+ const items = getMenuItems()
109
+ const currentIndex = focusedIndex
110
+
111
+ switch (event.key) {
112
+ case 'ArrowDown': {
113
+ event.preventDefault()
114
+ const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0
115
+ focusItem(nextIndex)
116
+ break
117
+ }
118
+
119
+ case 'ArrowUp': {
120
+ event.preventDefault()
121
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1
122
+ focusItem(prevIndex)
123
+ break
124
+ }
125
+
126
+ case 'Home':
127
+ event.preventDefault()
128
+ focusItem(0)
129
+ break
130
+
131
+ case 'End':
132
+ event.preventDefault()
133
+ focusItem(items.length - 1)
134
+ break
135
+
136
+ case 'Enter':
137
+ case ' ':
138
+ event.preventDefault()
139
+ if (items[currentIndex]) {
140
+ items[currentIndex].click()
141
+ }
142
+ break
143
+
144
+ case 'Escape':
145
+ event.preventDefault()
146
+ close()
147
+ break
148
+ }
149
+ }
150
+
151
+ // Handle when menu items get focus (via mouse or keyboard)
152
+ const handleItemFocus = (event: FocusEvent) => {
153
+ const items = getMenuItems()
154
+ const focusedItem = event.target as HTMLElement
155
+ const index = items.indexOf(focusedItem)
156
+ if (index !== -1) {
157
+ focusedIndex = index
158
+ }
159
+ }
160
+ </script>
161
+
162
+ <svelte:window onkeydown={handleKeydown} />
163
+
164
+ <Popover
165
+ bind:open
166
+ {position}
167
+ {offset}
168
+ closeOnClickOutside={true}
169
+ closeOnEscape={true}
170
+ onOpenChange={(newOpen) => {
171
+ open = newOpen
172
+ onOpenChange?.(newOpen)
173
+ if (!newOpen) {
174
+ focusedIndex = -1
175
+ }
176
+ }}
177
+ role="menu"
178
+ ariaLabel="Menu"
179
+ class="dropdown-menu {className}"
180
+ >
181
+ {#snippet trigger()}
182
+ <div
183
+ onclick={toggle}
184
+ onkeydown={(e) => {
185
+ if (e.key === 'Enter' || e.key === ' ') {
186
+ e.preventDefault()
187
+ toggle()
188
+ }
189
+ }}
190
+ tabindex="0"
191
+ role="button"
192
+ aria-haspopup="menu"
193
+ aria-expanded={open}
194
+ >
195
+ {@render triggerSnippet({ toggle, open })}
196
+ </div>
197
+ {/snippet}
198
+
199
+ {#snippet content()}
200
+ <div
201
+ bind:this={menuElement}
202
+ class="dropdown-menu-content"
203
+ style:max-width={maxWidth}
204
+ role="menu"
205
+ onfocus={handleItemFocus}
206
+ >
207
+ {@render children({ close, open })}
208
+ </div>
209
+ {/snippet}
210
+ </Popover>
211
+
212
+ <style>
213
+ .dropdown-menu-content {
214
+ --menu-item-padding: var(--popover-padding, var(--space-xs, 4px)) var(--popover-padding-lg, var(--space-s, 8px));
215
+ --menu-item-hover-bg: var(--popover-item-hover-bg, var(--color-surface-hover, rgba(0,0,0,0.04)));
216
+ --menu-item-border-radius: var(--popover-radius-sm, var(--radius-s, 6px));
217
+ --menu-separator-color: var(--popover-separator-color, var(--color-border-subtle, #e0e0e0));
218
+
219
+ background: var(--popover-bg, var(--color-bg-elevated, #fff));
220
+ border: var(--popover-border-width, var(--border-thickness-m, 1px)) solid var(--popover-border-color, var(--color-border-default, #e0e0e0));
221
+ border-radius: var(--popover-radius, var(--radius-m, 8px));
222
+ padding: var(--popover-padding, var(--space-xs, 4px));
223
+ box-shadow: var(--popover-shadow, var(--shadow-layer-2, 0 4px 12px rgba(0,0,0,0.12)));
224
+ min-width: fit-content;
225
+ }
226
+ </style>
@@ -0,0 +1,113 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+ import Popover from './popover.svelte'
4
+ import { debounce } from '@wentools/simmer'
5
+ import type { Position } from './types'
6
+
7
+ interface Props {
8
+ /** Delay before showing in milliseconds */
9
+ showDelay?: number
10
+ /** Delay before hiding in milliseconds */
11
+ hideDelay?: number
12
+ /** Position of the popover */
13
+ position?: Position
14
+ /** Offset from trigger element */
15
+ offset?: number
16
+ /** Maximum width of tooltip content */
17
+ maxWidth?: string
18
+ /** CSS class for styling the popover */
19
+ class?: string
20
+ /** Whether the popover is disabled */
21
+ disabled?: boolean
22
+ /** Trigger element (what you hover over) */
23
+ children: Snippet
24
+ /** Popover content */
25
+ content: Snippet<[{ close: () => void; open: boolean }]>
26
+ }
27
+
28
+ let {
29
+ showDelay = 400,
30
+ hideDelay = 200,
31
+ position = 'top',
32
+ offset = 8,
33
+ maxWidth = '35ch',
34
+ class: className = '',
35
+ disabled = false,
36
+ children,
37
+ content: _content,
38
+ }: Props = $props()
39
+
40
+ let open = $state(false)
41
+ let isHovering = $state(false)
42
+
43
+ const debouncedShow = $derived(
44
+ debounce(() => {
45
+ if (isHovering && !disabled) {
46
+ open = true
47
+ }
48
+ }, showDelay),
49
+ )
50
+
51
+ const debouncedHide = $derived(
52
+ debounce(() => {
53
+ if (!isHovering) {
54
+ open = false
55
+ }
56
+ }, hideDelay),
57
+ )
58
+
59
+ const handleMouseEnter = () => {
60
+ isHovering = true
61
+ debouncedShow()
62
+ }
63
+
64
+ const handleMouseLeave = () => {
65
+ isHovering = false
66
+ debouncedHide()
67
+ }
68
+ </script>
69
+
70
+ <Popover
71
+ bind:open
72
+ {position}
73
+ {offset}
74
+ closeOnClickOutside={false}
75
+ closeOnEscape={false}
76
+ role="tooltip"
77
+ class="hover-popover {className}"
78
+ >
79
+ {#snippet trigger()}
80
+ <div
81
+ onmouseenter={handleMouseEnter}
82
+ onmouseleave={handleMouseLeave}
83
+ onfocus={handleMouseEnter}
84
+ onblur={handleMouseLeave}
85
+ role="group"
86
+ >
87
+ {@render children()}
88
+ </div>
89
+ {/snippet}
90
+
91
+ {#snippet content({ close })}
92
+ <div
93
+ class="hover-popover-content"
94
+ onmouseenter={handleMouseEnter}
95
+ onmouseleave={handleMouseLeave}
96
+ style:max-width={maxWidth}
97
+ role="presentation"
98
+ >
99
+ {@render _content({ close, open })}
100
+ </div>
101
+ {/snippet}
102
+ </Popover>
103
+
104
+ <style>
105
+ .hover-popover-content {
106
+ background: var(--popover-bg, var(--color-bg-elevated, #fff));
107
+ color: var(--popover-color, var(--color-text-primary, inherit));
108
+ border: var(--popover-border-width, var(--border-thickness-m, 1px)) solid var(--popover-border-color, var(--color-border-default, #e0e0e0));
109
+ padding: var(--popover-padding, var(--space-xs, 4px)) var(--popover-padding-lg, var(--space-s, 8px));
110
+ border-radius: var(--popover-radius-sm, var(--radius-s, 6px));
111
+ box-shadow: var(--popover-shadow-sm, var(--shadow-layer-1, 0 2px 8px rgba(0,0,0,0.08)));
112
+ }
113
+ </style>
@@ -0,0 +1,102 @@
1
+ <!--
2
+ @component
3
+ MenuItem - Individual menu item for DropdownMenu
4
+
5
+ A styled menu item that works with DropdownMenu's keyboard navigation.
6
+ Follows ARIA menuitem patterns and integrates with focus management.
7
+
8
+ Perfect for: actions in dropdown menus, selectable options.
9
+
10
+ @example
11
+ ```svelte
12
+ <MenuItem onclick={() => handleAction()}>
13
+ Delete Item
14
+ </MenuItem>
15
+
16
+ <MenuItem disabled>
17
+ Unavailable Action
18
+ </MenuItem>
19
+ ```
20
+ -->
21
+ <script lang="ts">
22
+ import type { Snippet } from 'svelte'
23
+
24
+ interface Props {
25
+ /** Whether the menu item is disabled */
26
+ disabled?: boolean
27
+ /** Whether the menu item is highlighted (keyboard navigation) */
28
+ highlighted?: boolean
29
+ /** Color theme for special items (e.g., 'var(--blue-ch)' for create, 'var(--red-ch)' for destructive) */
30
+ color?: string
31
+ /** Click handler */
32
+ onclick?: () => void
33
+ /** CSS class for styling */
34
+ class?: string
35
+ /** Menu item content */
36
+ children: Snippet
37
+ }
38
+
39
+ let {
40
+ disabled = false,
41
+ highlighted = false,
42
+ color,
43
+ onclick,
44
+ class: className = '',
45
+ children,
46
+ }: Props = $props()
47
+ </script>
48
+
49
+ <button
50
+ class="menu-item {className}"
51
+ class:highlighted
52
+ class:disabled
53
+ {disabled}
54
+ role="menuitem"
55
+ tabindex="-1"
56
+ style:--item-color={color}
57
+ onclick={() => {
58
+ if (!disabled) {
59
+ onclick?.()
60
+ }
61
+ }}
62
+ >
63
+ {@render children()}
64
+ </button>
65
+
66
+ <style>
67
+ .menu-item {
68
+ /* Use CSS custom properties set by parent DropdownMenu */
69
+ padding: var(--menu-item-padding, var(--space-xs, 4px) var(--space-s, 8px));
70
+ border-radius: var(--menu-item-border-radius, var(--radius-s, 6px));
71
+
72
+ /* Base styling */
73
+ display: block;
74
+ width: 100%;
75
+ border: none;
76
+ background: none;
77
+ text-align: left;
78
+ cursor: pointer;
79
+ color: inherit;
80
+ transition: background-color 0.2s ease;
81
+ }
82
+
83
+ /* Default hover/focus states */
84
+ .menu-item:hover,
85
+ .menu-item:focus,
86
+ .menu-item.highlighted {
87
+ background: var(--menu-item-hover-bg, var(--color-surface-hover, rgba(0,0,0,0.04)));
88
+ outline: none;
89
+ }
90
+
91
+ /* Disabled states */
92
+ .menu-item.disabled {
93
+ opacity: 0.5;
94
+ cursor: not-allowed;
95
+ }
96
+
97
+ .menu-item.disabled:hover,
98
+ .menu-item.disabled:focus,
99
+ .menu-item.disabled.highlighted {
100
+ background: none;
101
+ }
102
+ </style>
@@ -0,0 +1,27 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+
4
+ interface Props {
5
+ /** Custom separator content. Defaults to a simple horizontal line. */
6
+ children?: Snippet
7
+ }
8
+
9
+ let { children }: Props = $props()
10
+ </script>
11
+
12
+ {#if children}
13
+ <div class="menu-separator" role="separator">
14
+ {@render children()}
15
+ </div>
16
+ {:else}
17
+ <hr class="menu-separator" />
18
+ {/if}
19
+
20
+ <style>
21
+ .menu-separator {
22
+ border: none;
23
+ height: 1px;
24
+ background: var(--popover-separator-color, var(--color-border-subtle, #e0e0e0));
25
+ margin: var(--popover-padding, var(--space-xs, 4px)) 0;
26
+ }
27
+ </style>
package/src/mod.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { default as Popover } from './popover.svelte'
2
+ export { default as HoverPopover } from './hover_popover.svelte'
3
+ export { default as ClickPopover } from './click_popover.svelte'
4
+ export { default as AutocompletePopover } from './autocomplete_popover.svelte'
5
+ export { default as DropdownMenu } from './dropdown_menu.svelte'
6
+ export { default as MenuItem } from './menu_item.svelte'
7
+ export { default as MenuSeparator } from './menu_separator.svelte'
8
+ export type { Position } from './types'
@@ -0,0 +1,317 @@
1
+ <!--
2
+ @component
3
+ Popover - Base positioned overlay component
4
+
5
+ Low-level popover that provides positioning, outside click handling, and escape key support.
6
+ Use higher-level components like ClickPopover, DropdownMenu, or AutocompletePopover unless you need custom behavior.
7
+
8
+ Perfect for: building custom popover patterns, tooltips, custom dropdowns.
9
+
10
+ @example
11
+ ```svelte
12
+ <Popover bind:open position="bottom-start">
13
+ {#snippet trigger()}
14
+ <button>Click me</button>
15
+ {/snippet}
16
+
17
+ {#snippet content({ close })}
18
+ <div>Custom popover content</div>
19
+ {/snippet}
20
+ </Popover>
21
+ ```
22
+ -->
23
+ <script lang="ts">
24
+ import { onMount, tick } from 'svelte'
25
+ import type { Snippet } from 'svelte'
26
+ import type { Position } from './types'
27
+ import { centerHorizontally, centerVertically } from '@wentools/spatial'
28
+
29
+ interface Props {
30
+ /** Whether the popover is open */
31
+ open?: boolean
32
+ /** Position of the popover relative to trigger */
33
+ position?: Position
34
+ /** Offset from the trigger element in pixels */
35
+ offset?: number
36
+ /** Minimum distance from viewport edge in pixels */
37
+ viewportMargin?: number
38
+ /** Whether to close on click outside */
39
+ closeOnClickOutside?: boolean
40
+ /** Whether to close on escape key */
41
+ closeOnEscape?: boolean
42
+ /** ARIA role for accessibility */
43
+ role?: string
44
+ /** ARIA label for accessibility */
45
+ ariaLabel?: string
46
+ /** CSS class for the popover content */
47
+ class?: string
48
+ /** Display type for trigger wrapper - useful for flex layouts */
49
+ triggerDisplay?: 'inline-block' | 'block' | 'flex' | 'inline-flex' | 'contents'
50
+ /** Flex value for trigger wrapper when in flex container */
51
+ triggerFlex?: string
52
+ /** Callback when open state changes */
53
+ onOpenChange?: (open: boolean) => void
54
+ /** Trigger element */
55
+ trigger: Snippet
56
+ /** Popover content */
57
+ content: Snippet<[{ close: () => void }]>
58
+ }
59
+
60
+ let {
61
+ open = $bindable(false),
62
+ position = 'auto',
63
+ offset = 8,
64
+ viewportMargin = 0,
65
+ closeOnClickOutside = true,
66
+ closeOnEscape = true,
67
+ role,
68
+ ariaLabel,
69
+ class: className = '',
70
+ triggerDisplay = 'inline-block',
71
+ triggerFlex,
72
+ onOpenChange,
73
+ trigger,
74
+ content,
75
+ }: Props = $props()
76
+
77
+ // DOM element references (don't need $state)
78
+ let triggerElement: HTMLElement | undefined = undefined
79
+ let popoverElement: HTMLElement | undefined = $state(undefined)
80
+
81
+ // Reactive state for positioning
82
+ let positionX = $state(0)
83
+ let positionY = $state(0)
84
+ let actualPosition = $state<Position>(position)
85
+
86
+ const close = () => {
87
+ open = false
88
+ onOpenChange?.(false)
89
+ }
90
+
91
+ const calculatePosition = () => {
92
+ if (!triggerElement || !popoverElement) return
93
+
94
+ const triggerRect = triggerElement.getBoundingClientRect()
95
+ const popoverRect = popoverElement.getBoundingClientRect()
96
+ const viewport = {
97
+ width: window.innerWidth,
98
+ height: window.innerHeight,
99
+ }
100
+
101
+ let newX = 0
102
+ let newY = 0
103
+ let newPosition = position
104
+
105
+ // Helper function to check if a position fits
106
+ const canFit = (pos: Position): boolean => {
107
+ switch (pos) {
108
+ case 'top':
109
+ case 'top-start':
110
+ case 'top-end':
111
+ return triggerRect.top - offset - popoverRect.height >= viewportMargin
112
+ case 'bottom':
113
+ case 'bottom-start':
114
+ case 'bottom-end':
115
+ return (
116
+ triggerRect.bottom + offset + popoverRect.height <= viewport.height - viewportMargin
117
+ )
118
+ case 'left':
119
+ case 'left-start':
120
+ case 'left-end':
121
+ return triggerRect.left - offset - popoverRect.width >= viewportMargin
122
+ case 'right':
123
+ case 'right-start':
124
+ case 'right-end':
125
+ return triggerRect.right + offset + popoverRect.width <= viewport.width - viewportMargin
126
+ default:
127
+ return false
128
+ }
129
+ }
130
+
131
+ // Smart positioning with fallbacks
132
+ if (position === 'auto') {
133
+ // Preferred order: bottom, top, right, left (with all variants)
134
+ const positions: Position[] = [
135
+ 'bottom',
136
+ 'bottom-start',
137
+ 'bottom-end',
138
+ 'top',
139
+ 'top-start',
140
+ 'top-end',
141
+ 'right',
142
+ 'right-start',
143
+ 'right-end',
144
+ 'left',
145
+ 'left-start',
146
+ 'left-end',
147
+ ]
148
+
149
+ newPosition = positions.find((pos) => canFit(pos)) ?? 'bottom'
150
+ } else {
151
+ newPosition = position
152
+ }
153
+
154
+ // Calculate position based on determined placement
155
+ switch (newPosition) {
156
+ // Top positions
157
+ case 'top':
158
+ newX = centerHorizontally(triggerRect, popoverRect)
159
+ newY = triggerRect.top - offset - popoverRect.height
160
+ break
161
+ case 'top-start':
162
+ newX = triggerRect.left
163
+ newY = triggerRect.top - offset - popoverRect.height
164
+ break
165
+ case 'top-end':
166
+ newX = triggerRect.right - popoverRect.width
167
+ newY = triggerRect.top - offset - popoverRect.height
168
+ break
169
+
170
+ // Bottom positions
171
+ case 'bottom':
172
+ newX = centerHorizontally(triggerRect, popoverRect)
173
+ newY = triggerRect.bottom + offset
174
+ break
175
+ case 'bottom-start':
176
+ newX = triggerRect.left
177
+ newY = triggerRect.bottom + offset
178
+ break
179
+ case 'bottom-end':
180
+ newX = triggerRect.right - popoverRect.width
181
+ newY = triggerRect.bottom + offset
182
+ break
183
+
184
+ // Left positions
185
+ case 'left':
186
+ newX = triggerRect.left - offset - popoverRect.width
187
+ newY = centerVertically(triggerRect, popoverRect)
188
+ break
189
+ case 'left-start':
190
+ newX = triggerRect.left - offset - popoverRect.width
191
+ newY = triggerRect.top
192
+ break
193
+ case 'left-end':
194
+ newX = triggerRect.left - offset - popoverRect.width
195
+ newY = triggerRect.bottom - popoverRect.height
196
+ break
197
+
198
+ // Right positions
199
+ case 'right':
200
+ newX = triggerRect.right + offset
201
+ newY = centerVertically(triggerRect, popoverRect)
202
+ break
203
+ case 'right-start':
204
+ newX = triggerRect.right + offset
205
+ newY = triggerRect.top
206
+ break
207
+ case 'right-end':
208
+ newX = triggerRect.right + offset
209
+ newY = triggerRect.bottom - popoverRect.height
210
+ break
211
+ }
212
+
213
+ // Ensure popover stays within viewport
214
+ newX = Math.max(
215
+ viewportMargin,
216
+ Math.min(newX, viewport.width - popoverRect.width - viewportMargin),
217
+ )
218
+ newY = Math.max(
219
+ viewportMargin,
220
+ Math.min(newY, viewport.height - popoverRect.height - viewportMargin),
221
+ )
222
+
223
+ positionX = newX
224
+ positionY = newY
225
+ actualPosition = newPosition
226
+ }
227
+
228
+ const handleClickOutside = (event: MouseEvent) => {
229
+ if (!open || !closeOnClickOutside) return
230
+
231
+ const target = event.target as Node
232
+ if (
233
+ popoverElement &&
234
+ !popoverElement.contains(target) &&
235
+ triggerElement &&
236
+ !triggerElement.contains(target)
237
+ ) {
238
+ close()
239
+ }
240
+ }
241
+
242
+ const handleKeydown = (event: KeyboardEvent) => {
243
+ if (!open || !closeOnEscape) return
244
+
245
+ if (event.key === 'Escape') {
246
+ event.preventDefault()
247
+ close()
248
+ }
249
+ }
250
+
251
+ // Position popover when it opens
252
+ $effect(() => {
253
+ if (open && triggerElement && popoverElement) {
254
+ tick().then(() => {
255
+ calculatePosition()
256
+ })
257
+ }
258
+ })
259
+
260
+ onMount(() => {
261
+ const cleanup: (() => void)[] = []
262
+
263
+ if (closeOnClickOutside) {
264
+ document.addEventListener('click', handleClickOutside)
265
+ cleanup.push(() => document.removeEventListener('click', handleClickOutside))
266
+ }
267
+
268
+ if (closeOnEscape) {
269
+ document.addEventListener('keydown', handleKeydown)
270
+ cleanup.push(() => document.removeEventListener('keydown', handleKeydown))
271
+ }
272
+
273
+ return () => {
274
+ cleanup.forEach((fn) => fn())
275
+ }
276
+ })
277
+ </script>
278
+
279
+ <svelte:window onresize={calculatePosition} onscroll={calculatePosition} />
280
+
281
+ <div
282
+ bind:this={triggerElement}
283
+ class="popover-trigger"
284
+ style:--trigger-display={triggerDisplay}
285
+ style:--trigger-flex={triggerFlex}
286
+ >
287
+ {@render trigger()}
288
+ </div>
289
+
290
+ {#if open}
291
+ <div
292
+ bind:this={popoverElement}
293
+ class="popover-content {className}"
294
+ {role}
295
+ aria-label={ariaLabel}
296
+ data-position={actualPosition}
297
+ style:--popover-x="{positionX}px"
298
+ style:--popover-y="{positionY}px"
299
+ >
300
+ {@render content({ close })}
301
+ </div>
302
+ {/if}
303
+
304
+ <style>
305
+ .popover-trigger {
306
+ display: var(--trigger-display, inline-block);
307
+ flex: var(--trigger-flex);
308
+ }
309
+
310
+ .popover-content {
311
+ /* Positioning only - no visual styling */
312
+ position: fixed;
313
+ top: var(--popover-y);
314
+ left: var(--popover-x);
315
+ z-index: var(--popover-z-index, var(--layer-tooltip, 1000));
316
+ }
317
+ </style>
package/src/types.ts ADDED
@@ -0,0 +1,14 @@
1
+ export type Position =
2
+ | 'top'
3
+ | 'top-start'
4
+ | 'top-end'
5
+ | 'bottom'
6
+ | 'bottom-start'
7
+ | 'bottom-end'
8
+ | 'left'
9
+ | 'left-start'
10
+ | 'left-end'
11
+ | 'right'
12
+ | 'right-start'
13
+ | 'right-end'
14
+ | 'auto'