PluginProbe
Tableberg – Simple Gutenberg Table Block / 1.1.5
Tableberg – Simple Gutenberg Table Block v1.1.5
1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.5 1.0.4 1.0.3 1.0.2 1.0.1 trunk 0.0.2 0.2.1 0.3.2 0.3.3 0.4.1 0.5.0 0.5.1 0.5.2 0.5.3 0.5.4 0.5.5 0.5.6 0.5.7 All 42 releases
tableberg / src / frontend / search.ts

search.ts in Tableberg – Simple Gutenberg Table Block 1.1.5, at src/frontend/search.ts

332 lines 9.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 export const TABLEBERG_SEARCH_CHANGED_EVENT = "tableberg:search-changed";
2
3 type SearchPosition = "left" | "right";
4
5 interface SearchConfig {
6 enabled: boolean;
7 placeholder: string;
8 position: SearchPosition;
9 highlightColor: string;
10 }
11
12 interface SearchState {
13 table: HTMLTableElement;
14 tbody: HTMLTableSectionElement;
15 headerEnabled: boolean;
16 footerEnabled: boolean;
17 config: SearchConfig;
18 inputEl: HTMLInputElement;
19 clearButtonEl: HTMLButtonElement;
20 resultsEl: HTMLSpanElement;
21 }
22
23 interface TableRows {
24 dataRows: HTMLTableRowElement[];
25 }
26
27 const SEARCH_HIDDEN_CLASS = "tableberg-search-row--hidden";
28 const SEARCH_HIGHLIGHT_CLASS = "tableberg-search-highlight";
29 const TEXT_ELEMENT_SELECTOR = ".tableberg-text-element";
30
31 function getTextElementsInRow(row: HTMLTableRowElement): HTMLElement[] {
32 return Array.from(row.querySelectorAll<HTMLElement>(TEXT_ELEMENT_SELECTOR));
33 }
34
35 function parseSearchConfig(table: HTMLTableElement): SearchConfig {
36 const enabled = table.dataset.tablebergSearchEnabled === "true";
37 const position =
38 table.dataset.tablebergSearchPosition === "right" ? "right" : "left";
39
40 return {
41 enabled,
42 placeholder: table.dataset.tablebergSearchPlaceholder || "Search...",
43 position,
44 highlightColor: table.dataset.tablebergSearchHighlightColor || "",
45 };
46 }
47
48 function getTableRows(state: SearchState): TableRows {
49 const rows = Array.from(state.tbody.rows);
50 const headerOffset = state.headerEnabled ? 1 : 0;
51 const footerOffset = state.footerEnabled ? 1 : 0;
52 const dataEnd = rows.length - footerOffset;
53
54 return {
55 dataRows: rows.slice(headerOffset, Math.max(headerOffset, dataEnd)),
56 };
57 }
58
59 function normalizeSearchTerm(value: string): string {
60 return value.toLowerCase().trim();
61 }
62
63 function rowMatchesSearch(
64 row: HTMLTableRowElement,
65 normalizedSearchTerm: string
66 ): boolean {
67 if (!normalizedSearchTerm) {
68 return true;
69 }
70
71 const textElements = getTextElementsInRow(row);
72 if (textElements.length === 0) {
73 return false;
74 }
75
76 return textElements.some(element =>
77 (element.textContent || "").toLowerCase().includes(normalizedSearchTerm)
78 );
79 }
80
81 function clearRowHighlights(row: HTMLTableRowElement) {
82 const highlights = row.querySelectorAll<HTMLElement>(
83 `mark.${SEARCH_HIGHLIGHT_CLASS}`
84 );
85
86 highlights.forEach(highlight => {
87 const textNode = document.createTextNode(highlight.textContent || "");
88 highlight.replaceWith(textNode);
89 });
90
91 row.normalize();
92 }
93
94 function escapeRegExp(value: string): string {
95 return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
96 }
97
98 function highlightTextInElement(element: HTMLElement, escapedTerm: string) {
99 const textNodes: Text[] = [];
100
101 const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, {
102 acceptNode(node) {
103 if (!node.nodeValue || !node.nodeValue.trim()) {
104 return NodeFilter.FILTER_REJECT;
105 }
106
107 if (!node.parentElement) {
108 return NodeFilter.FILTER_REJECT;
109 }
110
111 if (node.parentElement.closest(`mark.${SEARCH_HIGHLIGHT_CLASS}`)) {
112 return NodeFilter.FILTER_REJECT;
113 }
114
115 return NodeFilter.FILTER_ACCEPT;
116 },
117 });
118
119 let currentNode: Node | null = walker.nextNode();
120 while (currentNode) {
121 textNodes.push(currentNode as Text);
122 currentNode = walker.nextNode();
123 }
124
125 textNodes.forEach(node => {
126 const text = node.nodeValue || "";
127 const regex = new RegExp(`(${escapedTerm})`, "gi");
128 const parts = text.split(regex);
129
130 if (parts.length <= 1) {
131 return;
132 }
133
134 const fragment = document.createDocumentFragment();
135
136 parts.forEach((part, index) => {
137 if (!part) {
138 return;
139 }
140
141 if (index % 2 === 1) {
142 const mark = document.createElement("mark");
143 mark.className = SEARCH_HIGHLIGHT_CLASS;
144 mark.textContent = part;
145 fragment.appendChild(mark);
146 return;
147 }
148
149 fragment.appendChild(document.createTextNode(part));
150 });
151
152 node.replaceWith(fragment);
153 });
154 }
155
156 function highlightTextInRow(row: HTMLTableRowElement, term: string) {
157 if (!term) {
158 return;
159 }
160
161 const textElements = getTextElementsInRow(row);
162 if (textElements.length === 0) {
163 return;
164 }
165
166 const escapedTerm = escapeRegExp(term);
167 textElements.forEach(element => {
168 highlightTextInElement(element, escapedTerm);
169 });
170 }
171
172 function getResultsLabel(matchCount: number): string {
173 if (matchCount <= 0) {
174 return "No results found";
175 }
176
177 return matchCount === 1 ? "1 result" : `${matchCount} results`;
178 }
179
180 function updateResultDisplay(
181 state: SearchState,
182 normalizedSearchTerm: string,
183 matchCount: number
184 ) {
185 if (!normalizedSearchTerm) {
186 state.resultsEl.hidden = true;
187 state.resultsEl.textContent = "";
188 return;
189 }
190
191 state.resultsEl.hidden = false;
192 state.resultsEl.textContent = getResultsLabel(matchCount);
193 }
194
195 function applySearch(state: SearchState) {
196 const rawSearchTerm = state.inputEl.value.trim();
197 const normalizedSearchTerm = normalizeSearchTerm(rawSearchTerm);
198 const { dataRows } = getTableRows(state);
199
200 let matchCount = 0;
201
202 for (const row of dataRows) {
203 clearRowHighlights(row);
204
205 const matches = rowMatchesSearch(row, normalizedSearchTerm);
206
207 row.dataset.tablebergSearchMatch = matches ? "true" : "false";
208 row.classList.toggle(SEARCH_HIDDEN_CLASS, !matches);
209
210 if (matches) {
211 matchCount++;
212
213 if (normalizedSearchTerm) {
214 highlightTextInRow(row, rawSearchTerm);
215 }
216 }
217 }
218
219 state.clearButtonEl.hidden = normalizedSearchTerm.length === 0;
220 updateResultDisplay(state, normalizedSearchTerm, matchCount);
221
222 state.table.dispatchEvent(
223 new CustomEvent(TABLEBERG_SEARCH_CHANGED_EVENT, {
224 detail: {
225 searchTerm: normalizedSearchTerm,
226 matchCount,
227 },
228 })
229 );
230 }
231
232 function createSearchContainer(state: SearchState): HTMLDivElement {
233 const container = document.createElement("div");
234 container.className = "tableberg-search";
235 container.style.justifyContent =
236 state.config.position === "right" ? "flex-end" : "flex-start";
237
238 const inputWrapper = document.createElement("div");
239 inputWrapper.className = "tableberg-search__input-wrapper";
240
241 const icon = document.createElement("span");
242 icon.className = "tableberg-search__icon";
243 icon.setAttribute("aria-hidden", "true");
244 icon.innerHTML =
245 "<svg width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='8'></circle><path d='M21 21l-4.35-4.35'></path></svg>";
246
247 state.inputEl.className = "tableberg-search__input";
248 state.inputEl.type = "text";
249 state.inputEl.placeholder = state.config.placeholder;
250
251 state.clearButtonEl.className = "tableberg-search__clear";
252 state.clearButtonEl.type = "button";
253 state.clearButtonEl.setAttribute("aria-label", "Clear search");
254 state.clearButtonEl.hidden = true;
255 state.clearButtonEl.innerHTML =
256 "<svg width='16' height='16' viewBox='0 0 24 24' fill='currentColor'><path d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'></path></svg>";
257
258 state.resultsEl.className = "tableberg-search__results";
259 state.resultsEl.hidden = true;
260
261 inputWrapper.appendChild(icon);
262 inputWrapper.appendChild(state.inputEl);
263 inputWrapper.appendChild(state.clearButtonEl);
264
265 container.appendChild(inputWrapper);
266 container.appendChild(state.resultsEl);
267
268 return container;
269 }
270
271 function setupSearch(table: HTMLTableElement) {
272 if (table.dataset.tablebergSearchInitialized === "true") {
273 return;
274 }
275
276 const config = parseSearchConfig(table);
277 if (!config.enabled) {
278 return;
279 }
280
281 const tbody = table.tBodies.item(0);
282 if (!tbody) {
283 return;
284 }
285
286 const state: SearchState = {
287 table,
288 tbody,
289 headerEnabled: table.dataset.tablebergHeader === "true",
290 footerEnabled: table.dataset.tablebergFooter === "true",
291 config,
292 inputEl: document.createElement("input"),
293 clearButtonEl: document.createElement("button"),
294 resultsEl: document.createElement("span"),
295 };
296
297 if (state.config.highlightColor) {
298 table.style.setProperty(
299 "--tableberg-search-highlight-color",
300 state.config.highlightColor
301 );
302 }
303
304 const searchContainer = createSearchContainer(state);
305 table.insertAdjacentElement("beforebegin", searchContainer);
306
307 state.inputEl.addEventListener("input", () => {
308 applySearch(state);
309 });
310
311 state.clearButtonEl.addEventListener("click", () => {
312 state.inputEl.value = "";
313 applySearch(state);
314 state.inputEl.focus();
315 });
316
317 applySearch(state);
318 table.dataset.tablebergSearchInitialized = "true";
319 }
320
321 export function initializeSearch() {
322 const tables = Array.from(
323 document.querySelectorAll<HTMLTableElement>(
324 ".wp-block-tableberg[data-tableberg-search-enabled='true']"
325 )
326 );
327
328 for (const table of tables) {
329 setupSearch(table);
330 }
331 }
332