Gutenberg – building a more accessible table block

This is a long, single-post tutorial, so here is a table of contents to help you keep your place:

Overview

I’m writing this in June 2019. We’re a couple of releases past the big 5.0 block editor release.

The table block that ships in WP Core is noticeably lacking in basic functionality. When you add the block to the page, it simply asks you how many columns and rows you want, and then it builds a table with the right number of cells.

Tables that contain only a <tbody> with no designated headings are often perceived by screen readers as presentational rather than data tables. Put another way, there’s no way for people using assistive technology (or search engine spiders) to know what your data means.

Here’s what the output of the Core table block looks like:

<table class="wp-block-table">
	<tbody>
		<tr>
			<td>Food category</td>
			<td>Example</td>
		</tr>
		<tr>
			<td>Vegetable</td>
			<td>Green beans</td>
		</tr>
		<tr>
			<td>Fruit</td>
			<td>Apples</td>
		</tr>
	</tbody>
</table>

Without headings or a caption, no one – person or computer alike – knows how your data relate to each other. What’s worse, the only way you can add headings or captions is to edit the block’s HTML, and then the block no longer validates – so you have to convert it to a Classic Block, meaning everyone will always have to edit the HTML directly thereafter, defeating the purpose of the block editor.


The desired output

Not every table needs all heading types and a caption. However, every table needs at least some headings. This example contains all of the options I want from the table block:

<table class="wp-block-table">
	<caption>Food types in the cafeteria</caption>
	<thead>
		<tr>
			<th scope="col">Food category</th>
			<th scope="col">Example</th>
		</tr>
	</thead>
	<tbody>
		<tr>
			<th scope="row">Vegetable</th>
			<td>Green beans</td>
		</tr>
		<tr>
			<th scope="row">Fruit</th>
			<td>Apples</td>
		</tr>
	</tbody>
	<tfoot>
		<tr>
			<td colspan="2">Note: foods may vary. Check menu for details.</td>
		</tr>
	</tfoot>
</table>

Notice the scope=”row”, which associates the heading with the entire row; scope=”col”, which associates the heading with the entire column; the caption, which tells everyone what the table is about; and the tfoot, which provides the disclaimer in a way that associates it directly with the table. This gives us a much more accessible data table.

Gutenberg block tutorial

If you’re like me – you like your code clean and simple, no extra frameworks or boilerplates thrown in – you might be hesitant to use tools like create-guten-block, which promises no configuration needed. I tried repeatedly (and failed consistently) to set everything up on my own, and even create-guten-block took several tries to get going. WP 5.2 offered updates that are supposed to allow you to skip some of the webpack stuff, though again I tried and failed, so for now I’ve stuck with create-guten-block. I’ll keep this site updated in case I’m able to decouple from create-guten-block.

As a side note, outside of figuring out webpack and getting things to compile, frequent changes combined with a lack of documentation are the worst things about building Gutenberg blocks. It’s hard to find documentation or tutorials that are up to date with the current state of the block editor. My Core version as of this writing is 5.2.1 – as Core and the Block Editor are updated, this code too will likely need to be updated. So, you, like I, are signing up for maintaining code, possibly more often and more significantly than you’ve been used to with PHP.

All that aside, hopefully you came here to learn how to build a block, so let’s get started.

(If you just want to download this plugin and use the accessible table block, head over to the master branch on GitHub and you can either clone or download a compiled, ready-to-activate plugin to use as you wish.)


Setting up create-guten-block

A few things you need to know about:

  • Command line – you will need to run some commands from a command prompt, that little black text screen that looks like DOS. I installed Git Bash, which comes with Git CMD, and Git CMD is what I use for my command line. MacOS comes with a built-in Terminal you can use instead, and there are several IDEs that either come with command prompts, or allow plugins that add them, so find whatever works for you.
  • Node.js – now that you have your command prompt, type node -v. If Node is already installed, you will see a version number. If not, go to the Node.js website to install it.
  • NPM – next, type npm -v in your command prompt to see a version number and confirm that NPM is installed. It should have come with Node.js. NPM stands for Node Package Manager and basically lets you install things, kind of like an app store for your command line.

Finally, a word on running a local WordPress installation. Even though I set up a clean local install using XAMPP, I ended up having no end of console errors that prevented me from actually using the local install as I developed. So, I kept the local install in its folder, and just started uploading the files from local to a remote staging site. For some reason the staging site doesn’t give me all those console errors. create-guten-block expects to be run inside a “/wp-content/plugins/” folder and automatically makes you a subfolder for your specific block plugin, though it will make a subfolder of whatever folder you happen to be in, even if it’s not a local install.

Congratulations – you’ve made it past one of the hardest roadblocks. Next, follow the instructions at create-guten-block to set up a custom block, naming it “cgb-a11y-table” to follow along. (One other note: “a11y”, which is lowercase A, number 1, number 1, lowercase Y, is a popular shorthand for “accessibility.” It can be hard to tell it’s not “ally” with two L’s.)

Building a block, step 1: create a barebones block

Step 1 code on GitHub

I learn best by examples, but when I see a full-fledged example with hundreds of lines of code in a language I’m not fluent in, my eyes glaze over. So I’m hoping it will help you, too, to build this block step by step. You can always skip to the end if the step-by-step details go too slowly.

First, make sure you have NPM watching for changes by opening your command prompt, going to your “/wp-content/plugins/cgb-a11y-table” folder, and typing “npm start”. This tells NPM to start watching the files in your folder, and when you change (and save) those files, it will process and compile them for you.

Note: I don’t know if this is specific to Windows 10, but almost every time I try to run “npm start”, I get errors. So, I have to run “npm install” every time, wait for it to finish, and then run “npm start”. I hope that saves someone some frustration to at least know what to do when those errors are thrown (and I submitted a Git bug to the CGB author, who acknowledged the problem and plans to fix it).

Now that NPM is watching, open the file “/wp-content/plugins/cgb-a11y-table/src/block/block.js”. This is the JavaScript file you’ll be editing that contains the information for this one block that you’re building. Whenever you have NPM watching and you make changes to this file, webpack will compile everything into a file in “/wp-content/plugins/cgb-a11y-table/dist/block/block.js”, which is the one you actually enqueue in WP.

Let’s start out with just about the most basic block you could make:

const registerBlockType = wp.blocks.registerBlockType;
const { Dashicon } = wp.components;

registerBlockType("cgb/a11y-table", {
	title: 'A11y Table',
	icon: 'screenoptions',
	category: 'common',
	edit: props => {
		return <table><tbody><tr><td>Cell</td></tr></tbody></table>;
	},
	save: props => {
		return <table><tbody><tr><td>Cell</td></tr></tbody></table>;
	}
});

The very first line starts with const, which is short for Constant, as opposed to Variable. In PHP, we use $ to create variables. In JavaScript, we typically use var instead, and const is a little different in that it’s constant – you cannot directly assign a new value to it, so it’s always going to refer to the same thing. However, you can update its properties, so don’t think it can never change. More on that later.

The code after the first const says that whenever we call registerBlockType(), we are referring to the function called by that name inside of wp.blocks. The second line says to include WP’s built-in Dashicons, which we’ll use for the block icon at this stage, and for buttons in later steps.

Next, we call the registerBlockType() function, which requires a few arguments. The first one is the WordPress-readable name of the block, which always has to be a namespace, followed by a slash, followed by the name of our block. So, in this case our namespace is “cgb” (as in create-guten-block) and our block name is “a11y-table” (as in accessible table). Next come all the other arguments in the form of an object.

Some are fairly intuitive. In the editor, when you’re ready to add a block, you’ll see a menu of all the blocks enabled on your site – including an icon and title for each. The title should be short, descriptive text. The icon will come from the Dashicons we included earlier, but you don’t have to use Dashicons. You can create your own SVG and include that instead. The category corresponds to the same block adder, where you can search or browse through categories to find the specific block you want to add.

Next are the edit and save functions of the block. They’re really, really simple here just to get you used to the core basics of block building. The edit function runs, you guessed it, in the Editor, though it may run more often than you would expect. Here, all it does is display a table with a single uneditable cell.

You may notice both the edit function and the save function return something. This is important: after all the JavaScript processing is done, HTML output is being sent from the edit function to the Editor, and from the save function to the database.

While we’re at it, let’s remove the create-guten-block default CSS. Open up “editor.scss” and “style.scss” in the same “src” folder, select their contents, delete the contents, and save the files.

Note: if you’re uploading your plugin, just upload the files in the root folder (cgb-a11y-table), the ones in “/cgb-a11y-table/dist/”, and the ones in “/cgb-a11y-table/src/”. Skip “node_modules” – those are used in the build process, so you don’t need to upload them (or commit them to GitHub if you’re saving your progress).

Right now, if you activate this plugin and add the block to a page, you’ll see the same single-cell table in the editor as you will in the published post. Later on, we’ll make the table editable, and we’ll see that the save function will differ from the edit function because in the editor everything is, well, editable, while the save function just needs to output the uneditable HTML that will be saved to the database and ultimately appear in the front end.


Step 2: add CSS and an attribute

Step 2 code on GitHub

Let’s start fleshing out this table block. Open up style.scss, which is in the same folder as block.js.

This stylesheet is enqueued in a way that makes it work on both the back end (Editor) and the front end. This is where you’ll put styles that apply both places. One of the goals of the Block Editor was to make things look the same in the back end as they do in the front end – before, that wasn’t a focus, and theme CSS usually styled everything on the front end without touching the back end, so people used Preview a whole lot.

Paste this SASS, which webpack will compile into plain CSS for us automatically, into style.scss:

// A11y table block styles for both editor and front end
.wp-block-cgb-a11y-table {
	border-bottom:1px solid #777;
	table-layout:fixed;
	border-collapse:collapse;
	border-spacing:0;
	caption, td, th, tfoot {
		border:1px solid #777;
		border-bottom:none;
		text-align:left;
	}
	caption, tfoot {
		background:#fff;
	}
	th {
		background:#ddd;
	}
	td, th {
		height:2em;
	}
	tbody {
		border:none;
	}
	tbody tr:nth-child(even) {
		background:#eee;
	}
	tfoot td {
		font-style:italic;
		font-weight:normal;
	}
}

The other file, “editor.scss” (in the same folder), only gets applied in the Editor. We force the table to 100% width, which ensures that when we first add a table, it won’t be narrow and hard to type in just because the cells are empty. We add a bit of padding to each place a user can type, to make it a little easier to select the correct place.

We’ll come across the components-icon-button class later, when we add a toolbar to our block; this just ensures that it will use a pointer cursor. We’ll use the is-hidden utility class to hide the pieces of the table when the initial form is shown, and to hide the form once the table is built.

// A11y table block editor styles
.wp-block-cgb-a11y-table {
	width:100%;
	caption, th, td {
		padding: 3px;
	}
}
.components-icon-button:not(:disabled):focus, .components-icon-button:not(:disabled):hover {
	cursor:pointer;
}
.is-hidden {
	display:none;
}

