@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 +21 -0
- package/README.md +95 -0
- package/package.json +33 -0
- package/src/autocomplete_popover.svelte +187 -0
- package/src/click_popover.svelte +127 -0
- package/src/dropdown_menu.svelte +226 -0
- package/src/hover_popover.svelte +113 -0
- package/src/menu_item.svelte +102 -0
- package/src/menu_separator.svelte +27 -0
- package/src/mod.ts +8 -0
- package/src/popover.svelte +317 -0
- package/src/types.ts +14 -0
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>
|