@marianmeres/stuic 3.0.1 → 3.0.3
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/components/AlertConfirmPrompt/index.css +4 -4
- package/dist/components/Button/index.css +7 -7
- package/dist/components/DismissibleMessage/DismissibleMessage.svelte +1 -2
- package/dist/components/DismissibleMessage/index.css +10 -10
- package/dist/components/DropdownMenu/DropdownMenu.svelte +317 -73
- package/dist/components/DropdownMenu/DropdownMenu.svelte.d.ts +19 -0
- package/dist/components/DropdownMenu/index.css +64 -0
- package/dist/components/DropdownMenu/index.d.ts +1 -1
- package/dist/components/HoverExpandableWidth/HoverExpandableWidth.svelte +3 -1
- package/dist/components/HoverExpandableWidth/HoverExpandableWidth.svelte.d.ts +1 -0
- package/dist/components/Input/index.css +1 -1
- package/dist/components/Switch/index.css +1 -1
- package/dist/components/ThemePreview/ThemePreview.svelte +17 -17
- package/dist/components/ThemePreview/index.css +11 -12
- package/dist/icons/index.d.ts +1 -0
- package/dist/icons/index.js +1 -0
- package/dist/index.css +1 -1
- package/dist/themes/css/blue-orange.css +253 -0
- package/dist/themes/css/cyan-red.css +253 -0
- package/dist/themes/css/cyan-slate.css +253 -0
- package/dist/themes/css/emerald-amber-forest.css +253 -0
- package/dist/themes/css/emerald-pink.css +253 -0
- package/dist/themes/css/fuchsia-emerald.css +253 -0
- package/dist/themes/css/gray.css +253 -0
- package/dist/themes/css/indigo-amber.css +253 -0
- package/dist/themes/css/lime-fuchsia-neon.css +253 -0
- package/dist/themes/css/neutral.css +253 -0
- package/dist/themes/css/orange-pink-sunset.css +253 -0
- package/dist/themes/css/pink-emerald.css +253 -0
- package/dist/themes/css/pink-teal.css +253 -0
- package/dist/themes/css/purple-yellow.css +253 -0
- package/dist/themes/css/rainbow.css +253 -0
- package/dist/themes/css/red-blue.css +253 -0
- package/dist/themes/css/red-cyan.css +253 -0
- package/dist/themes/css/red-sky.css +253 -0
- package/dist/themes/css/rose-teal.css +253 -0
- package/dist/themes/css/sky-amber.css +253 -0
- package/dist/themes/css/slate-cyan.css +253 -0
- package/dist/themes/css/slate-teal-ocean.css +253 -0
- package/dist/themes/css/stone-orange-earth.css +253 -0
- package/dist/themes/css/teal-rose.css +253 -0
- package/dist/themes/css/violet-lime.css +253 -0
- package/dist/themes/css/violet-rose-dusk.css +253 -0
- package/dist/themes/emerald-amber-forest.d.ts +6 -0
- package/dist/themes/emerald-amber-forest.js +175 -0
- package/dist/themes/lime-fuchsia-neon.d.ts +6 -0
- package/dist/themes/lime-fuchsia-neon.js +175 -0
- package/dist/themes/orange-pink-sunset.d.ts +6 -0
- package/dist/themes/orange-pink-sunset.js +175 -0
- package/dist/themes/pink-teal.d.ts +6 -0
- package/dist/themes/pink-teal.js +175 -0
- package/dist/themes/red-sky.d.ts +6 -0
- package/dist/themes/red-sky.js +175 -0
- package/dist/themes/slate-teal-ocean.d.ts +6 -0
- package/dist/themes/slate-teal-ocean.js +175 -0
- package/dist/themes/stone-orange-earth.d.ts +6 -0
- package/dist/themes/stone-orange-earth.js +175 -0
- package/dist/themes/violet-rose-dusk.d.ts +6 -0
- package/dist/themes/violet-rose-dusk.js +175 -0
- package/dist/utils/design-tokens.d.ts +1 -1
- package/dist/utils/design-tokens.js +15 -1
- package/package.json +2 -2
- package/dist/base.css +0 -17
- package/dist/themes/blue-orange.css +0 -217
- package/dist/themes/cyan-red.css +0 -217
- package/dist/themes/cyan-slate.css +0 -217
- package/dist/themes/emerald-pink.css +0 -217
- package/dist/themes/fuchsia-emerald.css +0 -217
- package/dist/themes/gray.css +0 -217
- package/dist/themes/indigo-amber.css +0 -217
- package/dist/themes/neutral.css +0 -217
- package/dist/themes/pink-emerald.css +0 -217
- package/dist/themes/purple-yellow.css +0 -217
- package/dist/themes/rainbow.css +0 -217
- package/dist/themes/red-blue.css +0 -217
- package/dist/themes/red-cyan.css +0 -217
- package/dist/themes/rose-teal.css +0 -217
- package/dist/themes/sky-amber.css +0 -217
- package/dist/themes/slate-cyan.css +0 -217
- package/dist/themes/teal-rose.css +0 -217
- package/dist/themes/violet-lime.css +0 -217
|
@@ -42,25 +42,25 @@
|
|
|
42
42
|
/* Variant: info (default) */
|
|
43
43
|
.stuic-acp[data-variant="info"],
|
|
44
44
|
.stuic-acp:not([data-variant]) {
|
|
45
|
-
--_icon-bg: color-mix(in srgb, var(--stuic-color-primary) 15%,
|
|
45
|
+
--_icon-bg: color-mix(in srgb, var(--stuic-color-primary) 15%, var(--stuic-color-background));
|
|
46
46
|
--_icon-text: var(--stuic-color-primary);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/* Variant: success */
|
|
50
50
|
.stuic-acp[data-variant="success"] {
|
|
51
|
-
--_icon-bg: color-mix(in srgb, var(--stuic-color-primary) 15%,
|
|
51
|
+
--_icon-bg: color-mix(in srgb, var(--stuic-color-primary) 15%, var(--stuic-color-background));
|
|
52
52
|
--_icon-text: var(--stuic-color-primary);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/* Variant: warn */
|
|
56
56
|
.stuic-acp[data-variant="warn"] {
|
|
57
|
-
--_icon-bg: color-mix(in srgb, var(--stuic-color-primary) 15%,
|
|
57
|
+
--_icon-bg: color-mix(in srgb, var(--stuic-color-primary) 15%, var(--stuic-color-background));
|
|
58
58
|
--_icon-text: var(--stuic-color-primary);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/* Variant: error */
|
|
62
62
|
.stuic-acp[data-variant="error"] {
|
|
63
|
-
--_icon-bg: color-mix(in srgb, var(--stuic-color-destructive) 15%,
|
|
63
|
+
--_icon-bg: color-mix(in srgb, var(--stuic-color-destructive) 15%, var(--stuic-color-background));
|
|
64
64
|
--_icon-text: var(--stuic-color-destructive);
|
|
65
65
|
}
|
|
66
66
|
}
|
|
@@ -234,8 +234,8 @@
|
|
|
234
234
|
/* Outline: transparent background with colored border and text */
|
|
235
235
|
.stuic-button[data-variant="outline"] {
|
|
236
236
|
--_bg: transparent;
|
|
237
|
-
--_bg-hover: color-mix(in srgb, var(--_color) 10%,
|
|
238
|
-
--_bg-active: color-mix(in srgb, var(--_color) 20%,
|
|
237
|
+
--_bg-hover: color-mix(in srgb, var(--_color) 10%, var(--stuic-color-background));
|
|
238
|
+
--_bg-active: color-mix(in srgb, var(--_color) 20%, var(--stuic-color-background));
|
|
239
239
|
--_text: var(--_color);
|
|
240
240
|
--_text-hover: var(--_color-hover);
|
|
241
241
|
--_text-active: var(--_color-active);
|
|
@@ -247,8 +247,8 @@
|
|
|
247
247
|
/* Ghost: transparent background, colored text, subtle hover state */
|
|
248
248
|
.stuic-button[data-variant="ghost"] {
|
|
249
249
|
--_bg: transparent;
|
|
250
|
-
--_bg-hover: color-mix(in srgb, var(--_color) 10%,
|
|
251
|
-
--_bg-active: color-mix(in srgb, var(--_color) 20%,
|
|
250
|
+
--_bg-hover: color-mix(in srgb, var(--_color) 10%, var(--stuic-color-background));
|
|
251
|
+
--_bg-active: color-mix(in srgb, var(--_color) 20%, var(--stuic-color-background));
|
|
252
252
|
--_text: var(--_color);
|
|
253
253
|
--_text-hover: var(--_color-hover);
|
|
254
254
|
--_text-active: var(--_color-active);
|
|
@@ -259,9 +259,9 @@
|
|
|
259
259
|
|
|
260
260
|
/* Soft: muted/translucent background tint with colored text */
|
|
261
261
|
.stuic-button[data-variant="soft"] {
|
|
262
|
-
--_bg: color-mix(in srgb, var(--_color) 15%,
|
|
263
|
-
--_bg-hover: color-mix(in srgb, var(--_color) 25%,
|
|
264
|
-
--_bg-active: color-mix(in srgb, var(--_color) 35%,
|
|
262
|
+
--_bg: color-mix(in srgb, var(--_color) 15%, var(--stuic-color-background));
|
|
263
|
+
--_bg-hover: color-mix(in srgb, var(--_color) 25%, var(--stuic-color-background));
|
|
264
|
+
--_bg-active: color-mix(in srgb, var(--_color) 35%, var(--stuic-color-background));
|
|
265
265
|
--_text: var(--_color);
|
|
266
266
|
--_text-hover: var(--_color-hover);
|
|
267
267
|
--_text-active: var(--_color-active);
|
|
@@ -29,7 +29,6 @@
|
|
|
29
29
|
iconAlertError,
|
|
30
30
|
} from "../../icons/index.js";
|
|
31
31
|
|
|
32
|
-
|
|
33
32
|
const INTENT_ICONS: Record<MessageIntent, () => string> = {
|
|
34
33
|
destructive: () => iconAlertError({ size: 29 }),
|
|
35
34
|
warning: () => iconAlertWarning({ size: 29 }),
|
|
@@ -81,7 +80,7 @@
|
|
|
81
80
|
<div class="dismiss">
|
|
82
81
|
<Button
|
|
83
82
|
x
|
|
84
|
-
class="text-inherit
|
|
83
|
+
class="text-inherit"
|
|
85
84
|
variant="ghost"
|
|
86
85
|
roundedFull
|
|
87
86
|
size="sm"
|
|
@@ -86,34 +86,34 @@
|
|
|
86
86
|
|
|
87
87
|
/* Destructive: error messages */
|
|
88
88
|
.stuic-dismissible-message[data-intent="destructive"] {
|
|
89
|
-
--_bg: color-mix(in srgb, var(--stuic-color-destructive) 15%,
|
|
89
|
+
--_bg: color-mix(in srgb, var(--stuic-color-destructive) 15%, var(--stuic-color-background));
|
|
90
90
|
/* --_text: var(--stuic-color-destructive); */
|
|
91
91
|
--_text: color-mix(in srgb, var(--stuic-color-destructive), black 15%);
|
|
92
|
-
--_border: color-mix(in srgb, var(--stuic-color-destructive) 30%,
|
|
92
|
+
--_border: color-mix(in srgb, var(--stuic-color-destructive) 30%, var(--stuic-color-background));
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/* Warning: caution messages */
|
|
96
96
|
.stuic-dismissible-message[data-intent="warning"] {
|
|
97
|
-
--_bg: color-mix(in srgb, var(--stuic-color-warning) 15%,
|
|
97
|
+
--_bg: color-mix(in srgb, var(--stuic-color-warning) 15%, var(--stuic-color-background));
|
|
98
98
|
/* --_text: var(--stuic-color-warning); */
|
|
99
99
|
--_text: color-mix(in srgb, var(--stuic-color-warning), black 15%);
|
|
100
|
-
--_border: color-mix(in srgb, var(--stuic-color-warning) 30%,
|
|
100
|
+
--_border: color-mix(in srgb, var(--stuic-color-warning) 30%, var(--stuic-color-background));
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/* Success: confirmation messages */
|
|
104
104
|
.stuic-dismissible-message[data-intent="success"] {
|
|
105
|
-
--_bg: color-mix(in srgb, var(--stuic-color-success) 15%,
|
|
105
|
+
--_bg: color-mix(in srgb, var(--stuic-color-success) 15%, var(--stuic-color-background));
|
|
106
106
|
/* --_text: var(--stuic-color-success); */
|
|
107
107
|
--_text: color-mix(in srgb, var(--stuic-color-success), black 15%);
|
|
108
|
-
--_border: color-mix(in srgb, var(--stuic-color-success) 30%,
|
|
108
|
+
--_border: color-mix(in srgb, var(--stuic-color-success) 30%, var(--stuic-color-background));
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
/* Info: informational messages */
|
|
112
112
|
.stuic-dismissible-message[data-intent="info"] {
|
|
113
|
-
--_bg: color-mix(in srgb, var(--stuic-color-info) 15%,
|
|
113
|
+
--_bg: color-mix(in srgb, var(--stuic-color-info) 15%, var(--stuic-color-background));
|
|
114
114
|
/* --_text: var(--stuic-color-info); */
|
|
115
115
|
--_text: color-mix(in srgb, var(--stuic-color-info), black 15%);
|
|
116
|
-
--_border: color-mix(in srgb, var(--stuic-color-info) 30%,
|
|
116
|
+
--_border: color-mix(in srgb, var(--stuic-color-info) 30%, var(--stuic-color-background));
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
/* =============================================================================
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
.dark .stuic-dismissible-message[data-intent="warning"],
|
|
126
126
|
.dark .stuic-dismissible-message[data-intent="success"],
|
|
127
127
|
.dark .stuic-dismissible-message[data-intent="info"] {
|
|
128
|
-
--_bg: color-mix(in srgb, var(--_text) 20%,
|
|
129
|
-
--_border: color-mix(in srgb, var(--_text) 40%,
|
|
128
|
+
--_bg: color-mix(in srgb, var(--_text) 20%, var(--stuic-color-background));
|
|
129
|
+
--_border: color-mix(in srgb, var(--_text) 40%, var(--stuic-color-background));
|
|
130
130
|
}
|
|
131
131
|
}
|
|
@@ -100,6 +100,20 @@
|
|
|
100
100
|
}
|
|
101
101
|
export type NavigableItem = DropdownMenuActionItem | NavigableExpandable;
|
|
102
102
|
|
|
103
|
+
/** Search configuration options */
|
|
104
|
+
export interface DropdownMenuSearchConfig {
|
|
105
|
+
/** Placeholder text for search input */
|
|
106
|
+
placeholder?: string;
|
|
107
|
+
/** Search strategy */
|
|
108
|
+
strategy?: "prefix" | "exact" | "fuzzy";
|
|
109
|
+
/** Custom function to extract searchable text from an item */
|
|
110
|
+
getContent?: (item: DropdownMenuActionItem | DropdownMenuExpandableItem) => string;
|
|
111
|
+
/** Auto-focus search input when menu opens */
|
|
112
|
+
autoFocus?: boolean;
|
|
113
|
+
/** Message shown when no results found */
|
|
114
|
+
noResultsMessage?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
103
117
|
export interface Props extends Omit<HTMLButtonAttributes, "children"> {
|
|
104
118
|
/** Menu items to display */
|
|
105
119
|
items: DropdownMenuItem[];
|
|
@@ -119,6 +133,8 @@
|
|
|
119
133
|
closeOnEscape?: boolean;
|
|
120
134
|
/** Force fallback positioning mode (for testing) */
|
|
121
135
|
forceFallback?: boolean;
|
|
136
|
+
/** Enable search/filter functionality (true for defaults, or config object) */
|
|
137
|
+
search?: boolean | DropdownMenuSearchConfig;
|
|
122
138
|
/** Classes for the wrapper element */
|
|
123
139
|
class?: string;
|
|
124
140
|
/** Classes for the trigger button */
|
|
@@ -145,6 +161,10 @@
|
|
|
145
161
|
classExpandableContent?: string;
|
|
146
162
|
/** Classes for backdrop (fallback mode only) */
|
|
147
163
|
classBackdrop?: string;
|
|
164
|
+
/** Classes for search input container */
|
|
165
|
+
classSearchContainer?: string;
|
|
166
|
+
/** Classes for search input */
|
|
167
|
+
classSearchInput?: string;
|
|
148
168
|
/** Show backdrop in fallback mode (default: true) */
|
|
149
169
|
showBackdrop?: boolean;
|
|
150
170
|
/** Custom trigger snippet - receives isOpen state, toggle function, and ARIA props for full control */
|
|
@@ -223,7 +243,7 @@
|
|
|
223
243
|
import { getId } from "../../utils/get-id.js";
|
|
224
244
|
import { prefersReducedMotion } from "../../utils/prefers-reduced-motion.svelte.js";
|
|
225
245
|
import { ItemCollection } from "@marianmeres/item-collection";
|
|
226
|
-
import { iconChevronDown, iconChevronRight } from "../../icons/index.js";
|
|
246
|
+
import { iconChevronDown, iconChevronRight, iconSearch, iconX } from "../../icons/index.js";
|
|
227
247
|
import { onClickOutside } from "runed";
|
|
228
248
|
import { slide, fade } from "svelte/transition";
|
|
229
249
|
import { untrack } from "svelte";
|
|
@@ -242,6 +262,7 @@
|
|
|
242
262
|
closeOnClickOutside = true,
|
|
243
263
|
closeOnEscape = true,
|
|
244
264
|
forceFallback = false,
|
|
265
|
+
search,
|
|
245
266
|
class: classProp,
|
|
246
267
|
classTrigger,
|
|
247
268
|
classDropdown,
|
|
@@ -255,6 +276,8 @@
|
|
|
255
276
|
classExpandable,
|
|
256
277
|
classExpandableContent,
|
|
257
278
|
classBackdrop,
|
|
279
|
+
classSearchContainer,
|
|
280
|
+
classSearchInput,
|
|
258
281
|
showBackdrop = true,
|
|
259
282
|
trigger,
|
|
260
283
|
children,
|
|
@@ -288,6 +311,91 @@
|
|
|
288
311
|
// Track expanded sections (independent toggle - multiple can be open)
|
|
289
312
|
let expandedSections = $state<Set<string | number>>(new Set());
|
|
290
313
|
|
|
314
|
+
// Search state
|
|
315
|
+
let searchQuery = $state("");
|
|
316
|
+
let searchInputEl: HTMLInputElement | undefined = $state();
|
|
317
|
+
|
|
318
|
+
// Default content extractor for search
|
|
319
|
+
function defaultGetContent(
|
|
320
|
+
item: DropdownMenuActionItem | DropdownMenuExpandableItem
|
|
321
|
+
): string {
|
|
322
|
+
if (typeof item.label === "string") return item.label;
|
|
323
|
+
if (item.label && typeof item.label === "object" && "text" in item.label) {
|
|
324
|
+
return (item.label as { text?: string }).text || "";
|
|
325
|
+
}
|
|
326
|
+
return String(item.id);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Normalize search config
|
|
330
|
+
let searchConfig = $derived.by(() => {
|
|
331
|
+
if (!search) return null;
|
|
332
|
+
const defaults = {
|
|
333
|
+
placeholder: "Search...",
|
|
334
|
+
strategy: "prefix" as const,
|
|
335
|
+
getContent: defaultGetContent,
|
|
336
|
+
autoFocus: true,
|
|
337
|
+
noResultsMessage: "No results found",
|
|
338
|
+
};
|
|
339
|
+
return search === true ? defaults : { ...defaults, ...search };
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Extract all searchable items (action + expandable + nested actions)
|
|
343
|
+
let allSearchableItems = $derived.by(() => {
|
|
344
|
+
const result: (DropdownMenuActionItem | DropdownMenuExpandableItem)[] = [];
|
|
345
|
+
for (const item of items) {
|
|
346
|
+
if (item.type === "action") result.push(item);
|
|
347
|
+
if (item.type === "expandable") {
|
|
348
|
+
result.push(item);
|
|
349
|
+
for (const child of item.items) {
|
|
350
|
+
if (child.type === "action") result.push(child);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return result;
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Searchable collection (recreate when items or config changes)
|
|
358
|
+
let searchableCollection = $derived.by(() => {
|
|
359
|
+
if (!searchConfig) return null;
|
|
360
|
+
return new ItemCollection(allSearchableItems, {
|
|
361
|
+
idPropName: "id",
|
|
362
|
+
searchable: { getContent: searchConfig.getContent },
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// Filtered items based on search
|
|
367
|
+
let filteredItems = $derived.by(() => {
|
|
368
|
+
if (!searchConfig || !searchQuery.trim() || !searchableCollection) {
|
|
369
|
+
return items;
|
|
370
|
+
}
|
|
371
|
+
const results = searchableCollection.search(searchQuery, searchConfig.strategy);
|
|
372
|
+
const matchedIds = new Set(results.map((r) => r.id));
|
|
373
|
+
|
|
374
|
+
return items.filter((item) => {
|
|
375
|
+
if (item.type === "divider" || item.type === "header" || item.type === "custom") {
|
|
376
|
+
return false; // Hide during search
|
|
377
|
+
}
|
|
378
|
+
if (item.type === "action") return matchedIds.has(item.id);
|
|
379
|
+
if (item.type === "expandable") {
|
|
380
|
+
return (
|
|
381
|
+
matchedIds.has(item.id) ||
|
|
382
|
+
item.items.some((c) => c.type === "action" && matchedIds.has(c.id))
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
return false;
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Matched IDs for use in template filtering during search
|
|
390
|
+
let searchMatchedIds = $derived.by(() => {
|
|
391
|
+
if (!searchConfig || !searchQuery.trim() || !searchableCollection) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
return new Set(
|
|
395
|
+
searchableCollection.search(searchQuery, searchConfig.strategy).map((r) => r.id)
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
291
399
|
// Initialize expanded sections from defaultExpanded
|
|
292
400
|
$effect(() => {
|
|
293
401
|
const initial = new Set<string | number>();
|
|
@@ -306,10 +414,20 @@
|
|
|
306
414
|
});
|
|
307
415
|
let _navItems = $derived($navItems);
|
|
308
416
|
|
|
309
|
-
// Flatten navigable items (respects expanded state)
|
|
417
|
+
// Flatten navigable items (respects expanded state and search filter)
|
|
310
418
|
let navigableItems = $derived.by(() => {
|
|
419
|
+
const sourceItems = searchConfig && searchQuery.trim() ? filteredItems : items;
|
|
311
420
|
const flat: NavigableItem[] = [];
|
|
312
|
-
|
|
421
|
+
|
|
422
|
+
// When searching, get matched IDs to filter expandable children
|
|
423
|
+
const matchedIds =
|
|
424
|
+
searchConfig && searchQuery.trim() && searchableCollection
|
|
425
|
+
? new Set(
|
|
426
|
+
searchableCollection.search(searchQuery, searchConfig.strategy).map((r) => r.id)
|
|
427
|
+
)
|
|
428
|
+
: null;
|
|
429
|
+
|
|
430
|
+
for (const item of sourceItems) {
|
|
313
431
|
if (item.type === "action" && !item.disabled) {
|
|
314
432
|
flat.push(item);
|
|
315
433
|
} else if (item.type === "expandable" && !item.disabled) {
|
|
@@ -319,7 +437,14 @@
|
|
|
319
437
|
if (expandedSections.has(item.id)) {
|
|
320
438
|
for (const child of item.items) {
|
|
321
439
|
if (child.type === "action" && !child.disabled) {
|
|
322
|
-
|
|
440
|
+
// During search, only include matched children
|
|
441
|
+
if (matchedIds) {
|
|
442
|
+
if (matchedIds.has(child.id)) {
|
|
443
|
+
flat.push(child);
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
flat.push(child);
|
|
447
|
+
}
|
|
323
448
|
}
|
|
324
449
|
}
|
|
325
450
|
}
|
|
@@ -349,6 +474,45 @@
|
|
|
349
474
|
}
|
|
350
475
|
});
|
|
351
476
|
|
|
477
|
+
// Auto-focus search input when menu opens
|
|
478
|
+
$effect(() => {
|
|
479
|
+
if (isOpen && searchConfig?.autoFocus && searchInputEl) {
|
|
480
|
+
requestAnimationFrame(() => searchInputEl?.focus());
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// Reset search when menu closes
|
|
485
|
+
$effect(() => {
|
|
486
|
+
if (!isOpen) {
|
|
487
|
+
searchQuery = "";
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Auto-expand sections with matching children during search
|
|
492
|
+
$effect(() => {
|
|
493
|
+
if (!searchConfig || !searchQuery.trim() || !searchableCollection) return;
|
|
494
|
+
const matchedIds = new Set(
|
|
495
|
+
searchableCollection.search(searchQuery, searchConfig.strategy).map((r) => r.id)
|
|
496
|
+
);
|
|
497
|
+
// Use untrack to avoid infinite loop (read without creating dependency)
|
|
498
|
+
const currentExpanded = untrack(() => expandedSections);
|
|
499
|
+
const newExpanded = new Set(currentExpanded);
|
|
500
|
+
let hasChanges = false;
|
|
501
|
+
for (const item of items) {
|
|
502
|
+
if (item.type === "expandable") {
|
|
503
|
+
if (item.items.some((c) => c.type === "action" && matchedIds.has(c.id))) {
|
|
504
|
+
if (!newExpanded.has(item.id)) {
|
|
505
|
+
newExpanded.add(item.id);
|
|
506
|
+
hasChanges = true;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (hasChanges) {
|
|
512
|
+
expandedSections = newExpanded;
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
352
516
|
// Reset runtime fallback when menu closes
|
|
353
517
|
$effect(() => {
|
|
354
518
|
if (!isOpen) {
|
|
@@ -474,28 +638,57 @@
|
|
|
474
638
|
}
|
|
475
639
|
}
|
|
476
640
|
|
|
641
|
+
// Handle keyboard events in search input
|
|
642
|
+
function handleSearchKeydown(e: KeyboardEvent) {
|
|
643
|
+
if (e.key === "Escape" && searchQuery) {
|
|
644
|
+
e.preventDefault();
|
|
645
|
+
e.stopPropagation();
|
|
646
|
+
searchQuery = "";
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (e.key === "ArrowDown") {
|
|
650
|
+
e.preventDefault();
|
|
651
|
+
e.stopPropagation();
|
|
652
|
+
navItems.setActiveFirst();
|
|
653
|
+
}
|
|
654
|
+
if (e.key === "Enter" && filteredItems.length > 0) {
|
|
655
|
+
e.preventDefault();
|
|
656
|
+
const first = filteredItems.find(
|
|
657
|
+
(i): i is DropdownMenuActionItem => i.type === "action" && !i.disabled
|
|
658
|
+
);
|
|
659
|
+
if (first) selectItem(first);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
477
663
|
// Computed transition duration
|
|
478
664
|
let transitionDuration = $derived(reducedMotion.current ? 0 : 100);
|
|
479
665
|
|
|
480
666
|
// Position styles for CSS Anchor Positioning
|
|
481
667
|
let dropdownStyle = $derived.by(() => {
|
|
482
668
|
if (isSupported) {
|
|
669
|
+
// Use fixed height when search is enabled AND position is a "top" variant
|
|
670
|
+
// to prevent jarring resize during filtering (dropdown grows upward)
|
|
671
|
+
const isTopPosition = position.startsWith("top");
|
|
672
|
+
const heightStyle =
|
|
673
|
+
searchConfig && isTopPosition ? `height: ${maxHeight};` : `max-height: ${maxHeight};`;
|
|
483
674
|
return `
|
|
484
675
|
position: fixed;
|
|
485
676
|
position-anchor: ${anchorName};
|
|
486
677
|
position-area: ${POSITION_MAP[position] || "bottom"};
|
|
487
678
|
margin: ${offset};
|
|
488
|
-
|
|
679
|
+
${heightStyle}
|
|
489
680
|
`;
|
|
490
681
|
} else {
|
|
491
682
|
// Fallback: centered modal overlay
|
|
683
|
+
// Use fixed height when search is enabled to prevent jarring resize during filtering
|
|
684
|
+
const heightStyle = searchConfig ? `height: ${maxHeight};` : `max-height: ${maxHeight};`;
|
|
492
685
|
return `
|
|
493
686
|
position: fixed;
|
|
494
687
|
top: 50%;
|
|
495
688
|
left: 50%;
|
|
496
689
|
transform: translate(-50%, -50%);
|
|
497
690
|
max-width: 90vw;
|
|
498
|
-
|
|
691
|
+
${heightStyle}
|
|
499
692
|
z-index: 50;
|
|
500
693
|
`;
|
|
501
694
|
}
|
|
@@ -509,7 +702,13 @@
|
|
|
509
702
|
if (["ArrowDown", "ArrowUp"].includes(e.key)) {
|
|
510
703
|
e.preventDefault();
|
|
511
704
|
if (e.key === "ArrowUp") {
|
|
512
|
-
|
|
705
|
+
// Check if at first item and search is active - return to search input
|
|
706
|
+
if (searchConfig && _navItems.active?.id === navigableItems[0]?.id) {
|
|
707
|
+
navItems.unsetActive();
|
|
708
|
+
searchInputEl?.focus();
|
|
709
|
+
} else {
|
|
710
|
+
e.metaKey ? navItems.setActiveFirst() : navItems.setActivePrevious();
|
|
711
|
+
}
|
|
513
712
|
} else {
|
|
514
713
|
e.metaKey ? navItems.setActiveLast() : navItems.setActiveNext();
|
|
515
714
|
}
|
|
@@ -520,6 +719,8 @@
|
|
|
520
719
|
e.preventDefault();
|
|
521
720
|
navItems.setActiveLast();
|
|
522
721
|
} else if (["Enter", " "].includes(e.key)) {
|
|
722
|
+
// Don't intercept space when typing in search input
|
|
723
|
+
if (e.key === " " && e.target === searchInputEl) return;
|
|
523
724
|
e.preventDefault();
|
|
524
725
|
const active = _navItems.active;
|
|
525
726
|
if (active) {
|
|
@@ -652,29 +853,65 @@
|
|
|
652
853
|
</button>
|
|
653
854
|
</div>
|
|
654
855
|
{/if}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
<
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
<
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
856
|
+
<!-- Search input -->
|
|
857
|
+
{#if searchConfig}
|
|
858
|
+
<div class={twMerge("stuic-dropdown-menu-search", classSearchContainer)}>
|
|
859
|
+
<span class="stuic-dropdown-menu-search-icon">
|
|
860
|
+
{@html iconSearch({ size: 16 })}
|
|
861
|
+
</span>
|
|
862
|
+
<input
|
|
863
|
+
bind:this={searchInputEl}
|
|
864
|
+
bind:value={searchQuery}
|
|
865
|
+
type="text"
|
|
866
|
+
placeholder={searchConfig.placeholder}
|
|
867
|
+
class={twMerge("stuic-dropdown-menu-search-input", classSearchInput)}
|
|
868
|
+
autocomplete="off"
|
|
869
|
+
onfocus={() => navItems.unsetActive()}
|
|
870
|
+
onkeydown={handleSearchKeydown}
|
|
871
|
+
aria-label="Search menu items"
|
|
872
|
+
/>
|
|
873
|
+
{#if searchQuery}
|
|
874
|
+
<button
|
|
875
|
+
type="button"
|
|
876
|
+
class="stuic-dropdown-menu-search-clear"
|
|
877
|
+
onclick={() => {
|
|
878
|
+
searchQuery = "";
|
|
879
|
+
searchInputEl?.focus();
|
|
880
|
+
}}
|
|
881
|
+
aria-label="Clear search"
|
|
882
|
+
>
|
|
883
|
+
{@html iconX({ size: 14 })}
|
|
884
|
+
</button>
|
|
885
|
+
{/if}
|
|
886
|
+
</div>
|
|
887
|
+
{/if}
|
|
888
|
+
<!-- Menu items -->
|
|
889
|
+
{#if searchConfig && searchQuery && filteredItems.length === 0}
|
|
890
|
+
<div class="stuic-dropdown-menu-no-results">{searchConfig.noResultsMessage}</div>
|
|
891
|
+
{:else}
|
|
892
|
+
{#each filteredItems as item}
|
|
893
|
+
{#if item.type === "action"}
|
|
894
|
+
{@const isActive = _navItems.active?.id === item.id}
|
|
895
|
+
<ListItemButton
|
|
896
|
+
id={itemId(item.id)}
|
|
897
|
+
role="menuitem"
|
|
898
|
+
focused={isActive}
|
|
899
|
+
contentBefore={item.contentBefore}
|
|
900
|
+
contentAfter={item.contentAfter}
|
|
901
|
+
class={twMerge(classItem, item.class)}
|
|
902
|
+
classFocused={classItemActive}
|
|
903
|
+
classContentBefore={classItemBefore}
|
|
904
|
+
classContentAfter={classItemAfter}
|
|
905
|
+
onclick={() => selectItem(item)}
|
|
906
|
+
onmouseenter={() => navItems.setActive(item)}
|
|
907
|
+
disabled={item.disabled}
|
|
908
|
+
tabindex={-1}
|
|
909
|
+
>
|
|
910
|
+
<Thc thc={item.label} />
|
|
911
|
+
</ListItemButton>
|
|
912
|
+
{:else if item.type === "divider"}
|
|
913
|
+
<div
|
|
914
|
+
role="separator"
|
|
678
915
|
class={twMerge(DROPDOWN_MENU_DIVIDER_CLASSES, classDivider, item.class)}
|
|
679
916
|
></div>
|
|
680
917
|
{:else if item.type === "header"}
|
|
@@ -730,55 +967,62 @@
|
|
|
730
967
|
>
|
|
731
968
|
{#each item.items as childItem}
|
|
732
969
|
{#if childItem.type === "action"}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
id
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
970
|
+
<!-- During search, only show matched action items -->
|
|
971
|
+
{#if !searchMatchedIds || searchMatchedIds.has(childItem.id)}
|
|
972
|
+
{@const isChildActive = _navItems.active?.id === childItem.id}
|
|
973
|
+
<ListItemButton
|
|
974
|
+
id={itemId(childItem.id)}
|
|
975
|
+
role="menuitem"
|
|
976
|
+
focused={isChildActive}
|
|
977
|
+
contentBefore={childItem.contentBefore}
|
|
978
|
+
contentAfter={childItem.contentAfter}
|
|
979
|
+
class={twMerge(classItem, childItem.class)}
|
|
980
|
+
classFocused={classItemActive}
|
|
981
|
+
classContentBefore={classItemBefore}
|
|
982
|
+
classContentAfter={classItemAfter}
|
|
983
|
+
onclick={() => selectItem(childItem)}
|
|
984
|
+
onmouseenter={() => navItems.setActive(childItem)}
|
|
985
|
+
disabled={childItem.disabled}
|
|
986
|
+
tabindex={-1}
|
|
987
|
+
>
|
|
988
|
+
<Thc thc={childItem.label} />
|
|
989
|
+
</ListItemButton>
|
|
990
|
+
{/if}
|
|
991
|
+
{:else if !searchMatchedIds}
|
|
992
|
+
<!-- Only show non-action items (divider, header, custom) when NOT searching -->
|
|
993
|
+
{#if childItem.type === "divider"}
|
|
994
|
+
<div
|
|
995
|
+
role="separator"
|
|
996
|
+
class={twMerge(
|
|
997
|
+
DROPDOWN_MENU_DIVIDER_CLASSES,
|
|
998
|
+
classDivider,
|
|
999
|
+
childItem.class
|
|
1000
|
+
)}
|
|
1001
|
+
></div>
|
|
1002
|
+
{:else if childItem.type === "header"}
|
|
1003
|
+
<div
|
|
1004
|
+
role="presentation"
|
|
1005
|
+
class={twMerge(
|
|
1006
|
+
DROPDOWN_MENU_HEADER_CLASSES,
|
|
1007
|
+
classHeader,
|
|
1008
|
+
childItem.class
|
|
1009
|
+
)}
|
|
1010
|
+
>
|
|
1011
|
+
<Thc thc={childItem.label} />
|
|
1012
|
+
</div>
|
|
1013
|
+
{:else if childItem.type === "custom"}
|
|
1014
|
+
<div role="presentation" class={childItem.class}>
|
|
1015
|
+
<Thc thc={childItem.content} />
|
|
1016
|
+
</div>
|
|
1017
|
+
{/if}
|
|
775
1018
|
{/if}
|
|
776
1019
|
{/each}
|
|
777
1020
|
</div>
|
|
778
1021
|
{/if}
|
|
779
1022
|
</div>
|
|
780
1023
|
{/if}
|
|
781
|
-
|
|
1024
|
+
{/each}
|
|
1025
|
+
{/if}
|
|
782
1026
|
</div>
|
|
783
1027
|
{/if}
|
|
784
1028
|
</div>
|