cells -> elements). Adapt it to the v3 attrs shape so the // renderer below stays format-agnostic. $version = is_array($attributes) && isset($attributes['version']) && is_numeric($attributes['version']) ? (int) $attributes['version'] : 0; if ( $version >= 4 && is_object($block) && isset($block->parsed_block['innerBlocks']) && is_array($block->parsed_block['innerBlocks']) ) { $attributes = InnerBlocksAttrsAdapter::to_attrs( $attributes, $block->parsed_block['innerBlocks'] ); } $attrs = TableAttrs::from_array($attributes); $rows = $attrs->table->rows->value(); $cols = $attrs->table->cols->value(); $headerEnabled = $attrs->table->headerEnabled->value(); $footerEnabled = $attrs->table->footerEnabled->value(); // Sticky header/first column are pro features: free never enables // them on its own, and pro reads the raw table attrs to decide. $stickySettings = apply_filters( 'tableberg/table_sticky_settings', ['stickyHeader' => false, 'stickyFirstCol' => false], $attrs->attrs ); $stickyHeader = !empty($stickySettings['stickyHeader']); $stickyFirstCol = !empty($stickySettings['stickyFirstCol']); // Row-only/column-only is Pro-owned. Free's empty default preserves // the attribute without enabling its editor or frontend behavior. $innerBorderType = apply_filters( 'tableberg/inner_border_type', '', $attrs->attrs ); if (!in_array($innerBorderType, ['row', 'col'], true)) { $innerBorderType = ''; } $caption = $attrs->table->caption; $tableWidth = trim($attrs->table->tableWidth->asAttr()); $tableAlignment = $attrs->table->tableAlignment->asAttr(); $cellSpacingHorizontal = trim($attrs->table->cellSpacing->horizontal->asAttr()); $cellSpacingVertical = trim($attrs->table->cellSpacing->vertical->asAttr()); $tableBorderTop = trim($attrs->table->tableBorder->top->asAttr()); $tableBorderRight = trim($attrs->table->tableBorder->right->asAttr()); $tableBorderBottom = trim($attrs->table->tableBorder->bottom->asAttr()); $tableBorderLeft = trim($attrs->table->tableBorder->left->asAttr()); $fixedColumnWidths = $attrs->table->fixedColumnWidths->value(); $tableRadius = [ 'topLeft' => $attrs->cellDefaults->styles->borderRadius->topLeft->asAttr(), 'topRight' => $attrs->cellDefaults->styles->borderRadius->topRight->asAttr(), 'bottomRight' => $attrs->cellDefaults->styles->borderRadius->bottomRight->asAttr(), 'bottomLeft' => $attrs->cellDefaults->styles->borderRadius->bottomLeft->asAttr(), ]; $isZeroRadius = function ($value) { $trimmed = trim((string) $value); return $trimmed === '' || preg_match('/^0(?:\.0+)?[a-z%]*$/i', $trimmed) === 1; }; $hasRoundedCorners = count(array_filter( $tableRadius, function ($value) use ($isZeroRadius) { return !$isZeroRadius($value); } )) > 0; $isHorizontalSpacingZero = $cellSpacingHorizontal === '0'; $isVerticalSpacingZero = $cellSpacingVertical === '0'; $hasCellSpacing = !$isHorizontalSpacingZero || !$isVerticalSpacingZero; $isWideWidth = $tableWidth === 'wide'; $isFullWidth = $tableWidth === 'full'; $canApplyCustomWidth = $tableWidth !== '' && !$isWideWidth && !$isFullWidth && $tableWidth !== 'auto'; if (!$canApplyCustomWidth) { $tableWidth = ''; } $wrapperAlignmentClass = $this->getWrapperAlignmentClass( $isWideWidth, $isFullWidth, $canApplyCustomWidth, $tableAlignment ); $tableStyles = [ 'border-collapse: ' . ($hasCellSpacing ? 'separate' : 'collapse'), // Neutralize theme-level table borders. Tableberg owns its // borders through the table/cell controls and rounded wrapper. 'border: 0', ]; if ($hasCellSpacing) { $tableStyles[] = "border-spacing: {$cellSpacingHorizontal} {$cellSpacingVertical}"; } if ($tableWidth !== '') { $tableStyles[] = "width: {$tableWidth}"; $tableStyles[] = "max-width: {$tableWidth}"; } else { $tableStyles[] = 'width: 100%'; } if (!$hasRoundedCorners && $tableBorderTop !== '') { $tableStyles[] = "border-top: {$tableBorderTop}"; } if (!$hasRoundedCorners && $tableBorderRight !== '') { $tableStyles[] = "border-right: {$tableBorderRight}"; } if (!$hasRoundedCorners && $tableBorderBottom !== '') { $tableStyles[] = "border-bottom: {$tableBorderBottom}"; } if (!$hasRoundedCorners && $tableBorderLeft !== '') { $tableStyles[] = "border-left: {$tableBorderLeft}"; } $tableStyleAttr = "style='" . implode('; ', $tableStyles) . ";'"; $tableClasses = ['wp-block-tableberg']; if ($this->has_visible_border($tableBorderTop)) { $tableClasses[] = 'tableberg-has-table-border-top'; } if ($this->has_visible_border($tableBorderRight)) { $tableClasses[] = 'tableberg-has-table-border-right'; } if ($this->has_visible_border($tableBorderBottom)) { $tableClasses[] = 'tableberg-has-table-border-bottom'; } if ($this->has_visible_border($tableBorderLeft)) { $tableClasses[] = 'tableberg-has-table-border-left'; } if ($hasCellSpacing) { $tableClasses[] = 'tableberg-has-cell-spacing'; if ($isHorizontalSpacingZero) { $tableClasses[] = 'tableberg-cell-spacing-horizontal-zero'; } if ($isVerticalSpacingZero) { $tableClasses[] = 'tableberg-cell-spacing-vertical-zero'; } } $tableClassAttr = implode(' ', $tableClasses); // Column sorting is a pro feature: free never marks a column // sortable on its own, and pro reads the table's raw columns to // decide which ones are. $sortableColumns = apply_filters( 'tableberg/sortable_columns', [], $attrs->attrs ); if (!is_array($sortableColumns)) { $sortableColumns = []; } $paginationPageSize = (int) $attrs->table->pagination->pageSize->value(); if ($paginationPageSize < 1) { $paginationPageSize = 1; } $paginationConfig = [ 'enabled' => apply_filters('tableberg/pagination_enabled', false, $attrs->attrs), 'pageSize' => $paginationPageSize, 'showPageNumbers' => $attrs->table->pagination->showPageNumbers->value(), 'showPrevNext' => $attrs->table->pagination->showPrevNext->value(), ]; $searchSettings = apply_filters( 'tableberg/table_search_settings', [ 'enabled' => false, 'placeholder' => '', 'position' => 'left', 'highlightColor' => '', ], $attrs->attrs ); $searchEnabledAsStr = !empty($searchSettings['enabled']) ? 'true' : 'false'; $searchPlaceholder = (string) ($searchSettings['placeholder'] ?? ''); $searchPosition = (string) ($searchSettings['position'] ?? 'left'); $searchHighlightColor = (string) ($searchSettings['highlightColor'] ?? ''); $responsiveDataAttrs = $this->buildResponsiveDataAttrs($attrs, $rows, $cols); // Horizontal cell-element layout (and the wrap toggle that only // matters with it) is a pro feature; free's default is always the // vertical stack. Grouped in one filter since they're one decision. $cellLayout = apply_filters( 'tableberg/cell_default_layout', ['orientation' => 'vertical', 'wrap' => 'nowrap'], $attrs->attrs ); $globalCellStyles = [ 'padding' => [ 'top' => $attrs->cellDefaults->styles->padding->top->asAttr(), 'right' => $attrs->cellDefaults->styles->padding->right->asAttr(), 'bottom' => $attrs->cellDefaults->styles->padding->bottom->asAttr(), 'left' => $attrs->cellDefaults->styles->padding->left->asAttr(), ], 'orientation' => $cellLayout['orientation'] === 'horizontal' ? 'horizontal' : 'vertical', 'elementGap' => $attrs->cellDefaults->styles->elementGap->asAttr(), 'wrap' => $cellLayout['wrap'] === 'wrap' ? 'wrap' : 'nowrap', 'verticalAlign' => $attrs->cellDefaults->styles->verticalAlign->asAttr(), 'backgroundColor' => $attrs->cellDefaults->styles->backgroundColor->asAttr(), 'border' => [ 'top' => $attrs->cellDefaults->styles->border->top->asAttr(), 'right' => $attrs->cellDefaults->styles->border->right->asAttr(), 'bottom' => $attrs->cellDefaults->styles->border->bottom->asAttr(), 'left' => $attrs->cellDefaults->styles->border->left->asAttr(), ], 'borderRadius' => [ 'topLeft' => $attrs->cellDefaults->styles->borderRadius->topLeft->asAttr(), 'topRight' => $attrs->cellDefaults->styles->borderRadius->topRight->asAttr(), 'bottomRight' => $attrs->cellDefaults->styles->borderRadius->bottomRight->asAttr(), 'bottomLeft' => $attrs->cellDefaults->styles->borderRadius->bottomLeft->asAttr(), ], ]; // A collapsed table cannot reliably round its own explicit border. // The wrapper owns only the table border and clips the cell grid to // it. Cell borders stay on cells; copying them to the wrapper would // add an outline that the user did not configure as a table border. $tableOuterBorder = [ 'top' => $tableBorderTop, 'right' => $tableBorderRight, 'bottom' => $tableBorderBottom, 'left' => $tableBorderLeft, ]; $globalCellStyles['borderRadius'] = [ 'topLeft' => '', 'topRight' => '', 'bottomRight' => '', 'bottomLeft' => '', ]; $cellRenderer = new CellRenderer(); $hiddenBySpan = []; $rowsHtml = ''; for ($row = 0; $row < $rows; $row++) { $cellsHtml = ''; $rowHeight = $this->getRowHeight($row, $attrs->rows); $rowStyles = $this->getRowStyles($row, $attrs->rows); $rowBackgroundColor = isset($rowStyles['backgroundColor']) && is_string($rowStyles['backgroundColor']) ? $rowStyles['backgroundColor'] : ''; $cellStylesForRow = $globalCellStyles; // Header/footer/even/odd are table-wide defaults, resolved per // row here the same way `cell/index.tsx`'s // `useRowBackgroundStyleKey` does in the editor — header/footer // don't consume a slot in the even/odd count. $isHeaderRow = $headerEnabled && $row === 0; $isFooterRow = $footerEnabled && $rows > 0 && $row === $rows - 1; if ($isHeaderRow) { $rowPositionBackground = $attrs->cellDefaults->styles->headerBackgroundColor->asAttr(); } elseif ($isFooterRow) { $rowPositionBackground = $attrs->cellDefaults->styles->footerBackgroundColor->asAttr(); } else { $dataRowPosition = $headerEnabled ? $row - 1 : $row; $rowPositionBackground = ($dataRowPosition % 2 === 0) ? $attrs->cellDefaults->styles->oddRowBackgroundColor->asAttr() : $attrs->cellDefaults->styles->evenRowBackgroundColor->asAttr(); } if ($rowPositionBackground !== '') { $cellStylesForRow['backgroundColor'] = $rowPositionBackground; } // A row with its own background would otherwise never show // through: every cell paints its own background over the // ``'s, and cells fall back to the table-wide default // whenever they carry no colour of their own. So for this row's // cells, that fallback is cleared — a cell/column colour on top // of it (applied below by the pro filter) still wins either way. if ($rowBackgroundColor !== '') { $cellStylesForRow['backgroundColor'] = ''; } for ($col = 0; $col < $cols; $col++) { if (isset($hiddenBySpan[$row][$col])) { continue; } $cell = $this->getCell($row, $col, $attrs->cells); $span = $this->getCellSpan($cell); $rowSpan = is_numeric($span['rowSpan']) ? (int) $span['rowSpan'] : 1; $colSpan = is_numeric($span['colSpan']) ? (int) $span['colSpan'] : 1; if ($rowSpan > 1 || $colSpan > 1) { for ($rowOffset = 0; $rowOffset < $rowSpan; $rowOffset++) { for ($colOffset = 0; $colOffset < $colSpan; $colOffset++) { if ($rowOffset === 0 && $colOffset === 0) { continue; } $hiddenRow = $row + $rowOffset; $hiddenCol = $col + $colOffset; $hiddenBySpan[$hiddenRow][$hiddenCol] = true; } } } $isHeaderRowCell = $headerEnabled && $row === 0; $isFooterCell = $footerEnabled && $rows > 0 && $row === $rows - 1; $tag = ($isHeaderRowCell || $isFooterCell) ? 'th' : 'td'; $sortableType = ( $isHeaderRowCell && array_key_exists($col, $sortableColumns) ) ? $sortableColumns[$col] : null; $columnWidth = null; if ($colSpan === 1) { if ($fixedColumnWidths && $cols > 0) { $columnWidth = (string) (100 / $cols) . '%'; } else { $columnWidth = $this->getColumnWidth( $col, $attrs->columns ); } } $cellsHtml .= $cellRenderer->render( CellRenderContext::create( $row, $col, $rowSpan, $colSpan, $tag, $cellStylesForRow, $this->getCellElements($cell), $this->getCellStyleOverride($cell), $this->getCellRibbon($cell), $sortableType, $columnWidth, $rowHeight, $stickyHeader, $stickyFirstCol, $this->getCellClassName($cell), $cell instanceof CellData ? $cell->attrs : [], $this->isCellEmpty($cell), $innerBorderType, $rows, $cols ) ); } $rowStyleParts = []; if ($rowBackgroundColor !== '') { $rowStyleParts[] = 'background-color:' . esc_attr($rowBackgroundColor); } $rowBorder = isset($rowStyles['border']) && is_array($rowStyles['border']) ? $rowStyles['border'] : []; foreach (['top', 'right', 'bottom', 'left'] as $side) { $value = isset($rowBorder[$side]) && is_string($rowBorder[$side]) ? $rowBorder[$side] : ''; if ($value !== '') { $rowStyleParts[] = "border-{$side}:" . esc_attr($value); } } $rowStyleAttr = !empty($rowStyleParts) ? ' style="' . implode(';', $rowStyleParts) . '"' : ''; $rowsHtml .= "$cellsHtml"; } $sortingBoolAsStr = !empty($sortableColumns) ? 'true' : 'false'; $headerBoolAsStr = $headerEnabled ? 'true' : 'false'; $footerBoolAsStr = $footerEnabled ? 'true' : 'false'; $columnsData = []; foreach ($sortableColumns as $column => $sortType) { $columnsData[$column] = [ 'sortable' => $sortType, ]; } $columnsJson = wp_json_encode($columnsData); if (!is_string($columnsJson)) { $columnsJson = '{}'; } $columnsJson = esc_attr($columnsJson); $paginationJson = wp_json_encode($paginationConfig); if (!is_string($paginationJson)) { $paginationJson = '{}'; } $paginationJson = esc_attr($paginationJson); $searchPlaceholderAttr = esc_attr($searchPlaceholder); $searchPositionAttr = esc_attr($searchPosition); $searchHighlightColorAttr = esc_attr($searchHighlightColor); $wrapperClass = trim("tableberg-table-wrapper {$wrapperAlignmentClass}"); // Round the whole table's outer corners on the wrapper. // Zero radii are skipped so a table without any actual rounding does // not carry pointless border-radius declarations. $wrapperStyles = []; if (!$isZeroRadius($tableRadius['topLeft'])) { $wrapperStyles[] = 'border-top-left-radius:' . $tableRadius['topLeft']; } if (!$isZeroRadius($tableRadius['topRight'])) { $wrapperStyles[] = 'border-top-right-radius:' . $tableRadius['topRight']; } if (!$isZeroRadius($tableRadius['bottomRight'])) { $wrapperStyles[] = 'border-bottom-right-radius:' . $tableRadius['bottomRight']; } if (!$isZeroRadius($tableRadius['bottomLeft'])) { $wrapperStyles[] = 'border-bottom-left-radius:' . $tableRadius['bottomLeft']; } if (!empty($wrapperStyles)) { if ($tableOuterBorder['top'] !== '') { $wrapperStyles[] = 'border-top:' . $tableOuterBorder['top']; } if ($tableOuterBorder['right'] !== '') { $wrapperStyles[] = 'border-right:' . $tableOuterBorder['right']; } if ($tableOuterBorder['bottom'] !== '') { $wrapperStyles[] = 'border-bottom:' . $tableOuterBorder['bottom']; } if ($tableOuterBorder['left'] !== '') { $wrapperStyles[] = 'border-left:' . $tableOuterBorder['left']; } $wrapperStyles[] = 'box-sizing:border-box'; } // A table wider than its wrapper must scroll inside the wrapper // instead of pushing the whole page sideways, so the wrapper is a // scroll container by default. // // The one exception is a header that sticks to the page: a scroll // container of its own would capture it and it would never stick. // (CSS cannot do both — with overflow-x set, overflow-y can no longer // be visible.) A sticky first column, on the other hand, only works // *because* of the scroll container, so when both are enabled the // scrolling wins and the header sticks within the wrapper. // // A rounded wrapper always needs the clip, so it opts in regardless. $pageStickyHeader = $stickyHeader && !$stickyFirstCol; if ($hasRoundedCorners || !$pageStickyHeader) { // `auto`, not `hidden`: both clip to the rounded corners, but // `hidden` would also swallow a table wider than the wrapper. // Inline, so it also wins over the `.tableberg-scroll-x` rule. $wrapperStyles[] = 'overflow:auto'; } $wrapperStyleAttr = !empty($wrapperStyles) ? "style='" . implode(';', $wrapperStyles) . "'" : ''; $figureAlignmentClass = ''; if ($isWideWidth) { $figureAlignmentClass = 'alignwide'; } elseif ($isFullWidth) { $figureAlignmentClass = 'alignfull'; } $figureClass = trim( implode(' ', array_filter([ 'wp-block-tableberg', $attrs->table->className->asAttr(), $figureAlignmentClass, ])) ); $figureStyles = []; $spacingSides = [ 'margin-top' => $attrs->table->margin->top, 'margin-right' => $attrs->table->margin->right, 'margin-bottom' => $attrs->table->margin->bottom, 'margin-left' => $attrs->table->margin->left, 'padding-top' => $attrs->table->padding->top, 'padding-right' => $attrs->table->padding->right, 'padding-bottom' => $attrs->table->padding->bottom, 'padding-left' => $attrs->table->padding->left, ]; foreach ($spacingSides as $prop => $sideAttr) { if ($sideAttr->isNotEmpty()) { $figureStyles[] = $prop . ': ' . $sideAttr->asAttr(); } } $figureStyleAttr = !empty($figureStyles) ? "style='" . implode('; ', $figureStyles) . ";'" : ''; $captionHtml = ''; if ($caption->isNotEmpty()) { $captionHtml = "
{$caption->asHtml()}
"; } $html = "
{$rowsHtml}
{$captionHtml}
"; return $html; } /** * @param bool $isWideWidth * @param bool $isFullWidth * @param bool $canApplyCustomWidth * @param string $alignment * @return string */ private function getWrapperAlignmentClass( $isWideWidth, $isFullWidth, $canApplyCustomWidth, $alignment ) { if ($isWideWidth) { return 'alignwide'; } if ($isFullWidth) { return 'alignfull'; } if (!$canApplyCustomWidth) { return ''; } if ($alignment === 'left' || $alignment === 'right') { return 'justify-table-' . $alignment; } if ($alignment === 'center') { return 'justify-table-center'; } return ''; } /** * @param TableAttrs $attrs * @param int $rows * @param int $cols * @return string */ private function buildResponsiveDataAttrs($attrs, $rows, $cols) { $tablet = $attrs->table->responsive->tablet; $mobile = $attrs->table->responsive->mobile; $tabletEnabled = $tablet->enabled->value(); $mobileEnabled = $mobile->enabled->value(); if (!$tabletEnabled && !$mobileEnabled) { return ''; } $tabletMaxWidth = max(1, (int) $tablet->maxWidth->value()); $mobileMaxWidth = max(1, (int) $mobile->maxWidth->value()); $tabletStackCount = max(1, (int) $tablet->stackCount->value()); $mobileStackCount = max(1, (int) $mobile->stackCount->value()); // Repeating the first column in every stack row is a pro option. Only // the licensed pro plugin turns this filter on, so the saved value is // ignored without a valid licence. $proActive = (bool) apply_filters('tableberg/is_pro_runtime_active', false); $tabletRepeatFirstCol = $proActive ? $tablet->repeatFirstCol->asAttr() : ''; $mobileRepeatFirstCol = $proActive ? $mobile->repeatFirstCol->asAttr() : ''; return " data-tableberg-responsive='true' data-tableberg-rows='{$rows}' data-tableberg-cols='{$cols}' data-tableberg-tablet-enabled='{$tablet->enabled->asAttr()}' data-tableberg-tablet-width='{$tabletMaxWidth}' data-tableberg-tablet-mode='{$tablet->mode->asAttr()}' data-tableberg-tablet-transpose='{$tablet->transpose->asAttr()}' data-tableberg-tablet-count='{$tabletStackCount}' data-tableberg-tablet-repeat-first-col='{$tabletRepeatFirstCol}' data-tableberg-mobile-enabled='{$mobile->enabled->asAttr()}' data-tableberg-mobile-width='{$mobileMaxWidth}' data-tableberg-mobile-mode='{$mobile->mode->asAttr()}' data-tableberg-mobile-transpose='{$mobile->transpose->asAttr()}' data-tableberg-mobile-count='{$mobileStackCount}' data-tableberg-mobile-repeat-first-col='{$mobileRepeatFirstCol}' "; } private function getCell($row, $col, $cells) { $key = $row . ',' . $col; if (!is_array($cells) || !array_key_exists($key, $cells)) { return null; } return $cells[$key]; } private function getCellElements($cell) { if ($this->isCellEmpty($cell)) { return []; } return $cell instanceof CellData && is_array($cell->elements) ? $cell->elements : []; } /** * "Empty cell" is a pro feature: the cell renders as if it were a bare, * unstyled table cell — no content, no background, no border. Free's * default is to always show the cell as configured. * * @param mixed $cell * @return bool */ private function isCellEmpty($cell) { if (!$cell instanceof CellData) { return false; } return (bool) apply_filters('tableberg/cell_is_empty', false, $cell->attrs); } private function getCellStyleOverride($cell) { if (!$cell instanceof CellData || !is_array($cell->styles)) { return null; } return StringAttr::fromNestedArray($cell->styles); } private function getCellRibbon($cell) { if (!$cell instanceof CellData || !is_array($cell->ribbon)) { return null; } return $cell->ribbon; } /** * @param CellData|null $cell * @return string|null */ private function getCellClassName($cell) { if (!$cell instanceof CellData || !$cell->className instanceof StringAttr) { return null; } $className = $cell->className->asAttr(); return $className === '' ? null : $className; } private function getCellSpan($cell) { $span = ['rowSpan' => 1, 'colSpan' => 1]; if ($cell instanceof CellData && $cell->span instanceof Span) { $span['rowSpan'] = $cell->span->rowSpan->value(); $span['colSpan'] = $cell->span->colSpan->value(); } return $span; } /** * @param int $column * @param array $columns * @return string|null */ private function getColumnWidth($column, $columns) { if (!is_array($columns) || !array_key_exists($column, $columns)) { return null; } $columnConfig = $columns[$column]; if (!$columnConfig instanceof ColumnConfig || $columnConfig->width === null) { return null; } $width = $columnConfig->width->asAttr(); return $width === '' ? null : $width; } /** * @param int $row * @param array $rowConfigs * @return string|null */ private function getRowHeight($row, $rowConfigs) { if (!is_array($rowConfigs) || !array_key_exists($row, $rowConfigs)) { return null; } $rowConfig = $rowConfigs[$row]; if (!$rowConfig instanceof RowConfig || $rowConfig->height === null) { return null; } $height = $rowConfig->height->asAttr(); return $height === '' ? null : $height; } /** * Pro styling of a row (e.g. background colour). One filter carries * every pro row style, mirroring `tableberg/cell_styles`, so a new pro * style needs no change here. * * @param int $row * @param array $rowConfigs * @return array */ private function getRowStyles($row, $rowConfigs) { $rowAttrs = ( is_array($rowConfigs) && array_key_exists($row, $rowConfigs) && $rowConfigs[$row] instanceof RowConfig ) ? $rowConfigs[$row]->attrs : []; $styles = apply_filters('tableberg/row_styles', [], $rowAttrs); return is_array($styles) ? $styles : []; } }