@shohojdhara/atomix 0.2.8 → 0.2.9

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 (43) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +40 -1
  3. package/dist/atomix.css +96 -39
  4. package/dist/atomix.min.css +2 -2
  5. package/dist/index.d.ts +627 -2
  6. package/dist/index.esm.js +1292 -89
  7. package/dist/index.esm.js.map +1 -1
  8. package/dist/index.js +1316 -88
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.min.js +1 -1
  11. package/dist/index.min.js.map +1 -1
  12. package/dist/themes/applemix.css +96 -39
  13. package/dist/themes/applemix.min.css +2 -2
  14. package/dist/themes/boomdevs.css +96 -39
  15. package/dist/themes/boomdevs.min.css +2 -2
  16. package/dist/themes/esrar.css +96 -39
  17. package/dist/themes/esrar.min.css +2 -2
  18. package/dist/themes/flashtrade.css +97 -40
  19. package/dist/themes/flashtrade.min.css +2 -2
  20. package/dist/themes/mashroom.css +96 -39
  21. package/dist/themes/mashroom.min.css +3 -3
  22. package/dist/themes/shaj-default.css +96 -39
  23. package/dist/themes/shaj-default.min.css +2 -2
  24. package/package.json +13 -2
  25. package/src/components/Card/Card.tsx +9 -4
  26. package/src/components/Navigation/SideMenu/SideMenu.stories.tsx +301 -13
  27. package/src/components/Navigation/SideMenu/SideMenu.tsx +236 -9
  28. package/src/lib/composables/useSideMenu.ts +89 -30
  29. package/src/lib/index.ts +5 -0
  30. package/src/lib/theme/ThemeContext.tsx +17 -0
  31. package/src/lib/theme/ThemeManager.stories.tsx +472 -0
  32. package/src/lib/theme/ThemeManager.test.ts +186 -0
  33. package/src/lib/theme/ThemeManager.ts +501 -0
  34. package/src/lib/theme/ThemeProvider.tsx +227 -0
  35. package/src/lib/theme/index.ts +56 -0
  36. package/src/lib/theme/types.ts +247 -0
  37. package/src/lib/theme/useTheme.test.tsx +66 -0
  38. package/src/lib/theme/useTheme.ts +80 -0
  39. package/src/lib/theme/utils.test.ts +140 -0
  40. package/src/lib/theme/utils.ts +398 -0
  41. package/src/lib/types/components.ts +26 -0
  42. package/src/styles/06-components/_components.card.scss +39 -24
  43. package/src/styles/06-components/_components.side-menu.scss +79 -18
