@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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type {Snippet} from 'svelte'
|
|
3
|
+
import {isItem, isGroup, isSub, isSeparator, menuStyles, menuItemClass, type MenuItem, type MenuEntry, type SubEntry} from '../utils/menu'
|
|
4
|
+
import {useFloating, portal} from '../utils/menu'
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
items: MenuEntry[]
|
|
8
|
+
onSelect: (value: string) => void
|
|
9
|
+
onClose: () => void
|
|
10
|
+
triggerEl: HTMLElement | null
|
|
11
|
+
renderItemContent: Snippet<[MenuItem]>
|
|
12
|
+
renderSubMenu: Snippet<[SubEntry]>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
items,
|
|
17
|
+
onSelect,
|
|
18
|
+
onClose,
|
|
19
|
+
triggerEl,
|
|
20
|
+
renderItemContent,
|
|
21
|
+
renderSubMenu,
|
|
22
|
+
}: Props = $props()
|
|
23
|
+
|
|
24
|
+
let floatingEl = $state<HTMLElement | null>(null)
|
|
25
|
+
|
|
26
|
+
useFloating({
|
|
27
|
+
reference: () => triggerEl,
|
|
28
|
+
floating: () => floatingEl,
|
|
29
|
+
placement: 'right-start',
|
|
30
|
+
offset: 4,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
function activate() {
|
|
34
|
+
requestAnimationFrame(() => {
|
|
35
|
+
const first = floatingEl?.querySelector<HTMLElement>('[role="menuitem"]:not([data-disabled])')
|
|
36
|
+
first?.focus()
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getMenuItems(): HTMLElement[] {
|
|
41
|
+
if (!floatingEl) return []
|
|
42
|
+
return Array.from(floatingEl.querySelectorAll<HTMLElement>(':scope > [role="menuitem"]:not([data-disabled]), :scope > [role="group"] > [role="menuitem"]:not([data-disabled])'))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function focusItem(items: HTMLElement[], currentIndex: number, direction: 1 | -1) {
|
|
46
|
+
if (items.length === 0) return
|
|
47
|
+
const nextIndex = direction === 1
|
|
48
|
+
? (currentIndex >= items.length - 1 ? 0 : currentIndex + 1)
|
|
49
|
+
: (currentIndex <= 0 ? items.length - 1 : currentIndex - 1)
|
|
50
|
+
items[nextIndex]?.focus()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
54
|
+
const menuItems = getMenuItems()
|
|
55
|
+
const currentIndex = menuItems.indexOf(document.activeElement as HTMLElement)
|
|
56
|
+
|
|
57
|
+
switch (e.key) {
|
|
58
|
+
case 'ArrowDown':
|
|
59
|
+
e.preventDefault()
|
|
60
|
+
focusItem(menuItems, currentIndex, 1)
|
|
61
|
+
break
|
|
62
|
+
case 'ArrowUp':
|
|
63
|
+
e.preventDefault()
|
|
64
|
+
focusItem(menuItems, currentIndex, -1)
|
|
65
|
+
break
|
|
66
|
+
case 'ArrowLeft':
|
|
67
|
+
case 'Escape':
|
|
68
|
+
e.preventDefault()
|
|
69
|
+
onClose()
|
|
70
|
+
break
|
|
71
|
+
case 'Enter':
|
|
72
|
+
case ' ':
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
;(document.activeElement as HTMLElement)?.click()
|
|
75
|
+
break
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<div use:portal>
|
|
81
|
+
<div
|
|
82
|
+
bind:this={floatingEl}
|
|
83
|
+
role="menu"
|
|
84
|
+
data-menu-content
|
|
85
|
+
class={menuStyles.subContent}
|
|
86
|
+
onkeydown={handleKeydown}
|
|
87
|
+
onpointerenter={() => activate()}
|
|
88
|
+
onpointerleave={(e) => e.stopImmediatePropagation()}
|
|
89
|
+
>
|
|
90
|
+
{#each items as entry, i (i)}
|
|
91
|
+
{#if isSeparator(entry)}
|
|
92
|
+
<div role="separator" class={menuStyles.separator}></div>
|
|
93
|
+
{:else if isGroup(entry)}
|
|
94
|
+
{@const headingId = `dd-sub-simple-group-${i}`}
|
|
95
|
+
<div role="group" aria-labelledby={entry.heading ? headingId : undefined}>
|
|
96
|
+
{#if entry.heading}
|
|
97
|
+
<div id={headingId} role="presentation" class={menuStyles.groupHeading}>
|
|
98
|
+
{entry.heading}
|
|
99
|
+
</div>
|
|
100
|
+
{/if}
|
|
101
|
+
{#each entry.items as groupItem (isItem(groupItem) ? groupItem.value : groupItem.label)}
|
|
102
|
+
{#if isSub(groupItem)}
|
|
103
|
+
{@render renderSubMenu(groupItem)}
|
|
104
|
+
{:else if isItem(groupItem)}
|
|
105
|
+
<div
|
|
106
|
+
role="menuitem"
|
|
107
|
+
tabindex="-1"
|
|
108
|
+
data-disabled={groupItem.disabled ? '' : undefined}
|
|
109
|
+
class={menuItemClass({variant: groupItem.variant})}
|
|
110
|
+
onclick={() => onSelect(groupItem.value)}
|
|
111
|
+
onpointerenter={(e) => (e.currentTarget as HTMLElement).focus()}
|
|
112
|
+
>
|
|
113
|
+
{@render renderItemContent(groupItem)}
|
|
114
|
+
</div>
|
|
115
|
+
{/if}
|
|
116
|
+
{/each}
|
|
117
|
+
</div>
|
|
118
|
+
{:else if isSub(entry)}
|
|
119
|
+
{@render renderSubMenu(entry)}
|
|
120
|
+
{:else if isItem(entry)}
|
|
121
|
+
<div
|
|
122
|
+
role="menuitem"
|
|
123
|
+
tabindex="-1"
|
|
124
|
+
data-disabled={entry.disabled ? '' : undefined}
|
|
125
|
+
class={menuItemClass({variant: entry.variant})}
|
|
126
|
+
onclick={() => onSelect(entry.value)}
|
|
127
|
+
onpointerenter={(e) => (e.currentTarget as HTMLElement).focus()}
|
|
128
|
+
>
|
|
129
|
+
{@render renderItemContent(entry)}
|
|
130
|
+
</div>
|
|
131
|
+
{/if}
|
|
132
|
+
{/each}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type MenuItem, type MenuEntry, type SubEntry } from '../utils/menu';
|
|
3
|
+
type Props = {
|
|
4
|
+
items: MenuEntry[];
|
|
5
|
+
onSelect: (value: string) => void;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
triggerEl: HTMLElement | null;
|
|
8
|
+
renderItemContent: Snippet<[MenuItem]>;
|
|
9
|
+
renderSubMenu: Snippet<[SubEntry]>;
|
|
10
|
+
};
|
|
11
|
+
declare const IIDropdownMenuSubSimple: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type IIDropdownMenuSubSimple = ReturnType<typeof IIDropdownMenuSubSimple>;
|
|
13
|
+
export default IIDropdownMenuSubSimple;
|
package/dist/style/colors.css
CHANGED
|
@@ -63,7 +63,7 @@ export const keyframes = {
|
|
|
63
63
|
export const animation = {
|
|
64
64
|
spin: 'spin 0.6s linear infinite',
|
|
65
65
|
'fade-in': 'fadeIn 200ms ease-out',
|
|
66
|
-
'scale-in': 'scaleIn
|
|
66
|
+
'scale-in': 'scaleIn 120ms ease-out',
|
|
67
67
|
'slide-in': 'slideIn 150ms ease-out',
|
|
68
68
|
shake: 'shake 0.4s ease-out',
|
|
69
69
|
shimmer: 'shimmer 1.5s infinite',
|
|
@@ -9,7 +9,9 @@ export const boxShadow = {
|
|
|
9
9
|
// Card hover / raised state
|
|
10
10
|
'card-hover': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
|
11
11
|
// Dropdown menus, popovers, comboboxes, date pickers
|
|
12
|
-
dropdown: '0
|
|
12
|
+
dropdown: '0 6px 18px rgba(0, 0, 0, 0.02), 0 3px 9px rgba(0, 0, 0, 0.04), 0 1px 1px rgba(0, 0, 0, 0.04)',
|
|
13
|
+
// Submenu / nested floating panels
|
|
14
|
+
submenu: '0 9px 48px rgba(0, 0, 0, 0.08), 0 6px 24px rgba(0, 0, 0, 0.1), 0 1px 1px rgba(0, 0, 0, 0.04)',
|
|
13
15
|
// Modal dialogs
|
|
14
16
|
modal: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
|
15
17
|
// Side drawers / floating panels
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type {Snippet} from 'svelte'
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
icon?: Snippet
|
|
6
|
+
label: string
|
|
7
|
+
shortcut?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let {icon, label, shortcut}: Props = $props()
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
{#if icon}
|
|
14
|
+
<div class="w-16 h-16 flex items-center justify-center shrink-0 [&_svg]:w-16 [&_svg]:h-16">
|
|
15
|
+
{@render icon()}
|
|
16
|
+
</div>
|
|
17
|
+
{/if}
|
|
18
|
+
<span class="flex-1">{label}</span>
|
|
19
|
+
{#if shortcut}
|
|
20
|
+
<span class="text-tiny text-tertiary ml-8">{shortcut}</span>
|
|
21
|
+
{/if}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type Props = {
|
|
3
|
+
icon?: Snippet;
|
|
4
|
+
label: string;
|
|
5
|
+
shortcut?: string;
|
|
6
|
+
};
|
|
7
|
+
declare const MenuItemContent: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type MenuItemContent = ReturnType<typeof MenuItemContent>;
|
|
9
|
+
export default MenuItemContent;
|
|
@@ -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 MenuNoResults: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
|
|
15
|
+
[evt: string]: CustomEvent<any>;
|
|
16
|
+
}, {}, {}, string>;
|
|
17
|
+
type MenuNoResults = InstanceType<typeof MenuNoResults>;
|
|
18
|
+
export default MenuNoResults;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
type Props = {
|
|
3
|
+
inputEl?: HTMLInputElement | null
|
|
4
|
+
searchQuery?: string
|
|
5
|
+
placeholder?: string
|
|
6
|
+
onkeydown: (e: KeyboardEvent) => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
inputEl = $bindable(null),
|
|
11
|
+
searchQuery = $bindable(''),
|
|
12
|
+
placeholder = 'Search...',
|
|
13
|
+
onkeydown,
|
|
14
|
+
}: Props = $props()
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<div class="-mx-4 -mt-4 px-14 mb-4 border-b-[0.5px] border-dropdown-border">
|
|
18
|
+
<input
|
|
19
|
+
bind:this={inputEl}
|
|
20
|
+
bind:value={searchQuery}
|
|
21
|
+
{placeholder}
|
|
22
|
+
class="w-full h-36 bg-transparent text-small text-body placeholder:text-tertiary outline-none border-none p-0"
|
|
23
|
+
{onkeydown}
|
|
24
|
+
/>
|
|
25
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type Props = {
|
|
2
|
+
inputEl?: HTMLInputElement | null;
|
|
3
|
+
searchQuery?: string;
|
|
4
|
+
placeholder?: string;
|
|
5
|
+
onkeydown: (e: KeyboardEvent) => void;
|
|
6
|
+
};
|
|
7
|
+
declare const MenuSearchInput: import("svelte").Component<Props, {}, "inputEl" | "searchQuery">;
|
|
8
|
+
type MenuSearchInput = ReturnType<typeof MenuSearchInput>;
|
|
9
|
+
export default MenuSearchInput;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { default as MenuSearchInput } from './MenuSearchInput.svelte';
|
|
2
|
+
export { default as MenuNoResults } from './MenuNoResults.svelte';
|
|
3
|
+
export { default as MenuItemContent } from './MenuItemContent.svelte';
|
|
4
|
+
export { menuStyles, menuItemClass } from './menu-styles';
|
|
5
|
+
export { portal } from './use-portal';
|
|
6
|
+
export { clickOutside } from './use-click-outside';
|
|
7
|
+
export { useFloating } from './use-floating.svelte';
|
|
8
|
+
export { createMenuSearch, filterMenuItems, getSelectableItems, isItem, isGroup, isSub, isSeparator, type MenuItem, type MenuEntry, type SubEntry, type SeparatorEntry, type GroupEntry, } from './menu-search.svelte';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { default as MenuSearchInput } from './MenuSearchInput.svelte';
|
|
2
|
+
export { default as MenuNoResults } from './MenuNoResults.svelte';
|
|
3
|
+
export { default as MenuItemContent } from './MenuItemContent.svelte';
|
|
4
|
+
export { menuStyles, menuItemClass } from './menu-styles';
|
|
5
|
+
export { portal } from './use-portal';
|
|
6
|
+
export { clickOutside } from './use-click-outside';
|
|
7
|
+
export { useFloating } from './use-floating.svelte';
|
|
8
|
+
export { createMenuSearch, filterMenuItems, getSelectableItems, isItem, isGroup, isSub, isSeparator, } from './menu-search.svelte';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
export type MenuItem = {
|
|
3
|
+
label: string;
|
|
4
|
+
value: string;
|
|
5
|
+
icon?: Snippet;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
variant?: 'default' | 'destructive';
|
|
8
|
+
shortcut?: string;
|
|
9
|
+
};
|
|
10
|
+
export type SeparatorEntry = {
|
|
11
|
+
type: 'separator';
|
|
12
|
+
};
|
|
13
|
+
export type GroupEntry = {
|
|
14
|
+
type: 'group';
|
|
15
|
+
heading?: string;
|
|
16
|
+
items: (MenuItem | SubEntry)[];
|
|
17
|
+
};
|
|
18
|
+
export type SubEntry = {
|
|
19
|
+
type: 'sub';
|
|
20
|
+
label: string;
|
|
21
|
+
icon?: Snippet;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
shortcut?: string;
|
|
24
|
+
items: MenuEntry[];
|
|
25
|
+
searchable?: boolean;
|
|
26
|
+
searchPlaceholder?: string;
|
|
27
|
+
};
|
|
28
|
+
export type MenuEntry = MenuItem | SeparatorEntry | GroupEntry | SubEntry;
|
|
29
|
+
export declare function isSeparator(entry: MenuEntry): entry is SeparatorEntry;
|
|
30
|
+
export declare function isGroup(entry: MenuEntry): entry is GroupEntry;
|
|
31
|
+
export declare function isSub(entry: MenuEntry): entry is SubEntry;
|
|
32
|
+
export declare function isItem(entry: MenuEntry): entry is MenuItem;
|
|
33
|
+
export declare function filterMenuItems(entries: MenuEntry[], query: string): MenuEntry[];
|
|
34
|
+
export declare function getSelectableItems(entries: MenuEntry[]): MenuItem[];
|
|
35
|
+
export declare function createMenuSearch(opts: {
|
|
36
|
+
getItems: () => MenuEntry[];
|
|
37
|
+
onSelect: (value: string) => void;
|
|
38
|
+
/** Extra keys to pass through (not stopPropagation). Escape always passes through. */
|
|
39
|
+
passthroughKeys?: string[];
|
|
40
|
+
}): {
|
|
41
|
+
searchQuery: string;
|
|
42
|
+
readonly highlightedIndex: number;
|
|
43
|
+
inputEl: HTMLInputElement | null;
|
|
44
|
+
readonly filteredItems: MenuEntry[];
|
|
45
|
+
readonly selectableItems: MenuItem[];
|
|
46
|
+
reset: () => void;
|
|
47
|
+
focusInput: () => void;
|
|
48
|
+
resetAndFocus: () => void;
|
|
49
|
+
getItemIndex: (item: MenuItem) => number;
|
|
50
|
+
handleKeydown: (e: KeyboardEvent, containerSelector: string) => void;
|
|
51
|
+
setHighlight: (index: number) => void;
|
|
52
|
+
refocusInput: (e: FocusEvent) => void;
|
|
53
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
export function isSeparator(entry) {
|
|
2
|
+
return 'type' in entry && entry.type === 'separator';
|
|
3
|
+
}
|
|
4
|
+
export function isGroup(entry) {
|
|
5
|
+
return 'type' in entry && entry.type === 'group';
|
|
6
|
+
}
|
|
7
|
+
export function isSub(entry) {
|
|
8
|
+
return 'type' in entry && entry.type === 'sub';
|
|
9
|
+
}
|
|
10
|
+
export function isItem(entry) {
|
|
11
|
+
return !('type' in entry);
|
|
12
|
+
}
|
|
13
|
+
export function filterMenuItems(entries, query) {
|
|
14
|
+
if (!query)
|
|
15
|
+
return entries;
|
|
16
|
+
const lowerQuery = query.toLowerCase();
|
|
17
|
+
const filtered = [];
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
if (isSeparator(entry)) {
|
|
20
|
+
filtered.push(entry);
|
|
21
|
+
}
|
|
22
|
+
else if (isGroup(entry)) {
|
|
23
|
+
const filteredGroupItems = entry.items.filter(item => isSub(item) || item.label.toLowerCase().includes(lowerQuery));
|
|
24
|
+
if (filteredGroupItems.length > 0) {
|
|
25
|
+
filtered.push({ ...entry, items: filteredGroupItems });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else if (isSub(entry)) {
|
|
29
|
+
// Sub entries are not filtered by search — they don't appear in searchable panels
|
|
30
|
+
filtered.push(entry);
|
|
31
|
+
}
|
|
32
|
+
else if (isItem(entry)) {
|
|
33
|
+
if (entry.label.toLowerCase().includes(lowerQuery)) {
|
|
34
|
+
filtered.push(entry);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Remove leading/trailing/consecutive separators
|
|
39
|
+
return filtered.filter((entry, i, arr) => {
|
|
40
|
+
if (!isSeparator(entry))
|
|
41
|
+
return true;
|
|
42
|
+
if (i === 0 || i === arr.length - 1)
|
|
43
|
+
return false;
|
|
44
|
+
return !isSeparator(arr[i - 1]);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export function getSelectableItems(entries) {
|
|
48
|
+
const result = [];
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (isItem(entry) && !entry.disabled) {
|
|
51
|
+
result.push(entry);
|
|
52
|
+
}
|
|
53
|
+
else if (isGroup(entry)) {
|
|
54
|
+
for (const item of entry.items) {
|
|
55
|
+
if (isItem(item) && !item.disabled)
|
|
56
|
+
result.push(item);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
export function createMenuSearch(opts) {
|
|
63
|
+
let searchQuery = $state('');
|
|
64
|
+
let highlightedIndex = $state(0);
|
|
65
|
+
let inputEl = $state(null);
|
|
66
|
+
const filteredItems = $derived(filterMenuItems(opts.getItems(), searchQuery));
|
|
67
|
+
const selectableItems = $derived(getSelectableItems(filteredItems));
|
|
68
|
+
// Reset highlight when search changes
|
|
69
|
+
$effect(() => {
|
|
70
|
+
searchQuery;
|
|
71
|
+
highlightedIndex = 0;
|
|
72
|
+
});
|
|
73
|
+
function reset() {
|
|
74
|
+
searchQuery = '';
|
|
75
|
+
highlightedIndex = 0;
|
|
76
|
+
}
|
|
77
|
+
function focusInput() {
|
|
78
|
+
requestAnimationFrame(() => inputEl?.focus());
|
|
79
|
+
}
|
|
80
|
+
function resetAndFocus() {
|
|
81
|
+
reset();
|
|
82
|
+
focusInput();
|
|
83
|
+
}
|
|
84
|
+
function getItemIndex(item) {
|
|
85
|
+
return selectableItems.indexOf(item);
|
|
86
|
+
}
|
|
87
|
+
function scrollToHighlighted(containerSelector) {
|
|
88
|
+
requestAnimationFrame(() => {
|
|
89
|
+
const container = inputEl?.closest(containerSelector);
|
|
90
|
+
const el = container?.querySelectorAll('[data-search-item]')?.[highlightedIndex];
|
|
91
|
+
el?.scrollIntoView({ block: 'nearest' });
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function handleKeydown(e, containerSelector) {
|
|
95
|
+
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
highlightedIndex = Math.min(highlightedIndex + 1, selectableItems.length - 1);
|
|
98
|
+
scrollToHighlighted(containerSelector);
|
|
99
|
+
}
|
|
100
|
+
else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
highlightedIndex = Math.max(highlightedIndex - 1, 0);
|
|
103
|
+
scrollToHighlighted(containerSelector);
|
|
104
|
+
}
|
|
105
|
+
else if (e.key === 'Enter') {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
const item = selectableItems[highlightedIndex];
|
|
108
|
+
if (item)
|
|
109
|
+
opts.onSelect(item.value);
|
|
110
|
+
}
|
|
111
|
+
else if (e.key !== 'Escape' && !opts.passthroughKeys?.includes(e.key)) {
|
|
112
|
+
e.stopPropagation();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function setHighlight(index) {
|
|
116
|
+
highlightedIndex = index;
|
|
117
|
+
}
|
|
118
|
+
function refocusInput(e) {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
inputEl?.focus();
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
get searchQuery() { return searchQuery; },
|
|
124
|
+
set searchQuery(v) { searchQuery = v; },
|
|
125
|
+
get highlightedIndex() { return highlightedIndex; },
|
|
126
|
+
get inputEl() { return inputEl; },
|
|
127
|
+
set inputEl(v) { inputEl = v; },
|
|
128
|
+
get filteredItems() { return filteredItems; },
|
|
129
|
+
get selectableItems() { return selectableItems; },
|
|
130
|
+
reset,
|
|
131
|
+
focusInput,
|
|
132
|
+
resetAndFocus,
|
|
133
|
+
getItemIndex,
|
|
134
|
+
handleKeydown,
|
|
135
|
+
setHighlight,
|
|
136
|
+
refocusInput,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const menuStyles: {
|
|
2
|
+
readonly item: "flex items-center gap-8 px-12 py-8 rounded-4 text-small cursor-default select-none outline-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed data-[disabled]:pointer-events-none motion-reduce:transition-none";
|
|
3
|
+
readonly itemDefault: "text-dropdown-item hover:bg-dropdown-item-hover focus:bg-dropdown-item-hover data-[highlighted]:bg-dropdown-item-hover data-[highlighted]:outline-none data-[state=open]:bg-dropdown-item-hover";
|
|
4
|
+
readonly itemDestructive: "text-error hover:bg-error-bg focus:bg-error-bg data-[highlighted]:bg-error-bg data-[highlighted]:outline-none";
|
|
5
|
+
readonly content: "min-w-48 max-h-300 overflow-y-auto bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-dropdown p-4 z-12 outline-none origin-top-left animate-scale-in motion-reduce:animate-none";
|
|
6
|
+
readonly subContent: "min-w-48 max-h-300 overflow-y-auto bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-submenu p-4 z-12 outline-none origin-top-left animate-scale-in motion-reduce:animate-none";
|
|
7
|
+
readonly searchableSubContent: "min-w-48 bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-submenu p-4 z-12 outline-none overflow-hidden origin-top-left animate-scale-in motion-reduce:animate-none";
|
|
8
|
+
readonly scrollableItems: "max-h-250 overflow-y-auto overflow-x-hidden";
|
|
9
|
+
readonly separator: "h-1 bg-muted -mx-4 my-4";
|
|
10
|
+
readonly groupHeading: "text-tiny-emphasis text-secondary px-12 py-4 uppercase select-none";
|
|
11
|
+
};
|
|
12
|
+
export declare function menuItemClass(opts: {
|
|
13
|
+
variant?: 'default' | 'destructive';
|
|
14
|
+
searchable?: boolean;
|
|
15
|
+
isHighlighted?: boolean;
|
|
16
|
+
}): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { cn } from '../cn';
|
|
2
|
+
export const menuStyles = {
|
|
3
|
+
item: 'flex items-center gap-8 px-12 py-8 rounded-4 text-small cursor-default select-none outline-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed data-[disabled]:pointer-events-none motion-reduce:transition-none',
|
|
4
|
+
itemDefault: 'text-dropdown-item hover:bg-dropdown-item-hover focus:bg-dropdown-item-hover data-[highlighted]:bg-dropdown-item-hover data-[highlighted]:outline-none data-[state=open]:bg-dropdown-item-hover',
|
|
5
|
+
itemDestructive: 'text-error hover:bg-error-bg focus:bg-error-bg data-[highlighted]:bg-error-bg data-[highlighted]:outline-none',
|
|
6
|
+
content: 'min-w-48 max-h-300 overflow-y-auto bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-dropdown p-4 z-12 outline-none origin-top-left animate-scale-in motion-reduce:animate-none',
|
|
7
|
+
subContent: 'min-w-48 max-h-300 overflow-y-auto bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-submenu p-4 z-12 outline-none origin-top-left animate-scale-in motion-reduce:animate-none',
|
|
8
|
+
searchableSubContent: 'min-w-48 bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-submenu p-4 z-12 outline-none overflow-hidden origin-top-left animate-scale-in motion-reduce:animate-none',
|
|
9
|
+
scrollableItems: 'max-h-250 overflow-y-auto overflow-x-hidden',
|
|
10
|
+
separator: 'h-1 bg-muted -mx-4 my-4',
|
|
11
|
+
groupHeading: 'text-tiny-emphasis text-secondary px-12 py-4 uppercase select-none',
|
|
12
|
+
};
|
|
13
|
+
export function menuItemClass(opts) {
|
|
14
|
+
const { variant = 'default', searchable = false, isHighlighted = false } = opts;
|
|
15
|
+
if (variant === 'destructive') {
|
|
16
|
+
return cn(menuStyles.item, 'text-error', !searchable && 'hover:bg-error-bg focus:bg-error-bg data-[highlighted]:bg-error-bg', searchable && isHighlighted && 'bg-error-bg');
|
|
17
|
+
}
|
|
18
|
+
return cn(menuStyles.item, 'text-dropdown-item', !searchable && 'hover:bg-dropdown-item-hover focus:bg-dropdown-item-hover data-[highlighted]:bg-dropdown-item-hover', searchable && isHighlighted && 'bg-dropdown-item-hover');
|
|
19
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function clickOutside(node, params) {
|
|
2
|
+
let { onClose, enabled = true } = params;
|
|
3
|
+
let handler = null;
|
|
4
|
+
let rafId = null;
|
|
5
|
+
function setup() {
|
|
6
|
+
teardown();
|
|
7
|
+
if (!enabled)
|
|
8
|
+
return;
|
|
9
|
+
// Defer so we don't catch the opening click/contextmenu event
|
|
10
|
+
rafId = requestAnimationFrame(() => {
|
|
11
|
+
handler = (e) => {
|
|
12
|
+
const target = e.target;
|
|
13
|
+
// Don't close if click is inside any menu content (including portaled submenus)
|
|
14
|
+
const allMenuContent = document.querySelectorAll('[data-menu-content]');
|
|
15
|
+
for (const el of allMenuContent) {
|
|
16
|
+
if (el.contains(target))
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
onClose();
|
|
20
|
+
};
|
|
21
|
+
document.addEventListener('pointerdown', handler, { capture: true });
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function teardown() {
|
|
25
|
+
if (rafId !== null) {
|
|
26
|
+
cancelAnimationFrame(rafId);
|
|
27
|
+
rafId = null;
|
|
28
|
+
}
|
|
29
|
+
if (handler) {
|
|
30
|
+
document.removeEventListener('pointerdown', handler, { capture: true });
|
|
31
|
+
handler = null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
setup();
|
|
35
|
+
return {
|
|
36
|
+
update(newParams) {
|
|
37
|
+
onClose = newParams.onClose;
|
|
38
|
+
enabled = newParams.enabled ?? true;
|
|
39
|
+
setup();
|
|
40
|
+
},
|
|
41
|
+
destroy() {
|
|
42
|
+
teardown();
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Placement, type VirtualElement } from '@floating-ui/dom';
|
|
2
|
+
export type UseFloatingOptions = {
|
|
3
|
+
reference: () => HTMLElement | VirtualElement | null;
|
|
4
|
+
floating: () => HTMLElement | null;
|
|
5
|
+
placement?: Placement;
|
|
6
|
+
offset?: number;
|
|
7
|
+
flip?: boolean;
|
|
8
|
+
shift?: boolean | {
|
|
9
|
+
padding?: number;
|
|
10
|
+
};
|
|
11
|
+
matchWidth?: boolean;
|
|
12
|
+
};
|
|
13
|
+
export declare function useFloating(opts: UseFloatingOptions): void;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { computePosition, autoUpdate, offset as offsetMiddleware, flip as flipMiddleware, shift as shiftMiddleware, size as sizeMiddleware } from '@floating-ui/dom';
|
|
2
|
+
export function useFloating(opts) {
|
|
3
|
+
let cleanup = null;
|
|
4
|
+
function compute(reference, floating) {
|
|
5
|
+
const middleware = [];
|
|
6
|
+
if (opts.offset != null) {
|
|
7
|
+
middleware.push(offsetMiddleware(opts.offset));
|
|
8
|
+
}
|
|
9
|
+
if (opts.flip !== false) {
|
|
10
|
+
middleware.push(flipMiddleware());
|
|
11
|
+
}
|
|
12
|
+
if (opts.shift !== false) {
|
|
13
|
+
const padding = typeof opts.shift === 'object' ? opts.shift.padding : 8;
|
|
14
|
+
middleware.push(shiftMiddleware({ padding }));
|
|
15
|
+
}
|
|
16
|
+
if (opts.matchWidth) {
|
|
17
|
+
middleware.push(sizeMiddleware({
|
|
18
|
+
apply({ rects }) {
|
|
19
|
+
Object.assign(floating.style, {
|
|
20
|
+
minWidth: `${rects.reference.width}px`,
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
computePosition(reference, floating, {
|
|
26
|
+
placement: opts.placement ?? 'bottom-start',
|
|
27
|
+
middleware,
|
|
28
|
+
}).then(({ x, y }) => {
|
|
29
|
+
Object.assign(floating.style, {
|
|
30
|
+
position: 'absolute',
|
|
31
|
+
left: `${x}px`,
|
|
32
|
+
top: `${y}px`,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
$effect(() => {
|
|
37
|
+
const reference = opts.reference();
|
|
38
|
+
const floating = opts.floating();
|
|
39
|
+
if (cleanup) {
|
|
40
|
+
cleanup();
|
|
41
|
+
cleanup = null;
|
|
42
|
+
}
|
|
43
|
+
if (!reference || !floating)
|
|
44
|
+
return;
|
|
45
|
+
cleanup = autoUpdate(reference, floating, () => {
|
|
46
|
+
compute(reference, floating);
|
|
47
|
+
});
|
|
48
|
+
return () => {
|
|
49
|
+
if (cleanup) {
|
|
50
|
+
cleanup();
|
|
51
|
+
cleanup = null;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|