How can I rescue focus when the currently focused element is removed from the DOM?

Viewed 1217

I've created a composite widget grid that works well with keyboard navigation. A problem I've run into is when a a row in the grid where the currently focused element resides, the focus is returned to the <body> element. I'd like to be able to rescue the focus to the closest interactive element that makes sense (in row above or below).

My question is:

When the currently focused element is removed from the DOM, how can I set focus to the closest interactive element (that is still in the dom)?

I tried using the focus/blur events in combination with setTimeout to get the right signals but didn't get anywhere.

Also tried using a MutationObserver on the currently focused element, which worked but I ran into problems because the grid is virtually scrolled so the currently focused element could be removed from the DOM due to the row being recycled by the virtual scroller, in which case I don't want to rescue focus (it would cause the grid to scroll back up constantly to the new "rescued" focus and you could never reach the bottom)

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'));

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);
}

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 a or button elements.
  if (!currentFocusable) {
    return false;
  }
  currentFocusable.setAttribute('tabindex', '0');
  return true;
}

function focusDown(grid) {
  const currentFocus = grid.querySelector('[tabindex="0"]');
  const nextCell = findNextCell(grid, currentFocus, p => ({
    row: p.row + 1,
    col: p.col,
  }));
  if (!nextCell) return;

  // Target the first interactive element in the cell below
  const firstElem = nextCell.querySelectorAll('a, button')[0];
  transferFocus(currentFocus, firstElem);
}

function focusUp(grid) {
  const currentFocus = grid.querySelector('[tabindex="0"]');
  const nextCell = findNextCell(grid, currentFocus, p => ({
    row: p.row - 1,
    col: p.col,
  }));
  if (!nextCell) return;

  // Target the first interactive element in the cell above
  const firstElem = nextCell.querySelectorAll('a, button')[0];
  transferFocus(currentFocus, firstElem);
}

function focusLeft(grid) {
  const currentFocus = grid.querySelector('[tabindex="0"]');
  const nextEl = findNextElementInCell(currentFocus, -1);

  if (nextEl) {
    transferFocus(currentFocus, nextEl);
    return;
  }

  const nextCell = findNextCell(grid, currentFocus, p => ({
    row: p.row,
    col: p.col - 1,
  }));
  if (!nextCell) return;

  // Target the last interactive element in the cell to the left
  const prevCellElems = nextCell.querySelectorAll('a, button');
  const lastLink = prevCellElems[prevCellElems.length - 1];
  transferFocus(currentFocus, lastLink);
}

function focusRight(grid) {
  const currentFocus = grid.querySelector('[tabindex="0"]');

  // Exit early if next focusable element is found in the cell
  const nextEl = findNextElementInCell(currentFocus, 1);
  if (nextEl) {
    transferFocus(currentFocus, nextEl);
    return;
  }

  const nextCell = findNextCell(grid, currentFocus, p => ({
    row: p.row,
    col: p.col + 1,
  }));
  if (!nextCell) return;

  // Target the first interactive element in the cell to the right
  const nextCellEl = nextCell.querySelectorAll('a, button');
  const firstEl = nextCellEl[0];
  transferFocus(currentFocus, firstEl);
}

/**
 * Given an interactive element (button or a) this functions figures out it's
 * position in the grid based on aria attributes on it's parent elements.
 * @param interactiveElement element to find position of
 */
function getGridPosition(interactiveElement) {
  const row = parseInt(
    interactiveElement
      .closest('[aria-rowindex]')
      .getAttribute('aria-rowindex'),
    10,
  );
  const col = parseInt(
    interactiveElement
      .closest('[aria-colindex]')
      .getAttribute('aria-colindex'),
    10,
  );
  return { row, col };
}

/**
 * Move focus from oldEl -> newEl
 * @param oldEl element loosing focus
 * @param newEl element gaining focus
 */
function transferFocus(oldEl, newEl) {
  if (!oldEl || !newEl) return;
  oldEl.tabIndex = -1;
  newEl.tabIndex = 0;
  newEl.focus();
}

/**
 * Find the next/previous interactive element in the cell of provded element
 * @param element element to start search from
 * @param dir direction to search in, 1 : next, -1 : previous
 */
function findNextElementInCell(element, dir) {
  const cellElements = Array.from(
    element
      .closest('[aria-colindex]')
      .querySelectorAll('a, button')
  );
  const prevIndex = cellElements.findIndex(l => l === element) + dir;
  return cellElements[prevIndex];
}

/**
 * Traverse the grid in a direction until a cell with interactive elements is found
 * @param grid the grid element
 * @param element element to start search from.
 *                           It's position is calculated and used as a starting point
 * @param updatePos A function to update the position in a certain direction
 */
