@@ -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(() => {
@@ -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);
@@ -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]');
diff --git a/src/hooks/test/use-tab-key-navigation-test.js b/src/hooks/test/use-tab-key-navigation-test.js
index f101cd28..e02cfebc 100644
--- a/src/hooks/test/use-tab-key-navigation-test.js
+++ b/src/hooks/test/use-tab-key-navigation-test.js
@@ -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();
});
@@ -99,7 +102,7 @@ describe('useTabKeyNavigation', () => {
const forwardKey = 'Tab';
const backKey = { key: 'Tab', shiftKey: true };
- renderToolbar();
+ const toolbar = renderToolbar();
const steps = [
// Test navigating forwards.
@@ -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);
@@ -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]',
diff --git a/src/hooks/use-arrow-key-navigation.ts b/src/hooks/use-arrow-key-navigation.ts
index fca10f1b..d81004f6 100644
--- a/src/hooks/use-arrow-key-navigation.ts
+++ b/src/hooks/use-arrow-key-navigation.ts
@@ -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';
@@ -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(null);
+
useEffect(() => {
if (!containerRef.current) {
throw new Error('Container ref not set');
@@ -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;
};
/**
@@ -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();
}
}
@@ -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) {
diff --git a/src/hooks/use-tab-key-navigation.ts b/src/hooks/use-tab-key-navigation.ts
index e041f7de..101e5be7 100644
--- a/src/hooks/use-tab-key-navigation.ts
+++ b/src/hooks/use-tab-key-navigation.ts
@@ -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';
@@ -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,
{
enabled = true,
- selector = 'a,button,input,select,textarea',
+ selector = defaultSelector,
}: UseTabKeyNavigationOptions = {}
) {
+ const lastFocusedItem = useRef(null);
useEffect(() => {
if (!enabled) {
return () => {};
@@ -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();
}
}
@@ -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) {