@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,39 +1,27 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type {Snippet} from 'svelte'
|
|
3
|
-
import {DropdownMenu} from 'bits-ui'
|
|
4
3
|
import {cn} from '../utils/cn'
|
|
5
4
|
import {IIIcon} from '../IIIcon'
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
type SeparatorEntry = {
|
|
16
|
-
type: 'separator'
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
type GroupEntry = {
|
|
20
|
-
type: 'group'
|
|
21
|
-
heading?: string
|
|
22
|
-
items: Item[]
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
type MenuEntry = Item | SeparatorEntry | GroupEntry
|
|
5
|
+
import {createMenuSearch, isItem, isGroup, isSub, isSeparator, menuStyles, menuItemClass, type MenuItem, type MenuEntry, type SubEntry} from '../utils/menu'
|
|
6
|
+
import {useFloating, portal, clickOutside} from '../utils/menu'
|
|
7
|
+
import MenuItemContent from '../utils/menu/MenuItemContent.svelte'
|
|
8
|
+
import MenuSearchInput from '../utils/menu/MenuSearchInput.svelte'
|
|
9
|
+
import MenuNoResults from '../utils/menu/MenuNoResults.svelte'
|
|
10
|
+
import IIDropdownMenuSub from './IIDropdownMenuSub.svelte'
|
|
11
|
+
import IIDropdownMenuSubSimple from './IIDropdownMenuSubSimple.svelte'
|
|
26
12
|
|
|
27
13
|
type Props = {
|
|
28
14
|
items: MenuEntry[]
|
|
29
15
|
onSelect: (value: string) => void
|
|
30
16
|
open?: boolean
|
|
31
17
|
children?: Snippet
|
|
32
|
-
renderItem?: Snippet<[
|
|
18
|
+
renderItem?: Snippet<[MenuItem]>
|
|
33
19
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
|
34
20
|
align?: 'start' | 'center' | 'end'
|
|
35
21
|
collisionPadding?: number
|
|
36
22
|
triggerClass?: string
|
|
23
|
+
searchable?: boolean
|
|
24
|
+
searchPlaceholder?: string
|
|
37
25
|
class?: string
|
|
38
26
|
}
|
|
39
27
|
|
|
@@ -47,98 +35,299 @@
|
|
|
47
35
|
align = 'end',
|
|
48
36
|
collisionPadding = 8,
|
|
49
37
|
triggerClass,
|
|
38
|
+
searchable = false,
|
|
39
|
+
searchPlaceholder = 'Search...',
|
|
50
40
|
class: className,
|
|
51
41
|
}: Props = $props()
|
|
52
42
|
|
|
43
|
+
let triggerEl = $state<HTMLElement | null>(null)
|
|
44
|
+
let floatingEl = $state<HTMLElement | null>(null)
|
|
45
|
+
let openSubIndex = $state<number | null>(null)
|
|
46
|
+
let subTriggerEls: Record<number, HTMLElement | null> = {}
|
|
47
|
+
|
|
48
|
+
const placement = $derived(align === 'center' ? side : `${side}-${align}` as const)
|
|
49
|
+
|
|
50
|
+
useFloating({
|
|
51
|
+
reference: () => triggerEl,
|
|
52
|
+
floating: () => floatingEl,
|
|
53
|
+
placement: placement as any,
|
|
54
|
+
offset: 4,
|
|
55
|
+
shift: {padding: collisionPadding},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const search = createMenuSearch({
|
|
59
|
+
getItems: () => items,
|
|
60
|
+
onSelect: handleSelect,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
$effect(() => {
|
|
64
|
+
if (open && searchable) {
|
|
65
|
+
search.resetAndFocus()
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// Focus first item when menu opens (non-searchable)
|
|
70
|
+
$effect(() => {
|
|
71
|
+
if (open && floatingEl && !searchable) {
|
|
72
|
+
requestAnimationFrame(() => {
|
|
73
|
+
const first = floatingEl?.querySelector<HTMLElement>('[role="menuitem"]:not([data-disabled])')
|
|
74
|
+
first?.focus()
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
function toggle() {
|
|
80
|
+
if (open) {
|
|
81
|
+
close()
|
|
82
|
+
} else {
|
|
83
|
+
openSubIndex = null
|
|
84
|
+
open = true
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function close() {
|
|
89
|
+
open = false
|
|
90
|
+
openSubIndex = null
|
|
91
|
+
triggerEl?.focus()
|
|
92
|
+
}
|
|
93
|
+
|
|
53
94
|
function handleSelect(value: string) {
|
|
54
95
|
onSelect(value)
|
|
55
|
-
|
|
96
|
+
close()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getMenuItems(): HTMLElement[] {
|
|
100
|
+
if (!floatingEl) return []
|
|
101
|
+
return Array.from(floatingEl.querySelectorAll<HTMLElement>(':scope > [role="menuitem"]:not([data-disabled]), :scope > [role="group"] > [role="menuitem"]:not([data-disabled])'))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function focusItem(items: HTMLElement[], currentIndex: number, direction: 1 | -1) {
|
|
105
|
+
if (items.length === 0) return
|
|
106
|
+
const nextIndex = direction === 1
|
|
107
|
+
? (currentIndex >= items.length - 1 ? 0 : currentIndex + 1)
|
|
108
|
+
: (currentIndex <= 0 ? items.length - 1 : currentIndex - 1)
|
|
109
|
+
items[nextIndex]?.focus()
|
|
56
110
|
}
|
|
57
111
|
|
|
58
|
-
function
|
|
59
|
-
|
|
112
|
+
function openAndFocusSub(trigger: HTMLElement) {
|
|
113
|
+
trigger.click()
|
|
114
|
+
requestAnimationFrame(() => {
|
|
115
|
+
const subContent = document.querySelector('[data-menu-sub-content]')
|
|
116
|
+
const input = subContent?.querySelector('input') as HTMLInputElement
|
|
117
|
+
if (input) {
|
|
118
|
+
input.focus()
|
|
119
|
+
} else {
|
|
120
|
+
const firstItem = subContent?.querySelector('[role="menuitem"]:not([data-disabled])') as HTMLElement
|
|
121
|
+
firstItem?.focus()
|
|
122
|
+
}
|
|
123
|
+
})
|
|
60
124
|
}
|
|
61
125
|
|
|
62
|
-
function
|
|
63
|
-
return
|
|
126
|
+
function handleMenuKeydown(e: KeyboardEvent) {
|
|
127
|
+
if (searchable) return // search input handles its own keyboard nav
|
|
128
|
+
|
|
129
|
+
const menuItems = getMenuItems()
|
|
130
|
+
const currentIndex = menuItems.indexOf(document.activeElement as HTMLElement)
|
|
131
|
+
|
|
132
|
+
switch (e.key) {
|
|
133
|
+
case 'ArrowDown':
|
|
134
|
+
e.preventDefault()
|
|
135
|
+
focusItem(menuItems, currentIndex, 1)
|
|
136
|
+
break
|
|
137
|
+
case 'ArrowUp':
|
|
138
|
+
e.preventDefault()
|
|
139
|
+
focusItem(menuItems, currentIndex, -1)
|
|
140
|
+
break
|
|
141
|
+
case 'Enter':
|
|
142
|
+
case ' ': {
|
|
143
|
+
e.preventDefault()
|
|
144
|
+
const focused = document.activeElement as HTMLElement
|
|
145
|
+
if (focused?.hasAttribute('aria-haspopup')) {
|
|
146
|
+
openAndFocusSub(focused)
|
|
147
|
+
} else {
|
|
148
|
+
focused?.click()
|
|
149
|
+
}
|
|
150
|
+
break
|
|
151
|
+
}
|
|
152
|
+
case 'ArrowRight': {
|
|
153
|
+
const focused = document.activeElement as HTMLElement
|
|
154
|
+
if (focused?.hasAttribute('aria-haspopup')) {
|
|
155
|
+
e.preventDefault()
|
|
156
|
+
openAndFocusSub(focused)
|
|
157
|
+
}
|
|
158
|
+
break
|
|
159
|
+
}
|
|
160
|
+
case 'Escape':
|
|
161
|
+
e.preventDefault()
|
|
162
|
+
close()
|
|
163
|
+
break
|
|
164
|
+
}
|
|
64
165
|
}
|
|
65
166
|
|
|
66
|
-
function
|
|
67
|
-
|
|
167
|
+
function getSubIndex(entries: MenuEntry[], target: SubEntry): number {
|
|
168
|
+
let idx = 0
|
|
169
|
+
for (const e of entries) {
|
|
170
|
+
if (e === target) return idx
|
|
171
|
+
if (isSub(e)) idx++
|
|
172
|
+
if (isGroup(e)) {
|
|
173
|
+
for (const gi of e.items) {
|
|
174
|
+
if (gi === target) return idx
|
|
175
|
+
if (isSub(gi)) idx++
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return -1
|
|
68
180
|
}
|
|
181
|
+
|
|
182
|
+
const defaultTriggerClass = '[all:unset] cursor-default inline-flex items-center justify-center p-4 rounded-4 text-secondary transition-all duration-fast hover:bg-background hover:text-body data-[state=open]:bg-background data-[state=open]:text-body motion-reduce:transition-none'
|
|
69
183
|
</script>
|
|
70
184
|
|
|
71
|
-
{#snippet itemContent(item:
|
|
185
|
+
{#snippet itemContent(item: MenuItem)}
|
|
72
186
|
{#if renderItem}
|
|
73
187
|
{@render renderItem(item)}
|
|
74
188
|
{:else}
|
|
75
|
-
{
|
|
76
|
-
<div class="w-16 h-16 flex items-center justify-center shrink-0 [&_svg]:w-16 [&_svg]:h-16">
|
|
77
|
-
{@render item.icon()}
|
|
78
|
-
</div>
|
|
79
|
-
{/if}
|
|
80
|
-
<span class="flex-1">{item.label}</span>
|
|
189
|
+
<MenuItemContent icon={item.icon} label={item.label} shortcut={item.shortcut} />
|
|
81
190
|
{/if}
|
|
82
191
|
{/snippet}
|
|
83
192
|
|
|
84
|
-
{#snippet menuItem(item:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
)}
|
|
93
|
-
|
|
193
|
+
{#snippet menuItem(item: MenuItem)}
|
|
194
|
+
{@const index = searchable ? search.getItemIndex(item) : -1}
|
|
195
|
+
<div
|
|
196
|
+
role="menuitem"
|
|
197
|
+
tabindex="-1"
|
|
198
|
+
data-disabled={item.disabled ? '' : undefined}
|
|
199
|
+
class={menuItemClass({variant: item.variant, searchable, isHighlighted: index === search.highlightedIndex})}
|
|
200
|
+
data-search-item={searchable ? '' : undefined}
|
|
201
|
+
onclick={() => handleSelect(item.value)}
|
|
202
|
+
onfocus={searchable ? search.refocusInput : undefined}
|
|
203
|
+
onpointermove={searchable ? () => search.setHighlight(index) : undefined}
|
|
204
|
+
onpointerenter={!searchable ? (e) => {
|
|
205
|
+
openSubIndex = null
|
|
206
|
+
;(e.currentTarget as HTMLElement).focus()
|
|
207
|
+
} : undefined}
|
|
94
208
|
>
|
|
95
209
|
{@render itemContent(item)}
|
|
96
|
-
</
|
|
210
|
+
</div>
|
|
97
211
|
{/snippet}
|
|
98
212
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
213
|
+
{#snippet subMenu(entry: SubEntry)}
|
|
214
|
+
{@const subIdx = getSubIndex(items, entry)}
|
|
215
|
+
{@const isOpen = openSubIndex === subIdx}
|
|
216
|
+
<div
|
|
217
|
+
role="menuitem"
|
|
218
|
+
tabindex="-1"
|
|
219
|
+
aria-haspopup="menu"
|
|
220
|
+
aria-expanded={isOpen}
|
|
221
|
+
data-state={isOpen ? 'open' : 'closed'}
|
|
222
|
+
data-disabled={entry.disabled ? '' : undefined}
|
|
223
|
+
class={cn(menuStyles.item, menuStyles.itemDefault)}
|
|
224
|
+
bind:this={subTriggerEls[subIdx]}
|
|
225
|
+
onpointerenter={(e) => {
|
|
226
|
+
openSubIndex = subIdx
|
|
227
|
+
;(e.currentTarget as HTMLElement).focus()
|
|
228
|
+
}}
|
|
229
|
+
onpointerleave={(e) => e.stopImmediatePropagation()}
|
|
230
|
+
onclick={() => {
|
|
231
|
+
openSubIndex = isOpen ? null : subIdx
|
|
232
|
+
}}
|
|
107
233
|
>
|
|
108
|
-
{
|
|
109
|
-
|
|
234
|
+
<MenuItemContent icon={entry.icon} label={entry.label} shortcut={entry.shortcut} />
|
|
235
|
+
<IIIcon iconName="caret-right" class="w-12 h-12 text-tertiary shrink-0" />
|
|
236
|
+
</div>
|
|
237
|
+
{#if isOpen}
|
|
238
|
+
{#if entry.searchable}
|
|
239
|
+
<IIDropdownMenuSub
|
|
240
|
+
items={entry.items}
|
|
241
|
+
onSelect={handleSelect}
|
|
242
|
+
onClose={() => {
|
|
243
|
+
openSubIndex = null
|
|
244
|
+
subTriggerEls[subIdx]?.focus()
|
|
245
|
+
}}
|
|
246
|
+
triggerEl={subTriggerEls[subIdx]}
|
|
247
|
+
searchPlaceholder={entry.searchPlaceholder}
|
|
248
|
+
renderItemContent={itemContent}
|
|
249
|
+
/>
|
|
110
250
|
{:else}
|
|
111
|
-
<
|
|
251
|
+
<IIDropdownMenuSubSimple
|
|
252
|
+
items={entry.items}
|
|
253
|
+
onSelect={handleSelect}
|
|
254
|
+
onClose={() => {
|
|
255
|
+
openSubIndex = null
|
|
256
|
+
subTriggerEls[subIdx]?.focus()
|
|
257
|
+
}}
|
|
258
|
+
triggerEl={subTriggerEls[subIdx]}
|
|
259
|
+
renderItemContent={itemContent}
|
|
260
|
+
renderSubMenu={subMenu}
|
|
261
|
+
/>
|
|
112
262
|
{/if}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
class={
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
{:else if isGroup(entry)}
|
|
128
|
-
<DropdownMenu.Group>
|
|
129
|
-
{#if entry.heading}
|
|
130
|
-
<DropdownMenu.GroupHeading class="text-tiny-emphasis text-secondary px-12 py-4 uppercase select-none">
|
|
131
|
-
{entry.heading}
|
|
132
|
-
</DropdownMenu.GroupHeading>
|
|
133
|
-
{/if}
|
|
134
|
-
{#each entry.items as item (item.value)}
|
|
135
|
-
{@render menuItem(item)}
|
|
136
|
-
{/each}
|
|
137
|
-
</DropdownMenu.Group>
|
|
138
|
-
{:else if isItem(entry)}
|
|
139
|
-
{@render menuItem(entry)}
|
|
263
|
+
{/if}
|
|
264
|
+
{/snippet}
|
|
265
|
+
|
|
266
|
+
{#snippet menuEntries(entries: MenuEntry[])}
|
|
267
|
+
{#each entries as entry, i (i)}
|
|
268
|
+
{#if isSeparator(entry)}
|
|
269
|
+
<div role="separator" class={menuStyles.separator}></div>
|
|
270
|
+
{:else if isGroup(entry)}
|
|
271
|
+
{@const headingId = `dd-group-${i}`}
|
|
272
|
+
<div role="group" aria-labelledby={entry.heading ? headingId : undefined}>
|
|
273
|
+
{#if entry.heading}
|
|
274
|
+
<div id={headingId} role="presentation" class={menuStyles.groupHeading}>
|
|
275
|
+
{entry.heading}
|
|
276
|
+
</div>
|
|
140
277
|
{/if}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
278
|
+
{#each entry.items as groupItem (isItem(groupItem) ? groupItem.value : groupItem.label)}
|
|
279
|
+
{#if isSub(groupItem)}
|
|
280
|
+
{@render subMenu(groupItem)}
|
|
281
|
+
{:else if isItem(groupItem)}
|
|
282
|
+
{@render menuItem(groupItem)}
|
|
283
|
+
{/if}
|
|
284
|
+
{/each}
|
|
285
|
+
</div>
|
|
286
|
+
{:else if isSub(entry)}
|
|
287
|
+
{@render subMenu(entry)}
|
|
288
|
+
{:else if isItem(entry)}
|
|
289
|
+
{@render menuItem(entry)}
|
|
290
|
+
{/if}
|
|
291
|
+
{/each}
|
|
292
|
+
{/snippet}
|
|
293
|
+
|
|
294
|
+
<button
|
|
295
|
+
bind:this={triggerEl}
|
|
296
|
+
type="button"
|
|
297
|
+
aria-haspopup="menu"
|
|
298
|
+
aria-expanded={open}
|
|
299
|
+
data-state={open ? 'open' : 'closed'}
|
|
300
|
+
class={cn(children && triggerClass ? triggerClass : defaultTriggerClass)}
|
|
301
|
+
onclick={toggle}
|
|
302
|
+
>
|
|
303
|
+
{#if children}
|
|
304
|
+
{@render children()}
|
|
305
|
+
{:else}
|
|
306
|
+
<IIIcon iconName="dots-three-vertical" />
|
|
307
|
+
{/if}
|
|
308
|
+
</button>
|
|
309
|
+
|
|
310
|
+
{#if open}
|
|
311
|
+
<div use:portal use:clickOutside={{onClose: close}}>
|
|
312
|
+
<div
|
|
313
|
+
bind:this={floatingEl}
|
|
314
|
+
role="menu"
|
|
315
|
+
data-menu-content
|
|
316
|
+
class={cn(menuStyles.content, className)}
|
|
317
|
+
onkeydown={handleMenuKeydown}
|
|
318
|
+
>
|
|
319
|
+
{#if searchable}
|
|
320
|
+
<MenuSearchInput
|
|
321
|
+
bind:inputEl={search.inputEl}
|
|
322
|
+
bind:searchQuery={search.searchQuery}
|
|
323
|
+
placeholder={searchPlaceholder}
|
|
324
|
+
onkeydown={(e) => search.handleKeydown(e, '[data-menu-content]')}
|
|
325
|
+
/>
|
|
326
|
+
{/if}
|
|
327
|
+
{@render menuEntries(searchable ? search.filteredItems : items)}
|
|
328
|
+
{#if searchable && search.filteredItems.length === 0}
|
|
329
|
+
<MenuNoResults />
|
|
330
|
+
{/if}
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
{/if}
|
|
@@ -1,30 +1,17 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
-
type
|
|
3
|
-
label: string;
|
|
4
|
-
value: string;
|
|
5
|
-
icon?: Snippet;
|
|
6
|
-
disabled?: boolean;
|
|
7
|
-
variant?: 'default' | 'destructive';
|
|
8
|
-
};
|
|
9
|
-
type SeparatorEntry = {
|
|
10
|
-
type: 'separator';
|
|
11
|
-
};
|
|
12
|
-
type GroupEntry = {
|
|
13
|
-
type: 'group';
|
|
14
|
-
heading?: string;
|
|
15
|
-
items: Item[];
|
|
16
|
-
};
|
|
17
|
-
type MenuEntry = Item | SeparatorEntry | GroupEntry;
|
|
2
|
+
import { type MenuItem, type MenuEntry } from '../utils/menu';
|
|
18
3
|
type Props = {
|
|
19
4
|
items: MenuEntry[];
|
|
20
5
|
onSelect: (value: string) => void;
|
|
21
6
|
open?: boolean;
|
|
22
7
|
children?: Snippet;
|
|
23
|
-
renderItem?: Snippet<[
|
|
8
|
+
renderItem?: Snippet<[MenuItem]>;
|
|
24
9
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
|
25
10
|
align?: 'start' | 'center' | 'end';
|
|
26
11
|
collisionPadding?: number;
|
|
27
12
|
triggerClass?: string;
|
|
13
|
+
searchable?: boolean;
|
|
14
|
+
searchPlaceholder?: string;
|
|
28
15
|
class?: string;
|
|
29
16
|
};
|
|
30
17
|
declare const IIDropdownMenu: import("svelte").Component<Props, {}, "open">;
|
|
@@ -75,6 +75,26 @@
|
|
|
75
75
|
</IIDropdownMenu>
|
|
76
76
|
</section>
|
|
77
77
|
|
|
78
|
+
<!-- Searchable -->
|
|
79
|
+
<section>
|
|
80
|
+
<h2 class="text-default-emphasis text-primary mb-8">Searchable</h2>
|
|
81
|
+
<p class="text-small text-secondary mb-12">A search input at the top filters menu items as you type.</p>
|
|
82
|
+
<IIDropdownMenu
|
|
83
|
+
items={[
|
|
84
|
+
{label: 'Edit', value: 'edit'},
|
|
85
|
+
{label: 'Duplicate', value: 'duplicate'},
|
|
86
|
+
{label: 'Archive', value: 'archive'},
|
|
87
|
+
{label: 'Export', value: 'export'},
|
|
88
|
+
{label: 'Share', value: 'share'},
|
|
89
|
+
{label: 'Print', value: 'print'},
|
|
90
|
+
{type: 'separator'},
|
|
91
|
+
{label: 'Delete', value: 'delete', variant: 'destructive'},
|
|
92
|
+
]}
|
|
93
|
+
onSelect={handleSelect}
|
|
94
|
+
searchable
|
|
95
|
+
/>
|
|
96
|
+
</section>
|
|
97
|
+
|
|
78
98
|
<!-- Mixed Entries -->
|
|
79
99
|
<section>
|
|
80
100
|
<h2 class="text-default-emphasis text-primary mb-8">Mixed: Items + Separators + Groups</h2>
|
|
@@ -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 = `dd-sub-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 IIDropdownMenuSub: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type IIDropdownMenuSub = ReturnType<typeof IIDropdownMenuSub>;
|
|
13
|
+
export default IIDropdownMenuSub;
|