@vanduo-oss/framework 1.4.3 → 1.4.4

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.
@@ -69,9 +69,10 @@
69
69
  const snippets = this.queryWithin(root, '.vd-code-snippet');
70
70
 
71
71
  snippets.forEach(snippet => {
72
- if (!snippet.dataset.initialized) {
73
- this.initSnippet(snippet);
72
+ if (snippet.dataset.initialized === 'true') {
73
+ return;
74
74
  }
75
+ this.initSnippet(snippet);
75
76
  });
76
77
  },
77
78
 
@@ -80,7 +81,9 @@
80
81
  * @param {HTMLElement} snippet - Code snippet container element
81
82
  */
82
83
  initSnippet: function (snippet) {
83
- snippet.dataset.initialized = 'true';
84
+ if (snippet.dataset.initialized === 'true') {
85
+ return;
86
+ }
84
87
  snippet._codeSnippetCleanup = [];
85
88
 
86
89
  // Handle collapsible toggle
@@ -121,6 +124,8 @@
121
124
  lineNumberPanes.forEach(pane => {
122
125
  this.addLineNumbers(pane);
123
126
  });
127
+
128
+ snippet.dataset.initialized = 'true';
124
129
  },
125
130
 
126
131
  /**
@@ -132,14 +137,15 @@
132
137
  initCollapsible: function (snippet, toggle, content) {
133
138
  // Set initial state
134
139
  const isExpanded = snippet.dataset.expanded === 'true';
135
- toggle.setAttribute('aria-expanded', isExpanded);
136
- content.dataset.visible = isExpanded;
140
+ toggle.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
141
+ content.dataset.visible = isExpanded ? 'true' : 'false';
137
142
 
138
143
  this.addListener(snippet, toggle, 'click', () => {
139
144
  const expanded = snippet.dataset.expanded === 'true';
140
- snippet.dataset.expanded = !expanded;
141
- toggle.setAttribute('aria-expanded', !expanded);
142
- content.dataset.visible = !expanded;
145
+ const nextExpanded = !expanded;
146
+ snippet.dataset.expanded = nextExpanded ? 'true' : 'false';
147
+ toggle.setAttribute('aria-expanded', nextExpanded ? 'true' : 'false');
148
+ content.dataset.visible = nextExpanded ? 'true' : 'false';
143
149
 
144
150
  // Extract HTML on first expand if needed
145
151
  if (!expanded) {
@@ -6,23 +6,56 @@
6
6
  (function () {
7
7
  'use strict';
8
8
 
9
+ const THEME_MODES = ['system', 'light', 'dark'];
10
+
11
+ const THEME_ICON_CLASSES = {
12
+ system: 'ph ph-desktop',
13
+ light: 'ph ph-sun',
14
+ dark: 'ph ph-moon'
15
+ };
16
+
17
+ const THEME_LABELS = {
18
+ system: 'Theme: System',
19
+ light: 'Theme: Light',
20
+ dark: 'Theme: Dark'
21
+ };
22
+
23
+ const THEME_OPTION_TOOLTIPS = {
24
+ system: 'Use system preference',
25
+ light: 'Light theme',
26
+ dark: 'Dark theme'
27
+ };
28
+
9
29
  const ThemeSwitcher = {
10
30
  isInitialized: false,
11
31
  _mediaQuery: null,
12
32
  _onMediaChange: null,
33
+ menuInstances: new Map(),
13
34
 
14
35
  getToggles: function (root) {
36
+ const scope = root || document;
37
+ const toggles = window.Vanduo && typeof window.Vanduo.queryAll === 'function'
38
+ ? window.Vanduo.queryAll(scope, '[data-toggle="theme"]')
39
+ : Array.from(scope.querySelectorAll('[data-toggle="theme"]'));
40
+
41
+ return toggles.filter(function (toggle) {
42
+ return !toggle.closest('.vd-theme-switcher[data-theme-ui="menu"]');
43
+ });
44
+ },
45
+
46
+ getMenuSwitchers: function (root) {
47
+ const scope = root || document;
15
48
  if (window.Vanduo && typeof window.Vanduo.queryAll === 'function') {
16
- return window.Vanduo.queryAll(root, '[data-toggle="theme"]');
49
+ return window.Vanduo.queryAll(scope, '.vd-theme-switcher[data-theme-ui="menu"]');
17
50
  }
18
51
 
19
- return Array.from(document.querySelectorAll('[data-toggle="theme"]'));
52
+ return Array.from(scope.querySelectorAll('.vd-theme-switcher[data-theme-ui="menu"]'));
20
53
  },
21
54
 
22
55
  init: function (root) {
23
56
  this.STORAGE_KEY = 'vanduo-theme-preference';
24
57
  this.state = {
25
- preference: this.getPreference() // 'light', 'dark', or 'system'
58
+ preference: this.getPreference()
26
59
  };
27
60
 
28
61
  if (this.isInitialized) {
@@ -44,16 +77,18 @@
44
77
  },
45
78
 
46
79
  setPreference: function (pref) {
80
+ if (!THEME_MODES.includes(pref)) {
81
+ return;
82
+ }
83
+
47
84
  this.state.preference = pref;
48
85
  this.setStorageValue(this.STORAGE_KEY, pref);
49
86
  this.applyTheme();
50
-
51
- // Coordinate with ThemeCustomizer for primary color swap if available
52
- // Check _isApplying flag to prevent circular updates
87
+
53
88
  if (window.ThemeCustomizer && window.ThemeCustomizer.applyTheme && !window.ThemeCustomizer._isApplying) {
54
89
  window.ThemeCustomizer.applyTheme(pref);
55
90
  }
56
-
91
+
57
92
  this.updateUI();
58
93
  },
59
94
 
@@ -85,14 +120,6 @@
85
120
  const pref = this.state.preference;
86
121
 
87
122
  if (pref === 'system') {
88
- // When in system mode, we remove the data attribute to let the media query take over
89
- // or we can explicitly set it. Explicitly setting it ensures consistency if we rely on [data-theme]
90
- // But if we rely on @media in CSS, we might want to remove attributes.
91
- // However, our CSS strategy uses :root:not([data-theme="light"]) inside media query for system dark fallback
92
- // which is a bit complex.
93
-
94
- // Simpler approach:
95
- // If preference is system, REMOVE data-theme attribute. Let CSS media queries handle it.
96
123
  document.documentElement.removeAttribute('data-theme');
97
124
  } else {
98
125
  document.documentElement.setAttribute('data-theme', pref);
@@ -107,9 +134,7 @@
107
134
  this._mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
108
135
  this._onMediaChange = _e => {
109
136
  if (this.state.preference === 'system') {
110
- // Re-apply (effectively just to ensure consistency, though removing attribute usually suffices)
111
137
  this.applyTheme();
112
- // Keep default primary (black/amber) aligned when OS scheme changes while in system mode
113
138
  if (window.ThemeCustomizer && typeof window.ThemeCustomizer.applyTheme === 'function' && !window.ThemeCustomizer._isApplying) {
114
139
  window.ThemeCustomizer.applyTheme('system');
115
140
  }
@@ -118,9 +143,9 @@
118
143
  this._mediaQuery.addEventListener('change', this._onMediaChange);
119
144
  },
120
145
 
121
- // Helper to facilitate UI creation if needed, though often UI is in HTML
122
146
  renderUI: function (root) {
123
- // Look for any uninitialized theme toggles
147
+ this.renderMenuSwitchers(root);
148
+
124
149
  const toggles = this.getToggles(root);
125
150
  toggles.forEach(toggle => {
126
151
  if (toggle.getAttribute('data-theme-initialized') === 'true') {
@@ -130,7 +155,6 @@
130
155
  return;
131
156
  }
132
157
 
133
- // Simplified UI Binding - assumes a select or a button cycle
134
158
  if (toggle.tagName === 'SELECT') {
135
159
  toggle.value = this.state.preference;
136
160
  const onChange = (e) => {
@@ -139,18 +163,242 @@
139
163
  toggle.addEventListener('change', onChange);
140
164
  toggle._themeToggleHandler = onChange;
141
165
  } else {
142
- // Button implementation (cycle)
143
166
  const onClick = () => {
144
- const modes = ['system', 'light', 'dark'];
167
+ const modes = THEME_MODES;
145
168
  const nextIndex = (modes.indexOf(this.state.preference) + 1) % modes.length;
146
169
  this.setPreference(modes[nextIndex]);
147
170
  };
148
171
  toggle.addEventListener('click', onClick);
149
172
  toggle._themeToggleHandler = onClick;
150
173
  }
151
- // Mark as initialized
174
+
152
175
  toggle.setAttribute('data-theme-initialized', 'true');
153
176
  });
177
+
178
+ this.updateUI(root);
179
+ },
180
+
181
+ renderMenuSwitchers: function (root) {
182
+ const switchers = this.getMenuSwitchers(root);
183
+
184
+ switchers.forEach(switcher => {
185
+ if (switcher.getAttribute('data-theme-menu-initialized') === 'true') {
186
+ return;
187
+ }
188
+
189
+ const toggle = switcher.querySelector('.vd-theme-switcher-toggle');
190
+ const menu = switcher.querySelector('.vd-theme-switcher-menu');
191
+
192
+ if (!toggle || !menu) {
193
+ return;
194
+ }
195
+
196
+ const options = menu.querySelectorAll('[data-theme-value]');
197
+ const cleanupFunctions = [];
198
+
199
+ toggle.setAttribute('aria-haspopup', 'true');
200
+ toggle.setAttribute('aria-expanded', 'false');
201
+ menu.setAttribute('aria-hidden', 'true');
202
+
203
+ const toggleClickHandler = (e) => {
204
+ e.preventDefault();
205
+ e.stopPropagation();
206
+ this.toggleMenu(switcher, toggle, menu);
207
+ };
208
+ toggle.addEventListener('click', toggleClickHandler);
209
+ cleanupFunctions.push(() => toggle.removeEventListener('click', toggleClickHandler));
210
+
211
+ options.forEach(option => {
212
+ const optionClickHandler = (e) => {
213
+ e.preventDefault();
214
+ e.stopPropagation();
215
+ const value = option.getAttribute('data-theme-value');
216
+ if (value) {
217
+ this.setPreference(value);
218
+ }
219
+ this.closeMenu(switcher, toggle, menu);
220
+ };
221
+ option.addEventListener('click', optionClickHandler);
222
+ cleanupFunctions.push(() => option.removeEventListener('click', optionClickHandler));
223
+
224
+ const optionKeydownHandler = (e) => {
225
+ if (e.key === 'Enter' || e.key === ' ') {
226
+ e.preventDefault();
227
+ optionClickHandler(e);
228
+ }
229
+ };
230
+ option.addEventListener('keydown', optionKeydownHandler);
231
+ cleanupFunctions.push(() => option.removeEventListener('keydown', optionKeydownHandler));
232
+ });
233
+
234
+ const toggleKeydownHandler = (e) => {
235
+ if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
236
+ e.preventDefault();
237
+ if (!menu.classList.contains('is-open')) {
238
+ this.openMenu(switcher, toggle, menu);
239
+ }
240
+ } else if (e.key === 'Escape' && menu.classList.contains('is-open')) {
241
+ e.preventDefault();
242
+ this.closeMenu(switcher, toggle, menu);
243
+ }
244
+ };
245
+ toggle.addEventListener('keydown', toggleKeydownHandler);
246
+ cleanupFunctions.push(() => toggle.removeEventListener('keydown', toggleKeydownHandler));
247
+
248
+ const menuKeydownHandler = (e) => {
249
+ this.handleMenuKeydown(e, switcher, toggle, menu, options);
250
+ };
251
+ menu.addEventListener('keydown', menuKeydownHandler);
252
+ cleanupFunctions.push(() => menu.removeEventListener('keydown', menuKeydownHandler));
253
+
254
+ const documentClickHandler = (e) => {
255
+ if (!switcher.contains(e.target) && menu.classList.contains('is-open')) {
256
+ this.closeMenu(switcher, toggle, menu);
257
+ }
258
+ };
259
+ document.addEventListener('click', documentClickHandler);
260
+ cleanupFunctions.push(() => document.removeEventListener('click', documentClickHandler));
261
+
262
+ this.menuInstances.set(switcher, { toggle, menu, cleanup: cleanupFunctions });
263
+ switcher.setAttribute('data-theme-menu-initialized', 'true');
264
+
265
+ this.initMenuTooltips(switcher);
266
+ });
267
+ },
268
+
269
+ initMenuTooltips: function (switcher) {
270
+ const tooltips = window.Vanduo && typeof window.Vanduo.getComponent === 'function'
271
+ ? window.Vanduo.getComponent('tooltips')
272
+ : null;
273
+
274
+ if (tooltips && typeof tooltips.init === 'function') {
275
+ tooltips.init(switcher);
276
+ }
277
+ },
278
+
279
+ closeOtherMenus: function (exceptMenu) {
280
+ this.menuInstances.forEach((instance, switcher) => {
281
+ if (instance.menu !== exceptMenu && instance.menu.classList.contains('is-open')) {
282
+ this.closeMenu(switcher, instance.toggle, instance.menu);
283
+ }
284
+ });
285
+ },
286
+
287
+ toggleMenu: function (switcher, toggle, menu) {
288
+ if (menu.classList.contains('is-open')) {
289
+ this.closeMenu(switcher, toggle, menu);
290
+ } else {
291
+ this.openMenu(switcher, toggle, menu);
292
+ }
293
+ },
294
+
295
+ openMenu: function (switcher, toggle, menu) {
296
+ this.closeOtherMenus(menu);
297
+ switcher.classList.add('is-open');
298
+ menu.classList.add('is-open');
299
+ toggle.setAttribute('aria-expanded', 'true');
300
+ menu.setAttribute('aria-hidden', 'false');
301
+
302
+ const activeOption = menu.querySelector('[data-theme-value].is-active')
303
+ || menu.querySelector('[data-theme-value]');
304
+ if (activeOption) {
305
+ setTimeout(() => activeOption.focus(), 0);
306
+ }
307
+ },
308
+
309
+ closeMenu: function (switcher, toggle, menu) {
310
+ switcher.classList.remove('is-open');
311
+ menu.classList.remove('is-open');
312
+ toggle.setAttribute('aria-expanded', 'false');
313
+ menu.setAttribute('aria-hidden', 'true');
314
+ },
315
+
316
+ handleMenuKeydown: function (e, switcher, toggle, menu, options) {
317
+ const items = Array.from(options);
318
+ const currentIndex = items.indexOf(document.activeElement);
319
+
320
+ if (e.key === 'Escape') {
321
+ e.preventDefault();
322
+ this.closeMenu(switcher, toggle, menu);
323
+ toggle.focus();
324
+ return;
325
+ }
326
+
327
+ if (e.key === 'ArrowDown') {
328
+ e.preventDefault();
329
+ const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
330
+ items[nextIndex].focus();
331
+ return;
332
+ }
333
+
334
+ if (e.key === 'ArrowUp') {
335
+ e.preventDefault();
336
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
337
+ items[prevIndex].focus();
338
+ }
339
+ },
340
+
341
+ updateMenuSwitcher: function (switcher) {
342
+ const toggle = switcher.querySelector('.vd-theme-switcher-toggle');
343
+ const menu = switcher.querySelector('.vd-theme-switcher-menu');
344
+ if (!toggle || !menu) {
345
+ return;
346
+ }
347
+
348
+ const pref = this.state.preference;
349
+ const icon = toggle.querySelector('[data-theme-icon]');
350
+ const label = THEME_LABELS[pref] || THEME_LABELS.system;
351
+
352
+ if (icon) {
353
+ icon.className = THEME_ICON_CLASSES[pref] || THEME_ICON_CLASSES.system;
354
+ }
355
+
356
+ toggle.setAttribute('aria-label', label);
357
+ if (toggle.hasAttribute('data-tooltip')) {
358
+ toggle.setAttribute('data-tooltip', label);
359
+ this.refreshTooltipContent(toggle, label);
360
+ }
361
+
362
+ menu.querySelectorAll('[data-theme-value]').forEach(option => {
363
+ const value = option.getAttribute('data-theme-value');
364
+ const isActive = value === pref;
365
+ option.classList.toggle('is-active', isActive);
366
+ option.setAttribute('aria-checked', isActive ? 'true' : 'false');
367
+
368
+ const tooltipText = THEME_OPTION_TOOLTIPS[value];
369
+ if (tooltipText && option.hasAttribute('data-tooltip')) {
370
+ option.setAttribute('data-tooltip', tooltipText);
371
+ this.refreshTooltipContent(option, tooltipText);
372
+ }
373
+ });
374
+ },
375
+
376
+ refreshTooltipContent: function (element, text) {
377
+ const tooltips = window.Vanduo && typeof window.Vanduo.getComponent === 'function'
378
+ ? window.Vanduo.getComponent('tooltips')
379
+ : null;
380
+
381
+ if (!tooltips || typeof tooltips.update !== 'function') {
382
+ return;
383
+ }
384
+
385
+ tooltips.update(element, text);
386
+ },
387
+
388
+ updateCycleToggle: function (toggle) {
389
+ const pref = this.state.preference;
390
+ const icon = toggle.querySelector('[data-theme-icon]');
391
+ const label = THEME_LABELS[pref] || THEME_LABELS.system;
392
+
393
+ if (icon) {
394
+ icon.className = THEME_ICON_CLASSES[pref] || THEME_ICON_CLASSES.system;
395
+ }
396
+
397
+ toggle.setAttribute('aria-label', label);
398
+ if (toggle.hasAttribute('data-tooltip')) {
399
+ toggle.setAttribute('data-tooltip', label);
400
+ this.refreshTooltipContent(toggle, label);
401
+ }
154
402
  },
155
403
 
156
404
  updateUI: function (root) {
@@ -159,21 +407,35 @@
159
407
  if (toggle.tagName === 'SELECT') {
160
408
  toggle.value = this.state.preference;
161
409
  } else {
162
- // Update button text/icon if needed
163
- // e.g. toggle.textContent = this.state.preference;
164
- // For now, assume the user handles visual state or generic text
165
-
166
- // If there is an icon or text span inside, update it
167
410
  const span = toggle.querySelector('.theme-current-label');
168
411
  if (span) {
169
412
  span.textContent = this.state.preference.charAt(0).toUpperCase() + this.state.preference.slice(1);
170
413
  }
414
+
415
+ if (toggle.querySelector('[data-theme-icon]')) {
416
+ this.updateCycleToggle(toggle);
417
+ }
171
418
  }
172
419
  });
420
+
421
+ this.getMenuSwitchers(root).forEach(switcher => {
422
+ this.updateMenuSwitcher(switcher);
423
+ });
173
424
  },
174
425
 
175
426
  destroyAll: function (root) {
176
427
  const scope = root || document;
428
+
429
+ this.getMenuSwitchers(scope).forEach(switcher => {
430
+ const instance = this.menuInstances.get(switcher);
431
+ if (instance) {
432
+ instance.cleanup.forEach(fn => fn());
433
+ this.closeMenu(switcher, instance.toggle, instance.menu);
434
+ this.menuInstances.delete(switcher);
435
+ }
436
+ switcher.removeAttribute('data-theme-menu-initialized');
437
+ });
438
+
177
439
  const toggles = this.getToggles(scope).filter(function (toggle) {
178
440
  return toggle.getAttribute('data-theme-initialized') === 'true';
179
441
  });
@@ -198,7 +460,6 @@
198
460
  }
199
461
  };
200
462
 
201
- // Register component
202
463
  if (window.Vanduo) {
203
464
  window.Vanduo.register('themeSwitcher', ThemeSwitcher);
204
465
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanduo-oss/framework",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "description": "Zero-dependency CSS/JS framework built on Fibonacci/Golden Ratio design system with Open Color integration",
5
5
  "keywords": [
6
6
  "css",
@@ -40,12 +40,12 @@
40
40
  ],
41
41
  "devDependencies": {
42
42
  "@eslint/js": "^10.0.1",
43
- "@playwright/test": "^1.59.1",
44
- "esbuild": "^0.27.7",
45
- "eslint": "^10.3.0",
43
+ "@playwright/test": "^1.60.0",
44
+ "esbuild": "^0.28.0",
45
+ "eslint": "^10.4.1",
46
46
  "husky": "^9.1.7",
47
47
  "lightningcss": "^1.32.0",
48
- "stylelint": "^17.11.0",
48
+ "stylelint": "^17.12.0",
49
49
  "stylelint-config-standard": "^40.0.0"
50
50
  },
51
51
  "engines": {