Now, back to block.js to make use of our new CSS, and to introduce you to attributes.

import './style.scss';
import './editor.scss';

const registerBlockType = wp.blocks.registerBlockType;
const { Dashicon } = wp.components;

registerBlockType("cgb/a11y-table", {
	title: 'A11y Table',
	icon: 'screenoptions',
	category: 'common',
	attributes: {
		showTable: {
			type: 'boolean',
			default: false
		}
	},
	//////////////////// EDIT ////////////////////
	edit: props => {
		const { attributes: { showTable }, className, setAttributes } = props;
		let formClass = '';
		if(showTable == true) { formClass = 'is-hidden'; }
		return (
			<div>
				<table className={ className }>
					<tbody>
					</tbody>
				</table>
				<form className={ formClass }>
					<button
						type='submit'
						onClick={evt => buildTable(evt) }
					>
						Insert table
					</button>
				</form>
			</div>
		);
		function buildTable(evt) {
			evt.preventDefault();
			props.setAttributes({ showTable: true });
		}
	},
	//////////////////// SAVE ////////////////////
	save: props => {
		const { className } = props;
		return <table><tbody><tr><td>Cell</td></tr></tbody></table>;
	}
});

Attributes are a way of storing all the data about our block. Both the JavaScript and the HTML then take those attributes and use them to construct the block for the editor and for the browser. On line 12 we’re introducing a single attribute: showTable, which is a boolean, meaning it can either be true or false. By default, it’s false. We’ll use that to show our form when the block is newly added, but a table once the form is submitted.

Moving down to line 19, our edit function has a new line with another const. This one says that whenever we refer to showTable, we’re referring to the one in props.attributes. We’ll also make use of className, a CSS class name that WordPress dynamically generates from our block name – this is where .wp-block-cgb-a11y-table in our CSS files comes from.

Finally, when we call setAttributes, we’re calling the function by that name in props. This is the function that allows us to update the values of our attributes. Anytime the attributes are updated, the edit function runs again, so the output will change any time the attributes change.

On line 20, we create a variable called formClass that’s empty by default. Once the showTable attribute is true, we change formClass to our utility “is-hidden” class to hide the form.

Our edit return has changed – instead of being a snippet all on one line, we have code that spans multiple lines. Whenever you’re returning more than one line, there are two requirements: one, you need to use parenthesis around what you’re returning, and two, you have to return a single element. It’s odd to me to have to add a semantically useless div, but it’s important and doesn’t affect styling or accessibility. You may also notice some unusual things about the contents of return – for example, we’re using className instead of class because class is a reserved word in JavaScript.

Inside the div that our edit function returns, we now have a table and a form. I’ve removed the static cell for now just so the table doesn’t visually display. The button is more than just regular HTML – it’s called JSX, and this is one of the things we need webpack for. Not all browsers support JSX, so webpack takes care of compiling it out in ways that are compatible with older browsers. JSX looks similar to HTML, with attributes set using equals signs, so for example the type attribute of our button is submit.

We’ve added an onClick event that says whenever the button is clicked, run the buildTable() function, and pass it the event as an argument. (Event is another one of those potentially problematic words in JavaScript, so we’re referring to it as evt to prevent conflicts.) And you may notice that wrapped around the value of the onClick attribute, we have curly braces. In JSX, curly braces mean we’re referring to variables rather than static strings.

Finally, on line 38, notice that buildTable() is a function within a function. You can have functions within your edit function for complex blocks like this, and that just ensures they only run when edit runs – not when save runs. We prevent the default event (which would be to submit the form server-side, which would reload the page) and set the showTable attribute to true.

You may also notice we haven’t changed anything about save. You can work on edit all day long and not change save – it wouldn’t accomplish much, but you should just be aware that you can work on part of your block without immediately using the updated data on the other end. There are caveats – once you start setting up attributes that actually pull from the save function’s rendered HTML, you can run into console errors if you don’t have your attributes set up quite right. We’ll deal with that later.

With NPM running, if you save these three files in Step 2 (and upload them, if you’re not working locally) you can see that adding the block to the page now displays a form, and if you submit the form, the form disappears, because as soon as showTable becomes true, edit runs again and our formClass becomes “is-hidden”.


Step 3: update and use the attributes

Step 3 code on GitHub

Now that you understand a little about attributes, let’s get a little more dynamic. Let’s let the user decide how many rows and columns to create, and build a table that matches – both in edit and in save.

First, though, let’s make things translatable.

import './style.scss';
import './editor.scss';

const { __ } = wp.i18n;
const registerBlockType = wp.blocks.registerBlockType;
const { Dashicon } = wp.components;
const el = wp.element.createElement;

registerBlockType("cgb/a11y-table", {
	title: __('A11y Table'),
	icon: 'screenoptions',
	category: 'common',
	attributes: {
		dataBody: {
			type: 'array',
			source: 'query',
			default: [],
			selector: 'tbody tr',
			query: {
				bodyCells: {
					type: 'array',
					source: 'query',
					default: [],
					selector: 'td,th',
					query: {
						content: {
							type: 'string',
							source: 'html'
						}
					}
				}
			}
		},
		numCols: {
			type: 'string',
			default: '2'
		},
		numRows: {
			type: 'string',
			default: '2'
		},
		showTable: {
			type: 'boolean',
			default: false
		}
	},
	//////////////////// EDIT ////////////////////
	edit: props => {
		console.log('Edit Attributes: ',props.attributes);
		const { attributes: { dataBody, showTable }, className, setAttributes } = props;
		let numCols = parseInt(props.attributes.numCols, 10);
		let numRows = parseInt(props.attributes.numRows, 10);
		let formClass = '';
		if(showTable) { formClass = 'is-hidden'; }
		let tableBody, tableBodyData = dataBody
		.map(function(rows, rowIndex) {
			let rowCells = rows.bodyCells.map(function(cell, colIndex) {
				let cellOptions = {
					contenteditable: 'true',
					onInput: (evt) => {
						// Copy the dataBody
						let newBody = JSON.parse(JSON.stringify(dataBody));
						// Create a new cell
						let newCell = { content: evt.target.textContent };
						// Replace the old cell
						newBody[rowIndex].bodyCells[colIndex] = newCell;
						// Set the attribute
						props.setAttributes({
							dataBody: newBody
						});
					}
				};
				let currentBodyCell = el(
					'td',
					cellOptions,
					cell.content
				);
				return currentBodyCell;
			});
			return (<tr>{rowCells}</tr>);
		});
		if(tableBodyData.length) {		
			tableBody = <tbody>{ tableBodyData }</tbody>;
		}
		return (
			<div>
				<table className={ className }>
					{ tableBody }
				</table>
				<form className={ formClass }>
					<div>
						<label for='numCols'>{ __('Columns') }</label>
						<input
							type='number'
							id='numCols'
							value={ numCols }
							min='1'
							step='1'
							pattern='[0-9]*'
							onChange={ (evt) => props.setAttributes({ numCols: evt.target.value }) }
						/>
					</div>
					<div>
						<label for='numRows'>{ __('Rows') }</label>
						<input
							type='number'
							id='numRows'
							value={ numRows }
							min='1'
							step='1'
							pattern='[0-9]*'
							onChange={ (evt) => props.setAttributes({ numRows: evt.target.value }) }
						/>
					</div>
					<button
						type='submit'
						onClick={evt => buildTable(evt) }
					>
						{ __('Insert Table') }
					</button>
				</form>
			</div>
		);
		function buildTable(evt) {
			evt.preventDefault();
			// Only build the table and hide the form if there are rows and columns
			if(numCols > 0 && numRows > 0) {
				// Build the tbody attribute array
				let newBody = [];
				for(var row = 0; row < numRows; row++) {
					let thisRow = { bodyCells: [] };
					for(var col = 0; col < numCols; col++) {
						thisRow.bodyCells[col] = { content: '' };
					}
					newBody[row] = thisRow;
				}
				// Save atts
				props.setAttributes({
					dataBody: newBody,
					showTable: true
				});
			}
		}
	},
	//////////////////// SAVE ////////////////////
	save: props => {
		const { attributes: { dataBody }, className } = props;
		let numCols = parseInt(props.attributes.numCols, 10);
		let numRows = parseInt(props.attributes.numRows, 10);
		// Table Body
		let tableBody, tableBodyData = dataBody
		.map(function(rows) {
			let rowCells = rows.bodyCells.map(function(cell) {
				let currentBodyCell = el(
					'td',
					'',
					cell.content.trim(' ')
				);
				return currentBodyCell;
			});
			return (<tr>{rowCells}</tr>);
		});
		if(tableBodyData.length) {		
			tableBody = <tbody>{ tableBodyData }</tbody>;
		}
		return (
			<table className={ className }>
				{ tableBody }
			</table>
		);
	}
});

WordPress has supported internationalization – meaning translation – for awhile now. The “i18n” (short for internationalization) JavaScript library does what PHP’s __() function does – it tells WP that the string that follows should be translated. Once we’ve included that, anytime we use __(), our text will be translated as needed.

There’s one more new initial const, and it refers to a function called wp.element.createElement(). This is WordPress’s wrapper for React’s createElement() function. All it does is allow us to say el and have that create a React element, saving us from having to type out the full function name every time we need it. We will use this to create our table cells, and from those React elements the DOM itself will be updated. It’s all right if some of that is still rather fuzzy in your mind – keep following along, and things should start to make sense as you see them in action.

Moving on to our new attributes, we start out with the most complicated one: the dataBody, which will hold all of the tbody cells. Our showTable attribute only ever holds a true or false value, so it doesn’t require much code. Our dataBody will contain all of the individual cells and their contents, so it is an array. All of this extra code is to tell WordPress what to do when you edit a post that contains this block. When WP pulls the saved HTML out of the database, these instructions tell it which specific HTML to inspect to find the data, and how to put that data into our attributes once it does.

We’ll console out the attributes at the beginning of the edit function so you can see the structure of the dataBody in a minute, so don’t worry if you don’t entirely understand the next paragraph.

The query you see starting on line 19 searches for any td or th inside the selector, which is tbody tr, so that will find all the individual cells inside the tbody. (Later when we add column headings, those will be in the thead, so this just ensures we are only finding the cells in the body itself.) Each time it finds a set of cells inside a body row, it adds a bodyCells array to the top-level array, and each bodyCells array contains an object for every individual cell. Each cell object has a single property, content, to hold the cell’s contents. All you need to take away here is that an array attribute requires more code than a string attribute, so that WP knows which specific HTML elements to pull the data from.

Next, we have two new simple attributes: numCols to hold the number of columns, and numRows to hold the number of rows. Normally, for a number, you would use a “number” attribute type. But ironically, I could not get that to work – it kept giving the wrong number of rows and columns – so these attributes are set up to hold a string instead, with “2” as the default value. (Tip: when you place a number inside of either single quotes or double quotes, that makes it a string. A number without quotes would be a JavaScript number.)