@@ -0,0 +1,501 @@
1
+ /**
2
+ * Theme Manager
3
+ *
4
+ * Core theme management class for the Atomix Design System.
5
+ * Handles theme loading, switching, persistence, and events.
6
+ */
7
+
8
+ import type {
9
+ ThemeManagerConfig,
10
+ ThemeMetadata,
11
+ ThemeChangeEvent,
12
+ ThemeLoadOptions,
13
+ ThemeEventListeners,
14
+ ThemeChangeCallback,
15
+ ThemeLoadCallback,
16
+ ThemeErrorCallback,
17
+ StorageAdapter,
18
+ } from './types';
19
+
20
+ import {
21
+ isBrowser,
22
+ isServer,
23
+ loadThemeCSS,
24
+ removeThemeCSS,
25
+ removeAllThemeCSS,
26
+ applyThemeAttributes,
27
+ getCurrentThemeFromDOM,
28
+ isThemeLoaded as checkThemeLoaded,
29
+ validateThemeMetadata,
30
+ isValidThemeName,
31
+ createLocalStorageAdapter,
32
+ } from './utils';
33
+
34
+ /**
35
+ * Default configuration values
36
+ */
37
+ const DEFAULT_CONFIG: Partial<ThemeManagerConfig> = {
38
+ basePath: '/themes',
39
+ cdnPath: null,
40
+ lazy: true,
41
+ storageKey: 'atomix-theme',
42
+ dataAttribute: 'data-theme',
43
+ enablePersistence: true,
44
+ useMinified: false,
45
+ preload: [],
46
+ };
47
+
48
+ /**
49
+ * ThemeManager class
50
+ *
51
+ * Manages theme loading, switching, and persistence for Atomix Design System.
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * const themeManager = new ThemeManager({
56
+ * themes: themesConfig.metadata,
57
+ * defaultTheme: 'shaj-default',
58
+ * });
59
+ *
60
+ * await themeManager.setTheme('flashtrade');
61
+ * ```
62
+ */
63
+ export class ThemeManager {
64
+ private config: Required<Omit<ThemeManagerConfig, 'onThemeChange' | 'onError' | 'cdnPath'>> & {
65
+ cdnPath: string | null;
66
+ onThemeChange?: (theme: string) => void;
67
+ onError?: (error: Error, themeName: string) => void;
68
+ };
69
+
70
+ private currentTheme: string | null = null;
71
+ private loadedThemes: Set<string> = new Set();
72
+ private loadingThemes: Map<string, Promise<void>> = new Map();
73
+ private eventListeners: ThemeEventListeners = {
74
+ themeChange: [],
75
+ themeLoad: [],
76
+ themeError: [],
77
+ };
78
+ private storageAdapter: StorageAdapter;
79
+ private initialized: boolean = false;
80
+
81
+ /**
82
+ * Create a new ThemeManager instance
83
+ *
84
+ * @param config - Theme manager configuration
85
+ */
86
+ constructor(config: ThemeManagerConfig) {
87
+ // Validate required config
88
+ if (!config.themes || Object.keys(config.themes).length === 0) {
89
+ throw new Error('ThemeManager: themes configuration is required');
90
+ }
91
+
92
+ // Merge with defaults
93
+ this.config = {
94
+ ...DEFAULT_CONFIG,
95
+ ...config,
96
+ themes: config.themes,
97
+ defaultTheme: config.defaultTheme || Object.keys(config.themes)[0],
98
+ } as Required<Omit<ThemeManagerConfig, 'onThemeChange' | 'onError' | 'cdnPath'>> & {
99
+ cdnPath: string | null;
100
+ onThemeChange?: (theme: string) => void;
101
+ onError?: (error: Error, themeName: string) => void;
102
+ };
103
+
104
+ // Validate default theme exists
105
+ if (!this.config.themes[this.config.defaultTheme]) {
106
+ throw new Error(`ThemeManager: default theme "${this.config.defaultTheme}" not found in themes configuration`);
107
+ }
108
+
109
+ // Initialize storage adapter
110
+ this.storageAdapter = createLocalStorageAdapter();
111
+
112
+ // Initialize theme manager
113
+ this.initialize();
114
+ }
115
+
116
+ /**
117
+ * Initialize the theme manager
118
+ */
119
+ private initialize(): void {
120
+ if (this.initialized || isServer()) {
121
+ return;
122
+ }
123
+
124
+ // Try to load theme from storage
125
+ let initialTheme = this.config.defaultTheme;
126
+
127
+ if (this.config.enablePersistence && this.storageAdapter.isAvailable()) {
128
+ const storedTheme = this.storageAdapter.getItem(this.config.storageKey);
129
+ if (storedTheme && this.config.themes[storedTheme]) {
130
+ initialTheme = storedTheme;
131
+ }
132
+ }
133
+
134
+ // Check if theme is already set in DOM
135
+ const domTheme = getCurrentThemeFromDOM(this.config.dataAttribute);
136
+ if (domTheme && this.config.themes[domTheme]) {
137
+ initialTheme = domTheme;
138
+ }
139
+
140
+ // Set initial theme
141
+ this.currentTheme = initialTheme;
142
+
143
+ // Preload themes if configured
144
+ if (this.config.preload && this.config.preload.length > 0) {
145
+ this.config.preload.forEach(themeName => {
146
+ if (this.config.themes[themeName]) {
147
+ this.preloadTheme(themeName).catch(error => {
148
+ console.warn(`Failed to preload theme "${themeName}":`, error);
149
+ });
150
+ }
151
+ });
152
+ }
153
+
154
+ this.initialized = true;
155
+ }
156
+
157
+ /**
158
+ * Get the current theme name
159
+ *
160
+ * @returns Current theme name
161
+ */
162
+ public getTheme(): string {
163
+ return this.currentTheme || this.config.defaultTheme;
164
+ }
165
+
166
+ /**
167
+ * Get all available themes
168
+ *
169
+ * @returns Array of theme metadata
170
+ */
171
+ public getAvailableThemes(): ThemeMetadata[] {
172
+ return Object.entries(this.config.themes).map(([key, metadata]) => ({
173
+ ...metadata,
174
+ class: key,
175
+ }));
176
+ }
177
+
178
+ /**
179
+ * Get metadata for a specific theme
180
+ *
181
+ * @param themeName - Name of the theme
182
+ * @returns Theme metadata or null if not found
183
+ */
184
+ public getThemeMetadata(themeName: string): ThemeMetadata | null {
185
+ const metadata = this.config.themes[themeName];
186
+ return metadata ? { ...metadata, class: themeName } : null;
187
+ }
188
+
189
+ /**
190
+ * Check if a theme is currently loaded
191
+ *
192
+ * @param themeName - Name of the theme to check
193
+ * @returns True if theme is loaded
194
+ */
195
+ public isThemeLoaded(themeName: string): boolean {
196
+ if (isServer()) {
197
+ return false;
198
+ }
199
+ return this.loadedThemes.has(themeName) || checkThemeLoaded(themeName);
200
+ }
201
+
202
+ /**
203
+ * Validate a theme name
204
+ *
205
+ * @param themeName - Theme name to validate
206
+ * @returns True if theme exists and is valid
207
+ */
208
+ public validateTheme(themeName: string): boolean {
209
+ if (!isValidThemeName(themeName)) {
210
+ return false;
211
+ }
212
+
213
+ const metadata = this.config.themes[themeName];
214
+ if (!metadata) {
215
+ return false;
216
+ }
217
+
218
+ const validation = validateThemeMetadata(metadata);
219
+ return validation.valid;
220
+ }
221
+
222
+ /**
223
+ * Preload a theme without applying it
224
+ *
225
+ * @param themeName - Name of the theme to preload
226
+ * @returns Promise that resolves when theme is loaded
227
+ */
228
+ public async preloadTheme(themeName: string): Promise<void> {
229
+ if (isServer()) {
230
+ return Promise.resolve();
231
+ }
232
+
233
+ // Validate theme name format to prevent path injection
234
+ if (!isValidThemeName(themeName)) {
235
+ const error = new Error(`Invalid theme name: "${themeName}". Theme names must be lowercase alphanumeric with hyphens.`);
236
+ this.emitError(error, themeName);
237
+ throw error;
238
+ }
239
+
240
+ // Check if theme exists
241
+ if (!this.config.themes[themeName]) {
242
+ const error = new Error(`Theme "${themeName}" not found`);
243
+ this.emitError(error, themeName);
244
+ throw error;
245
+ }
246
+
247
+ // Check if already loaded
248
+ if (this.isThemeLoaded(themeName)) {
249
+ return Promise.resolve();
250
+ }
251
+
252
+ // Check if already loading
253
+ if (this.loadingThemes.has(themeName)) {
254
+ return this.loadingThemes.get(themeName)!;
255
+ }
256
+
257
+ // Load theme CSS
258
+ const loadPromise = loadThemeCSS(
259
+ themeName,
260
+ this.config.basePath,
261
+ this.config.useMinified,
262
+ this.config.cdnPath
263
+ )
264
+ .then(() => {
265
+ this.loadedThemes.add(themeName);
266
+ this.loadingThemes.delete(themeName);
267
+ this.emitLoad(themeName);
268
+ })
269
+ .catch(error => {
270
+ this.loadingThemes.delete(themeName);
271
+ this.emitError(error, themeName);
272
+ throw error;
273
+ });
274
+
275
+ this.loadingThemes.set(themeName, loadPromise);
276
+ return loadPromise;
277
+ }
278
+
279
+ /**
280
+ * Set the current theme
281
+ *
282
+ * @param themeName - Name of the theme to set
283
+ * @param options - Load options
284
+ * @returns Promise that resolves when theme is applied
285
+ */
286
+ public async setTheme(
287
+ themeName: string,
288
+ options: ThemeLoadOptions = {}
289
+ ): Promise<void> {
290
+ if (isServer()) {
291
+ return Promise.resolve();
292
+ }
293
+
294
+ // Validate theme name format to prevent path injection
295
+ if (!isValidThemeName(themeName)) {
296
+ const error = new Error(`Invalid theme name: "${themeName}". Theme names must be lowercase alphanumeric with hyphens.`);
297
+ this.emitError(error, themeName);
298
+ throw error;
299
+ }
300
+
301
+ // Validate theme exists
302
+ if (!this.config.themes[themeName]) {
303
+ const error = new Error(`Theme "${themeName}" not found`);
304
+ this.emitError(error, themeName);
305
+ throw error;
306
+ }
307
+
308
+ // Check if already current theme
309
+ if (themeName === this.currentTheme && !options.force) {
310
+ return Promise.resolve();
311
+ }
312
+
313
+ const previousTheme = this.currentTheme;
314
+
315
+ try {
316
+ // Load theme CSS if not already loaded
317
+ if (!this.isThemeLoaded(themeName) || options.force) {
318
+ await this.preloadTheme(themeName);
319
+ }
320
+
321
+ // Remove previous theme CSS if requested
322
+ if (options.removePrevious && previousTheme && previousTheme !== themeName) {
323
+ removeThemeCSS(previousTheme);
324
+ this.loadedThemes.delete(previousTheme);
325
+ }
326
+
327
+ // Apply theme attributes
328
+ applyThemeAttributes(themeName, this.config.dataAttribute);
329
+
330
+ // Update current theme
331
+ this.currentTheme = themeName;
332
+
333
+ // Persist to storage
334
+ if (this.config.enablePersistence && this.storageAdapter.isAvailable()) {
335
+ this.storageAdapter.setItem(this.config.storageKey, themeName);
336
+ }
337
+
338
+ // Emit theme change event
339
+ this.emitThemeChange(previousTheme, themeName);
340
+
341
+ // Call config callback
342
+ if (this.config.onThemeChange) {
343
+ this.config.onThemeChange(themeName);
344
+ }
345
+ } catch (error) {
346
+ const err = error instanceof Error ? error : new Error(String(error));
347
+ this.emitError(err, themeName);
348
+
349
+ if (this.config.onError) {
350
+ this.config.onError(err, themeName);
351
+ }
352
+
353
+ // Fallback to default theme if requested
354
+ if (options.fallbackOnError && themeName !== this.config.defaultTheme) {
355
+ console.warn(`Failed to load theme "${themeName}", falling back to default theme "${this.config.defaultTheme}"`);
356
+ return this.setTheme(this.config.defaultTheme, { ...options, fallbackOnError: false });
357
+ }
358
+
359
+ throw err;
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Enable theme persistence
365
+ *
366
+ * @param storageKey - Optional custom storage key
367
+ */
368
+ public enablePersistence(storageKey?: string): void {
369
+ this.config.enablePersistence = true;
370
+ if (storageKey) {
371
+ this.config.storageKey = storageKey;
372
+ }
373
+
374
+ // Save current theme
375
+ if (this.currentTheme && this.storageAdapter.isAvailable()) {
376
+ this.storageAdapter.setItem(this.config.storageKey, this.currentTheme);
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Disable theme persistence
382
+ */
383
+ public disablePersistence(): void {
384
+ this.config.enablePersistence = false;
385
+
386
+ // Clear stored theme
387
+ if (this.storageAdapter.isAvailable()) {
388
+ this.storageAdapter.removeItem(this.config.storageKey);
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Clear all loaded themes
394
+ */
395
+ public clearThemes(): void {
396
+ if (isServer()) {
397
+ return;
398
+ }
399
+
400
+ removeAllThemeCSS();
401
+ this.loadedThemes.clear();
402
+ this.loadingThemes.clear();
403
+ }
404
+
405
+ /**
406
+ * Add event listener
407
+ *
408
+ * @param event - Event name
409
+ * @param callback - Callback function
410
+ */
411
+ public on(event: 'themeChange', callback: ThemeChangeCallback): void;
412
+ public on(event: 'themeLoad', callback: ThemeLoadCallback): void;
413
+ public on(event: 'themeError', callback: ThemeErrorCallback): void;
414
+ public on(event: string, callback: any): void {
415
+ if (event === 'themeChange') {
416
+ this.eventListeners.themeChange.push(callback);
417
+ } else if (event === 'themeLoad') {
418
+ this.eventListeners.themeLoad.push(callback);
419
+ } else if (event === 'themeError') {
420
+ this.eventListeners.themeError.push(callback);
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Remove event listener
426
+ *
427
+ * @param event - Event name
428
+ * @param callback - Callback function to remove
429
+ */
430
+ public off(event: 'themeChange', callback: ThemeChangeCallback): void;
431
+ public off(event: 'themeLoad', callback: ThemeLoadCallback): void;
432
+ public off(event: 'themeError', callback: ThemeErrorCallback): void;
433
+ public off(event: string, callback: any): void {
434
+ if (event === 'themeChange') {
435
+ this.eventListeners.themeChange = this.eventListeners.themeChange.filter(cb => cb !== callback);
436
+ } else if (event === 'themeLoad') {
437
+ this.eventListeners.themeLoad = this.eventListeners.themeLoad.filter(cb => cb !== callback);
438
+ } else if (event === 'themeError') {
439
+ this.eventListeners.themeError = this.eventListeners.themeError.filter(cb => cb !== callback);
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Emit theme change event
445
+ */
446
+ private emitThemeChange(previousTheme: string | null, currentTheme: string): void {
447
+ const event: ThemeChangeEvent = {
448
+ previousTheme,
449
+ currentTheme,
450
+ timestamp: Date.now(),
451
+ source: 'user',
452
+ };
453
+
454
+ this.eventListeners.themeChange.forEach(callback => {
455
+ try {
456
+ callback(event);
457
+ } catch (error) {
458
+ console.error('Error in themeChange listener:', error);
459
+ }
460
+ });
461
+ }
462
+
463
+ /**
464
+ * Emit theme load event
465
+ */
466
+ private emitLoad(themeName: string): void {
467
+ this.eventListeners.themeLoad.forEach(callback => {
468
+ try {
469
+ callback(themeName);
470
+ } catch (error) {
471
+ console.error('Error in themeLoad listener:', error);
472
+ }
473
+ });
474
+ }
475
+
476
+ /**
477
+ * Emit theme error event
478
+ */
479
+ private emitError(error: Error, themeName: string): void {
480
+ this.eventListeners.themeError.forEach(callback => {
481
+ try {
482
+ callback(error, themeName);
483
+ } catch (err) {
484
+ console.error('Error in themeError listener:', err);
485
+ }
486
+ });
487
+ }
488
+
489
+ /**
490
+ * Destroy the theme manager and clean up
491
+ */
492
+ public destroy(): void {
493
+ this.clearThemes();
494
+ this.eventListeners.themeChange = [];
495
+ this.eventListeners.themeLoad = [];
496
+ this.eventListeners.themeError = [];
497
+ this.initialized = false;
498
+ }
499
+ }
500
+
501
+ export default ThemeManager;