@insymetri/styleguide 0.1.35 → 0.1.37
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/dist/IIContextMenu/IIContextMenu.svelte +223 -98
- package/dist/IIContextMenu/IIContextMenu.svelte.d.ts +13 -0
- package/dist/IIContextMenu/IIContextMenuStories.svelte +8 -0
- package/dist/IIContextMenu/IIContextMenuSub.svelte +131 -0
- package/dist/IIContextMenu/IIContextMenuSub.svelte.d.ts +13 -0
- package/dist/IIContextMenu/IIContextMenuSubSimple.svelte +137 -0
- package/dist/IIContextMenu/IIContextMenuSubSimple.svelte.d.ts +13 -0
- package/dist/IIDropdownInput/IIDropdownInput.svelte +161 -60
- package/dist/IIDropdownInput/IIDropdownInput.svelte.d.ts +6 -9
- package/dist/IIDropdownMenu/IIDropdownMenu.svelte +278 -89
- package/dist/IIDropdownMenu/IIDropdownMenu.svelte.d.ts +4 -17
- package/dist/IIDropdownMenu/IIDropdownMenuStories.svelte +20 -0
- package/dist/IIDropdownMenu/IIDropdownMenuSub.svelte +131 -0
- package/dist/IIDropdownMenu/IIDropdownMenuSub.svelte.d.ts +13 -0
- package/dist/IIDropdownMenu/IIDropdownMenuSubSimple.svelte +134 -0
- package/dist/IIDropdownMenu/IIDropdownMenuSubSimple.svelte.d.ts +13 -0
- package/dist/style/colors.css +1 -1
- package/dist/style/tailwind/animations.js +1 -1
- package/dist/style/tailwind/shadows.d.ts +1 -0
- package/dist/style/tailwind/shadows.js +3 -1
- package/dist/utils/menu/MenuItemContent.svelte +21 -0
- package/dist/utils/menu/MenuItemContent.svelte.d.ts +9 -0
- package/dist/utils/menu/MenuNoResults.svelte +4 -0
- package/dist/utils/menu/MenuNoResults.svelte.d.ts +18 -0
- package/dist/utils/menu/MenuSearchInput.svelte +25 -0
- package/dist/utils/menu/MenuSearchInput.svelte.d.ts +9 -0
- package/dist/utils/menu/index.d.ts +8 -0
- package/dist/utils/menu/index.js +8 -0
- package/dist/utils/menu/menu-search.svelte.d.ts +53 -0
- package/dist/utils/menu/menu-search.svelte.js +138 -0
- package/dist/utils/menu/menu-styles.d.ts +16 -0
- package/dist/utils/menu/menu-styles.js +19 -0
- package/dist/utils/menu/use-click-outside.d.ts +9 -0
- package/dist/utils/menu/use-click-outside.js +45 -0
- package/dist/utils/menu/use-floating.svelte.d.ts +13 -0
- package/dist/utils/menu/use-floating.svelte.js +55 -0
- package/dist/utils/menu/use-portal.d.ts +3 -0
- package/dist/utils/menu/use-portal.js +8 -0
- package/package.json +8 -1
|
@@ -1,45 +1,20 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type {Snippet} from 'svelte'
|
|
3
|
-
import {
|
|
3
|
+
import type {VirtualElement} from '@floating-ui/dom'
|
|
4
4
|
import {cn} from '../utils/cn'
|
|
5
5
|
import {IIIcon} from '../IIIcon'
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
disabled?: boolean
|
|
12
|
-
variant?: 'default' | 'destructive'
|
|
13
|
-
shortcut?: string
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
type SeparatorEntry = {
|
|
17
|
-
type: 'separator'
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
type GroupEntry = {
|
|
21
|
-
type: 'group'
|
|
22
|
-
heading?: string
|
|
23
|
-
items: (Item | SubEntry)[]
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
type SubEntry = {
|
|
27
|
-
type: 'sub'
|
|
28
|
-
label: string
|
|
29
|
-
icon?: Snippet
|
|
30
|
-
disabled?: boolean
|
|
31
|
-
shortcut?: string
|
|
32
|
-
items: MenuEntry[]
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
type MenuEntry = Item | SeparatorEntry | GroupEntry | SubEntry
|
|
6
|
+
import {isItem, isGroup, isSub, isSeparator, menuStyles, menuItemClass, type MenuItem, type MenuEntry, type SubEntry} from '../utils/menu'
|
|
7
|
+
import {useFloating, portal, clickOutside} from '../utils/menu'
|
|
8
|
+
import MenuItemContent from '../utils/menu/MenuItemContent.svelte'
|
|
9
|
+
import IIContextMenuSub from './IIContextMenuSub.svelte'
|
|
10
|
+
import IIContextMenuSubSimple from './IIContextMenuSubSimple.svelte'
|
|
36
11
|
|
|
37
12
|
type Props = {
|
|
38
13
|
items: MenuEntry[]
|
|
39
14
|
onSelect: (value: string) => void
|
|
40
15
|
open?: boolean
|
|
41
16
|
children: Snippet
|
|
42
|
-
renderItem?: Snippet<[
|
|
17
|
+
renderItem?: Snippet<[MenuItem]>
|
|
43
18
|
class?: string
|
|
44
19
|
}
|
|
45
20
|
|
|
@@ -52,103 +27,245 @@
|
|
|
52
27
|
class: className,
|
|
53
28
|
}: Props = $props()
|
|
54
29
|
|
|
30
|
+
let virtualRef = $state<VirtualElement | null>(null)
|
|
31
|
+
let floatingEl = $state<HTMLElement | null>(null)
|
|
32
|
+
let previousFocus = $state<HTMLElement | null>(null)
|
|
33
|
+
let openSubIndex = $state<number | null>(null)
|
|
34
|
+
let subTriggerEls: Record<number, HTMLElement | null> = {}
|
|
35
|
+
|
|
36
|
+
useFloating({
|
|
37
|
+
reference: () => virtualRef,
|
|
38
|
+
floating: () => floatingEl,
|
|
39
|
+
placement: 'bottom-start',
|
|
40
|
+
offset: 4,
|
|
41
|
+
shift: {padding: 8},
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
function handleContextMenu(e: MouseEvent) {
|
|
45
|
+
e.preventDefault()
|
|
46
|
+
previousFocus = document.activeElement as HTMLElement
|
|
47
|
+
openSubIndex = null
|
|
48
|
+
virtualRef = {
|
|
49
|
+
getBoundingClientRect: () => ({
|
|
50
|
+
x: e.clientX,
|
|
51
|
+
y: e.clientY,
|
|
52
|
+
width: 0,
|
|
53
|
+
height: 0,
|
|
54
|
+
top: e.clientY,
|
|
55
|
+
left: e.clientX,
|
|
56
|
+
right: e.clientX,
|
|
57
|
+
bottom: e.clientY,
|
|
58
|
+
}),
|
|
59
|
+
}
|
|
60
|
+
open = true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function close() {
|
|
64
|
+
open = false
|
|
65
|
+
openSubIndex = null
|
|
66
|
+
previousFocus?.focus()
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
function handleSelect(value: string) {
|
|
56
70
|
onSelect(value)
|
|
57
|
-
|
|
71
|
+
close()
|
|
58
72
|
}
|
|
59
73
|
|
|
60
|
-
|
|
61
|
-
|
|
74
|
+
// Focus first item when menu opens
|
|
75
|
+
$effect(() => {
|
|
76
|
+
if (open && floatingEl) {
|
|
77
|
+
requestAnimationFrame(() => {
|
|
78
|
+
const first = floatingEl?.querySelector<HTMLElement>('[role="menuitem"]:not([data-disabled])')
|
|
79
|
+
first?.focus()
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
function getMenuItems(): HTMLElement[] {
|
|
85
|
+
if (!floatingEl) return []
|
|
86
|
+
return Array.from(floatingEl.querySelectorAll<HTMLElement>(':scope > [role="menuitem"]:not([data-disabled]), :scope > [role="group"] > [role="menuitem"]:not([data-disabled])'))
|
|
62
87
|
}
|
|
63
88
|
|
|
64
|
-
function
|
|
65
|
-
|
|
89
|
+
function focusItem(items: HTMLElement[], currentIndex: number, direction: 1 | -1) {
|
|
90
|
+
if (items.length === 0) return
|
|
91
|
+
const nextIndex = direction === 1
|
|
92
|
+
? (currentIndex >= items.length - 1 ? 0 : currentIndex + 1)
|
|
93
|
+
: (currentIndex <= 0 ? items.length - 1 : currentIndex - 1)
|
|
94
|
+
items[nextIndex]?.focus()
|
|
66
95
|
}
|
|
67
96
|
|
|
68
|
-
function
|
|
69
|
-
|
|
97
|
+
function openAndFocusSub(trigger: HTMLElement) {
|
|
98
|
+
trigger.click()
|
|
99
|
+
requestAnimationFrame(() => {
|
|
100
|
+
const subContent = document.querySelector('[data-menu-sub-content]')
|
|
101
|
+
const input = subContent?.querySelector('input') as HTMLInputElement
|
|
102
|
+
if (input) {
|
|
103
|
+
input.focus()
|
|
104
|
+
} else {
|
|
105
|
+
const firstItem = subContent?.querySelector('[role="menuitem"]:not([data-disabled])') as HTMLElement
|
|
106
|
+
firstItem?.focus()
|
|
107
|
+
}
|
|
108
|
+
})
|
|
70
109
|
}
|
|
71
110
|
|
|
72
|
-
function
|
|
73
|
-
|
|
111
|
+
function handleMenuKeydown(e: KeyboardEvent) {
|
|
112
|
+
const menuItems = getMenuItems()
|
|
113
|
+
const currentIndex = menuItems.indexOf(document.activeElement as HTMLElement)
|
|
114
|
+
|
|
115
|
+
switch (e.key) {
|
|
116
|
+
case 'ArrowDown':
|
|
117
|
+
e.preventDefault()
|
|
118
|
+
focusItem(menuItems, currentIndex, 1)
|
|
119
|
+
break
|
|
120
|
+
case 'ArrowUp':
|
|
121
|
+
e.preventDefault()
|
|
122
|
+
focusItem(menuItems, currentIndex, -1)
|
|
123
|
+
break
|
|
124
|
+
case 'Tab':
|
|
125
|
+
e.preventDefault()
|
|
126
|
+
e.stopImmediatePropagation()
|
|
127
|
+
focusItem(menuItems, currentIndex, e.shiftKey ? -1 : 1)
|
|
128
|
+
break
|
|
129
|
+
case 'Enter':
|
|
130
|
+
case ' ': {
|
|
131
|
+
e.preventDefault()
|
|
132
|
+
const focused = document.activeElement as HTMLElement
|
|
133
|
+
if (focused?.hasAttribute('aria-haspopup')) {
|
|
134
|
+
openAndFocusSub(focused)
|
|
135
|
+
} else {
|
|
136
|
+
focused?.click()
|
|
137
|
+
}
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
case 'ArrowRight': {
|
|
141
|
+
const focused = document.activeElement as HTMLElement
|
|
142
|
+
if (focused?.hasAttribute('aria-haspopup')) {
|
|
143
|
+
e.preventDefault()
|
|
144
|
+
openAndFocusSub(focused)
|
|
145
|
+
}
|
|
146
|
+
break
|
|
147
|
+
}
|
|
148
|
+
case 'Escape':
|
|
149
|
+
e.preventDefault()
|
|
150
|
+
close()
|
|
151
|
+
break
|
|
152
|
+
}
|
|
74
153
|
}
|
|
75
154
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
155
|
+
// Deterministic sub-index: walks the items tree and counts subs until it finds the target
|
|
156
|
+
function getSubIndex(entries: MenuEntry[], target: SubEntry): number {
|
|
157
|
+
let idx = 0
|
|
158
|
+
for (const e of entries) {
|
|
159
|
+
if (e === target) return idx
|
|
160
|
+
if (isSub(e)) idx++
|
|
161
|
+
if (isGroup(e)) {
|
|
162
|
+
for (const gi of e.items) {
|
|
163
|
+
if (gi === target) return idx
|
|
164
|
+
if (isSub(gi)) idx++
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return -1
|
|
169
|
+
}
|
|
80
170
|
</script>
|
|
81
171
|
|
|
82
|
-
{#snippet itemContent(item:
|
|
172
|
+
{#snippet itemContent(item: MenuItem)}
|
|
83
173
|
{#if renderItem}
|
|
84
174
|
{@render renderItem(item)}
|
|
85
175
|
{:else}
|
|
86
|
-
{
|
|
87
|
-
<div class="w-16 h-16 flex items-center justify-center shrink-0 [&_svg]:w-16 [&_svg]:h-16">
|
|
88
|
-
{@render item.icon()}
|
|
89
|
-
</div>
|
|
90
|
-
{/if}
|
|
91
|
-
<span class="flex-1">{item.label}</span>
|
|
92
|
-
{#if item.shortcut}
|
|
93
|
-
<span class="text-tiny text-tertiary ml-8">{item.shortcut}</span>
|
|
94
|
-
{/if}
|
|
176
|
+
<MenuItemContent icon={item.icon} label={item.label} shortcut={item.shortcut} />
|
|
95
177
|
{/if}
|
|
96
178
|
{/snippet}
|
|
97
179
|
|
|
98
|
-
{#snippet menuItem(item:
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
180
|
+
{#snippet menuItem(item: MenuItem)}
|
|
181
|
+
<div
|
|
182
|
+
role="menuitem"
|
|
183
|
+
tabindex="-1"
|
|
184
|
+
data-disabled={item.disabled ? '' : undefined}
|
|
185
|
+
class={menuItemClass({variant: item.variant})}
|
|
186
|
+
onclick={() => handleSelect(item.value)}
|
|
187
|
+
onpointerenter={(e) => {
|
|
188
|
+
openSubIndex = null
|
|
189
|
+
;(e.currentTarget as HTMLElement).focus()
|
|
190
|
+
}}
|
|
103
191
|
>
|
|
104
192
|
{@render itemContent(item)}
|
|
105
|
-
</
|
|
193
|
+
</div>
|
|
106
194
|
{/snippet}
|
|
107
195
|
|
|
108
196
|
{#snippet subMenu(entry: SubEntry)}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
197
|
+
{@const subIdx = getSubIndex(items, entry)}
|
|
198
|
+
{@const isOpen = openSubIndex === subIdx}
|
|
199
|
+
<div
|
|
200
|
+
role="menuitem"
|
|
201
|
+
tabindex="-1"
|
|
202
|
+
aria-haspopup="menu"
|
|
203
|
+
aria-expanded={isOpen}
|
|
204
|
+
data-state={isOpen ? 'open' : 'closed'}
|
|
205
|
+
data-disabled={entry.disabled ? '' : undefined}
|
|
206
|
+
class={cn(menuStyles.item, menuStyles.itemDefault)}
|
|
207
|
+
bind:this={subTriggerEls[subIdx]}
|
|
208
|
+
onpointerenter={(e) => {
|
|
209
|
+
openSubIndex = subIdx
|
|
210
|
+
;(e.currentTarget as HTMLElement).focus()
|
|
211
|
+
}}
|
|
212
|
+
onpointerleave={(e) => e.stopImmediatePropagation()}
|
|
213
|
+
onclick={() => {
|
|
214
|
+
openSubIndex = isOpen ? null : subIdx
|
|
215
|
+
}}
|
|
216
|
+
>
|
|
217
|
+
<MenuItemContent icon={entry.icon} label={entry.label} shortcut={entry.shortcut} />
|
|
218
|
+
<IIIcon iconName="caret-right" class="w-12 h-12 text-tertiary shrink-0" />
|
|
219
|
+
</div>
|
|
220
|
+
{#if isOpen}
|
|
221
|
+
{#if entry.searchable}
|
|
222
|
+
<IIContextMenuSub
|
|
223
|
+
items={entry.items}
|
|
224
|
+
onSelect={handleSelect}
|
|
225
|
+
onClose={() => {
|
|
226
|
+
openSubIndex = null
|
|
227
|
+
subTriggerEls[subIdx]?.focus()
|
|
228
|
+
}}
|
|
229
|
+
triggerEl={subTriggerEls[subIdx]}
|
|
230
|
+
searchPlaceholder={entry.searchPlaceholder}
|
|
231
|
+
renderItemContent={itemContent}
|
|
232
|
+
/>
|
|
233
|
+
{:else}
|
|
234
|
+
<IIContextMenuSubSimple
|
|
235
|
+
items={entry.items}
|
|
236
|
+
onSelect={handleSelect}
|
|
237
|
+
onClose={() => {
|
|
238
|
+
openSubIndex = null
|
|
239
|
+
subTriggerEls[subIdx]?.focus()
|
|
240
|
+
}}
|
|
241
|
+
triggerEl={subTriggerEls[subIdx]}
|
|
242
|
+
renderItemContent={itemContent}
|
|
243
|
+
renderSubMenu={subMenu}
|
|
244
|
+
/>
|
|
245
|
+
{/if}
|
|
246
|
+
{/if}
|
|
131
247
|
{/snippet}
|
|
132
248
|
|
|
133
249
|
{#snippet menuEntries(entries: MenuEntry[])}
|
|
134
250
|
{#each entries as entry, i (i)}
|
|
135
251
|
{#if isSeparator(entry)}
|
|
136
|
-
<
|
|
252
|
+
<div role="separator" class={menuStyles.separator}></div>
|
|
137
253
|
{:else if isGroup(entry)}
|
|
138
|
-
|
|
254
|
+
{@const headingId = `ctx-group-${i}`}
|
|
255
|
+
<div role="group" aria-labelledby={entry.heading ? headingId : undefined}>
|
|
139
256
|
{#if entry.heading}
|
|
140
|
-
<
|
|
257
|
+
<div id={headingId} role="presentation" class={menuStyles.groupHeading}>
|
|
141
258
|
{entry.heading}
|
|
142
|
-
</
|
|
259
|
+
</div>
|
|
143
260
|
{/if}
|
|
144
261
|
{#each entry.items as groupItem (isItem(groupItem) ? groupItem.value : groupItem.label)}
|
|
145
262
|
{#if isSub(groupItem)}
|
|
146
263
|
{@render subMenu(groupItem)}
|
|
147
|
-
{:else}
|
|
264
|
+
{:else if isItem(groupItem)}
|
|
148
265
|
{@render menuItem(groupItem)}
|
|
149
266
|
{/if}
|
|
150
267
|
{/each}
|
|
151
|
-
</
|
|
268
|
+
</div>
|
|
152
269
|
{:else if isSub(entry)}
|
|
153
270
|
{@render subMenu(entry)}
|
|
154
271
|
{:else if isItem(entry)}
|
|
@@ -157,13 +274,21 @@
|
|
|
157
274
|
{/each}
|
|
158
275
|
{/snippet}
|
|
159
276
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
277
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
278
|
+
<div class="inline-block" oncontextmenu={handleContextMenu}>
|
|
279
|
+
{@render children()}
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
{#if open}
|
|
283
|
+
<div use:portal use:clickOutside={{onClose: close}}>
|
|
284
|
+
<div
|
|
285
|
+
bind:this={floatingEl}
|
|
286
|
+
role="menu"
|
|
287
|
+
data-menu-content
|
|
288
|
+
class={cn(menuStyles.content, className)}
|
|
289
|
+
onkeydown={handleMenuKeydown}
|
|
290
|
+
>
|
|
166
291
|
{@render menuEntries(items)}
|
|
167
|
-
</
|
|
168
|
-
</
|
|
169
|
-
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
{/if}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type MenuItem, type MenuEntry } from '../utils/menu';
|
|
3
|
+
type Props = {
|
|
4
|
+
items: MenuEntry[];
|
|
5
|
+
onSelect: (value: string) => void;
|
|
6
|
+
open?: boolean;
|
|
7
|
+
children: Snippet;
|
|
8
|
+
renderItem?: Snippet<[MenuItem]>;
|
|
9
|
+
class?: string;
|
|
10
|
+
};
|
|
11
|
+
declare const IIContextMenu: import("svelte").Component<Props, {}, "open">;
|
|
12
|
+
type IIContextMenu = ReturnType<typeof IIContextMenu>;
|
|
13
|
+
export default IIContextMenu;
|
|
@@ -68,6 +68,8 @@
|
|
|
68
68
|
label: 'Status',
|
|
69
69
|
icon: statusIcon,
|
|
70
70
|
shortcut: 'S',
|
|
71
|
+
searchable: true,
|
|
72
|
+
searchPlaceholder: 'Set status...',
|
|
71
73
|
items: [
|
|
72
74
|
{label: 'To Do', value: 'status-todo', icon: circleIcon},
|
|
73
75
|
{label: 'In Progress', value: 'status-progress', icon: clockIcon},
|
|
@@ -79,6 +81,8 @@
|
|
|
79
81
|
label: 'Assignee',
|
|
80
82
|
icon: userIcon,
|
|
81
83
|
shortcut: 'A',
|
|
84
|
+
searchable: true,
|
|
85
|
+
searchPlaceholder: 'Assign to...',
|
|
82
86
|
items: [
|
|
83
87
|
{label: 'No assignee', value: 'assign-none', icon: userIcon},
|
|
84
88
|
{type: 'separator'},
|
|
@@ -97,6 +101,8 @@
|
|
|
97
101
|
label: 'Priority',
|
|
98
102
|
icon: warningIcon,
|
|
99
103
|
shortcut: 'P',
|
|
104
|
+
searchable: true,
|
|
105
|
+
searchPlaceholder: 'Set priority...',
|
|
100
106
|
items: [
|
|
101
107
|
{label: 'Urgent', value: 'priority-urgent'},
|
|
102
108
|
{label: 'High', value: 'priority-high'},
|
|
@@ -110,6 +116,8 @@
|
|
|
110
116
|
label: 'Labels',
|
|
111
117
|
icon: listIcon,
|
|
112
118
|
shortcut: 'L',
|
|
119
|
+
searchable: true,
|
|
120
|
+
searchPlaceholder: 'Add label...',
|
|
113
121
|
items: [
|
|
114
122
|
{label: 'Bug', value: 'label-bug'},
|
|
115
123
|
{label: 'Feature', value: 'label-feature'},
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type {Snippet} from 'svelte'
|
|
3
|
+
import {createMenuSearch, isItem, isGroup, isSeparator, type MenuItem, type MenuEntry} from '../utils/menu'
|
|
4
|
+
import {menuStyles, menuItemClass} from '../utils/menu'
|
|
5
|
+
import {useFloating, portal} from '../utils/menu'
|
|
6
|
+
import MenuSearchInput from '../utils/menu/MenuSearchInput.svelte'
|
|
7
|
+
import MenuNoResults from '../utils/menu/MenuNoResults.svelte'
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
items: MenuEntry[]
|
|
11
|
+
onSelect: (value: string) => void
|
|
12
|
+
onClose: () => void
|
|
13
|
+
triggerEl: HTMLElement | null
|
|
14
|
+
searchPlaceholder?: string
|
|
15
|
+
renderItemContent: Snippet<[MenuItem]>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
items,
|
|
20
|
+
onSelect,
|
|
21
|
+
onClose,
|
|
22
|
+
triggerEl,
|
|
23
|
+
searchPlaceholder = 'Search...',
|
|
24
|
+
renderItemContent,
|
|
25
|
+
}: Props = $props()
|
|
26
|
+
|
|
27
|
+
const search = createMenuSearch({
|
|
28
|
+
getItems: () => items,
|
|
29
|
+
onSelect,
|
|
30
|
+
passthroughKeys: ['ArrowLeft'],
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
let floatingEl = $state<HTMLElement | null>(null)
|
|
34
|
+
|
|
35
|
+
useFloating({
|
|
36
|
+
reference: () => triggerEl,
|
|
37
|
+
floating: () => floatingEl,
|
|
38
|
+
placement: 'right-start',
|
|
39
|
+
offset: 4,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// Reset search state on mount but don't focus — focus transfers on ArrowRight or pointer enter
|
|
43
|
+
$effect(() => {
|
|
44
|
+
if (floatingEl) {
|
|
45
|
+
search.reset()
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
function activate() {
|
|
50
|
+
search.resetAndFocus()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
54
|
+
if (e.key === 'ArrowLeft' || e.key === 'Escape') {
|
|
55
|
+
e.preventDefault()
|
|
56
|
+
onClose()
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
search.handleKeydown(e, '[data-menu-sub-content]')
|
|
60
|
+
}
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<div use:portal>
|
|
64
|
+
<div
|
|
65
|
+
bind:this={floatingEl}
|
|
66
|
+
role="menu"
|
|
67
|
+
data-menu-content
|
|
68
|
+
data-menu-sub-content
|
|
69
|
+
class={menuStyles.searchableSubContent}
|
|
70
|
+
onpointerleave={(e) => {
|
|
71
|
+
e.stopImmediatePropagation()
|
|
72
|
+
search.inputEl?.blur()
|
|
73
|
+
}}
|
|
74
|
+
onpointerenter={() => {
|
|
75
|
+
activate()
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<MenuSearchInput
|
|
79
|
+
bind:inputEl={search.inputEl}
|
|
80
|
+
bind:searchQuery={search.searchQuery}
|
|
81
|
+
placeholder={searchPlaceholder}
|
|
82
|
+
onkeydown={handleKeydown}
|
|
83
|
+
/>
|
|
84
|
+
<div class={menuStyles.scrollableItems}>
|
|
85
|
+
{#each search.filteredItems as entry, i (i)}
|
|
86
|
+
{#if isSeparator(entry)}
|
|
87
|
+
<div role="separator" class={menuStyles.separator}></div>
|
|
88
|
+
{:else if isGroup(entry)}
|
|
89
|
+
{@const headingId = `menu-group-${i}`}
|
|
90
|
+
<div role="group" aria-labelledby={entry.heading ? headingId : undefined}>
|
|
91
|
+
{#if entry.heading}
|
|
92
|
+
<div id={headingId} role="presentation" class={menuStyles.groupHeading}>
|
|
93
|
+
{entry.heading}
|
|
94
|
+
</div>
|
|
95
|
+
{/if}
|
|
96
|
+
{#each entry.items.filter(isItem) as item (item.value)}
|
|
97
|
+
{@const index = search.getItemIndex(item)}
|
|
98
|
+
<div
|
|
99
|
+
role="menuitem"
|
|
100
|
+
tabindex="-1"
|
|
101
|
+
data-disabled={item.disabled ? '' : undefined}
|
|
102
|
+
class={menuItemClass({variant: item.variant, searchable: true, isHighlighted: index === search.highlightedIndex})}
|
|
103
|
+
data-search-item=""
|
|
104
|
+
onclick={() => onSelect(item.value)}
|
|
105
|
+
onpointermove={() => search.setHighlight(index)}
|
|
106
|
+
>
|
|
107
|
+
{@render renderItemContent(item)}
|
|
108
|
+
</div>
|
|
109
|
+
{/each}
|
|
110
|
+
</div>
|
|
111
|
+
{:else if isItem(entry)}
|
|
112
|
+
{@const index = search.getItemIndex(entry)}
|
|
113
|
+
<div
|
|
114
|
+
role="menuitem"
|
|
115
|
+
tabindex="-1"
|
|
116
|
+
data-disabled={entry.disabled ? '' : undefined}
|
|
117
|
+
class={menuItemClass({variant: entry.variant, searchable: true, isHighlighted: index === search.highlightedIndex})}
|
|
118
|
+
data-search-item=""
|
|
119
|
+
onclick={() => onSelect(entry.value)}
|
|
120
|
+
onpointermove={() => search.setHighlight(index)}
|
|
121
|
+
>
|
|
122
|
+
{@render renderItemContent(entry)}
|
|
123
|
+
</div>
|
|
124
|
+
{/if}
|
|
125
|
+
{/each}
|
|
126
|
+
{#if search.filteredItems.length === 0}
|
|
127
|
+
<MenuNoResults />
|
|
128
|
+
{/if}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type MenuItem, type MenuEntry } from '../utils/menu';
|
|
3
|
+
type Props = {
|
|
4
|
+
items: MenuEntry[];
|
|
5
|
+
onSelect: (value: string) => void;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
triggerEl: HTMLElement | null;
|
|
8
|
+
searchPlaceholder?: string;
|
|
9
|
+
renderItemContent: Snippet<[MenuItem]>;
|
|
10
|
+
};
|
|
11
|
+
declare const IIContextMenuSub: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type IIContextMenuSub = ReturnType<typeof IIContextMenuSub>;
|
|
13
|
+
export default IIContextMenuSub;
|