Vertical Layout Considerations
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. This article completes the process of creating a main navigation component that is universally accessible, both perceptually and operatively; can operate in a controlled or uncontrolled state; and is operable when displayed in a horizontal, desktop arrangement as well as in a vertical, mobile scenario.
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 1.0.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 components, 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 Vertical Alignment along with previous requirements.
Content Links
- Introduction
- Acceptance Criteria
- Data Setup
- Keeping SubLists Open
- Allowing Scroll when Focus Shifts
- Down Arrow Key Implementation
- Up Arrow Key Implementation
- Conclusion
Introduction
Up until now, all keyboard handling has been implemented with the default horizontal layout in mind. When displaying vertically, changes are necessary to meet user expectations. Sublists should remain open when focus shifts to a parent's sibling, and the up and down arrow keys should move in the same way as the Tab and Shift + Tab keys. An uncontrolled, horizontal layout is the default, so the acceptance criteria for this release focus on the differences required for a vertically laid-out component.
Acceptance Criteria
Vertical Alignment
- AC 1 - When moving between components in a vertically aligned component, sublists off the top row will not automatically close.
- AC 2 - When setting focus within a vertically aligned component, scrolling is allowed.
- AC 3 - When in a vertically aligned component, the DOWN-ARROW key should move to the parent's sibling when the current list is closed.
- AC 4 - When in a vertically aligned component, the DOWN-ARROW key should move to the parent's sibling when at the bottom of the last opened sublist in a list.
- AC 5 - When the last button on the top list is closed, and its last child is the last element in the component, focus should stay on the last button when the DOWN-ARROW key is pressed.
- AC 6 - When the DOWN-ARROW key is pressed on the last link in the component, focus should stay on the last link.
- AC 7 - When the Up-ARROW key is pressed, focus should move up the visible list and end at the first child of the top row.
A controlled component may not always be laid out vertically, and so it stands to reason that the configuration should hold the layout display type. In this case, the orientation prop, passed to the top list, is already available; it just needs to be stored in the NavigationProvider's config object.
Data Setup
const navigationContextProp = {
data : {
controllingRef : controllingRef,
isSubListOpen : isOpen,
storedParentEl : null,
storedList : [],
},
config : {
orientation : orientation,
skipName : skipName || " skip "
},
};
GitHub (release 1.0.0) - Navigation.tsx - Line 24
The config object contains name/value pairs that remain constant throughout the component's lifetime. Adding anything to the config object in the Navigation component makes it available within the provider. In this case, both orientation and skipName are placed in the config object.
const isLayoutVertical = useCallback (() => {
return config . orientation === " vertical ";
}, [ config . orientation ]);
GitHub (release 1.0.0) - NavigationProvider.tsx - Line 63
Since a horizontal layout is the default, all that's necessary is to create a function that answers the question "Is the navigation orientation vertical?" With the necessary data added to the context provider, implementation can begin.
Keeping SubLists Open
AC 1 - When moving between components in a vertically aligned component, sublists off the top row will not automatically close.
Contrary to the default horizontal layout, it feels jarring when sublists close on navigation within a vertical layout.
const _handleTopRowItemFocus = ( focusedEl ) => {
let returnEl ;
if ( isComponentActive () && ! isLayoutVertical ()) {
_closeOpenSiblings ( focusedEl );
}
...
};
GitHub (release 1.0.0) - useNavigation.tsx closeComponentWithFocus() - Line 232
All that's necessary is to execute _closeOpenSiblings only if the component is both active and laid out horizontally.
Allowing Scroll when Focus Shifts
AC 2 - When setting focus within a vertically aligned component, scrolling is allowed.
For the most part, scrolling on focus is disallowed in the horizontal layout, but needs to be available when the component is in a vertical orientation. focusableEl.focus({preventScroll: true}) has been a part of the shiftFocus function since the beginning. The issue, of course, is that isLayoutVertical is part of the NavigationProvider, whereas shiftFocus is part of the NavigationListProvider.
export default function NavigationList ({ ... }) {
const { isLayoutVertical } = useNavigation ();
const listContext = {
isLayoutVertical,
parentRef : parentRef,
};
}
GitHub (release 1.0.0) - NavigationList.tsx - Line 22
NavigationList can call the isLayoutVertical function from the navigation hook, since it lives within its context, passing it through to the NavigationList context provider.
export function NavigationListProvider ({ children , value }) {
const { isLayoutVertical , parentRef } = value ;
...
return (
< NavigationListContext . Provider
value = { {
getCurrentListItems ,
getParentEl ,
isLayoutVertical ,
registerItemInCurrentList ,
} }
>
{ children }
</ NavigationListContext . Provider >
);
}
GitHub (release 1.0.0) - NavigationListProvider.tsx - Line All
The provider does is expose the isLayoutVertical function for the companion hook.
export function useNavigationList () {
const navigationListContextObj = use ( NavigationListContext );
const {
getCurrentListItems ,
getParentEl ,
isLayoutVertical ,
registerItemInCurrentList ,
} = returnTrueElementOrUndefined (
!! navigationListContextObj ,
navigationListContextObj ,
);
...
const shiftFocus = ( focusableEl ) => {
focusableEl . focus ({
preventScroll : ! isLayoutVertical
});
};
GitHub (release 1.0.0) - useNavigationList.tsx - Line 34
If the layout is not vertical, then preventScroll is true; if it is vertical, then preventScroll is false, and the browser will scroll if necessary.
Down Arrow Key Implementation
Most of the work in this context involves making the down arrow behave the same as the Tab key. It means making sure the down key follows the visible DOM. All of the work for the down arrow criteria takes place in the getNextByButton and getNextByLink functions within the useNavigation hook, wrapped in the conditional isLayoutVertical().
const getNextByButton = ({...}) => {
...
if ( isLayoutVertical ()) {
// if last in parent list and not last in component, move to parent's next sibling.
const isLastInComponent = _isLastElementInComponent ( buttonEl );
if (
! isSubListOpen &&
currentList . indexOf ( buttonEl ) === currentList . length - 1 &&
! isLastInComponent
) {
const lastTopElement = _getLastElementInTopRow ( buttonEl );
const lastEl = _getLastElementByParent (
buttonEl as ControllingElementType ,
);
const lastComponentEl = _getLastElementInComponent ();
if (
buttonEl !== lastTopElement &&
lastEl !== lastComponentEl
) {
focusableEl = getFocusableElementFromDOM (
lastEl ,
" next " ,
) as FocusableElementType ;
} else {
focusableEl = buttonEl ;
}
}
}
}
GitHub (release 1.0.0) - useNavigation.tsx getNextByButton() - Line 340
AC 3 - When in a vertically aligned component, the DOWN-ARROW key should move to the parent's sibling when the current list is closed.
If the layout is vertical, the sublist is not open, and the currently focused element is not the last element within the component, the last element connected to a parent is determined, and the next focusable element in the DOM can be obtained.
AC 4 - When in a vertically aligned component, the DOWN-ARROW key should move to the parent's sibling when at the bottom of the last opened sublist in a list.
This criterion is already implemented in the normal getNextByButton handling (lines 335-338) and is mentioned here to support testing.
AC 5 - When the last button on the top list is closed, and its last child is the last element in the component, focus should stay on the last button when the DOWN-ARROW key is pressed.
const lastEl = _getLastElementByParent (
buttonEl as ControllingElementType ,
);
const lastComponentEl = _getLastElementInComponent ();
if (
buttonEl !== lastTopElement &&
lastEl !== lastComponentEl
) {
focusableEl = getFocusableElementFromDOM (
lastEl ,
" next " ,
) as FocusableElementType ;
} else {
focusableEl = buttonEl ;
}
GitHub (release 1.0.0) - useNavigation.tsx - getNextByButton() - Line 350
When the currently focused button is not the first element and the last element descended from it is not the last element in the component, the next focusable element in the DOM is retrieved and returned; otherwise, the same button element currently holding focus is returned.
AC 6 - When the DOWN-ARROW key is pressed on the last link in the component, focus should stay on the last link.
if ( isLayoutVertical ()) {
const isLinkLastInComponent = linkEl === _getLastElementInComponent ();
if ( isLinkLast && ! isLinkLastInComponent ) {
focusableEl = getFocusableElementFromDOM (
linkEl ,
" next " ,
) as FocusableElementType ;
} else if ( isLinkLast && isLinkLastInComponent ) {
focusableEl = undefined ;
}
}
GitHub (release 1.0.0) - useNavigation.tsx getNextByLink() - Line 399
Buttons and links are handled slightly differently since only a link can be the last element in any navigation component, and focus only shifts to the next element in the DOM when the link is not the last in the component; otherwise, focus remains where it is since only the TAB key is capable of leaving the component.
Up Arrow Key Implementation
AC 7 - When the Up-ARROW key is pressed, focus should move up the visible list and end at the first child of the top row.
The up arrow key works almost the same whether the top row is displayed in either a horizontal or vertical layout.
if ( isLayoutVertical ()) {
const { storedList : parentList } = _getNavigationObjectByListElement (
buttonEl
);
if ( _isElementInTopRow ( buttonEl )) {
const prevParentEl = _getPreviousElementInList (
buttonEl ,
parentList ,
) as ControllingElementType ;
if ( prevParentEl ?. type === " button " ) {
const lastElement = _getLastElementByParent ( prevParentEl );
const { isSubListOpen } = _getNavigationObjectByListElement (
lastElement
);
if ( isSubListOpen ) {
focusableEl = lastElement ;
} else {
const prevParentObj = _getNavigationObjectByParent (
prevParentEl as ControllingElementType ,
);
if ( prevParentObj . isSubListOpen ) {
const lastElement = _getLastElementByParent ( prevParentEl );
const {
isSubListOpen ,
storedParentEl
} = _getNavigationObjectByListElement ( lastElement );
/* istanbul ignore else */
if ( ! isSubListOpen ) {
focusableEl = storedParentEl as FocusableElementType ;
}
}
}
}
}
}
GitHub (release 1.0.0) - useNavigation.tsx getPreviousByButton() - Line 464
The only change on an up-arrow press for vertical alignment occurs when focus is on a button on the top row. In such a case, the previous sibling is found, and if the sibling is a button whose sublist is opened, then the last element in the sibling's sublist should receive focus. If the sublist is not opened, then focus should shift to the parent button.
Conclusion
This release concludes the development of a fully accessible navigation component as demonstrated by the last two videos.
- Demonstration of the completed navigation component, uncontrolled and suitable for desktop.
- Demonstration of the completed navigation component as a controlled, vertically aligned component suitable for mobile.
If you've read all these articles, thank you. Most components don't require the complex keyboard handling and jumping between components as this one has, but every component can benefit from considering the relationship between accessibility needs and architecture. Creating an accessible component means considering perceivability as it ties to a peripheral. Screen reader users benefit from structural HTML and clearly defined labels and aria-states; screen users benefit from structured HTML and aria-states that can serve as selectors for styling.
Designers and developers have different responsibilities when it comes to working with accessibility. Designers need to have a much more in-depth understanding of the visual deficiencies that need to be accommodated. At the same time, developers focus more on operability for screen/keyboard users and on ensuring that what screen readers send out is useful.
I'll be back at some point with a few articles on an updated theming system. While I'm working on that, I'd love to hear about your experiences implementing the navigation component.
Comments
No comments yet. Start the discussion.