Table with Expando Rows

I regularly work on projects with HTML tables that have been pushed to the edge with styles, scripts, and widget features. A common pattern is where rows are hidden until the user opts to show them. Unfortunately, the pattern is often over-complicated with unnecessary script and styles that regularly break the table semantics or fail to work across all contexts.

Typically there are only two things you as an author need to do for a row expando to retain table semantics (other than using a contiguous <table>, of course):

  1. Make sure you use the table-row CSS display property instead of block, and
  2. Use a disclosure widget as your toggle.

Example

You can visit the pen directly to fiddle with it, or view in debug mode to test it in your favorite AT (assistive technology).

Note this example is not responsive.

See the Pen Table with Expando Rows by Adrian Roselli (@aardrian) on CodePen.

Disclosure Widget

The disclosure widget is a native HTML <button>. If you read my post Link + Disclosure Widget Navigation, it goes into more detail than I will here.

Much like that example, the accessible name is concatenated from its own aria-label value and the text value in the relevant cell to provide a more natural name (eg: [n] more items from [author]). The aria-expanded value tells assistive technology whether the button has been expanded or not, and is also the hook we use for the styling.

[…]
    <tr>
      <td>
        <button type="button" id="btnMSb" aria-expanded="false" onclick="toggle(this.id,'#MS01b,#MS02b,#MS03b');" aria-controls="MS01b MS02b MS03b" aria-label="3 more from" aria-labelledby="btnMSb lblMSb">
          […]
        </button>
      </td>
      <td id="lblMSb">Mary Shelley</td>
[…]
    </tr>
[…]

The aria-controls value is a space-separated list of the ids of the rows you are affecting. Where aria-controls is supported, confirm that its announcement in screen readers (JAWS only today) is not too verbose for your users. If it is, you may want to remove it altogether. Its absence won’t result in a barrier, but it may make the widget easier to use for some users when useful screen reader support starts to appear.

The CSS

The easiest way to ensure the programmatic state of the button matches the visual styles is to use attribute selectors such as button[aria-expanded="true|false"]. The styles for both example tables only change the SVG.

.cell button[aria-expanded="true"] svg {
  transform: rotate(90deg);
}
.row button[aria-expanded="true"] svg {
  transform: rotate(180deg);
}

Your own styles will likely vary, of course.

The Script

The function is nothing special. It takes a list of ids and feeds them into a query selector, and it takes the id of the button as well. It checks then flips the aria-expanded value on the button while swapping the value of the class between shown and hidden.

If I could use adjacent sibling selectors (keying off the aria-expanded value) to toggle the visibility of the rows I would. The HTML table structure precludes that, which is the only reason I am using a class to do the work.

This is not production-ready script. It does, however, take Internet Explorer into account by not using classList.replace, which IE does not support.

function toggle(btnID, eIDs) {
  // Feed the list of ids as a selector
  var theRows = document.querySelectorAll(eIDs);
  // Get the button that triggered this
  var theButton = document.getElementById(btnID);
  // If the button is not expanded...
  if (theButton.getAttribute("aria-expanded") == "false") {
    // Loop through the rows and show them
    for (var i = 0; i < theRows.length; i++) {
      theRows[i].classList.add("shown");
      theRows[i].classList.remove("hidden");
    }
    // Now set the button to expanded
    theButton.setAttribute("aria-expanded", "true");
  // Otherwise button is not expanded...
  } else {
    // Loop through the rows and hide them
    for (var i = 0; i < theRows.length; i++) {
      theRows[i].classList.add("hidden");
      theRows[i].classList.remove("shown");
    }
    // Now set the button to collapsed
    theButton.setAttribute("aria-expanded", "false");
  }
}

At page load, the rows in my example are not hidden with inline CSS. Whether you hide or display them on initial load in the name of Progressive Enhancement is up to you and your use case. Either one can be a valid approach, but account for it in your function as well.

Tables

I made two tables so you could see two ways this might work. The full-row disclosure widget shows the text that is also announced to screen readers, and it provides a much larger hit area. As a column-spanning cell it can complicate table navigation for novice screen reader users but is much easier to find — you can stumble across it from any column.

The other example has the disclosure widget in its own column, arguably making it easier to avoid. It also warrants its own column header. The value is Toggle and I use a well-tested technique to visually hide it (partly because NVDA does not support the abbr attribute on <th>). See the visually-hidden class to steal the styles.

The HTML for the row is not complex. Just an id and a class, with the latter toggled via script.

    <tr id="EDENS02b" class="hidden">
      <td></td>
      <td>Emma Dorothy Eliza Nevitte Southworth</td>
      <td>Unknown; or the Mystery of Raven Rocks</td>
      <td>1889</td>
      <td></td>
      <td></td>
    </tr>

The CSS

