@marianmeres/stuic 3.0.0 → 3.0.2

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 (156) hide show
  1. package/dist/actions/index.d.ts +1 -0
  2. package/dist/actions/index.js +1 -0
  3. package/dist/actions/typeahead.svelte.d.ts +53 -0
  4. package/dist/actions/typeahead.svelte.js +328 -0
  5. package/dist/base.css +17 -0
  6. package/dist/components/AlertConfirmPrompt/AlertConfirmPrompt.svelte +4 -3
  7. package/dist/components/AlertConfirmPrompt/AlertConfirmPrompt.svelte.d.ts +4 -3
  8. package/dist/components/AlertConfirmPrompt/Current.svelte +1 -2
  9. package/dist/components/AlertConfirmPrompt/Current.svelte.d.ts +0 -1
  10. package/dist/components/AlertConfirmPrompt/index.css +47 -43
  11. package/dist/components/AssetsPreview/AssetsPreview.svelte +0 -1
  12. package/dist/components/AssetsPreview/AssetsPreview.svelte.d.ts +0 -1
  13. package/dist/components/AssetsPreview/index.css +31 -29
  14. package/dist/components/Avatar/Avatar.svelte +0 -1
  15. package/dist/components/Avatar/Avatar.svelte.d.ts +0 -1
  16. package/dist/components/Avatar/index.css +87 -85
  17. package/dist/components/Backdrop/Backdrop.svelte +0 -1
  18. package/dist/components/Backdrop/Backdrop.svelte.d.ts +0 -1
  19. package/dist/components/Backdrop/index.css +15 -13
  20. package/dist/components/Button/Button.svelte +0 -1
  21. package/dist/components/Button/Button.svelte.d.ts +0 -1
  22. package/dist/components/Button/index.css +431 -429
  23. package/dist/components/ButtonGroupRadio/ButtonGroupRadio.svelte +0 -1
  24. package/dist/components/ButtonGroupRadio/ButtonGroupRadio.svelte.d.ts +0 -1
  25. package/dist/components/ButtonGroupRadio/index.css +123 -117
  26. package/dist/components/Collapsible/index.css +17 -15
  27. package/dist/components/CommandMenu/CommandMenu.svelte +7 -4
  28. package/dist/components/CommandMenu/CommandMenu.svelte.d.ts +0 -1
  29. package/dist/components/CommandMenu/index.css +27 -25
  30. package/dist/components/DismissibleMessage/DismissibleMessage.svelte +0 -2
  31. package/dist/components/DismissibleMessage/DismissibleMessage.svelte.d.ts +0 -1
  32. package/dist/components/DismissibleMessage/index.css +116 -110
  33. package/dist/components/DropdownMenu/DropdownMenu.svelte +317 -74
  34. package/dist/components/DropdownMenu/DropdownMenu.svelte.d.ts +19 -1
  35. package/dist/components/DropdownMenu/index.css +236 -170
  36. package/dist/components/DropdownMenu/index.d.ts +1 -1
  37. package/dist/components/HoverExpandableWidth/HoverExpandableWidth.svelte +3 -1
  38. package/dist/components/HoverExpandableWidth/HoverExpandableWidth.svelte.d.ts +1 -0
  39. package/dist/components/Input/FieldInput.svelte +8 -0
  40. package/dist/components/Input/FieldInput.svelte.d.ts +2 -0
  41. package/dist/components/Input/FieldOptions.svelte +1 -1
  42. package/dist/components/Input/index.css +411 -398
  43. package/dist/components/KbdShortcut/KbdShortcut.svelte +4 -12
  44. package/dist/components/KbdShortcut/README.md +34 -0
  45. package/dist/components/KbdShortcut/index.css +55 -0
  46. package/dist/components/ListItemButton/ListItemButton.svelte +0 -1
  47. package/dist/components/ListItemButton/ListItemButton.svelte.d.ts +0 -1
  48. package/dist/components/ListItemButton/index.css +118 -116
  49. package/dist/components/Modal/Modal.svelte +0 -1
  50. package/dist/components/Modal/Modal.svelte.d.ts +0 -1
  51. package/dist/components/Modal/index.css +18 -16
  52. package/dist/components/ModalDialog/index.css +29 -27
  53. package/dist/components/Nav/Nav.svelte +732 -0
  54. package/dist/components/Nav/Nav.svelte.d.ts +110 -0
  55. package/dist/components/Nav/README.md +334 -0
  56. package/dist/components/Nav/index.css +318 -0
  57. package/dist/components/Nav/index.d.ts +1 -0
  58. package/dist/components/Nav/index.js +1 -0
  59. package/dist/components/Notifications/Notifications.svelte +2 -3
  60. package/dist/components/Notifications/Notifications.svelte.d.ts +0 -1
  61. package/dist/components/Notifications/index.css +158 -158
  62. package/dist/components/Notifications/notifications-stack.svelte.d.ts +4 -0
  63. package/dist/components/Notifications/notifications-stack.svelte.js +8 -0
  64. package/dist/components/Progress/Progress.svelte +4 -2
  65. package/dist/components/Progress/Progress.svelte.d.ts +1 -0
  66. package/dist/components/Progress/README.md +86 -15
  67. package/dist/components/Progress/_internal/Bar.svelte +4 -15
  68. package/dist/components/Progress/_internal/Bar.svelte.d.ts +1 -1
  69. package/dist/components/Progress/_internal/Circle.svelte +30 -2
  70. package/dist/components/Progress/_internal/Circle.svelte.d.ts +1 -0
  71. package/dist/components/Progress/index.css +47 -1
  72. package/dist/components/Skeleton/README.md +152 -0
  73. package/dist/components/Skeleton/Skeleton.svelte +6 -7
  74. package/dist/components/Skeleton/Skeleton.svelte.d.ts +0 -1
  75. package/dist/components/Skeleton/index.css +73 -43
  76. package/dist/components/Spinner/README.md +149 -37
  77. package/dist/components/Spinner/Spinner.svelte +14 -38
  78. package/dist/components/Spinner/Spinner.svelte.d.ts +2 -1
  79. package/dist/components/Spinner/SpinnerCircle.svelte +6 -34
  80. package/dist/components/Spinner/SpinnerCircle.svelte.d.ts +1 -0
  81. package/dist/components/Spinner/SpinnerCircleOscillate.svelte +10 -5
  82. package/dist/components/Spinner/SpinnerUnicode.svelte +3 -1
  83. package/dist/components/Spinner/SpinnerUnicode.svelte.d.ts +1 -0
  84. package/dist/components/Spinner/index.css +104 -0
  85. package/dist/components/Switch/README.md +34 -18
  86. package/dist/components/Switch/Switch.svelte +24 -46
  87. package/dist/components/Switch/Switch.svelte.d.ts +4 -2
  88. package/dist/components/Switch/index.css +120 -2
  89. package/dist/components/Switch/index.d.ts +1 -2
  90. package/dist/components/Switch/index.js +1 -2
  91. package/dist/components/TabbedMenu/README.md +28 -17
  92. package/dist/components/TabbedMenu/TabbedMenu.svelte +5 -46
  93. package/dist/components/TabbedMenu/TabbedMenu.svelte.d.ts +0 -1
  94. package/dist/components/TabbedMenu/index.css +85 -3
  95. package/dist/components/ThemePreview/ThemePreview.svelte +86 -33
  96. package/dist/components/ThemePreview/ThemePreview.svelte.d.ts +3 -1
  97. package/dist/components/ThemePreview/index.css +24 -8
  98. package/dist/components/TwCheck/README.md +32 -13
  99. package/dist/components/TwCheck/TwCheck.svelte +11 -9
  100. package/dist/components/TwCheck/TwCheck.svelte.d.ts +0 -1
  101. package/dist/components/TwCheck/index.css +14 -0
  102. package/dist/components/TypeaheadInput/TypeaheadInput.svelte +19 -187
  103. package/dist/components/TypeaheadInput/TypeaheadInput.svelte.d.ts +4 -2
  104. package/dist/icons/index.d.ts +1 -0
  105. package/dist/icons/index.js +1 -0
  106. package/dist/index.css +44 -39
  107. package/dist/index.d.ts +1 -0
  108. package/dist/index.js +1 -0
  109. package/dist/themes/blue-orange.css +246 -156
  110. package/dist/themes/blue-orange.js +24 -0
  111. package/dist/themes/cyan-red.css +246 -156
  112. package/dist/themes/cyan-red.js +24 -0
  113. package/dist/themes/cyan-slate.css +246 -156
  114. package/dist/themes/cyan-slate.js +25 -1
  115. package/dist/themes/emerald-pink.css +246 -156
  116. package/dist/themes/emerald-pink.js +25 -1
  117. package/dist/themes/fuchsia-emerald.css +246 -156
  118. package/dist/themes/fuchsia-emerald.js +25 -1
  119. package/dist/themes/gray.css +246 -156
  120. package/dist/themes/gray.js +24 -0
  121. package/dist/themes/indigo-amber.css +246 -156
  122. package/dist/themes/indigo-amber.js +26 -2
  123. package/dist/themes/neutral.css +246 -156
  124. package/dist/themes/neutral.js +24 -0
  125. package/dist/themes/pink-emerald.css +246 -156
  126. package/dist/themes/pink-emerald.js +25 -1
  127. package/dist/themes/pink-teal.css +253 -0
  128. package/dist/themes/pink-teal.d.ts +6 -0
  129. package/dist/themes/pink-teal.js +175 -0
  130. package/dist/themes/purple-yellow.css +246 -156
  131. package/dist/themes/purple-yellow.js +24 -0
  132. package/dist/themes/rainbow.css +246 -156
  133. package/dist/themes/rainbow.js +25 -1
  134. package/dist/themes/red-blue.css +246 -156
  135. package/dist/themes/red-blue.js +24 -0
  136. package/dist/themes/red-cyan.css +246 -156
  137. package/dist/themes/red-cyan.js +24 -0
  138. package/dist/themes/red-sky.css +253 -0
  139. package/dist/themes/red-sky.d.ts +6 -0
  140. package/dist/themes/red-sky.js +175 -0
  141. package/dist/themes/rose-teal.css +246 -156
  142. package/dist/themes/rose-teal.js +24 -0
  143. package/dist/themes/sky-amber.css +246 -156
  144. package/dist/themes/sky-amber.js +26 -2
  145. package/dist/themes/slate-cyan.css +246 -156
  146. package/dist/themes/slate-cyan.js +25 -1
  147. package/dist/themes/teal-rose.css +246 -156
  148. package/dist/themes/teal-rose.js +24 -0
  149. package/dist/themes/violet-lime.css +246 -156
  150. package/dist/themes/violet-lime.js +27 -3
  151. package/dist/utils/design-tokens.d.ts +1 -1
  152. package/dist/utils/design-tokens.js +44 -3
  153. package/dist/utils/storage-abstraction.js +1 -1
  154. package/package.json +11 -28
  155. package/dist/components/Switch/SwitchButton.svelte +0 -134
  156. package/dist/components/Switch/SwitchButton.svelte.d.ts +0 -21
