PluginProbe
Tableberg – Simple Gutenberg Table Block / 1.2.0
Tableberg – Simple Gutenberg Table Block v1.2.0
1.2.0 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 All 43 releases
tableberg / src / frontend / responsive.ts

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

621 lines 16.2 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 /**
160 * Stacked tables lose their column gap: the space between columns comes from
161 * the horizontal half of `border-spacing`, but once the cells sit in their own
162 * rows only the vertical half is visible. Borrow the horizontal value for the
163 * vertical gap while stacked, and put the original back on the way out.
164 */
165 function applyStackSpacing(table: HTMLTableElement) {
166 if (typeof table.dataset.tablebergSpacing === "string") {
167 return;
168 }
169
170 const [horizontal, vertical] = window
171 .getComputedStyle(table)
172 .borderSpacing.split(" ");
173
174 if (!horizontal) {
175 return;
176 }
177
178 const gap = parseFloat(horizontal);
179 if (!(gap > 0) || gap <= parseFloat(vertical ?? horizontal)) {
180 return;
181 }
182
183 table.dataset.tablebergSpacing = table.style.borderSpacing;
184 table.style.borderSpacing = `${horizontal} ${horizontal}`;
185 }
186
187 function restoreStackSpacing(table: HTMLTableElement) {
188 const original = table.dataset.tablebergSpacing;
189 if (typeof original !== "string") {
190 return;
191 }
192
193 table.style.borderSpacing = original;
194 delete table.dataset.tablebergSpacing;
195 }
196
197 function reviveTable(table: HTMLTableElement) {
198 restoreStackSpacing(table);
199
200 const oldMode = table.dataset.tablebergLast;
201 if (!oldMode) {
202 return;
203 }
204
205 delete table.dataset.tablebergLast;
206
207 const wrapper = getTableWrapper(table);
208 wrapper?.classList.remove("tableberg-scroll-x");
209
210 table
211 .querySelectorAll("[data-tableberg-tmp='1']")
212 .forEach(element => element.remove());
213
214 if (!oldMode.includes("stack")) {
215 setTableClassName(table);
216 return;
217 }
218
219 setTableClassName(table);
220
221 const colGroup = table.querySelector("colgroup");
222 if (colGroup) {
223 colGroup.removeAttribute("style");
224 }
225
226 const cells = Array.from(
227 table.querySelectorAll<HTMLTableCellElement>("th, td")
228 );
229 cells.sort((a, b) => {
230 const rowDiff = getCellRow(a) - getCellRow(b);
231 if (rowDiff !== 0) {
232 return rowDiff;
233 }
234
235 return getCellCol(a) - getCellCol(b);
236 });
237
238 const tbody = table.tBodies.item(0);
239 if (!tbody) {
240 return;
241 }
242
243 tbody.innerHTML = "";
244
245 const rowCount = getTableRowCount(table);
246 const hasHeader = parseBoolean(table.dataset.tablebergHeader);
247 const hasFooter = parseBoolean(table.dataset.tablebergFooter);
248
249 let lastRow = -1;
250 let lastRowElement: HTMLTableRowElement | null = null;
251
252 for (const cell of cells) {
253 const cellRow = getCellRow(cell);
254
255 if (lastRow !== cellRow) {
256 lastRow = cellRow;
257 lastRowElement = document.createElement("tr");
258 markRow(
259 lastRowElement,
260 getRowType(cellRow, rowCount, hasHeader, hasFooter)
261 );
262 tbody.appendChild(lastRowElement);
263 }
264
265 lastRowElement?.appendChild(cell);
266 }
267 }
268
269 function toScrollTable(table: HTMLTableElement) {
270 if (table.dataset.tablebergLast === "scroll") {
271 return;
272 }
273
274 if (table.dataset.tablebergLast) {
275 reviveTable(table);
276 }
277
278 table.dataset.tablebergLast = "scroll";
279 getTableWrapper(table)?.classList.add("tableberg-scroll-x");
280 }
281
282 function toRowStack(
283 table: HTMLTableElement,
284 repeatFirstCol: boolean,
285 stackCount: number,
286 tag: string
287 ) {
288 if (table.dataset.tablebergLast === tag) {
289 return;
290 }
291
292 reviveTable(table);
293 setTableClassName(table, "tableberg-rowstack-table");
294 table.dataset.tablebergLast = tag;
295
296 const colGroup = table.querySelector("colgroup");
297 if (colGroup) {
298 colGroup.style.display = "none";
299 }
300
301 const tbody = table.tBodies.item(0);
302 if (!tbody) {
303 return;
304 }
305
306 const cells = Array.from(
307 table.querySelectorAll<HTMLTableCellElement>("th, td")
308 );
309 tbody.innerHTML = "";
310
311 const hasHeader = parseBoolean(table.dataset.tablebergHeader);
312 const hasFooter = parseBoolean(table.dataset.tablebergFooter);
313 const rowCount = getTableRowCount(table);
314 const columnCount = getTableColCount(table);
315
316 const headerCells: HTMLTableCellElement[] = [];
317 const maxColumnsPerStack = repeatFirstCol
318 ? Math.max(2, stackCount)
319 : Math.max(1, stackCount);
320
321 if (repeatFirstCol) {
322 for (const cell of cells) {
323 if (getCellRow(cell) > 0) {
324 break;
325 }
326
327 headerCells.push(cell);
328 }
329 }
330
331 const subRowMap = new Map<
332 number,
333 {
334 count: number;
335 rowElement: HTMLTableRowElement;
336 }
337 >();
338
339 for (const cell of cells) {
340 const cellRow = getCellRow(cell);
341 const cellCol = getCellCol(cell);
342 const rowType = getRowType(cellRow, rowCount, hasHeader, hasFooter);
343 markRowCell(cell, rowType);
344
345 const mappedRow = subRowMap.get(cellCol);
346 if (!mappedRow) {
347 const rowElement = document.createElement("tr");
348 subRowMap.set(cellCol, {
349 count: 1,
350 rowElement,
351 });
352
353 rowElement.appendChild(cell);
354 tbody.appendChild(rowElement);
355 continue;
356 }
357
358 if (mappedRow.count >= maxColumnsPerStack) {
359 const rowElement = document.createElement("tr");
360 let nextCount = 1;
361
362 if (repeatFirstCol && headerCells[cellCol]) {
363 const clonedHeader = headerCells[cellCol].cloneNode(
364 true
365 ) as HTMLTableCellElement;
366 clonedHeader.setAttribute("data-tableberg-tmp", "1");
367 rowElement.appendChild(clonedHeader);
368 nextCount += 1;
369 }
370
371 rowElement.appendChild(cell);
372 tbody.appendChild(rowElement);
373
374 subRowMap.set(cellCol, {
375 count: nextCount,
376 rowElement,
377 });
378
379 continue;
380 }
381
382 mappedRow.count += 1;
383 mappedRow.rowElement.appendChild(cell);
384 }
385
386 if (columnCount > 0) {
387 table.dataset.tablebergResponsiveColumns = String(columnCount);
388 }
389 }
390
391 function toColStack(
392 table: HTMLTableElement,
393 repeatFirstCol: boolean,
394 stackCount: number,
395 tag: string
396 ) {
397 const previousMode = table.dataset.tablebergLast;
398 if (previousMode === tag) {
399 return;
400 }
401
402 reviveTable(table);
403 table.dataset.tablebergLast = tag;
404
405 setTableClassName(table, "tableberg-colstack-table");
406
407 let cells = Array.from(
408 table.querySelectorAll<HTMLTableCellElement>("th, td")
409 );
410
411 if (previousMode && previousMode.includes("stack-row")) {
412 cells.sort((a, b) => {
413 const rowDiff = getCellRow(a) - getCellRow(b);
414 if (rowDiff !== 0) {
415 return rowDiff;
416 }
417
418 return getCellCol(a) - getCellCol(b);
419 });
420 }
421
422 const tbody = table.tBodies.item(0);
423 if (!tbody) {
424 return;
425 }
426
427 tbody.innerHTML = "";
428
429 const totalRows = getTableRowCount(table);
430 const totalCols = getTableColCount(table);
431 const maxCellsPerStackRow = repeatFirstCol
432 ? Math.max(2, stackCount)
433 : Math.max(1, stackCount);
434 const perStackDataCount = repeatFirstCol
435 ? Math.max(1, maxCellsPerStackRow - 1)
436 : maxCellsPerStackRow;
437 const hasHeader = parseBoolean(table.dataset.tablebergHeader);
438 const hasFooter = parseBoolean(table.dataset.tablebergFooter);
439
440 const colsToStack = repeatFirstCol ? totalCols - 1 : totalCols;
441 if (colsToStack < 1) {
442 return;
443 }
444
445 const rowsToGenerate =
446 totalRows * Math.ceil(colsToStack / perStackDataCount);
447 const rowMarkups = Array.from({ length: rowsToGenerate }, () => "");
448
449 const markCell = (cell: HTMLTableCellElement) => {
450 markRowCell(
451 cell,
452 getRowType(getCellRow(cell), totalRows, hasHeader, hasFooter)
453 );
454 };
455
456 if (repeatFirstCol) {
457 const leftColumnCells = cells.filter(cell => getCellCol(cell) === 0);
458 const nonLeftCells = cells.filter(cell => getCellCol(cell) !== 0);
459 const leftCellsWithRowspanGaps: Array<HTMLTableCellElement | "gap"> =
460 [];
461
462 leftColumnCells.forEach(cell => {
463 leftCellsWithRowspanGaps.push(cell);
464
465 const rowSpanValue = parsePositiveInt(
466 cell.getAttribute("rowspan") || undefined,
467 1
468 );
469 for (let offset = 1; offset < rowSpanValue; offset++) {
470 leftCellsWithRowspanGaps.push("gap");
471 }
472 });
473
474 cells = nonLeftCells;
475
476 for (let row = 0; row < rowsToGenerate; row++) {
477 const cell = leftCellsWithRowspanGaps[row % totalRows];
478 if (!cell || cell === "gap") {
479 continue;
480 }
481
482 if (row > totalRows - 1) {
483 cell.setAttribute("data-tableberg-tmp", "1");
484 }
485
486 markCell(cell);
487 rowMarkups[row] += cell.outerHTML;
488 }
489 }
490
491 cells.forEach(cell => {
492 const colIndex = repeatFirstCol
493 ? getCellCol(cell) - 1
494 : getCellCol(cell);
495 const rowIndex = getCellRow(cell);
496
497 const targetRow =
498 totalRows * (Math.ceil((colIndex + 1) / perStackDataCount) - 1) +
499 rowIndex;
500
501 markCell(cell);
502
503 if (targetRow >= 0 && targetRow < rowMarkups.length) {
504 rowMarkups[targetRow] += cell.outerHTML;
505 }
506 });
507
508 let tableMarkup = "";
509
510 for (let rowIndex = 0; rowIndex < rowsToGenerate; rowIndex++) {
511 const isHeaderRow = rowIndex % totalRows === 0;
512 const isFooterRow = rowIndex % totalRows === totalRows - 1;
513
514 let rowType: RowType;
515 if (hasHeader && isHeaderRow) {
516 rowType = "header";
517 } else if (hasFooter && isFooterRow) {
518 rowType = "footer";
519 } else {
520 rowType = rowIndex % 2 === 0 ? "even-row" : "odd-row";
521 }
522
523 tableMarkup += `<tr class="tableberg-${rowType}">${rowMarkups[rowIndex]}</tr>`;
524 }
525
526 const colGroup = table.querySelector("colgroup");
527 if (colGroup) {
528 colGroup.style.display = "none";
529 }
530
531 tbody.innerHTML = tableMarkup;
532 }
533
534 function getCurrentBreakpoint(
535 table: HTMLTableElement
536 ): ResponsiveBreakpoint | null {
537 const mobile = getResponsiveBreakpoint(table, "mobile");
538 const tablet = getResponsiveBreakpoint(table, "tablet");
539 const viewportWidth = window.innerWidth;
540
541 if (mobile.enabled && viewportWidth <= mobile.maxWidth) {
542 return mobile;
543 }
544
545 if (tablet.enabled && viewportWidth <= tablet.maxWidth) {
546 return tablet;
547 }
548
549 return null;
550 }
551
552 function resizeTable(table: HTMLTableElement) {
553 const breakpoint = getCurrentBreakpoint(table);
554
555 if (!breakpoint || breakpoint.mode === "") {
556 reviveTable(table);
557 return;
558 }
559
560 if (breakpoint.mode === "scroll") {
561 toScrollTable(table);
562 table.dispatchEvent(
563 new CustomEvent(TABLEBERG_RESPONSIVE_CHANGED_EVENT)
564 );
565 return;
566 }
567
568 const renderTag = `stack-${breakpoint.transpose ? "transpose" : "normal"}-${breakpoint.stackCount}-${breakpoint.repeatFirstCol ? "repeat-first-col" : "none"}`;
569
570 if (breakpoint.transpose) {
571 toRowStack(
572 table,
573 breakpoint.repeatFirstCol,
574 breakpoint.stackCount,
575 renderTag
576 );
577 } else {
578 toColStack(
579 table,
580 breakpoint.repeatFirstCol,
581 breakpoint.stackCount,
582 renderTag
583 );
584 }
585
586 applyStackSpacing(table);
587
588 table.dispatchEvent(new CustomEvent(TABLEBERG_RESPONSIVE_CHANGED_EVENT));
589 }
590
591 export function initializeResponsive() {
592 const tables = Array.from(
593 document.querySelectorAll<HTMLTableElement>(
594 ".wp-block-tableberg[data-tableberg-responsive='true']"
595 )
596 );
597
598 if (tables.length === 0) {
599 return;
600 }
601
602 const runResize = () => {
603 tables.forEach(table => {
604 resizeTable(table);
605 });
606 };
607
608 runResize();
609
610 let resizeTimeout: number | undefined;
611 window.addEventListener("resize", () => {
612 if (resizeTimeout) {
613 window.clearTimeout(resizeTimeout);
614 }
615
616 resizeTimeout = window.setTimeout(() => {
617 runResize();
618 }, 120);
619 });
620 }
621