Down in our edit function, there’s a console to show you the slightly odd structure of the dataBody attribute. You’ll also see a couple of new lines underneath the initial const. We use parseInt() to convert the numbers from strings to integers. Now anytime we refer to those numbers, they are actually numbers.

We’re skipping over the new lines 53-84 for right now, because they rely on our buildTable() function, starting down on line 124. This is where we use numRows and numCols to build however many rows and cells the user has requested. We have a new check to make sure both numCols and numRows are greater than zero. Then, we have some nested loops.

The outer loop creates one item in dataBody for each number in numRows. So, if we have 2 rows, dataBody is now an array with two items in it.

The inner loop creates one item inside that array for each number in numCols – one cell inside the row. So, if we have 2 columns, dataBody is still an array with two items in it, but now those array items each contain two cell objects. The only property each cell object has is content.

setAttributes on line 138 updates our attributes, which in turn causes the Block Editor to re-render everything based on the updates.

Now, we’re ready to take a look at lines 53-84. Once the buildTable() function runs, our dataBody attribute contains empty cells. As soon as that happens, the edit function runs again, and dataBody.map() on line 56 means that for each item in the dataBody array, we want to run the function that follows.

Similar to the outer and inner loop we just saw in buildTable(), we have an outer map for the rows, which returns a <tr> for each item in that top-level array, and we have an inner map for the cells, which currently returns a <td> for each cell inside that row. Later on we’ll make that dynamic, so we can return a <th> for a heading as needed, or a <td> for regular cells, without repeating ourselves.

Each <td> has its contenteditable attribute set to true. This makes the cell act like a form text input – you can edit its contents by typing in it. The cells also have an onInput function. This is where things get a little less straightforward. When an attribute is just a string or a number, you can edit it directly and things work out just fine. But when an attribute is an array, you can’t modify it directly like you can in PHP. In JavaScript, that’s called mutation, and it can cause some really weird, unreliable effects. So, we make a copy of the dataBody by using JSON stringify to convert the data into JSON format, and immediately using JSON parse to convert the JSON back to JavaScript. Weird, but it works.

Then we create a new cell object (line 64) that contains the event target’s text content. We can update that cell in our copy of the dataBody, and finally, we use setAttributes() to update the dataBody attribute itself. If you type a real word into one of the cells at this point, you’ll notice an odd bug – as soon as you type one character, the attribute updates, and the cursor goes straight to the beginning of the cell – so if you type a word, it appears backwards. We’ll deal with that in the next step – I don’t want to overload you with too many new things at once!

Finally, although I mentioned earlier that you can change the edit function without updating save, it really is wiser to keep them both in sync. So skip on down to line 152, where we use a similar array map on dataBody. This time, we just need plain old <td>s – no contenteditable, no onInput, because what you save is what’s going into the database to show up on the front end of the website, and you don’t want your visitors thinking they can edit your tables, only to find there’s no way to save them.

A small note: instead of just using cell.content on line 157, we have cell.content.trim(‘ ‘). That’s because of the weird way extra whitespace is handled sometimes. By trimming off any trailing whitespace (i.e., an extra space at the end of the cell – after all the other text) we’re ensuring that we won’t end up with an odd HTML-encoded &nbsp; at the end of a cell by accident.

Take a minute to add the block at this stage, with your console open. You can now see the structure of the dataBody attribute, compared to the structure of the other attributes. Setting up the dataBody attribute was one of the tasks that took me the longest to achieve, and I’m not sure it’s completely optimized. If you’ve added the default 2 rows and 2 columns, dataBody should look like this:

dataBody: Array(2)
	0: {
		bodyCells: Array(2)
			0: { content: '' }
			1: { content: ''}
	}
	1: {
		bodyCells: Array(2)
			0: { content: '' }
			1: { content: ''}
	}

I tried to remove the bodyCells array/object and just set up an array of arrays, and that worked on the edit side.

dataBody: Array(2)
	0: Array(2)
		0: { content: '' }
		1: { content: ''}
	1: Array(2)
		0: { content: '' }
		1: { content: '' }

But this failed on the save side, which meant that I could create a table and save it, but if I left the editor and came back to re-open the same post, WP could not figure out what to do with the HTML in the database, so the block threw an error and was uneditable. If anyone knows how to simplify this attribute, I welcome suggestions and pull requests!


Step 4: fix the cursor; add captions and footers

Step 4 code on GitHub

You just got through the hardest part, so here’s a simpler step to keep up the momentum.

import './style.scss';
import './editor.scss';

const { __ } = wp.i18n;
const registerBlockType = wp.blocks.registerBlockType;
const { Dashicon } = wp.components;
const el = wp.element.createElement;

registerBlockType("cgb/a11y-table", {
	title: __('A11y Table'),
	icon: 'screenoptions',
	category: 'common',
	attributes: {
		dataBody: {
			type: 'array',
			source: 'query',
			default: [],
			selector: 'tbody tr',
			query: {
				bodyCells: {
					type: 'array',
					source: 'query',
					default: [],
					selector: 'td,th',
					query: {
						content: {
							type: 'string',
							source: 'html'
						}
					}
				}
			}
		},
		dataCaption: {
			type: 'string',
			source: 'text',
			selector: 'caption'
		},
		dataFooter: {
			type: 'string',
			source: 'text',
			selector: 'tfoot td'
		},
		numCols: {
			type: 'string',
			default: '2'
		},
		numRows: {
			type: 'string',
			default: '2'
		},
		showTable: {
			type: 'boolean',
			default: false
		},
		useCaption: {
			type: 'boolean',
			default: true
		},
		useFooter: {
			type: 'boolean',
			default: false
		}
	},
	//////////////////// EDIT ////////////////////
	edit: props => {
		const { attributes: { dataBody, dataCaption, dataFooter, showTable, useCaption, useFooter }, className, setAttributes } = props;
		let numCols = parseInt(props.attributes.numCols, 10);
		let numRows = parseInt(props.attributes.numRows, 10);
		// Caption
		let tableCaption, captionClass = 'is-hidden';
		if(showTable) {
			captionClass = '';
		}
		if(useCaption) {
			tableCaption = <caption
				className={ captionClass }
				contenteditable='true'
			>
				{ dataCaption }
			</caption>;
			tableCaption.props.onInput = function(evt) {
				props.setAttributes({ dataCaption: evt.target.textContent });
				// Move the cursor back where it was
				setCursor(evt);
			};
		}
		// Table Body
		let tableBody, formClass = '', tableBodyData = dataBody
		.map(function(rows, rowIndex) {
			let rowCells = rows.bodyCells.map(function(cell, colIndex) {
				let cellOptions = {
					contenteditable: 'true',
					onInput: (evt) => {
						// Copy the dataBody
						let newBody = JSON.parse(JSON.stringify(dataBody));
						// Create a new cell
						let newCell = { content: evt.target.textContent };
						// Replace the old cell
						newBody[rowIndex].bodyCells[colIndex] = newCell;
						// Set the attribute
						props.setAttributes({
							dataBody: newBody
						});
						// Move the cursor back where it was
						setCursor(evt);
					}
				};
				let currentBodyCell = el(
					'td',
					cellOptions,
					cell.content
				);
				return currentBodyCell;
			});
			return (<tr>{rowCells}</tr>);
		});
		if(tableBodyData.length) {		
			tableBody = <tbody>{ tableBodyData }</tbody>;
		}
		// Table Footer
		var tableFooter, footerClass = 'is-hidden';
		if(showTable) {
			footerClass = '';
			formClass = 'is-hidden';
		}
		if(useFooter == true) {
			let tableFooterTd = <td
				colspan={ numCols }
				className={ footerClass }
				contenteditable='true'
			>
				{ dataFooter }
			</td>;
			tableFooterTd.props.onInput = function(evt) {
				props.setAttributes({ dataFooter: evt.target.textContent });
				// Move the cursor back where it was
				setCursor(evt);
			};
			tableFooter = <tfoot><tr>{ tableFooterTd }</tr></tfoot>;
		}
		return (
			<div>
				<table className={ className }>
					{ tableCaption }
					{ tableBody }
					{ tableFooter }
				</table>
				<form className={ formClass }>
					<div>
						<label for='addCaption'>{ __('Add Caption') }</label>
						<input
							type='checkbox'
							id='captionCheck'
							checked={ useCaption }
							onChange={ function(evt) {
								props.setAttributes({ useCaption: evt.target.checked });
							}}
						/>
					</div>
					<div>
						<label for='numCols'>{ __('Columns') }</label>
						<input
							type='number'
							id='numCols'
							value={ numCols }
							min='1'
							step='1'
							pattern='[0-9]*'
							onChange={ (evt) => props.setAttributes({ numCols: evt.target.value }) }
						/>
					</div>
					<div>
						<label for='numRows'>{ __('Rows') }</label>
						<input
							type='number'
							id='numRows'
							value={ numRows }
							min='1'
							step='1'
							pattern='[0-9]*'
							onChange={ (evt) => props.setAttributes({ numRows: evt.target.value }) }
						/>
					</div>
					<div>
						<label for='addFooter'>{ __('Add Footer') }</label>
						<input
							type='checkbox'
							id='footerCheck'
							checked={ useFooter }
							onChange={ function(evt) {
								props.setAttributes({ useFooter: evt.target.checked });
							}}
						/>
					</div>
					<button
						type='submit'
						onClick={evt => buildTable(evt) }
					>
						{ __('Insert Table') }
					</button>
				</form>
			</div>
		);
		function buildTable(evt) {
			evt.preventDefault();
			// Only build the table and hide the form if there are rows and columns
			if(numCols > 0 && numRows > 0) {
				// Build the tbody attribute array
				let newBody = [];
				for(var row = 0; row < numRows; row++) {
					let thisRow = { bodyCells: [] };
					for(var col = 0; col < numCols; col++) {
						thisRow.bodyCells[col] = { content: '' };
					}
					newBody[row] = thisRow;
				}
				// Save atts
				props.setAttributes({
					dataBody: newBody,
					showTable: true
				});
			}
		}
		// Function that returns the cursor where it was, instead of the beginning of an input
		function setCursor(evt) {
			var node = evt.target;
			var caret = window.getSelection().anchorOffset;
			if(node.firstChild) {
				setTimeout(function() {
					let textNode = node.firstChild;
					var range = document.createRange();
					range.setStart(textNode, caret);
					range.setEnd(textNode, caret);
					var sel = window.getSelection();
					sel.removeAllRanges();
					sel.addRange(range);
				}, 1, node, caret);
			}
		}
	},
	//////////////////// SAVE ////////////////////
	save: props => {
		const { attributes: { dataBody, dataCaption, dataFooter, useCaption, useFooter }, className } = props;
		let numCols = parseInt(props.attributes.numCols, 10);
		let numRows = parseInt(props.attributes.numRows, 10);
		// Caption
		var tableCaption;
		if(useCaption === true) {
			tableCaption = <caption>{ dataCaption }</caption>
		}
		let tableBody, tableBodyData = dataBody
		.map(function(rows) {
			let rowCells = rows.bodyCells.map(function(cell) {
				let currentBodyCell = el(
					'td',
					'',
					cell.content.trim(' ')
				);
				return currentBodyCell;
			});
			return (<tr>{rowCells}</tr>);
		});
		if(tableBodyData.length) {		
			tableBody = <tbody>{ tableBodyData }</tbody>;
		}
		// Table Footer
		let tableFooter;
		if(useFooter == true) {
			tableFooter = <tfoot><tr><td colspan={ numCols }>{ dataFooter }</td></tr></tfoot>
		}
		return (
			<table className={ className }>
				{ tableCaption }
				{ tableBody }
				{ tableFooter }
			</table>
		);
	}
});

