@shohojdhara/atomix 0.2.7 → 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 +58 -0
- package/README.md +40 -1
- package/dist/atomix.css +412 -77
- package/dist/atomix.min.css +3 -3
- package/dist/index.d.ts +913 -12
- package/dist/index.esm.js +1739 -209
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1763 -208
- 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 +412 -77
- package/dist/themes/applemix.min.css +3 -3
- package/dist/themes/boomdevs.css +411 -76
- package/dist/themes/boomdevs.min.css +3 -3
- package/dist/themes/esrar.css +412 -77
- package/dist/themes/esrar.min.css +3 -3
- package/dist/themes/flashtrade.css +1803 -622
- package/dist/themes/flashtrade.min.css +113 -7
- package/dist/themes/mashroom.css +411 -76
- package/dist/themes/mashroom.min.css +4 -4
- package/dist/themes/shaj-default.css +411 -76
- package/dist/themes/shaj-default.min.css +3 -3
- package/package.json +13 -2
- package/src/components/Button/Button.stories.tsx +174 -0
- package/src/components/Button/Button.tsx +238 -78
- package/src/components/Card/Card.stories.tsx +202 -0
- package/src/components/Card/Card.tsx +253 -77
- package/src/components/Form/Input.stories.tsx +228 -2
- package/src/components/Navigation/SideMenu/SideMenu.stories.tsx +301 -13
- package/src/components/Navigation/SideMenu/SideMenu.tsx +236 -9
- package/src/components/Tooltip/Tooltip.tsx +68 -66
- package/src/lib/composables/useButton.ts +37 -5
- package/src/lib/composables/useInput.ts +39 -1
- package/src/lib/composables/useSideMenu.ts +89 -30
- package/src/lib/constants/components.ts +53 -0
- 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 +304 -4
- package/src/styles/01-settings/_settings.tooltip.scss +2 -2
- package/src/styles/06-components/_components.button.scss +100 -0
- package/src/styles/06-components/_components.card.scss +235 -2
- package/src/styles/06-components/_components.side-menu.scss +79 -18
- package/src/styles/06-components/_components.tooltip.scss +89 -66
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import { ThemeProvider, useTheme } from './index';
|
|
4
|
+
import { themesConfig } from '@/themes/themes.config';
|
|
5
|
+
import { Button } from '@/components/Button/Button';
|
|
6
|
+
import { Card } from '@/components/Card/Card';
|
|
7
|
+
import { ColorModeToggle } from '@/components/ColorModeToggle/ColorModeToggle';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Theme Manager
|
|
11
|
+
*
|
|
12
|
+
* The Atomix Theme Manager provides dynamic theme switching capabilities
|
|
13
|
+
* for both React and vanilla JavaScript applications.
|
|
14
|
+
*
|
|
15
|
+
* ## Features
|
|
16
|
+
* - Dynamic theme loading
|
|
17
|
+
* - Theme persistence
|
|
18
|
+
* - Preloading support
|
|
19
|
+
* - SSR compatible
|
|
20
|
+
* - Event system
|
|
21
|
+
*/
|
|
22
|
+
const meta = {
|
|
23
|
+
title: 'Utilities/Theme Manager',
|
|
24
|
+
parameters: {
|
|
25
|
+
docs: {
|
|
26
|
+
description: {
|
|
27
|
+
component: 'Dynamic theme management system for Atomix Design System. Supports theme switching, persistence, and preloading.',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
} satisfies Meta;
|
|
32
|
+
|
|
33
|
+
export default meta;
|
|
34
|
+
type Story = StoryObj<typeof meta>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Theme Switcher Component
|
|
38
|
+
*/
|
|
39
|
+
function ThemeSwitcher() {
|
|
40
|
+
const { theme, setTheme, availableThemes, isLoading, error } = useTheme();
|
|
41
|
+
|
|
42
|
+
if (error) {
|
|
43
|
+
return (
|
|
44
|
+
<div style={{ padding: '1rem', background: 'var(--atomix-red-1)', border: '1px solid var(--atomix-red-4)', borderRadius: '8px' }}>
|
|
45
|
+
<strong>Error:</strong> {error.message}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
52
|
+
<div>
|
|
53
|
+
<strong>Current Theme:</strong> {theme}
|
|
54
|
+
{isLoading && <span style={{ marginLeft: '0.5rem', color: 'var(--atomix-primary-6)' }}>Loading...</span>}
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
|
58
|
+
{availableThemes.map((t) => (
|
|
59
|
+
<Button
|
|
60
|
+
key={t.class}
|
|
61
|
+
variant={theme === t.class ? 'primary' : 'secondary'}
|
|
62
|
+
size="sm"
|
|
63
|
+
onClick={() => setTheme(t.class!)}
|
|
64
|
+
disabled={isLoading}
|
|
65
|
+
aria-label={`Switch to ${t.name} theme`}
|
|
66
|
+
aria-pressed={theme === t.class}
|
|
67
|
+
>
|
|
68
|
+
{t.name}
|
|
69
|
+
</Button>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Theme Info Display
|
|
78
|
+
*/
|
|
79
|
+
function ThemeInfo() {
|
|
80
|
+
const { theme, availableThemes } = useTheme();
|
|
81
|
+
const currentThemeMetadata = availableThemes.find(t => t.class === theme);
|
|
82
|
+
|
|
83
|
+
if (!currentThemeMetadata) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div style={{ display: 'grid', gap: '0.5rem', fontSize: '0.875rem' }}>
|
|
89
|
+
<div><strong>Name:</strong> {currentThemeMetadata.name}</div>
|
|
90
|
+
{currentThemeMetadata.description && (
|
|
91
|
+
<div><strong>Description:</strong> {currentThemeMetadata.description}</div>
|
|
92
|
+
)}
|
|
93
|
+
{currentThemeMetadata.author && (
|
|
94
|
+
<div><strong>Author:</strong> {currentThemeMetadata.author}</div>
|
|
95
|
+
)}
|
|
96
|
+
{currentThemeMetadata.version && (
|
|
97
|
+
<div><strong>Version:</strong> {currentThemeMetadata.version}</div>
|
|
98
|
+
)}
|
|
99
|
+
{currentThemeMetadata.status && (
|
|
100
|
+
<div><strong>Status:</strong> {currentThemeMetadata.status}</div>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Basic Theme Switching
|
|
108
|
+
*
|
|
109
|
+
* Demonstrates basic theme switching functionality with the ThemeProvider and useTheme hook.
|
|
110
|
+
*/
|
|
111
|
+
export const BasicThemeSwitching: Story = {
|
|
112
|
+
render: () => (
|
|
113
|
+
<ThemeProvider
|
|
114
|
+
themes={themesConfig.metadata}
|
|
115
|
+
defaultTheme="shaj-default"
|
|
116
|
+
enablePersistence={false}
|
|
117
|
+
>
|
|
118
|
+
<div style={{ padding: '2rem' }}>
|
|
119
|
+
<h2>Theme Switcher</h2>
|
|
120
|
+
<p>Click a button to switch themes</p>
|
|
121
|
+
<ThemeSwitcher />
|
|
122
|
+
</div>
|
|
123
|
+
</ThemeProvider>
|
|
124
|
+
),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* With Persistence
|
|
129
|
+
*
|
|
130
|
+
* Theme manager with localStorage persistence enabled.
|
|
131
|
+
* The selected theme will be remembered across page reloads.
|
|
132
|
+
*/
|
|
133
|
+
export const WithPersistence: Story = {
|
|
134
|
+
render: () => (
|
|
135
|
+
<ThemeProvider
|
|
136
|
+
themes={themesConfig.metadata}
|
|
137
|
+
defaultTheme="shaj-default"
|
|
138
|
+
enablePersistence={true}
|
|
139
|
+
storageKey="storybook-theme-demo"
|
|
140
|
+
>
|
|
141
|
+
<div style={{ padding: '2rem' }}>
|
|
142
|
+
<h2>Theme Switcher with Persistence</h2>
|
|
143
|
+
<p>Your theme selection will be saved to localStorage</p>
|
|
144
|
+
<ThemeSwitcher />
|
|
145
|
+
<div style={{ marginTop: '1rem', padding: '1rem', background: 'var(--atomix-gray-1)', borderRadius: '8px' }}>
|
|
146
|
+
<small>
|
|
147
|
+
<strong>Note:</strong> Reload the page to see persistence in action.
|
|
148
|
+
The theme will be restored from localStorage.
|
|
149
|
+
</small>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</ThemeProvider>
|
|
153
|
+
),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Theme Info Display
|
|
158
|
+
*
|
|
159
|
+
* Shows detailed information about the current theme.
|
|
160
|
+
*/
|
|
161
|
+
export const ThemeInfoDisplay: Story = {
|
|
162
|
+
render: () => (
|
|
163
|
+
<ThemeProvider
|
|
164
|
+
themes={themesConfig.metadata}
|
|
165
|
+
defaultTheme="shaj-default"
|
|
166
|
+
>
|
|
167
|
+
<div style={{ padding: '2rem', display: 'grid', gap: '1.5rem' }}>
|
|
168
|
+
<Card>
|
|
169
|
+
<h2>Theme Switcher</h2>
|
|
170
|
+
<ThemeSwitcher />
|
|
171
|
+
</Card>
|
|
172
|
+
|
|
173
|
+
<Card>
|
|
174
|
+
<h2>Current Theme Details</h2>
|
|
175
|
+
<ThemeInfo />
|
|
176
|
+
</Card>
|
|
177
|
+
</div>
|
|
178
|
+
</ThemeProvider>
|
|
179
|
+
),
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* With Preloading
|
|
184
|
+
*
|
|
185
|
+
* Demonstrates theme preloading for faster switching.
|
|
186
|
+
*/
|
|
187
|
+
export const WithPreloading: Story = {
|
|
188
|
+
render: () => {
|
|
189
|
+
function PreloadDemo() {
|
|
190
|
+
const { preloadTheme, availableThemes } = useTheme();
|
|
191
|
+
const [preloading, setPreloading] = useState<string | null>(null);
|
|
192
|
+
const [preloaded, setPreloaded] = useState<Set<string>>(new Set());
|
|
193
|
+
|
|
194
|
+
const handlePreload = async (themeName: string) => {
|
|
195
|
+
setPreloading(themeName);
|
|
196
|
+
try {
|
|
197
|
+
await preloadTheme(themeName);
|
|
198
|
+
setPreloaded(prev => new Set([...prev, themeName]));
|
|
199
|
+
} catch (error) {
|
|
200
|
+
console.error('Failed to preload theme:', error);
|
|
201
|
+
} finally {
|
|
202
|
+
setPreloading(null);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div style={{ display: 'grid', gap: '1.5rem' }}>
|
|
208
|
+
<Card>
|
|
209
|
+
<h2>Theme Switcher</h2>
|
|
210
|
+
<ThemeSwitcher />
|
|
211
|
+
</Card>
|
|
212
|
+
|
|
213
|
+
<Card>
|
|
214
|
+
<h2>Preload Themes</h2>
|
|
215
|
+
<p style={{ fontSize: '0.875rem', color: 'var(--atomix-gray-7)' }}>
|
|
216
|
+
Preload themes for instant switching
|
|
217
|
+
</p>
|
|
218
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '1rem' }}>
|
|
219
|
+
{availableThemes.map((t) => (
|
|
220
|
+
<Button
|
|
221
|
+
key={t.class}
|
|
222
|
+
variant={preloaded.has(t.class!) ? 'success' : 'outline'}
|
|
223
|
+
size="sm"
|
|
224
|
+
onClick={() => handlePreload(t.class!)}
|
|
225
|
+
disabled={preloading === t.class}
|
|
226
|
+
>
|
|
227
|
+
{preloading === t.class ? 'Preloading...' :
|
|
228
|
+
preloaded.has(t.class!) ? `✓ ${t.name}` :
|
|
229
|
+
`Preload ${t.name}`}
|
|
230
|
+
</Button>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
</Card>
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<ThemeProvider
|
|
240
|
+
themes={themesConfig.metadata}
|
|
241
|
+
defaultTheme="shaj-default"
|
|
242
|
+
preload={['shaj-default']}
|
|
243
|
+
>
|
|
244
|
+
<div style={{ padding: '2rem' }}>
|
|
245
|
+
<PreloadDemo />
|
|
246
|
+
</div>
|
|
247
|
+
</ThemeProvider>
|
|
248
|
+
);
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* With Color Mode Toggle
|
|
254
|
+
*
|
|
255
|
+
* Demonstrates integration with the ColorModeToggle component.
|
|
256
|
+
*/
|
|
257
|
+
export const WithColorModeToggle: Story = {
|
|
258
|
+
render: () => (
|
|
259
|
+
<ThemeProvider
|
|
260
|
+
themes={themesConfig.metadata}
|
|
261
|
+
defaultTheme="shaj-default"
|
|
262
|
+
>
|
|
263
|
+
<div style={{ padding: '2rem' }}>
|
|
264
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
|
265
|
+
<h2>Theme & Color Mode Controls</h2>
|
|
266
|
+
<ColorModeToggle />
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<Card>
|
|
270
|
+
<ThemeSwitcher />
|
|
271
|
+
</Card>
|
|
272
|
+
|
|
273
|
+
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'var(--atomix-gray-1)', borderRadius: '8px' }}>
|
|
274
|
+
<p style={{ fontSize: '0.875rem', margin: 0 }}>
|
|
275
|
+
<strong>Tip:</strong> Use the moon/sun icon to toggle between light and dark modes.
|
|
276
|
+
Each theme supports both color modes.
|
|
277
|
+
</p>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
</ThemeProvider>
|
|
281
|
+
),
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Component Showcase
|
|
286
|
+
*
|
|
287
|
+
* Shows how different components look in the current theme.
|
|
288
|
+
*/
|
|
289
|
+
export const ComponentShowcase: Story = {
|
|
290
|
+
render: () => (
|
|
291
|
+
<ThemeProvider
|
|
292
|
+
themes={themesConfig.metadata}
|
|
293
|
+
defaultTheme="shaj-default"
|
|
294
|
+
>
|
|
295
|
+
<div style={{ padding: '2rem', display: 'grid', gap: '1.5rem' }}>
|
|
296
|
+
<Card>
|
|
297
|
+
<h2>Theme Switcher</h2>
|
|
298
|
+
<ThemeSwitcher />
|
|
299
|
+
</Card>
|
|
300
|
+
|
|
301
|
+
<Card>
|
|
302
|
+
<h2>Component Showcase</h2>
|
|
303
|
+
<p>See how components look in the current theme</p>
|
|
304
|
+
|
|
305
|
+
<div style={{ display: 'grid', gap: '1.5rem', marginTop: '1rem' }}>
|
|
306
|
+
<div>
|
|
307
|
+
<h3 style={{ marginBottom: '0.5rem' }}>Buttons</h3>
|
|
308
|
+
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
309
|
+
<Button variant="primary">Primary</Button>
|
|
310
|
+
<Button variant="secondary">Secondary</Button>
|
|
311
|
+
<Button variant="success">Success</Button>
|
|
312
|
+
<Button variant="outline">Outline</Button>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<div>
|
|
317
|
+
<h3 style={{ marginBottom: '0.5rem' }}>Cards</h3>
|
|
318
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '1rem' }}>
|
|
319
|
+
<Card style={{ padding: '1rem' }}>
|
|
320
|
+
<h4 style={{ margin: '0 0 0.5rem 0' }}>Card 1</h4>
|
|
321
|
+
<p style={{ margin: 0, fontSize: '0.875rem' }}>Content here</p>
|
|
322
|
+
</Card>
|
|
323
|
+
<Card style={{ padding: '1rem' }}>
|
|
324
|
+
<h4 style={{ margin: '0 0 0.5rem 0' }}>Card 2</h4>
|
|
325
|
+
<p style={{ margin: 0, fontSize: '0.875rem' }}>Content here</p>
|
|
326
|
+
</Card>
|
|
327
|
+
<Card style={{ padding: '1rem' }}>
|
|
328
|
+
<h4 style={{ margin: '0 0 0.5rem 0' }}>Card 3</h4>
|
|
329
|
+
<p style={{ margin: 0, fontSize: '0.875rem' }}>Content here</p>
|
|
330
|
+
</Card>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
</Card>
|
|
335
|
+
</div>
|
|
336
|
+
</ThemeProvider>
|
|
337
|
+
),
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Error Handling
|
|
342
|
+
*
|
|
343
|
+
* Demonstrates error handling when theme loading fails.
|
|
344
|
+
*/
|
|
345
|
+
export const ErrorHandling: Story = {
|
|
346
|
+
render: () => {
|
|
347
|
+
function ErrorDemo() {
|
|
348
|
+
const { setTheme, error } = useTheme();
|
|
349
|
+
const [customError, setCustomError] = useState<string | null>(null);
|
|
350
|
+
|
|
351
|
+
const handleInvalidTheme = async () => {
|
|
352
|
+
try {
|
|
353
|
+
await setTheme('non-existent-theme');
|
|
354
|
+
} catch (err) {
|
|
355
|
+
setCustomError(err instanceof Error ? err.message : 'Unknown error');
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
return (
|
|
360
|
+
<div style={{ display: 'grid', gap: '1.5rem' }}>
|
|
361
|
+
<Card>
|
|
362
|
+
<h2>Theme Switcher</h2>
|
|
363
|
+
<ThemeSwitcher />
|
|
364
|
+
</Card>
|
|
365
|
+
|
|
366
|
+
<Card>
|
|
367
|
+
<h2>Error Handling Demo</h2>
|
|
368
|
+
<p style={{ fontSize: '0.875rem', color: 'var(--atomix-gray-7)' }}>
|
|
369
|
+
Click the button below to trigger an error by trying to load a non-existent theme
|
|
370
|
+
</p>
|
|
371
|
+
<Button variant="outline" onClick={handleInvalidTheme}>
|
|
372
|
+
Try Invalid Theme
|
|
373
|
+
</Button>
|
|
374
|
+
|
|
375
|
+
{(error || customError) && (
|
|
376
|
+
<div style={{
|
|
377
|
+
marginTop: '1rem',
|
|
378
|
+
padding: '1rem',
|
|
379
|
+
background: 'var(--atomix-red-1)',
|
|
380
|
+
border: '1px solid var(--atomix-red-4)',
|
|
381
|
+
borderRadius: '8px',
|
|
382
|
+
color: 'var(--atomix-red-9)'
|
|
383
|
+
}}>
|
|
384
|
+
<strong>Error:</strong> {error?.message || customError}
|
|
385
|
+
</div>
|
|
386
|
+
)}
|
|
387
|
+
</Card>
|
|
388
|
+
</div>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<ThemeProvider
|
|
394
|
+
themes={themesConfig.metadata}
|
|
395
|
+
defaultTheme="shaj-default"
|
|
396
|
+
onError={(error, themeName) => {
|
|
397
|
+
console.error(`Failed to load theme "${themeName}":`, error);
|
|
398
|
+
}}
|
|
399
|
+
>
|
|
400
|
+
<div style={{ padding: '2rem' }}>
|
|
401
|
+
<ErrorDemo />
|
|
402
|
+
</div>
|
|
403
|
+
</ThemeProvider>
|
|
404
|
+
);
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Custom Callbacks
|
|
410
|
+
*
|
|
411
|
+
* Demonstrates using custom callbacks for theme changes.
|
|
412
|
+
*/
|
|
413
|
+
export const CustomCallbacks: Story = {
|
|
414
|
+
render: () => {
|
|
415
|
+
function CallbackDemo() {
|
|
416
|
+
const [log, setLog] = useState<string[]>([]);
|
|
417
|
+
|
|
418
|
+
const addLog = (message: string) => {
|
|
419
|
+
setLog(prev => [...prev, `${new Date().toLocaleTimeString()}: ${message}`]);
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
return (
|
|
423
|
+
<ThemeProvider
|
|
424
|
+
themes={themesConfig.metadata}
|
|
425
|
+
defaultTheme="shaj-default"
|
|
426
|
+
onThemeChange={(theme) => {
|
|
427
|
+
addLog(`Theme changed to: ${theme}`);
|
|
428
|
+
}}
|
|
429
|
+
onError={(error, themeName) => {
|
|
430
|
+
addLog(`Error loading theme "${themeName}": ${error.message}`);
|
|
431
|
+
}}
|
|
432
|
+
>
|
|
433
|
+
<div style={{ display: 'grid', gap: '1.5rem' }}>
|
|
434
|
+
<Card>
|
|
435
|
+
<h2>Theme Switcher</h2>
|
|
436
|
+
<ThemeSwitcher />
|
|
437
|
+
</Card>
|
|
438
|
+
|
|
439
|
+
<Card>
|
|
440
|
+
<h2>Event Log</h2>
|
|
441
|
+
<div style={{
|
|
442
|
+
maxHeight: '200px',
|
|
443
|
+
overflowY: 'auto',
|
|
444
|
+
padding: '1rem',
|
|
445
|
+
background: 'var(--atomix-gray-1)',
|
|
446
|
+
borderRadius: '8px',
|
|
447
|
+
fontFamily: 'monospace',
|
|
448
|
+
fontSize: '0.75rem'
|
|
449
|
+
}}>
|
|
450
|
+
{log.length === 0 ? (
|
|
451
|
+
<div style={{ color: 'var(--atomix-gray-6)' }}>No events yet. Switch themes to see logs.</div>
|
|
452
|
+
) : (
|
|
453
|
+
log.map((entry, index) => (
|
|
454
|
+
<div key={index} style={{ marginBottom: '0.25rem' }}>
|
|
455
|
+
{entry}
|
|
456
|
+
</div>
|
|
457
|
+
))
|
|
458
|
+
)}
|
|
459
|
+
</div>
|
|
460
|
+
</Card>
|
|
461
|
+
</div>
|
|
462
|
+
</ThemeProvider>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return (
|
|
467
|
+
<div style={{ padding: '2rem' }}>
|
|
468
|
+
<CallbackDemo />
|
|
469
|
+
</div>
|
|
470
|
+
);
|
|
471
|
+
},
|
|
472
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { ThemeManager } from './ThemeManager';
|
|
3
|
+
import * as utils from './utils';
|
|
4
|
+
import type { ThemeManagerConfig } from './types';
|
|
5
|
+
|
|
6
|
+
// Mock utils
|
|
7
|
+
vi.mock('./utils', () => ({
|
|
8
|
+
isBrowser: vi.fn(() => true),
|
|
9
|
+
isServer: vi.fn(() => false),
|
|
10
|
+
loadThemeCSS: vi.fn(() => Promise.resolve()),
|
|
11
|
+
removeThemeCSS: vi.fn(),
|
|
12
|
+
removeAllThemeCSS: vi.fn(),
|
|
13
|
+
applyThemeAttributes: vi.fn(),
|
|
14
|
+
removeThemeAttributes: vi.fn(),
|
|
15
|
+
getCurrentThemeFromDOM: vi.fn(() => null),
|
|
16
|
+
getSystemTheme: vi.fn(() => 'light'),
|
|
17
|
+
isThemeLoaded: vi.fn(() => false),
|
|
18
|
+
validateThemeMetadata: vi.fn(() => ({ valid: true, errors: [], warnings: [] })),
|
|
19
|
+
isValidThemeName: vi.fn(() => true),
|
|
20
|
+
createLocalStorageAdapter: vi.fn(() => ({
|
|
21
|
+
getItem: vi.fn(),
|
|
22
|
+
setItem: vi.fn(),
|
|
23
|
+
removeItem: vi.fn(),
|
|
24
|
+
isAvailable: vi.fn(() => true),
|
|
25
|
+
})),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
describe('ThemeManager', () => {
|
|
29
|
+
const mockThemes = {
|
|
30
|
+
'theme-1': { name: 'Theme 1', class: 'theme-1' },
|
|
31
|
+
'theme-2': { name: 'Theme 2', class: 'theme-2' },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const defaultConfig: ThemeManagerConfig = {
|
|
35
|
+
themes: mockThemes,
|
|
36
|
+
defaultTheme: 'theme-1',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let themeManager: ThemeManager;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.clearAllMocks();
|
|
43
|
+
themeManager = new ThemeManager(defaultConfig);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
themeManager.destroy();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('Initialization', () => {
|
|
51
|
+
it('should initialize with default theme', () => {
|
|
52
|
+
expect(themeManager.getTheme()).toBe('theme-1');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should throw error if themes config is missing', () => {
|
|
56
|
+
expect(() => new ThemeManager({} as any)).toThrow('ThemeManager: themes configuration is required');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should throw error if default theme is not found', () => {
|
|
60
|
+
expect(() => new ThemeManager({
|
|
61
|
+
themes: mockThemes,
|
|
62
|
+
defaultTheme: 'non-existent',
|
|
63
|
+
})).toThrow('ThemeManager: default theme "non-existent" not found');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should load theme from storage if persistence is enabled', () => {
|
|
67
|
+
const mockGetItem = vi.fn(() => 'theme-2');
|
|
68
|
+
vi.mocked(utils.createLocalStorageAdapter).mockReturnValue({
|
|
69
|
+
getItem: mockGetItem,
|
|
70
|
+
setItem: vi.fn(),
|
|
71
|
+
removeItem: vi.fn(),
|
|
72
|
+
isAvailable: vi.fn(() => true),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const tm = new ThemeManager({
|
|
76
|
+
...defaultConfig,
|
|
77
|
+
enablePersistence: true,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(tm.getTheme()).toBe('theme-2');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('Theme Switching', () => {
|
|
85
|
+
it('should set theme successfully', async () => {
|
|
86
|
+
await themeManager.setTheme('theme-2');
|
|
87
|
+
expect(themeManager.getTheme()).toBe('theme-2');
|
|
88
|
+
expect(utils.loadThemeCSS).toHaveBeenCalledWith('theme-2', '/themes', false, null);
|
|
89
|
+
expect(utils.applyThemeAttributes).toHaveBeenCalledWith('theme-2', 'data-theme');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should not reload if theme is already active', async () => {
|
|
93
|
+
await themeManager.setTheme('theme-1');
|
|
94
|
+
expect(utils.loadThemeCSS).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should force reload if force option is true', async () => {
|
|
98
|
+
await themeManager.setTheme('theme-1', { force: true });
|
|
99
|
+
expect(utils.loadThemeCSS).toHaveBeenCalledWith('theme-1', '/themes', false, null);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should throw error for invalid theme', async () => {
|
|
103
|
+
await expect(themeManager.setTheme('invalid-theme')).rejects.toThrow('Theme "invalid-theme" not found');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should emit themeChange event', async () => {
|
|
107
|
+
const spy = vi.fn();
|
|
108
|
+
themeManager.on('themeChange', spy);
|
|
109
|
+
await themeManager.setTheme('theme-2');
|
|
110
|
+
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
|
|
111
|
+
previousTheme: 'theme-1',
|
|
112
|
+
currentTheme: 'theme-2',
|
|
113
|
+
source: 'user',
|
|
114
|
+
}));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should fallback to default theme on error if fallbackOnError is true', async () => {
|
|
118
|
+
vi.mocked(utils.loadThemeCSS).mockRejectedValueOnce(new Error('Load failed'));
|
|
119
|
+
|
|
120
|
+
await themeManager.setTheme('theme-2', { fallbackOnError: true });
|
|
121
|
+
|
|
122
|
+
expect(themeManager.getTheme()).toBe('theme-1');
|
|
123
|
+
// Should have tried to load theme-2 first
|
|
124
|
+
expect(utils.loadThemeCSS).toHaveBeenCalledWith('theme-2', '/themes', false, null);
|
|
125
|
+
// Then should have tried to load theme-1 (default) - actually default might be loaded or not
|
|
126
|
+
// If default is already loaded (it is initialized), it might skip loading CSS unless forced
|
|
127
|
+
// But setTheme calls preloadTheme which checks isThemeLoaded.
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('Preloading', () => {
|
|
132
|
+
it('should preload theme', async () => {
|
|
133
|
+
await themeManager.preloadTheme('theme-2');
|
|
134
|
+
expect(utils.loadThemeCSS).toHaveBeenCalledWith('theme-2', '/themes', false, null);
|
|
135
|
+
expect(themeManager.isThemeLoaded('theme-2')).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should not preload if already loaded', async () => {
|
|
139
|
+
vi.mocked(utils.isThemeLoaded).mockReturnValue(true);
|
|
140
|
+
await themeManager.preloadTheme('theme-2');
|
|
141
|
+
// ThemeManager checks its own loadedThemes set OR utils.isThemeLoaded
|
|
142
|
+
// Since we mocked utils.isThemeLoaded to return true, it might skip loading
|
|
143
|
+
// But ThemeManager.isThemeLoaded calls utils.isThemeLoaded
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('Persistence', () => {
|
|
148
|
+
it('should save theme to storage on change', async () => {
|
|
149
|
+
const mockSetItem = vi.fn();
|
|
150
|
+
vi.mocked(utils.createLocalStorageAdapter).mockReturnValue({
|
|
151
|
+
getItem: vi.fn(),
|
|
152
|
+
setItem: mockSetItem,
|
|
153
|
+
removeItem: vi.fn(),
|
|
154
|
+
isAvailable: vi.fn(() => true),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const tm = new ThemeManager({
|
|
158
|
+
...defaultConfig,
|
|
159
|
+
enablePersistence: true,
|
|
160
|
+
storageKey: 'test-key',
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await tm.setTheme('theme-2');
|
|
164
|
+
expect(mockSetItem).toHaveBeenCalledWith('test-key', 'theme-2');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should enable/disable persistence', () => {
|
|
168
|
+
const mockSetItem = vi.fn();
|
|
169
|
+
const mockRemoveItem = vi.fn();
|
|
170
|
+
vi.mocked(utils.createLocalStorageAdapter).mockReturnValue({
|
|
171
|
+
getItem: vi.fn(),
|
|
172
|
+
setItem: mockSetItem,
|
|
173
|
+
removeItem: mockRemoveItem,
|
|
174
|
+
isAvailable: vi.fn(() => true),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const tm = new ThemeManager(defaultConfig);
|
|
178
|
+
|
|
179
|
+
tm.enablePersistence('new-key');
|
|
180
|
+
expect(mockSetItem).toHaveBeenCalledWith('new-key', 'theme-1');
|
|
181
|
+
|
|
182
|
+
tm.disablePersistence();
|
|
183
|
+
expect(mockRemoveItem).toHaveBeenCalledWith('new-key');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|