@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.
Files changed (81) hide show
  1. package/dist/components/AlertConfirmPrompt/index.css +4 -4
  2. package/dist/components/Button/index.css +7 -7
  3. package/dist/components/DismissibleMessage/DismissibleMessage.svelte +1 -2
  4. package/dist/components/DismissibleMessage/index.css +10 -10
  5. package/dist/components/DropdownMenu/DropdownMenu.svelte +317 -73
  6. package/dist/components/DropdownMenu/DropdownMenu.svelte.d.ts +19 -0
  7. package/dist/components/DropdownMenu/index.css +64 -0
  8. package/dist/components/DropdownMenu/index.d.ts +1 -1
  9. package/dist/components/HoverExpandableWidth/HoverExpandableWidth.svelte +3 -1
  10. package/dist/components/HoverExpandableWidth/HoverExpandableWidth.svelte.d.ts +1 -0
  11. package/dist/components/Input/index.css +1 -1
  12. package/dist/components/Switch/index.css +1 -1
  13. package/dist/components/ThemePreview/ThemePreview.svelte +17 -17
  14. package/dist/components/ThemePreview/index.css +11 -12
  15. package/dist/icons/index.d.ts +1 -0
  16. package/dist/icons/index.js +1 -0
  17. package/dist/index.css +1 -1
  18. package/dist/themes/css/blue-orange.css +253 -0
  19. package/dist/themes/css/cyan-red.css +253 -0
  20. package/dist/themes/css/cyan-slate.css +253 -0
  21. package/dist/themes/css/emerald-amber-forest.css +253 -0
  22. package/dist/themes/css/emerald-pink.css +253 -0
  23. package/dist/themes/css/fuchsia-emerald.css +253 -0
  24. package/dist/themes/css/gray.css +253 -0
  25. package/dist/themes/css/indigo-amber.css +253 -0
  26. package/dist/themes/css/lime-fuchsia-neon.css +253 -0
  27. package/dist/themes/css/neutral.css +253 -0
  28. package/dist/themes/css/orange-pink-sunset.css +253 -0
  29. package/dist/themes/css/pink-emerald.css +253 -0
  30. package/dist/themes/css/pink-teal.css +253 -0
  31. package/dist/themes/css/purple-yellow.css +253 -0
  32. package/dist/themes/css/rainbow.css +253 -0
  33. package/dist/themes/css/red-blue.css +253 -0
  34. package/dist/themes/css/red-cyan.css +253 -0
  35. package/dist/themes/css/red-sky.css +253 -0
  36. package/dist/themes/css/rose-teal.css +253 -0
  37. package/dist/themes/css/sky-amber.css +253 -0
  38. package/dist/themes/css/slate-cyan.css +253 -0
  39. package/dist/themes/css/slate-teal-ocean.css +253 -0
  40. package/dist/themes/css/stone-orange-earth.css +253 -0
  41. package/dist/themes/css/teal-rose.css +253 -0
  42. package/dist/themes/css/violet-lime.css +253 -0
  43. package/dist/themes/css/violet-rose-dusk.css +253 -0
  44. package/dist/themes/emerald-amber-forest.d.ts +6 -0
  45. package/dist/themes/emerald-amber-forest.js +175 -0
  46. package/dist/themes/lime-fuchsia-neon.d.ts +6 -0
  47. package/dist/themes/lime-fuchsia-neon.js +175 -0
  48. package/dist/themes/orange-pink-sunset.d.ts +6 -0
  49. package/dist/themes/orange-pink-sunset.js +175 -0
  50. package/dist/themes/pink-teal.d.ts +6 -0
  51. package/dist/themes/pink-teal.js +175 -0
  52. package/dist/themes/red-sky.d.ts +6 -0
  53. package/dist/themes/red-sky.js +175 -0
  54. package/dist/themes/slate-teal-ocean.d.ts +6 -0
  55. package/dist/themes/slate-teal-ocean.js +175 -0
  56. package/dist/themes/stone-orange-earth.d.ts +6 -0
  57. package/dist/themes/stone-orange-earth.js +175 -0
  58. package/dist/themes/violet-rose-dusk.d.ts +6 -0
  59. package/dist/themes/violet-rose-dusk.js +175 -0
  60. package/dist/utils/design-tokens.d.ts +1 -1
  61. package/dist/utils/design-tokens.js +15 -1
  62. package/package.json +2 -2
  63. package/dist/base.css +0 -17
  64. package/dist/themes/blue-orange.css +0 -217
  65. package/dist/themes/cyan-red.css +0 -217
  66. package/dist/themes/cyan-slate.css +0 -217
  67. package/dist/themes/emerald-pink.css +0 -217
  68. package/dist/themes/fuchsia-emerald.css +0 -217
  69. package/dist/themes/gray.css +0 -217
  70. package/dist/themes/indigo-amber.css +0 -217
  71. package/dist/themes/neutral.css +0 -217
  72. package/dist/themes/pink-emerald.css +0 -217
  73. package/dist/themes/purple-yellow.css +0 -217
  74. package/dist/themes/rainbow.css +0 -217
  75. package/dist/themes/red-blue.css +0 -217
  76. package/dist/themes/red-cyan.css +0 -217
  77. package/dist/themes/rose-teal.css +0 -217
  78. package/dist/themes/sky-amber.css +0 -217
  79. package/dist/themes/slate-cyan.css +0 -217
  80. package/dist/themes/teal-rose.css +0 -217
  81. 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%, transparent);
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%, transparent);
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%, transparent);
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%, transparent);
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%, transparent);
238
- --_bg-active: color-mix(in srgb, var(--_color) 20%, transparent);
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%, transparent);
251
- --_bg-active: color-mix(in srgb, var(--_color) 20%, transparent);
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%, transparent);
263
- --_bg-hover: color-mix(in srgb, var(--_color) 25%, transparent);
264
- --_bg-active: color-mix(in srgb, var(--_color) 35%, transparent);
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%, transparent);
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%, transparent);
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%, transparent);
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%, transparent);
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%, transparent);
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%, transparent);
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%, transparent);
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%, transparent);
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%, transparent);
129
- --_border: color-mix(in srgb, var(--_text) 40%, transparent);
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
- for (const item of items) {
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
- flat.push(child);
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
- max-height: ${maxHeight};
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
- max-height: ${maxHeight};
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
- e.metaKey ? navItems.setActiveFirst() : navItems.setActivePrevious();
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
- {#each items as item}
656
- {#if item.type === "action"}
657
- {@const isActive = _navItems.active?.id === item.id}
658
- <ListItemButton
659
- id={itemId(item.id)}
660
- role="menuitem"
661
- focused={isActive}
662
- contentBefore={item.contentBefore}
663
- contentAfter={item.contentAfter}
664
- class={twMerge(classItem, item.class)}
665
- classFocused={classItemActive}
666
- classContentBefore={classItemBefore}
667
- classContentAfter={classItemAfter}
668
- onclick={() => selectItem(item)}
669
- onmouseenter={() => navItems.setActive(item)}
670
- disabled={item.disabled}
671
- tabindex={-1}
672
- >
673
- <Thc thc={item.label} />
674
- </ListItemButton>
675
- {:else if item.type === "divider"}
676
- <div
677
- role="separator"
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
- {@const isChildActive = _navItems.active?.id === childItem.id}
734
- <ListItemButton
735
- id={itemId(childItem.id)}
736
- role="menuitem"
737
- focused={isChildActive}
738
- contentBefore={childItem.contentBefore}
739
- contentAfter={childItem.contentAfter}
740
- class={twMerge(classItem, childItem.class)}
741
- classFocused={classItemActive}
742
- classContentBefore={classItemBefore}
743
- classContentAfter={classItemAfter}
744
- onclick={() => selectItem(childItem)}
745
- onmouseenter={() => navItems.setActive(childItem)}
746
- disabled={childItem.disabled}
747
- tabindex={-1}
748
- >
749
- <Thc thc={childItem.label} />
750
- </ListItemButton>
751
- {:else if childItem.type === "divider"}
752
- <div
753
- role="separator"
754
- class={twMerge(
755
- DROPDOWN_MENU_DIVIDER_CLASSES,
756
- classDivider,
757
- childItem.class
758
- )}
759
- ></div>
760
- {:else if childItem.type === "header"}
761
- <div
762
- role="presentation"
763
- class={twMerge(
764
- DROPDOWN_MENU_HEADER_CLASSES,
765
- classHeader,
766
- childItem.class
767
- )}
768
- >
769
- <Thc thc={childItem.label} />
770
- </div>
771
- {:else if childItem.type === "custom"}
772
- <div role="presentation" class={childItem.class}>
773
- <Thc thc={childItem.content} />
774
- </div>
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
- {/each}
1024
+ {/each}
1025
+ {/if}
782
1026
  </div>
783
1027
  {/if}
784
1028
  </div>