The caption and the footer are optional in this block, so we have two attributes set up for each of them: one attribute to tell WP whether or not to enable the option (useCaption, useFooter), and one attribute to hold the data if they’re enabled (dataCaption, dataFooter). I tried to name things in a succinct way that also makes it clear whether the attribute refers to an option or the actual data.

As usual, when we add the new attributes, we have to refer back to them later (see line 67). Our new lines 71-87 ensure that the <caption> tag remains invisible until showTable is true. If we didn’t add that “is-hidden” class, then as soon as the user checked the form checkbox to enable the caption, it would pop into view. We want a cleaner user experience, where none of the table shows up until the form is actually submitted.

Our caption suffers from the same cursor bug as our table cells, so we’re creating and calling a new function called setCursor() (down on lines 225-241) each time one of them is updated. Just like buildTable(), this function lives inside the edit function, so it can only be called while the user is editing the post. The function also gets called on line 106, so the cells will now let you type in the intended order as well. The nice part is, this returns the cursor to wherever it was previously, so if the user is editing in a right-to-left language, the code should work for them as well.

Lines 121-141 handle the footer. They build a full <tfoot> if the useFooter attribute is true, and they use the same setCursor() function to keep the cursor where it belongs.

Lines 144-148 have been tweaked – instead of just containing a table body, we now ensure that tableCaption and tableFooter are included as well. That’s because if those options are turned off, their content is empty, so the actual rendered table will only contain the tbody. But if they’re on, the table will also include a caption and a tfoot.

Now, we’d better make sure we add those options to the form, so the user can actually enable them. Lines 150-160 add a checkbox so the visitor can enable or disable the caption option. As soon as the user interacts, there’s an onChange event that updates our useCaption attribute. The same pattern appears in lines 185-195, for the footer.

Once again, it’s no good adding these options in edit if we don’t also add them in save, and once again things are fairly simple because we don’t need event listeners or contenteditable. The <caption> is the simplest, because it’s just a single HTML element. If our useCaption attribute is true, it creates the <caption> element and adds in the content from dataCaption.

The footer is just slightly more complicated – it sets a colspan on its cell, to ensure that the width of the footer cell equals the full width of the table. Finally, the return looks just like the return in our edit function – it now includes the tableCaption and tableFooter as well as the tableBody.


Step 5: enable scoped row and column headers

Step 5 code on GitHub

So far, we’re about on par with the WP Core table block, except that our block supports captions and table footers. Now, let’s dig into another very important item for accessibility and clarity: table headers, or <th>s.

Table headers are a little complicated, because they end up inside either the <thead> or the <tbody> depending on what they refer to. Column headers always appear in the <thead> – they’re the ones that show up all along the top, like so:

Column header 1 Column header 2
Data cell 1 Data cell 2

Row headers can be in either the <thead> or the <tbody>. Because we don’t want to bog users down with too many options that could confuse them, for the purposes of this table block, we assume that the <thead> should only contain column headers. That way, row headers will always appear in the <tbody>, like so:

Column header 1 Column header 2
Row header Data cell 1

With the desired structure in mind, here’s how we’ll add support for all these headers.

import './style.scss';
import './editor.scss';
 
const { __ } = wp.i18n;
const registerBlockType = wp.blocks.registerBlockType;
const { Dashicon } = wp.components;
const el = wp.element.createElement;
 
registerBlockType("cgb/a11y-table", {
    title: __('A11y Table'),
    icon: 'screenoptions',
    category: 'common',
    attributes: {
        dataBody: {
            type: 'array',
            source: 'query',
            default: [],
            selector: 'tbody tr',
            query: {
                bodyCells: {
                    type: 'array',
                    source: 'query',
                    default: [],
                    selector: 'td,th',
                    query: {
                        content: {
                            type: 'string',
                            source: 'html'
                        }
                    }
                }
            }
        },
        dataCaption: {
            type: 'string',
            source: 'text',
            selector: 'caption'
        },
        dataFooter: {
            type: 'string',
            source: 'text',
            selector: 'tfoot td'
        },
        dataHead: {
            type: 'array',
            source: 'query',
            default: [],
            selector: 'th[scope="col"]',
            query: {
                content: {
                    type: 'string',
                    source: 'html'
                }
            }
        },
        numCols: {
            type: 'string',
            default: '2'
        },
        numRows: {
            type: 'string',
            default: '2'
        },
        showTable: {
            type: 'boolean',
            default: false
        },
        useCaption: {
            type: 'boolean',
            default: true
        },
        useColHeadings: {
            type: 'boolean',
            default: true
        },
        useRowHeadings: {
            type: 'boolean',
            default: false
        },
        useFooter: {
            type: 'boolean',
            default: false
        }
    },
    //////////////////// EDIT ////////////////////
    edit: props => {
        console.log('Edit Attributes: ',props.attributes);
        const { attributes: { dataBody, dataCaption, dataFooter, dataHead, showTable, useCaption, useColHeadings, useFooter, useRowHeadings }, className, setAttributes } = props;
        let numCols = parseInt(props.attributes.numCols, 10);
        let numRows = parseInt(props.attributes.numRows, 10);
        // Caption
        let tableCaption, captionClass = 'is-hidden';
        if(showTable) {
            captionClass = '';
        }
        if(useCaption) {
            tableCaption = <caption
                className={ captionClass }
                contenteditable='true'
            >
                { dataCaption }
            </caption>;
            tableCaption.props.onInput = function(evt) {
                props.setAttributes({ dataCaption: evt.target.textContent });
                // Move the cursor back where it was
                setCursor(evt);
            };
        }
        // Row Counter for aria labels - start at 1
        let ariaLabel, rowCounter = 1;
        // Table Head
        let tableHead, headClass = 'is-hidden';
        if(showTable) {
            headClass = '';
        }
        const tableHeadData = dataHead
        .map(function(cell, colIndex) {
            ariaLabel = 'Row '+rowCounter+' Column '+(colIndex+1);
            let currentTh = <th
                aria-label={ ariaLabel }
                scope='col'
                contenteditable='true'
            >
                { cell.content }
            </th>;
            currentTh.props.onInput = function(evt) {
                // Copy the dataHead
                let newHead = JSON.parse(JSON.stringify(dataHead));
                // Create a new cell
                let newTh = { content: evt.target.textContent };
                // Replace the old cell with the new cell
                newHead.splice(colIndex, 1, newTh);
                // Save the dataHead attribute
                props.setAttributes({ dataHead: newHead });
                // Move the cursor back where it was
                setCursor(evt);
            };
            return currentTh;
        });
        if(tableHeadData.length) {
            tableHead = <thead className={ headClass }><tr>{ tableHeadData }</tr></thead>;
        } else {
            // If there is no table head, take rowCounter back down to 0, because Table Body has to increment it before output
            rowCounter--;
        }
        // Table Body
        let tableBody, formClass = '', tableBodyData = dataBody
        .map(function(rows, rowIndex) {
            rowCounter++;
            let rowCells = rows.bodyCells.map(function(cell, colIndex) {
                // Set up options
                ariaLabel = 'Row '+rowCounter+' Column '+(colIndex+1);
                let cellType = 'd';
                let cellOptions = {
                    'aria-label': ariaLabel,
                    contenteditable: 'true',
                    onInput: (evt) => {
                        // Copy the dataBody
                        let newBody = JSON.parse(JSON.stringify(dataBody));
                        // Create a new cell
                        let newCell = { content: evt.target.textContent };
                        // Replace the old cell
                        newBody[rowIndex].bodyCells[colIndex] = newCell;
                        // Set the attribute
                        props.setAttributes({
                            dataBody: newBody
                        });
                        // Move the cursor back where it was
                        setCursor(evt);
                    }
                };
                if(useRowHeadings == true && colIndex == 0) { cellType = 'h'; cellOptions.scope = 'row'; }
                // Create the element - either a TD or a TH
                let currentBodyCell = el(
                    `t${cellType}`,
                    cellOptions,
                    cell.content
                )
                return currentBodyCell;
            });
            return (<tr>{rowCells}</tr>);
        });
        if(tableBodyData.length) {      
            tableBody = <tbody>{ tableBodyData }</tbody>;
			formClass = 'is-hidden';
        }
        // Table Footer
        var tableFooter, footerClass = 'is-hidden';
        if(showTable) {
            footerClass = '';
        }
        // Calculate colspan: if useRowHeadings is true, there should be 1 extra column
        let totalCols = numCols;
        if(useRowHeadings == true) {
            totalCols++;
        }
        if(useFooter == true) {
            let tableFooterTd = <td
                colspan={ totalCols }
                className={ footerClass }
                contenteditable='true'
            >
                { dataFooter }
            </td>;
            tableFooterTd.props.onInput = function(evt) {
                props.setAttributes({ dataFooter: evt.target.textContent });
                // Move the cursor back where it was
                setCursor(evt);
            };
            tableFooter = <tfoot><tr>{ tableFooterTd }</tr></tfoot>;
        }
        return (
            <div>
                <table className={ className }>
                    { tableCaption }
                    { tableHead }
                    { tableBody }
                    { tableFooter }
                </table>
                <form className={ formClass }>
                    <div>
                        <label for='addCaption'>{ __('Add Caption') }</label>
                        <input
                            type='checkbox'
                            id='captionCheck'
                            checked={ useCaption }
                            onChange={ function(evt) {
                                props.setAttributes({ useCaption: evt.target.checked });
                            }}
                        />
                    </div>
                    <div>
                        <label for='useColHeadings'>{ __('Add Column Headings') }</label>
                        <input
                            type='checkbox'
                            id='useColHeadings'
                            checked={ useColHeadings }
                            onChange={ function(evt) {
                                if(evt.target.checked == true) {
                                    props.setAttributes({ useColHeadings: true });
                                } else {
                                    props.setAttributes({ useColHeadings: false });
                                }
                            }}
                        />
                    </div>
                    <div>
                        <label for='numCols'>{ __('Columns') }</label>
                        <input
                            type='number'
                            id='numCols'
                            value={ numCols }
                            min='1'
                            step='1'
                            pattern='[0-9]*'
                            onChange={ (evt) => props.setAttributes({ numCols: evt.target.value }) }
                        />
                    </div>
                    <div>
                        <label for='useRowHeadings'>{ __('Add Row Headings') }</label>
                        <input
                            type='checkbox'
                            id='useRowHeadings'
                            checked={ useRowHeadings }
                            onChange={ function(evt) {
                                if(evt.target.checked == true) {
                                    props.setAttributes({ useRowHeadings: true });
                                } else {
                                    props.setAttributes({ useRowHeadings: false });
                                }
                            }}
                        />
                    </div>
                    <div>
                        <label for='numRows'>{ __('Rows') }</label>
                        <input
                            type='number'
                            id='numRows'
                            value={ numRows }
                            min='1'
                            step='1'
                            pattern='[0-9]*'
                            onChange={ (evt) => props.setAttributes({ numRows: evt.target.value }) }
                        />
                    </div>
                    <div>
                        <label for='addFooter'>{ __('Add Footer') }</label>
                        <input
                            type='checkbox'
                            id='footerCheck'
                            checked={ useFooter }
                            onChange={ function(evt) {
                                props.setAttributes({ useFooter: evt.target.checked });
                            }}
                        />
                    </div>
                    <button
                        type='submit'
                        onClick={evt => buildTable(evt) }
                    >
                        { __('Insert Table') }
                    </button>
                </form>
            </div>
        );
        function buildTable(evt) {
            evt.preventDefault();
            // Only build the table and hide the form if there are rows and columns
            if(numCols > 0 && numRows > 0) {
                // Number of rows will always be numRows, because if useColHeadings is true, that extra row will be in the <thead>, not the <tbody>
                // But, number of columns will vary: if useRowHeadings is true, there should be 1 extra column
                // totalCols is used to build both the thead attribute array and the tbody attribute array
                let totalCols = numCols;
                if(useRowHeadings == true) {
                    totalCols++;
                }
                // Build the thead attribute array
                let newHead = [];
                // If useColHeadings is true, add placeholders for the THs. If not, add nothing, because there should not be a thead at all.
                if(useColHeadings == true) {
                    for(var i = 0; i < totalCols; i++) {
                        newHead[i] = { content: '' };
                    }
                }
                // Build the tbody attribute array
                let newBody = [];
                for(var row = 0; row < numRows; row++) {
                    let thisRow = { bodyCells: [] };
                    for(var col = 0; col < totalCols; col++) {
                        thisRow.bodyCells[col] = { content: '' };
                    }
                    newBody[row] = thisRow;
                }
                // Save atts
                props.setAttributes({
                    dataHead: newHead,
                    dataBody: newBody,
                    showTable: true
                });
            }
        }
        // Function that returns the cursor where it was, instead of the beginning of an input
        function setCursor(evt) {
            var node = evt.target;
            var caret = window.getSelection().anchorOffset;
            if(node.firstChild) {
                setTimeout(function() {
                    let textNode = node.firstChild;
                    var range = document.createRange();
                    range.setStart(textNode, caret);
                    range.setEnd(textNode, caret);
                    var sel = window.getSelection();
                    sel.removeAllRanges();
                    sel.addRange(range);
                }, 1, node, caret);
            }
        }
    },
    //////////////////// SAVE ////////////////////
    save: props => {
        const { attributes: { dataBody, dataCaption, dataFooter, dataHead, useCaption, useColHeadings, useFooter, useRowHeadings }, className } = props;
        let numCols = parseInt(props.attributes.numCols, 10);
        let numRows = parseInt(props.attributes.numRows, 10);
        // Caption
        let tableCaption;
        if(useCaption === true) {
            tableCaption = <caption>{ dataCaption }</caption>
        }
        // Table Head
        let tableHead;
        if(useColHeadings == true) {
            const tableHeadData = dataHead.map(function(cell, colIndex) {
                return (
                    <th scope='col'>{ cell.content.trim(' ') }</th>
                );
            });
            if(tableHeadData.length) {
                tableHead = <thead><tr>{ tableHeadData }</tr></thead>;
            }
        }
        // Table Body
        let tableBody, tableBodyData = dataBody
        .map(function(rows) {
            let rowCells = rows.bodyCells.map(function(cell, colIndex) {
                if(useRowHeadings == true && colIndex == 0) {
                    return <th scope='row'>{ cell.content.trim(' ') }</th>
                } else {
                    return <td>{ cell.content.trim(' ') }</td>
                }
            });
            return (<tr>{rowCells}</tr>);
        });
        if(tableBodyData.length) {      
            tableBody = <tbody>{ tableBodyData }</tbody>;
        }
        // Table Footer
        let tableFooter;
        // Calculate colspan: if useRowHeadings is true, there should be 1 extra column
        let totalCols = numCols;
        if(useRowHeadings == true) {
            totalCols++;
        }
        if(useFooter == true) {
            tableFooter = <tfoot><tr><td colspan={ totalCols }>{ dataFooter }</td></tr></tfoot>
        }
        return (
            <table className={ className }>
                { tableCaption }
                { tableHead }
                { tableBody }
                { tableFooter }
            </table>
        );
    }
});