The CSS for the two classes that adjust the display of the row is critical. If you use display: block instead of display: table-row then the browser drops all the semantics for the row and assistive technology cannot navigate it. See my post Tables, CSS Display Properties, and ARIA for more detail.

tr.shown, tr.hidden {
  background-color: #eee;
  display: table-row;
}

tr.hidden {
  display: none;
}

Screen Readers

Note that while a screen reader will not announce how many new rows are added (hence the accessible name to manage that expectation), once the new rows are visible the screen reader factors them into the total row count and user’s position within the table.

It is also important to ensure any new rows you add come after the control that makes them appear in the source order. Otherwise a screen reader use cannot be expected to know where they have appeared, let alone navigate around the table looking for them.

I have embedded some videos showing how a screen reader user might navigate the expando feature.

JAWS / IE11

Using JAWS 2018 and Internet Explorer 11 to navigate the table with a disclosure widget that lives in a single cell. Note that JAWS is visually highlighting the first cell in a row that has content, not the blank cells. It also does not highlight the buttons.

NVDA / Firefox

Using NVDA 2019 and Firefox 69 beta to navigate the table with a disclosure widget that lives in cell that spans all columns. NVDA only visually highlights the interactive controls (the red border around the spanning button).

VoiceOver / Safari

Using VoiceOver and Safari on macOS 10.14.5 to navigate the table with a disclosure widget that lives in a single cell. You may notice that the <caption> disappears when the disclosure is opened (it moves to below the table); I have no idea why and I have not taken time to dive into it.

Update: 19 April 2020

Last week someone on Twitter proposed that it was impossible to animate a new row in a table and have it extend beyond the width of the table itself. This post was referenced in another tweet to show one approach.

Separately, the @keyframers made a pen to show their approach and dedicated an hour long YouTube video. To their credit, they kept the table semantics. I forked their pen (embedded below) and adjusted the buttons to act as true disclosure widgets and to ensure the newly-shown row would have correct a column header.

See the Pen Flipping Tables | Table animation challenge using FLIP | @keyframers 3.0 by Adrian Roselli (@aardrian) on CodePen.

Update: 9 May 2020

I evaluated a pattern recently that used aria-rowindex to try to artificially reposition rows inserted at the end of the table DOM into a position much earlier in the table by providing a value much lower than its actual position. Similarly, the value of aria-rowcount was being changed to reflect the new total number of rows once more were disclosed or hidden.

These are both wrong uses. These properties are intended to be used with tables that are paged, showing only a few rows at a time from a larger set. You use aria-rowcount to identify the total number of rows in a table, including those not yet available. You would use aria-rowindex to identify the current position within that total number of rows across pages.

If you have 83 results in a search and you show 20 per page, then the table would have aria-rowcount="84" (one for the header row). The third row on the third screen would have aria-rowindex="42" (because the first row is the header row).

The good news is that for the tables in this example you can ignore all this. If your users are having trouble keeping track in larger or more complex tables, then it may be worth trying these properties. If you have paged tables with expando rows, and you use these properties, then you will need to do sufficient testing with users to identify if accounting for the hidden rows in your aria-rowindex values is more of a hindrance or help.

From my own testing with paged tables that tell you how many results per page and how many total pages or results, then even with expando rows you can ignore aria-rowcount and aria-rowindex altogether.

Update: 29 September 2020

Léonie Watson has just posted How screen readers navigate data tables where she walks through a sample table to get some information, explaining each step, the keyboard commands, and the output. She also links to a video demonstration, which I have embedded below.

Watch How screen readers navigate data tables at YouTube.

Update: 2 December 2023

In the comments I link demos of a table with nested tables hidden behind expando buttons and multiple nested tables with additional content. Both are valid but annoying to many users.

It turns out that using the just-released JAWS 2024 with Chrome or Edge they cannot be navigated (using JAWS with Firefox is fine). Darrell Hilicker first identified it and I confirmed it. So I went to the public JAWS issue tracker and filed #791 Unable to navigate nested tables in JAWS.

If you have projects that use nested tables, I encourage you to go over to the issue and give it your thumb. It might help motivate them (especially since NVDA does not have this problem).

13 Comments

Reply

This is good when the data in the expanded row is in the same data structure as the other data; frequently I see expanded rows where its all of the other data for that entry that they couldn’t fit on the table (or in a responsive design where they can’t fit all the columns in the view).

Laura F; . Permalink
In response to Laura F. Reply

This pattern cannot fix terrible information architecture. I did, however, get forced to making a table with nested tables hidden behind expando buttons and multiple nested tables with additional content. Both are technically accessible, but still a terrible idea.

Reply

Perhaps I’ve misread the release changes in 2019, but hasn’t JAWS essentially dropped support for aria-controls?

“If you encounter an element on a web page with a defined ARIA controls relationship, JAWS will no longer say ‘use JAWSKEY+ALT+M to move to controlled element’ by default. In most cases, the target of the controls relationship is adjacent to the element or does not provide any useful information.”

