DEV Community

Cover image for Navigating With Tabs
ShaynaProductions
ShaynaProductions

Posted on

Navigating With Tabs

Prologue

A while ago, I decided to develop a fully accessible main navigation component in React and write a series of articles documenting the steps it took to create a non-trivial accessible component.

In my last development article, I covered using an array of navigation objects to determine conditions and shift focus between components.

This article covers Tab Key navigation.


Note: This article is one of a series demonstrating building a React navigational component from scratch while considering accessibility through the process. The articles are accompanied by a GitHub repository with releases tied to one or more articles; each builds on the previous one until a fully implemented navigation component is complete.

Each release and its associated tag contain fully runnable code for the article. The code discussed in this article is available in the release. and may be downloaded at release 0.7.0. Links in the article will take you to the proper file in the tagged GitHub Repository.

Because the code for this release is scattered across the useNavigation hook, line numbers are added to make it easier to locate in the linked GitHub file. Line numbers are also provided for those who would like to follow along with a downloaded copy.

While code examples are written in JavaScript for brevity, all actual code is written in Typescript and targets React 19.x, all while using vanilla CSS. Examples use Next.js v16.x, which is not required to run the navigation component.

You can view the requirements for the Tab Handling Keyboard Release along with previous requirements.


Content Links

Introduction

As I've mentioned in earlier articles, keyboard handling has two disparate audiences: those who can see the screen and those who rely on a screen reader. The arrow, home and end keys, for the most part, rely on a user knowing where they are and being able to discern where they want to go when they choose a specific key; cursor movement needs to match the user's expectations of cursor movement on the screen.

While a screen reader user relies on the tab key to move around a browser window, those who perceive through the screen and operate a keyboard also use it to navigate components. The browser's default behavior for the Tab and Shift+Tab keys is to navigate the cursor through focusable elements in the order defined by the Document Object Model. Up until this point, the navigation component relied on the default behavior, which meant that using the Tab key caused the cursor to disappear as focus shifted to items not displayed on the screen.

For the most part, the up and down arrow key functionality added in the last release implements the functionality required by the Tab and Shift+Tab keys, making the acceptance criteria much easier to detail.

Acceptance Criteria - Tab Handling

Tab Key

  • AC 1 - With some exceptions, focus should match the arrow-down functionality. Link
  • AC 2 - If the link is part of the top row and is the last child, shift focus to the next focusable element outside of the component.
  • AC 3 - If the link is not part of the top row and is the last element within the component, shift focus to the next focusable element outside of the component.
  • AC 4 - If the link is not part of the top row, but is the last element associated with the parent on the top row, shift focus to the parent's next sibling. Button
  • AC 5 - If the sublist is not open and the button is the last child in its current list, shift focus to the next focusable item, based on the last link in its sublist's downline.

Shift + Tab Key

  • AC 6 - With some exceptions, focus should match the arrow-up functionality. Link
  • AC 7 - If the focusable element is the first child of the top list, focus should shift to the previous focusable element outside the component.
  • AC 8 - If the focusable element is the first child of the top list, focus should shift to the previous focusable element outside the component.

Users, through experience, have come to expect that the Tab and Shift+Tab keys are the only keys that move into and out of components. Additionally, when the tab key is pressed on the last visible element of an open list, focus should move to the parent's sibling instead of the default behavior of arrow-down, which shifts focus to the parent. Once again, work needs to be done to meet users' expectations.

Tab Key Handling

The tab key moves focus down the Document Object Model, landing only on focusable items. Focusable elements include form inputs, select, textarea, buttons and links. Other elements may be made focusable by setting tabindex="0" on an element; this should be used sparingly, if at all. In the navigation component, the focusable elements are the buttons and links.

Link

Implementing the Tab key begins by updating the handleKeyDown function in the components that hold the link and the button.

NavigationItem

const handleKeyDown = (e: React.KeyboardEvent) => {
    const linkEl = linkRef.current;

    switch (e.key) {
        case Keys.HOME:
        
        case Keys.TAB:
            e.preventDefault();
            e.stopPropagation();
            break;
    }

    HandleCommonKeyDown();

    let focusableEl;
    switch (e.key) {
        
        case Keys.TAB:
            if (e.shiftKey) {
                focusableEl = getPreviousByTabLink(linkEl);
            } else {
                focusableEl = getNextByTabLink(linkEl);
            }
            break;
    }
    if (focusableEl) {
        shiftFocus(focusableEl);
    }
};
Enter fullscreen mode Exit fullscreen mode

GitHub (release 0.7.0) - NavigationItem.tsx - Line 109

Tab and Shift+Tab need to be handled in the same case statement, using the shiftkey prop to decide which controller function to call. In this case, getNextByTabLink() is available in the useNavigation hook.