A new attribute called dataHead contains all the <th>s for the <thead>. Because there is always a single row in the <thead>, this attribute is simpler than our dataBody attribute – it’s a single-dimensional array that simply contains the content of each <th>. We use a query for this attribute in order to grab all of the <th>s with <scope=”col”>, meaning all of the column headers.

New boolean attributes called useColHeadings and useRowHeadings allow the user to toggle these headers on and off. As you’ve hopefully come to expect, we also refer to the new attributes in line 88 to make sure we can access the new data.

Now things get a little complicated again.

Complication #1: we now have to do some math to figure out how many rows and cells to add.

If the user adds a table with no headers, we can use numRows and numCols at face value. Two rows and two columns is simple and doesn’t require extra calculations.

But if the user adds a table with row headers, where do we put those headers? We could either add them on, in addition to numCols, or else we could take up one of the <td> cells in each row and replace it with a <th>. After experimenting and thinking about this for awhile, I came to the conclusion that adding an extra cell would be preferable, because if the user has already built a table and entered data into its cells, and then they decide to add row headers, they probably won’t be expecting their entire first column to be replaced with fresh, empty <th> cells. If they were expecting cells to just be converted, they can delete the unnecessary extra column.

Complication #2: We also want to make this block as accessible as possible for people who are editing things and building these tables, so it would also be nice to allow screen readers to announce which cell they’re in. So, we’re going to add an aria-label to each cell. This way instead of announcing “empty cell,” the reader can say “empty cell row 2, column 7”. This makes the math even more complicated, because now we’ll need to check whether there are column headers to determine whether the body’s first row of cells is row 1 or row 2.

(If there is no <thead>, then the first row of the <tbody> is the first row of the table. But if there is a <thead>, then the first row of the <tbody> is the second row of the table.)

Complication #3: Arrays start at an index of 0, not 1. So your first row is actually “row zero,” which would be confusing to average editors. So we’ll need to add 1 to make that a more human-readable “row one.”

But all of these complications just call for a bit of “if this is true, add one” conditional math, not a “find the derivative of…” advanced calculus problem. You can do this.

On line 110, we create a variable called ariaLabel that will hold each cell’s label, and a variable called rowCounter that will keep track of what row we’re on. (We’ll use that inside our ariaLabel.) Then, we set up tableHead and headClass variables, very much like our tableBody and bodyClass variables. If the table has been created (and showTable is true), then the headClass is empty so it will show up.

Lines 116-139 map the dataHead array into cells, much like the lines that map the dataBody array into rows of cells. Since there’s only one row in a <thead>, though, it’s a single array map rather than a nested one. You’ll notice that now our map function includes both cell and colIndex arguments. These are built in; we weren’t using the colIndex before, but now that we need to know what column we’re on to state that in the aria label, we’ll make use of that built-in ability and always add one because we always want to start with column 1, not column 0.

Next, if there’s anything in tableHeadData, we use that to create the tableHead. However, if there is nothing in tableHeadData, which means that the user has column headers off, then we subtract 1 from rowCounter to take it back down to 0. That’s because when we start looping through body rows, just like with head rows, we have to add 1 before we loop, and in this situation we need to start again with 0 to get back to 1 being the true first row. Line 149 takes care of adding that 1 for us, so now our aria labels are accurate no matter which options are enabled or disabled.

However, now we have to handle row headers. Remember, these are the ones that get added into the <tbody> rather than the <thead>, so now we’re putting both <td>s and <th>s in the <tbody>. We just need a little more logic to determine which ones to use where.

Line 152 should look familiar. Line 153 sets up the cell to be a <td> by default. We add the aria label, and on line 172 we check whether useRowHeadings is true. If so, and if this is also column 0 (using the array index, not the aria label), then we change the cell to be a <th> with scope=”row”.

Line 175 is the oddest. When you call createElement(), you can’t just specify a variable as the first argument. That is, the following will NOT work:

let cellType = 'td';
let currentBodyCell = el(
	cellType,
	cellOptions,
	cell.content
);

It won’t accept a plain variable as the HTML element type. However, we can use backticks to get around this limitation, so in the end we do end up specifying a variable as the element – just using a little different formatting than we have had to use so far.

Line 185 has been moved slightly. Here’s why: if you add a table (and therefore showTable is set to true), but then you delete all its cells by using the new buttons, it’s important to display the form again. So, instead of hiding the form when showTable is true, we’re now hiding the form when tableBody has a length – i.e., it contains something, even if it’s just empty placeholder cells.

Remember how we have a colspan in our footer cell? That will need to be updated as well. Lines 192-196 calculate the right number of columns, and line 199 has been updated to refer to our new variable totalCols so that no matter whether or not there are row headings, the total number of columns (and thus the colspan) will be correct.

Last but not least, we add the “Add Column Headings” and “Add Row Headings” options into our form, and that wraps up the edit function. What do you think comes next?

We’ve updated edit, so now we need to update save, and as usual, the save function is a bit less complex. Lines 369-380 handle the <thead>, while lines 384-390 use that same colIndex to determine whether a <td> or a <th> is appropriate. Lines 398-402 handle the colspan for the footer <td>. Finally, on line 409, we make sure to include the tableHead (which, again, will be empty if that option is turned off, so we always include it).

This might be the end, if we never expected our users to need to add or delete cells after the table is created. However, we know in the real world our users are going to need to be able to do those things, and maybe even change their minds about including captions and footers, so there’s one last step to complete.


Step 6: add a toolbar and inspector toggles

Step 6 code on GitHub

Custom toolbar

In this step, we will add a toolbar to our block that has buttons for:

  1. Insert Column Before
  2. Insert Column After
  3. Insert Row Before
  4. Insert Row After
  5. Delete Column
  6. Delete Row

Once again, our column headers complicate things a bit. Why? Well, if we have Column Headers enabled, and we’re currently in one of the Column Header cells, then we cannot delete the current row, and we also can’t add a row before this row – because it’s the header row, and it has to remain first.

If we have Row Headers enabled, and we’re currently in one of the Row Headers, we similarly cannot delete the current column or add a column before it.

And if we have both Column Headers and Row Headers enabled, and we are currently in the top-left cell, we can’t do any of those four operations – we can only insert a column after, or insert a row after.

