Commit ab16a393 authored by Robert Knight's avatar Robert Knight

Extract arrow key navigation into a reusable hook

Extract the arrow key navigation logic from `MarkdownEditor` into a
reusable `useArrowKeyNavigation` navigation hook. This simplifies the
MarkdownEditor component and will allow us to enable arrow key
navigation more widely thoughout the application.

A notable design choice is that the roving tab index state lives in the DOM
rather than in Preact. This enables the Preact component-facing API to be very
simple: a single hook call in the component that renders the container element
(of the toolbar, menu bar etc.). It does mean however that the `tabIndex` state
is not accessible to components. This works for the use cases where I have
tested it, but we may need to revisit in future.

If browsers add a native feature to handle this in future [1], the hook
could handle testing for support and using it where available.

 - Create `useArrowKeyNavigation` navigation hook in
   `src/shared/keyboard-navigation.js` as a general method of adding
   arrow key navigation to a composite widget

 - Modify `MarkdownEditor` to use the `useArrowKeyNavigation` hook to
   handle arrow key navigation

 - Replace detailed tests for arrow key navigation in `MarkdownEditor`
   with more basic tests, as the `useArrowKeyNavigation` tests cover the
   general functionality in detail

[1] eg. https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/Focusgroup/explainer.md
parent b436080d
import { useEffect } from 'preact/hooks';
import { ListenerCollection } from './listener-collection';
/**
* @param {HTMLElement & { disabled?: boolean }} element
*/
function isElementDisabled(element) {
return typeof element.disabled === 'boolean' && element.disabled;
}
/** @param {HTMLElement} element */
function isElementVisible(element) {
return element.offsetParent !== null;
}
/**
* 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 which sets the `tabindex`
* attribute of elements to control which element gets focus when the user
* tabs into the container.
*
* See [1] 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/#keyboard
*
* @param {import('preact').RefObject<HTMLElement>} containerRef
* @param {object} options
* @param {boolean} [options.autofocus] - Whether to focus the first element
* in the set of matching elements when the component is mounted
* @param {boolean} [options.horizontal] - Enable navigating elements using left/right arrow keys
* @param {boolean} [options.vertical] - Enable navigating elements using up/down arrow keys
* @param {string} [options.selector] - CSS selector which specifies the
* elements that navigation moves between
*/
export function useArrowKeyNavigation(
containerRef,
{
autofocus = false,
horizontal = true,
vertical = true,
selector = 'a,button',
} = {}
) {
useEffect(() => {
if (!containerRef.current) {
throw new Error('Container ref not set');
}
const container = containerRef.current;
const getNavigableElements = () => {
const elements = /** @type {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 {HTMLElement[]} elements
* @param {number} currentIndex - Index of element in `elements` to make current.
* Defaults to the current element if there is one, or the first element
* otherwise.
* @param {boolean} setFocus - Whether to focus the current element
*/
const updateTabIndexes = (
elements = getNavigableElements(),
currentIndex = -1,
setFocus = false
) => {
if (currentIndex < 0) {
currentIndex = elements.findIndex(el => el.tabIndex === 0);
if (currentIndex < 0) {
currentIndex = 0;
}
}
for (let [index, element] of elements.entries()) {
element.tabIndex = index === currentIndex ? 0 : -1;
if (index === currentIndex && setFocus) {
element.focus();
}
}
};
/** @param {KeyboardEvent} event */
const onKeyDown = event => {
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(
/** @type {HTMLElement} */ (event.target)
);
if (targetIndex >= 0) {
updateTabIndexes(elements, targetIndex);
}
});
listeners.add(
container,
'keydown',
/** @type {EventListener} */ (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 { options as preactOptions, render } from 'preact';
import { useRef } from 'preact/hooks';
import { act } from 'preact/test-utils';
import { useArrowKeyNavigation } from '../keyboard-navigation';
import { waitFor } from '../../test-util/wait';
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();
// Suppress "Add @babel/plugin-transform-react-jsx-source to get a more
// detailed component stack" warning in tests that trigger an error during
// effects.
sinon.stub(console, 'warn');
});
afterEach(() => {
container.remove();
console.warn.restore();
});
// 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 = {}) {
act(() => {
render(<Toolbar navigationOptions={options} />, container);
});
return getElement('toolbar');
}
function getElement(testId) {
return container.querySelector(`[data-testid=${testId}]`);
}
function getElements() {
return Array.from(getElement('toolbar').querySelectorAll('a,button'));
}
function pressKey(key) {
const event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key,
});
act(() => {
getElement('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);
for (let element of getElements()) {
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();
getElement('bold').focus();
getElement('italic').style.display = 'none';
pressKey('ArrowRight');
assert.equal(currentItem(), 'Underline');
});
it('should skip disabled elements', () => {
renderToolbar();
getElement('bold').focus();
getElement('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 });
getElement('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 });
getElement('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 />;
}
let error;
try {
act(() => render(<BrokenToolbar />, container));
} catch (e) {
error = e;
}
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]',
});
getElement('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 = getElement('bold');
const italicButton = getElement('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);
});
});
});
...@@ -6,7 +6,6 @@ import { ...@@ -6,7 +6,6 @@ import {
normalizeKeyName, normalizeKeyName,
} from '@hypothesis/frontend-shared'; } from '@hypothesis/frontend-shared';
import classnames from 'classnames'; import classnames from 'classnames';
import { createRef } from 'preact';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { import {
...@@ -16,6 +15,7 @@ import { ...@@ -16,6 +15,7 @@ import {
toggleSpanStyle, toggleSpanStyle,
} from '../markdown-commands'; } from '../markdown-commands';
import { isMacOS } from '../../shared/user-agent'; import { isMacOS } from '../../shared/user-agent';
import { useArrowKeyNavigation } from '../../shared/keyboard-navigation';
import MarkdownView from './MarkdownView'; import MarkdownView from './MarkdownView';
...@@ -156,25 +156,21 @@ function IconLink({ classes, icon, linkRef, ...restProps }) { ...@@ -156,25 +156,21 @@ function IconLink({ classes, icon, linkRef, ...restProps }) {
/** /**
* @typedef ToolbarButtonProps * @typedef ToolbarButtonProps
* @prop {import('preact').Ref<HTMLButtonElement>} buttonRef
* @prop {boolean} [disabled] * @prop {boolean} [disabled]
* @prop {string} [iconName] * @prop {string} [iconName]
* @prop {string} [label] * @prop {string} [label]
* @prop {(e: MouseEvent) => void} onClick * @prop {(e: MouseEvent) => void} onClick
* @prop {string} [shortcutKey] * @prop {string} [shortcutKey]
* @prop {number} tabIndex
* @prop {string} [title] * @prop {string} [title]
*/ */
/** @param {ToolbarButtonProps} props */ /** @param {ToolbarButtonProps} props */
function ToolbarButton({ function ToolbarButton({
buttonRef,
disabled = false, disabled = false,
iconName = '', iconName = '',
label, label,
onClick, onClick,
shortcutKey, shortcutKey,
tabIndex,
title = '', title = '',
}) { }) {
const modifierKey = useMemo(() => (isMacOS() ? 'Cmd' : 'Ctrl'), []); const modifierKey = useMemo(() => (isMacOS() ? 'Cmd' : 'Ctrl'), []);
...@@ -185,11 +181,9 @@ function ToolbarButton({ ...@@ -185,11 +181,9 @@ function ToolbarButton({
} }
const buttonProps = { const buttonProps = {
buttonRef,
disabled, disabled,
icon: iconName, icon: iconName,
onClick, onClick,
tabIndex,
title: tooltip, title: tooltip,
}; };
...@@ -252,113 +246,8 @@ function TextArea({ classes, containerRef, ...restProps }) { ...@@ -252,113 +246,8 @@ function TextArea({ classes, containerRef, ...restProps }) {
* @param {ToolbarProps} props * @param {ToolbarProps} props
*/ */
function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
const buttonIds = { const toolbarContainer = useRef(null);
// Ordered buttons useArrowKeyNavigation(toolbarContainer);
bold: 0,
italic: 1,
quote: 2,
link: 3,
image: 4,
math: 5,
numlist: 6,
list: 7,
help: 8,
preview: 9,
// Total button count
maxId: 10,
};
// Keep track of a roving index. The active roving tabIndex
// is set to 0, and all other elements are set to -1.
const [rovingElement, setRovingElement] = useState(0);
/** @type {Ref<HTMLElement>[]} */
const buttonRefs = useRef([]).current;
if (buttonRefs.length === 0) {
// Initialize buttonRefs on first render only
for (let i = 0; i <= buttonIds.maxId; i++) {
buttonRefs.push(createRef());
}
}
/**
* @template {HTMLElement} ElementType
* @param {keyof buttonIds} buttonId
* @return {Ref<ElementType>}
*/
function getRef(buttonId) {
return /** @type {Ref<ElementType>} */ (buttonRefs[buttonIds[buttonId]]);
}
/**
* Sets the element to be both focused and the active roving index.
*
* @param {number} index - Ordered index that matches the element
*/
const setFocusedElement = index => {
setRovingElement(index);
/** @type {HTMLElement} */ (buttonRefs[index].current).focus();
};
/**
* Handles left and right arrow navigation as well as home and end
* keys so the user may navigate the toolbar without multiple tab stops.
*
* @param {KeyboardEvent} e
*/
const handleKeyDown = e => {
let lowerLimit = 0;
const upperLimit = buttonIds.maxId - 1;
if (isPreviewing) {
// When isPreviewing is true, only allow navigation of
// the last 2 items.
lowerLimit = buttonIds.help;
}
let newFocusedElement = null;
switch (normalizeKeyName(e.key)) {
case 'ArrowLeft':
if (rovingElement <= lowerLimit) {
newFocusedElement = upperLimit;
} else {
newFocusedElement = rovingElement - 1;
}
break;
case 'ArrowRight':
if (rovingElement >= upperLimit) {
newFocusedElement = lowerLimit;
} else {
newFocusedElement = rovingElement + 1;
}
break;
case 'Home':
newFocusedElement = lowerLimit;
break;
case 'End':
newFocusedElement = upperLimit;
break;
}
if (newFocusedElement !== null) {
setFocusedElement(newFocusedElement);
e.preventDefault();
}
};
/**
* Returns the tab index value for a given element.
* Each element should be set to -1 unless its the
* active roving index, in which case it will be 0.
*
* @param {number} index - An index from `buttonIds`
* @return {number}
*/
const getTabIndex = index => {
if (rovingElement === index) {
return 0;
} else {
return -1;
}
};
return ( return (
<div <div
...@@ -374,15 +263,13 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -374,15 +263,13 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
data-testid="markdown-toolbar" data-testid="markdown-toolbar"
role="toolbar" role="toolbar"
aria-label="Markdown editor toolbar" aria-label="Markdown editor toolbar"
onKeyDown={handleKeyDown} ref={toolbarContainer}
> >
<ToolbarButton <ToolbarButton
disabled={isPreviewing} disabled={isPreviewing}
iconName="format-bold" iconName="format-bold"
onClick={() => onCommand('bold')} onClick={() => onCommand('bold')}
shortcutKey={SHORTCUT_KEYS.bold} shortcutKey={SHORTCUT_KEYS.bold}
buttonRef={getRef('bold')}
tabIndex={getTabIndex(buttonIds.bold)}
title="Bold" title="Bold"
/> />
<ToolbarButton <ToolbarButton
...@@ -390,8 +277,6 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -390,8 +277,6 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
iconName="format-italic" iconName="format-italic"
onClick={() => onCommand('italic')} onClick={() => onCommand('italic')}
shortcutKey={SHORTCUT_KEYS.italic} shortcutKey={SHORTCUT_KEYS.italic}
buttonRef={getRef('italic')}
tabIndex={getTabIndex(buttonIds.italic)}
title="Italic" title="Italic"
/> />
<ToolbarButton <ToolbarButton
...@@ -399,8 +284,6 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -399,8 +284,6 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
iconName="format-quote" iconName="format-quote"
onClick={() => onCommand('quote')} onClick={() => onCommand('quote')}
shortcutKey={SHORTCUT_KEYS.quote} shortcutKey={SHORTCUT_KEYS.quote}
buttonRef={getRef('quote')}
tabIndex={getTabIndex(buttonIds.quote)}
title="Quote" title="Quote"
/> />
<ToolbarButton <ToolbarButton
...@@ -408,8 +291,6 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -408,8 +291,6 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
iconName="link" iconName="link"
onClick={() => onCommand('link')} onClick={() => onCommand('link')}
shortcutKey={SHORTCUT_KEYS.link} shortcutKey={SHORTCUT_KEYS.link}
buttonRef={getRef('link')}
tabIndex={getTabIndex(buttonIds.link)}
title="Insert link" title="Insert link"
/> />
<ToolbarButton <ToolbarButton
...@@ -417,16 +298,12 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -417,16 +298,12 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
iconName="image" iconName="image"
onClick={() => onCommand('image')} onClick={() => onCommand('image')}
shortcutKey={SHORTCUT_KEYS.image} shortcutKey={SHORTCUT_KEYS.image}
buttonRef={getRef('image')}
tabIndex={getTabIndex(buttonIds.image)}
title="Insert image" title="Insert image"
/> />
<ToolbarButton <ToolbarButton
disabled={isPreviewing} disabled={isPreviewing}
iconName="format-functions" iconName="format-functions"
onClick={() => onCommand('math')} onClick={() => onCommand('math')}
buttonRef={getRef('math')}
tabIndex={getTabIndex(buttonIds.math)}
title="Insert math (LaTeX is supported)" title="Insert math (LaTeX is supported)"
/> />
<ToolbarButton <ToolbarButton
...@@ -434,8 +311,6 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -434,8 +311,6 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
iconName="format-list-numbered" iconName="format-list-numbered"
onClick={() => onCommand('numlist')} onClick={() => onCommand('numlist')}
shortcutKey={SHORTCUT_KEYS.numlist} shortcutKey={SHORTCUT_KEYS.numlist}
buttonRef={getRef('numlist')}
tabIndex={getTabIndex(buttonIds.numlist)}
title="Numbered list" title="Numbered list"
/> />
<ToolbarButton <ToolbarButton
...@@ -443,8 +318,6 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -443,8 +318,6 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
iconName="format-list-unordered" iconName="format-list-unordered"
onClick={() => onCommand('list')} onClick={() => onCommand('list')}
shortcutKey={SHORTCUT_KEYS.list} shortcutKey={SHORTCUT_KEYS.list}
buttonRef={getRef('list')}
tabIndex={getTabIndex(buttonIds.list)}
title="Bulleted list" title="Bulleted list"
/> />
<div className="grow flex justify-end"> <div className="grow flex justify-end">
...@@ -457,16 +330,12 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) { ...@@ -457,16 +330,12 @@ function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
href="https://web.hypothes.is/help/formatting-annotations-with-markdown/" href="https://web.hypothes.is/help/formatting-annotations-with-markdown/"
icon="help" icon="help"
target="_blank" target="_blank"
linkRef={getRef('help')}
tabIndex={getTabIndex(buttonIds.help)}
title="Formatting help" title="Formatting help"
aria-label="Formatting help" aria-label="Formatting help"
/> />
<ToolbarButton <ToolbarButton
label={isPreviewing ? 'Write' : 'Preview'} label={isPreviewing ? 'Write' : 'Preview'}
onClick={onTogglePreview} onClick={onTogglePreview}
buttonRef={getRef('preview')}
tabIndex={getTabIndex(buttonIds.preview)}
/> />
</div> </div>
</div> </div>
......
...@@ -311,172 +311,57 @@ describe('MarkdownEditor', () => { ...@@ -311,172 +311,57 @@ describe('MarkdownEditor', () => {
newContainer.remove(); newContainer.remove();
}); });
/**
* Helper method to simulate a keypress on the markdown wrapper
*
* @param {string} key - One of 'ArrowRight', 'ArrowLeft', 'End', 'Home'
*/
const pressKey = key => const pressKey = key =>
wrapper wrapper
.find('[data-testid="markdown-toolbar"]') .find('[data-testid="markdown-toolbar"]')
.simulate('keydown', { key }); .simulate('keydown', { key });
/** function testArrowKeySequence(buttons) {
* Asserts the active button's title partially matches the supplied string. for (let button of buttons) {
* pressKey('ArrowRight');
* @param {string} partialTitle const label =
*/ document.activeElement.getAttribute('title') ||
const matchesFocusedTitle = partialTitle => { document.activeElement.innerText;
assert.isTrue( assert.include(label, button);
document.activeElement.getAttribute('title').indexOf(partialTitle) >= 0 }
); }
};
/**
* Asserts the active button's inner text partially matches the supplied string.
*
* @param {string} partialText
*/
const matchesFocusedText = partialText => {
assert.isTrue(document.activeElement.innerText.indexOf(partialText) >= 0);
};
/**
* Asserts there should only be one "0" `tabIndex` value at a time which
* should be set on the focused element. All other `tabIndex` values
* on elements shall be "-1".
*/
const testRovingIndex = () => {
assert.isTrue(document.activeElement.getAttribute('tabIndex') === '0');
assert.equal(
wrapper.find('ToolbarButton[tabIndex=0]').length +
wrapper.find('a[tabIndex=0]').length,
1
);
};
context('when `isPreviewing` is false', () => { context('when `isPreviewing` is false', () => {
it('changes focus circularly to the left', () => { // This is a basic test of arrow key navigation in this component.
pressKey('ArrowLeft'); // `useArrowKeyNavigation` tests cover this more fully.
// preview is the last button it('arrow keys navigate through buttons', () => {
matchesFocusedText('Preview'); const buttons = [
testRovingIndex(); 'Italic',
}); 'Quote',
'Insert link',
it('changes focus circularly to the right', () => { 'Insert image',
pressKey('ArrowLeft'); // move to the end node 'Insert math',
pressKey('ArrowRight'); // move back to the start 'Numbered list',
matchesFocusedTitle('Bold'); 'Bulleted list',
testRovingIndex(); 'Formatting help',
}); 'Preview',
'Bold',
it('changes focus to the last element when pressing `end`', () => { 'Italic',
pressKey('End'); // move to the end node ];
matchesFocusedText('Preview'); testArrowKeySequence(buttons);
testRovingIndex();
});
it('changes focus to the first element when pressing `home`', () => {
pressKey('ArrowRight'); // move focus off first button
pressKey('Home');
matchesFocusedTitle('Bold');
testRovingIndex();
});
it('preserves the elements order and roving index', () => {
[
{
title: 'Italic',
},
{
title: 'Quote',
},
{
title: 'Insert link',
},
{
title: 'Insert image',
},
{
title: 'Insert math (LaTeX is supported)',
},
{
title: 'Numbered list',
},
{
title: 'Bulleted list',
},
{
title: 'Formatting help',
},
{
text: 'Preview',
},
{
// back to the start
title: 'Bold',
},
].forEach(test => {
pressKey('ArrowRight');
if (test.title) {
matchesFocusedTitle(test.title);
}
if (test.text) {
matchesFocusedText(test.text);
}
testRovingIndex();
});
}); });
}); });
context('when `isPreviewing` is true', () => { context('when `isPreviewing` is true', () => {
beforeEach(() => { beforeEach(() => {
// turn on Preview mode
act(() => {
wrapper.find('Toolbar').props().onTogglePreview();
});
const previewButton = wrapper const previewButton = wrapper
.find('button') .find('button')
.filterWhere(el => el.text() === 'Write'); .filterWhere(el => el.text() === 'Preview');
previewButton.simulate('focus'); previewButton.simulate('click');
pressKey('Home');
});
it('changes focus to the last element when pressing `end`', () => {
pressKey('End'); // move to the end node
matchesFocusedText('Write');
testRovingIndex();
});
it('changes focus to the first element when pressing `home`', () => {
pressKey('ArrowRight'); // move focus off first button
pressKey('Home'); pressKey('Home');
matchesFocusedTitle('Formatting help');
testRovingIndex();
}); });
it('preserves the elements order', () => { // This is a basic test of arrow key navigation in this component.
[ // `useArrowKeyNavigation` tests cover this more fully.
{ it('arrow keys navigate through buttons', () => {
text: 'Write', const buttons = ['Write', 'Formatting help', 'Write'];
}, testArrowKeySequence(buttons);
{
title: 'Formatting help',
},
{
// back to the start
text: 'Write',
},
].forEach(test => {
// only 2 enabled buttons
pressKey('ArrowRight');
if (test.title) {
matchesFocusedTitle(test.title);
}
if (test.text) {
matchesFocusedText(test.text);
}
testRovingIndex();
});
}); });
}); });
}); });
......
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