@vanduo-oss/framework 1.4.1 → 1.4.3

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.
@@ -566,19 +566,65 @@
566
566
  * @returns {string} HTML with syntax highlighting spans
567
567
  */
568
568
  highlightHtml: function (html) {
569
- // Highlight HTML tags
570
- html = html.replace(/(&lt;\/?)([\w-]+)/g, '$1<span class="code-tag">$2</span>');
569
+ // Comments first so tag highlighting does not treat them as elements.
570
+ html = html.replace(/(&lt;!--)(.*?)(--&gt;)/g, '<span class="code-comment">$1$2$3</span>');
571
571
 
572
- // Highlight attributes
573
- html = html.replace(/([\w-]+)(=)(["'])/g, '<span class="code-attr">$1</span>$2$3');
574
- html = html.replace(/([\w-]+)(=)(&quot;|&#39;)/g, '<span class="code-attr">$1</span>$2$3');
572
+ // Only highlight inside escaped HTML tags. Build the highlighted tag from the
573
+ // original escaped source so regexes never re-process our injected spans.
574
+ html = html.replace(/&lt;(?!--)([\s\S]*?)&gt;/g, function (match, inner) {
575
+ const closingMatch = inner.match(/^(\/)([\w-]+)\s*$/);
576
+ if (closingMatch) {
577
+ return '&lt;'
578
+ + closingMatch[1]
579
+ + '<span class="code-tag">' + closingMatch[2] + '</span>'
580
+ + '&gt;';
581
+ }
575
582
 
576
- // Highlight attribute values (strings)
577
- html = html.replace(/(["'])([^"']*)(["'])/g, '$1<span class="code-string">$2</span>$3');
578
- html = html.replace(/(&quot;|&#39;)([^&]*)(&quot;|&#39;)/g, '$1<span class="code-string">$2</span>$3');
583
+ const selfClosingMatch = inner.match(/(\s*\/)\s*$/);
584
+ const selfClosingSuffix = selfClosingMatch ? selfClosingMatch[1] : '';
585
+ const tagSource = selfClosingMatch
586
+ ? inner.slice(0, inner.length - selfClosingMatch[0].length)
587
+ : inner;
588
+ const openMatch = tagSource.match(/^([\w-]+)([\s\S]*)$/);
579
589
 
580
- // Highlight comments
581
- html = html.replace(/(&lt;!--)(.*?)(--&gt;)/g, '<span class="code-comment">$1$2$3</span>');
590
+ if (!openMatch) {
591
+ return match;
592
+ }
593
+
594
+ const tagName = openMatch[1];
595
+ let attrSource = openMatch[2];
596
+
597
+ attrSource = attrSource.replace(
598
+ /([\w:-]+)(\s*=\s*)(["'])([\s\S]*?)\3/g,
599
+ function (attrMatch, attrName, separator, quote, value) {
600
+ return '<span class="code-attr">' + attrName + '</span>'
601
+ + separator
602
+ + quote
603
+ + '<span class="code-string">' + value + '</span>'
604
+ + quote;
605
+ }
606
+ );
607
+
608
+ attrSource = attrSource.replace(
609
+ /([\w:-]+)(\s*=\s*)(&quot;|&#39;)([\s\S]*?)(&quot;|&#39;)/g,
610
+ function (attrMatch, attrName, separator, openQuote, value, closeQuote) {
611
+ if (openQuote !== closeQuote) {
612
+ return attrMatch;
613
+ }
614
+
615
+ return '<span class="code-attr">' + attrName + '</span>'
616
+ + separator
617
+ + openQuote
618
+ + '<span class="code-string">' + value + '</span>'
619
+ + closeQuote;
620
+ }
621
+ );
622
+
623
+ return '&lt;<span class="code-tag">' + tagName + '</span>'
624
+ + attrSource
625
+ + selfClosingSuffix
626
+ + '&gt;';
627
+ });
582
628
 
583
629
  return html;
584
630
  },
@@ -613,6 +659,20 @@
613
659
  * @returns {string} JS with syntax highlighting spans
614
660
  */
615
661
  highlightJs: function (js) {
662
+ const protectedTokens = [];
663
+ const protectToken = (value, className) => {
664
+ const marker = String.fromCharCode(0xE000 + protectedTokens.length);
665
+ protectedTokens.push({ marker, html: '<span class="' + className + '">' + value + '</span>' });
666
+ return marker;
667
+ };
668
+
669
+ // Protect comments and strings before adding markup so later passes never
670
+ // re-highlight attributes inside the generated spans.
671
+ js = js.replace(
672
+ /\/\*[\s\S]*?\*\/|\/\/.*$|'(?:[^'\\]|\\.){0,10000}'|"(?:[^"\\]|\\.){0,10000}"|`(?:[^`\\]|\\.){0,10000}`/gm,
673
+ match => protectToken(match, match.startsWith('/') ? 'code-comment' : 'code-string')
674
+ );
675
+
616
676
  // Highlight keywords
617
677
  const keywords = ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'switch', 'case', 'break', 'continue', 'new', 'this', 'class', 'extends', 'import', 'export', 'default', 'async', 'await', 'try', 'catch', 'throw', 'typeof', 'instanceof'];
618
678
  keywords.forEach(kw => {
@@ -620,18 +680,15 @@
620
680
  js = js.replace(regex, '<span class="code-keyword">$1</span>');
621
681
  });
622
682
 
623
- // Highlight strings (limit to 10 000 chars to prevent polynomial backtracking)
624
- js = js.replace(/('(?:[^'\\]|\\.){0,10000}'|"(?:[^"\\]|\\.){0,10000}"|`(?:[^`\\]|\\.){0,10000}`)/g, '<span class="code-string">$1</span>');
625
-
626
683
  // Highlight numbers
627
684
  js = js.replace(/\b(\d+\.?\d*)\b/g, '<span class="code-number">$1</span>');
628
685
 
629
686
  // Highlight function calls
630
687
  js = js.replace(/\b([\w]+)(\s*\()/g, '<span class="code-function">$1</span>$2');
631
688
 
632
- // Highlight comments
633
- js = js.replace(/(\/\/.*$)/gm, '<span class="code-comment">$1</span>');
634
- js = js.replace(/(\/\*[\s\S]*?\*\/)/g, '<span class="code-comment">$1</span>');
689
+ protectedTokens.forEach(token => {
690
+ js = js.replace(token.marker, token.html);
691
+ });
635
692
 
636
693
  return js;
637
694
  },
@@ -128,6 +128,32 @@
128
128
  return x;
129
129
  }
130
130
 
131
+ function positionAnchoredPopup(anchor, popup, gap) {
132
+ const padding = 8;
133
+ const offset = gap != null ? gap : 4;
134
+ const rect = anchor.getBoundingClientRect();
135
+
136
+ popup.style.minWidth = Math.max(rect.width, 0) + 'px';
137
+
138
+ let top = rect.bottom + offset;
139
+ let left = rect.left;
140
+ popup.style.top = top + 'px';
141
+ popup.style.left = left + 'px';
142
+
143
+ const popRect = popup.getBoundingClientRect();
144
+ if (popRect.bottom > window.innerHeight - padding && rect.top - popRect.height > padding) {
145
+ top = rect.top - popRect.height - offset;
146
+ popup.style.top = top + 'px';
147
+ }
148
+
149
+ const alignedRect = popup.getBoundingClientRect();
150
+ left = rect.left;
151
+ if (left + alignedRect.width > window.innerWidth - padding) {
152
+ left = window.innerWidth - alignedRect.width - padding;
153
+ }
154
+ popup.style.left = Math.max(padding, left) + 'px';
155
+ }
156
+
131
157
  const Datepicker = {
132
158
  instances: new Map(),
133
159
 
@@ -155,6 +181,8 @@
155
181
  let focusedDate = null;
156
182
  /** Prevents focus() after close from immediately re-opening the popup */
157
183
  let skipNextFocusOpen = false;
184
+ /** Ignore outside-click closes briefly after open (mobile click/focus ordering). */
185
+ let ignoreOutsideUntil = 0;
158
186
 
159
187
  const isDisabled = (d) => {
160
188
  if (minDate) {
@@ -221,7 +249,7 @@
221
249
  wrapper.style.display = 'inline-block';
222
250
  input.parentNode.insertBefore(wrapper, input);
223
251
  wrapper.appendChild(input);
224
- wrapper.appendChild(popup);
252
+ document.body.appendChild(popup);
225
253
 
226
254
  const isSameDay = (a, b) => a && b &&
227
255
  a.getFullYear() === b.getFullYear() &&
@@ -442,6 +470,10 @@
442
470
  }
443
471
  popup.appendChild(grid);
444
472
  }
473
+
474
+ if (popup.classList.contains('is-open')) {
475
+ requestAnimationFrame(positionPopup);
476
+ }
445
477
  };
446
478
 
447
479
  const handleGridKeydown = (e) => {
@@ -523,7 +555,21 @@
523
555
  requestAnimationFrame(focusFocusedDay);
524
556
  };
525
557
 
558
+ const positionPopup = () => {
559
+ if (!popup.classList.contains('is-open')) return;
560
+ positionAnchoredPopup(input, popup);
561
+ };
562
+
563
+ const repositionHandler = () => {
564
+ positionPopup();
565
+ };
566
+
567
+ const markIgnoreOutside = () => {
568
+ ignoreOutsideUntil = Date.now() + 100;
569
+ };
570
+
526
571
  const open = () => {
572
+ markIgnoreOutside();
527
573
  viewMode = 'days';
528
574
  if (selectedDate) {
529
575
  viewYear = selectedDate.getFullYear();
@@ -542,7 +588,10 @@
542
588
  render();
543
589
  popup.classList.add('is-open');
544
590
  input.setAttribute('aria-expanded', 'true');
545
- requestAnimationFrame(focusFocusedDay);
591
+ requestAnimationFrame(() => {
592
+ positionPopup();
593
+ focusFocusedDay();
594
+ });
546
595
  };
547
596
 
548
597
  const close = () => {
@@ -558,8 +607,25 @@
558
607
  }
559
608
  open();
560
609
  };
610
+ /** Mobile browsers may not focus the input before the click event (Playwright too). */
611
+ const clickHandler = () => {
612
+ if (!popup.classList.contains('is-open')) open();
613
+ };
614
+
615
+ const isOpenAnchorTarget = (target) => {
616
+ if (!target || !(target instanceof Node)) return false;
617
+ if (target === input || input.contains(target) || popup.contains(target)) return true;
618
+ const inputId = input.id;
619
+ if (inputId) {
620
+ const label = document.querySelector('label[for="' + inputId.replace(/"/g, '\\"') + '"]');
621
+ if (label && (target === label || label.contains(target))) return true;
622
+ }
623
+ return false;
624
+ };
625
+
561
626
  const outsideHandler = (e) => {
562
- if (!wrapper.contains(e.target)) close();
627
+ if (Date.now() < ignoreOutsideUntil) return;
628
+ if (!isOpenAnchorTarget(e.target)) close();
563
629
  };
564
630
  const escHandler = (e) => {
565
631
  if (e.key === 'Escape' && popup.classList.contains('is-open')) {
@@ -570,9 +636,12 @@
570
636
  };
571
637
 
572
638
  input.addEventListener('focus', focusHandler);
639
+ input.addEventListener('click', clickHandler);
573
640
  document.addEventListener('click', outsideHandler, true);
574
641
  document.addEventListener('keydown', escHandler);
575
642
  popup.addEventListener('keydown', handleGridKeydown);
643
+ window.addEventListener('resize', repositionHandler);
644
+ window.addEventListener('scroll', repositionHandler, true);
576
645
 
577
646
  input.setAttribute('aria-haspopup', 'dialog');
578
647
  input.setAttribute('aria-expanded', 'false');
@@ -580,9 +649,13 @@
580
649
 
581
650
  cleanup.push(
582
651
  () => input.removeEventListener('focus', focusHandler),
652
+ () => input.removeEventListener('click', clickHandler),
583
653
  () => document.removeEventListener('click', outsideHandler, true),
584
654
  () => document.removeEventListener('keydown', escHandler),
585
- () => popup.removeEventListener('keydown', handleGridKeydown)
655
+ () => popup.removeEventListener('keydown', handleGridKeydown),
656
+ () => window.removeEventListener('resize', repositionHandler),
657
+ () => window.removeEventListener('scroll', repositionHandler, true),
658
+ () => popup.remove()
586
659
  );
587
660
 
588
661
  this.instances.set(input, { cleanup: cleanup, open: open, close: close, popup: popup });
@@ -6,17 +6,6 @@
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
-
20
9
  /**
21
10
  * Allow same-origin URLs by default, or an explicit allowlist of origins.
22
11
  * @param {string} url
@@ -109,10 +98,24 @@
109
98
 
110
99
  const text = typeof item === 'object' ? (item.label || item.text || String(item)) : String(item);
111
100
  if (query) {
112
- // Escape HTML first to prevent XSS, then highlight matches in the safe string
113
- const escaped = _escapeHtml(text);
114
- const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
115
- li.innerHTML = escaped.replace(re, '<span class="vd-suggest-match">$1</span>');
101
+ const lowerText = text.toLowerCase();
102
+ const lowerQuery = query.toLowerCase();
103
+ let start = 0;
104
+ let matchIndex = lowerText.indexOf(lowerQuery, start);
105
+ while (matchIndex !== -1) {
106
+ if (matchIndex > start) {
107
+ li.appendChild(document.createTextNode(text.slice(start, matchIndex)));
108
+ }
109
+ const matchSpan = document.createElement('span');
110
+ matchSpan.className = 'vd-suggest-match';
111
+ matchSpan.textContent = text.slice(matchIndex, matchIndex + query.length);
112
+ li.appendChild(matchSpan);
113
+ start = matchIndex + query.length;
114
+ matchIndex = lowerText.indexOf(lowerQuery, start);
115
+ }
116
+ if (start < text.length) {
117
+ li.appendChild(document.createTextNode(text.slice(start)));
118
+ }
116
119
  } else {
117
120
  li.textContent = text;
118
121
  }
@@ -6,6 +6,32 @@
6
6
  (function () {
7
7
  'use strict';
8
8
 
9
+ function positionAnchoredPopup(anchor, popup, gap) {
10
+ const padding = 8;
11
+ const offset = gap != null ? gap : 4;
12
+ const rect = anchor.getBoundingClientRect();
13
+
14
+ popup.style.minWidth = Math.max(rect.width, 0) + 'px';
15
+
16
+ let top = rect.bottom + offset;
17
+ let left = rect.left;
18
+ popup.style.top = top + 'px';
19
+ popup.style.left = left + 'px';
20
+
21
+ const popRect = popup.getBoundingClientRect();
22
+ if (popRect.bottom > window.innerHeight - padding && rect.top - popRect.height > padding) {
23
+ top = rect.top - popRect.height - offset;
24
+ popup.style.top = top + 'px';
25
+ }
26
+
27
+ const alignedRect = popup.getBoundingClientRect();
28
+ left = rect.left;
29
+ if (left + alignedRect.width > window.innerWidth - padding) {
30
+ left = window.innerWidth - alignedRect.width - padding;
31
+ }
32
+ popup.style.left = Math.max(padding, left) + 'px';
33
+ }
34
+
9
35
  const Timepicker = {
10
36
  instances: new Map(),
11
37
 
@@ -36,7 +62,7 @@
36
62
  const popup = document.createElement('div');
37
63
  popup.className = 'vd-timepicker-popup';
38
64
  popup.setAttribute('role', 'listbox');
39
- wrapper.appendChild(popup);
65
+ document.body.appendChild(popup);
40
66
 
41
67
  // Generate time slots
42
68
  const times = [];
@@ -84,13 +110,24 @@
84
110
  });
85
111
  };
86
112
 
113
+ const positionPopup = () => {
114
+ if (!popup.classList.contains('is-open')) return;
115
+ positionAnchoredPopup(input, popup);
116
+ };
117
+
118
+ const repositionHandler = () => {
119
+ positionPopup();
120
+ };
121
+
87
122
  const open = () => {
88
123
  render();
89
124
  popup.classList.add('is-open');
90
125
  input.setAttribute('aria-expanded', 'true');
91
- // Scroll to selected
92
- const selected = popup.querySelector('.is-selected');
93
- if (selected) selected.scrollIntoView({ block: 'center' });
126
+ requestAnimationFrame(() => {
127
+ positionPopup();
128
+ const selected = popup.querySelector('.is-selected');
129
+ if (selected) selected.scrollIntoView({ block: 'center' });
130
+ });
94
131
  };
95
132
 
96
133
  const close = () => {
@@ -100,13 +137,15 @@
100
137
 
101
138
  const focusHandler = () => open();
102
139
  const outsideHandler = (e) => {
103
- if (!wrapper.contains(e.target)) close();
140
+ if (!input.contains(e.target) && !popup.contains(e.target)) close();
104
141
  };
105
142
  const escHandler = (e) => { if (e.key === 'Escape') close(); };
106
143
 
107
144
  input.addEventListener('focus', focusHandler);
108
145
  document.addEventListener('click', outsideHandler, true);
109
146
  document.addEventListener('keydown', escHandler);
147
+ window.addEventListener('resize', repositionHandler);
148
+ window.addEventListener('scroll', repositionHandler, true);
110
149
  input.setAttribute('aria-haspopup', 'listbox');
111
150
  input.setAttribute('aria-expanded', 'false');
112
151
  input.setAttribute('autocomplete', 'off');
@@ -115,7 +154,10 @@
115
154
  cleanup.push(
116
155
  () => input.removeEventListener('focus', focusHandler),
117
156
  () => document.removeEventListener('click', outsideHandler, true),
118
- () => document.removeEventListener('keydown', escHandler)
157
+ () => document.removeEventListener('keydown', escHandler),
158
+ () => window.removeEventListener('resize', repositionHandler),
159
+ () => window.removeEventListener('scroll', repositionHandler, true),
160
+ () => popup.remove()
119
161
  );
120
162
 
121
163
  this.instances.set(input, { cleanup, open, close });
package/js/index.js CHANGED
@@ -68,7 +68,6 @@ import './components/rating.js';
68
68
  import './components/transfer.js';
69
69
  import './components/tree.js';
70
70
  import './components/spotlight.js';
71
- import './components/music-player.js';
72
71
 
73
72
  // Re-export for ESM / CJS consumers
74
73
  const Vanduo = window.Vanduo;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanduo-oss/framework",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
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",