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 / renderer / Table / TableRenderer.php

TableRenderer.php in Tableberg – Simple Gutenberg Table Block 1.1.5, at renderer/Table/TableRenderer.php

813 lines 30.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Tableberg\Renderer\Table;
4
5 use Tableberg\Renderer\Cell\CellRenderer;
6 use Tableberg\Renderer\Cell\CellRenderContext;
7 use Tableberg\Renderer\Attrs\StringAttr;
8
9 class TableRenderer {
10 private function has_visible_border($border) {
11 $trimmed_border = trim($border);
12
13 if ($trimmed_border === '') {
14 return false;
15 }
16
17 $parts = preg_split('/\s+/', $trimmed_border, 3);
18 $width = $parts[0] ?? '';
19 $style = $parts[1] ?? '';
20
21 if ($width === 'none' || $width === 'hidden') {
22 return false;
23 }
24
25 if (preg_match('/^0(?:\.0+)?(?:[a-z%]+)?$/i', $width) === 1) {
26 return false;
27 }
28
29 if ($style === 'none' || $style === 'hidden') {
30 return false;
31 }
32
33 return true;
34 }
35
36 public function render($attributes, $content = '', $block = null) {
37 // v4 native-blocks format: table data lives in the innerBlocks tree
38 // (rows -> cells -> elements). Adapt it to the v3 attrs shape so the
39 // renderer below stays format-agnostic.
40 $version = is_array($attributes) && isset($attributes['version']) && is_numeric($attributes['version'])
41 ? (int) $attributes['version']
42 : 0;
43 if (
44 $version >= 4 &&
45 is_object($block) &&
46 isset($block->parsed_block['innerBlocks']) &&
47 is_array($block->parsed_block['innerBlocks'])
48 ) {
49 $attributes = InnerBlocksAttrsAdapter::to_attrs(
50 $attributes,
51 $block->parsed_block['innerBlocks']
52 );
53 }
54
55 $attrs = TableAttrs::from_array($attributes);
56
57 $rows = $attrs->table->rows->value();
58 $cols = $attrs->table->cols->value();
59
60 $headerEnabled = $attrs->table->headerEnabled->value();
61 $footerEnabled = $attrs->table->footerEnabled->value();
62
63 // Sticky header/first column are pro features: free never enables
64 // them on its own, and pro reads the raw table attrs to decide.
65 $stickySettings = apply_filters(
66 'tableberg/table_sticky_settings',
67 ['stickyHeader' => false, 'stickyFirstCol' => false],
68 $attrs->attrs
69 );
70 $stickyHeader = !empty($stickySettings['stickyHeader']);
71 $stickyFirstCol = !empty($stickySettings['stickyFirstCol']);
72
73 // Row-only/column-only is Pro-owned. Free's empty default preserves
74 // the attribute without enabling its editor or frontend behavior.
75 $innerBorderType = apply_filters(
76 'tableberg/inner_border_type',
77 '',
78 $attrs->attrs
79 );
80 if (!in_array($innerBorderType, ['row', 'col'], true)) {
81 $innerBorderType = '';
82 }
83
84 $caption = $attrs->table->caption;
85 $tableWidth = trim($attrs->table->tableWidth->asAttr());
86 $tableAlignment = $attrs->table->tableAlignment->asAttr();
87 $cellSpacingHorizontal = trim($attrs->table->cellSpacing->horizontal->asAttr());
88 $cellSpacingVertical = trim($attrs->table->cellSpacing->vertical->asAttr());
89 $tableBorderTop = trim($attrs->table->tableBorder->top->asAttr());
90 $tableBorderRight = trim($attrs->table->tableBorder->right->asAttr());
91 $tableBorderBottom = trim($attrs->table->tableBorder->bottom->asAttr());
92 $tableBorderLeft = trim($attrs->table->tableBorder->left->asAttr());
93 $fixedColumnWidths = $attrs->table->fixedColumnWidths->value();
94 $tableRadius = [
95 'topLeft' => $attrs->cellDefaults->styles->borderRadius->topLeft->asAttr(),
96 'topRight' => $attrs->cellDefaults->styles->borderRadius->topRight->asAttr(),
97 'bottomRight' => $attrs->cellDefaults->styles->borderRadius->bottomRight->asAttr(),
98 'bottomLeft' => $attrs->cellDefaults->styles->borderRadius->bottomLeft->asAttr(),
99 ];
100 $isZeroRadius = function ($value) {
101 $trimmed = trim((string) $value);
102 return $trimmed === '' || preg_match('/^0(?:\.0+)?[a-z%]*$/i', $trimmed) === 1;
103 };
104 $hasRoundedCorners = count(array_filter(
105 $tableRadius,
106 function ($value) use ($isZeroRadius) {
107 return !$isZeroRadius($value);
108 }
109 )) > 0;
110
111 $isHorizontalSpacingZero = $cellSpacingHorizontal === '0';
112 $isVerticalSpacingZero = $cellSpacingVertical === '0';
113 $hasCellSpacing = !$isHorizontalSpacingZero || !$isVerticalSpacingZero;
114
115 $isWideWidth = $tableWidth === 'wide';
116 $isFullWidth = $tableWidth === 'full';
117
118 $canApplyCustomWidth =
119 $tableWidth !== '' &&
120 !$isWideWidth &&
121 !$isFullWidth &&
122 $tableWidth !== 'auto';
123
124 if (!$canApplyCustomWidth) {
125 $tableWidth = '';
126 }
127
128 $wrapperAlignmentClass = $this->getWrapperAlignmentClass(
129 $isWideWidth,
130 $isFullWidth,
131 $canApplyCustomWidth,
132 $tableAlignment
133 );
134
135 $tableStyles = [
136 'border-collapse: ' . ($hasCellSpacing ? 'separate' : 'collapse'),
137 // Neutralize theme-level table borders. Tableberg owns its
138 // borders through the table/cell controls and rounded wrapper.
139 'border: 0',
140 ];
141
142 if ($hasCellSpacing) {
143 $tableStyles[] = "border-spacing: {$cellSpacingHorizontal} {$cellSpacingVertical}";
144 }
145
146 if ($tableWidth !== '') {
147 $tableStyles[] = "width: {$tableWidth}";
148 $tableStyles[] = "max-width: {$tableWidth}";
149 } else {
150 $tableStyles[] = 'width: 100%';
151 }
152
153 if (!$hasRoundedCorners && $tableBorderTop !== '') {
154 $tableStyles[] = "border-top: {$tableBorderTop}";
155 }
156
157 if (!$hasRoundedCorners && $tableBorderRight !== '') {
158 $tableStyles[] = "border-right: {$tableBorderRight}";
159 }
160
161 if (!$hasRoundedCorners && $tableBorderBottom !== '') {
162 $tableStyles[] = "border-bottom: {$tableBorderBottom}";
163 }
164
165 if (!$hasRoundedCorners && $tableBorderLeft !== '') {
166 $tableStyles[] = "border-left: {$tableBorderLeft}";
167 }
168
169 $tableStyleAttr = "style='" . implode('; ', $tableStyles) . ";'";
170
171 $tableClasses = ['wp-block-tableberg'];
172
173 if ($this->has_visible_border($tableBorderTop)) {
174 $tableClasses[] = 'tableberg-has-table-border-top';
175 }
176
177 if ($this->has_visible_border($tableBorderRight)) {
178 $tableClasses[] = 'tableberg-has-table-border-right';
179 }
180
181 if ($this->has_visible_border($tableBorderBottom)) {
182 $tableClasses[] = 'tableberg-has-table-border-bottom';
183 }
184
185 if ($this->has_visible_border($tableBorderLeft)) {
186 $tableClasses[] = 'tableberg-has-table-border-left';
187 }
188
189 if ($hasCellSpacing) {
190 $tableClasses[] = 'tableberg-has-cell-spacing';
191
192 if ($isHorizontalSpacingZero) {
193 $tableClasses[] = 'tableberg-cell-spacing-horizontal-zero';
194 }
195
196 if ($isVerticalSpacingZero) {
197 $tableClasses[] = 'tableberg-cell-spacing-vertical-zero';
198 }
199 }
200
201 $tableClassAttr = implode(' ', $tableClasses);
202
203 // Column sorting is a pro feature: free never marks a column
204 // sortable on its own, and pro reads the table's raw columns to
205 // decide which ones are.
206 $sortableColumns = apply_filters(
207 'tableberg/sortable_columns',
208 [],
209 $attrs->attrs
210 );
211 if (!is_array($sortableColumns)) {
212 $sortableColumns = [];
213 }
214
215 $paginationPageSize = (int) $attrs->table->pagination->pageSize->value();
216 if ($paginationPageSize < 1) {
217 $paginationPageSize = 1;
218 }
219
220 $paginationConfig = [
221 'enabled' => apply_filters('tableberg/pagination_enabled', false, $attrs->attrs),
222 'pageSize' => $paginationPageSize,
223 'showPageNumbers' => $attrs->table->pagination->showPageNumbers->value(),
224 'showPrevNext' => $attrs->table->pagination->showPrevNext->value(),
225 ];
226
227 $searchSettings = apply_filters(
228 'tableberg/table_search_settings',
229 [
230 'enabled' => false,
231 'placeholder' => '',
232 'position' => 'left',
233 'highlightColor' => '',
234 ],
235 $attrs->attrs
236 );
237 $searchEnabledAsStr = !empty($searchSettings['enabled']) ? 'true' : 'false';
238 $searchPlaceholder = (string) ($searchSettings['placeholder'] ?? '');
239 $searchPosition = (string) ($searchSettings['position'] ?? 'left');
240 $searchHighlightColor = (string) ($searchSettings['highlightColor'] ?? '');
241 $responsiveDataAttrs = $this->buildResponsiveDataAttrs($attrs, $rows, $cols);
242
243 // Horizontal cell-element layout (and the wrap toggle that only
244 // matters with it) is a pro feature; free's default is always the
245 // vertical stack. Grouped in one filter since they're one decision.
246 $cellLayout = apply_filters(
247 'tableberg/cell_default_layout',
248 ['orientation' => 'vertical', 'wrap' => 'nowrap'],
249 $attrs->attrs
250 );
251
252 $globalCellStyles = [
253 'padding' => [
254 'top' => $attrs->cellDefaults->styles->padding->top->asAttr(),
255 'right' => $attrs->cellDefaults->styles->padding->right->asAttr(),
256 'bottom' => $attrs->cellDefaults->styles->padding->bottom->asAttr(),
257 'left' => $attrs->cellDefaults->styles->padding->left->asAttr(),
258 ],
259 'orientation' => $cellLayout['orientation'] === 'horizontal' ? 'horizontal' : 'vertical',
260 'elementGap' => $attrs->cellDefaults->styles->elementGap->asAttr(),
261 'wrap' => $cellLayout['wrap'] === 'wrap' ? 'wrap' : 'nowrap',
262 'verticalAlign' => $attrs->cellDefaults->styles->verticalAlign->asAttr(),
263 'backgroundColor' => $attrs->cellDefaults->styles->backgroundColor->asAttr(),
264 'border' => [
265 'top' => $attrs->cellDefaults->styles->border->top->asAttr(),
266 'right' => $attrs->cellDefaults->styles->border->right->asAttr(),
267 'bottom' => $attrs->cellDefaults->styles->border->bottom->asAttr(),
268 'left' => $attrs->cellDefaults->styles->border->left->asAttr(),
269 ],
270 'borderRadius' => [
271 'topLeft' => $attrs->cellDefaults->styles->borderRadius->topLeft->asAttr(),
272 'topRight' => $attrs->cellDefaults->styles->borderRadius->topRight->asAttr(),
273 'bottomRight' => $attrs->cellDefaults->styles->borderRadius->bottomRight->asAttr(),
274 'bottomLeft' => $attrs->cellDefaults->styles->borderRadius->bottomLeft->asAttr(),
275 ],
276 ];
277
278 // A collapsed table cannot reliably round its own explicit border.
279 // The wrapper owns only the table border and clips the cell grid to
280 // it. Cell borders stay on cells; copying them to the wrapper would
281 // add an outline that the user did not configure as a table border.
282 $tableOuterBorder = [
283 'top' => $tableBorderTop,
284 'right' => $tableBorderRight,
285 'bottom' => $tableBorderBottom,
286 'left' => $tableBorderLeft,
287 ];
288 $globalCellStyles['borderRadius'] = [
289 'topLeft' => '',
290 'topRight' => '',
291 'bottomRight' => '',
292 'bottomLeft' => '',
293 ];
294
295 $cellRenderer = new CellRenderer();
296 $hiddenBySpan = [];
297
298 $rowsHtml = '';
299
300 for ($row = 0; $row < $rows; $row++) {
301 $cellsHtml = '';
302 $rowHeight = $this->getRowHeight($row, $attrs->rows);
303 $rowStyles = $this->getRowStyles($row, $attrs->rows);
304 $rowBackgroundColor = isset($rowStyles['backgroundColor']) && is_string($rowStyles['backgroundColor'])
305 ? $rowStyles['backgroundColor']
306 : '';
307
308 $cellStylesForRow = $globalCellStyles;
309
310 // Header/footer/even/odd are table-wide defaults, resolved per
311 // row here the same way `cell/index.tsx`'s
312 // `useRowBackgroundStyleKey` does in the editor — header/footer
313 // don't consume a slot in the even/odd count.
314 $isHeaderRow = $headerEnabled && $row === 0;
315 $isFooterRow = $footerEnabled && $rows > 0 && $row === $rows - 1;
316
317 if ($isHeaderRow) {
318 $rowPositionBackground = $attrs->cellDefaults->styles->headerBackgroundColor->asAttr();
319 } elseif ($isFooterRow) {
320 $rowPositionBackground = $attrs->cellDefaults->styles->footerBackgroundColor->asAttr();
321 } else {
322 $dataRowPosition = $headerEnabled ? $row - 1 : $row;
323 $rowPositionBackground = ($dataRowPosition % 2 === 0)
324 ? $attrs->cellDefaults->styles->oddRowBackgroundColor->asAttr()
325 : $attrs->cellDefaults->styles->evenRowBackgroundColor->asAttr();
326 }
327
328 if ($rowPositionBackground !== '') {
329 $cellStylesForRow['backgroundColor'] = $rowPositionBackground;
330 }
331
332 // A row with its own background would otherwise never show
333 // through: every cell paints its own background over the
334 // `<tr>`'s, and cells fall back to the table-wide default
335 // whenever they carry no colour of their own. So for this row's
336 // cells, that fallback is cleared — a cell/column colour on top
337 // of it (applied below by the pro filter) still wins either way.
338 if ($rowBackgroundColor !== '') {
339 $cellStylesForRow['backgroundColor'] = '';
340 }
341
342 for ($col = 0; $col < $cols; $col++) {
343 if (isset($hiddenBySpan[$row][$col])) {
344 continue;
345 }
346
347 $cell = $this->getCell($row, $col, $attrs->cells);
348 $span = $this->getCellSpan($cell);
349
350 $rowSpan = is_numeric($span['rowSpan']) ? (int) $span['rowSpan'] : 1;
351 $colSpan = is_numeric($span['colSpan']) ? (int) $span['colSpan'] : 1;
352
353 if ($rowSpan > 1 || $colSpan > 1) {
354 for ($rowOffset = 0; $rowOffset < $rowSpan; $rowOffset++) {
355 for ($colOffset = 0; $colOffset < $colSpan; $colOffset++) {
356 if ($rowOffset === 0 && $colOffset === 0) {
357 continue;
358 }
359
360 $hiddenRow = $row + $rowOffset;
361 $hiddenCol = $col + $colOffset;
362 $hiddenBySpan[$hiddenRow][$hiddenCol] = true;
363 }
364 }
365 }
366
367 $isHeaderRowCell = $headerEnabled && $row === 0;
368 $isFooterCell = $footerEnabled && $rows > 0 && $row === $rows - 1;
369
370 $tag = ($isHeaderRowCell || $isFooterCell) ? 'th' : 'td';
371
372 $sortableType = (
373 $isHeaderRowCell &&
374 array_key_exists($col, $sortableColumns)
375 ) ? $sortableColumns[$col] : null;
376
377 $columnWidth = null;
378 if ($colSpan === 1) {
379 if ($fixedColumnWidths && $cols > 0) {
380 $columnWidth = (string) (100 / $cols) . '%';
381 } else {
382 $columnWidth = $this->getColumnWidth(
383 $col,
384 $attrs->columns
385 );
386 }
387 }
388
389 $cellsHtml .= $cellRenderer->render(
390 CellRenderContext::create(
391 $row,
392 $col,
393 $rowSpan,
394 $colSpan,
395 $tag,
396 $cellStylesForRow,
397 $this->getCellElements($cell),
398 $this->getCellStyleOverride($cell),
399 $this->getCellRibbon($cell),
400 $sortableType,
401 $columnWidth,
402 $rowHeight,
403 $stickyHeader,
404 $stickyFirstCol,
405 $this->getCellClassName($cell),
406 $cell instanceof CellData ? $cell->attrs : [],
407 $this->isCellEmpty($cell),
408 $innerBorderType,
409 $rows,
410 $cols
411 )
412 );
413 }
414
415 $rowStyleParts = [];
416 if ($rowBackgroundColor !== '') {
417 $rowStyleParts[] = 'background-color:' . esc_attr($rowBackgroundColor);
418 }
419
420 $rowBorder = isset($rowStyles['border']) && is_array($rowStyles['border'])
421 ? $rowStyles['border']
422 : [];
423 foreach (['top', 'right', 'bottom', 'left'] as $side) {
424 $value = isset($rowBorder[$side]) && is_string($rowBorder[$side])
425 ? $rowBorder[$side]
426 : '';
427 if ($value !== '') {
428 $rowStyleParts[] = "border-{$side}:" . esc_attr($value);
429 }
430 }
431
432 $rowStyleAttr = !empty($rowStyleParts)
433 ? ' style="' . implode(';', $rowStyleParts) . '"'
434 : '';
435
436 $rowsHtml .= "<tr data-tableberg-row='{$row}'$rowStyleAttr>$cellsHtml</tr>";
437 }
438
439 $sortingBoolAsStr = !empty($sortableColumns) ? 'true' : 'false';
440 $headerBoolAsStr = $headerEnabled ? 'true' : 'false';
441 $footerBoolAsStr = $footerEnabled ? 'true' : 'false';
442
443 $columnsData = [];
444 foreach ($sortableColumns as $column => $sortType) {
445 $columnsData[$column] = [
446 'sortable' => $sortType,
447 ];
448 }
449
450 $columnsJson = wp_json_encode($columnsData);
451 if (!is_string($columnsJson)) {
452 $columnsJson = '{}';
453 }
454 $columnsJson = esc_attr($columnsJson);
455
456 $paginationJson = wp_json_encode($paginationConfig);
457 if (!is_string($paginationJson)) {
458 $paginationJson = '{}';
459 }
460 $paginationJson = esc_attr($paginationJson);
461
462 $searchPlaceholderAttr = esc_attr($searchPlaceholder);
463 $searchPositionAttr = esc_attr($searchPosition);
464 $searchHighlightColorAttr = esc_attr($searchHighlightColor);
465
466 $wrapperClass = trim("tableberg-table-wrapper {$wrapperAlignmentClass}");
467
468 // Round the whole table's outer corners on the wrapper.
469 // Zero radii are skipped so a table without any actual rounding does
470 // not carry pointless border-radius declarations.
471 $wrapperStyles = [];
472 if (!$isZeroRadius($tableRadius['topLeft'])) {
473 $wrapperStyles[] = 'border-top-left-radius:' . $tableRadius['topLeft'];
474 }
475 if (!$isZeroRadius($tableRadius['topRight'])) {
476 $wrapperStyles[] = 'border-top-right-radius:' . $tableRadius['topRight'];
477 }
478 if (!$isZeroRadius($tableRadius['bottomRight'])) {
479 $wrapperStyles[] = 'border-bottom-right-radius:' . $tableRadius['bottomRight'];
480 }
481 if (!$isZeroRadius($tableRadius['bottomLeft'])) {
482 $wrapperStyles[] = 'border-bottom-left-radius:' . $tableRadius['bottomLeft'];
483 }
484
485 if (!empty($wrapperStyles)) {
486 if ($tableOuterBorder['top'] !== '') {
487 $wrapperStyles[] = 'border-top:' . $tableOuterBorder['top'];
488 }
489 if ($tableOuterBorder['right'] !== '') {
490 $wrapperStyles[] = 'border-right:' . $tableOuterBorder['right'];
491 }
492 if ($tableOuterBorder['bottom'] !== '') {
493 $wrapperStyles[] = 'border-bottom:' . $tableOuterBorder['bottom'];
494 }
495 if ($tableOuterBorder['left'] !== '') {
496 $wrapperStyles[] = 'border-left:' . $tableOuterBorder['left'];
497 }
498 $wrapperStyles[] = 'box-sizing:border-box';
499 }
500
501 // A table wider than its wrapper must scroll inside the wrapper
502 // instead of pushing the whole page sideways, so the wrapper is a
503 // scroll container by default.
504 //
505 // The one exception is a header that sticks to the page: a scroll
506 // container of its own would capture it and it would never stick.
507 // (CSS cannot do both — with overflow-x set, overflow-y can no longer
508 // be visible.) A sticky first column, on the other hand, only works
509 // *because* of the scroll container, so when both are enabled the
510 // scrolling wins and the header sticks within the wrapper.
511 //
512 // A rounded wrapper always needs the clip, so it opts in regardless.
513 $pageStickyHeader = $stickyHeader && !$stickyFirstCol;
514 if ($hasRoundedCorners || !$pageStickyHeader) {
515 // `auto`, not `hidden`: both clip to the rounded corners, but
516 // `hidden` would also swallow a table wider than the wrapper.
517 // Inline, so it also wins over the `.tableberg-scroll-x` rule.
518 $wrapperStyles[] = 'overflow:auto';
519 }
520
521 $wrapperStyleAttr = !empty($wrapperStyles)
522 ? "style='" . implode(';', $wrapperStyles) . "'"
523 : '';
524
525 $figureAlignmentClass = '';
526 if ($isWideWidth) {
527 $figureAlignmentClass = 'alignwide';
528 } elseif ($isFullWidth) {
529 $figureAlignmentClass = 'alignfull';
530 }
531
532 $figureClass = trim(
533 implode(' ', array_filter([
534 'wp-block-tableberg',
535 $attrs->table->className->asAttr(),
536 $figureAlignmentClass,
537 ]))
538 );
539
540 $figureStyles = [];
541 $spacingSides = [
542 'margin-top' => $attrs->table->margin->top,
543 'margin-right' => $attrs->table->margin->right,
544 'margin-bottom' => $attrs->table->margin->bottom,
545 'margin-left' => $attrs->table->margin->left,
546 'padding-top' => $attrs->table->padding->top,
547 'padding-right' => $attrs->table->padding->right,
548 'padding-bottom' => $attrs->table->padding->bottom,
549 'padding-left' => $attrs->table->padding->left,
550 ];
551 foreach ($spacingSides as $prop => $sideAttr) {
552 if ($sideAttr->isNotEmpty()) {
553 $figureStyles[] = $prop . ': ' . $sideAttr->asAttr();
554 }
555 }
556 $figureStyleAttr = !empty($figureStyles)
557 ? "style='" . implode('; ', $figureStyles) . ";'"
558 : '';
559
560 $captionHtml = '';
561 if ($caption->isNotEmpty()) {
562 $captionHtml = "<figcaption class='tableberg-table-caption wp-element-caption'>{$caption->asHtml()}</figcaption>";
563 }
564
565 $html =
566 "<figure class='{$figureClass}' {$figureStyleAttr}>
567 <div class='{$wrapperClass}' {$wrapperStyleAttr}>
568 <table
569 class='{$tableClassAttr}'
570 {$tableStyleAttr}
571 data-tableberg-sortable='$sortingBoolAsStr'
572 data-tableberg-columns='$columnsJson'
573 data-tableberg-pagination='$paginationJson'
574 data-tableberg-search-enabled='$searchEnabledAsStr'
575 data-tableberg-search-placeholder='$searchPlaceholderAttr'
576 data-tableberg-search-position='$searchPositionAttr'
577 data-tableberg-search-highlight-color='$searchHighlightColorAttr'
578 data-tableberg-header='$headerBoolAsStr'
579 data-tableberg-footer='$footerBoolAsStr'
580 {$responsiveDataAttrs}
581 >
582 <tbody>
583 {$rowsHtml}
584 </tbody>
585 </table>
586 </div>
587 {$captionHtml}
588 </figure>";
589
590 return $html;
591 }
592
593 /**
594 * @param bool $isWideWidth
595 * @param bool $isFullWidth
596 * @param bool $canApplyCustomWidth
597 * @param string $alignment
598 * @return string
599 */
600 private function getWrapperAlignmentClass(
601 $isWideWidth,
602 $isFullWidth,
603 $canApplyCustomWidth,
604 $alignment
605 ) {
606 if ($isWideWidth) {
607 return 'alignwide';
608 }
609
610 if ($isFullWidth) {
611 return 'alignfull';
612 }
613
614 if (!$canApplyCustomWidth) {
615 return '';
616 }
617
618 if ($alignment === 'left' || $alignment === 'right') {
619 return 'justify-table-' . $alignment;
620 }
621
622 if ($alignment === 'center') {
623 return 'justify-table-center';
624 }
625
626 return '';
627 }
628
629 /**
630 * @param TableAttrs $attrs
631 * @param int $rows
632 * @param int $cols
633 * @return string
634 */
635 private function buildResponsiveDataAttrs($attrs, $rows, $cols) {
636 $tablet = $attrs->table->responsive->tablet;
637 $mobile = $attrs->table->responsive->mobile;
638
639 $tabletEnabled = $tablet->enabled->value();
640 $mobileEnabled = $mobile->enabled->value();
641
642 if (!$tabletEnabled && !$mobileEnabled) {
643 return '';
644 }
645
646 $tabletMaxWidth = max(1, (int) $tablet->maxWidth->value());
647 $mobileMaxWidth = max(1, (int) $mobile->maxWidth->value());
648 $tabletStackCount = max(1, (int) $tablet->stackCount->value());
649 $mobileStackCount = max(1, (int) $mobile->stackCount->value());
650
651 // Repeating the first column in every stack row is a pro option. Only
652 // the licensed pro plugin turns this filter on, so the saved value is
653 // ignored without a valid licence.
654 $proActive = (bool) apply_filters('tableberg/is_pro_runtime_active', false);
655 $tabletRepeatFirstCol = $proActive ? $tablet->repeatFirstCol->asAttr() : '';
656 $mobileRepeatFirstCol = $proActive ? $mobile->repeatFirstCol->asAttr() : '';
657
658 return "
659 data-tableberg-responsive='true'
660 data-tableberg-rows='{$rows}'
661 data-tableberg-cols='{$cols}'
662 data-tableberg-tablet-enabled='{$tablet->enabled->asAttr()}'
663 data-tableberg-tablet-width='{$tabletMaxWidth}'
664 data-tableberg-tablet-mode='{$tablet->mode->asAttr()}'
665 data-tableberg-tablet-transpose='{$tablet->transpose->asAttr()}'
666 data-tableberg-tablet-count='{$tabletStackCount}'
667 data-tableberg-tablet-repeat-first-col='{$tabletRepeatFirstCol}'
668 data-tableberg-mobile-enabled='{$mobile->enabled->asAttr()}'
669 data-tableberg-mobile-width='{$mobileMaxWidth}'
670 data-tableberg-mobile-mode='{$mobile->mode->asAttr()}'
671 data-tableberg-mobile-transpose='{$mobile->transpose->asAttr()}'
672 data-tableberg-mobile-count='{$mobileStackCount}'
673 data-tableberg-mobile-repeat-first-col='{$mobileRepeatFirstCol}'
674 ";
675 }
676
677 private function getCell($row, $col, $cells) {
678 $key = $row . ',' . $col;
679
680 if (!is_array($cells) || !array_key_exists($key, $cells)) {
681 return null;
682 }
683
684 return $cells[$key];
685 }
686
687 private function getCellElements($cell) {
688 if ($this->isCellEmpty($cell)) {
689 return [];
690 }
691
692 return $cell instanceof CellData && is_array($cell->elements)
693 ? $cell->elements
694 : [];
695 }
696
697 /**
698 * "Empty cell" is a pro feature: the cell renders as if it were a bare,
699 * unstyled table cell — no content, no background, no border. Free's
700 * default is to always show the cell as configured.
701 *
702 * @param mixed $cell
703 * @return bool
704 */
705 private function isCellEmpty($cell) {
706 if (!$cell instanceof CellData) {
707 return false;
708 }
709
710 return (bool) apply_filters('tableberg/cell_is_empty', false, $cell->attrs);
711 }
712
713 private function getCellStyleOverride($cell) {
714 if (!$cell instanceof CellData || !is_array($cell->styles)) {
715 return null;
716 }
717
718 return StringAttr::fromNestedArray($cell->styles);
719 }
720
721 private function getCellRibbon($cell) {
722 if (!$cell instanceof CellData || !is_array($cell->ribbon)) {
723 return null;
724 }
725
726 return $cell->ribbon;
727 }
728
729 /**
730 * @param CellData|null $cell
731 * @return string|null
732 */
733 private function getCellClassName($cell) {
734 if (!$cell instanceof CellData || !$cell->className instanceof StringAttr) {
735 return null;
736 }
737
738 $className = $cell->className->asAttr();
739
740 return $className === '' ? null : $className;
741 }
742
743 private function getCellSpan($cell) {
744 $span = ['rowSpan' => 1, 'colSpan' => 1];
745
746 if ($cell instanceof CellData && $cell->span instanceof Span) {
747 $span['rowSpan'] = $cell->span->rowSpan->value();
748 $span['colSpan'] = $cell->span->colSpan->value();
749 }
750
751 return $span;
752 }
753
754 /**
755 * @param int $column
756 * @param array<int|string, ColumnConfig> $columns
757 * @return string|null
758 */
759 private function getColumnWidth($column, $columns) {
760 if (!is_array($columns) || !array_key_exists($column, $columns)) {
761 return null;
762 }
763
764 $columnConfig = $columns[$column];
765 if (!$columnConfig instanceof ColumnConfig || $columnConfig->width === null) {
766 return null;
767 }
768
769 $width = $columnConfig->width->asAttr();
770 return $width === '' ? null : $width;
771 }
772
773 /**
774 * @param int $row
775 * @param array<int|string, RowConfig> $rowConfigs
776 * @return string|null
777 */
778 private function getRowHeight($row, $rowConfigs) {
779 if (!is_array($rowConfigs) || !array_key_exists($row, $rowConfigs)) {
780 return null;
781 }
782
783 $rowConfig = $rowConfigs[$row];
784 if (!$rowConfig instanceof RowConfig || $rowConfig->height === null) {
785 return null;
786 }
787
788 $height = $rowConfig->height->asAttr();
789 return $height === '' ? null : $height;
790 }
791
792 /**
793 * Pro styling of a row (e.g. background colour). One filter carries
794 * every pro row style, mirroring `tableberg/cell_styles`, so a new pro
795 * style needs no change here.
796 *
797 * @param int $row
798 * @param array<int|string, RowConfig> $rowConfigs
799 * @return array<string, string>
800 */
801 private function getRowStyles($row, $rowConfigs) {
802 $rowAttrs = (
803 is_array($rowConfigs) &&
804 array_key_exists($row, $rowConfigs) &&
805 $rowConfigs[$row] instanceof RowConfig
806 ) ? $rowConfigs[$row]->attrs : [];
807
808 $styles = apply_filters('tableberg/row_styles', [], $rowAttrs);
809
810 return is_array($styles) ? $styles : [];
811 }
812 }
813