@fivem-nui/svelte-components 0.0.0-stage → 1.0.0
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/README.md +101 -2
- package/dist/components/actions/NuiButton.svelte +292 -0
- package/dist/components/actions/NuiButton.svelte.d.ts +60 -0
- package/dist/components/data/NuiBadge.svelte +277 -0
- package/dist/components/data/NuiBadge.svelte.d.ts +74 -0
- package/dist/components/data/NuiCodeBlock.svelte +256 -0
- package/dist/components/data/NuiCodeBlock.svelte.d.ts +49 -0
- package/dist/components/data/NuiList.svelte +341 -0
- package/dist/components/data/NuiList.svelte.d.ts +52 -0
- package/dist/components/data/NuiProgressBar.svelte +264 -0
- package/dist/components/data/NuiProgressBar.svelte.d.ts +51 -0
- package/dist/components/data/NuiRichText.svelte +260 -0
- package/dist/components/data/NuiRichText.svelte.d.ts +33 -0
- package/dist/components/data/NuiTable.svelte +470 -0
- package/dist/components/data/NuiTable.svelte.d.ts +53 -0
- package/dist/components/data/chart/NuiChart.svelte +384 -0
- package/dist/components/data/chart/NuiChart.svelte.d.ts +49 -0
- package/dist/components/data/chart/color.d.ts +9 -0
- package/dist/components/data/chart/color.js +78 -0
- package/dist/components/data/chart/index.d.ts +10 -0
- package/dist/components/data/chart/index.js +10 -0
- package/dist/components/data/chart/registry.d.ts +14 -0
- package/dist/components/data/chart/registry.js +53 -0
- package/dist/components/data/chart/renderers/bar.d.ts +8 -0
- package/dist/components/data/chart/renderers/bar.js +79 -0
- package/dist/components/data/chart/renderers/base.d.ts +18 -0
- package/dist/components/data/chart/renderers/base.js +10 -0
- package/dist/components/data/chart/renderers/line.d.ts +8 -0
- package/dist/components/data/chart/renderers/line.js +89 -0
- package/dist/components/data/chart/renderers/pie.d.ts +10 -0
- package/dist/components/data/chart/renderers/pie.js +55 -0
- package/dist/components/data/chart/scale.d.ts +22 -0
- package/dist/components/data/chart/scale.js +101 -0
- package/dist/components/data/chart/types.d.ts +84 -0
- package/dist/components/data/chart/types.js +1 -0
- package/dist/components/data/chart/validator.d.ts +10 -0
- package/dist/components/data/chart/validator.js +82 -0
- package/dist/components/forms/NuiCheckbox.svelte +218 -0
- package/dist/components/forms/NuiCheckbox.svelte.d.ts +37 -0
- package/dist/components/forms/NuiDatepicker.svelte +755 -0
- package/dist/components/forms/NuiDatepicker.svelte.d.ts +34 -0
- package/dist/components/forms/NuiFileInput.svelte +1047 -0
- package/dist/components/forms/NuiFileInput.svelte.d.ts +93 -0
- package/dist/components/forms/NuiFileInput.types.d.ts +28 -0
- package/dist/components/forms/NuiFileInput.types.js +1 -0
- package/dist/components/forms/NuiForm.svelte +391 -0
- package/dist/components/forms/NuiForm.svelte.d.ts +69 -0
- package/dist/components/forms/NuiInput.svelte +296 -0
- package/dist/components/forms/NuiInput.svelte.d.ts +62 -0
- package/dist/components/forms/NuiRadioGroup.svelte +238 -0
- package/dist/components/forms/NuiRadioGroup.svelte.d.ts +47 -0
- package/dist/components/forms/NuiSelect.svelte +819 -0
- package/dist/components/forms/NuiSelect.svelte.d.ts +49 -0
- package/dist/components/layout/NuiCard.svelte +314 -0
- package/dist/components/layout/NuiCard.svelte.d.ts +48 -0
- package/dist/components/layout/NuiCardGrid.svelte +98 -0
- package/dist/components/layout/NuiCardGrid.svelte.d.ts +22 -0
- package/dist/components/layout/NuiFlex.svelte +121 -0
- package/dist/components/layout/NuiFlex.svelte.d.ts +23 -0
- package/dist/components/layout/NuiGrid.svelte +66 -0
- package/dist/components/layout/NuiGrid.svelte.d.ts +14 -0
- package/dist/components/layout/NuiStack.svelte +138 -0
- package/dist/components/layout/NuiStack.svelte.d.ts +26 -0
- package/dist/components/navigation/NuiAccordion.svelte +323 -0
- package/dist/components/navigation/NuiAccordion.svelte.d.ts +38 -0
- package/dist/components/navigation/NuiBreadcrumbs.svelte +133 -0
- package/dist/components/navigation/NuiBreadcrumbs.svelte.d.ts +19 -0
- package/dist/components/navigation/NuiMenu.svelte +596 -0
- package/dist/components/navigation/NuiMenu.svelte.d.ts +28 -0
- package/dist/components/navigation/NuiPagination.svelte +306 -0
- package/dist/components/navigation/NuiPagination.svelte.d.ts +40 -0
- package/dist/components/navigation/NuiSidebar.svelte +186 -0
- package/dist/components/navigation/NuiSidebar.svelte.d.ts +26 -0
- package/dist/components/navigation/NuiTabs.svelte +249 -0
- package/dist/components/navigation/NuiTabs.svelte.d.ts +31 -0
- package/dist/components/overlay/NuiDialog.svelte +118 -0
- package/dist/components/overlay/NuiDialog.svelte.d.ts +34 -0
- package/dist/components/overlay/NuiModal.svelte +992 -0
- package/dist/components/overlay/NuiModal.svelte.d.ts +72 -0
- package/dist/components/overlay/NuiTooltip.svelte +159 -0
- package/dist/components/overlay/NuiTooltip.svelte.d.ts +13 -0
- package/dist/composables/useClickOutside.d.ts +43 -0
- package/dist/composables/useClickOutside.js +73 -0
- package/dist/composables/useFormValidation.d.ts +23 -0
- package/dist/composables/useFormValidation.js +72 -0
- package/dist/composables/useIdle.svelte.d.ts +18 -0
- package/dist/composables/useIdle.svelte.js +144 -0
- package/dist/composables/useKeyboardShortcut.svelte.d.ts +114 -0
- package/dist/composables/useKeyboardShortcut.svelte.js +153 -0
- package/dist/composables/useLocalStorage.svelte.d.ts +22 -0
- package/dist/composables/useLocalStorage.svelte.js +105 -0
- package/dist/composables/useMediaQuery.svelte.d.ts +51 -0
- package/dist/composables/useMediaQuery.svelte.js +80 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +42 -0
- package/package.json +58 -3
- package/src/components/actions/NuiButton.svelte +292 -0
- package/src/components/data/NuiBadge.svelte +277 -0
- package/src/components/data/NuiCodeBlock.svelte +256 -0
- package/src/components/data/NuiList.svelte +341 -0
- package/src/components/data/NuiProgressBar.svelte +264 -0
- package/src/components/data/NuiRichText.svelte +260 -0
- package/src/components/data/NuiTable.svelte +470 -0
- package/src/components/data/chart/NuiChart.svelte +384 -0
- package/src/components/data/chart/color.ts +83 -0
- package/src/components/data/chart/index.ts +10 -0
- package/src/components/data/chart/registry.ts +71 -0
- package/src/components/data/chart/renderers/bar.ts +90 -0
- package/src/components/data/chart/renderers/base.ts +32 -0
- package/src/components/data/chart/renderers/line.ts +99 -0
- package/src/components/data/chart/renderers/pie.ts +66 -0
- package/src/components/data/chart/scale.ts +139 -0
- package/src/components/data/chart/types.ts +98 -0
- package/src/components/data/chart/validator.ts +98 -0
- package/src/components/forms/NuiCheckbox.svelte +218 -0
- package/src/components/forms/NuiDatepicker.svelte +755 -0
- package/src/components/forms/NuiFileInput.svelte +1047 -0
- package/src/components/forms/NuiFileInput.types.ts +29 -0
- package/src/components/forms/NuiForm.svelte +391 -0
- package/src/components/forms/NuiInput.svelte +296 -0
- package/src/components/forms/NuiRadioGroup.svelte +238 -0
- package/src/components/forms/NuiSelect.svelte +819 -0
- package/src/components/layout/NuiCard.svelte +314 -0
- package/src/components/layout/NuiCardGrid.svelte +98 -0
- package/src/components/layout/NuiFlex.svelte +121 -0
- package/src/components/layout/NuiGrid.svelte +66 -0
- package/src/components/layout/NuiStack.svelte +138 -0
- package/src/components/navigation/NuiAccordion.svelte +323 -0
- package/src/components/navigation/NuiBreadcrumbs.svelte +133 -0
- package/src/components/navigation/NuiMenu.svelte +596 -0
- package/src/components/navigation/NuiPagination.svelte +306 -0
- package/src/components/navigation/NuiSidebar.svelte +186 -0
- package/src/components/navigation/NuiTabs.svelte +249 -0
- package/src/components/overlay/NuiDialog.svelte +118 -0
- package/src/components/overlay/NuiModal.svelte +992 -0
- package/src/components/overlay/NuiTooltip.svelte +159 -0
- package/src/composables/useClickOutside.ts +132 -0
- package/src/composables/useFormValidation.ts +105 -0
- package/src/composables/useIdle.svelte.ts +191 -0
- package/src/composables/useKeyboardShortcut.svelte.ts +278 -0
- package/src/composables/useLocalStorage.svelte.ts +137 -0
- package/src/composables/useMediaQuery.svelte.ts +102 -0
- package/src/index.ts +66 -0
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
export interface MenuItem {
|
|
3
|
+
/** Unique id. Also required for separators (used as each key) */
|
|
4
|
+
id: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
icon?: string;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
/** One level of submenu */
|
|
9
|
+
children?: MenuItem[];
|
|
10
|
+
separator?: boolean;
|
|
11
|
+
onclick?: () => void;
|
|
12
|
+
}
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<script lang="ts">
|
|
16
|
+
import type { Snippet } from "svelte";
|
|
17
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
18
|
+
|
|
19
|
+
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
20
|
+
items: MenuItem[];
|
|
21
|
+
open?: boolean;
|
|
22
|
+
/** Text of the trigger button (ignored when `trigger` is provided) */
|
|
23
|
+
triggerLabel?: string;
|
|
24
|
+
/** Custom trigger content */
|
|
25
|
+
trigger?: Snippet;
|
|
26
|
+
/** Close on pointer down outside of the menu */
|
|
27
|
+
closeOnOutsideClick?: boolean;
|
|
28
|
+
/** Which edge of the trigger the dropdown aligns to. `end` also opens submenus to the left */
|
|
29
|
+
align?: "start" | "end";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const uid = $props.id();
|
|
33
|
+
const menuId = `nui-menu-${uid}`;
|
|
34
|
+
const triggerId = `${menuId}-trigger`;
|
|
35
|
+
|
|
36
|
+
let {
|
|
37
|
+
items = [],
|
|
38
|
+
open = $bindable(false),
|
|
39
|
+
triggerLabel = "Menu",
|
|
40
|
+
trigger,
|
|
41
|
+
closeOnOutsideClick = true,
|
|
42
|
+
align = "start",
|
|
43
|
+
class: className = "",
|
|
44
|
+
...rest
|
|
45
|
+
}: Props = $props();
|
|
46
|
+
|
|
47
|
+
let containerEl: HTMLElement | null = $state(null);
|
|
48
|
+
let triggerEl: HTMLButtonElement | null = $state(null);
|
|
49
|
+
let dropdownEl: HTMLElement | null = $state(null);
|
|
50
|
+
let openSub: string | null = $state(null);
|
|
51
|
+
let focusTargetOnOpen: "first" | "last" = "first";
|
|
52
|
+
|
|
53
|
+
function close(returnFocus = false) {
|
|
54
|
+
open = false;
|
|
55
|
+
openSub = null;
|
|
56
|
+
if (returnFocus) triggerEl?.focus();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toggle() {
|
|
60
|
+
if (open) close();
|
|
61
|
+
else open = true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function escapeCss(str: string): string {
|
|
65
|
+
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
66
|
+
return CSS.escape(str);
|
|
67
|
+
}
|
|
68
|
+
return str.replace(/["\\]/g, "\\$&");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function itemsOf(menu: HTMLElement | null): HTMLElement[] {
|
|
72
|
+
if (!menu) return [];
|
|
73
|
+
return [...menu.querySelectorAll<HTMLElement>('[role="menuitem"]')].filter(
|
|
74
|
+
(el) => el.closest('[role="menu"]') === menu,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getEnabledItems(menu: HTMLElement | null): HTMLElement[] {
|
|
79
|
+
return itemsOf(menu).filter(
|
|
80
|
+
(el) => el.getAttribute("aria-disabled") !== "true",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getNextEnabledItem(
|
|
85
|
+
menu: HTMLElement,
|
|
86
|
+
current: HTMLElement,
|
|
87
|
+
direction: 1 | -1,
|
|
88
|
+
): HTMLElement | null {
|
|
89
|
+
const all = itemsOf(menu);
|
|
90
|
+
const len = all.length;
|
|
91
|
+
if (len === 0) return null;
|
|
92
|
+
let idx = all.indexOf(current);
|
|
93
|
+
if (idx === -1) idx = direction > 0 ? -1 : 0;
|
|
94
|
+
|
|
95
|
+
for (let step = 1; step <= len; step++) {
|
|
96
|
+
const nextIdx = (idx + direction * step + len * 1000) % len;
|
|
97
|
+
const candidate = all[nextIdx];
|
|
98
|
+
if (candidate.getAttribute("aria-disabled") !== "true") {
|
|
99
|
+
return candidate;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let focusSubmenuOnOpen = false;
|
|
106
|
+
let hasFocusedInitial = false;
|
|
107
|
+
let typeaheadBuffer = "";
|
|
108
|
+
let typeaheadTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
109
|
+
|
|
110
|
+
// Focus the item after opening, reset submenu state after closing.
|
|
111
|
+
// Guarded by open && dropdownEl to ensure DOM node is bound before focusing.
|
|
112
|
+
// hasFocusedInitial prevents re-renders while menu is open from stealing focus.
|
|
113
|
+
$effect(() => {
|
|
114
|
+
if (open && dropdownEl) {
|
|
115
|
+
if (!hasFocusedInitial) {
|
|
116
|
+
hasFocusedInitial = true;
|
|
117
|
+
const enabled = getEnabledItems(dropdownEl);
|
|
118
|
+
const all = itemsOf(dropdownEl);
|
|
119
|
+
const target =
|
|
120
|
+
focusTargetOnOpen === "last"
|
|
121
|
+
? (enabled[enabled.length - 1] ?? all[all.length - 1])
|
|
122
|
+
: (enabled[0] ?? all[0]);
|
|
123
|
+
target?.focus();
|
|
124
|
+
focusTargetOnOpen = "first";
|
|
125
|
+
}
|
|
126
|
+
} else if (!open) {
|
|
127
|
+
hasFocusedInitial = false;
|
|
128
|
+
openSub = null;
|
|
129
|
+
focusTargetOnOpen = "first";
|
|
130
|
+
focusSubmenuOnOpen = false;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Focus the first item of submenu when opened via keyboard
|
|
135
|
+
$effect(() => {
|
|
136
|
+
if (openSub && focusSubmenuOnOpen && dropdownEl) {
|
|
137
|
+
const panel = dropdownEl.querySelector<HTMLElement>(
|
|
138
|
+
`[data-submenu-panel="${escapeCss(openSub)}"]`,
|
|
139
|
+
);
|
|
140
|
+
if (panel) {
|
|
141
|
+
const enabled = getEnabledItems(panel);
|
|
142
|
+
const first = enabled[0] ?? itemsOf(panel)[0];
|
|
143
|
+
first?.focus();
|
|
144
|
+
focusSubmenuOnOpen = false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
function activate(item: MenuItem) {
|
|
150
|
+
if (item.disabled) return;
|
|
151
|
+
try {
|
|
152
|
+
item.onclick?.();
|
|
153
|
+
} finally {
|
|
154
|
+
close(true);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function openSubmenu(id: string, focusFirst: boolean) {
|
|
159
|
+
focusSubmenuOnOpen = focusFirst;
|
|
160
|
+
openSub = id;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function closeSubmenuFromPanel(panel: HTMLElement) {
|
|
164
|
+
const id = panel.dataset.submenuPanel;
|
|
165
|
+
openSub = null;
|
|
166
|
+
if (id) {
|
|
167
|
+
dropdownEl
|
|
168
|
+
?.querySelector<HTMLElement>(`[data-submenu="${escapeCss(id)}"]`)
|
|
169
|
+
?.focus();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function toggleSubmenu(item: MenuItem, event: MouseEvent) {
|
|
174
|
+
if (item.disabled) return;
|
|
175
|
+
// detail === 0 means the click came from the keyboard (Enter/Space)
|
|
176
|
+
const fromKeyboard = event.detail === 0;
|
|
177
|
+
if (openSub === item.id && fromKeyboard) {
|
|
178
|
+
openSub = null;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
openSubmenu(item.id, fromKeyboard);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function hoverOpen(event: PointerEvent, item: MenuItem) {
|
|
185
|
+
if (event.pointerType === "mouse" && !item.disabled) openSub = item.id;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function hoverLeave(event: PointerEvent) {
|
|
189
|
+
if (event.pointerType === "mouse") {
|
|
190
|
+
// Do not dismiss submenu on mouse leave if keyboard focus is currently inside the submenu panel
|
|
191
|
+
if (openSub && dropdownEl && typeof document !== "undefined") {
|
|
192
|
+
const panel = dropdownEl.querySelector<HTMLElement>(
|
|
193
|
+
`[data-submenu-panel="${escapeCss(openSub)}"]`,
|
|
194
|
+
);
|
|
195
|
+
if (panel && document.activeElement && panel.contains(document.activeElement)) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
openSub = null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function onTriggerKeydown(event: KeyboardEvent) {
|
|
204
|
+
if (!open && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
|
|
205
|
+
event.preventDefault();
|
|
206
|
+
focusTargetOnOpen = event.key === "ArrowUp" ? "last" : "first";
|
|
207
|
+
open = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function onMenuKeydown(event: KeyboardEvent) {
|
|
212
|
+
const target = event.target as HTMLElement;
|
|
213
|
+
const menu = target.closest<HTMLElement>('[role="menu"]');
|
|
214
|
+
if (!menu) return;
|
|
215
|
+
|
|
216
|
+
const inSubmenu = menu !== dropdownEl;
|
|
217
|
+
const openSubKey = align === "end" ? "ArrowLeft" : "ArrowRight";
|
|
218
|
+
const closeSubKey = align === "end" ? "ArrowRight" : "ArrowLeft";
|
|
219
|
+
|
|
220
|
+
if (event.key === "Escape") {
|
|
221
|
+
event.preventDefault();
|
|
222
|
+
event.stopPropagation();
|
|
223
|
+
if (inSubmenu) closeSubmenuFromPanel(menu);
|
|
224
|
+
else close(true);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (inSubmenu && event.key === closeSubKey) {
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
closeSubmenuFromPanel(menu);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const list = itemsOf(menu);
|
|
235
|
+
if (!list.includes(target)) return;
|
|
236
|
+
|
|
237
|
+
switch (event.key) {
|
|
238
|
+
case "ArrowDown": {
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
const next = getNextEnabledItem(menu, target, 1);
|
|
241
|
+
next?.focus();
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
case "ArrowUp": {
|
|
245
|
+
event.preventDefault();
|
|
246
|
+
const prev = getNextEnabledItem(menu, target, -1);
|
|
247
|
+
prev?.focus();
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
case "Home": {
|
|
251
|
+
event.preventDefault();
|
|
252
|
+
const first = getEnabledItems(menu)[0] ?? itemsOf(menu)[0];
|
|
253
|
+
first?.focus();
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case "End": {
|
|
257
|
+
event.preventDefault();
|
|
258
|
+
const enabled = getEnabledItems(menu);
|
|
259
|
+
const last =
|
|
260
|
+
enabled[enabled.length - 1] ??
|
|
261
|
+
itemsOf(menu)[itemsOf(menu).length - 1];
|
|
262
|
+
last?.focus();
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
case "ArrowRight":
|
|
266
|
+
case "ArrowLeft": {
|
|
267
|
+
if (event.key === openSubKey) {
|
|
268
|
+
const id = target.dataset.submenu;
|
|
269
|
+
if (id && target.getAttribute("aria-disabled") !== "true") {
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
openSubmenu(id, true);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
case "Tab":
|
|
277
|
+
// Let the browser move focus on, starting from the trigger
|
|
278
|
+
close(true);
|
|
279
|
+
break;
|
|
280
|
+
default: {
|
|
281
|
+
if (
|
|
282
|
+
event.key.length === 1 &&
|
|
283
|
+
!event.ctrlKey &&
|
|
284
|
+
!event.metaKey &&
|
|
285
|
+
!event.altKey
|
|
286
|
+
) {
|
|
287
|
+
event.preventDefault();
|
|
288
|
+
if (typeaheadTimeout) clearTimeout(typeaheadTimeout);
|
|
289
|
+
typeaheadBuffer += event.key.toLowerCase();
|
|
290
|
+
typeaheadTimeout = setTimeout(() => {
|
|
291
|
+
typeaheadBuffer = "";
|
|
292
|
+
}, 500);
|
|
293
|
+
|
|
294
|
+
const enabled = getEnabledItems(menu);
|
|
295
|
+
if (enabled.length === 0) break;
|
|
296
|
+
const currIdx = enabled.indexOf(target);
|
|
297
|
+
|
|
298
|
+
// Support multi-character matching (e.g. "du" for duplicate)
|
|
299
|
+
// or single-character cycling when the same key is pressed consecutively
|
|
300
|
+
const isSingleKeyCycle =
|
|
301
|
+
typeaheadBuffer.length > 1 &&
|
|
302
|
+
typeaheadBuffer.split("").every((c) => c === typeaheadBuffer[0]);
|
|
303
|
+
const query = isSingleKeyCycle ? typeaheadBuffer[0] : typeaheadBuffer;
|
|
304
|
+
const searchStart = isSingleKeyCycle ? 1 : 0;
|
|
305
|
+
|
|
306
|
+
for (let step = searchStart; step < enabled.length + searchStart; step++) {
|
|
307
|
+
const idx = (currIdx + step) % enabled.length;
|
|
308
|
+
const item = enabled[idx];
|
|
309
|
+
// Match against data-label attribute to avoid icons interfering with query matching
|
|
310
|
+
const text = (item.getAttribute("data-label") ?? item.textContent ?? "")
|
|
311
|
+
.trim()
|
|
312
|
+
.toLowerCase();
|
|
313
|
+
if (text.startsWith(query)) {
|
|
314
|
+
item.focus();
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function handleWindowPointerDown(event: PointerEvent) {
|
|
325
|
+
if (
|
|
326
|
+
open &&
|
|
327
|
+
closeOnOutsideClick &&
|
|
328
|
+
containerEl &&
|
|
329
|
+
!containerEl.contains(event.target as Node)
|
|
330
|
+
) {
|
|
331
|
+
close();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Closes the menu when focus moves outside the component container.
|
|
337
|
+
* Note: relatedTarget is null for clicks on non-focusable areas, which are handled
|
|
338
|
+
* by the window pointerdown listener.
|
|
339
|
+
*/
|
|
340
|
+
function onFocusOut(event: FocusEvent) {
|
|
341
|
+
const next = event.relatedTarget as Node | null;
|
|
342
|
+
if (open && next && containerEl && !containerEl.contains(next)) close();
|
|
343
|
+
}
|
|
344
|
+
</script>
|
|
345
|
+
|
|
346
|
+
<svelte:window onpointerdown={handleWindowPointerDown} />
|
|
347
|
+
|
|
348
|
+
{#snippet leaf(item: MenuItem)}
|
|
349
|
+
{#if item.separator}
|
|
350
|
+
<div class="nui-menu__separator" role="separator"></div>
|
|
351
|
+
{:else}
|
|
352
|
+
<button
|
|
353
|
+
type="button"
|
|
354
|
+
role="menuitem"
|
|
355
|
+
tabindex="-1"
|
|
356
|
+
class="nui-menu__item"
|
|
357
|
+
data-id={item.id}
|
|
358
|
+
data-label={item.label}
|
|
359
|
+
aria-disabled={item.disabled || undefined}
|
|
360
|
+
onclick={() => activate(item)}
|
|
361
|
+
>
|
|
362
|
+
{#if item.icon}<span class="nui-menu__item-icon" aria-hidden="true"
|
|
363
|
+
>{item.icon}</span
|
|
364
|
+
>{/if}
|
|
365
|
+
<span>{item.label}</span>
|
|
366
|
+
</button>
|
|
367
|
+
{/if}
|
|
368
|
+
{/snippet}
|
|
369
|
+
|
|
370
|
+
<div
|
|
371
|
+
{...rest}
|
|
372
|
+
class={["nui-menu", align === "end" && "nui-menu--end", className]}
|
|
373
|
+
bind:this={containerEl}
|
|
374
|
+
onfocusout={onFocusOut}
|
|
375
|
+
>
|
|
376
|
+
<button
|
|
377
|
+
bind:this={triggerEl}
|
|
378
|
+
type="button"
|
|
379
|
+
id={triggerId}
|
|
380
|
+
class="nui-menu__trigger"
|
|
381
|
+
aria-haspopup="menu"
|
|
382
|
+
aria-expanded={open}
|
|
383
|
+
aria-controls={open ? menuId : undefined}
|
|
384
|
+
onclick={toggle}
|
|
385
|
+
onkeydown={onTriggerKeydown}
|
|
386
|
+
>
|
|
387
|
+
{#if trigger}
|
|
388
|
+
{@render trigger()}
|
|
389
|
+
{:else}
|
|
390
|
+
<span>{triggerLabel}</span>
|
|
391
|
+
{/if}
|
|
392
|
+
<span class="nui-menu__icon" aria-hidden="true">{open ? "▴" : "▾"}</span>
|
|
393
|
+
</button>
|
|
394
|
+
|
|
395
|
+
{#if open}
|
|
396
|
+
<div
|
|
397
|
+
bind:this={dropdownEl}
|
|
398
|
+
id={menuId}
|
|
399
|
+
class="nui-menu__dropdown"
|
|
400
|
+
role="menu"
|
|
401
|
+
tabindex="-1"
|
|
402
|
+
aria-labelledby={triggerId}
|
|
403
|
+
onkeydown={onMenuKeydown}
|
|
404
|
+
>
|
|
405
|
+
{#each items as item (item.id)}
|
|
406
|
+
{#if !item.separator && item.children?.length}
|
|
407
|
+
<div
|
|
408
|
+
class="nui-menu__submenu"
|
|
409
|
+
role="none"
|
|
410
|
+
onpointerenter={(e) => hoverOpen(e, item)}
|
|
411
|
+
onpointerleave={hoverLeave}
|
|
412
|
+
>
|
|
413
|
+
<button
|
|
414
|
+
type="button"
|
|
415
|
+
role="menuitem"
|
|
416
|
+
tabindex="-1"
|
|
417
|
+
class="nui-menu__item"
|
|
418
|
+
class:nui-menu__item--open={openSub === item.id}
|
|
419
|
+
data-id={item.id}
|
|
420
|
+
data-label={item.label}
|
|
421
|
+
data-submenu={item.id}
|
|
422
|
+
aria-haspopup="menu"
|
|
423
|
+
aria-expanded={openSub === item.id}
|
|
424
|
+
aria-disabled={item.disabled || undefined}
|
|
425
|
+
onclick={(e) => toggleSubmenu(item, e)}
|
|
426
|
+
>
|
|
427
|
+
{#if item.icon}<span
|
|
428
|
+
class="nui-menu__item-icon"
|
|
429
|
+
aria-hidden="true">{item.icon}</span
|
|
430
|
+
>{/if}
|
|
431
|
+
<span>{item.label}</span>
|
|
432
|
+
<span class="nui-menu__submenu-arrow" aria-hidden="true">›</span>
|
|
433
|
+
</button>
|
|
434
|
+
|
|
435
|
+
{#if openSub === item.id}
|
|
436
|
+
<div
|
|
437
|
+
class="nui-menu__submenu-content"
|
|
438
|
+
role="menu"
|
|
439
|
+
tabindex="-1"
|
|
440
|
+
aria-label={item.label}
|
|
441
|
+
data-submenu-panel={item.id}
|
|
442
|
+
>
|
|
443
|
+
{#each item.children as sub (sub.id)}
|
|
444
|
+
{@render leaf(sub)}
|
|
445
|
+
{/each}
|
|
446
|
+
</div>
|
|
447
|
+
{/if}
|
|
448
|
+
</div>
|
|
449
|
+
{:else}
|
|
450
|
+
{@render leaf(item)}
|
|
451
|
+
{/if}
|
|
452
|
+
{/each}
|
|
453
|
+
</div>
|
|
454
|
+
{/if}
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
<style>
|
|
458
|
+
.nui-menu {
|
|
459
|
+
position: relative;
|
|
460
|
+
display: inline-block;
|
|
461
|
+
font-family: inherit;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
.nui-menu__trigger {
|
|
465
|
+
display: inline-flex;
|
|
466
|
+
align-items: center;
|
|
467
|
+
gap: 0.5rem;
|
|
468
|
+
padding: 0.5rem 0.875rem;
|
|
469
|
+
background: var(--nui-surface-alt, #18181b);
|
|
470
|
+
color: var(--nui-fg, #fafafa);
|
|
471
|
+
border: 1px solid var(--nui-border, #3f3f46);
|
|
472
|
+
border-radius: var(--nui-radius, 0.375rem);
|
|
473
|
+
font: inherit;
|
|
474
|
+
font-size: 0.875rem;
|
|
475
|
+
font-weight: 500;
|
|
476
|
+
cursor: pointer;
|
|
477
|
+
transition:
|
|
478
|
+
border-color 0.15s ease,
|
|
479
|
+
background 0.15s ease;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.nui-menu__trigger:hover {
|
|
483
|
+
border-color: var(--nui-primary, #3b82f6);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
.nui-menu__trigger:focus-visible {
|
|
487
|
+
outline: 2px solid var(--nui-primary, #3b82f6);
|
|
488
|
+
outline-offset: 2px;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.nui-menu__icon {
|
|
492
|
+
font-size: 0.75rem;
|
|
493
|
+
opacity: 0.7;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
.nui-menu__dropdown {
|
|
497
|
+
position: absolute;
|
|
498
|
+
top: calc(100% + 0.25rem);
|
|
499
|
+
left: 0;
|
|
500
|
+
min-width: 12rem;
|
|
501
|
+
background: var(--nui-surface, #18181b);
|
|
502
|
+
border: 1px solid var(--nui-border, #3f3f46);
|
|
503
|
+
border-radius: var(--nui-radius, 0.375rem);
|
|
504
|
+
box-shadow: 0 10px 25px rgb(0 0 0 / 0.35);
|
|
505
|
+
z-index: var(--nui-z-dropdown, 100);
|
|
506
|
+
padding: 0.25rem;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
.nui-menu--end .nui-menu__dropdown {
|
|
510
|
+
left: auto;
|
|
511
|
+
right: 0;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
.nui-menu__dropdown:focus,
|
|
515
|
+
.nui-menu__submenu-content:focus {
|
|
516
|
+
outline: none;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
.nui-menu__item {
|
|
520
|
+
display: flex;
|
|
521
|
+
align-items: center;
|
|
522
|
+
gap: 0.5rem;
|
|
523
|
+
width: 100%;
|
|
524
|
+
padding: 0.45rem 0.75rem;
|
|
525
|
+
background: transparent;
|
|
526
|
+
border: none;
|
|
527
|
+
border-radius: var(--nui-radius, 0.25rem);
|
|
528
|
+
color: var(--nui-fg, #fafafa);
|
|
529
|
+
text-align: left;
|
|
530
|
+
font: inherit;
|
|
531
|
+
font-size: 0.8125rem;
|
|
532
|
+
cursor: pointer;
|
|
533
|
+
transition: background 0.15s ease;
|
|
534
|
+
box-sizing: border-box;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
.nui-menu__item:hover:not([aria-disabled="true"]),
|
|
538
|
+
.nui-menu__item--open {
|
|
539
|
+
background: var(--nui-hover, rgb(255 255 255 / 0.08));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
.nui-menu__item:focus-visible {
|
|
543
|
+
outline: 2px solid var(--nui-primary, #3b82f6);
|
|
544
|
+
outline-offset: -2px;
|
|
545
|
+
background: var(--nui-hover, rgb(255 255 255 / 0.08));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
.nui-menu__item[aria-disabled="true"] {
|
|
549
|
+
opacity: 0.4;
|
|
550
|
+
cursor: not-allowed;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
.nui-menu__item-icon {
|
|
554
|
+
font-size: 0.95rem;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
.nui-menu__separator {
|
|
558
|
+
height: 1px;
|
|
559
|
+
margin: 0.25rem 0;
|
|
560
|
+
background: var(--nui-border, #27272a);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
.nui-menu__submenu {
|
|
564
|
+
position: relative;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.nui-menu__submenu-arrow {
|
|
568
|
+
margin-left: auto;
|
|
569
|
+
font-size: 0.875rem;
|
|
570
|
+
opacity: 0.6;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
.nui-menu__submenu-content {
|
|
574
|
+
position: absolute;
|
|
575
|
+
left: 100%;
|
|
576
|
+
top: -0.25rem;
|
|
577
|
+
min-width: 10rem;
|
|
578
|
+
background: var(--nui-surface, #18181b);
|
|
579
|
+
border: 1px solid var(--nui-border, #3f3f46);
|
|
580
|
+
border-radius: var(--nui-radius, 0.375rem);
|
|
581
|
+
box-shadow: 0 10px 25px rgb(0 0 0 / 0.35);
|
|
582
|
+
padding: 0.25rem;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
.nui-menu--end .nui-menu__submenu-content {
|
|
586
|
+
left: auto;
|
|
587
|
+
right: 100%;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
@media (prefers-reduced-motion: reduce) {
|
|
591
|
+
.nui-menu__trigger,
|
|
592
|
+
.nui-menu__item {
|
|
593
|
+
transition: none;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
</style>
|