DEV Community

Tryggvi Gylfason
Tryggvi Gylfason

Posted on • Edited on

Building Accessible Grids 2

Part 1:


Here's what we'll build in this post. If you are reading on your phone I'm sorry, this wont work! This time we are focusing (😏) on keyboard support.

It's a playlist!

Let's quickly recap the first post. We want to build a composite grid component that can be skipped with a single Tab key press and has keyboard support. Pressing an arrow key will navigate to the next interactive element.

Let's build the playlist HTML.

<div class="grid" role="grid" tabindex="0">
  <!-- header row -->
  <div class="grid__header-row" role="row" aria-rowindex="1">
    <div role="columnheader" aria-colindex="1">
      <button tabindex="-1">Title</button>
    </div>
    <div role="columnheader" aria-colindex="2">
      <button tabindex="-1">Album</button>
    </div>
    <div role="columnheader" aria-colindex="3">Duration</div>
  </div>
  <!-- regular row -->
  <div class="grid__row" role="row" aria-rowindex="2">
    <div role="gridcell" aria-colindex="1">
      <div>Black Parade</div>
      <a href="#" tabindex="-1">BeyoncΓ©</a>
    </div>
    <div role="gridcell" aria-colindex="2"></div>
    <div role="gridcell" aria-colindex="3">
      4:41
      <button class="heart" tabindex="-1">
        <span class="sr-only">Add to your liked songs</span>
        β™‘
      </button>
    </div>
  </div>
  <!-- more rows ... -->
</div>
Enter fullscreen mode Exit fullscreen mode
  • 2 out of 3 headers are buttons. Perhaps they are sortable.
  • First column TITLE contains the song name and list of artists.
  • Second column ALBUM contains the album link if any (songs can be singles)
  • Third column DURATION contains the song duration but also a heart button to add the song to your liked songs. The heart should appear when the row is focused/hovered. When invisible it should still be able to receive focus!

The key hierarchy of selectors is this

div[role="grid"]
└── div[aria-rowindex]
    └── div[aria-colindex]
        └── a, button
Enter fullscreen mode Exit fullscreen mode

From any given button or link, we can traverse up its parent chain in the DOM and find which row and column index it belongs to. Let's move focus based on the current focus and the row and column indexes.

The first thing we have to do is remove all the buttons and links from the natural tab order.

const grid = document.querySelector('.grid');

// Remove all buttons/links from the natural tab order
grid
  .querySelectorAll('a:not([tabindex="0"]), button:not([tabindex="0"])')
  .forEach(el => el.setAttribute('tabindex', '-1'));
Enter fullscreen mode Exit fullscreen mode

Now the grid can be skipped with a single Tab key instead of the user having to tab through every link and button in the grid.

Next, let's add a keydown handle to the grid and call a function to move the focus in the direction of the arrow keys.

grid.addEventListener('keydown', (e) => {
    // Prevent scrolling
    if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
      e.preventDefault();
    }
    if (e.key === 'ArrowUp') moveFocus(grid, 'up');
    if (e.key === 'ArrowDown') moveFocus(grid, 'down');
    if (e.key === 'ArrowLeft') moveFocus(grid, 'left');
    if (e.key === 'ArrowRight') moveFocus(grid, 'right');
});

function moveFocus(grid, direction) {
  const hasFocusableElement = ensureFocusableElementInGrid(grid)
  if (!hasFocusableElement) return;
  if (direction === 'up') focusUp(grid);
  if (direction === 'down') focusDown(grid);
  if (direction === 'left') focusLeft(grid);
  if (direction === 'right') focusRight(grid);
}

Enter fullscreen mode Exit fullscreen mode

The first thing we do before moving the focus is call ensureFocusableElementInGrid to make sure that a focusable element is present in the grid.

function ensureFocusableElementInGrid(grid) {
  const firstElem = grid.querySelectorAll('a, button')[0];
  const currentFocusable = grid.querySelector(
    '[tabindex="0"]') || firstElem;

  // Happens if the grid does not contain any interactive elements.
  if (!currentFocusable) {
    return false;
  }
  currentFocusable.setAttribute('tabindex', '0');
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Now, let's look at

focusUp and focusDown:

  • find the currently focused element
  • find the next cell in above/below row that has interactive elements
  • If found, focus the first interactive element in the cell if going down and the last interactive element if going up.

focusLeft and focusRight require an extra step:

  • find the currently focused element
  • extra! focus the next interactive element in the cell if it exists.
  • Otherwise, find the next cell in the current row that has interactive elements
  • If found, focus the first interactive element in the cell if going right and the last interactive element if going left.

It's a bit of code involved but it's quite reusable:

Here is a React version

Wrapping up

I hope you can see how this can be extrapolated to lists or different kind of grids or tables. Maybe a spreadsheet for example.

The same basic principles apply.

  • Remove all interactive elements within the composite component from the tab order
  • On arrow key press, find the next element that should get focus
  • Move the focus using roving tabindex

Edit
It's worth mentioning the ARIA Grid: Supporting nonvisual layout and keyboard traversal article from Facebook that touches on very similar things.

Top comments (0)