@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.
- package/dist/actions/index.d.ts +1 -0
- package/dist/actions/index.js +1 -0
- package/dist/actions/typeahead.svelte.d.ts +53 -0
- package/dist/actions/typeahead.svelte.js +328 -0
- package/dist/base.css +17 -0
- package/dist/components/AlertConfirmPrompt/AlertConfirmPrompt.svelte +4 -3
- package/dist/components/AlertConfirmPrompt/AlertConfirmPrompt.svelte.d.ts +4 -3
- package/dist/components/AlertConfirmPrompt/Current.svelte +1 -2
- package/dist/components/AlertConfirmPrompt/Current.svelte.d.ts +0 -1
- package/dist/components/AlertConfirmPrompt/index.css +47 -43
- package/dist/components/AssetsPreview/AssetsPreview.svelte +0 -1
- package/dist/components/AssetsPreview/AssetsPreview.svelte.d.ts +0 -1
- package/dist/components/AssetsPreview/index.css +31 -29
- package/dist/components/Avatar/Avatar.svelte +0 -1
- package/dist/components/Avatar/Avatar.svelte.d.ts +0 -1
- package/dist/components/Avatar/index.css +87 -85
- package/dist/components/Backdrop/Backdrop.svelte +0 -1
- package/dist/components/Backdrop/Backdrop.svelte.d.ts +0 -1
- package/dist/components/Backdrop/index.css +15 -13
- package/dist/components/Button/Button.svelte +0 -1
- package/dist/components/Button/Button.svelte.d.ts +0 -1
- package/dist/components/Button/index.css +431 -429
- package/dist/components/ButtonGroupRadio/ButtonGroupRadio.svelte +0 -1
- package/dist/components/ButtonGroupRadio/ButtonGroupRadio.svelte.d.ts +0 -1
- package/dist/components/ButtonGroupRadio/index.css +123 -117
- package/dist/components/Collapsible/index.css +17 -15
- package/dist/components/CommandMenu/CommandMenu.svelte +7 -4
- package/dist/components/CommandMenu/CommandMenu.svelte.d.ts +0 -1
- package/dist/components/CommandMenu/index.css +27 -25
- package/dist/components/DismissibleMessage/DismissibleMessage.svelte +0 -1
- package/dist/components/DismissibleMessage/DismissibleMessage.svelte.d.ts +0 -1
- package/dist/components/DismissibleMessage/index.css +116 -110
- package/dist/components/DropdownMenu/DropdownMenu.svelte +0 -1
- package/dist/components/DropdownMenu/DropdownMenu.svelte.d.ts +0 -1
- package/dist/components/DropdownMenu/index.css +179 -177
- package/dist/components/Input/FieldInput.svelte +8 -0
- package/dist/components/Input/FieldInput.svelte.d.ts +2 -0
- package/dist/components/Input/FieldOptions.svelte +1 -1
- package/dist/components/Input/index.css +411 -398
- package/dist/components/KbdShortcut/KbdShortcut.svelte +4 -12
- package/dist/components/KbdShortcut/README.md +34 -0
- package/dist/components/KbdShortcut/index.css +55 -0
- package/dist/components/ListItemButton/ListItemButton.svelte +0 -1
- package/dist/components/ListItemButton/ListItemButton.svelte.d.ts +0 -1
- package/dist/components/ListItemButton/index.css +118 -116
- package/dist/components/Modal/Modal.svelte +0 -1
- package/dist/components/Modal/Modal.svelte.d.ts +0 -1
- package/dist/components/Modal/index.css +18 -16
- package/dist/components/ModalDialog/index.css +29 -27
- package/dist/components/Nav/Nav.svelte +732 -0
- package/dist/components/Nav/Nav.svelte.d.ts +110 -0
- package/dist/components/Nav/README.md +334 -0
- package/dist/components/Nav/index.css +318 -0
- package/dist/components/Nav/index.d.ts +1 -0
- package/dist/components/Nav/index.js +1 -0
- package/dist/components/Notifications/Notifications.svelte +2 -3
- package/dist/components/Notifications/Notifications.svelte.d.ts +0 -1
- package/dist/components/Notifications/index.css +158 -158
- package/dist/components/Notifications/notifications-stack.svelte.d.ts +4 -0
- package/dist/components/Notifications/notifications-stack.svelte.js +8 -0
- package/dist/components/Progress/Progress.svelte +4 -2
- package/dist/components/Progress/Progress.svelte.d.ts +1 -0
- package/dist/components/Progress/README.md +86 -15
- package/dist/components/Progress/_internal/Bar.svelte +4 -15
- package/dist/components/Progress/_internal/Bar.svelte.d.ts +1 -1
- package/dist/components/Progress/_internal/Circle.svelte +30 -2
- package/dist/components/Progress/_internal/Circle.svelte.d.ts +1 -0
- package/dist/components/Progress/index.css +47 -1
- package/dist/components/Skeleton/README.md +152 -0
- package/dist/components/Skeleton/Skeleton.svelte +6 -7
- package/dist/components/Skeleton/Skeleton.svelte.d.ts +0 -1
- package/dist/components/Skeleton/index.css +73 -43
- package/dist/components/Spinner/README.md +149 -37
- package/dist/components/Spinner/Spinner.svelte +14 -38
- package/dist/components/Spinner/Spinner.svelte.d.ts +2 -1
- package/dist/components/Spinner/SpinnerCircle.svelte +6 -34
- package/dist/components/Spinner/SpinnerCircle.svelte.d.ts +1 -0
- package/dist/components/Spinner/SpinnerCircleOscillate.svelte +10 -5
- package/dist/components/Spinner/SpinnerUnicode.svelte +3 -1
- package/dist/components/Spinner/SpinnerUnicode.svelte.d.ts +1 -0
- package/dist/components/Spinner/index.css +104 -0
- package/dist/components/Switch/README.md +34 -18
- package/dist/components/Switch/Switch.svelte +24 -46
- package/dist/components/Switch/Switch.svelte.d.ts +4 -2
- package/dist/components/Switch/index.css +120 -2
- package/dist/components/Switch/index.d.ts +1 -2
- package/dist/components/Switch/index.js +1 -2
- package/dist/components/TabbedMenu/README.md +28 -17
- package/dist/components/TabbedMenu/TabbedMenu.svelte +5 -46
- package/dist/components/TabbedMenu/TabbedMenu.svelte.d.ts +0 -1
- package/dist/components/TabbedMenu/index.css +85 -3
- package/dist/components/ThemePreview/ThemePreview.svelte +76 -23
- package/dist/components/ThemePreview/ThemePreview.svelte.d.ts +3 -1
- package/dist/components/ThemePreview/index.css +17 -1
- package/dist/components/TwCheck/README.md +32 -13
- package/dist/components/TwCheck/TwCheck.svelte +11 -9
- package/dist/components/TwCheck/TwCheck.svelte.d.ts +0 -1
- package/dist/components/TwCheck/index.css +14 -0
- package/dist/components/TypeaheadInput/TypeaheadInput.svelte +19 -187
- package/dist/components/TypeaheadInput/TypeaheadInput.svelte.d.ts +4 -2
- package/dist/index.css +44 -39
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/themes/blue-orange.css +202 -148
- package/dist/themes/blue-orange.js +24 -0
- package/dist/themes/cyan-red.css +202 -148
- package/dist/themes/cyan-red.js +24 -0
- package/dist/themes/cyan-slate.css +202 -148
- package/dist/themes/cyan-slate.js +25 -1
- package/dist/themes/emerald-pink.css +202 -148
- package/dist/themes/emerald-pink.js +25 -1
- package/dist/themes/fuchsia-emerald.css +202 -148
- package/dist/themes/fuchsia-emerald.js +25 -1
- package/dist/themes/gray.css +202 -148
- package/dist/themes/gray.js +24 -0
- package/dist/themes/indigo-amber.css +202 -148
- package/dist/themes/indigo-amber.js +26 -2
- package/dist/themes/neutral.css +202 -148
- package/dist/themes/neutral.js +24 -0
- package/dist/themes/pink-emerald.css +202 -148
- package/dist/themes/pink-emerald.js +25 -1
- package/dist/themes/purple-yellow.css +202 -148
- package/dist/themes/purple-yellow.js +24 -0
- package/dist/themes/rainbow.css +202 -148
- package/dist/themes/rainbow.js +25 -1
- package/dist/themes/red-blue.css +202 -148
- package/dist/themes/red-blue.js +24 -0
- package/dist/themes/red-cyan.css +202 -148
- package/dist/themes/red-cyan.js +24 -0
- package/dist/themes/rose-teal.css +202 -148
- package/dist/themes/rose-teal.js +24 -0
- package/dist/themes/sky-amber.css +202 -148
- package/dist/themes/sky-amber.js +26 -2
- package/dist/themes/slate-cyan.css +202 -148
- package/dist/themes/slate-cyan.js +25 -1
- package/dist/themes/teal-rose.css +202 -148
- package/dist/themes/teal-rose.js +24 -0
- package/dist/themes/violet-lime.css +202 -148
- package/dist/themes/violet-lime.js +27 -3
- package/dist/utils/design-tokens.js +29 -2
- package/dist/utils/storage-abstraction.js +1 -1
- package/package.json +11 -28
- package/dist/components/Switch/SwitchButton.svelte +0 -134
- 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>
|