@insymetri/styleguide 0.1.34 → 0.1.36

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.
Files changed (43) hide show
  1. package/dist/IIContextMenu/IIContextMenu.svelte +294 -0
  2. package/dist/IIContextMenu/IIContextMenu.svelte.d.ts +13 -0
  3. package/dist/IIContextMenu/IIContextMenuStories.svelte +170 -0
  4. package/dist/IIContextMenu/IIContextMenuStories.svelte.d.ts +18 -0
  5. package/dist/IIContextMenu/IIContextMenuSub.svelte +131 -0
  6. package/dist/IIContextMenu/IIContextMenuSub.svelte.d.ts +13 -0
  7. package/dist/IIContextMenu/IIContextMenuSubSimple.svelte +137 -0
  8. package/dist/IIContextMenu/IIContextMenuSubSimple.svelte.d.ts +13 -0
  9. package/dist/IIContextMenu/index.d.ts +1 -0
  10. package/dist/IIContextMenu/index.js +1 -0
  11. package/dist/IIDropdownInput/IIDropdownInput.svelte +161 -60
  12. package/dist/IIDropdownInput/IIDropdownInput.svelte.d.ts +6 -9
  13. package/dist/IIDropdownMenu/IIDropdownMenu.svelte +278 -89
  14. package/dist/IIDropdownMenu/IIDropdownMenu.svelte.d.ts +4 -17
  15. package/dist/IIDropdownMenu/IIDropdownMenuStories.svelte +20 -0
  16. package/dist/IIDropdownMenu/IIDropdownMenuSub.svelte +131 -0
  17. package/dist/IIDropdownMenu/IIDropdownMenuSub.svelte.d.ts +13 -0
  18. package/dist/IIDropdownMenu/IIDropdownMenuSubSimple.svelte +134 -0
  19. package/dist/IIDropdownMenu/IIDropdownMenuSubSimple.svelte.d.ts +13 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/style/tailwind/animations.js +1 -1
  23. package/dist/style/tailwind/shadows.d.ts +1 -0
  24. package/dist/style/tailwind/shadows.js +3 -1
  25. package/dist/utils/menu/MenuItemContent.svelte +21 -0
  26. package/dist/utils/menu/MenuItemContent.svelte.d.ts +9 -0
  27. package/dist/utils/menu/MenuNoResults.svelte +4 -0
  28. package/dist/utils/menu/MenuNoResults.svelte.d.ts +18 -0
  29. package/dist/utils/menu/MenuSearchInput.svelte +25 -0
  30. package/dist/utils/menu/MenuSearchInput.svelte.d.ts +9 -0
  31. package/dist/utils/menu/index.d.ts +8 -0
  32. package/dist/utils/menu/index.js +8 -0
  33. package/dist/utils/menu/menu-search.svelte.d.ts +53 -0
  34. package/dist/utils/menu/menu-search.svelte.js +138 -0
  35. package/dist/utils/menu/menu-styles.d.ts +16 -0
  36. package/dist/utils/menu/menu-styles.js +19 -0
  37. package/dist/utils/menu/use-click-outside.d.ts +9 -0
  38. package/dist/utils/menu/use-click-outside.js +45 -0
  39. package/dist/utils/menu/use-floating.svelte.d.ts +13 -0
  40. package/dist/utils/menu/use-floating.svelte.js +55 -0
  41. package/dist/utils/menu/use-portal.d.ts +3 -0
  42. package/dist/utils/menu/use-portal.js +8 -0
  43. package/package.json +8 -1
