bulma-turbo-themes 0.10.8 → 0.11.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/README.md +26 -26
- data/lib/bulma-turbo-themes.rb +3 -4
- metadata +23 -58
- data/_data/i18n.yml +0 -3
- data/_layouts/default.html +0 -377
- data/assets/css/custom.css +0 -62
- data/assets/css/themes/base.css +0 -2
- data/assets/css/themes/bulma-dark.css +0 -1
- data/assets/css/themes/bulma-light.css +0 -1
- data/assets/css/themes/catppuccin-frappe.css +0 -1
- data/assets/css/themes/catppuccin-latte.css +0 -1
- data/assets/css/themes/catppuccin-macchiato.css +0 -1
- data/assets/css/themes/catppuccin-mocha.css +0 -1
- data/assets/css/themes/critical.css +0 -1
- data/assets/css/themes/dracula.css +0 -1
- data/assets/css/themes/github-dark.css +0 -1
- data/assets/css/themes/github-light.css +0 -1
- data/assets/img/bulma-logo-dark.png +0 -0
- data/assets/img/bulma-logo-dark.webp +0 -0
- data/assets/img/bulma-logo.png +0 -0
- data/assets/img/bulma-logo.webp +0 -0
- data/assets/img/catppuccin-logo-latte.png +0 -0
- data/assets/img/catppuccin-logo-latte.webp +0 -0
- data/assets/img/catppuccin-logo-macchiato.png +0 -0
- data/assets/img/catppuccin-logo-macchiato.webp +0 -0
- data/assets/img/dracula-logo.png +0 -0
- data/assets/img/dracula-logo.webp +0 -0
- data/assets/img/github-logo-dark.png +0 -0
- data/assets/img/github-logo-dark.webp +0 -0
- data/assets/img/github-logo-light.png +0 -0
- data/assets/img/github-logo-light.webp +0 -0
- data/assets/js/theme-selector.js +0 -687
- data/assets/js/theme-selector.min.js +0 -1
- data/lib/bulma-turbo-themes/version.rb +0 -6
data/assets/js/theme-selector.js
DELETED
|
@@ -1,687 +0,0 @@
|
|
|
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
|
-
};
|
|
7
|
-
const THEMES = [
|
|
8
|
-
// Bulma themes
|
|
9
|
-
{
|
|
10
|
-
id: 'bulma-light',
|
|
11
|
-
name: 'Light',
|
|
12
|
-
description: 'Classic Bulma look with a bright, neutral palette.',
|
|
13
|
-
cssFile: 'assets/css/themes/bulma-light.css',
|
|
14
|
-
icon: 'assets/img/bulma-logo.png',
|
|
15
|
-
family: 'bulma',
|
|
16
|
-
appearance: 'light',
|
|
17
|
-
colors: { bg: '#ffffff', surface: '#f5f5f5', accent: '#00d1b2', text: '#363636' },
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
id: 'bulma-dark',
|
|
21
|
-
name: 'Dark',
|
|
22
|
-
description: 'Dark Bulma theme tuned for low-light reading.',
|
|
23
|
-
cssFile: 'assets/css/themes/bulma-dark.css',
|
|
24
|
-
icon: 'assets/img/bulma-logo.png',
|
|
25
|
-
family: 'bulma',
|
|
26
|
-
appearance: 'dark',
|
|
27
|
-
colors: { bg: '#1a1a2e', surface: '#252540', accent: '#00d1b2', text: '#f5f5f5' },
|
|
28
|
-
},
|
|
29
|
-
// Catppuccin themes
|
|
30
|
-
{
|
|
31
|
-
id: 'catppuccin-latte',
|
|
32
|
-
name: 'Latte',
|
|
33
|
-
description: 'Light, soft Catppuccin palette for daytime use.',
|
|
34
|
-
cssFile: 'assets/css/themes/catppuccin-latte.css',
|
|
35
|
-
icon: 'assets/img/catppuccin-logo-latte.png',
|
|
36
|
-
family: 'catppuccin',
|
|
37
|
-
appearance: 'light',
|
|
38
|
-
colors: { bg: '#eff1f5', surface: '#e6e9ef', accent: '#8839ef', text: '#4c4f69' },
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
id: 'catppuccin-frappe',
|
|
42
|
-
name: 'Frappé',
|
|
43
|
-
description: 'Balanced dark Catppuccin theme for focused work.',
|
|
44
|
-
cssFile: 'assets/css/themes/catppuccin-frappe.css',
|
|
45
|
-
icon: 'assets/img/catppuccin-logo-latte.png',
|
|
46
|
-
family: 'catppuccin',
|
|
47
|
-
appearance: 'dark',
|
|
48
|
-
colors: { bg: '#303446', surface: '#414559', accent: '#ca9ee6', text: '#c6d0f5' },
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
id: 'catppuccin-macchiato',
|
|
52
|
-
name: 'Macchiato',
|
|
53
|
-
description: 'Deep, atmospheric Catppuccin variant with rich contrast.',
|
|
54
|
-
cssFile: 'assets/css/themes/catppuccin-macchiato.css',
|
|
55
|
-
icon: 'assets/img/catppuccin-logo-macchiato.png',
|
|
56
|
-
family: 'catppuccin',
|
|
57
|
-
appearance: 'dark',
|
|
58
|
-
colors: { bg: '#24273a', surface: '#363a4f', accent: '#c6a0f6', text: '#cad3f5' },
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
id: 'catppuccin-mocha',
|
|
62
|
-
name: 'Mocha',
|
|
63
|
-
description: 'Cozy, high-contrast Catppuccin theme for late-night sessions.',
|
|
64
|
-
cssFile: 'assets/css/themes/catppuccin-mocha.css',
|
|
65
|
-
icon: 'assets/img/catppuccin-logo-macchiato.png',
|
|
66
|
-
family: 'catppuccin',
|
|
67
|
-
appearance: 'dark',
|
|
68
|
-
colors: { bg: '#1e1e2e', surface: '#313244', accent: '#cba6f7', text: '#cdd6f4' },
|
|
69
|
-
},
|
|
70
|
-
// Dracula theme
|
|
71
|
-
{
|
|
72
|
-
id: 'dracula',
|
|
73
|
-
name: 'Classic',
|
|
74
|
-
description: 'Iconic Dracula dark theme with vibrant accents.',
|
|
75
|
-
cssFile: 'assets/css/themes/dracula.css',
|
|
76
|
-
icon: 'assets/img/dracula-logo.png',
|
|
77
|
-
family: 'dracula',
|
|
78
|
-
appearance: 'dark',
|
|
79
|
-
colors: { bg: '#282a36', surface: '#44475a', accent: '#bd93f9', text: '#f8f8f2' },
|
|
80
|
-
},
|
|
81
|
-
// GitHub themes
|
|
82
|
-
{
|
|
83
|
-
id: 'github-light',
|
|
84
|
-
name: 'Light',
|
|
85
|
-
description: 'GitHub-inspired light theme suited for documentation and UI heavy pages.',
|
|
86
|
-
cssFile: 'assets/css/themes/github-light.css',
|
|
87
|
-
icon: 'assets/img/github-logo-light.png',
|
|
88
|
-
family: 'github',
|
|
89
|
-
appearance: 'light',
|
|
90
|
-
colors: { bg: '#ffffff', surface: '#f6f8fa', accent: '#0969da', text: '#1f2328' },
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
id: 'github-dark',
|
|
94
|
-
name: 'Dark',
|
|
95
|
-
description: 'GitHub dark theme optimized for code-heavy views.',
|
|
96
|
-
cssFile: 'assets/css/themes/github-dark.css',
|
|
97
|
-
icon: 'assets/img/github-logo-dark.png',
|
|
98
|
-
family: 'github',
|
|
99
|
-
appearance: 'dark',
|
|
100
|
-
colors: { bg: '#0d1117', surface: '#161b22', accent: '#58a6ff', text: '#c9d1d9' },
|
|
101
|
-
},
|
|
102
|
-
];
|
|
103
|
-
const STORAGE_KEY = 'bulma-theme-flavor';
|
|
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
|
-
}
|
|
114
|
-
function getBaseUrl(doc) {
|
|
115
|
-
const baseElement = doc.documentElement;
|
|
116
|
-
const raw = baseElement?.getAttribute('data-baseurl') || '';
|
|
117
|
-
try {
|
|
118
|
-
const u = new URL(raw, 'http://localhost');
|
|
119
|
-
// Only allow same-origin relative paths; strip origin used for parsing
|
|
120
|
-
return u.origin === 'http://localhost' ? u.pathname.replace(/\/$/, '') : '';
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
return '';
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
async function applyTheme(doc, themeId) {
|
|
127
|
-
const theme = THEMES.find((t) => t.id === themeId) || THEMES.find((t) => t.id === DEFAULT_THEME);
|
|
128
|
-
const baseUrl = getBaseUrl(doc);
|
|
129
|
-
// Add loading state to trigger button
|
|
130
|
-
const trigger = doc.getElementById('theme-flavor-trigger');
|
|
131
|
-
if (trigger) {
|
|
132
|
-
trigger.classList.add('is-loading');
|
|
133
|
-
}
|
|
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);
|
|
155
|
-
try {
|
|
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;
|
|
163
|
-
}
|
|
164
|
-
catch {
|
|
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);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
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
|
-
}
|
|
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
|
-
});
|
|
238
|
-
}
|
|
239
|
-
finally {
|
|
240
|
-
// Remove loading state
|
|
241
|
-
if (trigger) {
|
|
242
|
-
trigger.classList.remove('is-loading');
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
export async function initTheme(documentObj, windowObj) {
|
|
247
|
-
// Check if theme was already applied by blocking script
|
|
248
|
-
const initialTheme = windowObj.__INITIAL_THEME__;
|
|
249
|
-
const savedTheme = windowObj.localStorage.getItem(STORAGE_KEY) || DEFAULT_THEME;
|
|
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);
|
|
266
|
-
}
|
|
267
|
-
export function initNavbar(documentObj) {
|
|
268
|
-
const currentPath = documentObj.location.pathname;
|
|
269
|
-
const navbarItems = documentObj.querySelectorAll('.navbar-item');
|
|
270
|
-
// Find the matching link first
|
|
271
|
-
let matchingItem = null;
|
|
272
|
-
const checkedItems = new Set();
|
|
273
|
-
navbarItems.forEach((item) => {
|
|
274
|
-
const link = item;
|
|
275
|
-
if (link.href) {
|
|
276
|
-
try {
|
|
277
|
-
const linkPath = new URL(link.href).pathname;
|
|
278
|
-
// Remove trailing slashes for comparison
|
|
279
|
-
const normalizedCurrentPath = currentPath.replace(/\/$/, '') || '/';
|
|
280
|
-
const normalizedLinkPath = linkPath.replace(/\/$/, '') || '/';
|
|
281
|
-
checkedItems.add(item);
|
|
282
|
-
if (normalizedCurrentPath === normalizedLinkPath) {
|
|
283
|
-
matchingItem = item;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
catch {
|
|
287
|
-
// Ignore invalid URLs - don't add to checkedItems
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
});
|
|
291
|
-
// Clear all active states except the matching one (only for items that were checked)
|
|
292
|
-
navbarItems.forEach((item) => {
|
|
293
|
-
if (item !== matchingItem && checkedItems.has(item)) {
|
|
294
|
-
item.classList.remove('is-active');
|
|
295
|
-
const link = item;
|
|
296
|
-
// Check if removeAttribute exists (for test mocks that might not have it)
|
|
297
|
-
if (link && 'removeAttribute' in link && typeof link.removeAttribute === 'function') {
|
|
298
|
-
link.removeAttribute('aria-current');
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
});
|
|
302
|
-
// Set active state for the matching link
|
|
303
|
-
if (matchingItem) {
|
|
304
|
-
matchingItem.classList.add('is-active');
|
|
305
|
-
const link = matchingItem;
|
|
306
|
-
// Check if setAttribute exists (for test mocks that might not have it)
|
|
307
|
-
if (link && 'setAttribute' in link && typeof link.setAttribute === 'function') {
|
|
308
|
-
link.setAttribute('aria-current', 'page');
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
// Handle Reports dropdown highlighting
|
|
312
|
-
const reportsLink = documentObj.querySelector('[data-testid="nav-reports"]');
|
|
313
|
-
if (reportsLink) {
|
|
314
|
-
const reportPaths = ['/coverage', '/playwright', '/lighthouse'];
|
|
315
|
-
const normalizedCurrentPath = currentPath.replace(/\/$/, '') || '/';
|
|
316
|
-
const isOnReportsPage = reportPaths.some((path) => normalizedCurrentPath === path || normalizedCurrentPath.startsWith(path + '/'));
|
|
317
|
-
if (isOnReportsPage) {
|
|
318
|
-
reportsLink.classList.add('is-active');
|
|
319
|
-
}
|
|
320
|
-
else {
|
|
321
|
-
reportsLink.classList.remove('is-active');
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
// Only assign to window in browser context
|
|
326
|
-
if (typeof window !== 'undefined') {
|
|
327
|
-
window.initNavbar = initNavbar;
|
|
328
|
-
}
|
|
329
|
-
export function wireFlavorSelector(documentObj, windowObj) {
|
|
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');
|
|
336
|
-
const baseUrl = getBaseUrl(documentObj);
|
|
337
|
-
if (!dropdownMenu || !trigger || !dropdown) {
|
|
338
|
-
return {
|
|
339
|
-
cleanup: () => {
|
|
340
|
-
abortController.abort();
|
|
341
|
-
},
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
let currentIndex = -1;
|
|
345
|
-
const menuItems = [];
|
|
346
|
-
// Get current theme to set initial aria-checked state
|
|
347
|
-
const currentThemeId = windowObj.localStorage.getItem(STORAGE_KEY) ||
|
|
348
|
-
getCurrentThemeFromClasses(documentObj.documentElement) ||
|
|
349
|
-
DEFAULT_THEME;
|
|
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);
|
|
355
|
-
}
|
|
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;
|
|
362
|
-
}
|
|
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`);
|
|
392
|
-
}
|
|
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);
|
|
478
|
-
});
|
|
479
|
-
group.appendChild(themesContainer);
|
|
480
|
-
dropdownMenu.appendChild(group);
|
|
481
|
-
});
|
|
482
|
-
// Update aria-expanded on trigger
|
|
483
|
-
const updateAriaExpanded = (expanded) => {
|
|
484
|
-
if (trigger) {
|
|
485
|
-
trigger.setAttribute('aria-expanded', String(expanded));
|
|
486
|
-
}
|
|
487
|
-
};
|
|
488
|
-
// Focus management
|
|
489
|
-
const focusMenuItem = (index) => {
|
|
490
|
-
if (index < 0 || index >= menuItems.length)
|
|
491
|
-
return;
|
|
492
|
-
const item = menuItems[index];
|
|
493
|
-
// Set tabindex to -1 for all items
|
|
494
|
-
menuItems.forEach((menuItem) => {
|
|
495
|
-
menuItem.setAttribute('tabindex', '-1');
|
|
496
|
-
});
|
|
497
|
-
// Focus and set tabindex to 0 on current item
|
|
498
|
-
item.setAttribute('tabindex', '0');
|
|
499
|
-
item.focus();
|
|
500
|
-
currentIndex = index;
|
|
501
|
-
};
|
|
502
|
-
const closeDropdown = (options = {}) => {
|
|
503
|
-
const { restoreFocus = true } = options;
|
|
504
|
-
if (dropdown) {
|
|
505
|
-
dropdown.classList.remove('is-active');
|
|
506
|
-
}
|
|
507
|
-
updateAriaExpanded(false);
|
|
508
|
-
menuItems.forEach((menuItem) => {
|
|
509
|
-
menuItem.setAttribute('tabindex', '-1');
|
|
510
|
-
});
|
|
511
|
-
currentIndex = -1;
|
|
512
|
-
if (restoreFocus && trigger) {
|
|
513
|
-
// Only restore focus to trigger when explicitly requested (e.g., selection or Esc)
|
|
514
|
-
trigger.focus();
|
|
515
|
-
}
|
|
516
|
-
};
|
|
517
|
-
// Toggle dropdown helper function
|
|
518
|
-
const toggleDropdown = (focusFirst = false) => {
|
|
519
|
-
if (!dropdown)
|
|
520
|
-
return;
|
|
521
|
-
const isActive = dropdown.classList.toggle('is-active');
|
|
522
|
-
updateAriaExpanded(isActive);
|
|
523
|
-
if (!isActive) {
|
|
524
|
-
currentIndex = -1;
|
|
525
|
-
menuItems.forEach((menuItem) => {
|
|
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
|
-
}
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
|
-
else if (focusFirst && menuItems.length > 0) {
|
|
536
|
-
// When opening via keyboard, focus first item
|
|
537
|
-
focusMenuItem(0);
|
|
538
|
-
}
|
|
539
|
-
};
|
|
540
|
-
// Toggle dropdown on trigger click (for touch devices)
|
|
541
|
-
if (trigger) {
|
|
542
|
-
trigger.addEventListener('click', (e) => {
|
|
543
|
-
e.preventDefault();
|
|
544
|
-
toggleDropdown();
|
|
545
|
-
}, { signal: abortController.signal });
|
|
546
|
-
}
|
|
547
|
-
// Close dropdown when clicking outside
|
|
548
|
-
documentObj.addEventListener('click', (e) => {
|
|
549
|
-
if (dropdown && !dropdown.contains(e.target)) {
|
|
550
|
-
// Close on any outside click; do not steal focus from the newly clicked element
|
|
551
|
-
closeDropdown({ restoreFocus: false });
|
|
552
|
-
}
|
|
553
|
-
}, { signal: abortController.signal });
|
|
554
|
-
// Handle Escape key globally to close dropdown
|
|
555
|
-
documentObj.addEventListener('keydown', (e) => {
|
|
556
|
-
if (e.key === 'Escape' && dropdown && dropdown.classList.contains('is-active')) {
|
|
557
|
-
closeDropdown({ restoreFocus: true });
|
|
558
|
-
}
|
|
559
|
-
}, { signal: abortController.signal });
|
|
560
|
-
// Keyboard navigation
|
|
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);
|
|
572
|
-
}
|
|
573
|
-
else {
|
|
574
|
-
// If closed, open and focus first item
|
|
575
|
-
toggleDropdown(true);
|
|
576
|
-
}
|
|
577
|
-
}
|
|
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
|
-
}
|
|
595
|
-
}
|
|
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
|
-
}
|
|
610
|
-
}
|
|
611
|
-
}, { signal: abortController.signal });
|
|
612
|
-
}
|
|
613
|
-
// Keyboard navigation on menu items
|
|
614
|
-
menuItems.forEach((item, index) => {
|
|
615
|
-
item.addEventListener('keydown', (e) => {
|
|
616
|
-
const key = e.key;
|
|
617
|
-
if (key === 'ArrowDown') {
|
|
618
|
-
e.preventDefault();
|
|
619
|
-
const nextIndex = index < menuItems.length - 1 ? index + 1 : 0;
|
|
620
|
-
focusMenuItem(nextIndex);
|
|
621
|
-
}
|
|
622
|
-
else if (key === 'ArrowUp') {
|
|
623
|
-
e.preventDefault();
|
|
624
|
-
const prevIndex = index > 0 ? index - 1 : menuItems.length - 1;
|
|
625
|
-
focusMenuItem(prevIndex);
|
|
626
|
-
}
|
|
627
|
-
else if (key === 'Escape') {
|
|
628
|
-
e.preventDefault();
|
|
629
|
-
closeDropdown();
|
|
630
|
-
}
|
|
631
|
-
else if (key === 'Enter' || key === ' ') {
|
|
632
|
-
e.preventDefault();
|
|
633
|
-
item.click();
|
|
634
|
-
}
|
|
635
|
-
else if (key === 'Home') {
|
|
636
|
-
e.preventDefault();
|
|
637
|
-
focusMenuItem(0);
|
|
638
|
-
}
|
|
639
|
-
else if (key === 'End') {
|
|
640
|
-
e.preventDefault();
|
|
641
|
-
focusMenuItem(menuItems.length - 1);
|
|
642
|
-
}
|
|
643
|
-
}, { signal: abortController.signal });
|
|
644
|
-
});
|
|
645
|
-
// Initialize aria-expanded
|
|
646
|
-
updateAriaExpanded(false);
|
|
647
|
-
// For navbar dropdown, ensure proper initial state
|
|
648
|
-
if (dropdown) {
|
|
649
|
-
dropdown.classList.remove('is-active');
|
|
650
|
-
}
|
|
651
|
-
return {
|
|
652
|
-
cleanup: () => {
|
|
653
|
-
abortController.abort();
|
|
654
|
-
},
|
|
655
|
-
};
|
|
656
|
-
}
|
|
657
|
-
export function enhanceAccessibility(documentObj) {
|
|
658
|
-
const pres = documentObj.querySelectorAll('.highlight > pre');
|
|
659
|
-
pres.forEach((pre) => {
|
|
660
|
-
if (!pre.hasAttribute('tabindex'))
|
|
661
|
-
pre.setAttribute('tabindex', '0');
|
|
662
|
-
if (!pre.hasAttribute('role'))
|
|
663
|
-
pre.setAttribute('role', 'region');
|
|
664
|
-
if (!pre.hasAttribute('aria-label'))
|
|
665
|
-
pre.setAttribute('aria-label', 'Code block');
|
|
666
|
-
});
|
|
667
|
-
}
|
|
668
|
-
// Auto-initialize on DOMContentLoaded
|
|
669
|
-
if (typeof document !== 'undefined' && typeof window !== 'undefined') {
|
|
670
|
-
document.addEventListener('DOMContentLoaded', () => {
|
|
671
|
-
initTheme(document, window)
|
|
672
|
-
.then(() => {
|
|
673
|
-
const { cleanup } = wireFlavorSelector(document, window);
|
|
674
|
-
enhanceAccessibility(document);
|
|
675
|
-
// Register cleanup to run on teardown
|
|
676
|
-
const pagehideHandler = () => {
|
|
677
|
-
cleanup();
|
|
678
|
-
window.removeEventListener('pagehide', pagehideHandler);
|
|
679
|
-
};
|
|
680
|
-
window.addEventListener('pagehide', pagehideHandler);
|
|
681
|
-
})
|
|
682
|
-
.catch((error) => {
|
|
683
|
-
console.error('Theme switcher initialization failed:', error);
|
|
684
|
-
});
|
|
685
|
-
});
|
|
686
|
-
}
|
|
687
|
-
//# sourceMappingURL=index.js.map
|