Responsive Table Blocks in WordPress

Table HTML elements often look great on large screens where the viewport is wide enough to fit everything. However, you may find that all goes out the window (literally) when you view the table on a mobile phone and realize that the table width exceeds the width of the viewport:

Screenshot of a table on a small viewport and the table is cut off
Screenshot of an HTML table overflowing horizontally on a mobile size screen.

We can fix this with JavaScript by selecting the table, iterating through each th and td element, and using the data from each cell to create new vertically oriented tables that are more legible on smaller screens.

Then use CSS so that in large viewports the original table is visible but the newly generated tables are hidden. Then vice-versa in small viewports so that the original table is hidden and the newly generated tables are visible. We can achieve this with @media queries as well as display: none; and display: block;

Screenshot of a table on a large viewport
Screenshot of an HTML table element on a large size screen. Imagine this trying to fit on your mobile phone. Yikes!
Screenshot of the same table but now split into two separate tables that are stacked on top of each other, making them easily read in a small viewport.

WordPress Plugin

In this particular scenario, I need this to work with tables that a client will create via WordPress’ Gutenberg block editor. I don’t want to affect all tables, only the tables that the client decides will need it. This means we need to assign a specific class to the desired tables so that the script knows which tables to select and which to ignore.

This is simple enough for a developer, as you can use the ‘Additional CSS Class(es)’ field when editing a block. However, expecting a client to manually add a class name to a table (and remember exactly what that class name is) seems unreasonable.

The solution is to give the client the ability to enable/disable the class within each table block using a custom UI input within the block editor.

There are plug-ins that will do this and much more but I want something lean. Something simple that won’t burden the client with bloat or potential vulnerabilities. This custom plugin adds new Toggle Switches in the Settings Panel when editing a core table block. When toggled ‘on’, the class is applied to the “Additional CSS Class(es)” input field without having to be manually typed in.

Then we can use custom CSS and JS within the theme files to determine what these custom classes do, such as make the table responsive as I described in the beginning of this article.

Screenshot depicting the custom WordPress plugin which adds a new ‘Table Settings’ panel to the table block. When toggled ‘on’, it adds the ‘wp-block-table–a’ class within the table block. When toggled ‘off’, it removes the class.

You can find the necessary WordPress plugin files on GitHub or copy/paste the code below into a new directory within your ‘wp-content/plugins/hypercoda-custom-table-block’ directory.

This plugin simply adds the ability to toggle classes on/off within each core table block. It does not make your tables responsive, that is done separately and can be found in the ‘HTML / CSS / JavaScript’ section toward the end of this article.

custom-table-block.php

<?php
/*
Plugin Name: Custom Table Block
Description: Add custom settings to the Table block.
Version: 1.0.2
Author: HyperCoda
*/

function custom_table_block_register() {
    register_block_type('core/table', array(
        'attributes' => array(
            'mobileClassA' => array(
                'type' => 'boolean',
                'default' => false,
            ),
            /*
            'mobileClassB' => array(
                'type' => 'boolean',
                'default' => false,
            ),
            */
        ),
        'render_callback' => 'custom_table_block_render',
    ));
}

function custom_table_block_render($attributes, $content) {
    $classA = $attributes['mobileClassA'] ? 'wp-block-table--a' : '';
    // $classB = $attributes['mobileClassB'] ? 'wp-block-table--b' : '';

    // Uncomment this and comment the other below it if using multiple classes:
    // $combinedClasses = implode(' ', array_filter([$classA, $classB]));
    $combinedClasses = implode(' ', array_filter([$classA]));

    return sprintf('<figure class="%s">%s</figure>', esc_attr($combinedClasses), esc_html($content));
}

function custom_table_block_enqueue_scripts($hook) {
    if ($hook === 'post.php' || $hook === 'post-new.php') {
        global $post;

        if ($post && has_blocks($post->post_content)) {
            $blocks = parse_blocks($post->post_content);

            foreach ($blocks as $block) {
                if ($block['blockName'] === 'core/table') {
                    wp_enqueue_script(
                        'custom-table-block-script',
                        plugin_dir_url(__FILE__) . 'custom-table-block.js',
                        array('wp-blocks', 'wp-element', 'wp-components', 'wp-editor'),
                        null,
                        true
                    );
                    break;
                }
            }
        }
    }
}

