Commit 5041947a authored by Alejandro Celaya's avatar Alejandro Celaya Committed by Alejandro Celaya

Replace local useArrowKeyNavigation with frontend-shared one

parent 60bc6091
import type { RefObject } from 'preact';
import { useEffect } from 'preact/hooks';
import { ListenerCollection } from './listener-collection';
function isElementDisabled(
element: HTMLElement & { disabled?: boolean },
): element is HTMLElement & { disabled: true } {
return typeof element.disabled === 'boolean' && element.disabled;
}
function isElementVisible(element: HTMLElement): boolean {
return element.offsetParent !== null;
}
export type ArrowKeyNavigationOptions = {
/**
* Whether to focus the first element in the set of matching elements when the
* component is mounted
*/
autofocus?: boolean;
/** Enable navigating elements using left/right arrow keys */
horizontal?: boolean;
/** Enable navigating elements using up/down arrow keys */
vertical?: boolean;
/** CSS selector which specifies the elements that navigation moves between */
selector?: string;
};
/**
* Enable arrow key navigation between interactive descendants of a
* container element.
*
* In addition to moving focus between elements when arrow keys are pressed,
* this also implements the "roving tabindex" pattern [1] which sets the
* `tabindex` attribute of elements to control which element gets focus when the
* user tabs into the container.
*
* See [2] for a reference of how keyboard navigation should work in web
* applications and how it applies to various common widgets.
*
* @example
* function MyToolbar() {
* const container = useRef();
*
* // Enable arrow key navigation between interactive elements in the
* // toolbar container.
* useArrowKeyNavigation(container);
*
* return (
* <div ref={container} role="toolbar">
* <button>Bold</bold>
* <button>Italic</bold>
* <a href="https://example.com/help">Help</a>
* </div>
* )
* }
*
* [1] https://www.w3.org/TR/wai-aria-practices/#kbd_roving_tabindex
* [2] https://www.w3.org/TR/wai-aria-practices/#keyboard
*/
export function useArrowKeyNavigation(
containerRef: RefObject<HTMLElement>,
{
autofocus = false,
horizontal = true,
vertical = true,
selector = 'a,button',
}: ArrowKeyNavigationOptions = {},
) {
useEffect(() => {
if (!containerRef.current) {
throw new Error('Container ref not set');
}
const container = containerRef.current;
const getNavigableElements = () => {
const elements: HTMLElement[] = Array.from(
container.querySelectorAll(selector),
);
return elements.filter(
el => isElementVisible(el) && !isElementDisabled(el),
);
};
/**
* Update the `tabindex` attribute of navigable elements.
*
* Exactly one element will have `tabindex=0` and all others will have
* `tabindex=1`.
*
* @param currentIndex - Index of element in `elements` to make current.
* Defaults to the current element if there is one, or the first element
* otherwise.
* @param setFocus - Whether to focus the current element
*/
const updateTabIndexes = (
elements: HTMLElement[] = getNavigableElements(),
currentIndex = -1,
setFocus = false,
) => {
if (currentIndex < 0) {
currentIndex = elements.findIndex(el => el.tabIndex === 0);
if (currentIndex < 0) {
currentIndex = 0;
}
}
for (const [index, element] of elements.entries()) {
element.tabIndex = index === currentIndex ? 0 : -1;
if (index === currentIndex && setFocus) {
element.focus();
}
}
};
const onKeyDown = (event: KeyboardEvent) => {
const elements = getNavigableElements();
let currentIndex = elements.findIndex(item => item.tabIndex === 0);
let handled = false;
if (
(horizontal && event.key === 'ArrowLeft') ||
(vertical && event.key === 'ArrowUp')
) {
if (currentIndex === 0) {
currentIndex = elements.length - 1;
} else {
--currentIndex;
}
handled = true;
} else if (
(horizontal && event.key === 'ArrowRight') ||
(vertical && event.key === 'ArrowDown')
) {
if (currentIndex === elements.length - 1) {
currentIndex = 0;
} else {
++currentIndex;
}
handled = true;
} else if (event.key === 'Home') {
currentIndex = 0;
handled = true;
} else if (event.key === 'End') {
currentIndex = elements.length - 1;
handled = true;
}
if (!handled) {
return;
}
updateTabIndexes(elements, currentIndex, true);
event.preventDefault();
event.stopPropagation();
};
updateTabIndexes(getNavigableElements(), 0, autofocus);
const listeners = new ListenerCollection();
// Set an element as current when it gains focus. In Safari this event
// may not be received if the element immediately loses focus after it
// is triggered.
listeners.add(container, 'focusin', event => {
const elements = getNavigableElements();
const targetIndex = elements.indexOf(event.target as HTMLElement);
if (targetIndex >= 0) {
updateTabIndexes(elements, targetIndex);
}
});
listeners.add(container, 'keydown', onKeyDown);
// Update the tab indexes of elements as they are added, removed, enabled
// or disabled.
const mo = new MutationObserver(() => {
updateTabIndexes();
});
mo.observe(container, {
subtree: true,
attributes: true,
attributeFilter: ['disabled'],
childList: true,
});
return () => {
listeners.removeAll();
mo.disconnect();
};
}, [autofocus, containerRef, horizontal, selector, vertical]);
}
import { waitFor } from '@hypothesis/frontend-testing';
import { options as preactOptions, render } from 'preact';
import { useRef } from 'preact/hooks';
import { act } from 'preact/test-utils';
import { useArrowKeyNavigation } from '../keyboard-navigation';
function Toolbar({ navigationOptions = {} }) {
const containerRef = useRef();
useArrowKeyNavigation(containerRef, navigationOptions);
return (
<div ref={containerRef} data-testid="toolbar">
<button data-testid="bold">Bold</button>
<button data-testid="italic">Italic</button>
<button data-testid="underline">Underline</button>
<a href="/help" target="_blank" data-testid="help">
Help
</a>
</div>
);
}
describe('shared/keyboard-navigation', () => {
describe('useArrowKeyNavigation', () => {
let container;
beforeEach(() => {
container = document.createElement('div');
document.body.append(container);
renderToolbar();
});
afterEach(() => {
container.remove();
});
// Workaround for an issue with `useEffect` throwing exceptions during
// `act` callbacks. Can be removed when https://github.com/preactjs/preact/pull/3530 is shipped.
let prevDebounceRendering;
beforeEach(() => {
prevDebounceRendering = preactOptions.debounceRendering;
});
afterEach(() => {
preactOptions.debounceRendering = prevDebounceRendering;
});
function renderToolbar(options = {}) {
// We render the component with Preact directly rather than using Enzyme
// for these tests. Since the `tabIndex` state lives only in the DOM,
// and there are no child components involved, this is more convenient.
act(() => {
render(<Toolbar navigationOptions={options} />, container);
});
return findElementByTestId('toolbar');
}
function findElementByTestId(testId) {
return container.querySelector(`[data-testid=${testId}]`);
}
function pressKey(key) {
const event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key,
});
act(() => {
findElementByTestId('toolbar').dispatchEvent(event);
});
return event;
}
function currentItem() {
return document.activeElement.innerText;
}
[
{ forwardKey: 'ArrowRight', backKey: 'ArrowLeft' },
{ forwardKey: 'ArrowDown', backKey: 'ArrowUp' },
].forEach(({ forwardKey, backKey }) => {
it('should move focus and tab stop between elements when arrow keys are pressed', () => {
const steps = [
// Test navigating forwards.
[forwardKey, 'Italic'],
[forwardKey, 'Underline'],
[forwardKey, 'Help'],
// Test that navigation wraps to start.
[forwardKey, 'Bold'],
// Test that navigation wraps to end.
[backKey, 'Help'],
// Test navigating backwards.
[backKey, 'Underline'],
[backKey, 'Italic'],
[backKey, 'Bold'],
// Test jump to start / end.
['End', 'Help'],
['Home', 'Bold'],
];
for (let [key, expectedItem] of steps) {
pressKey(key);
const currentElement = document.activeElement;
assert.equal(currentElement.innerText, expectedItem);
const toolbarButtons = container.querySelectorAll('a,button');
for (let element of toolbarButtons) {
if (element === currentElement) {
assert.equal(element.tabIndex, 0);
} else {
assert.equal(element.tabIndex, -1);
}
}
}
});
});
[
// Keys handled with default options.
{
key: 'ArrowLeft',
shouldHandle: true,
},
{
key: 'ArrowRight',
shouldHandle: true,
},
{
key: 'ArrowUp',
shouldHandle: true,
},
{
key: 'ArrowDown',
shouldHandle: true,
},
{
key: 'End',
shouldHandle: true,
},
{
key: 'Home',
shouldHandle: true,
},
// Keys never handled.
{
key: 'Space',
shouldHandle: false,
},
// Keys not handled if horizontal navigation is disabled
{
key: 'ArrowLeft',
horizontal: false,
shouldHandle: false,
},
{
key: 'ArrowRight',
horizontal: false,
shouldHandle: false,
},
// Keys not handled if vertical navigation is disabled
{
key: 'ArrowUp',
vertical: false,
shouldHandle: false,
},
{
key: 'ArrowDown',
vertical: false,
shouldHandle: false,
},
].forEach(({ key, horizontal, vertical, shouldHandle }) => {
it('should stop keyboard event propagation if event is handled', () => {
renderToolbar({ horizontal, vertical });
const handleKeyDown = sinon.stub();
container.addEventListener('keydown', handleKeyDown);
const event = pressKey(key);
assert.equal(
event.defaultPrevented,
shouldHandle,
`${key} defaultPrevented`,
);
assert.equal(handleKeyDown.called, !shouldHandle, `${key} propagated`);
handleKeyDown.resetHistory();
});
});
it('should skip hidden elements', () => {
renderToolbar();
findElementByTestId('bold').focus();
findElementByTestId('italic').style.display = 'none';
pressKey('ArrowRight');
assert.equal(currentItem(), 'Underline');
});
it('should skip disabled elements', () => {
renderToolbar();
findElementByTestId('bold').focus();
findElementByTestId('italic').disabled = true;
pressKey('ArrowRight');
assert.equal(currentItem(), 'Underline');
});
it('should not respond to Up/Down arrow keys if vertical navigation is disabled', () => {
renderToolbar({ vertical: false });
findElementByTestId('bold').focus();
pressKey('ArrowDown');
assert.equal(currentItem(), 'Bold');
});
it('should not respond to Left/Right arrow keys if horizontal navigation is disabled', () => {
renderToolbar({ horizontal: false });
findElementByTestId('bold').focus();
pressKey('ArrowRight');
assert.equal(currentItem(), 'Bold');
});
it('shows an error if container ref is not initialized', () => {
function BrokenToolbar() {
const ref = useRef();
useArrowKeyNavigation(ref);
return <div />;
}
// Suppress "Add @babel/plugin-transform-react-jsx-source to get a more
// detailed component stack" warning from the `render` call below.
sinon.stub(console, 'warn');
let error;
try {
act(() => render(<BrokenToolbar />, container));
} catch (e) {
error = e;
} finally {
console.warn.restore();
}
assert.instanceOf(error, Error);
assert.equal(error.message, 'Container ref not set');
});
it('should respect a custom element selector', () => {
renderToolbar({
selector: '[data-testid=bold],[data-testid=italic]',
});
findElementByTestId('bold').focus();
pressKey('ArrowRight');
assert.equal(currentItem(), 'Italic');
pressKey('ArrowRight');
assert.equal(currentItem(), 'Bold');
pressKey('ArrowLeft');
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]');
const italicButton = toolbar.querySelector('[data-testid=italic]');
boldButton.focus();
assert.equal(boldButton.tabIndex, 0);
assert.equal(italicButton.tabIndex, -1);
boldButton.remove();
// nb. tabIndex update is async because it uses MutationObserver
await waitFor(() => italicButton.tabIndex === 0);
});
it('should re-initialize tabindex attributes if current element is disabled', async () => {
renderToolbar();
const boldButton = findElementByTestId('bold');
const italicButton = findElementByTestId('italic');
boldButton.focus();
assert.equal(boldButton.tabIndex, 0);
assert.equal(italicButton.tabIndex, -1);
boldButton.disabled = true;
// nb. tabIndex update is async because it uses MutationObserver
await waitFor(() => italicButton.tabIndex === 0);
});
});
});
......@@ -9,13 +9,13 @@ import {
LinkIcon,
ListOrderedIcon,
ListUnorderedIcon,
useArrowKeyNavigation,
} from '@hypothesis/frontend-shared';
import type { IconComponent } from '@hypothesis/frontend-shared/lib/types';
import classnames from 'classnames';
import type { Ref, JSX } from 'preact';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useArrowKeyNavigation } from '../../shared/keyboard-navigation';
import { isMacOS } from '../../shared/user-agent';
import {
LinkType,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment