bulma-turbo-themes 0.8.1 → 0.9.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +86 -25
- data/_layouts/default.html +158 -51
- data/assets/css/custom.css +30 -0
- data/assets/css/themes/base.css +2 -0
- data/assets/css/themes/bulma-dark.css +1 -96
- data/assets/css/themes/bulma-light.css +1 -96
- data/assets/css/themes/catppuccin-frappe.css +1 -99
- data/assets/css/themes/catppuccin-latte.css +1 -99
- data/assets/css/themes/catppuccin-macchiato.css +1 -99
- data/assets/css/themes/catppuccin-mocha.css +1 -99
- data/assets/css/themes/critical.css +1 -0
- data/assets/css/themes/dracula.css +1 -212
- data/assets/css/themes/github-dark.css +1 -103
- data/assets/css/themes/github-light.css +1 -103
- data/assets/img/bulma-logo-dark.webp +0 -0
- data/assets/img/bulma-logo.webp +0 -0
- data/assets/img/catppuccin-logo-latte.webp +0 -0
- data/assets/img/catppuccin-logo-macchiato.webp +0 -0
- data/assets/img/dracula-logo.webp +0 -0
- data/assets/img/github-logo-dark.webp +0 -0
- data/assets/img/github-logo-light.webp +0 -0
- data/assets/js/theme-selector.js +409 -211
- data/assets/js/theme-selector.min.js +1 -0
- data/lib/bulma-turbo-themes/version.rb +1 -1
- metadata +12 -3
- data/assets/css/themes/global.css +0 -460
data/assets/js/theme-selector.js
CHANGED
|
@@ -1,61 +1,116 @@
|
|
|
1
|
+
const THEME_FAMILIES = {
|
|
2
|
+
bulma: { name: 'Bulma', description: 'Classic Bulma themes' },
|
|
3
|
+
catppuccin: { name: 'Catppuccin', description: 'Soothing pastel themes' },
|
|
4
|
+
github: { name: 'GitHub', description: 'GitHub-inspired themes' },
|
|
5
|
+
dracula: { name: 'Dracula', description: 'Dark vampire aesthetic' },
|
|
6
|
+
};
|
|
1
7
|
const THEMES = [
|
|
8
|
+
// Bulma themes
|
|
2
9
|
{
|
|
3
10
|
id: 'bulma-light',
|
|
4
|
-
name: '
|
|
11
|
+
name: 'Light',
|
|
12
|
+
description: 'Classic Bulma look with a bright, neutral palette.',
|
|
5
13
|
cssFile: 'assets/css/themes/bulma-light.css',
|
|
6
14
|
icon: 'assets/img/bulma-logo.png',
|
|
15
|
+
family: 'bulma',
|
|
16
|
+
appearance: 'light',
|
|
17
|
+
colors: { bg: '#ffffff', surface: '#f5f5f5', accent: '#00d1b2', text: '#363636' },
|
|
7
18
|
},
|
|
8
19
|
{
|
|
9
20
|
id: 'bulma-dark',
|
|
10
|
-
name: '
|
|
21
|
+
name: 'Dark',
|
|
22
|
+
description: 'Dark Bulma theme tuned for low-light reading.',
|
|
11
23
|
cssFile: 'assets/css/themes/bulma-dark.css',
|
|
12
|
-
icon: 'assets/img/bulma-logo
|
|
24
|
+
icon: 'assets/img/bulma-logo.png',
|
|
25
|
+
family: 'bulma',
|
|
26
|
+
appearance: 'dark',
|
|
27
|
+
colors: { bg: '#1a1a2e', surface: '#252540', accent: '#00d1b2', text: '#f5f5f5' },
|
|
13
28
|
},
|
|
29
|
+
// Catppuccin themes
|
|
14
30
|
{
|
|
15
31
|
id: 'catppuccin-latte',
|
|
16
|
-
name: '
|
|
32
|
+
name: 'Latte',
|
|
33
|
+
description: 'Light, soft Catppuccin palette for daytime use.',
|
|
17
34
|
cssFile: 'assets/css/themes/catppuccin-latte.css',
|
|
18
35
|
icon: 'assets/img/catppuccin-logo-latte.png',
|
|
36
|
+
family: 'catppuccin',
|
|
37
|
+
appearance: 'light',
|
|
38
|
+
colors: { bg: '#eff1f5', surface: '#e6e9ef', accent: '#8839ef', text: '#4c4f69' },
|
|
19
39
|
},
|
|
20
40
|
{
|
|
21
41
|
id: 'catppuccin-frappe',
|
|
22
|
-
name: '
|
|
42
|
+
name: 'Frappé',
|
|
43
|
+
description: 'Balanced dark Catppuccin theme for focused work.',
|
|
23
44
|
cssFile: 'assets/css/themes/catppuccin-frappe.css',
|
|
24
45
|
icon: 'assets/img/catppuccin-logo-latte.png',
|
|
46
|
+
family: 'catppuccin',
|
|
47
|
+
appearance: 'dark',
|
|
48
|
+
colors: { bg: '#303446', surface: '#414559', accent: '#ca9ee6', text: '#c6d0f5' },
|
|
25
49
|
},
|
|
26
50
|
{
|
|
27
51
|
id: 'catppuccin-macchiato',
|
|
28
|
-
name: '
|
|
52
|
+
name: 'Macchiato',
|
|
53
|
+
description: 'Deep, atmospheric Catppuccin variant with rich contrast.',
|
|
29
54
|
cssFile: 'assets/css/themes/catppuccin-macchiato.css',
|
|
30
55
|
icon: 'assets/img/catppuccin-logo-macchiato.png',
|
|
56
|
+
family: 'catppuccin',
|
|
57
|
+
appearance: 'dark',
|
|
58
|
+
colors: { bg: '#24273a', surface: '#363a4f', accent: '#c6a0f6', text: '#cad3f5' },
|
|
31
59
|
},
|
|
32
60
|
{
|
|
33
61
|
id: 'catppuccin-mocha',
|
|
34
|
-
name: '
|
|
62
|
+
name: 'Mocha',
|
|
63
|
+
description: 'Cozy, high-contrast Catppuccin theme for late-night sessions.',
|
|
35
64
|
cssFile: 'assets/css/themes/catppuccin-mocha.css',
|
|
36
65
|
icon: 'assets/img/catppuccin-logo-macchiato.png',
|
|
66
|
+
family: 'catppuccin',
|
|
67
|
+
appearance: 'dark',
|
|
68
|
+
colors: { bg: '#1e1e2e', surface: '#313244', accent: '#cba6f7', text: '#cdd6f4' },
|
|
37
69
|
},
|
|
70
|
+
// Dracula theme
|
|
38
71
|
{
|
|
39
72
|
id: 'dracula',
|
|
40
|
-
name: '
|
|
73
|
+
name: 'Classic',
|
|
74
|
+
description: 'Iconic Dracula dark theme with vibrant accents.',
|
|
41
75
|
cssFile: 'assets/css/themes/dracula.css',
|
|
42
76
|
icon: 'assets/img/dracula-logo.png',
|
|
77
|
+
family: 'dracula',
|
|
78
|
+
appearance: 'dark',
|
|
79
|
+
colors: { bg: '#282a36', surface: '#44475a', accent: '#bd93f9', text: '#f8f8f2' },
|
|
43
80
|
},
|
|
81
|
+
// GitHub themes
|
|
44
82
|
{
|
|
45
83
|
id: 'github-light',
|
|
46
|
-
name: '
|
|
84
|
+
name: 'Light',
|
|
85
|
+
description: 'GitHub-inspired light theme suited for documentation and UI heavy pages.',
|
|
47
86
|
cssFile: 'assets/css/themes/github-light.css',
|
|
48
87
|
icon: 'assets/img/github-logo-light.png',
|
|
88
|
+
family: 'github',
|
|
89
|
+
appearance: 'light',
|
|
90
|
+
colors: { bg: '#ffffff', surface: '#f6f8fa', accent: '#0969da', text: '#1f2328' },
|
|
49
91
|
},
|
|
50
92
|
{
|
|
51
93
|
id: 'github-dark',
|
|
52
|
-
name: '
|
|
94
|
+
name: 'Dark',
|
|
95
|
+
description: 'GitHub dark theme optimized for code-heavy views.',
|
|
53
96
|
cssFile: 'assets/css/themes/github-dark.css',
|
|
54
97
|
icon: 'assets/img/github-logo-dark.png',
|
|
98
|
+
family: 'github',
|
|
99
|
+
appearance: 'dark',
|
|
100
|
+
colors: { bg: '#0d1117', surface: '#161b22', accent: '#58a6ff', text: '#c9d1d9' },
|
|
55
101
|
},
|
|
56
102
|
];
|
|
57
103
|
const STORAGE_KEY = 'bulma-theme-flavor';
|
|
58
104
|
const DEFAULT_THEME = 'catppuccin-mocha';
|
|
105
|
+
function getCurrentThemeFromClasses(element) {
|
|
106
|
+
const classList = Array.from(element.classList);
|
|
107
|
+
for (const className of classList) {
|
|
108
|
+
if (className.startsWith('theme-')) {
|
|
109
|
+
return className.substring(6); // Remove 'theme-' prefix
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
59
114
|
function getBaseUrl(doc) {
|
|
60
115
|
const baseElement = doc.documentElement;
|
|
61
116
|
const raw = baseElement?.getAttribute('data-baseurl') || '';
|
|
@@ -68,81 +123,146 @@ function getBaseUrl(doc) {
|
|
|
68
123
|
return '';
|
|
69
124
|
}
|
|
70
125
|
}
|
|
71
|
-
function applyTheme(doc, themeId) {
|
|
126
|
+
async function applyTheme(doc, themeId) {
|
|
72
127
|
const theme = THEMES.find((t) => t.id === themeId) || THEMES.find((t) => t.id === DEFAULT_THEME);
|
|
73
128
|
const baseUrl = getBaseUrl(doc);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const fullPath = baseUrl ? `${baseUrl}/${theme.cssFile}` : theme.cssFile;
|
|
79
|
-
const url = new URL(fullPath, 'http://localhost');
|
|
80
|
-
flavorLink.href = url.pathname;
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
// Ignore invalid URL
|
|
84
|
-
}
|
|
129
|
+
// Add loading state to trigger button
|
|
130
|
+
const trigger = doc.getElementById('theme-flavor-trigger');
|
|
131
|
+
if (trigger) {
|
|
132
|
+
trigger.classList.add('is-loading');
|
|
85
133
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
134
|
+
try {
|
|
135
|
+
// Apply theme class immediately (before CSS loading)
|
|
136
|
+
// This ensures the theme is applied even if CSS loading fails
|
|
137
|
+
const classList = Array.from(doc.documentElement.classList);
|
|
138
|
+
classList.forEach((className) => {
|
|
139
|
+
if (className.startsWith('theme-')) {
|
|
140
|
+
doc.documentElement.classList.remove(className);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
// Add the new theme class (use resolved theme.id, not the input themeId)
|
|
144
|
+
doc.documentElement.classList.add(`theme-${theme.id}`);
|
|
145
|
+
// Lazy load theme CSS if not already loaded
|
|
146
|
+
// Use resolved theme.id consistently (not the input themeId which may have been invalid)
|
|
147
|
+
const themeLinkId = `theme-${theme.id}-css`;
|
|
148
|
+
let themeLink = doc.getElementById(themeLinkId);
|
|
149
|
+
if (!themeLink) {
|
|
150
|
+
themeLink = doc.createElement('link');
|
|
151
|
+
themeLink.id = themeLinkId;
|
|
152
|
+
themeLink.rel = 'stylesheet';
|
|
153
|
+
themeLink.type = 'text/css';
|
|
154
|
+
themeLink.setAttribute('data-theme-id', theme.id);
|
|
97
155
|
try {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
156
|
+
// Resolve path relative to site root
|
|
157
|
+
// Use baseUrl if set, otherwise resolve from root
|
|
158
|
+
const base = baseUrl
|
|
159
|
+
? `${window.location.origin}${baseUrl}/`
|
|
160
|
+
: `${window.location.origin}/`;
|
|
161
|
+
const resolvedPath = new URL(theme.cssFile, base).pathname;
|
|
162
|
+
themeLink.href = resolvedPath;
|
|
101
163
|
}
|
|
102
164
|
catch {
|
|
103
|
-
|
|
165
|
+
console.warn(`Invalid theme CSS path for ${theme.id}`);
|
|
166
|
+
// Theme class already applied, so we can return successfully
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// Add to document head
|
|
170
|
+
doc.head.appendChild(themeLink);
|
|
171
|
+
// Wait for CSS to load (but don't fail if it doesn't load)
|
|
172
|
+
try {
|
|
173
|
+
await new Promise((resolve, reject) => {
|
|
174
|
+
// Store timeout ID to clear on success/error (prevents memory leak)
|
|
175
|
+
const timeoutId = setTimeout(() => {
|
|
176
|
+
themeLink.onload = null;
|
|
177
|
+
themeLink.onerror = null;
|
|
178
|
+
reject(new Error(`Theme ${theme.id} load timeout`));
|
|
179
|
+
}, 10000);
|
|
180
|
+
themeLink.onload = () => {
|
|
181
|
+
clearTimeout(timeoutId);
|
|
182
|
+
themeLink.onload = null;
|
|
183
|
+
themeLink.onerror = null;
|
|
184
|
+
resolve();
|
|
185
|
+
};
|
|
186
|
+
themeLink.onerror = () => {
|
|
187
|
+
clearTimeout(timeoutId);
|
|
188
|
+
themeLink.onload = null;
|
|
189
|
+
themeLink.onerror = null;
|
|
190
|
+
reject(new Error(`Failed to load theme ${theme.id}`));
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
// CSS loading failed, but theme class is already applied
|
|
196
|
+
// Log the error but don't throw - theme switching should still work
|
|
197
|
+
console.warn(`Theme CSS failed to load for ${theme.id}:`, error);
|
|
104
198
|
}
|
|
105
|
-
img.alt = theme.name;
|
|
106
|
-
img.title = theme.name; // Tooltip on hover
|
|
107
|
-
img.width = 28;
|
|
108
|
-
img.height = 28;
|
|
109
|
-
triggerIcon.appendChild(img);
|
|
110
199
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
200
|
+
// Clean up old theme CSS links (keep current and base themes)
|
|
201
|
+
const themeLinks = doc.querySelectorAll('link[id^="theme-"][id$="-css"]');
|
|
202
|
+
themeLinks.forEach((link) => {
|
|
203
|
+
const linkThemeId = link.id.replace('theme-', '').replace('-css', '');
|
|
204
|
+
if (linkThemeId !== theme.id && linkThemeId !== 'base') {
|
|
205
|
+
link.remove();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
// Update trigger button icon with theme's icon image
|
|
209
|
+
const triggerIcon = doc.getElementById('theme-flavor-trigger-icon');
|
|
210
|
+
if (triggerIcon && theme.icon) {
|
|
211
|
+
try {
|
|
212
|
+
// Resolve path relative to site root
|
|
213
|
+
// Use baseUrl if set, otherwise resolve from root
|
|
214
|
+
const base = baseUrl
|
|
215
|
+
? `${window.location.origin}${baseUrl}/`
|
|
216
|
+
: `${window.location.origin}/`;
|
|
217
|
+
const resolvedPath = new URL(theme.icon, base).pathname;
|
|
218
|
+
triggerIcon.src = resolvedPath;
|
|
219
|
+
triggerIcon.alt = `${THEME_FAMILIES[theme.family].name} ${theme.name}`;
|
|
220
|
+
triggerIcon.title = `${THEME_FAMILIES[theme.family].name} ${theme.name}`;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
console.warn(`Invalid theme icon path for ${theme.id}`);
|
|
224
|
+
}
|
|
128
225
|
}
|
|
226
|
+
// Update active state in dropdown
|
|
227
|
+
const dropdownItems = doc.querySelectorAll('#theme-flavor-menu .dropdown-item.theme-item');
|
|
228
|
+
dropdownItems.forEach((item) => {
|
|
229
|
+
if (item.getAttribute('data-theme-id') === theme.id) {
|
|
230
|
+
item.classList.add('is-active');
|
|
231
|
+
item.setAttribute('aria-checked', 'true');
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
item.classList.remove('is-active');
|
|
235
|
+
item.setAttribute('aria-checked', 'false');
|
|
236
|
+
}
|
|
237
|
+
});
|
|
129
238
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
item.classList.add('is-active');
|
|
135
|
-
item.setAttribute('aria-checked', 'true');
|
|
239
|
+
finally {
|
|
240
|
+
// Remove loading state
|
|
241
|
+
if (trigger) {
|
|
242
|
+
trigger.classList.remove('is-loading');
|
|
136
243
|
}
|
|
137
|
-
|
|
138
|
-
item.classList.remove('is-active');
|
|
139
|
-
item.setAttribute('aria-checked', 'false');
|
|
140
|
-
}
|
|
141
|
-
});
|
|
244
|
+
}
|
|
142
245
|
}
|
|
143
|
-
export function initTheme(documentObj, windowObj) {
|
|
246
|
+
export async function initTheme(documentObj, windowObj) {
|
|
247
|
+
// Check if theme was already applied by blocking script
|
|
248
|
+
const initialTheme = windowObj.__INITIAL_THEME__;
|
|
144
249
|
const savedTheme = windowObj.localStorage.getItem(STORAGE_KEY) || DEFAULT_THEME;
|
|
145
|
-
|
|
250
|
+
// If blocking script already applied theme and it matches saved, just load CSS if needed
|
|
251
|
+
if (initialTheme && initialTheme === savedTheme) {
|
|
252
|
+
const currentTheme = getCurrentThemeFromClasses(documentObj.documentElement);
|
|
253
|
+
if (currentTheme === savedTheme) {
|
|
254
|
+
// Theme class already applied by blocking script, just ensure CSS is loaded
|
|
255
|
+
const themeLinkId = `theme-${savedTheme}-css`;
|
|
256
|
+
const themeLink = documentObj.getElementById(themeLinkId);
|
|
257
|
+
if (!themeLink) {
|
|
258
|
+
// CSS not loaded yet, load it now
|
|
259
|
+
await applyTheme(documentObj, savedTheme);
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Otherwise, apply theme normally
|
|
265
|
+
await applyTheme(documentObj, savedTheme);
|
|
146
266
|
}
|
|
147
267
|
export function initNavbar(documentObj) {
|
|
148
268
|
const currentPath = documentObj.location.pathname;
|
|
@@ -202,14 +322,19 @@ export function initNavbar(documentObj) {
|
|
|
202
322
|
}
|
|
203
323
|
}
|
|
204
324
|
}
|
|
205
|
-
window
|
|
325
|
+
// Only assign to window in browser context
|
|
326
|
+
if (typeof window !== 'undefined') {
|
|
327
|
+
window.initNavbar = initNavbar;
|
|
328
|
+
}
|
|
206
329
|
export function wireFlavorSelector(documentObj, windowObj) {
|
|
207
330
|
const abortController = new AbortController();
|
|
331
|
+
const dropdownMenu = documentObj.getElementById('theme-flavor-menu');
|
|
332
|
+
const trigger = documentObj.getElementById('theme-flavor-trigger');
|
|
333
|
+
// Get the outer dropdown container (navbar-item has-dropdown) for active state toggling
|
|
334
|
+
const dropdown = trigger?.closest('.navbar-item.has-dropdown');
|
|
335
|
+
const selectEl = documentObj.getElementById('theme-flavor-select');
|
|
208
336
|
const baseUrl = getBaseUrl(documentObj);
|
|
209
|
-
|
|
210
|
-
const trigger = documentObj.querySelector('.theme-flavor-trigger');
|
|
211
|
-
const dropdown = documentObj.getElementById('theme-flavor-dd');
|
|
212
|
-
if (!dropdownContent || !trigger || !dropdown) {
|
|
337
|
+
if (!dropdownMenu || !trigger || !dropdown) {
|
|
213
338
|
return {
|
|
214
339
|
cleanup: () => {
|
|
215
340
|
abortController.abort();
|
|
@@ -220,72 +345,145 @@ export function wireFlavorSelector(documentObj, windowObj) {
|
|
|
220
345
|
const menuItems = [];
|
|
221
346
|
// Get current theme to set initial aria-checked state
|
|
222
347
|
const currentThemeId = windowObj.localStorage.getItem(STORAGE_KEY) ||
|
|
223
|
-
documentObj.documentElement
|
|
348
|
+
getCurrentThemeFromClasses(documentObj.documentElement) ||
|
|
224
349
|
DEFAULT_THEME;
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
item.setAttribute('data-theme-id', theme.id);
|
|
231
|
-
item.setAttribute('role', 'menuitemradio');
|
|
232
|
-
item.setAttribute('aria-label', theme.name);
|
|
233
|
-
item.setAttribute('tabindex', '-1');
|
|
234
|
-
const isActive = theme.id === currentThemeId;
|
|
235
|
-
item.setAttribute('aria-checked', String(isActive));
|
|
236
|
-
if (isActive) {
|
|
237
|
-
item.classList.add('is-active');
|
|
350
|
+
// Keep optional native select (DDL) in sync with current theme
|
|
351
|
+
if (selectEl) {
|
|
352
|
+
// Clear any existing options
|
|
353
|
+
while (selectEl.firstChild) {
|
|
354
|
+
selectEl.removeChild(selectEl.firstChild);
|
|
238
355
|
}
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
catch {
|
|
247
|
-
// Ignore invalid URL
|
|
356
|
+
THEMES.forEach((theme) => {
|
|
357
|
+
const option = documentObj.createElement('option');
|
|
358
|
+
option.value = theme.id;
|
|
359
|
+
option.textContent = theme.name;
|
|
360
|
+
if (theme.id === currentThemeId) {
|
|
361
|
+
option.selected = true;
|
|
248
362
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
363
|
+
selectEl.appendChild(option);
|
|
364
|
+
});
|
|
365
|
+
// Enable select when JS is active
|
|
366
|
+
selectEl.disabled = false;
|
|
367
|
+
// Allow changing theme via native select
|
|
368
|
+
selectEl.addEventListener('change', (event) => {
|
|
369
|
+
const target = event.target;
|
|
370
|
+
const selectedThemeId = target?.value || DEFAULT_THEME;
|
|
371
|
+
windowObj.localStorage.setItem(STORAGE_KEY, selectedThemeId);
|
|
372
|
+
applyTheme(documentObj, selectedThemeId).catch((error) => {
|
|
373
|
+
console.error(`Failed to apply theme ${selectedThemeId}:`, error);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
// Populate dropdown with grouped theme options
|
|
378
|
+
const families = Object.keys(THEME_FAMILIES);
|
|
379
|
+
let animationDelay = 0;
|
|
380
|
+
families.forEach((familyKey) => {
|
|
381
|
+
const familyThemes = THEMES.filter((t) => t.family === familyKey);
|
|
382
|
+
if (familyThemes.length === 0)
|
|
383
|
+
return;
|
|
384
|
+
const familyMeta = THEME_FAMILIES[familyKey];
|
|
385
|
+
// Create family group container
|
|
386
|
+
const group = documentObj.createElement('div');
|
|
387
|
+
group.className = 'theme-family-group';
|
|
388
|
+
group.setAttribute('role', 'group');
|
|
389
|
+
group.setAttribute('aria-labelledby', `theme-family-${familyKey}`);
|
|
390
|
+
if (group.style && typeof group.style.setProperty === 'function') {
|
|
391
|
+
group.style.setProperty('--animation-delay', `${animationDelay}ms`);
|
|
263
392
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
393
|
+
animationDelay += 30;
|
|
394
|
+
// Create family header
|
|
395
|
+
const header = documentObj.createElement('div');
|
|
396
|
+
header.className = 'theme-family-header';
|
|
397
|
+
header.id = `theme-family-${familyKey}`;
|
|
398
|
+
const headerTitle = documentObj.createElement('span');
|
|
399
|
+
headerTitle.className = 'theme-family-name';
|
|
400
|
+
headerTitle.textContent = familyMeta.name;
|
|
401
|
+
header.appendChild(headerTitle);
|
|
402
|
+
group.appendChild(header);
|
|
403
|
+
// Create themes container
|
|
404
|
+
const themesContainer = documentObj.createElement('div');
|
|
405
|
+
themesContainer.className = 'theme-family-items';
|
|
406
|
+
familyThemes.forEach((theme) => {
|
|
407
|
+
const item = documentObj.createElement('button');
|
|
408
|
+
item.type = 'button';
|
|
409
|
+
item.className = 'dropdown-item theme-item';
|
|
410
|
+
item.setAttribute('data-theme-id', theme.id);
|
|
411
|
+
item.setAttribute('data-appearance', theme.appearance);
|
|
412
|
+
item.setAttribute('role', 'menuitemradio');
|
|
413
|
+
item.setAttribute('aria-label', `${familyMeta.name} ${theme.name} (${theme.appearance}). ${theme.description}`);
|
|
414
|
+
item.setAttribute('tabindex', '-1');
|
|
415
|
+
const isActive = theme.id === currentThemeId;
|
|
416
|
+
item.setAttribute('aria-checked', String(isActive));
|
|
417
|
+
if (isActive) {
|
|
418
|
+
item.classList.add('is-active');
|
|
419
|
+
}
|
|
420
|
+
// Icon
|
|
421
|
+
const icon = documentObj.createElement('img');
|
|
422
|
+
icon.className = 'theme-icon';
|
|
423
|
+
if (theme.icon) {
|
|
424
|
+
const iconPath = baseUrl ? `${baseUrl}/${theme.icon}` : theme.icon;
|
|
425
|
+
icon.src = iconPath;
|
|
426
|
+
icon.alt = `${familyMeta.name} ${theme.name}`;
|
|
427
|
+
}
|
|
428
|
+
icon.width = 24;
|
|
429
|
+
icon.height = 24;
|
|
430
|
+
item.appendChild(icon);
|
|
431
|
+
// Text content
|
|
432
|
+
const copy = documentObj.createElement('div');
|
|
433
|
+
copy.className = 'theme-copy';
|
|
434
|
+
const titleEl = documentObj.createElement('span');
|
|
435
|
+
titleEl.className = 'theme-title';
|
|
436
|
+
titleEl.textContent = `${familyMeta.name} · ${theme.name}`;
|
|
437
|
+
copy.appendChild(titleEl);
|
|
438
|
+
const descriptionEl = documentObj.createElement('span');
|
|
439
|
+
descriptionEl.className = 'theme-description';
|
|
440
|
+
descriptionEl.textContent = theme.description;
|
|
441
|
+
copy.appendChild(descriptionEl);
|
|
442
|
+
item.appendChild(copy);
|
|
443
|
+
// Checkmark for active state
|
|
444
|
+
const check = documentObj.createElement('span');
|
|
445
|
+
check.className = 'theme-check';
|
|
446
|
+
const svg = documentObj.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
447
|
+
svg.setAttribute('width', '16');
|
|
448
|
+
svg.setAttribute('height', '16');
|
|
449
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
450
|
+
svg.setAttribute('fill', 'none');
|
|
451
|
+
svg.setAttribute('stroke', 'currentColor');
|
|
452
|
+
svg.setAttribute('stroke-width', '3');
|
|
453
|
+
svg.setAttribute('stroke-linecap', 'round');
|
|
454
|
+
svg.setAttribute('stroke-linejoin', 'round');
|
|
455
|
+
const polyline = documentObj.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
|
456
|
+
polyline.setAttribute('points', '20 6 9 17 4 12');
|
|
457
|
+
svg.appendChild(polyline);
|
|
458
|
+
check.appendChild(svg);
|
|
459
|
+
item.appendChild(check);
|
|
460
|
+
item.addEventListener('click', (e) => {
|
|
461
|
+
e.preventDefault();
|
|
462
|
+
// Always update localStorage and close dropdown, even if CSS loading fails
|
|
463
|
+
windowObj.localStorage.setItem(STORAGE_KEY, theme.id);
|
|
464
|
+
if (selectEl) {
|
|
465
|
+
selectEl.value = theme.id;
|
|
466
|
+
// Notify any listeners watching the native select
|
|
467
|
+
const changeEvent = new Event('change', { bubbles: true });
|
|
468
|
+
selectEl.dispatchEvent(changeEvent);
|
|
469
|
+
}
|
|
470
|
+
closeDropdown({ restoreFocus: true });
|
|
471
|
+
// Apply theme asynchronously (doesn't block dropdown closing)
|
|
472
|
+
applyTheme(documentObj, theme.id).catch((error) => {
|
|
473
|
+
console.error(`Failed to apply theme ${theme.id}:`, error);
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
menuItems.push(item);
|
|
477
|
+
themesContainer.appendChild(item);
|
|
282
478
|
});
|
|
283
|
-
|
|
284
|
-
|
|
479
|
+
group.appendChild(themesContainer);
|
|
480
|
+
dropdownMenu.appendChild(group);
|
|
285
481
|
});
|
|
286
482
|
// Update aria-expanded on trigger
|
|
287
483
|
const updateAriaExpanded = (expanded) => {
|
|
288
|
-
|
|
484
|
+
if (trigger) {
|
|
485
|
+
trigger.setAttribute('aria-expanded', String(expanded));
|
|
486
|
+
}
|
|
289
487
|
};
|
|
290
488
|
// Focus management
|
|
291
489
|
const focusMenuItem = (index) => {
|
|
@@ -301,119 +499,117 @@ export function wireFlavorSelector(documentObj, windowObj) {
|
|
|
301
499
|
item.focus();
|
|
302
500
|
currentIndex = index;
|
|
303
501
|
};
|
|
304
|
-
const openDropdown = () => {
|
|
305
|
-
dropdown.classList.add('is-active');
|
|
306
|
-
updateAriaExpanded(true);
|
|
307
|
-
currentIndex = -1;
|
|
308
|
-
};
|
|
309
502
|
const closeDropdown = (options = {}) => {
|
|
310
503
|
const { restoreFocus = true } = options;
|
|
311
|
-
dropdown
|
|
504
|
+
if (dropdown) {
|
|
505
|
+
dropdown.classList.remove('is-active');
|
|
506
|
+
}
|
|
312
507
|
updateAriaExpanded(false);
|
|
313
508
|
menuItems.forEach((menuItem) => {
|
|
314
509
|
menuItem.setAttribute('tabindex', '-1');
|
|
315
510
|
});
|
|
316
511
|
currentIndex = -1;
|
|
317
|
-
if (restoreFocus) {
|
|
512
|
+
if (restoreFocus && trigger) {
|
|
318
513
|
// Only restore focus to trigger when explicitly requested (e.g., selection or Esc)
|
|
319
514
|
trigger.focus();
|
|
320
515
|
}
|
|
321
516
|
};
|
|
322
|
-
// Open dropdown on hover
|
|
323
|
-
dropdown.addEventListener('mouseenter', () => {
|
|
324
|
-
openDropdown();
|
|
325
|
-
}, { signal: abortController.signal });
|
|
326
|
-
// Close dropdown when mouse leaves
|
|
327
|
-
dropdown.addEventListener('mouseleave', () => {
|
|
328
|
-
// Only close if not keyboard navigating
|
|
329
|
-
if (currentIndex === -1) {
|
|
330
|
-
closeDropdown();
|
|
331
|
-
}
|
|
332
|
-
}, { signal: abortController.signal });
|
|
333
517
|
// Toggle dropdown helper function
|
|
334
518
|
const toggleDropdown = (focusFirst = false) => {
|
|
519
|
+
if (!dropdown)
|
|
520
|
+
return;
|
|
335
521
|
const isActive = dropdown.classList.toggle('is-active');
|
|
336
522
|
updateAriaExpanded(isActive);
|
|
337
523
|
if (!isActive) {
|
|
338
524
|
currentIndex = -1;
|
|
339
525
|
menuItems.forEach((menuItem) => {
|
|
340
|
-
menuItem.setAttribute
|
|
341
|
-
|
|
526
|
+
if (menuItem && typeof menuItem.setAttribute === 'function') {
|
|
527
|
+
menuItem.setAttribute('tabindex', '-1');
|
|
528
|
+
const isActiveItem = menuItem.classList && typeof menuItem.classList.contains === 'function'
|
|
529
|
+
? menuItem.classList.contains('is-active')
|
|
530
|
+
: false;
|
|
531
|
+
menuItem.setAttribute('aria-checked', String(isActiveItem));
|
|
532
|
+
}
|
|
342
533
|
});
|
|
343
534
|
}
|
|
344
535
|
else if (focusFirst && menuItems.length > 0) {
|
|
345
|
-
// ...rest of the existing logic
|
|
346
536
|
// When opening via keyboard, focus first item
|
|
347
537
|
focusMenuItem(0);
|
|
348
538
|
}
|
|
349
539
|
};
|
|
350
540
|
// Toggle dropdown on trigger click (for touch devices)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
541
|
+
if (trigger) {
|
|
542
|
+
trigger.addEventListener('click', (e) => {
|
|
543
|
+
e.preventDefault();
|
|
544
|
+
toggleDropdown();
|
|
545
|
+
}, { signal: abortController.signal });
|
|
546
|
+
}
|
|
355
547
|
// Close dropdown when clicking outside
|
|
356
548
|
documentObj.addEventListener('click', (e) => {
|
|
357
|
-
if (!dropdown.contains(e.target)) {
|
|
549
|
+
if (dropdown && !dropdown.contains(e.target)) {
|
|
358
550
|
// Close on any outside click; do not steal focus from the newly clicked element
|
|
359
551
|
closeDropdown({ restoreFocus: false });
|
|
360
552
|
}
|
|
361
553
|
}, { signal: abortController.signal });
|
|
362
554
|
// Handle Escape key globally to close dropdown
|
|
363
555
|
documentObj.addEventListener('keydown', (e) => {
|
|
364
|
-
if (e.key === 'Escape' && dropdown.classList.contains('is-active')) {
|
|
556
|
+
if (e.key === 'Escape' && dropdown && dropdown.classList.contains('is-active')) {
|
|
365
557
|
closeDropdown({ restoreFocus: true });
|
|
366
558
|
}
|
|
367
559
|
}, { signal: abortController.signal });
|
|
368
560
|
// Keyboard navigation
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
if (
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
toggleDropdown(true);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
else if (key === 'ArrowDown') {
|
|
384
|
-
e.preventDefault();
|
|
385
|
-
if (!dropdown.classList.contains('is-active')) {
|
|
386
|
-
dropdown.classList.add('is-active');
|
|
387
|
-
updateAriaExpanded(true);
|
|
388
|
-
focusMenuItem(0); // Focus first item when opening
|
|
389
|
-
}
|
|
390
|
-
else {
|
|
391
|
-
// If currentIndex is -1 (dropdown opened via mouse or not yet initialized), focus first item
|
|
392
|
-
if (currentIndex < 0) {
|
|
393
|
-
focusMenuItem(0);
|
|
561
|
+
if (trigger) {
|
|
562
|
+
trigger.addEventListener('keydown', (e) => {
|
|
563
|
+
if (!dropdown)
|
|
564
|
+
return;
|
|
565
|
+
const key = e.key;
|
|
566
|
+
if (key === 'Enter' || key === ' ') {
|
|
567
|
+
e.preventDefault();
|
|
568
|
+
const wasActive = dropdown.classList.contains('is-active');
|
|
569
|
+
if (wasActive) {
|
|
570
|
+
// If already open, close it
|
|
571
|
+
toggleDropdown(false);
|
|
394
572
|
}
|
|
395
573
|
else {
|
|
396
|
-
|
|
397
|
-
|
|
574
|
+
// If closed, open and focus first item
|
|
575
|
+
toggleDropdown(true);
|
|
398
576
|
}
|
|
399
577
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
578
|
+
else if (key === 'ArrowDown') {
|
|
579
|
+
e.preventDefault();
|
|
580
|
+
if (!dropdown.classList.contains('is-active')) {
|
|
581
|
+
dropdown.classList.add('is-active');
|
|
582
|
+
updateAriaExpanded(true);
|
|
583
|
+
focusMenuItem(0); // Focus first item when opening
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
// If currentIndex is -1 (dropdown opened via mouse or not yet initialized), focus first item
|
|
587
|
+
if (currentIndex < 0) {
|
|
588
|
+
focusMenuItem(0);
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
const nextIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0;
|
|
592
|
+
focusMenuItem(nextIndex);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
408
595
|
}
|
|
409
|
-
else {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
596
|
+
else if (key === 'ArrowUp') {
|
|
597
|
+
e.preventDefault();
|
|
598
|
+
if (!dropdown.classList.contains('is-active')) {
|
|
599
|
+
dropdown.classList.add('is-active');
|
|
600
|
+
updateAriaExpanded(true);
|
|
601
|
+
// Start from last item when opening with ArrowUp
|
|
602
|
+
focusMenuItem(menuItems.length - 1);
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
// If currentIndex is -1 (dropdown opened via mouse), start from last
|
|
606
|
+
const startIndex = currentIndex < 0 ? menuItems.length - 1 : currentIndex;
|
|
607
|
+
const prevIndex = startIndex > 0 ? startIndex - 1 : menuItems.length - 1;
|
|
608
|
+
focusMenuItem(prevIndex);
|
|
609
|
+
}
|
|
414
610
|
}
|
|
415
|
-
}
|
|
416
|
-
}
|
|
611
|
+
}, { signal: abortController.signal });
|
|
612
|
+
}
|
|
417
613
|
// Keyboard navigation on menu items
|
|
418
614
|
menuItems.forEach((item, index) => {
|
|
419
615
|
item.addEventListener('keydown', (e) => {
|
|
@@ -448,6 +644,10 @@ export function wireFlavorSelector(documentObj, windowObj) {
|
|
|
448
644
|
});
|
|
449
645
|
// Initialize aria-expanded
|
|
450
646
|
updateAriaExpanded(false);
|
|
647
|
+
// For navbar dropdown, ensure proper initial state
|
|
648
|
+
if (dropdown) {
|
|
649
|
+
dropdown.classList.remove('is-active');
|
|
650
|
+
}
|
|
451
651
|
return {
|
|
452
652
|
cleanup: () => {
|
|
453
653
|
abortController.abort();
|
|
@@ -468,9 +668,8 @@ export function enhanceAccessibility(documentObj) {
|
|
|
468
668
|
// Auto-initialize on DOMContentLoaded
|
|
469
669
|
if (typeof document !== 'undefined' && typeof window !== 'undefined') {
|
|
470
670
|
document.addEventListener('DOMContentLoaded', () => {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
initTheme(document, window);
|
|
671
|
+
initTheme(document, window)
|
|
672
|
+
.then(() => {
|
|
474
673
|
const { cleanup } = wireFlavorSelector(document, window);
|
|
475
674
|
enhanceAccessibility(document);
|
|
476
675
|
// Register cleanup to run on teardown
|
|
@@ -479,11 +678,10 @@ if (typeof document !== 'undefined' && typeof window !== 'undefined') {
|
|
|
479
678
|
window.removeEventListener('pagehide', pagehideHandler);
|
|
480
679
|
};
|
|
481
680
|
window.addEventListener('pagehide', pagehideHandler);
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
catch (error) {
|
|
681
|
+
})
|
|
682
|
+
.catch((error) => {
|
|
485
683
|
console.error('Theme switcher initialization failed:', error);
|
|
486
|
-
}
|
|
684
|
+
});
|
|
487
685
|
});
|
|
488
686
|
}
|
|
489
687
|
//# sourceMappingURL=index.js.map
|