Skip to content

Commit

Permalink
Support keyboard navigation in nested widgets
Browse files Browse the repository at this point in the history
Ensure that widgets with keyboard navigation (arrow or tab) support can
be navigated within and between each other, restoring tabindex sequence
and position when re-entering a widget.

This makes it possible to nest widgets like `TabList` and `DataTable`
(arrow-key navigation) within a `ModalDialog` (tab navigation).
  • Loading branch information
lyzadanger committed Apr 17, 2023
1 parent c8518ae commit 98a8e0c
Show file tree
Hide file tree
Showing 4 changed files with 123 additions and 9 deletions.
46 changes: 43 additions & 3 deletions src/hooks/test/use-arrow-key-navigation-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function Toolbar({ navigationOptions = {} }) {
useArrowKeyNavigation(containerRef, navigationOptions);

return (
<div ref={containerRef} data-testid="toolbar">
<div ref={containerRef} data-testid="toolbar" tabIndex={-1}>
<button data-testid="bold">Bold</button>
<button data-testid="italic">Italic</button>
<button data-testid="underline">Underline</button>
Expand All @@ -24,11 +24,15 @@ function Toolbar({ navigationOptions = {} }) {

describe('useArrowKeyNavigation', () => {
let container;
let toolbar;

beforeEach(() => {
container = document.createElement('div');
const button = document.createElement('button');
button.setAttribute('data-testid', 'outside-button');
container.append(button);
document.body.append(container);
renderToolbar();
toolbar = renderToolbar();
});

afterEach(() => {
Expand Down Expand Up @@ -98,7 +102,7 @@ describe('useArrowKeyNavigation', () => {
const currentElement = document.activeElement;
assert.equal(currentElement.innerText, expectedItem);

const toolbarButtons = container.querySelectorAll('a,button');
const toolbarButtons = toolbar.querySelectorAll('a,button');
for (let element of toolbarButtons) {
if (element === currentElement) {
assert.equal(element.tabIndex, 0);
Expand Down Expand Up @@ -260,6 +264,42 @@ describe('useArrowKeyNavigation', () => {
assert.equal(currentItem(), 'Italic');
});

it('should restore tabindex sequence when widget is re-entered', () => {
const toolbar = renderToolbar();

// Set focus on the widget's container, which isn't otherwise part
// of the widget's navigable element set.
toolbar.focus();

// In this case, the first right-arrow press will go to the first item
pressKey('ArrowRight');
assert.equal(currentItem(), 'Bold');
pressKey('ArrowRight');
assert.equal(currentItem(), 'Italic');

const outsideButton = container.querySelector(
'[data-testid="outside-button"]'
);

// Place focus on an element entirely outside of the widget
outsideButton.focus();
assert.equal(document.activeElement, outsideButton);

// Now trigger a `focusin` event on the widget's container
toolbar.dispatchEvent(new FocusEvent('focusin'));

// Focus/navigation sequence is restored to the last option
assert.equal(currentItem(), 'Italic');

// Take focus out of the sequence of navigable elements and set it back
// on the container
toolbar.focus();

// As there is a previously-focused item, focus will be restored
// to that item
assert.equal(currentItem(), 'Italic');
});

it('should re-initialize tabindex attributes if current element is removed', async () => {
const toolbar = renderToolbar();
const boldButton = toolbar.querySelector('[data-testid=bold]');
Expand Down
38 changes: 36 additions & 2 deletions src/hooks/test/use-tab-key-navigation-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ describe('useTabKeyNavigation', () => {

beforeEach(() => {
container = document.createElement('div');
const button = document.createElement('button');
button.setAttribute('data-testid', 'outside-button');
container.append(button);
document.body.append(container);
renderToolbar();
});
Expand Down Expand Up @@ -99,7 +102,7 @@ describe('useTabKeyNavigation', () => {
const forwardKey = 'Tab';
const backKey = { key: 'Tab', shiftKey: true };

renderToolbar();
const toolbar = renderToolbar();

const steps = [
// Test navigating forwards.
Expand All @@ -125,7 +128,7 @@ describe('useTabKeyNavigation', () => {
const currentElement = document.activeElement;
assert.equal(currentElement.innerText, expectedItem);

const toolbarButtons = container.querySelectorAll('a,button');
const toolbarButtons = toolbar.querySelectorAll('a,button');
for (let element of toolbarButtons) {
if (element === currentElement) {
assert.equal(element.tabIndex, 0);
Expand Down Expand Up @@ -267,6 +270,37 @@ describe('useTabKeyNavigation', () => {
assert.equal(error.message, 'Container ref not set');
});

it('should restore tabindex sequence when widget is re-entered', () => {
const toolbar = renderToolbar();
pressKey('Tab');

assert.equal(currentItem(), 'Italic');

const outsideButton = container.querySelector(
'[data-testid="outside-button"]'
);
outsideButton.focus();

assert.equal(document.activeElement, outsideButton);

toolbar.dispatchEvent(new FocusEvent('focusin'));

pressKey('Tab');

// Focus/navigation sequence is restored, and continues on to the next
// option on first Tab press after re-entry
assert.equal(currentItem(), 'Underline');

// Put focus back on the widget's container, which is "outside" of the
// normal set of navigable elements
toolbar.focus();

// Subsequent tab navigation should pick up where it left off
pressKey('Tab');

assert.equal(currentItem(), 'Help');
});

it('should respect a custom element selector', () => {
renderToolbar({
selector: '[data-testid=bold],[data-testid=italic]',
Expand Down
25 changes: 23 additions & 2 deletions src/hooks/use-arrow-key-navigation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { RefObject } from 'preact';
import { useEffect } from 'preact/hooks';
import { useEffect, useRef } from 'preact/hooks';

import { ListenerCollection } from '../util/listener-collection';

Expand Down Expand Up @@ -72,6 +72,11 @@ export function useArrowKeyNavigation(
selector = 'a,button',
}: UseArrowKeyNavigationOptions = {}
) {
// Keep track of the element that was last focused by this hook such that
// navigation can be restored if focus moves outside of the container
// and then back to/into it.
const lastFocusedItem = useRef<HTMLOrSVGElement | null>(null);

useEffect(() => {
if (!containerRef.current) {
throw new Error('Container ref not set');
Expand All @@ -82,9 +87,17 @@ export function useArrowKeyNavigation(
const elements: HTMLElement[] = Array.from(
container.querySelectorAll(selector)
);
return elements.filter(
const filtered = elements.filter(
el => isElementVisible(el) && !isElementDisabled(el)
);
// Include the container itself in the set of navigable elements if it
// is currently focused. It will not be part of the tab sequence once it
// loses focus. This allows, e.g., a widget container to be focused when
// opened but not be part of the subsequent keyboard-navigation sequence.
if (document.activeElement === container) {
filtered.unshift(container);
}
return filtered;
};

/**
Expand Down Expand Up @@ -113,6 +126,7 @@ export function useArrowKeyNavigation(
for (const [index, element] of elements.entries()) {
element.tabIndex = index === currentIndex ? 0 : -1;
if (index === currentIndex && setFocus) {
lastFocusedItem.current = element;
element.focus();
}
}
Expand Down Expand Up @@ -169,6 +183,13 @@ export function useArrowKeyNavigation(
// may not be received if the element immediately loses focus after it
// is triggered.
listeners.add(container, 'focusin', event => {
if (event.target === container && lastFocusedItem.current) {
// Focus is moving back to the container after having left. Restore the
// last tabindex. This allows users to exit and re-enter the widget
// without resetting the navigation sequence.
lastFocusedItem.current.focus();
return;
}
const elements = getNavigableElements();
const targetIndex = elements.indexOf(event.target as HTMLElement);
if (targetIndex >= 0) {
Expand Down
23 changes: 21 additions & 2 deletions src/hooks/use-tab-key-navigation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { RefObject } from 'preact';
import { useEffect } from 'preact/hooks';
import { useEffect, useRef } from 'preact/hooks';

import { ListenerCollection } from '../util/listener-collection';

Expand Down Expand Up @@ -57,13 +57,21 @@ export type UseTabKeyNavigationOptions = {
*
*/

// By default, include standard browser focus-able, tab-sequence elements (links, buttons,
// inputs). Also include the containers for ARIA interactive widgets `grid` and
// `tablist`. Internal keyboard navigation for those widgets should be handled
// separately: exclude `tab`-role buttons from this hook's navigation sequence.
const defaultSelector =
'a,button:not([role="tab"]),input,select,textarea,[role="grid"],[role="tablist"]';

export function useTabKeyNavigation(
containerRef: RefObject<HTMLElement | undefined>,
{
enabled = true,
selector = 'a,button,input,select,textarea',
selector = defaultSelector,
}: UseTabKeyNavigationOptions = {}
) {
const lastFocusedItem = useRef<HTMLOrSVGElement | null>(null);
useEffect(() => {
if (!enabled) {
return () => {};
Expand Down Expand Up @@ -116,6 +124,7 @@ export function useTabKeyNavigation(
for (const [index, element] of elements.entries()) {
element.tabIndex = index === currentIndex ? 0 : -1;
if (index === currentIndex && setFocus) {
lastFocusedItem.current = element;
element.focus();
}
}
Expand All @@ -124,6 +133,16 @@ export function useTabKeyNavigation(
const onKeyDown = (event: KeyboardEvent) => {
const elements = getNavigableElements();
let currentIndex = elements.findIndex(item => item.tabIndex === 0);
if (
(currentIndex === -1 || elements[currentIndex] === container) &&
lastFocusedItem.current
) {
// Focus is moving back to/into the container after having left (or
// active tabindex is a non-navigable element). Restore previous active
// tabindex. This allows the user to exit and re-enter the widget
// without losing tab-sequence position.
currentIndex = elements.indexOf(lastFocusedItem.current as HTMLElement);
}

let handled = false;
if (event.key === 'Tab' && event.shiftKey) {
Expand Down

0 comments on commit 98a8e0c

Please sign in to comment.