@@ -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,13 +243,12 @@
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";
230
250
  import Thc from "../Thc/Thc.svelte";
231
251
  import ListItemButton from "../ListItemButton/ListItemButton.svelte";
232
- import "./index.css";
233
252
  import { BodyScroll } from "../../utils/body-scroll-locker.js";
234
253
  import { waitForTwoRepaints } from "../../utils/paint.js";
235
254
 
@@ -243,6 +262,7 @@
243
262
  closeOnClickOutside = true,
244
263
  closeOnEscape = true,
245
264
  forceFallback = false,
265
+ search,
246
266
  class: classProp,
247
267
  classTrigger,
248
268
  classDropdown,
@@ -256,6 +276,8 @@
256
276
  classExpandable,
257
277
  classExpandableContent,
258
278
  classBackdrop,
279
+ classSearchContainer,
280
+ classSearchInput,
259
281
  showBackdrop = true,
260
282
  trigger,
261
283
  children,
@@ -289,6 +311,91 @@
289
311
  // Track expanded sections (independent toggle - multiple can be open)
290
312
  let expandedSections = $state<Set<string | number>>(new Set());
291
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
+
292
399
  // Initialize expanded sections from defaultExpanded
293
400
  $effect(() => {
294
401
  const initial = new Set<string | number>();
@@ -307,10 +414,20 @@
307
414
  });