useNavigation

AC 1

With some exceptions, focus should match the arrow-down functionality.

AC 2

If the link is in the top row and is the last child, shift focus to the next focusable element outside the component.

AC 3

If the link is not part of the top row and is the last element within the component, shift focus to the next focusable element outside of the component.

AC 4

If the link is not part of the top row, but is the last element associated with the parent on the top row, shift focus to the parent's next sibling.

const getNextByTabLink = ( linkEl ) => {
    let focusableEl = getNextByLink(linkEl);

    const isInTopRow = _isElementInTopRow(linkEl);

    if (
        (isInTopRow && !focusableEl) ||
        (!isInTopRow && _getLastElementInTopList(linkEl))
    ) {
        focusableEl = getFocusableElementFromDOM(
            linkEl,
            "next",
        ) as FocusableElementType;
    }

    return focusableEl;
};
Enter fullscreen mode Exit fullscreen mode

GitHub (release 0.7.0) - useNavigation.tsx - getNextByTabLink Line 228

The first acceptance criterion states that most functionality should match the behavior when the arrow-down key is pressed on a link, so the first call to set the focusable element is made by calling getNextByLink.

The criteria covered by AC 2 and AC 3 have the same outcome; shifting focus to the first focusable element outside the component by calling a new utility function.

getFocusableElementFromDOM

export function getFocusableElementFromDOM(lastEl, direction) {
    //add all elements we want to include in our selection
    const focusable =
        'a:not([aria-disabled]), button:not([aria-disabled]), input[type=text]:not([aria-disabled]), select:not([aria-disabled]), textarea:not([aria-disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])';

    const focusableElements = document.querySelectorAll(focusable);

    let index = -1,
        nextIndex;
    for (let i = 0; i < focusableElements.length; i += 1) {
        if (focusableElements[i] === lastEl) {
            index = i;
            break;
        }
    }
    if (direction === "next") {
        nextIndex = index + 1;
    } else {
        nextIndex = index - 1;
    }

    return focusableElements[nextIndex];
}
Enter fullscreen mode Exit fullscreen mode

GitHub (release 0.7.0) - getFocusableElementFromDOM.ts

This utility function applies a document query selector to return a NodeList of all non-disabled focusable elements. Recall that in a previous article on updating accessibility within base components, there are issues using the disabled attribute within focusable elements, and as I demonstrated in the base/button component, the aria-disabled attribute was added when an element is disabled. The focusable variable uses aria-disabled to determine which elements to exclude from the list. If your code still uses disabled, then :not[disabled] should be added to the string.

Even though a NodeList provides index-based access like an array, it is not an array, and so no array-specific methods may be used. Specific to the direction, the index is incremented or decremented, and the appropriate next or previous focusable element is returned.

Button

AC 5

If the sublist is not open and the button is the last child in its current list, shift focus to the next focusable item, based on the last link in its sublist's downline.

Tabbing on a button differs from arrowing down on a button only by one criterion. When a button's sublist is not open, and the button is the last visible element in a list, shift focus to the next focusable element in the DOM.

SubNavigation.tsx

 const handleKeyDown = (e) => {
    const buttonEl = buttonRef.current;

    switch (e.key) {
        case Keys.HOME:
        
        case Keys.TAB:
            e.preventDefault();
            e.stopPropagation();
            break;
    }

    HandleCommonKeyDown();

    let focusableEl: FocusableElementType | undefined;
    switch (e.key) {
        
        case Keys.TAB:
            if (e.shiftKey) {
                focusableEl = getPreviousByTabButton(buttonEl);
            } else {
                focusableEl = getNextByTabButton(buttonEl);
            }
            break;
    }
    if (focusableEl) {
        shiftFocus(focusableEl);
    }
};
Enter fullscreen mode Exit fullscreen mode

GitHub (release 0.7.0) - SubNavigation.tsx Line 146

Setting up the call for a Tab or Shift+Tab on a button requires the same basic code as in the NavigationItem component: calling the getNextByTabButton function.

useNavigation

getNextByTabButton

const getNextByTabButton = (buttonEl, isSubListOpen) => {
    let focusableEl = getNextByButton(buttonEl, isSubListOpen);

    const { storedList } = _getNavigationObjectByListElement(buttonEl);

    if (
        !isSubListOpen &&
        storedList.indexOf(buttonEl) === storedList.length - 1
    ) {
        const lastEl = _getLastElementByParent(buttonEl);

        focusableEl = getFocusableElementFromDOM(
            lastEl,
            "next",
        ) as FocusableElementType;
    }

    return focusableEl;
};
Enter fullscreen mode Exit fullscreen mode

GitHub (release 0.7.0) - useNavigation.tsx - getNextByTabButton Line 205

