@marianmeres/stuic 3.0.0 → 3.0.1

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 (144) 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 -1
  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 +0 -1
  34. package/dist/components/DropdownMenu/DropdownMenu.svelte.d.ts +0 -1
  35. package/dist/components/DropdownMenu/index.css +179 -177
  36. package/dist/components/Input/FieldInput.svelte +8 -0
  37. package/dist/components/Input/FieldInput.svelte.d.ts +2 -0
  38. package/dist/components/Input/FieldOptions.svelte +1 -1
  39. package/dist/components/Input/index.css +411 -398
  40. package/dist/components/KbdShortcut/KbdShortcut.svelte +4 -12
  41. package/dist/components/KbdShortcut/README.md +34 -0
  42. package/dist/components/KbdShortcut/index.css +55 -0
  43. package/dist/components/ListItemButton/ListItemButton.svelte +0 -1
  44. package/dist/components/ListItemButton/ListItemButton.svelte.d.ts +0 -1
  45. package/dist/components/ListItemButton/index.css +118 -116
  46. package/dist/components/Modal/Modal.svelte +0 -1
  47. package/dist/components/Modal/Modal.svelte.d.ts +0 -1
  48. package/dist/components/Modal/index.css +18 -16
  49. package/dist/components/ModalDialog/index.css +29 -27
  50. package/dist/components/Nav/Nav.svelte +732 -0
  51. package/dist/components/Nav/Nav.svelte.d.ts +110 -0
  52. package/dist/components/Nav/README.md +334 -0
  53. package/dist/components/Nav/index.css +318 -0
  54. package/dist/components/Nav/index.d.ts +1 -0
  55. package/dist/components/Nav/index.js +1 -0
  56. package/dist/components/Notifications/Notifications.svelte +2 -3
  57. package/dist/components/Notifications/Notifications.svelte.d.ts +0 -1
  58. package/dist/components/Notifications/index.css +158 -158
  59. package/dist/components/Notifications/notifications-stack.svelte.d.ts +4 -0
  60. package/dist/components/Notifications/notifications-stack.svelte.js +8 -0
  61. package/dist/components/Progress/Progress.svelte +4 -2
  62. package/dist/components/Progress/Progress.svelte.d.ts +1 -0
  63. package/dist/components/Progress/README.md +86 -15
  64. package/dist/components/Progress/_internal/Bar.svelte +4 -15
  65. package/dist/components/Progress/_internal/Bar.svelte.d.ts +1 -1
  66. package/dist/components/Progress/_internal/Circle.svelte +30 -2
  67. package/dist/components/Progress/_internal/Circle.svelte.d.ts +1 -0
  68. package/dist/components/Progress/index.css +47 -1
  69. package/dist/components/Skeleton/README.md +152 -0
  70. package/dist/components/Skeleton/Skeleton.svelte +6 -7
  71. package/dist/components/Skeleton/Skeleton.svelte.d.ts +0 -1
  72. package/dist/components/Skeleton/index.css +73 -43
  73. package/dist/components/Spinner/README.md +149 -37
  74. package/dist/components/Spinner/Spinner.svelte +14 -38
  75. package/dist/components/Spinner/Spinner.svelte.d.ts +2 -1
  76. package/dist/components/Spinner/SpinnerCircle.svelte +6 -34
  77. package/dist/components/Spinner/SpinnerCircle.svelte.d.ts +1 -0
  78. package/dist/components/Spinner/SpinnerCircleOscillate.svelte +10 -5
  79. package/dist/components/Spinner/SpinnerUnicode.svelte +3 -1
  80. package/dist/components/Spinner/SpinnerUnicode.svelte.d.ts +1 -0
  81. package/dist/components/Spinner/index.css +104 -0
  82. package/dist/components/Switch/README.md +34 -18
  83. package/dist/components/Switch/Switch.svelte +24 -46
  84. package/dist/components/Switch/Switch.svelte.d.ts +4 -2
  85. package/dist/components/Switch/index.css +120 -2
  86. package/dist/components/Switch/index.d.ts +1 -2
  87. package/dist/components/Switch/index.js +1 -2
  88. package/dist/components/TabbedMenu/README.md +28 -17
  89. package/dist/components/TabbedMenu/TabbedMenu.svelte +5 -46
  90. package/dist/components/TabbedMenu/TabbedMenu.svelte.d.ts +0 -1
  91. package/dist/components/TabbedMenu/index.css +85 -3
  92. package/dist/components/ThemePreview/ThemePreview.svelte +76 -23
  93. package/dist/components/ThemePreview/ThemePreview.svelte.d.ts +3 -1
  94. package/dist/components/ThemePreview/index.css +17 -1
  95. package/dist/components/TwCheck/README.md +32 -13
  96. package/dist/components/TwCheck/TwCheck.svelte +11 -9
  97. package/dist/components/TwCheck/TwCheck.svelte.d.ts +0 -1
  98. package/dist/components/TwCheck/index.css +14 -0
  99. package/dist/components/TypeaheadInput/TypeaheadInput.svelte +19 -187
  100. package/dist/components/TypeaheadInput/TypeaheadInput.svelte.d.ts +4 -2
  101. package/dist/index.css +44 -39
  102. package/dist/index.d.ts +1 -0
  103. package/dist/index.js +1 -0
  104. package/dist/themes/blue-orange.css +202 -148
  105. package/dist/themes/blue-orange.js +24 -0
  106. package/dist/themes/cyan-red.css +202 -148
  107. package/dist/themes/cyan-red.js +24 -0
  108. package/dist/themes/cyan-slate.css +202 -148
  109. package/dist/themes/cyan-slate.js +25 -1
  110. package/dist/themes/emerald-pink.css +202 -148
  111. package/dist/themes/emerald-pink.js +25 -1
  112. package/dist/themes/fuchsia-emerald.css +202 -148
  113. package/dist/themes/fuchsia-emerald.js +25 -1
  114. package/dist/themes/gray.css +202 -148
  115. package/dist/themes/gray.js +24 -0
  116. package/dist/themes/indigo-amber.css +202 -148
  117. package/dist/themes/indigo-amber.js +26 -2
  118. package/dist/themes/neutral.css +202 -148
  119. package/dist/themes/neutral.js +24 -0
  120. package/dist/themes/pink-emerald.css +202 -148
  121. package/dist/themes/pink-emerald.js +25 -1
  122. package/dist/themes/purple-yellow.css +202 -148
  123. package/dist/themes/purple-yellow.js +24 -0
  124. package/dist/themes/rainbow.css +202 -148
  125. package/dist/themes/rainbow.js +25 -1
  126. package/dist/themes/red-blue.css +202 -148
  127. package/dist/themes/red-blue.js +24 -0
  128. package/dist/themes/red-cyan.css +202 -148
  129. package/dist/themes/red-cyan.js +24 -0
  130. package/dist/themes/rose-teal.css +202 -148
  131. package/dist/themes/rose-teal.js +24 -0
  132. package/dist/themes/sky-amber.css +202 -148
  133. package/dist/themes/sky-amber.js +26 -2
  134. package/dist/themes/slate-cyan.css +202 -148
  135. package/dist/themes/slate-cyan.js +25 -1
  136. package/dist/themes/teal-rose.css +202 -148
  137. package/dist/themes/teal-rose.js +24 -0
  138. package/dist/themes/violet-lime.css +202 -148
  139. package/dist/themes/violet-lime.js +27 -3
  140. package/dist/utils/design-tokens.js +29 -2
  141. package/dist/utils/storage-abstraction.js +1 -1
  142. package/package.json +11 -28
  143. package/dist/components/Switch/SwitchButton.svelte +0 -134
  144. package/dist/components/Switch/SwitchButton.svelte.d.ts +0 -21
