1export const escapeRegExp = (string) => { 2 return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 3}; 4 5// Store original HTML for each element to allow robust restoration 6const ORIGINAL_HTML_KEY = '__original_html__'; 7 8function storeOriginalHtml(node) { 9 if (node && node[ORIGINAL_HTML_KEY] === undefined) { 10 node[ORIGINAL_HTML_KEY] = node.innerHTML; 11 } 12} 13 14function restoreOriginalHtml(node) { 15 if (node && node[ORIGINAL_HTML_KEY] !== undefined) { 16 node.innerHTML = node[ORIGINAL_HTML_KEY]; 17 } 18} 19 20export const removeHighlights = (node) => { 21 if (!node) { 22 return; 23 } 24 25 // Remove all <mark> elements recursively in the subtree 26 const marks = node.querySelectorAll('mark'); 27 marks.forEach(mark => { 28 // Replace <mark> with its text content (preserve whitespace) 29 mark.replaceWith(document.createTextNode(mark.textContent)); 30 }); 31}; 32 33export const collectTextNodes = (node) => { 34 const acceptNode = (node) => { 35 // Skip text nodes inside script, style, or mark elements 36 let parent = node.parentNode; 37 while (parent && parent !== node.ownerDocument) { 38 const tagName = parent.tagName && parent.tagName.toLowerCase(); 39 if (tagName === 'script' || tagName === 'style' || tagName === 'mark') { 40 return NodeFilter.FILTER_REJECT; 41 } 42 parent = parent.parentNode; 43 } 44 return NodeFilter.FILTER_ACCEPT; 45 }; 46 47 const walker = document.createTreeWalker( 48 node, 49 NodeFilter.SHOW_TEXT, 50 {acceptNode}, 51 false 52 ); 53 54 const textNodes = []; 55 let currentNode; 56 while ((currentNode = walker.nextNode())) { 57 // Only include non-empty text nodes 58 if (currentNode.nodeValue && currentNode.nodeValue.trim()) { 59 textNodes.push(currentNode); 60 } 61 } 62 return textNodes; 63}; 64 65export const createHighlightedFragment = (text, term) => { 66 if (!term) { 67 return null; 68 } 69 // Case-insensitive global search, escape special chars 70 const regex = new RegExp(escapeRegExp(term), 'gi'); 71 let lastIndex = 0; 72 let match; 73 const frag = document.createDocumentFragment(); 74 75 // If no match, return null (no highlight) 76 if (!regex.test(text)) { 77 return null; 78 } 79 regex.lastIndex = 0; 80 while ((match = regex.exec(text)) !== null) { 81 // Add text before the match (preserve whitespace exactly) 82 if (match.index > lastIndex) { 83 frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index))); 84 } 85 // Only wrap the exact match, not surrounding whitespace 86 const matchedText = match[0]; 87 const mark = document.createElement('mark'); 88 mark.textContent = matchedText; 89 frag.appendChild(mark); 90 lastIndex = match.index + matchedText.length; 91 } 92 // Always append the remaining text after the last match (even if empty string) 93 if (lastIndex < text.length) { 94 frag.appendChild(document.createTextNode(text.slice(lastIndex))); 95 } 96 return frag; 97}; 98 99export const highlightMatches = (node, term) => { 100 if (!term || !node) { 101 return; 102 } 103 104 // Collect all text nodes in the DOM tree 105 const textNodes = collectTextNodes(node); 106 107 textNodes.forEach(textNode => { 108 const text = textNode.nodeValue; 109 if (!text || !text.toLowerCase().includes(term.toLowerCase())) { 110 return; 111 } 112 113 const fragment = createHighlightedFragment(text, term); 114 if (fragment && textNode.parentNode) { 115 // Replace the text node with the fragment containing text + marks 116 textNode.parentNode.replaceChild(fragment, textNode); 117 } 118 }); 119}; 120 121export const normalizeSearchTerm = (term) => { 122 return term.trim().toLowerCase(); 123}; 124 125export const matchesSearchTerm = (text, searchTerm) => { 126 if (!searchTerm) { 127 return false; 128 } 129 130 return text.toLowerCase().includes(searchTerm); 131}; 132 133export const processAccordionItem = (item, searchTerm) => { 134 const btn = item.querySelector('.accordion-button'); 135 const body = item.querySelector('.accordion-body'); 136 137 // Always remove all highlights before storing/restoring 138 removeHighlights(btn); 139 removeHighlights(body); 140 141 // Store original HTML on first run 142 storeOriginalHtml(btn); 143 storeOriginalHtml(body); 144 145 // Always restore original HTML before highlighting 146 restoreOriginalHtml(btn); 147 restoreOriginalHtml(body); 148 149 const btnText = btn ? btn.textContent : ''; 150 const bodyText = body ? body.textContent : ''; 151 152 const itemHasMatch = matchesSearchTerm(btnText, searchTerm) || matchesSearchTerm(bodyText, searchTerm); 153 154 if (itemHasMatch && searchTerm) { 155 if (btn) { 156 highlightMatches(btn, searchTerm); 157 } 158 159 if (body) { 160 highlightMatches(body, searchTerm); 161 } 162 } 163 164 item.style.display = itemHasMatch || !searchTerm ? '' : 'none'; 165 166 return itemHasMatch; 167}; 168 169export const processAccordionGroup = (group, searchTerm) => { 170 let groupHasMatch = false;
171 172 group.querySelectorAll('.accordion-item').forEach(item => { 173 const itemHasMatch = processAccordionItem(item, searchTerm); 174 if (itemHasMatch) { 175 groupHasMatch = true; 176 } 177 }); 178 179 group.style.display = groupHasMatch || !searchTerm ? '' : 'none'; 180 181 return groupHasMatch; 182}; 183 184// Utility: check if any .accordion-item is visible 185function anyFaqVisible(accordion) { 186 const items = accordion.querySelectorAll('.accordion-item'); 187 return Array.from(items).some(item => item.style.display !== 'none'); 188} 189 190// Show/hide no-results and clear button using d-none toggle 191function updateNoResultsState(accordion) { 192 const noResults = document.querySelector('.col-12.faq-no-results'); 193 if (!noResults) { 194 return; 195 } 196 if (!anyFaqVisible(accordion)) { 197 noResults.classList.remove('d-none'); 198 } else { 199 noResults.classList.add('d-none'); 200 } 201} 202 203export const filterAccordion = (accordion, term) => { 204 const searchTerm = normalizeSearchTerm(term); 205 const groups = accordion.querySelectorAll('.accordion-group'); 206 groups.forEach(group => { 207 processAccordionGroup(group, searchTerm); 208 }); 209 210 // Update no-results state after filtering 211 updateNoResultsState(accordion); 212}; 213 214export const initFaqAccordion = () => { 215 const accordion = document.querySelector('.accordion[rcslt-faq-accordion]'); 216 const searchInput = document.querySelector('input[type="search"][id="faq-search-input"]'); 217 const clearBtn = document.querySelector('.faq-search-clear'); 218 const noResults = document.querySelector('.col-12.faq-no-results'); 219 const clearResultsBtn = noResults ? noResults.querySelector('.clear-faq-search') : null; 220 221 if (!accordion || !searchInput) { 222 return null; 223 } 224 225 // Show/hide clear icon 226 const updateClearBtn = () => { 227 if (clearBtn) { 228 clearBtn.style.display = searchInput.value ? 'inline-flex' : 'none'; 229 } 230 }; 231 232 const handleInput = (e) => { 233 filterAccordion(accordion, e.target.value); 234 updateClearBtn(); 235 }; 236 237 const handleReset = () => { 238 setTimeout(() => { 239 filterAccordion(accordion, ''); 240 updateClearBtn(); 241 }, 0); 242 }; 243 244 searchInput.addEventListener('input', handleInput); 245 246 const form = searchInput.closest('form'); 247 if (form) { 248 form.addEventListener('reset', handleReset); 249 } 250 251 if (clearBtn) { 252 clearBtn.addEventListener('click', () => { 253 searchInput.value = ''; 254 filterAccordion(accordion, ''); 255 updateClearBtn(); 256 searchInput.focus(); 257 }); 258 } 259 260 if (clearResultsBtn) { 261 clearResultsBtn.addEventListener('click', () => { 262 searchInput.value = ''; 263 filterAccordion(accordion, ''); 264 updateClearBtn(); 265 searchInput.focus(); 266 }); 267 } 268 269 // Initial state 270 filterAccordion(accordion, searchInput.value); 271 updateClearBtn(); 272 273 return { 274 accordion, 275 searchInput, 276 form, 277 destroy: () => { 278 searchInput.removeEventListener('input', handleInput); 279 if (form) { 280 form.removeEventListener('reset', handleReset); 281 } 282 if (clearBtn) { 283 clearBtn.removeEventListener('click', () => {}); 284 } 285 if (clearResultsBtn) { 286 clearResultsBtn.removeEventListener('click', () => {}); 287 } 288 }, 289 }; 290}; 291 292if (typeof document !== 'undefined') { 293 document.addEventListener('DOMContentLoaded', initFaqAccordion); 294}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.