@vanduo-oss/framework 1.4.2 → 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) {
@@ -566,19 +572,65 @@
566
572
  * @returns {string} HTML with syntax highlighting spans
567
573
  */
568
574
  highlightHtml: function (html) {
569
- // Highlight HTML tags
570
- html = html.replace(/(&lt;\/?)([\w-]+)/g, '$1<span class="code-tag">$2</span>');
575
+ // Comments first so tag highlighting does not treat them as elements.
576
+ html = html.replace(/(&lt;!--)(.*?)(--&gt;)/g, '<span class="code-comment">$1$2$3</span>');
571
577
 
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');
578
+ // Only highlight inside escaped HTML tags. Build the highlighted tag from the
579
+ // original escaped source so regexes never re-process our injected spans.
580
+ html = html.replace(/&lt;(?!--)([\s\S]*?)&gt;/g, function (match, inner) {
581
+ const closingMatch = inner.match(/^(\/)([\w-]+)\s*$/);
582
+ if (closingMatch) {
583
+ return '&lt;'
584
+ + closingMatch[1]
585
+ + '<span class="code-tag">' + closingMatch[2] + '</span>'
586
+ + '&gt;';
587
+ }
575
588
 
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');
589
+ const selfClosingMatch = inner.match(/(\s*\/)\s*$/);
590
+ const selfClosingSuffix = selfClosingMatch ? selfClosingMatch[1] : '';
591
+ const tagSource = selfClosingMatch
592
+ ? inner.slice(0, inner.length - selfClosingMatch[0].length)
593
+ : inner;
594
+ const openMatch = tagSource.match(/^([\w-]+)([\s\S]*)$/);
579
595
 
580
- // Highlight comments
581
- html = html.replace(/(&lt;!--)(.*?)(--&gt;)/g, '<span class="code-comment">$1$2$3</span>');
596
+ if (!openMatch) {
597
+ return match;
598
+ }
599
+
600
+ const tagName = openMatch[1];
601
+ let attrSource = openMatch[2];
602
+
603
+ attrSource = attrSource.replace(
604
+ /([\w:-]+)(\s*=\s*)(["'])([\s\S]*?)\3/g,
605
+ function (attrMatch, attrName, separator, quote, value) {
606
+ return '<span class="code-attr">' + attrName + '</span>'
607
+ + separator
608
+ + quote
609
+ + '<span class="code-string">' + value + '</span>'
610
+ + quote;
611
+ }
612
+ );
613
+
614
+ attrSource = attrSource.replace(
615
+ /([\w:-]+)(\s*=\s*)(&quot;|&#39;)([\s\S]*?)(&quot;|&#39;)/g,
616
+ function (attrMatch, attrName, separator, openQuote, value, closeQuote) {
617
+ if (openQuote !== closeQuote) {
618
+ return attrMatch;
619
+ }
620
+
621
+ return '<span class="code-attr">' + attrName + '</span>'
622
+ + separator
623
+ + openQuote
624
+ + '<span class="code-string">' + value + '</span>'
625
+ + closeQuote;
626
+ }
627
+ );
628
+
629
+ return '&lt;<span class="code-tag">' + tagName + '</span>'
630
+ + attrSource
631
+ + selfClosingSuffix
632
+ + '&gt;';
633
+ });
582
634
 
583
635
  return html;
584
636
  },
@@ -613,6 +665,20 @@
613
665
  * @returns {string} JS with syntax highlighting spans
614
666
  */
615
667
  highlightJs: function (js) {
668
+ const protectedTokens = [];
669
+ const protectToken = (value, className) => {
670
+ const marker = String.fromCharCode(0xE000 + protectedTokens.length);
671
+ protectedTokens.push({ marker, html: '<span class="' + className + '">' + value + '</span>' });
672
+ return marker;
673
+ };
674
+
675
+ // Protect comments and strings before adding markup so later passes never
676
+ // re-highlight attributes inside the generated spans.
677
+ js = js.replace(
678
+ /\/\*[\s\S]*?\*\/|\/\/.*$|'(?:[^'\\]|\\.){0,10000}'|"(?:[^"\\]|\\.){0,10000}"|`(?:[^`\\]|\\.){0,10000}`/gm,
679
+ match => protectToken(match, match.startsWith('/') ? 'code-comment' : 'code-string')
680
+ );
681
+
616
682
  // Highlight keywords
617
683
  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
684
  keywords.forEach(kw => {
@@ -620,18 +686,15 @@
620
686
  js = js.replace(regex, '<span class="code-keyword">$1</span>');
621
687
  });
622
688
 
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
689
  // Highlight numbers
627
690
  js = js.replace(/\b(\d+\.?\d*)\b/g, '<span class="code-number">$1</span>');
628
691
 
629
692
  // Highlight function calls
630
693
  js = js.replace(/\b([\w]+)(\s*\()/g, '<span class="code-function">$1</span>$2');
631
694
 
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>');
695
+ protectedTokens.forEach(token => {
696
+ js = js.replace(token.marker, token.html);
697
+ });
635
698
 
636
699
  return js;
637
700
  },
@@ -181,6 +181,8 @@
181
181
  let focusedDate = null;
182
182
  /** Prevents focus() after close from immediately re-opening the popup */
183
183
  let skipNextFocusOpen = false;
184
+ /** Ignore outside-click closes briefly after open (mobile click/focus ordering). */
185
+ let ignoreOutsideUntil = 0;
184
186
 
185
187
  const isDisabled = (d) => {
186
188
  if (minDate) {
@@ -562,7 +564,12 @@
562
564
  positionPopup();
563
565
  };
564
566
 
567
+ const markIgnoreOutside = () => {
568
+ ignoreOutsideUntil = Date.now() + 100;
569
+ };
570
+
565
571
  const open = () => {
572
+ markIgnoreOutside();
566
573
  viewMode = 'days';
567
574
  if (selectedDate) {
568
575
  viewYear = selectedDate.getFullYear();
@@ -600,8 +607,25 @@
600
607
  }
601
608
  open();
602
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
+
603
626
  const outsideHandler = (e) => {
604
- if (!input.contains(e.target) && !popup.contains(e.target)) close();
627
+ if (Date.now() < ignoreOutsideUntil) return;
628
+ if (!isOpenAnchorTarget(e.target)) close();
605
629
  };
606
630
  const escHandler = (e) => {
607
631
  if (e.key === 'Escape' && popup.classList.contains('is-open')) {
@@ -612,6 +636,7 @@
612
636
  };
613
637
 
614
638
  input.addEventListener('focus', focusHandler);
639
+ input.addEventListener('click', clickHandler);
615
640
  document.addEventListener('click', outsideHandler, true);
616
641
  document.addEventListener('keydown', escHandler);
617
642
  popup.addEventListener('keydown', handleGridKeydown);
@@ -624,6 +649,7 @@
624
649
 
625
650
  cleanup.push(
626
651
  () => input.removeEventListener('focus', focusHandler),
652
+ () => input.removeEventListener('click', clickHandler),
627
653
  () => document.removeEventListener('click', outsideHandler, true),
628
654
  () => document.removeEventListener('keydown', escHandler),
629
655
  () => popup.removeEventListener('keydown', handleGridKeydown),
@@ -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
  }