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 / preview / table / index.tsx

index.tsx in Tableberg – Simple Gutenberg Table Block 1.2.0, at src/preview/table/index.tsx

652 lines 21.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { useMemo, CSSProperties } from "react";
2 import classNames from "classnames";
3 import { useSelect } from "@wordpress/data";
4 import { useTableStore } from "../../store";
5 import { Cell } from "../cell";
6 import { SortPreviewBanner } from "../../components/SortPreviewBanner";
7 import { PaginationNavigation } from "../../components/PaginationNavigation";
8 import { SearchInput } from "../../components/SearchInput";
9 import { isProAvailable } from "../../pro-status";
10 import {
11 CellKey,
12 ResponsiveBreakpoint,
13 Span,
14 attrDefaults,
15 getCellKey,
16 } from "../../attributes";
17 import { sortRowsByColumn } from "../../sorting";
18 import { filterRowsBySearch } from "../../search";
19 import { getElementTextContent } from "../../elements";
20 import { getTypographyCss } from "@tableberg/shared/utils/typography";
21
22 const tableConfigDefaults = attrDefaults.table;
23
24 type PreviewDevice = "desktop" | "tablet" | "mobile";
25
26 type ResponsivePreviewStore = {
27 getDeviceType?: () => string;
28 __experimentalGetPreviewDeviceType?: () => string;
29 };
30
31 type PreviewCellRef = {
32 coords: CellKey;
33 span: Span;
34 key: string;
35 };
36
37 type LegacyResponsiveBreakpoint = Partial<ResponsiveBreakpoint> & {
38 direction?: "row" | "col";
39 headerAsCol?: boolean;
40 };
41
42 const zeroWidthBorderPattern = /^0(?:\.0+)?(?:[a-z%]+)?$/i;
43
44 function hasNonZeroCssValue(value?: string) {
45 const trimmed = value?.trim() || "";
46 return !!trimmed && !zeroWidthBorderPattern.test(trimmed);
47 }
48
49 function hasVisibleBorder(border: string) {
50 const trimmedBorder = border.trim();
51
52 if (!trimmedBorder) {
53 return false;
54 }
55
56 const [width = "", style = ""] = trimmedBorder.split(/\s+/, 3);
57
58 if (width === "none" || width === "hidden") {
59 return false;
60 }
61
62 if (zeroWidthBorderPattern.test(width)) {
63 return false;
64 }
65
66 if (style === "none" || style === "hidden") {
67 return false;
68 }
69
70 return true;
71 }
72
73 function normalizeResponsiveBreakpoint(
74 breakpoint: LegacyResponsiveBreakpoint | null | undefined,
75 fallback: ResponsiveBreakpoint
76 ): ResponsiveBreakpoint {
77 const normalized = {
78 ...fallback,
79 ...(breakpoint || {}),
80 } as ResponsiveBreakpoint;
81
82 normalized.transpose =
83 typeof breakpoint?.transpose === "boolean"
84 ? breakpoint.transpose
85 : breakpoint?.direction === "row";
86
87 normalized.repeatFirstCol =
88 typeof breakpoint?.repeatFirstCol === "boolean"
89 ? breakpoint.repeatFirstCol
90 : !!breakpoint?.headerAsCol;
91
92 return normalized;
93 }
94
95 function buildResponsivePreviewRows(
96 baseRows: PreviewCellRef[][],
97 maxItemsPerRow: number,
98 transformRowsToCols: boolean,
99 repeatFirstColumn: boolean
100 ): PreviewCellRef[][] {
101 if (baseRows.length === 0) {
102 return [];
103 }
104
105 const maxCols = baseRows.reduce((max, row) => Math.max(max, row.length), 0);
106
107 if (maxCols === 0) {
108 return [];
109 }
110
111 const matrix: Array<Array<PreviewCellRef | null>> = baseRows.map(row => {
112 const padded = row.map(cell => cell as PreviewCellRef | null);
113 while (padded.length < maxCols) {
114 padded.push(null);
115 }
116 return padded;
117 });
118
119 const sourceRows: Array<Array<PreviewCellRef | null>> = transformRowsToCols
120 ? Array.from({ length: maxCols }, (_, row) =>
121 matrix.map(column => column[row] || null)
122 )
123 : matrix;
124
125 const sourceCols = sourceRows[0]?.length || 0;
126 if (sourceCols === 0) {
127 return [];
128 }
129
130 const effectiveMax = repeatFirstColumn
131 ? Math.max(2, maxItemsPerRow)
132 : Math.max(1, maxItemsPerRow);
133
134 const outputRows: PreviewCellRef[][] = [];
135
136 if (!repeatFirstColumn) {
137 const groups = Math.ceil(sourceCols / effectiveMax);
138
139 for (let group = 0; group < groups; group++) {
140 const start = group * effectiveMax;
141 const end = Math.min(start + effectiveMax, sourceCols);
142
143 for (let row = 0; row < sourceRows.length; row++) {
144 const cells = sourceRows[row]
145 .slice(start, end)
146 .filter((cell): cell is PreviewCellRef => cell !== null);
147
148 if (cells.length > 0) {
149 outputRows.push(cells);
150 }
151 }
152 }
153
154 return outputRows;
155 }
156
157 const firstColumn = 0;
158 const dataChunkSize = Math.max(1, effectiveMax - 1);
159 const groups =
160 sourceCols <= 1 ? 1 : Math.ceil((sourceCols - 1) / dataChunkSize);
161
162 for (let group = 0; group < groups; group++) {
163 const start = 1 + group * dataChunkSize;
164 const end = Math.min(start + dataChunkSize, sourceCols);
165
166 for (let row = 0; row < sourceRows.length; row++) {
167 const firstCell = sourceRows[row][firstColumn];
168 const dataCells = sourceRows[row]
169 .slice(start, end)
170 .filter((cell): cell is PreviewCellRef => cell !== null);
171
172 const rowCells: PreviewCellRef[] = [];
173
174 if (firstCell) {
175 if (group === 0) {
176 rowCells.push(firstCell);
177 } else {
178 rowCells.push({
179 ...firstCell,
180 key: `${firstCell.key}-repeat-${group}`,
181 span: { rowSpan: 1, colSpan: 1 },
182 });
183 }
184 }
185
186 rowCells.push(...dataCells);
187
188 if (rowCells.length > 0) {
189 outputRows.push(rowCells);
190 }
191 }
192 }
193
194 return outputRows;
195 }
196
197 function getPreviewDeviceType(
198 rawDeviceType: string | undefined
199 ): PreviewDevice {
200 const normalized = (rawDeviceType || "desktop").toLowerCase();
201
202 if (normalized === "tablet") {
203 return "tablet";
204 }
205
206 if (normalized === "mobile") {
207 return "mobile";
208 }
209
210 return "desktop";
211 }
212
213 export const PrimaryTable = () => {
214 const isPro = isProAvailable();
215 const tableConfig = useTableStore(state => state.table);
216 const cells = useTableStore(state => state.cells);
217 const columns = useTableStore(state => state.columns);
218 const getCellSpan = useTableStore(state => state.getCellSpan);
219 const tableBorderRadius = useTableStore(
220 state => state.cellDefaults.styles.borderRadius
221 );
222 const sortPreviewMode = useTableStore(state => state.sortPreviewMode);
223 const previewSortColumn = useTableStore(state => state.previewSortColumn);
224 const previewSortOrder = useTableStore(state => state.previewSortOrder);
225
226 const currentPage = useTableStore(state => state.currentPage);
227 const setCurrentPage = useTableStore(state => state.setCurrentPage);
228 const paginationConfig = useTableStore(state => state.table.pagination!);
229
230 const searchTerm = useTableStore(state => state.searchTerm);
231 const searchConfig = useTableStore(state => state.table.search);
232
233 const previewDevice = useSelect(select => {
234 const editorStore = select("core/editor") as ResponsivePreviewStore;
235 const siteEditorStore = select(
236 "core/edit-site"
237 ) as ResponsivePreviewStore;
238 const postEditorStore = select(
239 "core/edit-post"
240 ) as ResponsivePreviewStore;
241
242 const rawDeviceType =
243 editorStore?.getDeviceType?.() ||
244 siteEditorStore?.__experimentalGetPreviewDeviceType?.() ||
245 postEditorStore?.__experimentalGetPreviewDeviceType?.();
246
247 return getPreviewDeviceType(rawDeviceType);
248 }, []);
249
250 const { rows: totalRows, cols: totalCols } = tableConfig;
251 const paginationEnabled = isPro && paginationConfig.enabled;
252 const searchEnabled = isPro && (searchConfig?.enabled || false);
253 const tableAlignment = tableConfig.tableAlignment || "left";
254 const tableWidthValue = (tableConfig.tableWidth || "auto").trim();
255 const customTableWidthAllowed =
256 tableWidthValue !== "auto" &&
257 tableWidthValue !== "wide" &&
258 tableWidthValue !== "full";
259 const tableWidth = customTableWidthAllowed ? tableWidthValue : "100%";
260 const wrapperAlignmentClass = customTableWidthAllowed
261 ? `justify-table-${tableAlignment}`
262 : "";
263 const defaultCellSpacing = tableConfigDefaults.cellSpacing!!;
264 const cellSpacing = tableConfig.cellSpacing || defaultCellSpacing;
265 const defaultTableBorder = tableConfigDefaults.tableBorder!!;
266 const tableBorder = tableConfig.tableBorder || defaultTableBorder;
267 const hasTableBorderTop = hasVisibleBorder(tableBorder.top);
268 const hasTableBorderRight = hasVisibleBorder(tableBorder.right);
269 const hasTableBorderBottom = hasVisibleBorder(tableBorder.bottom);
270 const hasTableBorderLeft = hasVisibleBorder(tableBorder.left);
271 const horizontalCellSpacing =
272 cellSpacing.horizontal || defaultCellSpacing.horizontal;
273 const verticalCellSpacing =
274 cellSpacing.vertical || defaultCellSpacing.vertical;
275 const isHorizontalSpacingZero = horizontalCellSpacing === "0";
276 const isVerticalSpacingZero = verticalCellSpacing === "0";
277 const hasCellSpacing = !isHorizontalSpacingZero || !isVerticalSpacingZero;
278 const tableClassName = [
279 "wp-block-tableberg",
280 hasTableBorderTop ? "tableberg-has-table-border-top" : "",
281 hasTableBorderRight ? "tableberg-has-table-border-right" : "",
282 hasTableBorderBottom ? "tableberg-has-table-border-bottom" : "",
283 hasTableBorderLeft ? "tableberg-has-table-border-left" : "",
284 hasCellSpacing ? "tableberg-has-cell-spacing" : "",
285 hasCellSpacing && isHorizontalSpacingZero
286 ? "tableberg-cell-spacing-horizontal-zero"
287 : "",
288 hasCellSpacing && isVerticalSpacingZero
289 ? "tableberg-cell-spacing-vertical-zero"
290 : "",
291 ]
292 .filter(Boolean)
293 .join(" ");
294
295 const activeResponsiveBreakpoint: ResponsiveBreakpoint | null =
296 previewDevice === "desktop"
297 ? null
298 : normalizeResponsiveBreakpoint(
299 tableConfig.responsive?.[
300 previewDevice
301 ] as LegacyResponsiveBreakpoint,
302 tableConfigDefaults.responsive![previewDevice]
303 );
304
305 const hasResponsivePreview =
306 !!activeResponsiveBreakpoint?.enabled &&
307 !!activeResponsiveBreakpoint?.mode;
308
309 const responsivePreviewClassName = !hasResponsivePreview
310 ? ""
311 : activeResponsiveBreakpoint?.mode === "scroll"
312 ? "tableberg-scroll-x"
313 : "";
314
315 const editorResponsiveStackCount = hasResponsivePreview
316 ? Math.max(1, activeResponsiveBreakpoint?.stackCount || 1)
317 : 1;
318
319 // Stacked cells sit in their own rows, so the column gap (the horizontal
320 // half of border-spacing) stops showing. Borrow it for the vertical gap
321 // while previewing a stack, matching what the frontend does.
322 const isStackPreview =
323 hasResponsivePreview && activeResponsiveBreakpoint?.mode === "stack";
324 const previewVerticalCellSpacing =
325 isStackPreview &&
326 parseFloat(horizontalCellSpacing) > parseFloat(verticalCellSpacing)
327 ? horizontalCellSpacing
328 : verticalCellSpacing;
329
330 const wrapperClassName = classNames(
331 "tableberg-table-wrapper",
332 wrapperAlignmentClass,
333 responsivePreviewClassName
334 );
335
336 const filteredRowIndices = useMemo(() => {
337 if (!searchEnabled || !searchTerm.trim()) {
338 return Array.from({ length: totalRows }, (_, i) => i);
339 }
340
341 return filterRowsBySearch(
342 cells,
343 totalRows,
344 totalCols,
345 tableConfig,
346 searchTerm
347 );
348 }, [searchEnabled, searchTerm, cells, totalRows, totalCols, tableConfig]);
349
350 const sortedRowIndices = useMemo(() => {
351 if (!isPro || !sortPreviewMode || previewSortColumn === null) {
352 return filteredRowIndices;
353 }
354
355 const sortType = columns[previewSortColumn]?.sortable || "text";
356
357 if (searchEnabled && searchTerm.trim()) {
358 const { headerEnabled, footerEnabled } = tableConfig;
359 const headerRow = headerEnabled ? 0 : -1;
360 const footerRow = footerEnabled ? totalRows - 1 : -1;
361
362 const dataRows = filteredRowIndices.filter(
363 idx => idx !== headerRow && idx !== footerRow
364 );
365
366 const rowsWithValues = dataRows.map(rowIdx => {
367 const elements =
368 cells[`${rowIdx},${previewSortColumn}`]?.elements || [];
369 const rawValue =
370 elements.length > 0
371 ? getElementTextContent(elements[0])
372 : "";
373
374 let parsedValue: string | number | Date;
375 switch (sortType) {
376 case "number": {
377 const cleaned = rawValue.replace(/[^0-9.-]/g, "");
378 const num = parseFloat(cleaned);
379 parsedValue = isNaN(num) ? 0 : num;
380 break;
381 }
382 case "date": {
383 const date = new Date(rawValue);
384 parsedValue = isNaN(date.getTime())
385 ? new Date(0)
386 : date;
387 break;
388 }
389 case "text":
390 default:
391 parsedValue = rawValue.toLowerCase();
392 }
393
394 return { index: rowIdx, value: parsedValue };
395 });
396
397 rowsWithValues.sort(({ value: a }, { value: b }) => {
398 let result: number;
399 if (typeof a === "number" && typeof b === "number") {
400 result = a - b;
401 } else if (a instanceof Date && b instanceof Date) {
402 result = a.getTime() - b.getTime();
403 } else {
404 result = String(a).localeCompare(String(b));
405 }
406 return previewSortOrder === "asc" ? result : -result;
407 });
408
409 const result: number[] = [];
410 if (headerEnabled && filteredRowIndices.includes(0)) {
411 result.push(0);
412 }
413 for (const { index } of rowsWithValues) {
414 result.push(index);
415 }
416 if (footerEnabled && filteredRowIndices.includes(totalRows - 1)) {
417 result.push(totalRows - 1);
418 }
419
420 return result;
421 }
422
423 return sortRowsByColumn(
424 cells,
425 totalRows,
426 tableConfig,
427 previewSortColumn,
428 sortType,
429 previewSortOrder
430 );
431 }, [
432 sortPreviewMode,
433 previewSortColumn,
434 previewSortOrder,
435 filteredRowIndices,
436 cells,
437 columns,
438 tableConfig,
439 totalRows,
440 searchEnabled,
441 searchTerm,
442 isPro,
443 ]);
444
445 const rowOrder = useMemo(() => {
446 let rows = sortedRowIndices;
447
448 if (paginationEnabled) {
449 const { headerEnabled, footerEnabled } = tableConfig;
450
451 const headerRow = headerEnabled ? 0 : -1;
452 const footerRow = footerEnabled ? totalRows - 1 : -1;
453
454 const dataRowsInOrder = rows.filter(
455 idx => idx !== headerRow && idx !== footerRow
456 );
457
458 const { pageSize } = paginationConfig;
459 const startIdx = currentPage * pageSize;
460 const endIdx = Math.min(
461 startIdx + pageSize,
462 dataRowsInOrder.length
463 );
464
465 const maxPage = Math.max(
466 0,
467 Math.ceil(dataRowsInOrder.length / pageSize) - 1
468 );
469 if (currentPage > maxPage && maxPage >= 0) {
470 setCurrentPage(maxPage);
471 }
472
473 const pagedRows: number[] = [];
474
475 if (headerEnabled && rows.includes(0)) {
476 pagedRows.push(0);
477 }
478
479 for (let i = startIdx; i < endIdx; i++) {
480 pagedRows.push(dataRowsInOrder[i]);
481 }
482
483 if (footerEnabled && rows.includes(totalRows - 1)) {
484 pagedRows.push(totalRows - 1);
485 }
486
487 rows = pagedRows;
488 }
489
490 return rows;
491 }, [
492 sortedRowIndices,
493 paginationEnabled,
494 currentPage,
495 paginationConfig.pageSize,
496 tableConfig,
497 totalRows,
498 setCurrentPage,
499 ]);
500
501 const getOccupiedCellsForRow = (actualRow: number) => {
502 if (sortPreviewMode || paginationEnabled) {
503 return new Set<number>();
504 }
505
506 const occupied = new Set<number>();
507 for (const [key, cell] of Object.entries(cells)) {
508 const span = cell.span;
509 if (!span) {
510 continue;
511 }
512
513 const [cellRow, cellCol] = key.split(",").map(Number);
514 if (cellRow <= actualRow && cellRow + span.rowSpan > actualRow) {
515 for (let cs = 0; cs < span.colSpan; cs++) {
516 if (cellRow !== actualRow) {
517 occupied.add(cellCol + cs);
518 }
519 }
520 }
521 }
522 return occupied;
523 };
524
525 const tableRows: JSX.Element[] = [];
526 const baseRowsForResponsivePreview: PreviewCellRef[][] = [];
527 for (const actualRow of rowOrder) {
528 const rowCells: JSX.Element[] = [];
529 const rowCellsForPreview: PreviewCellRef[] = [];
530 const occupiedCols = getOccupiedCellsForRow(actualRow);
531
532 for (let col = 0; col < totalCols; col++) {
533 if (occupiedCols.has(col)) {
534 continue;
535 }
536
537 const cellKey = `${actualRow},${col}`;
538 const span = getCellSpan(cellKey as CellKey);
539
540 if (!sortPreviewMode && !paginationEnabled) {
541 for (let cs = 1; cs < span.colSpan; cs++) {
542 occupiedCols.add(col + cs);
543 }
544 }
545
546 rowCellsForPreview.push({
547 coords: getCellKey(actualRow, col),
548 span,
549 key: cellKey,
550 });
551
552 rowCells.push(
553 <Cell
554 key={cellKey}
555 span={span}
556 cellCoords={cellKey as CellKey}
557 />
558 );
559 }
560
561 baseRowsForResponsivePreview.push(rowCellsForPreview);
562 tableRows.push(<tr key={actualRow}>{rowCells}</tr>);
563 }
564
565 let renderedRows = tableRows;
566
567 if (hasResponsivePreview && activeResponsiveBreakpoint?.mode === "stack") {
568 const previewRows = buildResponsivePreviewRows(
569 baseRowsForResponsivePreview,
570 editorResponsiveStackCount,
571 activeResponsiveBreakpoint.transpose,
572 activeResponsiveBreakpoint.repeatFirstCol
573 );
574
575 renderedRows = previewRows.map((previewRow, rowIndex) => (
576 <tr key={`responsive-preview-row-${rowIndex}`}>
577 {previewRow.map((cell, cellIndex) => (
578 <Cell
579 key={`${cell.key}-${rowIndex}-${cellIndex}`}
580 span={{ rowSpan: 1, colSpan: 1 }}
581 cellCoords={cell.coords}
582 />
583 ))}
584 </tr>
585 ));
586 }
587
588 // The rounded wrapper owns only the explicit table border. Cell borders
589 // stay on cells so they do not create a second, unintended outer border.
590 const hasTableRadius = !!(
591 hasNonZeroCssValue(tableBorderRadius?.topLeft) ||
592 hasNonZeroCssValue(tableBorderRadius?.topRight) ||
593 hasNonZeroCssValue(tableBorderRadius?.bottomRight) ||
594 hasNonZeroCssValue(tableBorderRadius?.bottomLeft)
595 );
596 const tableRadiusWrapperStyle: CSSProperties | undefined = hasTableRadius
597 ? {
598 borderTopLeftRadius: tableBorderRadius?.topLeft,
599 borderTopRightRadius: tableBorderRadius?.topRight,
600 borderBottomRightRadius: tableBorderRadius?.bottomRight,
601 borderBottomLeftRadius: tableBorderRadius?.bottomLeft,
602 borderTop: tableBorder.top || undefined,
603 borderRight: tableBorder.right || undefined,
604 borderBottom: tableBorder.bottom || undefined,
605 borderLeft: tableBorder.left || undefined,
606 boxSizing: "border-box",
607 overflow: "hidden",
608 }
609 : undefined;
610
611 return (
612 <div className={wrapperClassName} style={tableRadiusWrapperStyle}>
613 <SearchInput />
614 <SortPreviewBanner />
615 <table
616 className={tableClassName}
617 data-tableberg-editor-preview={
618 hasResponsivePreview ? previewDevice : undefined
619 }
620 style={{
621 ...getTypographyCss(tableConfig.typography),
622 borderCollapse: hasCellSpacing ? "separate" : "collapse",
623 borderSpacing: hasCellSpacing
624 ? `${horizontalCellSpacing} ${previewVerticalCellSpacing}`
625 : undefined,
626 width: tableWidth,
627 ...(customTableWidthAllowed
628 ? {
629 maxWidth: tableWidth,
630 }
631 : {}),
632 borderTop: hasTableRadius
633 ? undefined
634 : tableBorder.top || undefined,
635 borderRight: hasTableRadius
636 ? undefined
637 : tableBorder.right || undefined,
638 borderBottom: hasTableRadius
639 ? undefined
640 : tableBorder.bottom || undefined,
641 borderLeft: hasTableRadius
642 ? undefined
643 : tableBorder.left || undefined,
644 }}
645 >
646 <tbody>{renderedRows}</tbody>
647 </table>
648 <PaginationNavigation filteredRowCount={sortedRowIndices.length} />
649 </div>
650 );
651 };
652