We’re going to have to keep track of what cell is selected, and enable and disable the right toolbar buttons, to make sure the user can only do the desired operations.

Custom inspector panel

The Inspector is the right sidebar that changes contextually. If you’re just in the editor with no block selected, it shows you post-related information. Once you select a block, it shows you information related specifically to that block. So, we’re adding an Inspector Panel – an accordion drawer, for lack of better words – to allow the user to toggle four options:

  • Include a Caption
  • Include Column Headings
  • Include Row Headings
  • Include a Footer

This is a little simpler – the user doesn’t have to have a specific cell selected, just the block overall. If they do have a cell selected, it’s safe to perform any of these on/off switches regardless of which cell they’re in.

Here’s what our code will look like at the end of this tutorial:

import './style.scss';
import './editor.scss';

const { __ } = wp.i18n;
const registerBlockType = wp.blocks.registerBlockType;
const { Dashicon, FormToggle, PanelBody, PanelRow, Toolbar, Button, Tooltip } = wp.components;
const { BlockControls, InspectorControls } = wp.editor;
const el = wp.element.createElement;

registerBlockType("cgb/a11y-table", {
	title: __('A11y Table'),
	icon: 'screenoptions',
	category: 'common',
	attributes: {
		buttonStates: {
			type: 'object',
			default: {
				disabled1: true,
				disabled2: true,
				disabled3: true,
				disabled4: true,
				disabled5: true,
				disabled6: true
			}
		},
		currentCell: {
			type: 'object',
			default: {
				row: '',
				col: ''
			}
		},
		dataBody: {
			type: 'array',
			source: 'query',
			default: [],
			selector: 'tbody tr',
			query: {
				bodyCells: {
					type: 'array',
					source: 'query',
					default: [],
					selector: 'td,th',
					query: {
						content: {
							type: 'string',
							source: 'html'
						}
					}
				}
			}
		},
		dataCaption: {
			type: 'string',
			source: 'text',
			selector: 'caption'
		},
		dataFooter: {
			type: 'string',
			source: 'text',
			selector: 'tfoot td'
		},
		dataHead: {
			type: 'array',
			source: 'query',
			default: [],
			selector: 'th[scope="col"]',
			query: {
				content: {
					type: 'string',
					source: 'html'
				}
			}
		},
		numCols: {
			type: 'string',
			default: '2'
		},
		numRows: {
			type: 'string',
			default: '2'
		},
		showTable: {
			type: 'boolean',
			default: false
		},
		useCaption: {
			type: 'boolean',
			default: true
		},
		useColHeadings: {
			type: 'boolean',
			default: true
		},
		useRowHeadings: {
			type: 'boolean',
			default: false
		},
		useFooter: {
			type: 'boolean',
			default: false
		}
	},
	//////////////////// EDIT ////////////////////
	edit: props => {
		const { attributes: { buttonStates, currentCell, dataBody, dataCaption, dataFooter, dataHead, showTable, useCaption, useColHeadings, useFooter, useRowHeadings }, className, setAttributes } = props;
		let numCols = parseInt(props.attributes.numCols, 10);
		let numRows = parseInt(props.attributes.numRows, 10);
		var buttonsToEnable;
		// Caption
		let tableCaption, captionClass = 'is-hidden';
		if(showTable) {
			captionClass = '';
		}
		if(useCaption) {
			tableCaption = <caption
				className={ captionClass }
				contenteditable='true'
				onFocus={evt => {
					exitCellState(evt);
				}}
			>
				{ dataCaption }
			</caption>;
			tableCaption.props.onInput = function(evt) {
				props.setAttributes({ dataCaption: evt.target.textContent });
				// Move the cursor back where it was
				setCursor(evt);
			};
		}
		// Row Counter for aria labels - start at 1
		let ariaLabel, rowCounter = 1;
		// Table Head
		let tableHead, headClass = 'is-hidden';
		if(showTable) {
			headClass = '';
		}
		const tableHeadData = dataHead
		.map(function(cell, colIndex) {
			ariaLabel = 'Row '+rowCounter+' Column '+(colIndex+1);
			let currentThButtons = '1,2,4,5';
			// If row headings are enabled, and this is the very first col TH, don't allow insert col before or delete col
			if(colIndex == 0 && useRowHeadings == true) { 
				currentThButtons = '2,4';
			}
			let currentTh = <th
				aria-label={ ariaLabel }
				scope='col'
				contenteditable='true'
				data-buttons={ currentThButtons }
				onFocus={evt => {
					enterCellState(evt);
				}}
			>
				{ cell.content }
			</th>;
			currentTh.props.onInput = function(evt) {
				// Copy the dataHead
				let newHead = JSON.parse(JSON.stringify(dataHead));
				// Create a new cell
				let newTh = { content: evt.target.textContent };
				// Replace the old cell with the new cell
				newHead.splice(colIndex, 1, newTh);
				// Save the dataHead attribute
				props.setAttributes({ dataHead: newHead });
				// Move the cursor back where it was
				setCursor(evt);
			};
			return currentTh;
		});
		if(tableHeadData.length) {
			tableHead = <thead className={ headClass }><tr>{ tableHeadData }</tr></thead>;
		} else {
			// If there is no table head, take rowCounter back down to 0, because Table Body has to increment it before output
			rowCounter--;
		}
		// Table Body
		let tableBody, formClass = '', tableBodyData = dataBody
		.map(function(rows, rowIndex) {
			rowCounter++;
			let rowCells = rows.bodyCells.map(function(cell, colIndex) {
				// Set up options
				ariaLabel = 'Row '+rowCounter+' Column '+(colIndex+1);
				let cellType = 'd';
				let cellOptions = {
					'aria-label': ariaLabel,
					contenteditable: 'true',
					'data-buttons': '1,2,3,4,5,6',
					onFocus: (evt) => { enterCellState(evt); },
					onInput: (evt) => {
						// Copy the dataBody
						let newBody = JSON.parse(JSON.stringify(dataBody));
						// Create a new cell
						let newCell = { content: evt.target.textContent };
						// Replace the old cell
						newBody[rowIndex].bodyCells[colIndex] = newCell;
						// Set the attribute
						props.setAttributes({
							dataBody: newBody
						});
						// Move the cursor back where it was
						setCursor(evt);
					}
				};
				if(useRowHeadings == true && colIndex == 0) { cellType = 'h'; cellOptions['data-buttons'] = '2,3,4,6'; cellOptions.scope = 'row'; }
				// Create the element - either a TD or a TH
				let currentBodyCell = el(
					`t${cellType}`,
					cellOptions,
					cell.content
				)
				return currentBodyCell;
			});
			return (<tr>{rowCells}</tr>);
		});
		if(tableBodyData.length) {		
			tableBody = <tbody>{ tableBodyData }</tbody>;
			formClass = 'is-hidden';
		}
		// Table Footer
		var tableFooter, footerClass = 'is-hidden';
		if(showTable) {
			footerClass = '';
		}
		// Calculate colspan: if useRowHeadings is true, there should be 1 extra column
		let totalCols = numCols;
		if(useRowHeadings == true) {
			totalCols++;
		}
		if(useFooter == true) {
			let tableFooterTd = <td
				colspan={ totalCols }
				className={ footerClass }
				contenteditable='true'
				onFocus={evt => {
					exitCellState(evt);
				}}
			>
				{ dataFooter }
			</td>;
			tableFooterTd.props.onInput = function(evt) {
				props.setAttributes({ dataFooter: evt.target.textContent });
				// Move the cursor back where it was
				setCursor(evt);
			};
			tableFooter = <tfoot><tr>{ tableFooterTd }</tr></tfoot>;
		}
		// Final Return
		return (
			<div>
				<BlockControls key='a11y-form-controls'>
					<Toolbar>
						<Tooltip text="{ __('Insert Column Before') }">
							<button
								className='components-icon-button'
								onClick={ () => doInsert('col','before') }
								disabled={ buttonStates.disabled1 }
							>
								<Dashicon icon="table-col-before" />
							</button>
						</Tooltip>
						<Tooltip text="{ __('Insert Column After') }">
							<button
								className='components-icon-button'
								onClick={ () => doInsert('col','after') }
								disabled={ buttonStates.disabled2 }
							>
								<Dashicon icon="table-col-after" />
							</button>
						</Tooltip>
						<Tooltip text="{ __('Insert Row Before') }">
							<button
								className='components-icon-button'
								onClick={ () => doInsert('row','before') }
								disabled={ buttonStates.disabled3 }
							>
								<Dashicon icon="table-row-before" />
							</button>
						</Tooltip>
						<Tooltip text="{ __('Insert Row After') }">
							<button
								className='components-icon-button'
								onClick={ () => doInsert('row','after') }
								disabled={ buttonStates.disabled4 }
							>
								<Dashicon icon="table-row-after" />
							</button>
						</Tooltip>
					</Toolbar>
					<Toolbar>
						<Tooltip text="{ __('Delete Column') }">
							<button
								className='components-icon-button'
								onClick={ () => doDelete('col') }
								disabled={ buttonStates.disabled5 }
							>
								<Dashicon icon="table-col-delete" />
							</button>
						</Tooltip>
						<Tooltip text="{ __('Delete Row') }">
							<button
								className='components-icon-button'
								onClick={ () => doDelete('row') }
								disabled={ buttonStates.disabled6 }
							>
								<Dashicon icon="table-row-delete" />
							</button>
						</Tooltip>
					</Toolbar>
				</BlockControls>
				<InspectorControls>
					<PanelBody title="{ __('Table Options') }">
						<PanelRow>
							<label for='toggle-caption'>{ __('Include a Caption') }</label>
							<FormToggle
								id='toggle-caption'
								checked={ useCaption }
								onChange={ toggleCaption }
							/>
						</PanelRow>
						<PanelRow>
							<label for='toggle-col-headings'>{ __('Include Column Headings') }</label>
							<FormToggle
								id='toggle-col-headings'
								checked={ useColHeadings }
								onChange={ toggleColHeadings }
							/>
						</PanelRow>
						<PanelRow>
							<label for='toggle-row-headings'>{ __('Include Row Headings') }</label>
							<FormToggle
								id='toggle-row-headings'
								checked={ useRowHeadings }
								onChange={ toggleRowHeadings }
							/>
						</PanelRow>
						<PanelRow>
							<label for='toggle-footer'>{ __('Include a Footer') }</label>
							<FormToggle
								id='toggle-footer'
								checked={ useFooter }
								onChange={ toggleFooter }
							/>
						</PanelRow>
					</PanelBody>
				</InspectorControls>
				<table className={ className }>
					{ tableCaption }
					{ tableHead }
					{ tableBody }
					{ tableFooter }
				</table>
				<form className={ formClass }>
					<div>
						<label for='addCaption'>{ __('Add Caption') }</label>
						<input
							type='checkbox'
							id='captionCheck'
							checked={ useCaption }
							onChange={ function(evt) {
								props.setAttributes({ useCaption: evt.target.checked });
							}}
						/>
					</div>
					<div>
						<label for='useColHeadings'>{ __('Add Column Headings') }</label>
						<input
							type='checkbox'
							id='useColHeadings'
							checked={ useColHeadings }
							onChange={ function(evt) {
								if(evt.target.checked == true) {
									props.setAttributes({ useColHeadings: true });
								} else {
									props.setAttributes({ useColHeadings: false });
								}
							}}
						/>
					</div>
					<div>
						<label for='numCols'>{ __('Columns') }</label>
						<input
							type='number'
							id='numCols'
							value={ numCols }
							min='1'
							step='1'
							pattern='[0-9]*'
							onChange={ (evt) => props.setAttributes({ numCols: evt.target.value }) }
						/>
					</div>
					<div>
						<label for='useRowHeadings'>{ __('Add Row Headings') }</label>
						<input
							type='checkbox'
							id='useRowHeadings'
							checked={ useRowHeadings }
							onChange={ function(evt) {
								if(evt.target.checked == true) {
									props.setAttributes({ useRowHeadings: true });
								} else {
									props.setAttributes({ useRowHeadings: false });
								}
							}}
						/>
					</div>
					<div>
						<label for='numRows'>{ __('Rows') }</label>
						<input
							type='number'
							id='numRows'
							value={ numRows }
							min='1'
							step='1'
							pattern='[0-9]*'
							onChange={ (evt) => props.setAttributes({ numRows: evt.target.value }) }
						/>
					</div>
					<div>
						<label for='addFooter'>{ __('Add Footer') }</label>
						<input
							type='checkbox'
							id='footerCheck'
							checked={ useFooter }
							onChange={ function(evt) {
								props.setAttributes({ useFooter: evt.target.checked });
							}}
						/>
					</div>
					<button
						type='submit'
						onClick={evt => buildTable(evt) }
					>
						{ __('Insert Table') }
					</button>
				</form>
			</div>
		);
		// Function that builds the table when the form is submitted
		function buildTable(evt) {
			evt.preventDefault();
			// Only build the table and hide the form if there are rows and columns
			if(numCols > 0 && numRows > 0) {
				// Number of rows will always be numRows, because if useColHeadings is true, that extra row will be in the <thead>, not the <tbody>
				// But, number of columns will vary: if useRowHeadings is true, there should be 1 extra column
				// totalCols is used to build both the thead attribute array and the tbody attribute array
				let totalCols = numCols;
				if(useRowHeadings == true) {
					totalCols++;
				}
				// Build the thead attribute array
				let newHead = [];
				// If useColHeadings is true, add placeholders for the THs. If not, add nothing, because there should not be a thead at all.
				if(useColHeadings == true) {
					for(var i=0; i<totalCols; i++) {
						newHead[i] = { content: '' };
					}
				}
				// Build the tbody attribute array
				let newBody = [];
				for(var row = 0; row < numRows; row++) {
					let thisRow = { bodyCells: [] };
					for(var col=0; col<totalCols; col++) {
						thisRow.bodyCells[col] = { content: '' };
					}
					newBody[row] = thisRow;
				}
				// Save atts
				props.setAttributes({
					dataHead: newHead,
					dataBody: newBody,
					showTable: true
				});
			}
		}
		// Function that returns the cursor where it was, instead of the beginning of an input
		function setCursor(evt) {
			var node = evt.target;
			var caret = window.getSelection().anchorOffset;
			if(node.firstChild) {
				setTimeout(function() {
					let textNode = node.firstChild;
					var range = document.createRange();
					range.setStart(textNode, caret);
					range.setEnd(textNode, caret);
					var sel = window.getSelection();
					sel.removeAllRanges();
					sel.addRange(range);
				}, 1, node, caret);
			}
		}
		// Button Function: insert
		function doInsert(type, location) {
			let selectedRow = currentCell.row;
			// If there is a table head
			if(useColHeadings == true) {
				selectedRow--;
			}
			let selectedCol = currentCell.col;
			// If we are inserting after, add 1 to insert in the right place
			if(location == 'after') {
				selectedRow++;
				selectedCol++;
			}
			let endingRows = numRows;
			// 2 vars to track columns: allCols includes any potential THs; endingCols will be saved as numCols, and cannot contain THs
			let allCols = numCols, endingCols = numCols;
			// If row headings are enabled, add 1 extra cell to the row
			if(useRowHeadings == true) {
				allCols++;
			}
			let newBody = JSON.parse(JSON.stringify(dataBody));
			let newHead = JSON.parse(JSON.stringify(dataHead));
			if(type == 'row') {
				// First create a row
				let newRow = {
					bodyCells: []
				};
				for(var c = 0; c < allCols; c++) {
					newRow.bodyCells.push({ content: ''});
				}
				// Now insert the row (in this case splice isn't deleting anything because of the 0)
				newBody.splice(selectedRow, 0, newRow);
				// Increase the total number of rows
				endingRows++;
			} else if(type == 'col') {
				// Update the body
				for(var r = 0; r < endingRows; r++) {
					// Create a new cell
					let newCell = { content: '' };
					// Add the cell
					newBody[r].bodyCells.splice(selectedCol, 0, newCell);
				}
				// If there is a thead, update that too
				if(useColHeadings == true) {
					// Create a new cell
					let newTh = { content: '' };
					// Add the cell object
					newHead.splice(selectedCol, 0, newTh);
				}
				// Increase the total number of cols
				endingCols++;
			}
			props.setAttributes({
				dataBody: newBody,
				dataHead: newHead,
				numRows: endingRows.toString(),
				numCols: endingCols.toString()
			});
		}
		// Button Function: delete
		function doDelete(type) {
			let selectedRow = currentCell.row, selectedCol = currentCell.col,
			shouldShowTable = showTable, endingRows = numRows, endingCols = numCols,
			newBody = JSON.parse(JSON.stringify(dataBody)), newHead = JSON.parse(JSON.stringify(dataHead));
			if(type == 'row') {
				// If deleting the only row, set "showTable" to false, so only the form appears
				if(newBody.length == 1) {
					shouldShowTable = false;
				}
				// If there is a table head
				if(useColHeadings == true) {
					selectedRow--;
				}
				newBody.splice(selectedRow, 1);
				endingRows--;
			} else if(type == 'col') {
				endingCols--;
				// If deleting the only col, set "showTable" to false, so only the form appears
				if(newBody[selectedRow].bodyCells.length == 1) {
					shouldShowTable = false;
				}
				// Update the body
				for(var r = 0; r < endingRows; r++) {
					// Delete the cell
					newBody[r].bodyCells.splice(selectedCol, 1);
				}
				// If there is a thead, update that too
				if(useColHeadings == true) {
					// Delete the cell
					newHead.splice(selectedCol, 1);
				}
			}
			// Save the atts
			props.setAttributes({
				dataBody: newBody,
				dataHead: newHead,
				showTable: shouldShowTable,
				numRows: endingRows.toString(),
				numCols: endingCols.toString()
			});
		}
		// Enter Cell State to enable button functions
		function enterCellState(evt) {
			// Set enabled buttons
			buttonsToEnable = evt.target.dataset.buttons.split(',');
			let newButtonStates = {};
			for(let prop in buttonStates) {
				newButtonStates[prop] = true;
			}
			for(var b = 0; b < buttonsToEnable.length; b++) {
				let enableVar = 'disabled' + buttonsToEnable[b];
				newButtonStates[enableVar] = false;
			}
			// Set currently selected cell (convert row and column numbers to array keys - one less than the human-readable value in aria)
			let cellLabel = evt.target.getAttribute('aria-label');
			let cellCoords = cellLabel.split(' ');
			let cellRow = parseInt(cellCoords[1], 10)-1;
			let cellCol = parseInt(cellCoords[3], 10)-1;
			props.setAttributes({
				buttonStates: newButtonStates,
				currentCell: { row:cellRow, col:cellCol }
			});
		}
		// Exit Cell State to disable button functions
		function exitCellState() {
			// Disable all buttons by building a new object with every property set to true (disabled)
			let newButtonStates = {};
			for(let prop in buttonStates) {
				newButtonStates[prop] = true;
			}
			props.setAttributes({
				buttonStates: newButtonStates
			});
		}
		// Inspector - toggle caption
		function toggleCaption() {
			if(useCaption == false) {
				props.setAttributes({ useCaption: true });
			} else {
				props.setAttributes({ useCaption: false, dataCaption: '' });
			}
		}
		// Inspector - toggle col headings
		function toggleColHeadings() {
			if(useColHeadings == false) {
				// If the table has been built already, build the thead array and set attributes with it
				if(showTable == true) {
					// Just like in our Build Table function, if row headings is true, we need 1 extra column beyond the data-columns
					let totalCols = numCols;
					if(useRowHeadings == true) {
						totalCols++;
					}
					// Build the thead attribute array
					let newHead = [];
					for(var i=0; i<totalCols; i++) {
						newHead[i] = { content: '' };
					}
					props.setAttributes({
						useColHeadings: true,
						dataHead: newHead
					});
				}
				// Else, the table has not been built yet and the form is showing, so only update useColHeadings
				else {
					props.setAttributes({ useColHeadings: true });
				}
			} else {
				props.setAttributes({
					useColHeadings: false,
					dataHead: []
				});
			}
		}
		// Inspector - toggle row headings
		function toggleRowHeadings() {
			if(useRowHeadings == false) {
				// If the table has been built already
				if(showTable == true) {
					// Similar to our Do Insert function for a column
					let endingRows = numRows;
					let newBody = JSON.parse(JSON.stringify(dataBody));
					let newHead = JSON.parse(JSON.stringify(dataHead));
					// Update the body
					for(var r = 0; r < endingRows; r++) {
						// Create a new cell
						let newCell = { content: '' };
						// Add the cell
						newBody[r].bodyCells.splice(0, 0, newCell);
					}
					// If there is a thead, update that too
					if(useColHeadings == true) {
						// Create a new cell
						let newTh = { content: '' };
						// Add the cell
						newHead.splice(0, 0, newTh);
					}
					// Set Atts
					props.setAttributes({
						useRowHeadings: true,
						dataBody: newBody,
						dataHead: newHead
					});
				}
				// Else, the table has not been built yet and the form is showing, so only update useRowHeadings
				else {
					props.setAttributes({ useRowHeadings: true });
				}
			} else {
				// If the table has been built already
				if(showTable == true) {
					let endingRows = numRows;
					let newBody = JSON.parse(JSON.stringify(dataBody));
					let newHead = JSON.parse(JSON.stringify(dataHead));
					// Update the body
					for(var r = 0; r < endingRows; r++) {
						// Remove the first cell
						newBody[r].bodyCells.splice(0, 1);
					}
					// If there is a thead, update that too
					if(useColHeadings == true) {
						// Remove the first cell
						newHead.splice(0, 1);
					}
					// Set Atts
					props.setAttributes({
						useRowHeadings: false,
						dataBody: newBody,
						dataHead: newHead
					});
				}
				// Else, the table has not been built yet and the form is showing, so only update useRowHeadings
				else {
					props.setAttributes({ useRowHeadings: false });
				}
			}
		}
		// Inspector - toggle footer
		function toggleFooter() {
			if(useFooter == false) {
				props.setAttributes({ useFooter: true });
			} else {
				props.setAttributes({ useFooter: false, dataFooter: '' });
			}
		}
	},
	//////////////////// SAVE ////////////////////
	save: props => {
		const { attributes: { dataBody, dataCaption, dataFooter, dataHead, useCaption, useColHeadings, useFooter, useRowHeadings }, className } = props;
		let numCols = parseInt(props.attributes.numCols, 10);
		let numRows = parseInt(props.attributes.numRows, 10);
		// Caption
		let tableCaption;
		if(useCaption === true) {
			tableCaption = <caption>{ dataCaption }</caption>
		}
		// Table Head
		let tableHead;
		if(useColHeadings == true) {
			const tableHeadData = dataHead.map(function(cell, colIndex) {
				return (
					<th scope='col'>{ cell.content.trim(' ') }</th>
				);
			});
			if(tableHeadData.length) {
				tableHead = <thead><tr>{ tableHeadData }</tr></thead>;
			}
		}
		// Table Body
		let tableBody, tableBodyData = dataBody.map(function(rows) {
			let eachRowCells = rows.bodyCells.map(function(cell, colIndex) {
				if(useRowHeadings == true && colIndex == 0) {
					return <th scope='row'>{ cell.content.trim(' ') }</th>
				} else {
					return <td>{ cell.content.trim(' ') }</td>
				}
			});
			return <tr>{ eachRowCells }</tr>;
		});
		if(tableBodyData.length) {
			tableBody = <tbody>{ tableBodyData }</tbody>;
		}
		// Table Footer
		let tableFooter;
		// Calculate colspan: if useRowHeadings is true, there should be 1 extra column
		let totalCols = numCols;
		if(useRowHeadings == true) {
			totalCols++;
		}
		if(useFooter == true) {
			tableFooter = <tfoot><tr><td colspan={ totalCols }>{ dataFooter }</td></tr></tfoot>
		}
		// Final Return
		return (
			<table className={ className }>
				{ tableCaption }
				{ tableHead }
				{ tableBody }
				{ tableFooter }
			</table>
		);
	}
});