308
415
  let _navItems = $derived($navItems);
309
416
 
310
- // Flatten navigable items (respects expanded state)
417
+ // Flatten navigable items (respects expanded state and search filter)
311
418
  let navigableItems = $derived.by(() => {
419
+ const sourceItems = searchConfig && searchQuery.trim() ? filteredItems : items;
312
420
  const flat: NavigableItem[] = [];
313
- 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) {
314
431
  if (item.type === "action" && !item.disabled) {
315
432
  flat.push(item);
316
433
  } else if (item.type === "expandable" && !item.disabled) {
@@ -320,7 +437,14 @@
320
437
  if (expandedSections.has(item.id)) {
321
438
  for (const child of item.items) {
322
439
  if (child.type === "action" && !child.disabled) {
323
- 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
+ }
324
448
  }
325
449
  }
326
450
  }
@@ -350,6 +474,45 @@
350
474
  }
351
475
  });
352
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
+
353
516
  // Reset runtime fallback when menu closes
354
517
  $effect(() => {
355
518
  if (!isOpen) {
@@ -475,28 +638,57 @@
475
638
  }
476
639
  }
477
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
+
478
663
  // Computed transition duration
479
664
  let transitionDuration = $derived(reducedMotion.current ? 0 : 100);
480
665
 
481
666
  // Position styles for CSS Anchor Positioning
482
667
  let dropdownStyle = $derived.by(() => {
483
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};`;
484
674
  return `
485
675
  position: fixed;
486
676
  position-anchor: ${anchorName};
487
677
  position-area: ${POSITION_MAP[position] || "bottom"};
488
678
  margin: ${offset};
489
- max-height: ${maxHeight};
679
+ ${heightStyle}
490
680
  `;
491
681
  } else {
492
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};`;
493
685
  return `
494
686
  position: fixed;
495
687
  top: 50%;
496
688
  left: 50%;
497
689
  transform: translate(-50%, -50%);
498
690
  max-width: 90vw;
499
- max-height: ${maxHeight};
691
+ ${heightStyle}
500
692
  z-index: 50;
501
693
  `;
502
694
  }
@@ -510,7 +702,13 @@
510
702
  if (["ArrowDown", "ArrowUp"].includes(e.key)) {
511
703
  e.preventDefault();
512
704
  if (e.key === "ArrowUp") {
513
- 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
+ }
514
712
  } else {
515
713
  e.metaKey ? navItems.setActiveLast() : navItems.setActiveNext();
516
714
  }
@@ -521,6 +719,8 @@
521
719
  e.preventDefault();
522
720
  navItems.setActiveLast();
523
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;
524
724
  e.preventDefault();
525
725
  const active = _navItems.active;
526
726
  if (active) {
@@ -653,29 +853,65 @@
653
853
  </button>
654
854
  </div>
655
855
  {/if}
656
- {#each items as item}
657
- {#if item.type === "action"}
658
- {@const isActive = _navItems.active?.id === item.id}
659
- <ListItemButton
660
- id={itemId(item.id)}
661
- role="menuitem"
662
- focused={isActive}
663
- contentBefore={item.contentBefore}
664
- contentAfter={item.contentAfter}
665
- class={twMerge(classItem, item.class)}
666
- classFocused={classItemActive}
667
- classContentBefore={classItemBefore}
668
- classContentAfter={classItemAfter}
669
- onclick={() => selectItem(item)}
670
- onmouseenter={() => navItems.setActive(item)}
671
- disabled={item.disabled}
672
- tabindex={-1}
673
- >
674
- <Thc thc={item.label} />
675
- </ListItemButton>
676
- {:else if item.type === "divider"}
677
- <div
678
- 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"
679
915
  class={twMerge(DROPDOWN_MENU_DIVIDER_CLASSES, classDivider, item.class)}
680
916
  ></div>
681
917
  {:else if item.type === "header"}
@@ -731,55 +967,62 @@
731
967
  >
732
968
  {#each item.items as childItem}
733
969
  {#if childItem.type === "action"}
734
- {@const isChildActive = _navItems.active?.id === childItem.id}
735
- <ListItemButton
736
- id={itemId(childItem.id)}
737
- role="menuitem"
738
- focused={isChildActive}
739
- contentBefore={childItem.contentBefore}
740
- contentAfter={childItem.contentAfter}
741
- class={twMerge(classItem, childItem.class)}
742
- classFocused={classItemActive}
743
- classContentBefore={classItemBefore}
744
- classContentAfter={classItemAfter}
745
- onclick={() => selectItem(childItem)}
746
- onmouseenter={() => navItems.setActive(childItem)}
747
- disabled={childItem.disabled}
748
- tabindex={-1}
749
- >
750
- <Thc thc={childItem.label} />
751
- </ListItemButton>
752
- {:else if childItem.type === "divider"}
753
- <div
754
- role="separator"
755
- class={twMerge(
756
- DROPDOWN_MENU_DIVIDER_CLASSES,
757
- classDivider,
758
- childItem.class
759
- )}
760
- ></div>
761
- {:else if childItem.type === "header"}
762
- <div
763
- role="presentation"
764
- class={twMerge(
765
- DROPDOWN_MENU_HEADER_CLASSES,
766
- classHeader,
767
- childItem.class
768
- )}
769
- >
770
- <Thc thc={childItem.label} />
771
- </div>
772
- {:else if childItem.type === "custom"}
773
- <div role="presentation" class={childItem.class}>
774
- <Thc thc={childItem.content} />
775
- </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}
776
1018
  {/if}
777
1019
  {/each}
778
1020
  </div>
779
1021
  {/if}
780
1022
  </div>
781
1023
  {/if}
782
- {/each}
1024
+ {/each}
1025
+ {/if}
783
1026
  </div>
784
1027
  {/if}
785
1028
  </div>
@@ -72,6 +72,19 @@ export interface NavigableExpandable {
72
72
  expandableItem: DropdownMenuExpandableItem;
73
73
  }
74
74
  export type NavigableItem = DropdownMenuActionItem | NavigableExpandable;
75
+ /** Search configuration options */
76
+ export interface DropdownMenuSearchConfig {
77
+ /** Placeholder text for search input */
78
+ placeholder?: string;
79
+ /** Search strategy */
80
+ strategy?: "prefix" | "exact" | "fuzzy";
81
+ /** Custom function to extract searchable text from an item */
82
+ getContent?: (item: DropdownMenuActionItem | DropdownMenuExpandableItem) => string;
83
+ /** Auto-focus search input when menu opens */
84
+ autoFocus?: boolean;
85
+ /** Message shown when no results found */
86
+ noResultsMessage?: string;
87
+ }
75
88
  export interface Props extends Omit<HTMLButtonAttributes, "children"> {
76
89
  /** Menu items to display */
77
90
  items: DropdownMenuItem[];
@@ -91,6 +104,8 @@ export interface Props extends Omit<HTMLButtonAttributes, "children"> {
91
104
  closeOnEscape?: boolean;
92
105
  /** Force fallback positioning mode (for testing) */
93
106
  forceFallback?: boolean;
107
+ /** Enable search/filter functionality (true for defaults, or config object) */
108
+ search?: boolean | DropdownMenuSearchConfig;
94
109
  /** Classes for the wrapper element */
95
110
  class?: string;
96
111
  /** Classes for the trigger button */
@@ -117,6 +132,10 @@ export interface Props extends Omit<HTMLButtonAttributes, "children"> {
117
132
  classExpandableContent?: string;
118
133
  /** Classes for backdrop (fallback mode only) */
119
134
  classBackdrop?: string;
135
+ /** Classes for search input container */
136
+ classSearchContainer?: string;
137
+ /** Classes for search input */
138
+ classSearchInput?: string;
120
139
  /** Show backdrop in fallback mode (default: true) */
121
140
  showBackdrop?: boolean;
122
141
  /** Custom trigger snippet - receives isOpen state, toggle function, and ARIA props for full control */
@@ -153,7 +172,6 @@ export declare const DROPDOWN_MENU_DROPDOWN_CLASSES = "stuic-dropdown-menu-dropd
153
172
  export declare const DROPDOWN_MENU_DIVIDER_CLASSES = "stuic-dropdown-menu-divider";
154
173
  export declare const DROPDOWN_MENU_HEADER_CLASSES = "stuic-dropdown-menu-header";
155
174
  export declare const DROPDOWN_MENU_BACKDROP_CLASSES = "stuic-dropdown-menu-backdrop";
156
- import "./index.css";
157
175
  declare const DropdownMenu: import("svelte").Component<Props, {}, "isOpen" | "triggerEl" | "dropdownEl">;
158
176
  type DropdownMenu = ReturnType<typeof DropdownMenu>;
159
177
  export default DropdownMenu;