diff --git a/src/hooks/test/use-arrow-key-navigation-test.js b/src/hooks/test/use-arrow-key-navigation-test.js index c68ccb04..2be80d99 100644 --- a/src/hooks/test/use-arrow-key-navigation-test.js +++ b/src/hooks/test/use-arrow-key-navigation-test.js @@ -11,7 +11,7 @@ function Toolbar({ navigationOptions = {} }) { useArrowKeyNavigation(containerRef, navigationOptions); return ( -
+
@@ -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) {