First off, notice lines 6 and 7. Instead of just including Dashicons, we’re now pulling in a bundle of new Block Editor components, which are like little building blocks. Using these will make development a little faster, and it will also ensure that as we add components, they get the WP Core CSS classes automatically applied, so they’ll look like the rest of the UI, no matter how that may change in the future.

Okay. Here come the last of the attributes. Lines 15-25 define one called buttonStates – an object this time. We’re using the numbers 1 through 6 to refer to the specific buttons we’ll need to enable in shorthand, and this will make it easy to loop through them later.

currentCell on lines 26-32, another object, gives us a quick way to identify which cell is currently selected.

We update line 106 to reference these two new attributes, and we create a new variable on line 109: buttonsToEnable. Now we’re ready to set up two new functions: enterCellState(), which fires when a user selects a table cell and saves its location to the currentCell attribute plus saves buttonStates to keep track of which buttons to enable; and exitCellState(), which fires when the user selects a caption or footer (where they will not be able to use any of the toolbar buttons) and sets all of the buttonStates to disabled.

Lines 119-121 call exitCellState() if a caption is present and the user focuses inside it.

Line 141 sets up the default buttons, and 142-145 update them if the very first <th> of the <thead> is selected. Lines 150-153 are new: they set a data-attribute to hold the numbers of the buttons that should be enabled when they’re selected, and fire enterCellState() on focus to capture the cell.

