@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.
- package/CHANGELOG.md +56 -0
- package/README.md +40 -1
- package/dist/atomix.css +96 -39
- package/dist/atomix.min.css +2 -2
- package/dist/index.d.ts +627 -2
- package/dist/index.esm.js +1292 -89
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1316 -88
- package/dist/index.js.map +1 -1
- package/dist/index.min.js +1 -1
- package/dist/index.min.js.map +1 -1
- package/dist/themes/applemix.css +96 -39
- package/dist/themes/applemix.min.css +2 -2
- package/dist/themes/boomdevs.css +96 -39
- package/dist/themes/boomdevs.min.css +2 -2
- package/dist/themes/esrar.css +96 -39
- package/dist/themes/esrar.min.css +2 -2
- package/dist/themes/flashtrade.css +97 -40
- package/dist/themes/flashtrade.min.css +2 -2
- package/dist/themes/mashroom.css +96 -39
- package/dist/themes/mashroom.min.css +3 -3
- package/dist/themes/shaj-default.css +96 -39
- package/dist/themes/shaj-default.min.css +2 -2
- package/package.json +13 -2
- package/src/components/Card/Card.tsx +9 -4
- package/src/components/Navigation/SideMenu/SideMenu.stories.tsx +301 -13
- package/src/components/Navigation/SideMenu/SideMenu.tsx +236 -9
- package/src/lib/composables/useSideMenu.ts +89 -30
- package/src/lib/index.ts +5 -0
- package/src/lib/theme/ThemeContext.tsx +17 -0
- package/src/lib/theme/ThemeManager.stories.tsx +472 -0
- package/src/lib/theme/ThemeManager.test.ts +186 -0
- package/src/lib/theme/ThemeManager.ts +501 -0
- package/src/lib/theme/ThemeProvider.tsx +227 -0
- package/src/lib/theme/index.ts +56 -0
- package/src/lib/theme/types.ts +247 -0
- package/src/lib/theme/useTheme.test.tsx +66 -0
- package/src/lib/theme/useTheme.ts +80 -0
- package/src/lib/theme/utils.test.ts +140 -0
- package/src/lib/theme/utils.ts +398 -0
- package/src/lib/types/components.ts +26 -0
- package/src/styles/06-components/_components.card.scss +39 -24
- 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
|
*/
|