@miozu/jera 0.3.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +350 -59
- package/README.md +30 -22
- package/llms.txt +37 -4
- package/package.json +12 -2
- package/src/components/docs/CodeBlock.svelte +203 -0
- package/src/components/docs/DocSection.svelte +120 -0
- package/src/components/docs/PropsTable.svelte +136 -0
- package/src/components/docs/SplitPane.svelte +98 -0
- package/src/components/docs/index.js +14 -0
- package/src/components/feedback/Alert.svelte +234 -0
- package/src/components/feedback/EmptyState.svelte +6 -6
- package/src/components/feedback/ProgressBar.svelte +8 -8
- package/src/components/feedback/Skeleton.svelte +4 -4
- package/src/components/feedback/Spinner.svelte +1 -1
- package/src/components/feedback/Toast.svelte +137 -173
- package/src/components/forms/Checkbox.svelte +10 -10
- package/src/components/forms/Dropzone.svelte +14 -14
- package/src/components/forms/FileUpload.svelte +16 -16
- package/src/components/forms/IconInput.svelte +4 -4
- package/src/components/forms/Input.svelte +14 -14
- package/src/components/forms/NumberInput.svelte +13 -13
- package/src/components/forms/PinInput.svelte +8 -8
- package/src/components/forms/Radio.svelte +8 -8
- package/src/components/forms/RangeSlider.svelte +12 -12
- package/src/components/forms/SearchInput.svelte +10 -10
- package/src/components/forms/Select.svelte +156 -158
- package/src/components/forms/Switch.svelte +4 -4
- package/src/components/forms/Textarea.svelte +9 -9
- package/src/components/navigation/Accordion.svelte +1 -1
- package/src/components/navigation/AccordionItem.svelte +6 -6
- package/src/components/navigation/NavigationContainer.svelte +344 -0
- package/src/components/navigation/Sidebar.svelte +334 -0
- package/src/components/navigation/SidebarAccountGroup.svelte +495 -0
- package/src/components/navigation/SidebarAccountItem.svelte +492 -0
- package/src/components/navigation/SidebarGroup.svelte +230 -0
- package/src/components/navigation/SidebarGroupSwitcher.svelte +262 -0
- package/src/components/navigation/SidebarItem.svelte +210 -0
- package/src/components/navigation/SidebarNavigationItem.svelte +470 -0
- package/src/components/navigation/SidebarPopover.svelte +145 -0
- package/src/components/navigation/SidebarSearch.svelte +236 -0
- package/src/components/navigation/SidebarSection.svelte +158 -0
- package/src/components/navigation/SidebarToggle.svelte +86 -0
- package/src/components/navigation/Tabs.svelte +18 -18
- package/src/components/navigation/WorkspaceMenu.svelte +416 -0
- package/src/components/navigation/blocks/NavigationAccountGroup.svelte +396 -0
- package/src/components/navigation/blocks/NavigationCustomBlock.svelte +74 -0
- package/src/components/navigation/blocks/NavigationGroupSwitcher.svelte +277 -0
- package/src/components/navigation/blocks/NavigationSearch.svelte +300 -0
- package/src/components/navigation/blocks/NavigationSection.svelte +230 -0
- package/src/components/navigation/index.js +22 -0
- package/src/components/overlays/ConfirmDialog.svelte +18 -18
- package/src/components/overlays/Dropdown.svelte +2 -2
- package/src/components/overlays/DropdownDivider.svelte +1 -1
- package/src/components/overlays/DropdownItem.svelte +5 -5
- package/src/components/overlays/Modal.svelte +13 -13
- package/src/components/overlays/Popover.svelte +3 -3
- package/src/components/primitives/Avatar.svelte +12 -12
- package/src/components/primitives/Badge.svelte +7 -7
- package/src/components/primitives/Button.svelte +126 -174
- package/src/components/primitives/Card.svelte +15 -15
- package/src/components/primitives/Divider.svelte +3 -3
- package/src/components/primitives/LazyImage.svelte +1 -1
- package/src/components/primitives/Link.svelte +2 -2
- package/src/components/primitives/Stat.svelte +197 -0
- package/src/components/primitives/StatusBadge.svelte +24 -24
- package/src/index.js +62 -7
- package/src/tokens/colors.css +96 -128
- package/src/utils/highlighter.js +124 -0
- package/src/utils/index.js +7 -2
- package/src/utils/navigation.svelte.js +423 -0
- package/src/utils/reactive.svelte.js +126 -37
- package/src/utils/sidebar.svelte.js +211 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NavigationState - Enterprise-grade reactive state management for navigation
|
|
3
|
+
*
|
|
4
|
+
* Provides centralized state management for complex navigation scenarios including:
|
|
5
|
+
* - Multi-level recursive navigation
|
|
6
|
+
* - Search and filtering across all items
|
|
7
|
+
* - Expansion state management
|
|
8
|
+
* - Active state detection
|
|
9
|
+
* - Breadcrumb generation
|
|
10
|
+
* - Permission-based visibility
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const nav = new NavigationState({
|
|
14
|
+
* items: navigationConfig,
|
|
15
|
+
* persistKey: 'my-app-nav',
|
|
16
|
+
* searchable: true,
|
|
17
|
+
* recursive: true
|
|
18
|
+
* });
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { browser } from '$app/environment';
|
|
22
|
+
|
|
23
|
+
export class NavigationState {
|
|
24
|
+
// Core reactive state
|
|
25
|
+
items = $state([]);
|
|
26
|
+
searchQuery = $state('');
|
|
27
|
+
expandedSections = $state({});
|
|
28
|
+
expandedItems = $state({});
|
|
29
|
+
activeItem = $state(null);
|
|
30
|
+
activePath = $state([]);
|
|
31
|
+
|
|
32
|
+
// Configuration
|
|
33
|
+
config = $state({
|
|
34
|
+
persistKey: null,
|
|
35
|
+
searchable: true,
|
|
36
|
+
recursive: true,
|
|
37
|
+
maxDepth: 10,
|
|
38
|
+
autoExpand: false,
|
|
39
|
+
caseSensitiveSearch: false
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Computed properties
|
|
43
|
+
filteredItems = $derived.by(() => this.#filterItems(this.items, this.searchQuery));
|
|
44
|
+
breadcrumbs = $derived.by(() => this.#generateBreadcrumbs(this.activeItem));
|
|
45
|
+
totalItemCount = $derived.by(() => this.#countItems(this.items));
|
|
46
|
+
visibleItemCount = $derived.by(() => this.#countItems(this.filteredItems));
|
|
47
|
+
|
|
48
|
+
constructor(options = {}) {
|
|
49
|
+
// Apply configuration
|
|
50
|
+
this.config = { ...this.config, ...options };
|
|
51
|
+
|
|
52
|
+
if (options.items) {
|
|
53
|
+
this.items = this.#normalizeItems(options.items);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Load persisted state
|
|
57
|
+
if (this.config.persistKey && browser) {
|
|
58
|
+
this.#loadPersistedState();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Auto-save state when it changes
|
|
62
|
+
if (this.config.persistKey && browser) {
|
|
63
|
+
$effect(() => {
|
|
64
|
+
this.#savePersistedState();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Update navigation items with intelligent merging
|
|
71
|
+
*/
|
|
72
|
+
setItems(items) {
|
|
73
|
+
this.items = this.#normalizeItems(items);
|
|
74
|
+
|
|
75
|
+
// Auto-expand if configured
|
|
76
|
+
if (this.config.autoExpand) {
|
|
77
|
+
this.#autoExpandItems();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Set active item by ID or path
|
|
83
|
+
*/
|
|
84
|
+
setActive(identifier, shouldExpand = true) {
|
|
85
|
+
const item = typeof identifier === 'string'
|
|
86
|
+
? this.#findItemById(identifier)
|
|
87
|
+
: identifier;
|
|
88
|
+
|
|
89
|
+
if (item) {
|
|
90
|
+
this.activeItem = item;
|
|
91
|
+
this.activePath = this.#getItemPath(item);
|
|
92
|
+
|
|
93
|
+
if (shouldExpand) {
|
|
94
|
+
this.#expandToItem(item);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Search navigation items
|
|
101
|
+
*/
|
|
102
|
+
search(query) {
|
|
103
|
+
this.searchQuery = query;
|
|
104
|
+
|
|
105
|
+
// Auto-expand search results
|
|
106
|
+
if (query && this.config.autoExpand) {
|
|
107
|
+
this.#expandSearchResults();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Clear search and collapse expanded results
|
|
113
|
+
*/
|
|
114
|
+
clearSearch() {
|
|
115
|
+
this.searchQuery = '';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Toggle section expansion
|
|
120
|
+
*/
|
|
121
|
+
toggleSection(sectionId) {
|
|
122
|
+
this.expandedSections[sectionId] = !this.expandedSections[sectionId];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Toggle item expansion (for recursive items)
|
|
127
|
+
*/
|
|
128
|
+
toggleItem(itemId) {
|
|
129
|
+
this.expandedItems[itemId] = !this.expandedItems[itemId];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Expand all sections and items
|
|
134
|
+
*/
|
|
135
|
+
expandAll() {
|
|
136
|
+
this.#walkItems(this.items, (item) => {
|
|
137
|
+
if (item.children?.length > 0) {
|
|
138
|
+
this.expandedItems[item.id] = true;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Expand all sections
|
|
143
|
+
Object.keys(this.expandedSections).forEach(key => {
|
|
144
|
+
this.expandedSections[key] = true;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Collapse all sections and items
|
|
150
|
+
*/
|
|
151
|
+
collapseAll() {
|
|
152
|
+
this.expandedItems = {};
|
|
153
|
+
Object.keys(this.expandedSections).forEach(key => {
|
|
154
|
+
this.expandedSections[key] = false;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get navigation item by ID
|
|
160
|
+
*/
|
|
161
|
+
getItem(id) {
|
|
162
|
+
return this.#findItemById(id);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Get all children of an item
|
|
167
|
+
*/
|
|
168
|
+
getChildren(itemId) {
|
|
169
|
+
const item = this.#findItemById(itemId);
|
|
170
|
+
return item?.children || [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Get parent of an item
|
|
175
|
+
*/
|
|
176
|
+
getParent(itemId) {
|
|
177
|
+
return this.#findParentItem(itemId);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get path to root for an item
|
|
182
|
+
*/
|
|
183
|
+
getPath(itemId) {
|
|
184
|
+
const item = this.#findItemById(itemId);
|
|
185
|
+
return item ? this.#getItemPath(item) : [];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Check if item is visible based on permissions
|
|
190
|
+
*/
|
|
191
|
+
isVisible(item) {
|
|
192
|
+
if (!item.permissions) return true;
|
|
193
|
+
|
|
194
|
+
// Override in subclass or provide permissions checker
|
|
195
|
+
return this.config.permissionChecker
|
|
196
|
+
? this.config.permissionChecker(item.permissions)
|
|
197
|
+
: true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Check if item is active
|
|
202
|
+
*/
|
|
203
|
+
isActive(item) {
|
|
204
|
+
return this.activeItem?.id === item.id;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Check if item is in active path
|
|
209
|
+
*/
|
|
210
|
+
isInActivePath(item) {
|
|
211
|
+
return this.activePath.some(pathItem => pathItem.id === item.id);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Check if section is expanded
|
|
216
|
+
*/
|
|
217
|
+
isSectionExpanded(sectionId) {
|
|
218
|
+
return this.expandedSections[sectionId] ?? false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Check if item is expanded
|
|
223
|
+
*/
|
|
224
|
+
isItemExpanded(itemId) {
|
|
225
|
+
return this.expandedItems[itemId] ?? false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Private methods
|
|
229
|
+
#normalizeItems(items) {
|
|
230
|
+
const normalized = [];
|
|
231
|
+
|
|
232
|
+
for (let i = 0; i < items.length; i++) {
|
|
233
|
+
const item = items[i];
|
|
234
|
+
const normalizedItem = {
|
|
235
|
+
id: item.id || `item-${i}`,
|
|
236
|
+
label: item.label || '',
|
|
237
|
+
href: item.href || null,
|
|
238
|
+
icon: item.icon || null,
|
|
239
|
+
badge: item.badge || null,
|
|
240
|
+
children: item.children ? this.#normalizeItems(item.children) : [],
|
|
241
|
+
permissions: item.permissions || null,
|
|
242
|
+
metadata: item.metadata || {},
|
|
243
|
+
depth: item.depth || 0,
|
|
244
|
+
parent: item.parent || null,
|
|
245
|
+
...item
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// Set parent references for children
|
|
249
|
+
normalizedItem.children.forEach(child => {
|
|
250
|
+
child.parent = normalizedItem.id;
|
|
251
|
+
child.depth = normalizedItem.depth + 1;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
normalized.push(normalizedItem);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return normalized;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
#filterItems(items, query) {
|
|
261
|
+
if (!query) return items;
|
|
262
|
+
|
|
263
|
+
const filtered = [];
|
|
264
|
+
const searchLower = this.config.caseSensitiveSearch ? query : query.toLowerCase();
|
|
265
|
+
|
|
266
|
+
for (const item of items) {
|
|
267
|
+
const matches = this.#itemMatches(item, searchLower);
|
|
268
|
+
const filteredChildren = this.#filterItems(item.children || [], query);
|
|
269
|
+
|
|
270
|
+
if (matches || filteredChildren.length > 0) {
|
|
271
|
+
filtered.push({
|
|
272
|
+
...item,
|
|
273
|
+
children: filteredChildren,
|
|
274
|
+
_matchesSearch: matches
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return filtered;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#itemMatches(item, searchQuery) {
|
|
283
|
+
const searchFields = [
|
|
284
|
+
item.label,
|
|
285
|
+
item.description,
|
|
286
|
+
item.metadata?.tags?.join(' '),
|
|
287
|
+
item.metadata?.keywords?.join(' ')
|
|
288
|
+
].filter(Boolean);
|
|
289
|
+
|
|
290
|
+
const searchText = this.config.caseSensitiveSearch
|
|
291
|
+
? searchFields.join(' ')
|
|
292
|
+
: searchFields.join(' ').toLowerCase();
|
|
293
|
+
|
|
294
|
+
return searchText.includes(searchQuery);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#findItemById(id, items = this.items) {
|
|
298
|
+
for (const item of items) {
|
|
299
|
+
if (item.id === id) return item;
|
|
300
|
+
|
|
301
|
+
if (item.children) {
|
|
302
|
+
const found = this.#findItemById(id, item.children);
|
|
303
|
+
if (found) return found;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#findParentItem(itemId, items = this.items, parent = null) {
|
|
310
|
+
for (const item of items) {
|
|
311
|
+
if (item.id === itemId) return parent;
|
|
312
|
+
|
|
313
|
+
if (item.children) {
|
|
314
|
+
const found = this.#findParentItem(itemId, item.children, item);
|
|
315
|
+
if (found) return found;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#getItemPath(item) {
|
|
322
|
+
const path = [];
|
|
323
|
+
let current = item;
|
|
324
|
+
|
|
325
|
+
while (current) {
|
|
326
|
+
path.unshift(current);
|
|
327
|
+
current = current.parent ? this.#findItemById(current.parent) : null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return path;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
#expandToItem(item) {
|
|
334
|
+
const path = this.#getItemPath(item);
|
|
335
|
+
|
|
336
|
+
path.forEach(pathItem => {
|
|
337
|
+
if (pathItem.parent) {
|
|
338
|
+
this.expandedItems[pathItem.parent] = true;
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
#expandSearchResults() {
|
|
344
|
+
this.#walkItems(this.filteredItems, (item) => {
|
|
345
|
+
if (item._matchesSearch && item.parent) {
|
|
346
|
+
this.expandedItems[item.parent] = true;
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#autoExpandItems() {
|
|
352
|
+
this.#walkItems(this.items, (item) => {
|
|
353
|
+
if (item.children?.length > 0) {
|
|
354
|
+
this.expandedItems[item.id] = true;
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
#walkItems(items, callback) {
|
|
360
|
+
for (const item of items) {
|
|
361
|
+
callback(item);
|
|
362
|
+
if (item.children) {
|
|
363
|
+
this.#walkItems(item.children, callback);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#countItems(items) {
|
|
369
|
+
let count = 0;
|
|
370
|
+
this.#walkItems(items, () => count++);
|
|
371
|
+
return count;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#generateBreadcrumbs(activeItem) {
|
|
375
|
+
if (!activeItem) return [];
|
|
376
|
+
return this.#getItemPath(activeItem);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
#loadPersistedState() {
|
|
380
|
+
try {
|
|
381
|
+
const saved = localStorage.getItem(this.config.persistKey);
|
|
382
|
+
if (saved) {
|
|
383
|
+
const state = JSON.parse(saved);
|
|
384
|
+
this.expandedSections = state.expandedSections || {};
|
|
385
|
+
this.expandedItems = state.expandedItems || {};
|
|
386
|
+
this.searchQuery = state.searchQuery || '';
|
|
387
|
+
}
|
|
388
|
+
} catch (error) {
|
|
389
|
+
console.warn('Failed to load navigation state:', error);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
#savePersistedState() {
|
|
394
|
+
try {
|
|
395
|
+
const state = {
|
|
396
|
+
expandedSections: this.expandedSections,
|
|
397
|
+
expandedItems: this.expandedItems,
|
|
398
|
+
searchQuery: this.searchQuery
|
|
399
|
+
};
|
|
400
|
+
localStorage.setItem(this.config.persistKey, JSON.stringify(state));
|
|
401
|
+
} catch (error) {
|
|
402
|
+
console.warn('Failed to save navigation state:', error);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Create a navigation state instance with common defaults
|
|
409
|
+
*/
|
|
410
|
+
export function createNavigationState(options = {}) {
|
|
411
|
+
return new NavigationState({
|
|
412
|
+
searchable: true,
|
|
413
|
+
recursive: true,
|
|
414
|
+
maxDepth: 10,
|
|
415
|
+
autoExpand: false,
|
|
416
|
+
...options
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Navigation context key for Svelte context
|
|
422
|
+
*/
|
|
423
|
+
export const NAVIGATION_CONTEXT_KEY = 'navigation-state';
|
|
@@ -5,8 +5,6 @@
|
|
|
5
5
|
* Uses Svelte 5 runes ($state, $derived, $effect) internally.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { getContext, setContext, onMount } from 'svelte';
|
|
9
|
-
|
|
10
8
|
/**
|
|
11
9
|
* Create a reactive store-like object with Svelte 5 runes
|
|
12
10
|
* Unlike stores, these work seamlessly with runes and don't need $ prefix
|
|
@@ -58,15 +56,20 @@ export function createDerived(fn) {
|
|
|
58
56
|
}
|
|
59
57
|
|
|
60
58
|
/**
|
|
61
|
-
* Theme
|
|
62
|
-
*/
|
|
63
|
-
const THEME_KEY = Symbol('jera-theme');
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Theme Reactive State Class
|
|
59
|
+
* Theme Reactive State Class (Singleton Pattern)
|
|
67
60
|
*
|
|
68
61
|
* Manages theme state with persistence and SSR support.
|
|
69
|
-
* Uses
|
|
62
|
+
* Uses singleton pattern for global app-level state.
|
|
63
|
+
*
|
|
64
|
+
* Storage key: 'miozu-theme'
|
|
65
|
+
* Data-theme values: 'miozu-dark' | 'miozu-light'
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* import { getTheme } from '@miozu/jera';
|
|
69
|
+
* const theme = getTheme();
|
|
70
|
+
* theme.init(); // Call once in root layout onMount
|
|
71
|
+
* theme.toggle(); // Toggle dark/light
|
|
72
|
+
* theme.set('system'); // Use system preference
|
|
70
73
|
*/
|
|
71
74
|
export class ThemeState {
|
|
72
75
|
/** @type {'light' | 'dark' | 'system'} */
|
|
@@ -75,8 +78,23 @@ export class ThemeState {
|
|
|
75
78
|
/** @type {'light' | 'dark'} */
|
|
76
79
|
resolved = $derived.by(() => this.#resolveTheme());
|
|
77
80
|
|
|
81
|
+
/** @type {'miozu-light' | 'miozu-dark'} */
|
|
82
|
+
dataTheme = $derived.by(() => this.resolved === 'dark' ? 'miozu-dark' : 'miozu-light');
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Alias for dataTheme - backwards compatibility
|
|
86
|
+
* @type {'miozu-light' | 'miozu-dark'}
|
|
87
|
+
*/
|
|
88
|
+
currentTheme = $derived.by(() => this.dataTheme);
|
|
89
|
+
|
|
78
90
|
/** @type {boolean} */
|
|
79
|
-
|
|
91
|
+
isDark = $derived.by(() => this.resolved === 'dark');
|
|
92
|
+
|
|
93
|
+
/** @type {boolean} */
|
|
94
|
+
isLight = $derived.by(() => this.resolved === 'light');
|
|
95
|
+
|
|
96
|
+
/** @type {boolean} */
|
|
97
|
+
#initialized = false;
|
|
80
98
|
|
|
81
99
|
/** @type {MediaQueryList | null} */
|
|
82
100
|
#mediaQuery = null;
|
|
@@ -84,100 +102,171 @@ export class ThemeState {
|
|
|
84
102
|
/** @type {(() => void) | null} */
|
|
85
103
|
#mediaQueryHandler = null;
|
|
86
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Parse theme from cookie string (for SSR)
|
|
107
|
+
* @param {string | null} cookieString - Raw cookie header
|
|
108
|
+
* @returns {'miozu-light' | 'miozu-dark'} - Theme value for data-theme attribute
|
|
109
|
+
*/
|
|
110
|
+
static getThemeFromCookieString(cookieString) {
|
|
111
|
+
if (!cookieString) return 'miozu-dark';
|
|
112
|
+
|
|
113
|
+
const match = cookieString.match(/miozu-theme=([^;]+)/);
|
|
114
|
+
const preference = match ? match[1] : null;
|
|
115
|
+
|
|
116
|
+
if (preference === 'light') return 'miozu-light';
|
|
117
|
+
if (preference === 'dark') return 'miozu-dark';
|
|
118
|
+
// 'system' or invalid - default to dark
|
|
119
|
+
return 'miozu-dark';
|
|
120
|
+
}
|
|
121
|
+
|
|
87
122
|
constructor(initial = 'system') {
|
|
88
123
|
this.current = initial;
|
|
89
124
|
}
|
|
90
125
|
|
|
91
126
|
#resolveTheme() {
|
|
92
127
|
if (this.current === 'system') {
|
|
93
|
-
// SSR-safe: default to
|
|
94
|
-
if (typeof window === 'undefined') return '
|
|
128
|
+
// SSR-safe: default to dark if no window (miozu default)
|
|
129
|
+
if (typeof window === 'undefined') return 'dark';
|
|
95
130
|
return this.#mediaQuery?.matches ? 'dark' : 'light';
|
|
96
131
|
}
|
|
97
132
|
return this.current;
|
|
98
133
|
}
|
|
99
134
|
|
|
100
135
|
/**
|
|
101
|
-
* Initialize theme - call in onMount
|
|
136
|
+
* Initialize theme - call once in root layout onMount
|
|
137
|
+
* Loads from storage and sets up system preference listener
|
|
102
138
|
*/
|
|
103
139
|
init() {
|
|
104
140
|
if (typeof window === 'undefined') return;
|
|
141
|
+
if (this.#initialized) return; // Prevent double init
|
|
105
142
|
|
|
106
143
|
// Load from storage
|
|
107
|
-
const stored = localStorage.getItem('
|
|
144
|
+
const stored = localStorage.getItem('miozu-theme');
|
|
108
145
|
if (stored && ['light', 'dark', 'system'].includes(stored)) {
|
|
109
146
|
this.current = stored;
|
|
110
147
|
}
|
|
111
148
|
|
|
112
|
-
// Setup media query listener
|
|
149
|
+
// Setup media query listener for system preference
|
|
113
150
|
this.#mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
114
151
|
this.#mediaQueryHandler = () => {
|
|
115
|
-
//
|
|
116
|
-
this.current
|
|
152
|
+
// Force re-resolution when system preference changes
|
|
153
|
+
if (this.current === 'system') {
|
|
154
|
+
this.#apply();
|
|
155
|
+
}
|
|
117
156
|
};
|
|
118
157
|
this.#mediaQuery.addEventListener('change', this.#mediaQueryHandler);
|
|
119
158
|
|
|
120
|
-
this.#
|
|
159
|
+
this.#initialized = true;
|
|
121
160
|
this.#apply();
|
|
122
161
|
}
|
|
123
162
|
|
|
124
163
|
/**
|
|
125
|
-
* Cleanup
|
|
164
|
+
* Cleanup media query listener
|
|
165
|
+
* Call in onDestroy if needed (usually not necessary for singleton)
|
|
126
166
|
*/
|
|
127
167
|
cleanup() {
|
|
128
168
|
if (this.#mediaQuery && this.#mediaQueryHandler) {
|
|
129
169
|
this.#mediaQuery.removeEventListener('change', this.#mediaQueryHandler);
|
|
130
170
|
this.#mediaQueryHandler = null;
|
|
131
171
|
}
|
|
132
|
-
this.#
|
|
172
|
+
this.#initialized = false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Sync state with current DOM attribute (for hydration)
|
|
177
|
+
* Call after SSR to ensure state matches what app.html set
|
|
178
|
+
*/
|
|
179
|
+
sync() {
|
|
180
|
+
if (typeof document === 'undefined') return;
|
|
181
|
+
|
|
182
|
+
const domTheme = document.documentElement.getAttribute('data-theme');
|
|
183
|
+
if (domTheme === 'miozu-dark') {
|
|
184
|
+
this.current = 'dark';
|
|
185
|
+
} else if (domTheme === 'miozu-light') {
|
|
186
|
+
this.current = 'light';
|
|
187
|
+
}
|
|
133
188
|
}
|
|
134
189
|
|
|
135
190
|
/**
|
|
136
|
-
* Set theme and persist
|
|
191
|
+
* Set theme preference and persist
|
|
137
192
|
* @param {'light' | 'dark' | 'system'} theme
|
|
138
193
|
*/
|
|
139
194
|
set(theme) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
document.cookie = `jera-theme=${theme};path=/;max-age=31536000`;
|
|
195
|
+
if (!['light', 'dark', 'system'].includes(theme)) {
|
|
196
|
+
console.warn(`Invalid theme: ${theme}`);
|
|
197
|
+
return;
|
|
144
198
|
}
|
|
199
|
+
this.current = theme;
|
|
200
|
+
this.#persist();
|
|
145
201
|
this.#apply();
|
|
146
202
|
}
|
|
147
203
|
|
|
148
204
|
/**
|
|
149
|
-
* Toggle between light and dark
|
|
205
|
+
* Toggle between light and dark (skips system)
|
|
150
206
|
*/
|
|
151
207
|
toggle() {
|
|
152
208
|
this.set(this.resolved === 'light' ? 'dark' : 'light');
|
|
153
209
|
}
|
|
154
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Persist to localStorage and cookie
|
|
213
|
+
*/
|
|
214
|
+
#persist() {
|
|
215
|
+
if (typeof window === 'undefined') return;
|
|
216
|
+
localStorage.setItem('miozu-theme', this.current);
|
|
217
|
+
document.cookie = `miozu-theme=${this.current};path=/;max-age=31536000;SameSite=Lax`;
|
|
218
|
+
}
|
|
219
|
+
|
|
155
220
|
/**
|
|
156
221
|
* Apply theme to document
|
|
157
222
|
*/
|
|
158
223
|
#apply() {
|
|
159
224
|
if (typeof document === 'undefined') return;
|
|
160
|
-
document.documentElement.setAttribute('data-theme', this.
|
|
225
|
+
document.documentElement.setAttribute('data-theme', this.dataTheme);
|
|
226
|
+
document.documentElement.style.colorScheme = this.resolved;
|
|
161
227
|
}
|
|
162
228
|
}
|
|
163
229
|
|
|
230
|
+
// ============================================
|
|
231
|
+
// SINGLETON INSTANCE
|
|
232
|
+
// ============================================
|
|
233
|
+
|
|
234
|
+
/** @type {ThemeState | null} */
|
|
235
|
+
let themeInstance = null;
|
|
236
|
+
|
|
164
237
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
238
|
+
* Get the global theme singleton
|
|
239
|
+
* Creates instance on first call (lazy initialization)
|
|
240
|
+
*
|
|
241
|
+
* @param {'light' | 'dark' | 'system'} [initial] - Initial theme (only used on first call)
|
|
167
242
|
* @returns {ThemeState}
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* // In root +layout.svelte
|
|
246
|
+
* import { getTheme } from '@miozu/jera';
|
|
247
|
+
* const theme = getTheme();
|
|
248
|
+
* onMount(() => theme.init());
|
|
249
|
+
*
|
|
250
|
+
* // Anywhere else
|
|
251
|
+
* import { getTheme } from '@miozu/jera';
|
|
252
|
+
* const theme = getTheme();
|
|
253
|
+
* theme.toggle();
|
|
168
254
|
*/
|
|
169
|
-
export function
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
255
|
+
export function getTheme(initial = 'system') {
|
|
256
|
+
if (!themeInstance) {
|
|
257
|
+
themeInstance = new ThemeState(initial);
|
|
258
|
+
}
|
|
259
|
+
return themeInstance;
|
|
173
260
|
}
|
|
174
261
|
|
|
175
262
|
/**
|
|
176
|
-
*
|
|
177
|
-
* @returns {ThemeState}
|
|
263
|
+
* Reset theme singleton (for testing)
|
|
178
264
|
*/
|
|
179
|
-
export function
|
|
180
|
-
|
|
265
|
+
export function resetTheme() {
|
|
266
|
+
if (themeInstance) {
|
|
267
|
+
themeInstance.cleanup();
|
|
268
|
+
themeInstance = null;
|
|
269
|
+
}
|
|
181
270
|
}
|
|
182
271
|
|
|
183
272
|
/**
|