bulma-turbo-themes 0.8.1 → 0.10.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.
@@ -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: 'Bulma Light',
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: 'Bulma Dark',
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-dark.png',
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: 'Catppuccin Latte',
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: 'Catppuccin Frappé',
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: 'Catppuccin Macchiato',
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: 'Catppuccin Mocha',
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: 'Dracula',
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: 'GitHub Light',
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: 'GitHub Dark',
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
- const flavorLink = doc.getElementById('theme-flavor-css');
75
- if (flavorLink) {
76
- // Build a safe URL relative to base by prepending baseUrl to relative path
77
- try {
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
- doc.documentElement.setAttribute('data-flavor', themeId);
87
- // Update trigger button icon
88
- const triggerIcon = doc.getElementById('theme-flavor-trigger-icon');
89
- if (triggerIcon) {
90
- // Clear existing content first
91
- while (triggerIcon.firstChild) {
92
- triggerIcon.removeChild(triggerIcon.firstChild);
93
- }
94
- if (theme.icon) {
95
- // Create and append img element (CSP-friendly)
96
- const img = doc.createElement('img');
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
- const fullPath = baseUrl ? `${baseUrl}/${theme.icon}` : theme.icon;
99
- const url = new URL(fullPath, 'http://localhost');
100
- img.src = url.pathname;
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
- // Ignore invalid URL
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
- else {
112
- // Fallback: show first two letters with circular background
113
- const span = doc.createElement('span');
114
- span.textContent = theme.name.substring(0, 2).toUpperCase();
115
- span.style.fontSize = '12px';
116
- span.style.fontWeight = 'bold';
117
- span.style.color = 'var(--theme-text, inherit)';
118
- span.style.display = 'flex';
119
- span.style.alignItems = 'center';
120
- span.style.justifyContent = 'center';
121
- span.style.width = '28px';
122
- span.style.height = '28px';
123
- span.style.borderRadius = '50%';
124
- span.style.backgroundColor = 'var(--theme-surface-1, #f5f5f5)';
125
- span.style.border = '1px solid var(--theme-border, #ddd)';
126
- span.title = theme.name; // Tooltip on hover
127
- triggerIcon.appendChild(span);
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
- // Update active state in dropdown
131
- const dropdownItems = doc.querySelectorAll('#theme-flavor-items .dropdown-item');
132
- dropdownItems.forEach((item) => {
133
- if (item.getAttribute('data-theme-id') === themeId) {
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
- else {
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
- applyTheme(documentObj, savedTheme);
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.initNavbar = initNavbar;
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
- const dropdownContent = documentObj.getElementById('theme-flavor-items');
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.getAttribute('data-flavor') ||
348
+ getCurrentThemeFromClasses(documentObj.documentElement) ||
224
349
  DEFAULT_THEME;
225
- // Populate dropdown with theme options
226
- THEMES.forEach((theme) => {
227
- const item = documentObj.createElement('a');
228
- item.href = '#';
229
- item.className = 'dropdown-item';
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
- if (theme.icon) {
240
- const img = documentObj.createElement('img');
241
- try {
242
- const fullPath = baseUrl ? `${baseUrl}/${theme.icon}` : theme.icon;
243
- const url = new URL(fullPath, 'http://localhost');
244
- img.src = url.pathname;
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
- img.alt = theme.name;
250
- img.title = theme.name;
251
- img.width = 28;
252
- img.height = 28;
253
- item.appendChild(img);
254
- }
255
- else {
256
- // Fallback: show first two letters with styled background
257
- const span = documentObj.createElement('span');
258
- span.textContent = theme.name.substring(0, 2);
259
- span.style.fontSize = '14px';
260
- span.style.fontWeight = 'bold';
261
- span.style.color = 'var(--theme-text, inherit)';
262
- item.appendChild(span);
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
- // Always include a visually hidden full name for screen readers
265
- const srOnly = documentObj.createElement('span');
266
- srOnly.textContent = theme.name;
267
- srOnly.style.position = 'absolute';
268
- srOnly.style.width = '1px';
269
- srOnly.style.height = '1px';
270
- srOnly.style.padding = '0';
271
- srOnly.style.margin = '-1px';
272
- srOnly.style.overflow = 'hidden';
273
- srOnly.style.clip = 'rect(0, 0, 0, 0)';
274
- srOnly.style.whiteSpace = 'nowrap';
275
- srOnly.style.border = '0';
276
- item.appendChild(srOnly);
277
- item.addEventListener('click', (e) => {
278
- e.preventDefault();
279
- applyTheme(documentObj, theme.id);
280
- windowObj.localStorage.setItem(STORAGE_KEY, theme.id);
281
- closeDropdown({ restoreFocus: true });
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
- menuItems.push(item);
284
- dropdownContent.appendChild(item);
479
+ group.appendChild(themesContainer);
480
+ dropdownMenu.appendChild(group);
285
481
  });
286
482
  // Update aria-expanded on trigger
287
483
  const updateAriaExpanded = (expanded) => {
288
- trigger.setAttribute('aria-expanded', String(expanded));
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.classList.remove('is-active');
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('tabindex', '-1');
341
- menuItem.setAttribute('aria-checked', String(menuItem.classList.contains('is-active')));
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
- trigger.addEventListener('click', (e) => {
352
- e.preventDefault();
353
- toggleDropdown();
354
- }, { signal: abortController.signal });
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
- trigger.addEventListener('keydown', (e) => {
370
- const key = e.key;
371
- if (key === 'Enter' || key === ' ') {
372
- e.preventDefault();
373
- const wasActive = dropdown.classList.contains('is-active');
374
- if (wasActive) {
375
- // If already open, close it
376
- toggleDropdown(false);
377
- }
378
- else {
379
- // If closed, open and focus first item
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
- const nextIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0;
397
- focusMenuItem(nextIndex);
574
+ // If closed, open and focus first item
575
+ toggleDropdown(true);
398
576
  }
399
577
  }
400
- }
401
- else if (key === 'ArrowUp') {
402
- e.preventDefault();
403
- if (!dropdown.classList.contains('is-active')) {
404
- dropdown.classList.add('is-active');
405
- updateAriaExpanded(true);
406
- // Start from last item when opening with ArrowUp
407
- focusMenuItem(menuItems.length - 1);
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
- // If currentIndex is -1 (dropdown opened via mouse), start from last
411
- const startIndex = currentIndex < 0 ? menuItems.length - 1 : currentIndex;
412
- const prevIndex = startIndex > 0 ? startIndex - 1 : menuItems.length - 1;
413
- focusMenuItem(prevIndex);
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
- }, { signal: abortController.signal });
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
- console.warn('Theme switcher initializing...');
472
- try {
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
- console.warn('Theme switcher initialized successfully');
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