function findNextCell(grid, element, updatePos) {
  // recursively visit cells at given position and checks if it has any interactive elements
  const rec = currPos => {
    const nextPos = updatePos(currPos);
    const nextCell = grid.querySelector(
      `[aria-rowindex="${nextPos.row}"] [aria-colindex="${nextPos.col}"]`,
    );
    // No next cell found. Hitting edge of grid
    if (nextCell === null) return null;
    // Found next cell containing a or button tags, return it
    if (nextCell.querySelectorAll('a, button').length) {
      return nextCell;
    }
    // Continue searching. Visit next cell
    return rec(nextPos);
  };
  const position = getGridPosition(element);
  return rec(position);
}
.arrow-keys-indicator {
  bottom: 10px;
  right: 0;
  position: fixed;
  height: 65px;
  width: 85px;
  display: none;
}

.grid {
  display: grid;
  grid-gap: 16px;
}
.grid:focus-within ~ .arrow-keys-indicator {
  display: block;
}


.grid__header-row,
.grid__row {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
}

.heart {
  /* screen reader only */
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.grid__row:focus-within .heart,
.grid__row:hover .heart {
  /* undo screen reader only */
  position: static;
  width: auto;
  height: auto;
  padding: 0;
  margin: 0;
  overflow: visible;
  clip: auto;
  white-space: normal;
}

.sr-only {
    /* screen reader only */
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
<h1>Accessible Grid</h1>
<p>Start <a href="#">pressing</a> the Tab key until you <a href="#">reach</a> the grid</p>

<div class="grid" role="grid" tabindex="0">
  <div class="grid__header-row" role="row" aria-rowindex="1">
    <div role="columnheader" aria-colindex="1">
      <button>TITLE</button>
    </div>
    <div role="columnheader" aria-colindex="2">
      <button>ALBUM</button>
    </div>
    <div role="columnheader" aria-colindex="3">DURATION</div>
  </div>
  <div class="grid__row" role="row" aria-rowindex="2">
    <div role="gridcell" aria-colindex="1">
      <div>Black Parade</div>
      <a href="#">Beyoncé</a>
    </div>
    <div role="gridcell" aria-colindex="2"></div>
    <div role="gridcell" aria-colindex="3">
      4:41
      <button class="heart">
        <span class="sr-only">Add to your liked songs</span>
        ♡
      </button>
    </div>
  </div>
  <div class="grid__row" role="row" aria-rowindex="3">
    <div role="gridcell" aria-colindex="1">
      <div>Texas Sun</div>
      <a href="#">Kruangbin</a>,
      <a href="#">Leon Bridges</a>
    </div>
    <div role="gridcell" aria-colindex="2">
      <a href="#">Texas Sun</a>
    </div>
    <div role="gridcell" aria-colindex="3">
      4:12
      <button class="heart">
        <span class="sr-only">Add to your liked songs</span>
        ♡
      </button>
      </div>
  </div>
  <div class="grid__row" role="row" aria-rowindex="4">
    <div role="gridcell" aria-colindex="1">
      <div>Disconnect</div>
      <a href="#">Basement</a>
    </div>
    <div role="gridcell" aria-colindex="2">
      <a href="#">Beside Myself</a>
    </div>
    <div role="gridcell" aria-colindex="3">
      3:29
      <button class="heart">
        <span class="sr-only">Add to your liked songs</span>
        ♡
      </button>
      </div>
  </div>
</div>

<img class="arrow-keys-indicator" src="https://www.w3.org/TR/wai-aria-practices/examples/grid/imgs/black_keys.png" alt=""/>

</br>

<p>The <a href="#">links</a> in this section should be <a href="#">reachable</a> with a single Tab key press if the grid is in focus.</p>

2 Answers

You must always ensure that the focus is never lost. This is, as you have experienced yourself, a golden rule in keyboard accessibility.

The general rule for DOM element removal is the following: if you have to remove the currently focused element from the DOM, you must move the focus somewhere else before removing it.

If you don't move the focus before, then the focus is going to move to an arbitrary place once the element has been removed, and keyboard accessibility is broken. Sometimes the focus is even moved to nowhere at all, without any way to recover it without a mouse.

In your example, you should proceed like this to remove a row:

  1. Check if the focus is inside the row you are about to remove. Use document.activeElement and go up the DOM hierarchy if needed in order to determine it.
  2. If yes, call focusDown to place the focus to the next row (or focusUp if you are on the last row), as if the user pressed up/down arrow. You probably don't need another special logic to find out the best element to focus.
  3. Effectively remove the row from the DOM. Now that the focus is no longer there, you ahve no problem anymore.

If you can't control exactly how and when rows are removed, then your grid control is badly designed. You must fix it.

I need to be honest, I didn't read properly but what do you think about:

if (document.hasFocus()) document.activeElement.blur()

Related