@@ -0,0 +1,732 @@
1
+ <script lang="ts" module>
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import type { THC } from "../Thc/Thc.svelte";
4
+ import type { MaybeLocalized } from "../../utils/tr.js";
5
+
6
+ /**
7
+ * Navigation item within a group.
8
+ */
9
+ export interface NavItem {
10
+ /** Unique identifier for the item */
11
+ id: string;
12
+ /** Display label (supports localization) */
13
+ label: MaybeLocalized;
14
+ /** Navigation URL (use href OR onClick, not both) */
15
+ href?: string;
16
+ /** Click handler (alternative to href) */
17
+ onClick?: () => void;
18
+ /** Icon content (THC for flexibility: string, html, component) */
19
+ icon?: THC;
20
+ /** Nested children - parent items with children become expand/collapse toggles */
21
+ children?: NavItem[];
22
+ /** Whether this item is disabled */
23
+ disabled?: boolean;
24
+ /** Additional CSS classes */
25
+ class?: string;
26
+ /** Arbitrary data to pass through */
27
+ data?: Record<string, unknown>;
28
+ }
29
+
30
+ /**
31
+ * A navigation group containing a title and items.
32
+ * If items is empty, the group acts as a regular nav item (clickable without chevron).
33
+ */
34
+ export interface NavGroup {
35
+ /** Group title (supports localization) */
36
+ title: MaybeLocalized;
37
+ /** Navigation items in this group (empty = group acts as nav item) */
38
+ items?: NavItem[];
39
+ /** Group icon (optional) */
40
+ icon?: THC;
41
+ /** Whether the group starts collapsed */
42
+ defaultCollapsed?: boolean;
43
+ /** Navigation URL for groups without items */
44
+ href?: string;
45
+ /** Click handler for groups without items */
46
+ onClick?: () => void;
47
+ /** Unique identifier (used for activeId matching when group has no items) */
48
+ id?: string;
49
+ }
50
+
51
+ export interface Props extends Omit<HTMLAttributes<HTMLElement>, "children" | "title"> {
52
+ /** The navigation groups to render */
53
+ groups: NavGroup[];
54
+
55
+ /** Optional section title rendered above the groups (uppercase, non-interactive) */
56
+ title?: MaybeLocalized;
57
+
58
+ /** Current locale for MaybeLocalized resolution */
59
+ locale?: string;
60
+
61
+ /** Whether the sidebar is in collapsed mode (icon-only) */
62
+ isCollapsed?: boolean;
63
+
64
+ /** Whether the sidebar is expanding (transitioning from collapsed to expanded) */
65
+ isExpanding?: boolean;
66
+
67
+ /** Active item ID for highlighting */
68
+ activeId?: string;
69
+
70
+ /** Callback when checking if an item is active (alternative to activeId) */
71
+ isActive?: (item: NavItem) => boolean;
72
+
73
+ /** Callback when checking if a group (without items) is active */
74
+ isGroupActive?: (group: NavGroup) => boolean;
75
+
76
+ /** Callback when an item is selected */
77
+ onSelect?: (item: NavItem) => void;
78
+
79
+ /** Callback when a group (without items) is selected */
80
+ onGroupSelect?: (group: NavGroup) => void;
81
+
82
+ /** Callback when group expand/collapse state changes */
83
+ onGroupToggle?: (groupIndex: number, isExpanded: boolean) => void;
84
+
85
+ /** Touch-friendly sizing mode */
86
+ touchFriendly?: boolean | "auto";
87
+
88
+ /** Classes for the wrapper element */
89
+ class?: string;
90
+ /** Classes for the section title */
91
+ classTitle?: string;
92
+ /** Classes for the group title/header */
93
+ classGroupTitle?: string;
94
+ /** Classes for individual items */
95
+ classItem?: string;
96
+ /** Classes for active items */
97
+ classItemActive?: string;
98
+ /** Classes for collapsed mode items */
99
+ classItemCollapsed?: string;
100
+ /** Classes for disabled items */
101
+ classItemDisabled?: string;
102
+ /** Classes for icons */
103
+ classIcon?: string;
104
+ /** Classes for labels */
105
+ classLabel?: string;
106
+ /** Classes for children container */
107
+ classChildren?: string;
108
+ /** Classes for chevron icon */
109
+ classChevron?: string;
110
+
111
+ /** Skip all default styling */
112
+ unstyled?: boolean;
113
+
114
+ /** Element reference */
115
+ el?: HTMLElement;
116
+
117
+ /** Enable localStorage persistence for expand/collapse state (default: true) */
118
+ persistState?: boolean;
119
+
120
+ /** Storage key prefix for localStorage (default: 'stuic-nav') */
121
+ storageKeyPrefix?: string;
122
+ }
123
+
124
+ export const NAV_BASE_CLASSES = "stuic-nav";
125
+ export const NAV_SECTION_TITLE_CLASSES = "stuic-nav-section-title";
126
+ export const NAV_GROUP_TITLE_CLASSES = "stuic-nav-group-title";
127
+ export const NAV_ITEM_CLASSES = "stuic-nav-item";
128
+ export const NAV_CHILDREN_CLASSES = "stuic-nav-children";
129
+ </script>
130
+
131
+ <script lang="ts">
132
+ import { twMerge } from "../../utils/tw-merge.js";
133
+ import { tr } from "../../utils/tr.js";
134
+ import { localStorageValue } from "../../utils/storage-abstraction.js";
135
+ import { getId } from "../../utils/get-id.js";
136
+ import { prefersReducedMotion } from "../../utils/prefers-reduced-motion.svelte.js";
137
+ import { DevicePointer } from "../../utils/device-pointer.svelte.js";
138
+ import { iconChevronRight } from "../../icons/index.js";
139
+ import { slide } from "svelte/transition";
140
+ import { untrack } from "svelte";
141
+ import Thc from "../Thc/Thc.svelte";
142
+ import { tooltip } from "../../actions/index.js";
143
+
144
+ let {
145
+ groups,
146
+ title,
147
+ locale,
148
+ isCollapsed = false,
149
+ isExpanding = false,
150
+ activeId,
151
+ isActive,
152
+ isGroupActive,
153
+ onSelect,
154
+ onGroupSelect,
155
+ onGroupToggle,
156
+ touchFriendly = false,
157
+ class: classProp,
158
+ classTitle,
159
+ classGroupTitle,
160
+ classItem,
161
+ classItemActive,
162
+ classItemCollapsed,
163
+ classItemDisabled,
164
+ classIcon,
165
+ classLabel,
166
+ classChildren,
167
+ classChevron,
168
+ unstyled = false,
169
+ el = $bindable(),
170
+ persistState = true,
171
+ storageKeyPrefix = 'stuic-nav',
172
+ ...rest
173
+ }: Props = $props();
174
+
175
+ // Unique IDs for accessibility
176
+ const navId = getId("nav-");
177
+
178
+ // Device detection for touch-friendly sizing
179
+ const devicePointer = new DevicePointer();
180
+ const reducedMotion = prefersReducedMotion();
181
+
182
+ // Compute whether touch-friendly should be active
183
+ let isTouchFriendly = $derived.by(() => {
184
+ if (touchFriendly === true) return true;
185
+ if (touchFriendly === "auto" && devicePointer.isCoarse) return true;
186
+ return false;
187
+ });
188
+
189
+ // Icon size based on touch mode
190
+ const iconSize = $derived(isTouchFriendly ? 22 : 18);
191
+
192
+ // Storage helper functions for persistence
193
+ function getGroupStorageKey(groupId: string): string {
194
+ return `${storageKeyPrefix}-group-${groupId}`;
195
+ }
196
+
197
+ function getItemStorageKey(itemId: string): string {
198
+ return `${storageKeyPrefix}-item-${itemId}`;
199
+ }
200
+
201
+ function loadGroupState(groupId: string): boolean | undefined {
202
+ if (!persistState) return undefined;
203
+ return localStorageValue<boolean | undefined>(getGroupStorageKey(groupId), undefined).get();
204
+ }
205
+
206
+ function saveGroupState(groupId: string, expanded: boolean): void {
207
+ if (!persistState) return;
208
+ localStorageValue(getGroupStorageKey(groupId), expanded).set(expanded);
209
+ }
210
+
211
+ function loadItemState(itemId: string): boolean | undefined {
212
+ if (!persistState) return undefined;
213
+ return localStorageValue<boolean | undefined>(getItemStorageKey(itemId), undefined).get();
214
+ }
215
+
216
+ function saveItemState(itemId: string, expanded: boolean): void {
217
+ if (!persistState) return;
218
+ localStorageValue(getItemStorageKey(itemId), expanded).set(expanded);
219
+ }
220
+
221
+ // Track expanded state for each group
222
+ let groupExpandedStates = $state<boolean[]>([]);
223
+
224
+ // Initialize expanded states based on groups
225
+ $effect(() => {
226
+ const currentGroups = groups;
227
+ untrack(() => {
228
+ // Only initialize if length changed or states array is empty
229
+ if (groupExpandedStates.length !== currentGroups.length) {
230
+ groupExpandedStates = currentGroups.map((g) => {
231
+ // If group has an id, try to load from localStorage
232
+ if (g.id) {
233
+ const stored = loadGroupState(g.id);
234
+ if (stored !== undefined) return stored;
235
+ }
236
+ // Fall back to defaultCollapsed
237
+ return !g.defaultCollapsed;
238
+ });
239
+ }
240
+ });
241
+ });
242
+
243
+ // Check if group is expanded
244
+ function isGroupExpanded(index: number): boolean {
245
+ return groupExpandedStates[index] ?? true;
246
+ }
247
+
248
+ // Toggle group expand/collapse
249
+ function toggleGroup(index: number) {
250
+ groupExpandedStates[index] = !groupExpandedStates[index];
251
+
252
+ // Persist state if group has an id
253
+ const group = groups[index];
254
+ if (group?.id) {
255
+ saveGroupState(group.id, groupExpandedStates[index]);
256
+ }
257
+
258
+ onGroupToggle?.(index, groupExpandedStates[index]);
259
+ }
260
+
261
+ // Check if a group has items (and thus should be expandable)
262
+ function groupHasItems(group: NavGroup): boolean {
263
+ return (group.items?.length ?? 0) > 0;
264
+ }
265
+
266
+ // Check if an item has children
267
+ function itemHasChildren(item: NavItem): boolean {
268
+ return (item.children?.length ?? 0) > 0;
269
+ }
270
+
271
+ // Track expanded state for individual items with children
272
+ let itemExpandedStates = $state<Set<string>>(new Set());
273
+ let itemStatesInitialized = false;
274
+
275
+ // Collect all items with children recursively
276
+ function collectItemsWithChildren(items: NavItem[]): NavItem[] {
277
+ const result: NavItem[] = [];
278
+ for (const item of items) {
279
+ if (item.children?.length) {
280
+ result.push(item);
281
+ result.push(...collectItemsWithChildren(item.children));
282
+ }
283
+ }
284
+ return result;
285
+ }
286
+
287
+ // Initialize item expanded states from localStorage
288
+ $effect(() => {
289
+ const currentGroups = groups;
290
+ untrack(() => {
291
+ if (itemStatesInitialized) return;
292
+ itemStatesInitialized = true;
293
+
294
+ if (!persistState) return;
295
+
296
+ const expandedItems = new Set<string>();
297
+ for (const group of currentGroups) {
298
+ if (group.items) {
299
+ const itemsWithChildren = collectItemsWithChildren(group.items);
300
+ for (const item of itemsWithChildren) {
301
+ const stored = loadItemState(item.id);
302
+ if (stored === true) {
303
+ expandedItems.add(item.id);
304
+ }
305
+ }
306
+ }
307
+ }
308
+ if (expandedItems.size > 0) {
309
+ itemExpandedStates = expandedItems;
310
+ }
311
+ });
312
+ });
313
+
314
+ // Check if an item is expanded
315
+ function isItemExpanded(itemId: string): boolean {
316
+ return itemExpandedStates.has(itemId);
317
+ }
318
+
319
+ // Find item by ID in nested structure
320
+ function findItemById(items: NavItem[], id: string): NavItem | undefined {
321
+ for (const item of items) {
322
+ if (item.id === id) return item;
323
+ if (item.children) {
324
+ const found = findItemById(item.children, id);
325
+ if (found) return found;
326
+ }
327
+ }
328
+ return undefined;
329
+ }
330
+
331
+ // Find item across all groups
332
+ function findItemInGroups(itemId: string): NavItem | undefined {
333
+ for (const group of groups) {
334
+ if (group.items) {
335
+ const found = findItemById(group.items, itemId);
336
+ if (found) return found;
337
+ }
338
+ }
339
+ return undefined;
340
+ }
341
+
342
+ // Get all descendant IDs for collapsing
343
+ function getDescendantIds(item: NavItem): string[] {
344
+ const ids: string[] = [];
345
+ if (item.children) {
346
+ for (const child of item.children) {
347
+ ids.push(child.id);
348
+ ids.push(...getDescendantIds(child));
349
+ }
350
+ }
351
+ return ids;
352
+ }
353
+
354
+ // Toggle item expand/collapse
355
+ function toggleItem(itemId: string) {
356
+ if (itemExpandedStates.has(itemId)) {
357
+ // Collapse: also collapse all descendants
358
+ itemExpandedStates.delete(itemId);
359
+ saveItemState(itemId, false);
360
+ const item = findItemInGroups(itemId);
361
+ if (item) {
362
+ for (const id of getDescendantIds(item)) {
363
+ itemExpandedStates.delete(id);
364
+ saveItemState(id, false);
365
+ }
366
+ }
367
+ } else {
368
+ itemExpandedStates.add(itemId);
369
+ saveItemState(itemId, true);
370
+ }
371
+ itemExpandedStates = new Set(itemExpandedStates);
372
+ }
373
+
374
+ // Transition duration
375
+ let transitionDuration = $derived(reducedMotion.current ? 0 : 150);
376
+
377
+ // Resolve localized label
378
+ function resolveLabel(label: MaybeLocalized): string {
379
+ return tr(label, locale);
380
+ }
381
+
382
+ // Get first letter of a label for collapsed mode fallback
383
+ function getFirstLetter(label: string): string {
384
+ return label.charAt(0).toUpperCase();
385
+ }
386
+
387
+ // Check if item is currently active
388
+ function isItemActive(item: NavItem): boolean {
389
+ if (isActive) return isActive(item);
390
+ if (activeId) return item.id === activeId;
391
+ return false;
392
+ }
393
+
394
+ // Check if group (without items) is currently active
395
+ function isGroupItemActive(group: NavGroup): boolean {
396
+ if (isGroupActive) return isGroupActive(group);
397
+ if (activeId && group.id) return group.id === activeId;
398
+ return false;
399
+ }
400
+
401
+ // Handle item selection
402
+ function handleItemSelect(item: NavItem) {
403
+ if (item.disabled) return;
404
+ if (item.onClick) {
405
+ item.onClick();
406
+ }
407
+ onSelect?.(item);
408
+ }
409
+
410
+ // Handle group (without items) selection
411
+ function handleGroupSelect(group: NavGroup) {
412
+ if (group.onClick) {
413
+ group.onClick();
414
+ }
415
+ onGroupSelect?.(group);
416
+ }
417
+
418
+ // Generate group element ID
419
+ function groupElId(index: number): string {
420
+ return `${navId}-group-${index}`;
421
+ }
422
+
423
+ // Generate item element ID
424
+ function itemElId(groupIndex: number, itemId: string): string {
425
+ return `${navId}-group-${groupIndex}-item-${itemId}`;
426
+ }
427
+ </script>
428
+
429
+ <nav
430
+ bind:this={el}
431
+ class={twMerge(!unstyled && NAV_BASE_CLASSES, classProp)}
432
+ data-collapsed={!unstyled && isCollapsed ? "" : undefined}
433
+ data-expanding={!unstyled && isExpanding ? "" : undefined}
434
+ data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
435
+ {...rest}
436
+ >
437
+ <!-- Section title (optional, non-interactive) -->
438
+ {#if title}
439
+ <span
440
+ class={twMerge(
441
+ !unstyled && NAV_SECTION_TITLE_CLASSES,
442
+ "uppercase",
443
+ classTitle
444
+ )}
445
+ data-collapsed={!unstyled && isCollapsed ? "" : undefined}
446
+ >
447
+ {resolveLabel(title)}
448
+ </span>
449
+ {/if}
450
+
451
+ <!-- Render each group -->
452
+ {#each groups as group, groupIndex}
453
+ {@const hasItems = groupHasItems(group)}
454
+ {@const expanded = isGroupExpanded(groupIndex)}
455
+ {@const groupActive = !hasItems && isGroupItemActive(group)}
456
+
457
+ {#if hasItems}
458
+ <!-- Group with items: show expandable header -->
459
+ {#if !isCollapsed}
460
+ <button
461
+ type="button"
462
+ id={groupElId(groupIndex)}
463
+ class={twMerge(!unstyled && NAV_GROUP_TITLE_CLASSES, classGroupTitle)}
464
+ onclick={() => toggleGroup(groupIndex)}
465
+ aria-expanded={expanded}
466
+ data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
467
+ >
468
+ <span
469
+ class={twMerge(
470
+ "inline-block shrink-0 transition-transform duration-150",
471
+ expanded && "rotate-90",
472
+ classChevron
473
+ )}
474
+ >
475
+ {@html iconChevronRight({ size: isTouchFriendly ? 18 : 16 })}
476
+ </span>
477
+ {#if group.icon}
478
+ <span class={twMerge("shrink-0", classIcon)}>
479
+ <Thc thc={group.icon} />
480
+ </span>
481
+ {/if}
482
+ <span class={twMerge(classLabel)}>{resolveLabel(group.title)}</span>
483
+ </button>
484
+ {:else}
485
+ <!-- Collapsed mode: show only chevron -->
486
+ <button
487
+ type="button"
488
+ class={twMerge(!unstyled && NAV_ITEM_CLASSES, classItemCollapsed)}
489
+ onclick={() => toggleGroup(groupIndex)}
490
+ data-collapsed=""
491
+ data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
492
+ use:tooltip={() => ({
493
+ enabled: isCollapsed,
494
+ content: resolveLabel(group.title),
495
+ position: "right",
496
+ })}
497
+ >
498
+ <span
499
+ class={twMerge(
500
+ "inline-block shrink-0 transition-transform duration-150",
501
+ expanded && "rotate-90",
502
+ classChevron
503
+ )}
504
+ >
505
+ {@html iconChevronRight({ size: 16 })}
506
+ </span>
507
+ </button>
508
+ {/if}
509
+
510
+ <!-- Items -->
511
+ {#if expanded}
512
+ <ul
513
+ class={twMerge(!unstyled && NAV_CHILDREN_CLASSES, classChildren)}
514
+ aria-labelledby={groupElId(groupIndex)}
515
+ transition:slide={{ duration: transitionDuration }}
516
+ >
517
+ {#snippet renderItem(item: NavItem, depth: number)}
518
+ {@const hasChildren = itemHasChildren(item)}
519
+ {@const itemExpanded = hasChildren && isItemExpanded(item.id)}
520
+ {@const active = isItemActive(item)}
521
+ {@const label = resolveLabel(item.label)}
522
+ <li>
523
+ {#if hasChildren}
524
+ <!-- Parent with children: render as toggle button -->
525
+ <button
526
+ type="button"
527
+ id={itemElId(groupIndex, item.id)}
528
+ class={twMerge(
529
+ !unstyled && NAV_ITEM_CLASSES,
530
+ isCollapsed && classItemCollapsed,
531
+ active && classItemActive,
532
+ item.disabled && classItemDisabled,
533
+ item.class,
534
+ classItem
535
+ )}
536
+ onclick={() => toggleItem(item.id)}
537
+ disabled={item.disabled}
538
+ data-active={!unstyled && active ? "" : undefined}
539
+ data-collapsed={!unstyled && isCollapsed ? "" : undefined}
540
+ data-has-children=""
541
+ data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
542
+ aria-expanded={itemExpanded}
543
+ use:tooltip={() => ({
544
+ enabled: isCollapsed,
545
+ content: label,
546
+ position: "right",
547
+ })}
548
+ >
549
+ <!-- Chevron indicator -->
550
+ <span
551
+ class={twMerge(
552
+ "inline-block shrink-0 transition-transform duration-150",
553
+ itemExpanded && "rotate-90",
554
+ classChevron
555
+ )}
556
+ >
557
+ {@html iconChevronRight({ size: isTouchFriendly ? 18 : 16 })}
558
+ </span>
559
+ {#if item.icon && !isCollapsed}
560
+ <span class={twMerge("shrink-0", classIcon)}>
561
+ <Thc thc={item.icon} />
562
+ </span>
563
+ {/if}
564
+ {#if !isCollapsed}
565
+ <span class={classLabel}>{label}</span>
566
+ {/if}
567
+ </button>
568
+
569
+ <!-- Children (only shown when expanded) -->
570
+ {#if itemExpanded}
571
+ <ul
572
+ class={twMerge(!unstyled && NAV_CHILDREN_CLASSES, classChildren)}
573
+ transition:slide={{ duration: transitionDuration }}
574
+ >
575
+ {#each item.children ?? [] as child}
576
+ {@render renderItem(child, depth + 1)}
577
+ {/each}
578
+ </ul>
579
+ {/if}
580
+ {:else if item.href}
581
+ <!-- Leaf item with href -->
582
+ <a
583
+ id={itemElId(groupIndex, item.id)}
584
+ href={item.href}
585
+ class={twMerge(
586
+ !unstyled && NAV_ITEM_CLASSES,
587
+ isCollapsed && classItemCollapsed,
588
+ active && classItemActive,
589
+ item.disabled && classItemDisabled,
590
+ item.class,
591
+ classItem
592
+ )}
593
+ onclick={() => handleItemSelect(item)}
594
+ data-active={!unstyled && active ? "" : undefined}
595
+ data-collapsed={!unstyled && isCollapsed ? "" : undefined}
596
+ data-expanding={!unstyled && isExpanding ? "" : undefined}
597
+ data-disabled={!unstyled && item.disabled ? "" : undefined}
598
+ data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
599
+ aria-disabled={item.disabled}
600
+ tabindex={item.disabled ? -1 : 0}
601
+ use:tooltip={() => ({
602
+ enabled: isCollapsed,
603
+ content: label,
604
+ position: "right",
605
+ })}
606
+ >
607
+ {#if item.icon}
608
+ <span class={twMerge("shrink-0", classIcon)}>
609
+ <Thc thc={item.icon} />
610
+ </span>
611
+ {:else if isCollapsed}
612
+ <span class={twMerge("shrink-0 font-medium", classIcon)}>{getFirstLetter(label)}</span>
613
+ {/if}
614
+ {#if !isCollapsed}
615
+ <span class={classLabel}>{label}</span>
616
+ {/if}
617
+ </a>
618
+ {:else}
619
+ <!-- Leaf item with onClick only -->
620
+ <button
621
+ type="button"
622
+ id={itemElId(groupIndex, item.id)}
623
+ class={twMerge(
624
+ !unstyled && NAV_ITEM_CLASSES,
625
+ isCollapsed && classItemCollapsed,
626
+ active && classItemActive,
627
+ item.disabled && classItemDisabled,
628
+ item.class,
629
+ classItem
630
+ )}
631
+ onclick={() => handleItemSelect(item)}
632
+ disabled={item.disabled}
633
+ data-active={!unstyled && active ? "" : undefined}
634
+ data-collapsed={!unstyled && isCollapsed ? "" : undefined}
635
+ data-expanding={!unstyled && isExpanding ? "" : undefined}
636
+ data-disabled={!unstyled && item.disabled ? "" : undefined}
637
+ data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
638
+ use:tooltip={() => ({
639
+ enabled: isCollapsed,
640
+ content: label,
641
+ position: "right",
642
+ })}
643
+ >
644
+ {#if item.icon}
645
+ <span class={twMerge("shrink-0", classIcon)}>
646
+ <Thc thc={item.icon} />
647
+ </span>
648
+ {:else if isCollapsed}
649
+ <span class={twMerge("shrink-0 font-medium", classIcon)}>{getFirstLetter(label)}</span>
650
+ {/if}
651
+ {#if !isCollapsed}
652
+ <span class={classLabel}>{label}</span>
653
+ {/if}
654
+ </button>
655
+ {/if}
656
+ </li>
657
+ {/snippet}
658
+
659
+ {#each group.items ?? [] as item}
660
+ {@render renderItem(item, 0)}
661
+ {/each}
662
+ </ul>
663
+ {/if}
664
+ {:else}
665
+ <!-- Group without items: render as a simple nav item (no chevron) -->
666
+ {@const label = resolveLabel(group.title)}
667
+ {#if group.href}
668
+ <a
669
+ href={group.href}
670
+ class={twMerge(
671
+ !unstyled && NAV_ITEM_CLASSES,
672
+ isCollapsed && classItemCollapsed,
673
+ groupActive && classItemActive,
674
+ classItem
675
+ )}
676
+ onclick={() => handleGroupSelect(group)}
677
+ data-active={!unstyled && groupActive ? "" : undefined}
678
+ data-collapsed={!unstyled && isCollapsed ? "" : undefined}
679
+ data-expanding={!unstyled && isExpanding ? "" : undefined}
680
+ data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
681
+ use:tooltip={() => ({
682
+ enabled: isCollapsed,
683
+ content: label,
684
+ position: "right",
685
+ })}
686
+ >
687
+ {#if group.icon}
688
+ <span class={twMerge("shrink-0", classIcon)}>
689
+ <Thc thc={group.icon} />
690
+ </span>
691
+ {:else if isCollapsed}
692
+ <span class={twMerge("shrink-0 font-medium", classIcon)}>{getFirstLetter(label)}</span>
693
+ {/if}
694
+ {#if !isCollapsed}
695
+ <span class={classLabel}>{label}</span>
696
+ {/if}
697
+ </a>
698
+ {:else}
699
+ <button
700
+ type="button"
701
+ class={twMerge(
702
+ !unstyled && NAV_ITEM_CLASSES,
703
+ isCollapsed && classItemCollapsed,
704
+ groupActive && classItemActive,
705
+ classItem
706
+ )}
707
+ onclick={() => handleGroupSelect(group)}
708
+ data-active={!unstyled && groupActive ? "" : undefined}
709
+ data-collapsed={!unstyled && isCollapsed ? "" : undefined}
710
+ data-expanding={!unstyled && isExpanding ? "" : undefined}
711
+ data-touch-friendly={!unstyled && isTouchFriendly ? "" : undefined}
712
+ use:tooltip={() => ({
713
+ enabled: isCollapsed,
714
+ content: label,
715
+ position: "right",
716
+ })}
717
+ >
718
+ {#if group.icon}
719
+ <span class={twMerge("shrink-0", classIcon)}>
720
+ <Thc thc={group.icon} />
721
+ </span>
722
+ {:else if isCollapsed}
723
+ <span class={twMerge("shrink-0 font-medium", classIcon)}>{getFirstLetter(label)}</span>
724
+ {/if}
725
+ {#if !isCollapsed}
726
+ <span class={classLabel}>{label}</span>
727
+ {/if}
728
+ </button>
729
+ {/if}
730
+ {/if}
731
+ {/each}
732
+ </nav>