Peter Weil; . Permalink
In response to Peter Weil. Reply

Peter, it has (per the 13th bullet in the release notes). You hear use JAWS Key + ALT + M to move to controlled element in my video (10 second mark) because I am still running JAWS 2018. If JAWS brings it back and others later support it, I am hoping they do a better job than JAWS did.

Reply

Why do we set the display value for the .shown class? It’s tr’s default value.

Mika T; . Permalink
In response to Mika T. Reply

Great question! I did a poor job of explaining why I am doing it this way because you are right, you do not need to add the shown class. Readers can test this by commenting out lines 10 and 20 in the JavaScript and see the widget works fine.

However, I used it for two reasons:

  1. to demonstrate that display: table-row; is the value to use over display: block; if you are messing with the display properties, and
  2. to provide a hook for the background color to make it easier to visually distinguish the now-visible rows.

Obviously if you want simpler code and/or you come up with a better approach that still retains the row semantics, then by all means ignore my code.

Reply

In my case, it seems that CSS ‘button’ for example “.row button {…}” conflicts with other plugins that requires no meddling with button’s styles.

Would it be possible to add a class to the button when defining and have CSS changed to:
.row expBtn { …}
.row expBtn svg {…}
.row expBtn[aria-expanded=”true”] svg {…}
…etc

i tried this, but SVG is not working. would appreciate your help.

In response to aj. Reply

aj, without seeing it at a URL, I cannot debug it. However, in the CSS in your comment, the selector is missing a leading dot to indicate it is a class. Try this:

.row .expBtn { …}
.row .expBtn svg {…}
.row .expBtn[aria-expanded="true"] svg {…}
In response to Adrian Roselli. Reply

Brilliant Adrian! Your suggestion works and really appreciate it.

Reply

Hi Adrian. How are you?

Thanks so much for the tests and and sharing your insights. I’m currently also looking into the disclosure pattern inside tables, especially related to the aria-rowindex issue.

While I agree that user testing is the only way to know for sure, I am assuming that the ARIA standards are based on such, hence have full confidence in them.

I’m wondering about your statement:

> These are both wrong uses. These properties are intended to be used with tables that are paged

What leads you to relate this attribute to pagination only? I read the ARIA docs several times now, to understand better, and to me their explanation fits perfectly to the disclosure pattern as well (on aria-rowindex):

> However, if only a portion of the rows is present in the DOM at a given moment, this attribute is needed to provide an explicit indication of each row’s position with respect to the full table.
https://www.w3.org/TR/wai-aria-1.1/#aria-rowindex

While testing my code pen, https://codepen.io/andypillip/pen/xxrxVVV, I realise that the indication of row-indices with respect to the whole table, which jump from row 19 to 25, can help considerably convey the nature and impact of the disclosure.

Any insights from screen reader users?

Btw I just got informed that the working group is discussing these very attributes this week, since they are not understandable enough.

In response to Andy. Reply

Andy, I made that statement for two reasons:

  1. aria-rowindex should not be used to try to reposition rows and the spec does not allow for that; and
  2. in testing, screen readers did fine adapting the row count and position.

Your example shows in action how aria-rowindex can be used, and is potentially useful for cases where the table is full of collapsed data.

However, that example is a structure I would not use in real life. I would not have spanning cells that have no relation to the column headers, I would not have everything collapsed by default, and I would not allow for buttons that trigger nothing. I understand it is just a demo, so the construct is necessarily atypical.

In testing with users (banking context, skilled and advanced SR users), user feedback was that they did just fine since screen readers adjusted to new rows being added to the DOM.

Instead, since users had no intention of expanding collapsed content at every row, any unexpected jump in row numbering was confusing.

They found value in the aria-rowindex giving them their relative position in the context of a paged set of results. However, since the pagination controls already gave them that context, they only found value in aria-rowindex when we added it and then asked for feedback.

As for your statement here:

While I agree that user testing is the only way to know for sure, I am assuming that the ARIA standards are based on such, hence have full confidence in them.

I can only caution you that a quick wander through the ARIA GitHub suggests that, like any standards process, testing isn’t always done or even possible (such as this comment on #558 or the related comment on #1602 last week). And this is just for the ARIA specification. This does not address the lack of testing in non-specification documents, such as the ARIA Authoring Practices.

Reply

Thanks for the write-up Adrian! A question: have you seen this also implemented with the APG treegrid pattern? Any concerns or considerations that would make one or the other the best pattern for the job?

In response to Robin Métral. Reply

Robin, a treegrid is a completely different pattern. For one, it is a composite widget meaning it is a single tab-top on the page and all nodes are potentially interactive. Another thing that makes it different is that it generally does not work (support is poor). The APG example has a few open issues that speak to that (never mind the big warning at the top of the APG page essentially saying not to use it). All of this might explain why I have not seen it in the wild.

Leave a Comment or Response

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>