@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,140 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import * as utils from './utils';
3
+
4
+ describe('Theme Utils', () => {
5
+ describe('buildThemePath', () => {
6
+ it('should build correct path with default settings', () => {
7
+ expect(utils.buildThemePath('my-theme')).toBe('/themes/my-theme.css');
8
+ });
9
+
10
+ it('should handle custom base path', () => {
11
+ expect(utils.buildThemePath('my-theme', '/custom/path')).toBe('/custom/path/my-theme.css');
12
+ });
13
+
14
+ it('should handle trailing slash in base path', () => {
15
+ expect(utils.buildThemePath('my-theme', '/custom/path/')).toBe('/custom/path/my-theme.css');
16
+ });
17
+
18
+ it('should handle minified option', () => {
19
+ expect(utils.buildThemePath('my-theme', '/themes', true)).toBe('/themes/my-theme.min.css');
20
+ });
21
+
22
+ it('should use CDN path if provided', () => {
23
+ expect(utils.buildThemePath('my-theme', '/themes', false, 'https://cdn.example.com')).toBe('https://cdn.example.com/my-theme.css');
24
+ });
25
+ });
26
+
27
+ describe('isValidThemeName', () => {
28
+ it('should return true for valid names', () => {
29
+ expect(utils.isValidThemeName('theme-name')).toBe(true);
30
+ expect(utils.isValidThemeName('theme123')).toBe(true);
31
+ expect(utils.isValidThemeName('my-cool-theme')).toBe(true);
32
+ });
33
+
34
+ it('should return false for invalid names', () => {
35
+ expect(utils.isValidThemeName('Theme Name')).toBe(false); // spaces/caps
36
+ expect(utils.isValidThemeName('theme_name')).toBe(false); // underscore
37
+ expect(utils.isValidThemeName('theme.name')).toBe(false); // dot
38
+ expect(utils.isValidThemeName('')).toBe(false);
39
+ expect(utils.isValidThemeName(null as any)).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe('validateThemeMetadata', () => {
44
+ it('should validate correct metadata', () => {
45
+ const metadata = {
46
+ name: 'My Theme',
47
+ description: 'A cool theme',
48
+ version: '1.0.0',
49
+ author: 'Me',
50
+ };
51
+ const result = utils.validateThemeMetadata(metadata);
52
+ expect(result.valid).toBe(true);
53
+ expect(result.errors).toHaveLength(0);
54
+ expect(result.warnings).toHaveLength(0);
55
+ });
56
+
57
+ it('should return errors for missing required fields', () => {
58
+ const metadata = {};
59
+ const result = utils.validateThemeMetadata(metadata);
60
+ expect(result.valid).toBe(false);
61
+ expect(result.errors).toContain('Theme must have a valid name');
62
+ });
63
+
64
+ it('should return warnings for missing optional fields', () => {
65
+ const metadata = { name: 'My Theme' };
66
+ const result = utils.validateThemeMetadata(metadata);
67
+ expect(result.valid).toBe(true);
68
+ expect(result.warnings).toContain('Theme should have a description');
69
+ expect(result.warnings).toContain('Theme should have a version');
70
+ expect(result.warnings).toContain('Theme should have an author');
71
+ });
72
+ });
73
+
74
+ describe('DOM Operations', () => {
75
+ beforeEach(() => {
76
+ document.head.innerHTML = '';
77
+ document.body.removeAttribute('data-theme');
78
+ document.documentElement.removeAttribute('data-theme');
79
+ });
80
+
81
+ describe('loadThemeCSS', () => {
82
+ it('should create link element', () => {
83
+ // We can't easily wait for onload in JSDOM without manual triggering
84
+ // So we just call it and check side effects immediately (ignoring the promise for a moment)
85
+ // actually, we can't ignore the promise if we want to be clean.
86
+ // But we can check if element exists.
87
+
88
+ const promise = utils.loadThemeCSS('test-theme');
89
+
90
+ const link = document.getElementById('atomix-theme-test-theme') as HTMLLinkElement;
91
+ expect(link).not.toBeNull();
92
+ expect(link.tagName).toBe('LINK');
93
+ expect(link.getAttribute('href')).toBe('/themes/test-theme.css');
94
+ expect(link.getAttribute('data-atomix-theme')).toBe('test-theme');
95
+
96
+ // Manually trigger load to resolve promise
97
+ link.onload?.(new Event('load'));
98
+
99
+ return expect(promise).resolves.toBeUndefined();
100
+ });
101
+
102
+ it('should not create duplicate link', async () => {
103
+ const link = document.createElement('link');
104
+ link.id = 'atomix-theme-existing';
105
+ document.head.appendChild(link);
106
+
107
+ await utils.loadThemeCSS('existing');
108
+
109
+ expect(document.querySelectorAll('#atomix-theme-existing')).toHaveLength(1);
110
+ });
111
+ });
112
+
113
+ describe('removeThemeCSS', () => {
114
+ it('should remove link element', () => {
115
+ const link = document.createElement('link');
116
+ link.id = 'atomix-theme-to-remove';
117
+ document.head.appendChild(link);
118
+
119
+ utils.removeThemeCSS('to-remove');
120
+
121
+ expect(document.getElementById('atomix-theme-to-remove')).toBeNull();
122
+ });
123
+ });
124
+
125
+ describe('applyThemeAttributes', () => {
126
+ it('should apply attributes to body and html', () => {
127
+ utils.applyThemeAttributes('new-theme');
128
+
129
+ expect(document.body.getAttribute('data-theme')).toBe('new-theme');
130
+ expect(document.documentElement.getAttribute('data-theme')).toBe('new-theme');
131
+ });
132
+
133
+ it('should use custom attribute name', () => {
134
+ utils.applyThemeAttributes('new-theme', 'data-custom');
135
+
136
+ expect(document.body.getAttribute('data-custom')).toBe('new-theme');
137
+ });
138
+ });
139
+ });
140
+ });
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Theme Manager Utility Functions
3
+ *
4
+ * Helper functions for theme operations including CSS loading, DOM manipulation,
5
+ * and theme validation.
6
+ */
7
+
8
+ import type { ThemeMetadata, ThemeValidationResult } from './types';
9
+
10
+ /**
11
+ * Check if code is running in a browser environment
12
+ */
13
+ export const isBrowser = (): boolean => {
14
+ return typeof window !== 'undefined' && typeof document !== 'undefined';
15
+ };
16
+
17
+ /**
18
+ * Check if code is running on the server (SSR)
19
+ */
20
+ export const isServer = (): boolean => {
21
+ return !isBrowser();
22
+ };
23
+
24
+ /**
25
+ * Generate a unique ID for theme link elements
26
+ */
27
+ export const getThemeLinkId = (themeName: string): string => {
28
+ return `atomix-theme-${themeName}`;
29
+ };
30
+
31
+ /**
32
+ * Build the CSS file path for a theme
33
+ *
34
+ * @param themeName - Name of the theme
35
+ * @param basePath - Base path for theme files
36
+ * @param useMinified - Whether to use minified CSS
37
+ * @param cdnPath - Optional CDN path
38
+ * @returns Full path to the theme CSS file
39
+ */
40
+ export const buildThemePath = (
41
+ themeName: string,
42
+ basePath: string = '/themes',
43
+ useMinified: boolean = false,
44
+ cdnPath: string | null = null
45
+ ): string => {
46
+ // Validate theme name to prevent path injection
47
+ if (!isValidThemeName(themeName)) {
48
+ throw new Error(`Invalid theme name: "${themeName}". Theme names must be lowercase alphanumeric with hyphens.`);
49
+ }
50
+
51
+ const extension = useMinified ? '.min.css' : '.css';
52
+ const fileName = `${themeName}${extension}`;
53
+
54
+ if (cdnPath) {
55
+ // Validate CDN path doesn't contain dangerous characters
56
+ const cleanCdnPath = cdnPath.replace(/[<>"']/g, '');
57
+ return `${cleanCdnPath}/${fileName}`;
58
+ }
59
+
60
+ // Ensure basePath doesn't end with slash and fileName doesn't start with slash
61
+ // Also sanitize basePath to prevent path injection
62
+ const cleanBasePath = basePath.replace(/\/$/, '').replace(/[<>"']/g, '');
63
+ const cleanFileName = fileName.replace(/^\//, '');
64
+
65
+ return `${cleanBasePath}/${cleanFileName}`;
66
+ };
67
+
68
+ /**
69
+ * Load theme CSS file dynamically
70
+ *
71
+ * @param themeName - Name of the theme to load
72
+ * @param basePath - Base path for theme files
73
+ * @param useMinified - Whether to use minified CSS
74
+ * @param cdnPath - Optional CDN path
75
+ * @returns Promise that resolves when CSS is loaded
76
+ */
77
+ export const loadThemeCSS = (
78
+ themeName: string,
79
+ basePath: string = '/themes',
80
+ useMinified: boolean = false,
81
+ cdnPath: string | null = null
82
+ ): Promise<void> => {
83
+ if (isServer()) {
84
+ return Promise.resolve();
85
+ }
86
+
87
+ return new Promise((resolve, reject) => {
88
+ const linkId = getThemeLinkId(themeName);
89
+
90
+ // Check if theme is already loaded
91
+ const existingLink = document.getElementById(linkId);
92
+ if (existingLink) {
93
+ resolve();
94
+ return;
95
+ }
96
+
97
+ // Create link element
98
+ const link = document.createElement('link');
99
+ link.id = linkId;
100
+ link.rel = 'stylesheet';
101
+ link.type = 'text/css';
102
+ link.href = buildThemePath(themeName, basePath, useMinified, cdnPath);
103
+
104
+ // Add data attribute for tracking
105
+ link.setAttribute('data-atomix-theme', themeName);
106
+
107
+ // Handle load success
108
+ link.onload = () => {
109
+ resolve();
110
+ };
111
+
112
+ // Handle load error
113
+ link.onerror = () => {
114
+ // Remove failed link element
115
+ link.remove();
116
+ reject(new Error(`Failed to load theme: ${themeName}`));
117
+ };
118
+
119
+ // Append to head
120
+ document.head.appendChild(link);
121
+ });
122
+ };
123
+
124
+ /**
125
+ * Remove theme CSS from the DOM
126
+ *
127
+ * @param themeName - Name of the theme to remove
128
+ */
129
+ export const removeThemeCSS = (themeName: string): void => {
130
+ if (isServer()) {
131
+ return;
132
+ }
133
+
134
+ const linkId = getThemeLinkId(themeName);
135
+ const link = document.getElementById(linkId);
136
+
137
+ if (link) {
138
+ link.remove();
139
+ }
140
+ };
141
+
142
+ /**
143
+ * Remove all theme CSS files from the DOM
144
+ */
145
+ export const removeAllThemeCSS = (): void => {
146
+ if (isServer()) {
147
+ return;
148
+ }
149
+
150
+ const themeLinks = document.querySelectorAll('link[data-atomix-theme]');
151
+ themeLinks.forEach(link => link.remove());
152
+ };
153
+
154
+ /**
155
+ * Apply theme data attributes to the document
156
+ *
157
+ * @param themeName - Name of the theme
158
+ * @param dataAttribute - Data attribute name (default: 'data-theme')
159
+ */
160
+ export const applyThemeAttributes = (
161
+ themeName: string,
162
+ dataAttribute: string = 'data-theme'
163
+ ): void => {
164
+ if (isServer()) {
165
+ return;
166
+ }
167
+
168
+ // Set data attribute on body
169
+ document.body.setAttribute(dataAttribute, themeName);
170
+
171
+ // Also set on documentElement for broader compatibility
172
+ document.documentElement.setAttribute(dataAttribute, themeName);
173
+ };
174
+
175
+ /**
176
+ * Remove theme data attributes from the document
177
+ *
178
+ * @param dataAttribute - Data attribute name (default: 'data-theme')
179
+ */
180
+ export const removeThemeAttributes = (
181
+ dataAttribute: string = 'data-theme'
182
+ ): void => {
183
+ if (isServer()) {
184
+ return;
185
+ }
186
+
187
+ document.body.removeAttribute(dataAttribute);
188
+ document.documentElement.removeAttribute(dataAttribute);
189
+ };
190
+
191
+ /**
192
+ * Get the current theme from data attributes
193
+ *
194
+ * @param dataAttribute - Data attribute name (default: 'data-theme')
195
+ * @returns Current theme name or null
196
+ */
197
+ export const getCurrentThemeFromDOM = (
198
+ dataAttribute: string = 'data-theme'
199
+ ): string | null => {
200
+ if (isServer()) {
201
+ return null;
202
+ }
203
+
204
+ return document.body.getAttribute(dataAttribute) ||
205
+ document.documentElement.getAttribute(dataAttribute);
206
+ };
207
+
208
+ /**
209
+ * Detect system theme preference
210
+ *
211
+ * @returns 'dark' if system prefers dark mode, 'light' otherwise
212
+ */
213
+ export const getSystemTheme = (): 'light' | 'dark' => {
214
+ if (isServer()) {
215
+ return 'light';
216
+ }
217
+
218
+ if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
219
+ return 'dark';
220
+ }
221
+
222
+ return 'light';
223
+ };
224
+
225
+ /**
226
+ * Check if a theme is currently loaded in the DOM
227
+ *
228
+ * @param themeName - Name of the theme to check
229
+ * @returns True if theme CSS is loaded
230
+ */
231
+ export const isThemeLoaded = (themeName: string): boolean => {
232
+ if (isServer()) {
233
+ return false;
234
+ }
235
+
236
+ const linkId = getThemeLinkId(themeName);
237
+ return document.getElementById(linkId) !== null;
238
+ };
239
+
240
+ /**
241
+ * Validate theme metadata
242
+ *
243
+ * @param metadata - Theme metadata to validate
244
+ * @returns Validation result with errors and warnings
245
+ */
246
+ export const validateThemeMetadata = (
247
+ metadata: unknown
248
+ ): ThemeValidationResult => {
249
+ const errors: string[] = [];
250
+ const warnings: string[] = [];
251
+
252
+ if (!metadata || typeof metadata !== 'object') {
253
+ errors.push('Theme metadata must be an object');
254
+ return { valid: false, errors, warnings };
255
+ }
256
+
257
+ const theme = metadata as Partial<ThemeMetadata>;
258
+
259
+ // Required fields
260
+ if (!theme.name || typeof theme.name !== 'string') {
261
+ errors.push('Theme must have a valid name');
262
+ }
263
+
264
+ // Optional but recommended fields
265
+ if (!theme.description) {
266
+ warnings.push('Theme should have a description');
267
+ }
268
+
269
+ if (!theme.version) {
270
+ warnings.push('Theme should have a version');
271
+ }
272
+
273
+ if (!theme.author) {
274
+ warnings.push('Theme should have an author');
275
+ }
276
+
277
+ // Validate status if provided
278
+ if (theme.status) {
279
+ const validStatuses = ['stable', 'beta', 'experimental', 'deprecated'];
280
+ if (!validStatuses.includes(theme.status)) {
281
+ errors.push(`Invalid status: ${theme.status}. Must be one of: ${validStatuses.join(', ')}`);
282
+ }
283
+ }
284
+
285
+ // Validate color if provided
286
+ if (theme.color && typeof theme.color !== 'string') {
287
+ errors.push('Theme color must be a string');
288
+ }
289
+
290
+ // Validate a11y if provided
291
+ if (theme.a11y) {
292
+ if (typeof theme.a11y !== 'object') {
293
+ errors.push('Theme a11y must be an object');
294
+ } else {
295
+ if (theme.a11y.contrastTarget !== undefined) {
296
+ if (typeof theme.a11y.contrastTarget !== 'number' || theme.a11y.contrastTarget < 0) {
297
+ errors.push('Theme a11y.contrastTarget must be a positive number');
298
+ }
299
+ }
300
+ if (theme.a11y.modes !== undefined) {
301
+ if (!Array.isArray(theme.a11y.modes)) {
302
+ errors.push('Theme a11y.modes must be an array');
303
+ }
304
+ }
305
+ }
306
+ }
307
+
308
+ return {
309
+ valid: errors.length === 0,
310
+ errors,
311
+ warnings,
312
+ };
313
+ };
314
+
315
+ /**
316
+ * Validate theme name format
317
+ *
318
+ * @param themeName - Theme name to validate
319
+ * @returns True if valid
320
+ */
321
+ export const isValidThemeName = (themeName: string): boolean => {
322
+ if (!themeName || typeof themeName !== 'string') {
323
+ return false;
324
+ }
325
+
326
+ // Theme names should be lowercase alphanumeric with hyphens
327
+ const validPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
328
+ return validPattern.test(themeName);
329
+ };
330
+
331
+ /**
332
+ * Create a storage adapter for localStorage
333
+ */
334
+ export const createLocalStorageAdapter = () => {
335
+ return {
336
+ getItem: (key: string): string | null => {
337
+ if (isServer()) return null;
338
+ try {
339
+ return localStorage.getItem(key);
340
+ } catch {
341
+ return null;
342
+ }
343
+ },
344
+ setItem: (key: string, value: string): void => {
345
+ if (isServer()) return;
346
+ try {
347
+ localStorage.setItem(key, value);
348
+ } catch {
349
+ // Silently fail if localStorage is not available
350
+ }
351
+ },
352
+ removeItem: (key: string): void => {
353
+ if (isServer()) return;
354
+ try {
355
+ localStorage.removeItem(key);
356
+ } catch {
357
+ // Silently fail
358
+ }
359
+ },
360
+ isAvailable: (): boolean => {
361
+ if (isServer()) return false;
362
+ try {
363
+ const test = '__atomix_storage_test__';
364
+ localStorage.setItem(test, test);
365
+ localStorage.removeItem(test);
366
+ return true;
367
+ } catch {
368
+ return false;
369
+ }
370
+ },
371
+ };
372
+ };
373
+
374
+ /**
375
+ * Debounce function for performance optimization
376
+ *
377
+ * @param func - Function to debounce
378
+ * @param wait - Wait time in milliseconds
379
+ * @returns Debounced function
380
+ */
381
+ export const debounce = <T extends (...args: any[]) => any>(
382
+ func: T,
383
+ wait: number
384
+ ): ((...args: Parameters<T>) => void) => {
385
+ let timeout: ReturnType<typeof setTimeout> | null = null;
386
+
387
+ return function executedFunction(...args: Parameters<T>) {
388
+ const later = () => {
389
+ timeout = null;
390
+ func(...args);
391
+ };
392
+
393
+ if (timeout !== null) {
394
+ clearTimeout(timeout);
395
+ }
396
+ timeout = setTimeout(later, wait);
397
+ };
398
+ };
@@ -1217,6 +1217,21 @@ export interface SideMenuProps extends BaseComponentProps {
1217
1217
  */
1218
1218
  children: ReactNode;
1219
1219
 
1220
+ /**
1221
+ * Menu items
1222
+ */
1223
+ menuItems?: {
1224
+ title?: ReactNode;
1225
+ toggleIcon?: ReactNode;
1226
+ items?: {
1227
+ title?: ReactNode;
1228
+ icon?: ReactNode;
1229
+ href?: string;
1230
+ onClick?: (event: React.MouseEvent) => void;
1231
+ active?: boolean;
1232
+ disabled?: boolean;
1233
+ }[];
1234
+ }[];
1220
1235
  /**
1221
1236
  * Whether the menu is open (for controlled component)
1222
1237
  */
@@ -1232,6 +1247,17 @@ export interface SideMenuProps extends BaseComponentProps {
1232
1247
  */
1233
1248
  collapsible?: boolean;
1234
1249
 
1250
+ /**
1251
+ * Whether the menu can be collapsed on desktop (vertical collapse)
1252
+ * When true, adds a toggle button and supports collapsed/expanded states on desktop
1253
+ */
1254
+ collapsibleDesktop?: boolean;
1255
+
1256
+ /**
1257
+ * Whether the menu starts collapsed on desktop (only applies when collapsibleDesktop is true)
1258
+ */
1259
+ defaultCollapsedDesktop?: boolean;
1260
+
1235
1261
  /**
1236
1262
  * Custom toggle icon
1237
1263
  */