@@ -0,0 +1,294 @@
1
+ <script lang="ts">
2
+ import type {Snippet} from 'svelte'
3
+ import type {VirtualElement} from '@floating-ui/dom'
4
+ import {cn} from '../utils/cn'
5
+ import {IIIcon} from '../IIIcon'
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'
11
+
12
+ type Props = {
13
+ items: MenuEntry[]
14
+ onSelect: (value: string) => void
15
+ open?: boolean
16
+ children: Snippet
17
+ renderItem?: Snippet<[MenuItem]>
18
+ class?: string
19
+ }
20
+
21
+ let {
22
+ items,
23
+ onSelect,
24
+ open = $bindable(false),
25
+ children,
26
+ renderItem,
27
+ class: className,
28
+ }: Props = $props()
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
+
69
+ function handleSelect(value: string) {
70
+ onSelect(value)
71
+ close()
72
+ }
73
+
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])'))
87
+ }
88
+
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()
95
+ }
96
+
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
+ })
109
+ }
110
+
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
+ }
153
+ }
154
+
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
+ }
170
+ </script>
171
+
172
+ {#snippet itemContent(item: MenuItem)}
173
+ {#if renderItem}
174
+ {@render renderItem(item)}
175
+ {:else}
176
+ <MenuItemContent icon={item.icon} label={item.label} shortcut={item.shortcut} />
177
+ {/if}
178
+ {/snippet}
179
+
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
+ }}
191
+ >
192
+ {@render itemContent(item)}
193
+ </div>
194
+ {/snippet}
195
+
196
+ {#snippet subMenu(entry: SubEntry)}
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}
247
+ {/snippet}
248
+
249
+ {#snippet menuEntries(entries: MenuEntry[])}
250
+ {#each entries as entry, i (i)}
251
+ {#if isSeparator(entry)}
252
+ <div role="separator" class={menuStyles.separator}></div>
253
+ {:else if isGroup(entry)}
254
+ {@const headingId = `ctx-group-${i}`}
255
+ <div role="group" aria-labelledby={entry.heading ? headingId : undefined}>
256
+ {#if entry.heading}
257
+ <div id={headingId} role="presentation" class={menuStyles.groupHeading}>
258
+ {entry.heading}
259
+ </div>
260
+ {/if}
261
+ {#each entry.items as groupItem (isItem(groupItem) ? groupItem.value : groupItem.label)}
262
+ {#if isSub(groupItem)}
263
+ {@render subMenu(groupItem)}
264
+ {:else if isItem(groupItem)}
265
+ {@render menuItem(groupItem)}
266
+ {/if}
267
+ {/each}
268
+ </div>
269
+ {:else if isSub(entry)}
270
+ {@render subMenu(entry)}
271
+ {:else if isItem(entry)}
272
+ {@render menuItem(entry)}
273
+ {/if}
274
+ {/each}
275
+ {/snippet}
276
+
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
+ >
291
+ {@render menuEntries(items)}
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;
@@ -0,0 +1,170 @@
1
+ <script lang="ts">
2
+ import IIContextMenu from './IIContextMenu.svelte'
3
+ import {IIIcon} from '../IIIcon'
4
+
5
+ function handleSelect(value: string) {
6
+ console.log('Selected:', value)
7
+ }
8
+ </script>
9
+
10
+ {#snippet statusIcon()}<IIIcon iconName="clock" />{/snippet}
11
+ {#snippet clockIcon()}<IIIcon iconName="clock-clockwise" />{/snippet}
12
+ {#snippet circleIcon()}<IIIcon iconName="circle" />{/snippet}
13
+ {#snippet checkCircleIcon()}<IIIcon iconName="check-circle" />{/snippet}
14
+ {#snippet userIcon()}<IIIcon iconName="user" />{/snippet}
15
+ {#snippet warningIcon()}<IIIcon iconName="warning-circle" />{/snippet}
16
+ {#snippet listIcon()}<IIIcon iconName="list" />{/snippet}
17
+ {#snippet pencilIcon()}<IIIcon iconName="pencil-simple" />{/snippet}
18
+ {#snippet trashIcon()}<IIIcon iconName="trash" />{/snippet}
19
+
20
+ <div class="flex flex-col gap-32">
21
+ <!-- Basic -->
22
+ <section>
23
+ <h2 class="text-default-emphasis text-primary mb-8">Basic</h2>
24
+ <p class="text-small text-secondary mb-12">Right-click the area below to open the context menu.</p>
25
+ <IIContextMenu
26
+ items={[
27
+ {label: 'Edit', value: 'edit'},
28
+ {label: 'Duplicate', value: 'duplicate'},
29
+ {label: 'Archive', value: 'archive'},
30
+ {label: 'Delete', value: 'delete', variant: 'destructive'},
31
+ ]}
32
+ onSelect={handleSelect}
33
+ >
34
+ <div class="w-full h-100 border border-dashed border-muted rounded-8 flex items-center justify-center text-small text-secondary">
35
+ Right-click here
36
+ </div>
37
+ </IIContextMenu>
38
+ </section>
39
+
40
+ <!-- With Separators -->
41
+ <section>
42
+ <h2 class="text-default-emphasis text-primary mb-8">With Separators</h2>
43
+ <p class="text-small text-secondary mb-12">Menu entries can include separator dividers.</p>
44
+ <IIContextMenu
45
+ items={[
46
+ {label: 'Cut', value: 'cut', shortcut: '⌘X'},
47
+ {label: 'Copy', value: 'copy', shortcut: '⌘C'},
48
+ {label: 'Paste', value: 'paste', shortcut: '⌘V'},
49
+ {type: 'separator'},
50
+ {label: 'Delete', value: 'delete', variant: 'destructive'},
51
+ ]}
52
+ onSelect={handleSelect}
53
+ >
54
+ <div class="w-full h-100 border border-dashed border-muted rounded-8 flex items-center justify-center text-small text-secondary">
55
+ Right-click here
56
+ </div>
57
+ </IIContextMenu>
58
+ </section>
59
+
60
+ <!-- With Submenus -->
61
+ <section>
62
+ <h2 class="text-default-emphasis text-primary mb-8">With Submenus</h2>
63
+ <p class="text-small text-secondary mb-12">Hover over an item with an arrow to reveal its submenu.</p>
64
+ <IIContextMenu
65
+ items={[
66
+ {
67
+ type: 'sub',
68
+ label: 'Status',
69
+ icon: statusIcon,
70
+ shortcut: 'S',
71
+ searchable: true,
72
+ searchPlaceholder: 'Set status...',
73
+ items: [
74
+ {label: 'To Do', value: 'status-todo', icon: circleIcon},
75
+ {label: 'In Progress', value: 'status-progress', icon: clockIcon},
76
+ {label: 'Done', value: 'status-done', icon: checkCircleIcon},
77
+ ],
78
+ },
79
+ {
80
+ type: 'sub',
81
+ label: 'Assignee',
82
+ icon: userIcon,
83
+ shortcut: 'A',
84
+ searchable: true,
85
+ searchPlaceholder: 'Assign to...',
86
+ items: [
87
+ {label: 'No assignee', value: 'assign-none', icon: userIcon},
88
+ {type: 'separator'},
89
+ {
90
+ type: 'group',
91
+ heading: 'Team members',
92
+ items: [
93
+ {label: 'Alice', value: 'assign-alice', icon: userIcon},
94
+ {label: 'Bob', value: 'assign-bob', icon: userIcon},
95
+ ],
96
+ },
97
+ ],
98
+ },
99
+ {
100
+ type: 'sub',
101
+ label: 'Priority',
102
+ icon: warningIcon,
103
+ shortcut: 'P',
104
+ searchable: true,
105
+ searchPlaceholder: 'Set priority...',
106
+ items: [
107
+ {label: 'Urgent', value: 'priority-urgent'},
108
+ {label: 'High', value: 'priority-high'},
109
+ {label: 'Medium', value: 'priority-medium'},
110
+ {label: 'Low', value: 'priority-low'},
111
+ {label: 'No priority', value: 'priority-none'},
112
+ ],
113
+ },
114
+ {
115
+ type: 'sub',
116
+ label: 'Labels',
117
+ icon: listIcon,
118
+ shortcut: 'L',
119
+ searchable: true,
120
+ searchPlaceholder: 'Add label...',
121
+ items: [
122
+ {label: 'Bug', value: 'label-bug'},
123
+ {label: 'Feature', value: 'label-feature'},
124
+ {label: 'Improvement', value: 'label-improvement'},
125
+ ],
126
+ },
127
+ {type: 'separator'},
128
+ {label: 'Rename...', value: 'rename', icon: pencilIcon, shortcut: 'R'},
129
+ {label: 'Delete', value: 'delete', icon: trashIcon, variant: 'destructive'},
130
+ ]}
131
+ onSelect={handleSelect}
132
+ >
133
+ <div class="w-full h-100 border border-dashed border-muted rounded-8 flex items-center justify-center text-small text-secondary">
134
+ Right-click here
135
+ </div>
136
+ </IIContextMenu>
137
+ </section>
138
+
139
+ <!-- With Groups -->
140
+ <section>
141
+ <h2 class="text-default-emphasis text-primary mb-8">With Groups</h2>
142
+ <p class="text-small text-secondary mb-12">Items organized into labeled groups with headings.</p>
143
+ <IIContextMenu
144
+ items={[
145
+ {
146
+ type: 'group',
147
+ heading: 'Edit',
148
+ items: [
149
+ {label: 'Cut', value: 'cut'},
150
+ {label: 'Copy', value: 'copy'},
151
+ {label: 'Paste', value: 'paste'},
152
+ ],
153
+ },
154
+ {type: 'separator'},
155
+ {
156
+ type: 'group',
157
+ heading: 'Danger Zone',
158
+ items: [
159
+ {label: 'Delete', value: 'delete', variant: 'destructive'},
160
+ ],
161
+ },
162
+ ]}
163
+ onSelect={handleSelect}
164
+ >
165
+ <div class="w-full h-100 border border-dashed border-muted rounded-8 flex items-center justify-center text-small text-secondary">
166
+ Right-click here
167
+ </div>
168
+ </IIContextMenu>
169
+ </section>
170
+ </div>
@@ -0,0 +1,18 @@
1
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
+ $$bindings?: Bindings;
4
+ } & Exports;
5
+ (internal: unknown, props: {
6
+ $$events?: Events;
7
+ $$slots?: Slots;
8
+ }): Exports & {
9
+ $set?: any;
10
+ $on?: any;
11
+ };
12
+ z_$$bindings?: Bindings;
13
+ }
14
+ declare const IIContextMenuStories: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
+ [evt: string]: CustomEvent<any>;
16
+ }, {}, {}, string>;
17
+ type IIContextMenuStories = InstanceType<typeof IIContextMenuStories>;
18
+ export default IIContextMenuStories;
@@ -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;