@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.
- package/dist/IIContextMenu/IIContextMenu.svelte +294 -0
- package/dist/IIContextMenu/IIContextMenu.svelte.d.ts +13 -0
- package/dist/IIContextMenu/IIContextMenuStories.svelte +170 -0
- package/dist/IIContextMenu/IIContextMenuStories.svelte.d.ts +18 -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/IIContextMenu/index.d.ts +1 -0
- package/dist/IIContextMenu/index.js +1 -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/index.d.ts +1 -0
- package/dist/index.js +1 -0
- 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
|
@@ -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;
|