Lines 188-189 set up the same type of data-attribute and event on the <tbody> cells.

Lines 235-237 call exitCellState() when the footer gets focus, which disables all of the toolbar buttons.

And now we come to a bit more code we haven’t seen before. Lines 251-310 add our new toolbar – starting with a BlockControls; element. This wraps around our new Toolbar, which in turn wraps around all of our buttons. The structure continues to be wrapper around wrapper: inside the Toolbar, each button is wrapped by a Tooltip so that when you hover over the button, you see a text label.

The buttons themselves have onClick events that fire their individual functions when the button is pressed, and they have a disabled attribute so if the currently selected cell doesn’t allow you to use the button, the browser will prevent anything from happening when you try to press the button. Finally, we nest a Dashicon inside each button, which pulls in a specific icon from WP Core. (My hope is that these may get a little clearer in the future, but you could also replace the Dashicons with custom SVGs if you so desire.)

That takes care of the Toolbar. Next, lines 311-346 define this block’s InspectorControls. InspectorControls always contain a PanelBody, which in turn can contain PanelRows to make it easier to contain each item on one row. Each of our rows contains a label with a corresponding toggle, which is a nice little UI component for booleans.

Now for the functions that the buttons actually fire. Stick with me here – you’re almost to the end, and the first function is the most complicated.

Insert Cells

Lines 492-551 handle inserting. The function takes two arguments: a type (which will be either “row” or “column”) and a location (which will be either “before” or “after”).

First, we determine what cell to use as a reference point by checking the currentCell attribute and adjusting the row if Column Headers are enabled. Next, if we’re inserting after the current cell, we add 1 to the coordinates.

We create a variable called endingRows to store the final number of rows the table will contain after our operation is complete. And we set up two variables to track columns: allCols includes every cell – even <th>s, and endingCols contains just the number of <td>s. On line 510, if Row Headers are enabled, we add an extra cell to allCols.

Next, we use our old JSON stringify – then JSON parse – trick to make copies of the dataBody and the dataHead that we can manipulate as needed.

Lines 514-525 fire if we’re inserting a row. If you’ll recall our brief painful discussion of math, we only allow rows to be added in the <tbody>, so only newBody is affected. We create a new row, push empty cell objects into it, splice it into the newBody array at the correct location – splice() in JavaScript can add, delete, or add and delete items in an array – and track that additional row in our endingRows variable.

Lines 526-543 fire if, instead, we’re inserting a column. Columns are added in both the <tbody> and the <thead>, so both newBody and newHead are affected (if Column Headers are enabled and we have a <thead>). We use the same splice operation to add the new cells in both places.

Finally, lines 544-549 update the actual attributes. You may notice that both numRows and numCols require us to convert our numbers back into strings, since the attributes are set up to hold strings. I really would like to fix that and have them remain native numbers throughout.

Delete Cells

Lines 551-596 handle deleting. The function takes one argument: a type (which will be either “row” or “column”).

First, we set up a bunch of reference variables, like the selected row and column, the number of rows and columns we’ll end up with after the cells are deleted, our newBody and newHead, and a new variable called shouldShowTable that starts out with the same value as the showTable attribute.

Lines 556-566 fire if we’re deleting a row. This is where we use shouldShowTable – if we’re deleting the only row in the table, we set that variable to false, so that later when we set all the attributes, showTable will become false and the form will display again. We go back to the math that says if there’s a table head, our selectedRow has to be one less than we thought it was, and then we use splice to delete the row. Finally, we reduce the number of endingRows by 1.

Lines 567-583 fire if we’re deleting a column. Just like insertion, deleting columns affects both the <tbody> and the <thead>. And just like deleting rows, if we’re deleting the only column, we set shouldShowTable to false. Next, we splice the body, then we splice the thead, and finally we reduce the number of endingCols by 1.

Lines 584-591 update the attributes, and again, we deal with the number-to-string conversion.

Enter Cell State

Our enterCellState() function takes just one argument – the event. (Reminder: event is a reserved keyword, so we’re referring to it again as evt.)

First, we pull the numbers of the buttons that need to be enabled for the current cell right out of its data-attribute. The split() function separates these numbers by comma into an array. Once again we can’t update an array directly, so our for loop on lines 598-600 makes a copy of every property of the buttonStates attribute and sets them all to true (which means they are all disabled). The second for loop on lines 601-604 sets any of the buttons that were in that data-attribute to false, meaning now just those buttons are enabled.

Next, we save the currently selected cell to our currentCell attribute, making sure to parse our strings into real integers and then subtract one, because the numbers we’re pulling are the human-readable aria labels that start with 1, and we need the array-key index that starts with 0.

Finally, we update our two attributes.

Exit Cell State

exitCellState() is a bit simpler: all it has to do is disable every button. We copy all the props of buttonStates into a fresh new array, and we set them all to true – meaning they’re all disabled. And we update the buttonStates attribute.

Inspector: toggle the Caption

toggleCaption() on lines 626-633 is a fairly straightforward function. If the useCaption attribute is false, it gets updated to true. If the useCaption attribute is true, it gets updated to false, and the dataCaption attribute is reset to an empty string. (If the user has a caption full of data, removes the caption, and wants to restore their data, they have a Redo button – also accessible by keyboard control-Z – baked right into Core to handle their dilemma.)

Inspector: toggle Column Headers

toggleColHeadings() is a bit more complicated. If the headers are currently off, we check to see whether the table has been built already. If it has, we calculate the correct number of total columns and update both the useColHeadings attribute (to “true”) and the dataHead attribute (with our placeholders). If it has not, we can just set useColHeadings to true and wait for the buildTable() function to create the placeholders later.

Otherwise – if headers are currently on – we simply set useColHeadings to false and set the dataHead to an empty object.

Inspector: toggle Row Headers

toggleRowHeadings() is where we get into the last of the math. If useRowHeadings is false, and the table has already been built, we update not only the useRowHeadings attribute but also the dataBody and the dataHead with new placeholders. If it hasn’t been built, updating just useRowHeadings is enough.

Lines 699-727 handle things if useRowHeadings is true. If the table has already been built, we splice the newBody and the newHead and update those along with setting useRowHeadings to false. If it hasn’t been built, updating just useRowHeadings is enough.

Inspector: toggle Footer

Last but not least, our toggleFooter() function is much like our toggleCaption() function. If the footer wasn’t there, we set useFooter to true. If it was there, we set useFooter to false and also set dataFooter to an empty string.

The final save

Because all of our operations affect only the edit function, we don’t have to update the save function. Adding or removing cells, or toggling captions or footers on and off, won’t change any of the logic in save. So, we have come to the end of the tutorial, and we now have a usable block.


Conclusion

I have learned in web development that you’re never really done. There are always new things to try, code that can be improved, updates that change everything. And having someone with another perspective look over your work can result in a ton of modifications for the better. So as I’ve said throughout this article, I absolutely do welcome pull requests and suggestions on how to make this code even better for more people.

The string attributes that hold numbers seem odd but don’t affect the end user; there may be a more elegant way to handle the cursor problem; I’d love to simplify the dataBody attribute. I’m sure there are also ways the block could be made even more usable and accessible, and I don’t claim to have all the answers.

But I hope this tutorial has helped you learn more about how Block Editor blocks are built and given you some food for thought on what you might be able to improve on.

Leave a Reply

Your email address will not be published. Required fields are marked *