add_action('init', 'custom_table_block_register');
add_action('admin_enqueue_scripts', 'custom_table_block_enqueue_scripts');
?>

custom-table-block.js

(function () {
    var el = wp.element.createElement;
    var InspectorControls = wp.blockEditor.InspectorControls;
    var PanelBody = wp.components.PanelBody;
    var ToggleControl = wp.components.ToggleControl;

    // Define the class strings as variables
    var classA = 'wp-block-table--a';
    // var classB = 'wp-block-table--b';

    wp.hooks.addFilter(
        'editor.BlockEdit',
        'custom-table-block/add-control',
        function (BlockEdit) {
            return function (props) {
                // Check if the block is a Table block
                if (props.name !== 'core/table') {
                    return el(BlockEdit, props);
                }

                // Extract existing class names or default to an empty string
                var existingClasses = props.attributes.className || '';

                var mobileClassA = props.attributes.mobileClassA || existingClasses.includes(classA);
                // var mobileClassB = props.attributes.mobileClassB || existingClasses.includes(classB);

                var onChangeMobileClassA = function () {
                    const newMobileClassA = !mobileClassA;

                    // Toggle the class within className
                    const updatedClasses = newMobileClassA
                        ? `${existingClasses} ${classA}`
                        : existingClasses.replace(new RegExp(`\\b${classA}\\b`, 'g'), '').trim();

                    props.setAttributes({
                        mobileClassA: newMobileClassA,
                        // mobileClassB: props.attributes.mobileClassB,
                        className: updatedClasses,
                    });
                };

                /*
                var onChangeMobileClassB = function () {
                    const newMobileClassB = !mobileClassB;

                    // Toggle the class within className
                    const updatedClasses = newMobileClassB
                        ? `${existingClasses} ${classB}`
                        : existingClasses.replace(new RegExp(`\\b${classB}\\b`, 'g'), '').trim();

                    props.setAttributes({
                        mobileClassA: props.attributes.mobileClassA,
                        mobileClassB: newMobileClassB,
                        className: updatedClasses,
                    });
                };
                */

                // Move InspectorControls outside the conditional check
                return el(
                    'div',
                    null,
                    el(
                        InspectorControls,
                        null,
                        el(
                            PanelBody,
                            { title: 'Table Settings', initialOpen: true },
                            el(ToggleControl, {
                                label: 'Mobile Class A',
                                checked: mobileClassA,
                                onChange: onChangeMobileClassA,
                            }),
                            /*
                            el(ToggleControl, {
                                label: 'Mobile Class B',
                                checked: mobileClassB,
                                onChange: onChangeMobileClassB,
                            })
                            */
                        )
                    ),
                    el(BlockEdit, props)
                );
            };
        }
    );
})();

HTML / CSS / JavaScript

You can find the necessary HTML / CSS / JavaScript on CodePen or copy/paste the code below into your WordPress theme’s code.

This allows for tables with specific class names to have their data iterated and then used to create a new (mobile-friendly) table. It can certainly be used without WordPress, however this is designed to go along with the plugin described earlier in this article.

It’s also important to note that this works only with a specific kind of table. Note how the first <th> element is empty. Also note how the remaining <th> elements and the first <td> element of each row is a descriptor for the remaining <td> elements. In the future, I may modify the JavaScript code below to support other formats of tables.

HTML

