@marianmeres/stuic 2.12.0 → 2.13.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.
@@ -0,0 +1,764 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from "svelte";
3
+ import type { HTMLButtonAttributes } from "svelte/elements";
4
+ import type { THC } from "../Thc/Thc.svelte";
5
+
6
+ /**
7
+ * Valid positions for dropdown placement relative to the trigger element.
8
+ */
9
+ export type DropdownMenuPosition =
10
+ | "top"
11
+ | "top-left"
12
+ | "top-right"
13
+ | "top-span-left"
14
+ | "top-span-right"
15
+ | "bottom"
16
+ | "bottom-left"
17
+ | "bottom-right"
18
+ | "bottom-span-left"
19
+ | "bottom-span-right"
20
+ | "left"
21
+ | "right";
22
+
23
+ /** Base properties shared by most item types */
24
+ interface DropdownMenuItemBase {
25
+ /** Unique identifier - required for keyboard navigation tracking */
26
+ id: string | number;
27
+ /** Whether this item is disabled */
28
+ disabled?: boolean;
29
+ /** Additional CSS classes for this specific item */
30
+ class?: string;
31
+ /** Arbitrary data to pass through */
32
+ data?: Record<string, any>;
33
+ }
34
+
35
+ /** Clickable action item */
36
+ export interface DropdownMenuActionItem extends DropdownMenuItemBase {
37
+ type: "action";
38
+ /** Label displayed - supports THC for icons, HTML, etc. */
39
+ label: THC;
40
+ /** Shortcut hint displayed on the right (e.g., "Cmd+K") */
41
+ shortcut?: string;
42
+ /** Icon displayed before label - supports THC */
43
+ icon?: THC;
44
+ /** Callback when item is selected */
45
+ onSelect?: () => void | boolean;
46
+ }
47
+
48
+ /** Visual divider/separator */
49
+ export interface DropdownMenuDividerItem {
50
+ type: "divider";
51
+ id?: string | number;
52
+ class?: string;
53
+ }
54
+
55
+ /** Non-interactive section header */
56
+ export interface DropdownMenuHeaderItem {
57
+ type: "header";
58
+ id?: string | number;
59
+ label: THC;
60
+ class?: string;
61
+ }
62
+
63
+ /** Custom static content (non-interactive) */
64
+ export interface DropdownMenuCustomItem {
65
+ type: "custom";
66
+ id?: string | number;
67
+ /** Content rendered via THC */
68
+ content: THC;
69
+ class?: string;
70
+ }
71
+
72
+ /** Expandable section containing nested items */
73
+ export interface DropdownMenuExpandableItem extends DropdownMenuItemBase {
74
+ type: "expandable";
75
+ /** Label for the expandable header */
76
+ label: THC;
77
+ icon?: THC;
78
+ /** Nested items (single level only - no nested expandables) */
79
+ items: DropdownMenuFlatItem[];
80
+ /** Whether section starts expanded */
81
+ defaultExpanded?: boolean;
82
+ }
83
+
84
+ /** Flat items (non-expandable) */
85
+ export type DropdownMenuFlatItem =
86
+ | DropdownMenuActionItem
87
+ | DropdownMenuDividerItem
88
+ | DropdownMenuHeaderItem
89
+ | DropdownMenuCustomItem;
90
+
91
+ /** All possible item types */
92
+ export type DropdownMenuItem = DropdownMenuFlatItem | DropdownMenuExpandableItem;
93
+
94
+ /** Internal type for navigable items (includes expandable headers) */
95
+ export interface NavigableExpandable {
96
+ type: "expandable-header";
97
+ id: string | number;
98
+ expandableItem: DropdownMenuExpandableItem;
99
+ }
100
+ export type NavigableItem = DropdownMenuActionItem | NavigableExpandable;
101
+
102
+ export interface Props extends Omit<HTMLButtonAttributes, "children"> {
103
+ /** Menu items to display */
104
+ items: DropdownMenuItem[];
105
+ /** Controlled open state */
106
+ isOpen?: boolean;
107
+ /** Popover position relative to trigger */
108
+ position?: DropdownMenuPosition;
109
+ /** Offset from trigger element (CSS value) */
110
+ offset?: string;
111
+ /** Max height of dropdown (CSS value) */
112
+ maxHeight?: string;
113
+ /** Whether to close menu when an action item is selected */
114
+ closeOnSelect?: boolean;
115
+ /** Close on click outside */
116
+ closeOnClickOutside?: boolean;
117
+ /** Close on Escape key */
118
+ closeOnEscape?: boolean;
119
+ /** Force fallback positioning mode (for testing) */
120
+ forceFallback?: boolean;
121
+ /** Classes for the wrapper element */
122
+ class?: string;
123
+ /** Classes for the trigger button */
124
+ classTrigger?: string;
125
+ /** Classes for the dropdown container */
126
+ classDropdown?: string;
127
+ /** Classes for action items */
128
+ classItem?: string;
129
+ /** Classes for active/focused action item */
130
+ classItemActive?: string;
131
+ /** Classes for disabled items */
132
+ classItemDisabled?: string;
133
+ /** Classes for dividers */
134
+ classDivider?: string;
135
+ /** Classes for header items */
136
+ classHeader?: string;
137
+ /** Classes for expandable section header */
138
+ classExpandable?: string;
139
+ /** Classes for expandable section content */
140
+ classExpandableContent?: string;
141
+ /** Custom trigger snippet - receives isOpen state, toggle function, and ARIA props for full control */
142
+ trigger?: Snippet<
143
+ [
144
+ {
145
+ isOpen: boolean;
146
+ toggle: () => void;
147
+ triggerProps: {
148
+ id: string;
149
+ "aria-haspopup": "menu";
150
+ "aria-expanded": boolean;
151
+ "aria-controls": string;
152
+ };
153
+ },
154
+ ]
155
+ >;
156
+ /** Simple content rendered inside the default trigger button */
157
+ children?: Snippet;
158
+ /** Called when menu opens */
159
+ onOpen?: () => void;
160
+ /** Called when menu closes */
161
+ onClose?: () => void;
162
+ /** Called when any action item is selected */
163
+ onSelect?: (item: DropdownMenuActionItem) => void;
164
+ /** Reference to trigger element */
165
+ triggerEl?: HTMLButtonElement;
166
+ /** Reference to dropdown element */
167
+ dropdownEl?: HTMLDivElement;
168
+ }
169
+
170
+ const POSITION_MAP: Record<string, string> = {
171
+ top: "top",
172
+ "top-left": "top left",
173
+ "top-right": "top right",
174
+ "top-span-left": "top span-left",
175
+ "top-span-right": "top span-right",
176
+ bottom: "bottom",
177
+ "bottom-left": "bottom left",
178
+ "bottom-right": "bottom right",
179
+ "bottom-span-left": "bottom span-left",
180
+ "bottom-span-right": "bottom span-right",
181
+ left: "left",
182
+ right: "right",
183
+ };
184
+
185
+ /**
186
+ * Checks if the browser supports CSS Anchor Positioning.
187
+ */
188
+ function isAnchorPositioningSupported(): boolean {
189
+ if (typeof CSS === "undefined") return false;
190
+ return (
191
+ CSS.supports("anchor-name", "--anchor") &&
192
+ CSS.supports("position-area", "top") &&
193
+ CSS.supports("position-try-fallbacks", "top")
194
+ );
195
+ }
196
+
197
+ export const DROPDOWN_MENU_BASE_CLASSES = `stuic-dropdown-menu relative inline-block`;
198
+
199
+ export const DROPDOWN_MENU_TRIGGER_CLASSES = `
200
+ inline-flex items-center justify-center gap-2
201
+ px-3 py-2
202
+ rounded-md border
203
+ bg-white dark:bg-neutral-800
204
+ text-neutral-900 dark:text-neutral-100
205
+ border-neutral-200 dark:border-neutral-700
206
+ hover:brightness-95 dark:hover:brightness-110
207
+ focus-visible:outline-2 focus-visible:outline-offset-2
208
+ cursor-pointer
209
+ `;
210
+
211
+ export const DROPDOWN_MENU_DROPDOWN_CLASSES = `
212
+ stuic-dropdown-menu-dropdown
213
+ bg-white dark:bg-neutral-800
214
+ text-neutral-900 dark:text-neutral-100
215
+ border border-neutral-200 dark:border-neutral-700
216
+ rounded-md shadow-lg
217
+ p-1
218
+ overflow-y-auto
219
+ z-50
220
+ min-w-48
221
+ `;
222
+
223
+ export const DROPDOWN_MENU_ITEM_CLASSES = `
224
+ w-full
225
+ flex items-center gap-2
226
+ px-2 py-1.5
227
+ min-h-[44px]
228
+ text-left text-sm
229
+ rounded-sm
230
+ cursor-pointer
231
+ touch-action-manipulation
232
+ hover:bg-neutral-100 dark:hover:bg-neutral-700
233
+ focus:outline-none
234
+ focus-visible:bg-neutral-200 dark:focus-visible:bg-neutral-600
235
+ `;
236
+
237
+ export const DROPDOWN_MENU_DIVIDER_CLASSES = `
238
+ h-px my-1
239
+ bg-neutral-200 dark:bg-neutral-700
240
+ `;
241
+
242
+ export const DROPDOWN_MENU_HEADER_CLASSES = `
243
+ px-2 py-1.5
244
+ text-xs font-semibold uppercase tracking-wide
245
+ text-neutral-500 dark:text-neutral-400
246
+ select-none
247
+ `;
248
+ </script>
249
+
250
+ <script lang="ts">
251
+ import { twMerge } from "../../utils/tw-merge.js";
252
+ import { getId } from "../../utils/get-id.js";
253
+ import { prefersReducedMotion } from "../../utils/prefers-reduced-motion.svelte.js";
254
+ import { ItemCollection } from "@marianmeres/item-collection";
255
+ import { iconLucideChevronDown } from "@marianmeres/icons-fns/lucide/iconLucideChevronDown.js";
256
+ import { iconLucideChevronRight } from "@marianmeres/icons-fns/lucide/iconLucideChevronRight.js";
257
+ import { onClickOutside } from "runed";
258
+ import { slide } from "svelte/transition";
259
+ import { untrack } from "svelte";
260
+ import Thc from "../Thc/Thc.svelte";
261
+ import "./index.css";
262
+
263
+ let {
264
+ items,
265
+ isOpen = $bindable(false),
266
+ position = "bottom-span-left",
267
+ offset = "0.25rem",
268
+ maxHeight = "300px",
269
+ closeOnSelect = true,
270
+ closeOnClickOutside = true,
271
+ closeOnEscape = true,
272
+ forceFallback = false,
273
+ class: classProp,
274
+ classTrigger,
275
+ classDropdown,
276
+ classItem,
277
+ classItemActive,
278
+ classItemDisabled,
279
+ classDivider,
280
+ classHeader,
281
+ classExpandable,
282
+ classExpandableContent,
283
+ trigger,
284
+ children,
285
+ onOpen,
286
+ onClose,
287
+ onSelect,
288
+ triggerEl = $bindable(),
289
+ dropdownEl = $bindable(),
290
+ ...rest
291
+ }: Props = $props();
292
+
293
+ // Unique IDs for ARIA
294
+ const triggerId = getId("dropdown-trigger-");
295
+ const dropdownId = getId("dropdown-menu-");
296
+ const anchorName = `--dropdown-anchor-${triggerId}`;
297
+
298
+ // State
299
+ let wrapperEl: HTMLDivElement = $state()!;
300
+ let activeItemEl: HTMLButtonElement | undefined = $state();
301
+ const reducedMotion = prefersReducedMotion();
302
+ const isSupported = !forceFallback && isAnchorPositioningSupported();
303
+
304
+ // Track expanded sections (independent toggle - multiple can be open)
305
+ let expandedSections = $state<Set<string | number>>(new Set());
306
+
307
+ // Initialize expanded sections from defaultExpanded
308
+ $effect(() => {
309
+ const initial = new Set<string | number>();
310
+ for (const item of items) {
311
+ if (item.type === "expandable" && item.defaultExpanded) {
312
+ initial.add(item.id);
313
+ }
314
+ }
315
+ expandedSections = initial;
316
+ });
317
+
318
+ // ItemCollection for keyboard navigation (includes action items and expandable headers)
319
+ // svelte-ignore state_referenced_locally
320
+ const navItems = new ItemCollection<NavigableItem>([], {
321
+ idPropName: "id",
322
+ });
323
+ let _navItems = $derived($navItems);
324
+
325
+ // Flatten navigable items (respects expanded state)
326
+ let navigableItems = $derived.by(() => {
327
+ const flat: NavigableItem[] = [];
328
+ for (const item of items) {
329
+ if (item.type === "action" && !item.disabled) {
330
+ flat.push(item);
331
+ } else if (item.type === "expandable" && !item.disabled) {
332
+ // Add expandable header as navigable
333
+ flat.push({ type: "expandable-header", id: item.id, expandableItem: item });
334
+ // If expanded, add child actions
335
+ if (expandedSections.has(item.id)) {
336
+ for (const child of item.items) {
337
+ if (child.type === "action" && !child.disabled) {
338
+ flat.push(child);
339
+ }
340
+ }
341
+ }
342
+ }
343
+ }
344
+ return flat;
345
+ });
346
+
347
+ // Update collection when navigable items change, preserving active state
348
+ $effect(() => {
349
+ // Use untrack to read active ID without creating a dependency on $navItems
350
+ const activeId = untrack(() => $navItems.active?.id);
351
+ navItems.clear().addMany(navigableItems);
352
+ // Restore active item if it still exists
353
+ if (activeId !== undefined) {
354
+ const item = navigableItems.find((i) => i.id === activeId);
355
+ if (item) {
356
+ navItems.setActive(item);
357
+ }
358
+ }
359
+ });
360
+
361
+ // Reset active when menu closes
362
+ $effect(() => {
363
+ if (!isOpen) {
364
+ navItems.unsetActive();
365
+ }
366
+ });
367
+
368
+ // Scroll active item into view
369
+ $effect(() => {
370
+ if (isOpen && _navItems.active?.id) {
371
+ const active = _navItems.active;
372
+ const elId =
373
+ active.type === "expandable-header"
374
+ ? expandableHeaderId(active.id)
375
+ : itemId(active.id);
376
+ const el = document.getElementById(elId);
377
+ if (el) {
378
+ el.scrollIntoView({ behavior: "smooth", block: "nearest" });
379
+ el.focus();
380
+ }
381
+ }
382
+ });
383
+
384
+ // Handle open/close callbacks
385
+ $effect(() => {
386
+ if (isOpen) {
387
+ onOpen?.();
388
+ }
389
+ });
390
+
391
+ let wasOpen = $state(false);
392
+ $effect(() => {
393
+ if (wasOpen && !isOpen) {
394
+ onClose?.();
395
+ }
396
+ wasOpen = isOpen;
397
+ });
398
+
399
+ // Click outside handler
400
+ onClickOutside(
401
+ () => wrapperEl,
402
+ () => {
403
+ if (closeOnClickOutside && isOpen) {
404
+ isOpen = false;
405
+ triggerEl?.focus();
406
+ }
407
+ }
408
+ );
409
+
410
+ // Helper to generate item IDs
411
+ function itemId(id: string | number): string {
412
+ return `${dropdownId}-item-${id}`;
413
+ }
414
+
415
+ // Helper to generate header IDs for expandable sections
416
+ function expandableHeaderId(id: string | number): string {
417
+ return `${dropdownId}-expandable-${id}`;
418
+ }
419
+
420
+ // Toggle expandable section
421
+ function toggleExpanded(id: string | number) {
422
+ const newSet = new Set(expandedSections);
423
+ if (newSet.has(id)) {
424
+ newSet.delete(id);
425
+ } else {
426
+ newSet.add(id);
427
+ }
428
+ expandedSections = newSet;
429
+ }
430
+
431
+ // Select an action item
432
+ function selectItem(item: DropdownMenuActionItem) {
433
+ if (item.disabled) return;
434
+
435
+ const result = item.onSelect?.();
436
+ onSelect?.(item);
437
+
438
+ if (result !== false && closeOnSelect) {
439
+ isOpen = false;
440
+ triggerEl?.focus();
441
+ }
442
+ }
443
+
444
+ // Computed transition duration
445
+ let transitionDuration = $derived(reducedMotion.current ? 0 : 100);
446
+
447
+ // Position styles for CSS Anchor Positioning
448
+ let dropdownStyle = $derived.by(() => {
449
+ if (isSupported) {
450
+ return `
451
+ position: fixed;
452
+ position-anchor: ${anchorName};
453
+ position-area: ${POSITION_MAP[position] || "bottom"};
454
+ margin: ${offset};
455
+ max-height: ${maxHeight};
456
+ `;
457
+ } else {
458
+ // Fallback: absolute positioning
459
+ if (position === "left") {
460
+ return `position: absolute; right: 100%; top: 0; margin-right: ${offset}; max-height: ${maxHeight};`;
461
+ } else if (position === "right") {
462
+ return `position: absolute; left: 100%; top: 0; margin-left: ${offset}; max-height: ${maxHeight};`;
463
+ }
464
+ const isTop = position.startsWith("top");
465
+ const isLeft =
466
+ position.includes("left") || position === "bottom" || position === "top";
467
+ return `
468
+ position: absolute;
469
+ ${isTop ? "bottom: 100%;" : "top: 100%;"}
470
+ ${isLeft ? "left: 0;" : "right: 0;"}
471
+ margin-top: ${isTop ? "0" : offset};
472
+ margin-bottom: ${isTop ? offset : "0"};
473
+ max-height: ${maxHeight};
474
+ `;
475
+ }
476
+ });
477
+ </script>
478
+
479
+ <svelte:window
480
+ onkeydown={(e) => {
481
+ if (!isOpen) return;
482
+
483
+ if (["ArrowDown", "ArrowUp"].includes(e.key)) {
484
+ e.preventDefault();
485
+ if (e.key === "ArrowUp") {
486
+ e.metaKey ? navItems.setActiveFirst() : navItems.setActivePrevious();
487
+ } else {
488
+ e.metaKey ? navItems.setActiveLast() : navItems.setActiveNext();
489
+ }
490
+ } else if (e.key === "Home") {
491
+ e.preventDefault();
492
+ navItems.setActiveFirst();
493
+ } else if (e.key === "End") {
494
+ e.preventDefault();
495
+ navItems.setActiveLast();
496
+ } else if (["Enter", " "].includes(e.key)) {
497
+ e.preventDefault();
498
+ const active = _navItems.active;
499
+ if (active) {
500
+ if (active.type === "expandable-header") {
501
+ toggleExpanded(active.id);
502
+ } else {
503
+ selectItem(active as DropdownMenuActionItem);
504
+ }
505
+ }
506
+ } else if (e.key === "ArrowRight") {
507
+ const active = _navItems.active;
508
+ if (active?.type === "expandable-header" && !expandedSections.has(active.id)) {
509
+ e.preventDefault();
510
+ toggleExpanded(active.id);
511
+ }
512
+ } else if (e.key === "ArrowLeft") {
513
+ const active = _navItems.active;
514
+ if (active?.type === "expandable-header" && expandedSections.has(active.id)) {
515
+ e.preventDefault();
516
+ toggleExpanded(active.id);
517
+ }
518
+ } else if (e.key === "Escape" && closeOnEscape) {
519
+ e.preventDefault();
520
+ isOpen = false;
521
+ triggerEl?.focus();
522
+ } else if (e.key === "Tab") {
523
+ isOpen = false;
524
+ }
525
+ }}
526
+ />
527
+
528
+ <div
529
+ bind:this={wrapperEl}
530
+ class={twMerge(DROPDOWN_MENU_BASE_CLASSES, classProp)}
531
+ style:anchor-name={isSupported ? anchorName : undefined}
532
+ >
533
+ <!-- Trigger -->
534
+ {#if trigger}
535
+ <!-- Custom trigger with full control -->
536
+ {@render trigger({
537
+ isOpen,
538
+ toggle: () => (isOpen = !isOpen),
539
+ triggerProps: {
540
+ id: triggerId,
541
+ "aria-haspopup": "menu",
542
+ "aria-expanded": isOpen,
543
+ "aria-controls": dropdownId,
544
+ },
545
+ })}
546
+ {:else}
547
+ <!-- Default button (optionally with children content) -->
548
+ <button
549
+ bind:this={triggerEl}
550
+ id={triggerId}
551
+ class={twMerge(DROPDOWN_MENU_TRIGGER_CLASSES, classTrigger)}
552
+ onclick={() => (isOpen = !isOpen)}
553
+ aria-haspopup="menu"
554
+ aria-expanded={isOpen}
555
+ aria-controls={dropdownId}
556
+ type="button"
557
+ {...rest}
558
+ >
559
+ {#if children}
560
+ {@render children()}
561
+ {:else}
562
+ Menu
563
+ <span class={isOpen ? "rotate-180 inline-block" : "inline-block"}>
564
+ {@html iconLucideChevronDown({ size: 16 })}
565
+ </span>
566
+ {/if}
567
+ </button>
568
+ {/if}
569
+
570
+ <!-- Dropdown Menu -->
571
+ {#if isOpen}
572
+ <div
573
+ bind:this={dropdownEl}
574
+ id={dropdownId}
575
+ role="menu"
576
+ aria-labelledby={triggerId}
577
+ class={twMerge(DROPDOWN_MENU_DROPDOWN_CLASSES, classDropdown)}
578
+ style={dropdownStyle}
579
+ transition:slide={{ duration: transitionDuration }}
580
+ >
581
+ {#each items as item}
582
+ {#if item.type === "action"}
583
+ {@const isActive = _navItems.active?.id === item.id}
584
+ <button
585
+ id={itemId(item.id)}
586
+ role="menuitem"
587
+ class={twMerge(
588
+ DROPDOWN_MENU_ITEM_CLASSES,
589
+ isActive && "bg-neutral-200 dark:bg-neutral-600",
590
+ isActive && classItemActive,
591
+ item.disabled && "opacity-50 cursor-not-allowed pointer-events-none",
592
+ item.disabled && classItemDisabled,
593
+ classItem,
594
+ item.class
595
+ )}
596
+ onclick={() => selectItem(item)}
597
+ onmouseenter={() => navItems.setActive(item)}
598
+ aria-disabled={item.disabled || undefined}
599
+ tabindex={-1}
600
+ type="button"
601
+ >
602
+ {#if item.icon}
603
+ <span class="shrink-0">
604
+ <Thc thc={item.icon} />
605
+ </span>
606
+ {/if}
607
+ <span class="flex-1">
608
+ <Thc thc={item.label} />
609
+ </span>
610
+ {#if item.shortcut}
611
+ <span
612
+ class="text-xs text-dropdown-header dark:text-dropdown-header-dark ml-auto"
613
+ >
614
+ {item.shortcut}
615
+ </span>
616
+ {/if}
617
+ </button>
618
+ {:else if item.type === "divider"}
619
+ <div
620
+ role="separator"
621
+ class={twMerge(DROPDOWN_MENU_DIVIDER_CLASSES, classDivider, item.class)}
622
+ ></div>
623
+ {:else if item.type === "header"}
624
+ <div
625
+ role="presentation"
626
+ class={twMerge(DROPDOWN_MENU_HEADER_CLASSES, classHeader, item.class)}
627
+ >
628
+ <Thc thc={item.label} />
629
+ </div>
630
+ {:else if item.type === "custom"}
631
+ <div role="presentation" class={item.class}>
632
+ <Thc thc={item.content} />
633
+ </div>
634
+ {:else if item.type === "expandable"}
635
+ {@const isExpanded = expandedSections.has(item.id)}
636
+ {@const isExpandableActive =
637
+ _navItems.active?.type === "expandable-header" &&
638
+ _navItems.active?.id === item.id}
639
+ <div role="group" aria-labelledby={expandableHeaderId(item.id)}>
640
+ <!-- Expandable header -->
641
+ <button
642
+ id={expandableHeaderId(item.id)}
643
+ role="menuitem"
644
+ class={twMerge(
645
+ DROPDOWN_MENU_ITEM_CLASSES,
646
+ "font-medium",
647
+ isExpandableActive && "bg-neutral-200 dark:bg-neutral-600",
648
+ isExpandableActive && classItemActive,
649
+ item.disabled && "opacity-50 cursor-not-allowed pointer-events-none",
650
+ item.disabled && classItemDisabled,
651
+ classExpandable,
652
+ item.class
653
+ )}
654
+ onclick={() => toggleExpanded(item.id)}
655
+ onmouseenter={() =>
656
+ navItems.setActive({
657
+ type: "expandable-header",
658
+ id: item.id,
659
+ expandableItem: item,
660
+ })}
661
+ aria-expanded={isExpanded}
662
+ aria-disabled={item.disabled || undefined}
663
+ tabindex={-1}
664
+ type="button"
665
+ >
666
+ {#if item.icon}
667
+ <span class="shrink-0">
668
+ <Thc thc={item.icon} />
669
+ </span>
670
+ {/if}
671
+ <span class="flex-1">
672
+ <Thc thc={item.label} />
673
+ </span>
674
+ <span
675
+ class={twMerge(
676
+ "transition-transform inline-block",
677
+ isExpanded && "rotate-90"
678
+ )}
679
+ >
680
+ {@html iconLucideChevronRight({ size: 16 })}
681
+ </span>
682
+ </button>
683
+
684
+ <!-- Expandable content -->
685
+ {#if isExpanded}
686
+ <div
687
+ class={twMerge(
688
+ "stuic-dropdown-menu-expandable-content pl-4",
689
+ classExpandableContent
690
+ )}
691
+ transition:slide={{ duration: transitionDuration }}
692
+ >
693
+ {#each item.items as childItem}
694
+ {#if childItem.type === "action"}
695
+ {@const isChildActive = _navItems.active?.id === childItem.id}
696
+ <button
697
+ id={itemId(childItem.id)}
698
+ role="menuitem"
699
+ class={twMerge(
700
+ DROPDOWN_MENU_ITEM_CLASSES,
701
+ isChildActive && "bg-neutral-200 dark:bg-neutral-600",
702
+ isChildActive && classItemActive,
703
+ childItem.disabled &&
704
+ "opacity-50 cursor-not-allowed pointer-events-none",
705
+ childItem.disabled && classItemDisabled,
706
+ classItem,
707
+ childItem.class
708
+ )}
709
+ onclick={() => selectItem(childItem)}
710
+ onmouseenter={() => navItems.setActive(childItem)}
711
+ aria-disabled={childItem.disabled || undefined}
712
+ tabindex={-1}
713
+ type="button"
714
+ >
715
+ {#if childItem.icon}
716
+ <span class="shrink-0">
717
+ <Thc thc={childItem.icon} />
718
+ </span>
719
+ {/if}
720
+ <span class="flex-1">
721
+ <Thc thc={childItem.label} />
722
+ </span>
723
+ {#if childItem.shortcut}
724
+ <span
725
+ class="text-xs text-dropdown-header dark:text-dropdown-header-dark ml-auto"
726
+ >
727
+ {childItem.shortcut}
728
+ </span>
729
+ {/if}
730
+ </button>
731
+ {:else if childItem.type === "divider"}
732
+ <div
733
+ role="separator"
734
+ class={twMerge(
735
+ DROPDOWN_MENU_DIVIDER_CLASSES,
736
+ classDivider,
737
+ childItem.class
738
+ )}
739
+ ></div>
740
+ {:else if childItem.type === "header"}
741
+ <div
742
+ role="presentation"
743
+ class={twMerge(
744
+ DROPDOWN_MENU_HEADER_CLASSES,
745
+ classHeader,
746
+ childItem.class
747
+ )}
748
+ >
749
+ <Thc thc={childItem.label} />
750
+ </div>
751
+ {:else if childItem.type === "custom"}
752
+ <div role="presentation" class={childItem.class}>
753
+ <Thc thc={childItem.content} />
754
+ </div>
755
+ {/if}
756
+ {/each}
757
+ </div>
758
+ {/if}
759
+ </div>
760
+ {/if}
761
+ {/each}
762
+ </div>
763
+ {/if}
764
+ </div>
@@ -0,0 +1,148 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { HTMLButtonAttributes } from "svelte/elements";
3
+ import type { THC } from "../Thc/Thc.svelte";
4
+ /**
5
+ * Valid positions for dropdown placement relative to the trigger element.
6
+ */
7
+ export type DropdownMenuPosition = "top" | "top-left" | "top-right" | "top-span-left" | "top-span-right" | "bottom" | "bottom-left" | "bottom-right" | "bottom-span-left" | "bottom-span-right" | "left" | "right";
8
+ /** Base properties shared by most item types */
9
+ interface DropdownMenuItemBase {
10
+ /** Unique identifier - required for keyboard navigation tracking */
11
+ id: string | number;
12
+ /** Whether this item is disabled */
13
+ disabled?: boolean;
14
+ /** Additional CSS classes for this specific item */
15
+ class?: string;
16
+ /** Arbitrary data to pass through */
17
+ data?: Record<string, any>;
18
+ }
19
+ /** Clickable action item */
20
+ export interface DropdownMenuActionItem extends DropdownMenuItemBase {
21
+ type: "action";
22
+ /** Label displayed - supports THC for icons, HTML, etc. */
23
+ label: THC;
24
+ /** Shortcut hint displayed on the right (e.g., "Cmd+K") */
25
+ shortcut?: string;
26
+ /** Icon displayed before label - supports THC */
27
+ icon?: THC;
28
+ /** Callback when item is selected */
29
+ onSelect?: () => void | boolean;
30
+ }
31
+ /** Visual divider/separator */
32
+ export interface DropdownMenuDividerItem {
33
+ type: "divider";
34
+ id?: string | number;
35
+ class?: string;
36
+ }
37
+ /** Non-interactive section header */
38
+ export interface DropdownMenuHeaderItem {
39
+ type: "header";
40
+ id?: string | number;
41
+ label: THC;
42
+ class?: string;
43
+ }
44
+ /** Custom static content (non-interactive) */
45
+ export interface DropdownMenuCustomItem {
46
+ type: "custom";
47
+ id?: string | number;
48
+ /** Content rendered via THC */
49
+ content: THC;
50
+ class?: string;
51
+ }
52
+ /** Expandable section containing nested items */
53
+ export interface DropdownMenuExpandableItem extends DropdownMenuItemBase {
54
+ type: "expandable";
55
+ /** Label for the expandable header */
56
+ label: THC;
57
+ icon?: THC;
58
+ /** Nested items (single level only - no nested expandables) */
59
+ items: DropdownMenuFlatItem[];
60
+ /** Whether section starts expanded */
61
+ defaultExpanded?: boolean;
62
+ }
63
+ /** Flat items (non-expandable) */
64
+ export type DropdownMenuFlatItem = DropdownMenuActionItem | DropdownMenuDividerItem | DropdownMenuHeaderItem | DropdownMenuCustomItem;
65
+ /** All possible item types */
66
+ export type DropdownMenuItem = DropdownMenuFlatItem | DropdownMenuExpandableItem;
67
+ /** Internal type for navigable items (includes expandable headers) */
68
+ export interface NavigableExpandable {
69
+ type: "expandable-header";
70
+ id: string | number;
71
+ expandableItem: DropdownMenuExpandableItem;
72
+ }
73
+ export type NavigableItem = DropdownMenuActionItem | NavigableExpandable;
74
+ export interface Props extends Omit<HTMLButtonAttributes, "children"> {
75
+ /** Menu items to display */
76
+ items: DropdownMenuItem[];
77
+ /** Controlled open state */
78
+ isOpen?: boolean;
79
+ /** Popover position relative to trigger */
80
+ position?: DropdownMenuPosition;
81
+ /** Offset from trigger element (CSS value) */
82
+ offset?: string;
83
+ /** Max height of dropdown (CSS value) */
84
+ maxHeight?: string;
85
+ /** Whether to close menu when an action item is selected */
86
+ closeOnSelect?: boolean;
87
+ /** Close on click outside */
88
+ closeOnClickOutside?: boolean;
89
+ /** Close on Escape key */
90
+ closeOnEscape?: boolean;
91
+ /** Force fallback positioning mode (for testing) */
92
+ forceFallback?: boolean;
93
+ /** Classes for the wrapper element */
94
+ class?: string;
95
+ /** Classes for the trigger button */
96
+ classTrigger?: string;
97
+ /** Classes for the dropdown container */
98
+ classDropdown?: string;
99
+ /** Classes for action items */
100
+ classItem?: string;
101
+ /** Classes for active/focused action item */
102
+ classItemActive?: string;
103
+ /** Classes for disabled items */
104
+ classItemDisabled?: string;
105
+ /** Classes for dividers */
106
+ classDivider?: string;
107
+ /** Classes for header items */
108
+ classHeader?: string;
109
+ /** Classes for expandable section header */
110
+ classExpandable?: string;
111
+ /** Classes for expandable section content */
112
+ classExpandableContent?: string;
113
+ /** Custom trigger snippet - receives isOpen state, toggle function, and ARIA props for full control */
114
+ trigger?: Snippet<[
115
+ {
116
+ isOpen: boolean;
117
+ toggle: () => void;
118
+ triggerProps: {
119
+ id: string;
120
+ "aria-haspopup": "menu";
121
+ "aria-expanded": boolean;
122
+ "aria-controls": string;
123
+ };
124
+ }
125
+ ]>;
126
+ /** Simple content rendered inside the default trigger button */
127
+ children?: Snippet;
128
+ /** Called when menu opens */
129
+ onOpen?: () => void;
130
+ /** Called when menu closes */
131
+ onClose?: () => void;
132
+ /** Called when any action item is selected */
133
+ onSelect?: (item: DropdownMenuActionItem) => void;
134
+ /** Reference to trigger element */
135
+ triggerEl?: HTMLButtonElement;
136
+ /** Reference to dropdown element */
137
+ dropdownEl?: HTMLDivElement;
138
+ }
139
+ export declare const DROPDOWN_MENU_BASE_CLASSES = "stuic-dropdown-menu relative inline-block";
140
+ export declare const DROPDOWN_MENU_TRIGGER_CLASSES = "\n\t\tinline-flex items-center justify-center gap-2\n\t\tpx-3 py-2\n\t\trounded-md border\n\t\tbg-white dark:bg-neutral-800\n\t\ttext-neutral-900 dark:text-neutral-100\n\t\tborder-neutral-200 dark:border-neutral-700\n\t\thover:brightness-95 dark:hover:brightness-110\n\t\tfocus-visible:outline-2 focus-visible:outline-offset-2\n\t\tcursor-pointer\n\t";
141
+ export declare const DROPDOWN_MENU_DROPDOWN_CLASSES = "\n\t\tstuic-dropdown-menu-dropdown\n\t\tbg-white dark:bg-neutral-800\n\t\ttext-neutral-900 dark:text-neutral-100\n\t\tborder border-neutral-200 dark:border-neutral-700\n\t\trounded-md shadow-lg\n\t\tp-1\n\t\toverflow-y-auto\n\t\tz-50\n\t\tmin-w-48\n\t";
142
+ export declare const DROPDOWN_MENU_ITEM_CLASSES = "\n\t\tw-full\n\t\tflex items-center gap-2\n\t\tpx-2 py-1.5\n\t\tmin-h-[44px]\n\t\ttext-left text-sm\n\t\trounded-sm\n\t\tcursor-pointer\n\t\ttouch-action-manipulation\n\t\thover:bg-neutral-100 dark:hover:bg-neutral-700\n\t\tfocus:outline-none\n\t\tfocus-visible:bg-neutral-200 dark:focus-visible:bg-neutral-600\n\t";
143
+ export declare const DROPDOWN_MENU_DIVIDER_CLASSES = "\n\t\th-px my-1\n\t\tbg-neutral-200 dark:bg-neutral-700\n\t";
144
+ export declare const DROPDOWN_MENU_HEADER_CLASSES = "\n\t\tpx-2 py-1.5\n\t\ttext-xs font-semibold uppercase tracking-wide\n\t\ttext-neutral-500 dark:text-neutral-400\n\t\tselect-none\n\t";
145
+ import "./index.css";
146
+ declare const DropdownMenu: import("svelte").Component<Props, {}, "isOpen" | "triggerEl" | "dropdownEl">;
147
+ type DropdownMenu = ReturnType<typeof DropdownMenu>;
148
+ export default DropdownMenu;
@@ -0,0 +1,16 @@
1
+ /* Base dropdown styles */
2
+ .stuic-dropdown-menu-dropdown {
3
+ scrollbar-width: thin;
4
+ }
5
+
6
+ /* CSS Anchor Positioning supported mode - flip only when viewport overflow */
7
+ @supports (anchor-name: --anchor) {
8
+ .stuic-dropdown-menu-dropdown {
9
+ position-try-fallbacks: flip-block, flip-inline;
10
+ }
11
+ }
12
+
13
+ /* Expandable section animation handled by Svelte slide transition */
14
+ .stuic-dropdown-menu-expandable-content {
15
+ overflow: hidden;
16
+ }
@@ -0,0 +1 @@
1
+ export { default as DropdownMenu, type Props as DropdownMenuProps, type DropdownMenuItem, type DropdownMenuActionItem, type DropdownMenuDividerItem, type DropdownMenuHeaderItem, type DropdownMenuCustomItem, type DropdownMenuExpandableItem, type DropdownMenuFlatItem, type DropdownMenuPosition, type NavigableItem, type NavigableExpandable, DROPDOWN_MENU_BASE_CLASSES, DROPDOWN_MENU_TRIGGER_CLASSES, DROPDOWN_MENU_DROPDOWN_CLASSES, DROPDOWN_MENU_ITEM_CLASSES, DROPDOWN_MENU_DIVIDER_CLASSES, DROPDOWN_MENU_HEADER_CLASSES, } from "./DropdownMenu.svelte";
@@ -0,0 +1 @@
1
+ export { default as DropdownMenu, DROPDOWN_MENU_BASE_CLASSES, DROPDOWN_MENU_TRIGGER_CLASSES, DROPDOWN_MENU_DROPDOWN_CLASSES, DROPDOWN_MENU_ITEM_CLASSES, DROPDOWN_MENU_DIVIDER_CLASSES, DROPDOWN_MENU_HEADER_CLASSES, } from "./DropdownMenu.svelte";
package/dist/index.d.ts CHANGED
@@ -31,6 +31,7 @@ export * from "./components/ColorScheme/index.js";
31
31
  export * from "./components/CommandMenu/index.js";
32
32
  export * from "./components/DismissibleMessage/index.js";
33
33
  export * from "./components/Drawer/index.js";
34
+ export * from "./components/DropdownMenu/index.js";
34
35
  export * from "./components/HoverExpandableWidth/index.js";
35
36
  export * from "./components/Input/index.js";
36
37
  export * from "./components/KbdShortcut/index.js";
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ export * from "./components/ColorScheme/index.js";
32
32
  export * from "./components/CommandMenu/index.js";
33
33
  export * from "./components/DismissibleMessage/index.js";
34
34
  export * from "./components/Drawer/index.js";
35
+ export * from "./components/DropdownMenu/index.js";
35
36
  export * from "./components/HoverExpandableWidth/index.js";
36
37
  export * from "./components/Input/index.js";
37
38
  export * from "./components/KbdShortcut/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",