@vanduo-oss/framework 1.3.0 → 1.3.1

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.
@@ -282,19 +282,20 @@
282
282
  const codeElement = activePane.querySelector('code') || activePane;
283
283
  const code = codeElement.textContent;
284
284
 
285
+ let copySuccess;
285
286
  try {
286
287
  await navigator.clipboard.writeText(code);
287
- this.showCopyFeedback(copyBtn, true);
288
+ copySuccess = true;
288
289
  } catch (_err) {
289
290
  // Fallback for older browsers
290
- const success = this.fallbackCopy(code);
291
- this.showCopyFeedback(copyBtn, success);
291
+ copySuccess = this.fallbackCopy(code);
292
292
  }
293
+ this.showCopyFeedback(copyBtn, copySuccess);
293
294
 
294
295
  // Dispatch event
295
296
  const event = new CustomEvent('codesnippet:copy', {
296
297
  bubbles: true,
297
- detail: { snippet, code, success: true }
298
+ detail: { snippet, code, success: copySuccess }
298
299
  });
299
300
  snippet.dispatchEvent(event);
300
301
  },
@@ -12,9 +12,6 @@
12
12
  const Dropdown = {
13
13
  // Store initialized dropdowns and their cleanup functions
14
14
  instances: new Map(),
15
- // Typeahead state
16
- _typeaheadBuffer: '',
17
- _typeaheadTimer: null,
18
15
 
19
16
  /**
20
17
  * Initialize dropdown components
@@ -95,7 +92,7 @@
95
92
  cleanupFunctions.push(() => item.removeEventListener('keydown', itemKeydownHandler));
96
93
  });
97
94
 
98
- this.instances.set(dropdown, { toggle, menu, cleanup: cleanupFunctions });
95
+ this.instances.set(dropdown, { toggle, menu, cleanup: cleanupFunctions, typeaheadBuffer: '', typeaheadTimer: null });
99
96
  },
100
97
 
101
98
  /**
@@ -249,18 +246,21 @@
249
246
  default:
250
247
  // Typeahead: jump to matching item when typing printable characters
251
248
  if (isOpen && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
252
- clearTimeout(this._typeaheadTimer);
253
- this._typeaheadBuffer += e.key.toLowerCase();
249
+ // Per-instance typeahead state to avoid cross-instance corruption
250
+ const instance = this.instances.get(dropdown);
251
+ if (!instance) break;
252
+ clearTimeout(instance.typeaheadTimer);
253
+ instance.typeaheadBuffer += e.key.toLowerCase();
254
254
 
255
255
  const match = items.find(item =>
256
- item.textContent.trim().toLowerCase().startsWith(this._typeaheadBuffer)
256
+ item.textContent.trim().toLowerCase().startsWith(instance.typeaheadBuffer)
257
257
  );
258
258
  if (match) {
259
259
  match.focus();
260
260
  }
261
261
 
262
- this._typeaheadTimer = setTimeout(() => {
263
- this._typeaheadBuffer = '';
262
+ instance.typeaheadTimer = setTimeout(() => {
263
+ instance.typeaheadBuffer = '';
264
264
  }, 500);
265
265
  }
266
266
  break;
@@ -274,9 +274,10 @@
274
274
  // Handle image load
275
275
  if (!this.img.complete) {
276
276
  this.img.style.opacity = '0';
277
- this.img.onload = () => {
277
+ this._imgLoadHandler = () => {
278
278
  this.img.style.opacity = '';
279
279
  };
280
+ this.img.addEventListener('load', this._imgLoadHandler, { once: true });
280
281
  }
281
282
  },
282
283
 
@@ -305,6 +306,11 @@
305
306
  // Clear image after transition
306
307
  setTimeout(() => {
307
308
  if (!this.isOpen) {
309
+ // Clean up load handler if still pending
310
+ if (this._imgLoadHandler) {
311
+ this.img.removeEventListener('load', this._imgLoadHandler);
312
+ this._imgLoadHandler = null;
313
+ }
308
314
  this.img.src = '';
309
315
  this.img.alt = '';
310
316
  }
@@ -16,6 +16,8 @@
16
16
 
17
17
  // Store trigger cleanup functions
18
18
  _triggerCleanups: [],
19
+ // Shared ESC key handler (installed once)
20
+ _sharedEscHandler: null,
19
21
 
20
22
  /**
21
23
  * Initialize modals
@@ -99,17 +101,18 @@
99
101
  backdrop.addEventListener('click', backdropClickHandler);
100
102
  cleanupFunctions.push(() => backdrop.removeEventListener('click', backdropClickHandler));
101
103
 
102
- // ESC key handler
103
- const escKeyHandler = (e) => {
104
- if (e.key === 'Escape' && this.openModals.length > 0) {
105
- const topModal = this.openModals[this.openModals.length - 1];
106
- if (topModal === modal && topModal.dataset.keyboard !== 'false') {
107
- this.close(topModal);
104
+ // ESC key handler — use a single shared handler instead of one-per-modal
105
+ if (!this._sharedEscHandler) {
106
+ this._sharedEscHandler = (e) => {
107
+ if (e.key === 'Escape' && this.openModals.length > 0) {
108
+ const topModal = this.openModals[this.openModals.length - 1];
109
+ if (topModal.dataset.keyboard !== 'false') {
110
+ this.close(topModal);
111
+ }
108
112
  }
109
- }
110
- };
111
- document.addEventListener('keydown', escKeyHandler);
112
- cleanupFunctions.push(() => document.removeEventListener('keydown', escKeyHandler));
113
+ };
114
+ document.addEventListener('keydown', this._sharedEscHandler);
115
+ }
113
116
 
114
117
  this.modals.set(modal, { backdrop, dialog, trapHandler: null, cleanup: cleanupFunctions });
115
118
  },
@@ -352,6 +355,11 @@
352
355
  // Clean up trigger listeners
353
356
  this._triggerCleanups.forEach(fn => fn());
354
357
  this._triggerCleanups = [];
358
+ // Remove shared ESC handler
359
+ if (this._sharedEscHandler) {
360
+ document.removeEventListener('keydown', this._sharedEscHandler);
361
+ this._sharedEscHandler = null;
362
+ }
355
363
  }
356
364
  };
357
365
 
@@ -203,8 +203,8 @@
203
203
  overlay.classList.add('is-active');
204
204
  }
205
205
 
206
- // Prevent body scroll when menu is open
207
- document.body.style.overflow = 'hidden';
206
+ // Prevent body scroll when menu is open (use class to avoid conflicts with modals)
207
+ document.body.classList.add('body-navbar-open');
208
208
 
209
209
  // Set ARIA attributes
210
210
  toggle.setAttribute('aria-expanded', 'true');
@@ -227,7 +227,7 @@
227
227
  }
228
228
 
229
229
  // Restore body scroll
230
- document.body.style.overflow = '';
230
+ document.body.classList.remove('body-navbar-open');
231
231
 
232
232
  // Close all dropdown menus
233
233
  const dropdownMenus = menu.querySelectorAll('.vd-navbar-dropdown-menu.is-open');
@@ -12,9 +12,6 @@
12
12
  const Select = {
13
13
  // Store initialized selects and their cleanup functions
14
14
  instances: new Map(),
15
- // Typeahead state
16
- _typeaheadBuffer: '',
17
- _typeaheadTimer: null,
18
15
 
19
16
  /**
20
17
  * Initialize select components
@@ -123,7 +120,7 @@
123
120
  select.addEventListener('change', changeHandler);
124
121
  cleanupFunctions.push(() => select.removeEventListener('change', changeHandler));
125
122
 
126
- this.instances.set(select, { wrapper, button, dropdown, cleanup: cleanupFunctions });
123
+ this.instances.set(select, { wrapper, button, dropdown, cleanup: cleanupFunctions, typeaheadBuffer: '', typeaheadTimer: null });
127
124
  },
128
125
 
129
126
  /**
@@ -233,7 +230,7 @@
233
230
  * @param {HTMLElement} dropdown - Dropdown container
234
231
  */
235
232
  updateSelectedOptions: function (select, dropdown) {
236
- const options = dropdown.querySelectorAll('.vd-custom-select-option');
233
+ const options = dropdown.querySelectorAll('.custom-select-option');
237
234
  const selectedValues = Array.from(select.selectedOptions).map(opt => opt.value);
238
235
 
239
236
  options.forEach(optionEl => {
@@ -273,7 +270,7 @@
273
270
  button.setAttribute('aria-expanded', 'true');
274
271
 
275
272
  // Focus first option
276
- const firstOption = dropdown.querySelector('.vd-custom-select-option:not(.is-disabled)');
273
+ const firstOption = dropdown.querySelector('.custom-select-option:not(.is-disabled)');
277
274
  if (firstOption) {
278
275
  firstOption.focus();
279
276
  }
@@ -298,7 +295,7 @@
298
295
  */
299
296
  handleKeydown: function (e, select, button, dropdown) {
300
297
  const isOpen = dropdown.classList.contains('is-open');
301
- const options = Array.from(dropdown.querySelectorAll('.vd-custom-select-option:not(.is-disabled)'));
298
+ const options = Array.from(dropdown.querySelectorAll('.custom-select-option:not(.is-disabled)'));
302
299
  const currentIndex = options.findIndex(opt => opt === document.activeElement);
303
300
 
304
301
  switch (e.key) {
@@ -357,18 +354,21 @@
357
354
  default:
358
355
  // Typeahead: jump to matching option when typing printable characters
359
356
  if (isOpen && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
360
- clearTimeout(this._typeaheadTimer);
361
- this._typeaheadBuffer += e.key.toLowerCase();
357
+ // Per-instance typeahead state to avoid cross-instance corruption
358
+ const instance = this.instances.get(select);
359
+ if (!instance) break;
360
+ clearTimeout(instance.typeaheadTimer);
361
+ instance.typeaheadBuffer += e.key.toLowerCase();
362
362
 
363
363
  const match = options.find(opt =>
364
- opt.textContent.trim().toLowerCase().startsWith(this._typeaheadBuffer)
364
+ opt.textContent.trim().toLowerCase().startsWith(instance.typeaheadBuffer)
365
365
  );
366
366
  if (match) {
367
367
  match.focus();
368
368
  }
369
369
 
370
- this._typeaheadTimer = setTimeout(() => {
371
- this._typeaheadBuffer = '';
370
+ instance.typeaheadTimer = setTimeout(() => {
371
+ instance.typeaheadBuffer = '';
372
372
  }, 500);
373
373
  }
374
374
  break;
@@ -403,7 +403,9 @@
403
403
  if (element.id) {
404
404
  return element.id;
405
405
  }
406
- return 'select-' + Math.random().toString(36).substr(2, 9);
406
+ const id = 'select-' + Math.random().toString(36).substr(2, 9);
407
+ element.id = id;
408
+ return id;
407
409
  },
408
410
 
409
411
  /**
@@ -6,6 +6,17 @@
6
6
  (function () {
7
7
  'use strict';
8
8
 
9
+ /**
10
+ * Escape HTML entities to prevent XSS when inserting into innerHTML
11
+ * @param {string} text
12
+ * @returns {string}
13
+ */
14
+ function _escapeHtml(text) {
15
+ const div = document.createElement('div');
16
+ div.textContent = text;
17
+ return div.innerHTML;
18
+ }
19
+
9
20
  const Suggest = {
10
21
  instances: new Map(),
11
22
 
@@ -77,8 +88,10 @@
77
88
 
78
89
  const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);
79
90
  if (query) {
91
+ // Escape HTML first to prevent XSS, then highlight matches in the safe string
92
+ const escaped = _escapeHtml(text);
80
93
  const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
81
- li.innerHTML = text.replace(re, '<span class="vd-suggest-match">$1</span>');
94
+ li.innerHTML = escaped.replace(re, '<span class="vd-suggest-match">$1</span>');
82
95
  } else {
83
96
  li.textContent = text;
84
97
  }
@@ -714,7 +714,6 @@
714
714
  this.applyNeutral(this.DEFAULTS.NEUTRAL);
715
715
  this.applyRadius(this.DEFAULTS.RADIUS);
716
716
  this.applyFont(this.DEFAULTS.FONT);
717
- this.applyTheme(this.DEFAULTS.THEME);
718
717
  this.updateUI();
719
718
 
720
719
  this.dispatchEvent('reset', { state: { ...this.state } });
@@ -18,10 +18,21 @@
18
18
  max: (value, param) => value.length <= parseInt(param, 10),
19
19
  minVal: (value, param) => parseFloat(value) >= parseFloat(param),
20
20
  maxVal: (value, param) => parseFloat(value) <= parseFloat(param),
21
- pattern: (value, param) => { try { return new RegExp(param).test(value); } catch (_e) { return false; } },
21
+ pattern: (value, param) => {
22
+ try {
23
+ // Cap regex length to prevent ReDoS from excessively complex patterns
24
+ if (param.length > 100) return false;
25
+ return new RegExp(param).test(value);
26
+ } catch (_e) { return false; }
27
+ },
22
28
  match: (value, param) => {
23
- const other = document.querySelector('[name="' + param + '"]');
24
- return other ? value === other.value : false;
29
+ try {
30
+ const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(param) : param;
31
+ const other = document.querySelector('[name="' + escaped + '"]');
32
+ return other ? value === other.value : false;
33
+ } catch (_e) {
34
+ return false;
35
+ }
25
36
  }
26
37
  },
27
38
 
@@ -74,6 +74,7 @@ function safeStorageSet(key, value) {
74
74
  * @param {string} event - Event type
75
75
  * @param {string|Function} handlerOrSelector - Event handler or selector for delegation
76
76
  * @param {Function} handler - Event handler (if using delegation)
77
+ * @returns {Function|undefined} The actual bound handler (use this with off() to remove delegation listeners)
77
78
  */
78
79
  function on(target, event, handlerOrSelector, handler) {
79
80
  const element = typeof target === 'string' ? $(target) : target;
@@ -83,9 +84,10 @@ function on(target, event, handlerOrSelector, handler) {
83
84
  if (typeof handlerOrSelector === 'function') {
84
85
  // Direct event binding
85
86
  element.addEventListener(event, handlerOrSelector);
87
+ return handlerOrSelector;
86
88
  } else {
87
- // Event delegation
88
- element.addEventListener(event, function (e) {
89
+ // Event delegation — return the wrapper so callers can remove it via off()
90
+ const wrapper = function (e) {
89
91
  const delegateTarget = e.target.closest(handlerOrSelector);
90
92
  if (delegateTarget && element.contains(delegateTarget)) {
91
93
  try {
@@ -94,7 +96,9 @@ function on(target, event, handlerOrSelector, handler) {
94
96
  console.warn('[Vanduo Helpers] Delegated handler error:', error);
95
97
  }
96
98
  }
97
- });
99
+ };
100
+ element.addEventListener(event, wrapper);
101
+ return wrapper;
98
102
  }
99
103
  }
100
104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanduo-oss/framework",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
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",
@@ -45,7 +45,7 @@
45
45
  "eslint": "^10.0.3",
46
46
  "husky": "^9.1.7",
47
47
  "lightningcss": "^1.32.0",
48
- "stylelint": "^17.4.0",
48
+ "stylelint": "^17.5.0",
49
49
  "stylelint-config-standard": "^40.0.0"
50
50
  },
51
51
  "engines": {