As with the earlier getNextByTabLink function, focus is first set to whatever was returned when arrow-down is used, by calling getNextByButton first. The cascade replaces the focusable element only when the sublist is closed, and the button is the last element in its list. If the condition exists, then the last element under the top row parent, getLastElementByParent(), is returned, and the focusable element is the next focusable element in the DOM. This next focusable element may be in the component or outside of it, depending on which list the original button element is in.

const _getLastElementByParent = (parentEl) => {
    return _getRecursiveLastElementByParent(
        parentEl,
        _getNavigationObjectByListElement,
        _getNavigationObjectByParent,
    );
};
Enter fullscreen mode Exit fullscreen mode

GitHub (release 0.7.0) - useNavigation.tsx - getLastElementByParent Line 71

As with the recursive function to find the ultimate ancestor of an element in the list, another recursive function is defined outside of the render, and the variables and functions necessary for the recursive function are passed through to accomplish its objective, returning the last focusable element of its descendants.

_getRecursiveLastElementByParent

export const _getRecursiveLastElementByParent = (
    focusableEl,
    _getNavigationObjectByListElement,
    _getNavigationObjectByParent,
) => {
    if (focusableEl.type === "button") {
        const { storedList } = _getNavigationObjectByParent(focusableEl);
        return _getRecursiveLastElementByParent(
            storedList[storedList.length - 1],
            _getNavigationObjectByListElement,
            _getNavigationObjectByParent,
        );
    } else {
        const { storedList } = _getNavigationObjectByListElement(focusableEl);
        return storedList[storedList.length - 1];
    }
};
Enter fullscreen mode Exit fullscreen mode

GitHub (release 0.7.0) - hookFunctions.ts - _getRecursiveLastElementByParent

The element will always return the last item in a list when the element is a link, since a link is always the last element in any list. Recursion occurs when the passed-through element is a button, meaning the last element in its sublist must be retrieved and passed to the same function.

Shift+ Tab Key Handling

The Shift+Tab key combination handles movement to focusable elements above the current focus. Since the handlers were shown earlier, the code for NavigationItem and SubNavigation is not duplicated here.

AC 6

  • With some exceptions, focus should match the arrow-up functionality. ### AC 7
  • If the previous focusable item is a button and its sublist is open, focus should shift to the last child in the open sublist. ### AC 8
  • If the focusable element is the first child of the top list, focus should shift to the previous focusable element outside the component.

All three criteria are applied to the functionality for both links and buttons.

Link

useNavigation

getPreviousByTabLink

const getPreviousByTabLink =
    (linkEl) => {
        let focusableEl = getPreviousByLink(linkEl);
        if (_isElementInTopRow(linkEl)) {
            const { storedList } = _getNavigationObjectByListElement(linkEl);

            if (storedList.indexOf(linkEl) === 0) {
                focusableEl = getFocusableElementFromDOM(
                    linkEl,
                    "prev",
                ) as FocusableElementType;
            } else {
                focusableEl = _getPreviousElementInList(linkEl, storedList);
            }
        }
        return focusableEl;
    };
Enter fullscreen mode Exit fullscreen mode

GitHub (release 0.7.0) - useNavigation.tsx - getPreviousByTabLink Line 288

AC 6 and 7 are covered with the getPreviousByLink functionality. The only difference between the two is that, when the element is the first child of the first row, it must find the previous focusable element outside the component.

Button

useNavigation

getPreviousByTabButton

const getPreviousByTabButton = (buttonEl) => {
    let focusableEl = getPreviousByButton(buttonEl);
    if (_isElementInTopRow(buttonEl)) {
        const { storedList } = _getNavigationObjectByListElement(buttonEl);
        if (storedList.indexOf(buttonEl) === 0) {
            focusableEl = getFocusableElementFromDOM(
                buttonEl,
                "prev",
            ) as FocusableElementType;
        }
    }
    return focusableEl;
};
Enter fullscreen mode Exit fullscreen mode

GitHub (release 0.7.0) - useNavigation.tsx - getPreviousByTabButton Line 273

The getPrevious button and link controllers behave similarly: when the function handling the up-arrow is called, they retrieve the focusable element, and if the element is the first element in the top row, they shift focus to the first element in the DOM preceding the component.

Summary

The cascade pattern allowed us to leverage all the work done in the up/down keyboard-handling release, making it easy to handle tab key calls and focus solely on implementing the specific requirements unique to Tab and Shift+Tab.

Keyboard handling has been implemented for the most part. From an operable standpoint, all that's left is handling closing scenarios and tweaking the component for display and operability on mobile devices.

You can view how the implemented code works in this video.

My next article in the series will focus on working with color as I style a horizontal navigation component.

Top comments (0)