coding character sheets

JavaScript Example: Random Tables

Paul Stefko
Apr 1, 2022
We walk through an example of using JavaScript to add a little interactivity to your page: a table that randomly selects one of its rows when you click it.
📕📕 8 min.

The time has come. We're going to walk through a small example script that does something useful for GMs and designers: implement an HTML table that will select a random entry whenever you click. Grab the example from GitHub and follow along.

HTML

First, let's look at the HTML file for the example. It's a very barebones page. The <head> includes some boilerplate metadata, a <title>, and the JavaScript and CSS files linked in.

<title>Random Table</title>
<link rel="stylesheet" href="table.css">
<script src="table.js" defer></script>

In the <body>, we have two <table> elements, "random-table-1" and "random-table-2". Those id attributes are important, so keep them in mind.

In the <table> tag for "random-table-2", we see another attribute, data-weighted. What does that mean? It's a custom data attribute which we can reference with JavaScript later. This attribute is a boolean (true/false) attribute, and HTML assumes that an attribute name without a value defaults to "true."

<table id="random-table-1" data-weighted>

Similarly, each <tr> in "random-table-2" has a custom data-weight attribute, but these have a number value listed. This number is how we calculate weighted probability in the advanced form of the tool.

<tr data-weight="15">

CSS

In the CSS file, we have a few rules just for making the example look a little neater, but the important styles start at the following:

table[id^="random"] {
cursor: pointer;
border-collapse: collapse;
}

That selector is weird, right? What it means is "select each <table> element with an id attribute that starts with 'random'." This rule makes the mouse cursor turn into a pointer to indicate that the table is clickable. (It also removes the gaps in our table that would be filled by borders, but that's just cosmetic.)

The next couple styles are also cosmetic, and they use some advanced selector tricks that might not be clear. :first-of-type is what's called a "pseudo-class." This keyword tells CSS to only select the element if it is the first of its siblings of that type. Similarly, :not() tells CSS to negate the criterion inside the parentheses. So that will select the element only if it is not the first sibling of its type.

The final two styles are what we really care about for our example. Our script is going to add and remove the highlight and gray-out classes to indicate which row was selected. (highlight doesn't actually give any styling right now, but you're free to customize it for your design.)

JavaScript

Okay, here we go. We start with the highlightRow() function, which is the heart of our script. It takes one argument, an Element object. We find all <td> cells and store them in the allCells variable. Then we find all <tr> rows inside the <tbody> and store them in the bodyRows variable. (We use const because these values won't change once we set them.)

const allCells = table.querySelectorAll("td");
const bodyRows = table.querySelectorAll("tbody tr");

Next, we pick a random number rowNum between zero and the number of body rows minus one. (We use that range because arrays start at 0 in JavaScript.)

let rowNum = Math.floor(Math.random() * bodyRows.length);

If the table isn't weighted, that's number we'll use. But if the table has the data-weighted attribute, we have to pick a number based on the weights. We check for that using the dataset property of the table element, which has a weighted property if the element has the data-weighted attribute.

if (table.dataset.weighted) {

Inside that block, we set up a couple variables. totalWeight will keep a tally of the weights as we go through the body rows. entries will keep a list of each entry's position and weight. For now, they're 0 and an empty array, respectively. (We use let here because these values will change.)

let totalWeight = 0;
let entries = [];

Now we'll cycle through all of the rows using the forEach() method on bodyRows. This method takes a callback function as its argument, and we use an arrow function here. forEach() will run that function for each element in the list.

The callback function takes two arguments, the row element we're working on el and the index of that element in the list of rows. Then we check if el has a data-weight attribute using the same dataset property:

bodyRows.forEach((el, index) => {
if (el.dataset.weight) {

If it has data-weight, we add that weight to the totalWeight. (We divide el.dataset.weight by 1 because it's actually a string but we want to convert it to a number.) Then we add an element to the entries array using the push() method. This element is a custom object with a row property equal to index and a ceiling property equal to totalWeight. (We indicate that it's an object by wrapping those properties in curly braces {}. This is called an object literal.)

totalWeight += el.dataset.weight/1;
entries.push({
row: index,
ceiling: totalWeight
});

Once we do that for each weighted row, we need to select a new row. First, we generate a random number rand between zero and totalWeight minus one. Then we find the first row with a ceiling property greater than rand and assign its row property to rowNum.

let rand = Math.floor(Math.random() * totalWeight);
rowNum = entries.find(el => el.ceiling > rand).row;

Now we have a randomly selected row, one way or another. First, we'll gray out every row in our table by removing the highlight class and adding the gray-out class to every <td> cell. Then, for every cell in our selected row, we remove gray-out and add highlight.

allCells.forEach(el => {
el.classList.remove("highlight");
el.classList.add("gray-out");
});
bodyRows[rowNum].querySelectorAll("td").forEach(el => {
el.classList.remove("gray-out");
el.classList.add("highlight");
});

Thus endeth highlightRow(). Next we have a quick function clearTable() that just removes both highlight and gray-out from each cell in a table.

function clearTable(table) {
table.querySelectorAll("td").forEach(el => {
el.classList.remove("highlight","gray-out");
});
}

The last two bits of the script are complicated, but let's walk through them. First, we are going to add an event listener to each table in our page with an id attribute that starts with "random". (Told you that would be important.) Clicking on such a table will run highlightRow() with the clicked table as the argument.

document.querySelectorAll("table[id^=\"random\"]").forEach(table => 
table.addEventListener("click",ev => highlightRow(ev.currentTarget));
);

Finally, we add another event listener, this time to every <td> cell in each table with an id attribute that starts with "random". When you click such a cell, if it has the highlight class, the script runs clearTable() with the cell's table as the argument.

document.querySelectorAll("table[id^=\"random\"] td").forEach(cell => {
cell.addEventListener("click", ev => {
if (ev.currentTarget.classList.contains("highlight")) {
clearTable(ev.currentTarget.closest("table"));
ev.stopPropagation();
}
})
});

The stopPropagation() method is there because events normally "bubble up" through the DOM. That means, if you click an element, the click event triggers on that element, but then it triggers on that element's parent, then the parent's parent, and so on, all the way back to the base of the tree. stopPropagation() prevents this, so clicking on a selected row to clear the table doesn't then proceed to select a new row (via the click event on the table calling highlightRow()).

Sample

Here's a sample of the tool at work. Click the table to select an entry at random. Click that entry to clear the selection.

Roll (1d6) Result
1 No effect
2 Minor Effect
3 Significant Effect
4 Major Effect
5 Extreme Effect
6 Two Major Effects

Conclusion

There you have it: a simple tool for adding a bit of interactivity to your page with JavaScript. I encourage you to play around with it, add styling, maybe even find a way to select multiple rows at once. I'll present more examples like this in the future, but for now, have fun!