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 / responsive.ts

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

579 lines 15.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 export const TABLEBERG_RESPONSIVE_CHANGED_EVENT =
2 "tableberg:responsive-changed";
3
4 type ResponsiveMode = "" | "scroll" | "stack";
5
6 type RowType = "header" | "footer" | "even-row" | "odd-row";
7
8 interface ResponsiveBreakpoint {
9 enabled: boolean;
10 maxWidth: number;
11 mode: ResponsiveMode;
12 transpose: boolean;
13 stackCount: number;
14 repeatFirstCol: boolean;
15 }
16
17 function parsePositiveInt(value: string | undefined, fallback: number): number {
18 const parsed = Number.parseInt(value || "", 10);
19
20 if (Number.isNaN(parsed) || parsed < 1) {
21 return fallback;
22 }
23
24 return parsed;
25 }
26
27 function parseBoolean(value: string | undefined): boolean {
28 return value === "true" || value === "1";
29 }
30
31 function parseNonNegativeInt(
32 value: string | undefined,
33 fallback: number
34 ): number {
35 const parsed = Number.parseInt(value || "", 10);
36
37 if (Number.isNaN(parsed) || parsed < 0) {
38 return fallback;
39 }
40
41 return parsed;
42 }
43
44 function getCellRow(cell: HTMLTableCellElement): number {
45 return parseNonNegativeInt(
46 cell.getAttribute("data-cell-row") || undefined,
47 0
48 );
49 }
50
51 function getCellCol(cell: HTMLTableCellElement): number {
52 return parseNonNegativeInt(
53 cell.getAttribute("data-cell-col") || undefined,
54 0
55 );
56 }
57
58 function getTableRowCount(table: HTMLTableElement): number {
59 return parsePositiveInt(
60 table.dataset.tablebergRows,
61 table.tBodies[0]?.rows.length || 1
62 );
63 }
64
65 function getTableColCount(table: HTMLTableElement): number {
66 return parsePositiveInt(table.dataset.tablebergCols, 1);
67 }
68
69 function getTableWrapper(table: HTMLTableElement): HTMLElement | null {
70 return table.closest<HTMLElement>(".tableberg-table-wrapper");
71 }
72
73 function setTableClassName(table: HTMLTableElement, className?: string) {
74 ["tableberg-rowstack-table", "tableberg-colstack-table"].forEach(value =>
75 table.classList.remove(value)
76 );
77
78 if (className) {
79 table.classList.add(className);
80 }
81 }
82
83 function markRowCell(cell: HTMLTableCellElement, rowType: RowType) {
84 [
85 "tableberg-even-row-cell",
86 "tableberg-header-cell",
87 "tableberg-footer-cell",
88 "tableberg-odd-row-cell",
89 ].forEach(className => cell.classList.remove(className));
90
91 cell.classList.add(`tableberg-${rowType}-cell`);
92 }
93
94 function markRow(row: HTMLTableRowElement, rowType: RowType) {
95 [
96 "tableberg-even-row",
97 "tableberg-header",
98 "tableberg-footer",
99 "tableberg-odd-row",
100 ].forEach(className => row.classList.remove(className));
101
102 row.classList.add(`tableberg-${rowType}`);
103 }
104
105 function getRowType(
106 row: number,
107 rows: number,
108 hasHeader: boolean,
109 hasFooter: boolean
110 ): RowType {
111 if (hasHeader && row === 0) {
112 return "header";
113 }
114
115 if (hasFooter && row === rows - 1) {
116 return "footer";
117 }
118
119 let adjustedRow = row;
120 if (hasHeader && row > 0) {
121 adjustedRow += 1;
122 }
123
124 return adjustedRow % 2 ? "even-row" : "odd-row";
125 }
126
127 function getResponsiveBreakpoint(
128 table: HTMLTableElement,
129 device: "mobile" | "tablet"
130 ): ResponsiveBreakpoint {
131 const prefix = device === "mobile" ? "tablebergMobile" : "tablebergTablet";
132
133 const enabled = parseBoolean(table.dataset[`${prefix}Enabled`]);
134 const maxWidth = parsePositiveInt(
135 table.dataset[`${prefix}Width`],
136 device === "mobile" ? 700 : 1024
137 );
138 const modeValue = table.dataset[`${prefix}Mode`] || "";
139 const mode: ResponsiveMode =
140 modeValue === "scroll" || modeValue === "stack" ? modeValue : "";
141 const transpose =
142 parseBoolean(table.dataset[`${prefix}Transpose`]) ||
143 table.dataset[`${prefix}Direction`] === "row";
144
145 const repeatFirstCol =
146 parseBoolean(table.dataset[`${prefix}RepeatFirstCol`]) ||
147 parseBoolean(table.dataset[`${prefix}Header`]);
148
149 return {
150 enabled,
151 maxWidth,
152 mode,
153 transpose,
154 stackCount: parsePositiveInt(table.dataset[`${prefix}Count`], 1),
155 repeatFirstCol,
156 };
157 }
158
159 function reviveTable(table: HTMLTableElement) {
160 const oldMode = table.dataset.tablebergLast;
161 if (!oldMode) {
162 return;
163 }
164
165 delete table.dataset.tablebergLast;
166
167 const wrapper = getTableWrapper(table);
168 wrapper?.classList.remove("tableberg-scroll-x");
169
170 table
171 .querySelectorAll("[data-tableberg-tmp='1']")
172 .forEach(element => element.remove());
173
174 if (!oldMode.includes("stack")) {
175 setTableClassName(table);
176 return;
177 }
178
179 setTableClassName(table);
180
181 const colGroup = table.querySelector("colgroup");
182 if (colGroup) {
183 colGroup.removeAttribute("style");
184 }
185
186 const cells = Array.from(
187 table.querySelectorAll<HTMLTableCellElement>("th, td")
188 );
189 cells.sort((a, b) => {
190 const rowDiff = getCellRow(a) - getCellRow(b);
191 if (rowDiff !== 0) {
192 return rowDiff;
193 }
194
195 return getCellCol(a) - getCellCol(b);
196 });
197
198 const tbody = table.tBodies.item(0);
199 if (!tbody) {
200 return;
201 }
202
203 tbody.innerHTML = "";
204
205 const rowCount = getTableRowCount(table);
206 const hasHeader = parseBoolean(table.dataset.tablebergHeader);
207 const hasFooter = parseBoolean(table.dataset.tablebergFooter);
208
209 let lastRow = -1;
210 let lastRowElement: HTMLTableRowElement | null = null;
211
212 for (const cell of cells) {
213 const cellRow = getCellRow(cell);
214
215 if (lastRow !== cellRow) {
216 lastRow = cellRow;
217 lastRowElement = document.createElement("tr");
218 markRow(
219 lastRowElement,
220 getRowType(cellRow, rowCount, hasHeader, hasFooter)
221 );
222 tbody.appendChild(lastRowElement);
223 }
224
225 lastRowElement?.appendChild(cell);
226 }
227 }
228
229 function toScrollTable(table: HTMLTableElement) {
230 if (table.dataset.tablebergLast === "scroll") {
231 return;
232 }
233
234 if (table.dataset.tablebergLast) {
235 reviveTable(table);
236 }
237
238 table.dataset.tablebergLast = "scroll";
239 getTableWrapper(table)?.classList.add("tableberg-scroll-x");
240 }
241
242 function toRowStack(
243 table: HTMLTableElement,
244 repeatFirstCol: boolean,
245 stackCount: number,
246 tag: string
247 ) {
248 if (table.dataset.tablebergLast === tag) {
249 return;
250 }
251
252 reviveTable(table);
253 setTableClassName(table, "tableberg-rowstack-table");
254 table.dataset.tablebergLast = tag;
255
256 const colGroup = table.querySelector("colgroup");
257 if (colGroup) {
258 colGroup.style.display = "none";
259 }
260
261 const tbody = table.tBodies.item(0);
262 if (!tbody) {
263 return;
264 }
265
266 const cells = Array.from(
267 table.querySelectorAll<HTMLTableCellElement>("th, td")
268 );
269 tbody.innerHTML = "";
270
271 const hasHeader = parseBoolean(table.dataset.tablebergHeader);
272 const hasFooter = parseBoolean(table.dataset.tablebergFooter);
273 const rowCount = getTableRowCount(table);
274 const columnCount = getTableColCount(table);
275
276 const headerCells: HTMLTableCellElement[] = [];
277 const maxColumnsPerStack = repeatFirstCol
278 ? Math.max(2, stackCount)
279 : Math.max(1, stackCount);
280
281 if (repeatFirstCol) {
282 for (const cell of cells) {
283 if (getCellRow(cell) > 0) {
284 break;
285 }
286
287 headerCells.push(cell);
288 }
289 }
290
291 const subRowMap = new Map<
292 number,
293 {
294 count: number;
295 rowElement: HTMLTableRowElement;
296 }
297 >();
298
299 for (const cell of cells) {
300 const cellRow = getCellRow(cell);
301 const cellCol = getCellCol(cell);
302 const rowType = getRowType(cellRow, rowCount, hasHeader, hasFooter);
303 markRowCell(cell, rowType);
304
305 const mappedRow = subRowMap.get(cellCol);
306 if (!mappedRow) {
307 const rowElement = document.createElement("tr");
308 subRowMap.set(cellCol, {
309 count: 1,
310 rowElement,
311 });
312
313 rowElement.appendChild(cell);
314 tbody.appendChild(rowElement);
315 continue;
316 }
317
318 if (mappedRow.count >= maxColumnsPerStack) {
319 const rowElement = document.createElement("tr");
320 let nextCount = 1;
321
322 if (repeatFirstCol && headerCells[cellCol]) {
323 const clonedHeader = headerCells[cellCol].cloneNode(
324 true
325 ) as HTMLTableCellElement;
326 clonedHeader.setAttribute("data-tableberg-tmp", "1");
327 rowElement.appendChild(clonedHeader);
328 nextCount += 1;
329 }
330
331 rowElement.appendChild(cell);
332 tbody.appendChild(rowElement);
333
334 subRowMap.set(cellCol, {
335 count: nextCount,
336 rowElement,
337 });
338
339 continue;
340 }
341
342 mappedRow.count += 1;
343 mappedRow.rowElement.appendChild(cell);
344 }
345
346 if (columnCount > 0) {
347 table.dataset.tablebergResponsiveColumns = String(columnCount);
348 }
349 }
350
351 function toColStack(
352 table: HTMLTableElement,
353 repeatFirstCol: boolean,
354 stackCount: number,
355 tag: string
356 ) {
357 const previousMode = table.dataset.tablebergLast;
358 if (previousMode === tag) {
359 return;
360 }
361
362 reviveTable(table);
363 table.dataset.tablebergLast = tag;
364
365 setTableClassName(table, "tableberg-colstack-table");
366
367 let cells = Array.from(
368 table.querySelectorAll<HTMLTableCellElement>("th, td")
369 );
370
371 if (previousMode && previousMode.includes("stack-row")) {
372 cells.sort((a, b) => {
373 const rowDiff = getCellRow(a) - getCellRow(b);
374 if (rowDiff !== 0) {
375 return rowDiff;
376 }
377
378 return getCellCol(a) - getCellCol(b);
379 });
380 }
381
382 const tbody = table.tBodies.item(0);
383 if (!tbody) {
384 return;
385 }
386
387 tbody.innerHTML = "";
388
389 const totalRows = getTableRowCount(table);
390 const totalCols = getTableColCount(table);
391 const maxCellsPerStackRow = repeatFirstCol
392 ? Math.max(2, stackCount)
393 : Math.max(1, stackCount);
394 const perStackDataCount = repeatFirstCol
395 ? Math.max(1, maxCellsPerStackRow - 1)
396 : maxCellsPerStackRow;
397 const hasHeader = parseBoolean(table.dataset.tablebergHeader);
398 const hasFooter = parseBoolean(table.dataset.tablebergFooter);
399
400 const colsToStack = repeatFirstCol ? totalCols - 1 : totalCols;
401 if (colsToStack < 1) {
402 return;
403 }
404
405 const rowsToGenerate =
406 totalRows * Math.ceil(colsToStack / perStackDataCount);
407 const rowMarkups = Array.from({ length: rowsToGenerate }, () => "");
408
409 const markCell = (cell: HTMLTableCellElement) => {
410 markRowCell(
411 cell,
412 getRowType(getCellRow(cell), totalRows, hasHeader, hasFooter)
413 );
414 };
415
416 if (repeatFirstCol) {
417 const leftColumnCells = cells.filter(cell => getCellCol(cell) === 0);
418 const nonLeftCells = cells.filter(cell => getCellCol(cell) !== 0);
419 const leftCellsWithRowspanGaps: Array<HTMLTableCellElement | "gap"> =
420 [];
421
422 leftColumnCells.forEach(cell => {
423 leftCellsWithRowspanGaps.push(cell);
424
425 const rowSpanValue = parsePositiveInt(
426 cell.getAttribute("rowspan") || undefined,
427 1
428 );
429 for (let offset = 1; offset < rowSpanValue; offset++) {
430 leftCellsWithRowspanGaps.push("gap");
431 }
432 });
433
434 cells = nonLeftCells;
435
436 for (let row = 0; row < rowsToGenerate; row++) {
437 const cell = leftCellsWithRowspanGaps[row % totalRows];
438 if (!cell || cell === "gap") {
439 continue;
440 }
441
442 if (row > totalRows - 1) {
443 cell.setAttribute("data-tableberg-tmp", "1");
444 }
445
446 markCell(cell);
447 rowMarkups[row] += cell.outerHTML;
448 }
449 }
450
451 cells.forEach(cell => {
452 const colIndex = repeatFirstCol
453 ? getCellCol(cell) - 1
454 : getCellCol(cell);
455 const rowIndex = getCellRow(cell);
456
457 const targetRow =
458 totalRows * (Math.ceil((colIndex + 1) / perStackDataCount) - 1) +
459 rowIndex;
460
461 markCell(cell);
462
463 if (targetRow >= 0 && targetRow < rowMarkups.length) {
464 rowMarkups[targetRow] += cell.outerHTML;
465 }
466 });
467
468 let tableMarkup = "";
469
470 for (let rowIndex = 0; rowIndex < rowsToGenerate; rowIndex++) {
471 const isHeaderRow = rowIndex % totalRows === 0;
472 const isFooterRow = rowIndex % totalRows === totalRows - 1;
473
474 let rowType: RowType;
475 if (hasHeader && isHeaderRow) {
476 rowType = "header";
477 } else if (hasFooter && isFooterRow) {
478 rowType = "footer";
479 } else {
480 rowType = rowIndex % 2 === 0 ? "even-row" : "odd-row";
481 }
482
483 tableMarkup += `<tr class="tableberg-${rowType}">${rowMarkups[rowIndex]}</tr>`;
484 }
485
486 const colGroup = table.querySelector("colgroup");
487 if (colGroup) {
488 colGroup.style.display = "none";
489 }
490
491 tbody.innerHTML = tableMarkup;
492 }
493
494 function getCurrentBreakpoint(
495 table: HTMLTableElement
496 ): ResponsiveBreakpoint | null {
497 const mobile = getResponsiveBreakpoint(table, "mobile");
498 const tablet = getResponsiveBreakpoint(table, "tablet");
499 const viewportWidth = window.innerWidth;
500
501 if (mobile.enabled && viewportWidth <= mobile.maxWidth) {
502 return mobile;
503 }
504
505 if (tablet.enabled && viewportWidth <= tablet.maxWidth) {
506 return tablet;
507 }
508
509 return null;
510 }
511
512 function resizeTable(table: HTMLTableElement) {
513 const breakpoint = getCurrentBreakpoint(table);
514
515 if (!breakpoint || breakpoint.mode === "") {
516 reviveTable(table);
517 return;
518 }
519
520 if (breakpoint.mode === "scroll") {
521 toScrollTable(table);
522 table.dispatchEvent(
523 new CustomEvent(TABLEBERG_RESPONSIVE_CHANGED_EVENT)
524 );
525 return;
526 }
527
528 const renderTag = `stack-${breakpoint.transpose ? "transpose" : "normal"}-${breakpoint.stackCount}-${breakpoint.repeatFirstCol ? "repeat-first-col" : "none"}`;
529
530 if (breakpoint.transpose) {
531 toRowStack(
532 table,
533 breakpoint.repeatFirstCol,
534 breakpoint.stackCount,
535 renderTag
536 );
537 } else {
538 toColStack(
539 table,
540 breakpoint.repeatFirstCol,
541 breakpoint.stackCount,
542 renderTag
543 );
544 }
545
546 table.dispatchEvent(new CustomEvent(TABLEBERG_RESPONSIVE_CHANGED_EVENT));
547 }
548
549 export function initializeResponsive() {
550 const tables = Array.from(
551 document.querySelectorAll<HTMLTableElement>(
552 ".wp-block-tableberg[data-tableberg-responsive='true']"
553 )
554 );
555
556 if (tables.length === 0) {
557 return;
558 }
559
560 const runResize = () => {
561 tables.forEach(table => {
562 resizeTable(table);
563 });
564 };
565
566 runResize();
567
568 let resizeTimeout: number | undefined;
569 window.addEventListener("resize", () => {
570 if (resizeTimeout) {
571 window.clearTimeout(resizeTimeout);
572 }
573
574 resizeTimeout = window.setTimeout(() => {
575 runResize();
576 }, 120);
577 });
578 }
579