<figure class="wp-block-table is-style-stripes">
  <table>
    <thead>
      <tr>
        <th></th>
        <th>Whole-Day Experiences</th>
        <th>Partial-Day Experiences</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>Duration</strong></td>
        <td>6–8 hours</td>
        <td>1–3 hours</td>
      </tr>
      <tr>
        <td><strong>Program Components</strong></td>
        <td><strong>Includes</strong>:<br>– Live science show for the entire school<br>– 40-minute programs for individual classrooms<br>– Pop-up exhibit</td>
        <td><strong>Choose one:</strong><br>– 45-minute live science show<br>– Three 45-minute consecutive hands-on workshops<br>– 3 hours with a pop-up exhibit</td>
      </tr>
      <tr>
        <td><strong>Participants Served</strong></td>
        <td>6–15 classrooms or groups<br>(100–480 participants per day)</td>
        <td>– Science Show: Up to 400 participants<br>– Workshops: 1–3 classrooms or groups with up to 32 participants each&nbsp;<br>– Exhibit Exploration: Up to 300 participants</td>
      </tr>
      <tr>
        <td><strong>Price*</strong></td>
        <td>$2,375–$3,450</td>
        <td>$750–$1,500</td>
      </tr>
      <tr>
        <td><strong>Location</strong></td>
        <td>– Local and Western Washington year round<br>– Eastern Washington seasonally<br>(September–early November and March–June)</td>
        <td>Local only** year round</td>
      </tr>
    </tbody>
  </table>

  <figcaption class="wp-element-caption">*Funding available for qualifying schools, libraries, and community groups.<br>**Some partial-day programming is available regionally with additional travel fees. Inquire for more info.</figcaption>
</figure>

CSS

// ***********************************
//  Responsive WordPress Tables
// ***********************************
// Hide newly generated mobile tables on large viewports
// but then show them on smaller viewports
.wp-block-table__mobile {
  display: none;

  @media (max-width: 768px) {
    display: table;
  }
}

// Hide original table on small viewports
figure.wp-block-table {
  table:not([class]) {
    @media (max-width: 768px) {
      display: none;
    }
  }
}

// Important styling for newly generated mobile tables
.wp-block-table__mobile {
  tbody {
    tr {
      display: flex;
      flex-direction: column;
      gap: 0.5em;
      padding: 0.5em;

      td {
        display: block;
        padding: 0;
      }
    }
  }
}

JavaScript

const responsiveTable = () => {
  // Select all tables with the class '.wp-block-table'
  const tables = document.querySelectorAll('.wp-block-table table');

  // Iterate through each selected table
  tables.forEach((table) => {
    // Get the header row of the table
    const thead = table.querySelector('thead tr');
    const theadLength = thead.children.length;

    // Check if the first <th> element is empty
    if (thead.children[0].innerHTML.trim() !== '') {
      // If not empty, return and do nothing
      return;
    }

    // Iterate through each <th> element (excluding the first one)
    for (let i = 1; i < theadLength; i++) {
      // Create a new table for each <th> element
      const newTable = document.createElement('table');
      // Add classes to the new table for styling purposes
      newTable.classList.add('wp-block-table__mobile', `wp-block-table__mobile--${i}`);

      // Create a new <thead> and <tr> structure within each new table
      const newThead = document.createElement('thead');
      const newTr = document.createElement('tr');
      // Create a new <th> and append the content of the original <th> (excluding the first one)
      const newTh = document.createElement('th');
      newTh.innerHTML = thead.children[i].innerHTML;
      newTr.appendChild(newTh);
      newThead.appendChild(newTr);
      newTable.appendChild(newThead);

      // Create a new <tbody> for the remaining rows
      const newTbody = document.createElement('tbody');

      // Iterate through each <tbody><tr> element
      const tbodyRows = table.querySelectorAll('tbody tr');
      tbodyRows.forEach((tbodyRow) => {
        // Create a new <tr> structure within each new tbody
        const newTrBody = document.createElement('tr');

        // Create a new <td> and append the content of the first <td> in the original table
        const newTdLabel = document.createElement('td');
        newTdLabel.innerHTML = tbodyRow.children[0].innerHTML;

        // Create a new <td> and append the content of the corresponding <td> in the original table
        const newTdValue = document.createElement('td');
        newTdValue.innerHTML = tbodyRow.children[i].innerHTML;

        // Append the content of both <td> elements within the same <tr>
        newTrBody.appendChild(newTdLabel);
        newTrBody.appendChild(newTdValue);

        // Append the new <tr> to the new <tbody>
        newTbody.appendChild(newTrBody);
      });

      // Append the new tbody to the new table
      newTable.appendChild(newTbody);

      // Insert the new table before the original table in the DOM
      table.parentNode.insertBefore(newTable, table);
    }
  });
};
responsiveTable();