PageSourceSearch

https://www.rcslt.org/wp-content/plugins/rcslt/assets/js/block/faq-accordion/main.js?ver=0.0.9

js rcslt.org collected 2026-09-24 09:23:22 UTC 8,018 bytes, 294 lines download raw bytes

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.