utils: add Toolbar component

This adds a generic toolbar component that follow aria guidelines.
This commit is contained in:
David Lechner
2022-05-13 22:36:01 -05:00
parent a185457067
commit f0dcb281de
5 changed files with 290 additions and 10 deletions
+26 -8
View File
@@ -13,7 +13,7 @@ import {
useHotkeys,
} from '@blueprintjs/core';
import { I18n, useI18n } from '@shopify/react-i18n';
import React, { useCallback, useMemo, useState } from 'react';
import React, { RefObject, useCallback, useMemo, useRef, useState } from 'react';
import {
ControlledTreeEnvironment,
LiveDescriptors,
@@ -25,7 +25,9 @@ import {
} from 'react-complex-tree';
import { useDispatch } from 'react-redux';
import { useSelector } from '../reducers';
import Toolbar from '../utils/Toolbar';
import { isMacOS } from '../utils/os';
import { useRovingTabIndex } from '../utils/react';
import { TreeItemContext, TreeItemData, renderers } from '../utils/tree-renderer';
import {
explorerActivateFile,
@@ -48,6 +50,8 @@ type ActionButtonProps = {
tooltip: string;
/** If false, prevent focus. Default is true. */
focusable?: boolean;
/** Reference to the `<button>` HTML element. */
elementRef?: RefObject<HTMLButtonElement>;
/** Callback for button click event. */
onClick: () => void;
};
@@ -56,6 +60,7 @@ const ActionButton: React.VoidFunctionComponent<ActionButtonProps> = ({
icon,
tooltip,
focusable,
elementRef,
onClick,
}) => {
const handleClick = useCallback<React.MouseEventHandler>(
@@ -72,6 +77,7 @@ const ActionButton: React.VoidFunctionComponent<ActionButtonProps> = ({
icon={icon}
title={tooltip}
tabIndex={focusable === false ? -1 : undefined}
elementRef={elementRef}
onFocus={focusable === false ? (e) => e.preventDefault() : undefined}
onClick={handleClick}
/>
@@ -137,18 +143,28 @@ type HeaderProps = {
};
const Header: React.VoidFunctionComponent<HeaderProps> = ({ i18n }) => {
const archiveButtonRef = useRef<HTMLButtonElement>(null);
const exportButtonRef = useRef<HTMLButtonElement>(null);
const newButtonRef = useRef<HTMLButtonElement>(null);
const dispatch = useDispatch();
const moveFocus = useRovingTabIndex(
archiveButtonRef,
exportButtonRef,
newButtonRef,
);
return (
<div className="pb-explorer-header">
<ButtonGroup
minimal={true}
role="toolbar"
aria-label={i18n.translate(I18nId.HeaderToolbarTitle)}
>
<Toolbar
className="pb-explorer-header-toolbar"
aria-label={i18n.translate(I18nId.HeaderToolbarTitle)}
onKeyboard={moveFocus}
>
<ButtonGroup minimal={true}>
<ActionButton
icon="archive"
tooltip={i18n.translate(I18nId.HeaderToolbarExportAll)}
elementRef={archiveButtonRef}
onClick={() => dispatch(explorerArchiveAllFiles())}
/>
<ActionButton
@@ -157,15 +173,17 @@ const Header: React.VoidFunctionComponent<HeaderProps> = ({ i18n }) => {
// even though this is the "import" action
icon="export"
tooltip={i18n.translate(I18nId.HeaderToolbarImport)}
elementRef={exportButtonRef}
onClick={() => dispatch(explorerImportFiles())}
/>
<ActionButton
icon="plus"
tooltip={i18n.translate(I18nId.HeaderToolbarAddNew)}
elementRef={newButtonRef}
onClick={() => dispatch(explorerCreateNewFile())}
/>
</ButtonGroup>
</div>
</Toolbar>
);
};
+1 -1
View File
@@ -9,7 +9,7 @@
}
.pb-explorer {
&-header {
&-header-toolbar {
display: flex;
justify-content: flex-end;
}
+69
View File
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import React, { AriaAttributes, KeyboardEventHandler, useCallback } from 'react';
import { FocusAction } from './react';
type ToolbarProps = Pick<AriaAttributes, 'aria-label' | 'aria-labelledby'> & {
/** CSS class name for the tooltip element. */
className?: string;
/** Indicates that the toolbar has a vertical orientation. */
vertical?: boolean;
/** Called when a keyboard event occurs. */
onKeyboard?: (action: FocusAction) => void;
};
/**
* An accessible toolbar component.
*
* For accessible keyboard navigation, this needs to be used with a focus
* manager like `useRovingTabIndex`.
*
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/toolbar_role
*/
const Toolbar: React.FunctionComponent<ToolbarProps> = ({
children,
vertical,
onKeyboard,
...divProps
}) => {
const handleKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
(e) => {
// ignore all key presses with modifiers
if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) {
return;
}
if (e.key === (vertical ? 'ArrowUp' : 'ArrowLeft')) {
onKeyboard?.(FocusAction.MovePrev);
} else if (e.key === (vertical ? 'ArrowDown' : 'ArrowRight')) {
onKeyboard?.(FocusAction.MoveNext);
} else if (e.key === 'Home') {
onKeyboard?.(FocusAction.MoveFirst);
} else if (e.key === 'End') {
onKeyboard?.(FocusAction.MoveLast);
} else {
// allow everything else to propagate.
return;
}
// we consumed the key press
e.preventDefault();
e.stopPropagation();
},
[vertical, onKeyboard],
);
return (
<div
role="toolbar"
aria-orientation={vertical ? 'vertical' : undefined}
{...divProps}
onKeyDown={handleKeyDown}
>
{children}
</div>
);
};
export default Toolbar;
+119
View File
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useRef } from 'react';
import { testRender } from '../../test';
import Toolbar from './Toolbar';
import { useRovingTabIndex } from './react';
afterEach(() => {
cleanup();
});
const TestToolbar: React.VoidFunctionComponent = () => {
const button1Ref = useRef<HTMLButtonElement>(null);
const button2Ref = useRef<HTMLButtonElement>(null);
const button3Ref = useRef<HTMLButtonElement>(null);
const moveFocus = useRovingTabIndex(button1Ref, button2Ref, button3Ref);
return (
<Toolbar onKeyboard={moveFocus}>
<button data-testid="button1" ref={button1Ref} />
<button data-testid="button2" ref={button2Ref} />
<button data-testid="button3" ref={button3Ref} />
</Toolbar>
);
};
describe('useRovingTabIndex', () => {
it('should focus the first element by default', async () => {
const [toolbar] = testRender(<TestToolbar />);
const button1 = toolbar.getByTestId('button1');
const button2 = toolbar.getByTestId('button2');
const button3 = toolbar.getByTestId('button3');
() => expect(button1).toHaveAttribute('tabindex', '0');
() => expect(button2).toHaveAttribute('tabindex', '-1');
() => expect(button3).toHaveAttribute('tabindex', '-1');
userEvent.tab();
expect(button1).toHaveFocus();
});
it('should focus next with right arrow key', () => {
const [toolbar] = testRender(<TestToolbar />);
toolbar.getByTestId('button1').focus();
userEvent.keyboard('[ArrowRight]');
expect(toolbar.getByTestId('button2')).toHaveFocus();
});
it('should focus previous with left arrow key', () => {
const [toolbar] = testRender(<TestToolbar />);
toolbar.getByTestId('button3').focus();
userEvent.keyboard('[ArrowLeft]');
expect(toolbar.getByTestId('button2')).toHaveFocus();
});
it('should wrap focus next with right arrow key', () => {
const [toolbar] = testRender(<TestToolbar />);
toolbar.getByTestId('button3').focus();
userEvent.keyboard('[ArrowRight]');
expect(toolbar.getByTestId('button1')).toHaveFocus();
});
it('should wrap focus previous with left arrow key', () => {
const [toolbar] = testRender(<TestToolbar />);
toolbar.getByTestId('button1').focus();
userEvent.keyboard('[ArrowLeft]');
expect(toolbar.getByTestId('button3')).toHaveFocus();
});
it('should focus first with home key', () => {
const [toolbar] = testRender(<TestToolbar />);
toolbar.getByTestId('button3').focus();
userEvent.keyboard('[Home]');
expect(toolbar.getByTestId('button1')).toHaveFocus();
});
it('should focus last with end key', () => {
const [toolbar] = testRender(<TestToolbar />);
toolbar.getByTestId('button1').focus();
userEvent.keyboard('[End]');
expect(toolbar.getByTestId('button3')).toHaveFocus();
});
it('should not change focus with up arrow key', () => {
const [toolbar] = testRender(<TestToolbar />);
toolbar.getByTestId('button2').focus();
userEvent.keyboard('[ArrowUp]');
expect(toolbar.getByTestId('button2')).toHaveFocus();
});
it('should not change focus with down arrow key', () => {
const [toolbar] = testRender(<TestToolbar />);
toolbar.getByTestId('button2').focus();
userEvent.keyboard('[ArrowDown]');
expect(toolbar.getByTestId('button2')).toHaveFocus();
});
});
+75 -1
View File
@@ -1,6 +1,7 @@
// helper functions for React components
import { useState } from 'react';
import { IRefObject } from '@blueprintjs/core';
import { useEffect, useMemo, useState } from 'react';
import { createCountFunc } from './iter';
const nextId = createCountFunc();
@@ -16,3 +17,76 @@ export const useUniqueId = (prefix: string): string => {
const [uniqueId] = useState(`${prefix}-${nextId()}`);
return uniqueId;
};
/** Describes the requested interaction. */
export enum FocusAction {
/** Move focus to the previous control. */
MovePrev,
/** Move focus to the next control. */
MoveNext,
/** Move focus to the first control. */
MoveFirst,
/** Move focus to the last control. */
MoveLast,
}
/**
* React hook to manage roving tab index for accessible keyboard navigation
* of components.
*
* @param elements References to elements to be focuses in the order they
* they should be focused. The number of elements and order must be constant!
*
* @returns A function that should be used to focus an element in reaction to
* a keyboard event. For example, this can be passed directly as the
* `onKeyboard` property of the `Toolbar` component.
*/
export function useRovingTabIndex(
...elements: Array<IRefObject<HTMLElement>>
): (action: FocusAction) => void {
// when the component is first mounted, the first item will be the focus target
useEffect(() => {
for (const [i, e] of elements.entries()) {
e.current?.setAttribute('tabindex', i === 0 ? '0' : '-1');
}
}, [...elements]);
return useMemo(() => {
function move(action: FocusAction): void {
// default is to focus the first element
let newFocusIndex = 0;
if (action === FocusAction.MoveFirst) {
// correct index is already selected
} else if (action === FocusAction.MoveLast) {
newFocusIndex = elements.length - 1;
} else {
// find the currently focused element, if any
const currentFocusIndex = elements.findIndex(
(e) => e.current === document.activeElement,
);
if (currentFocusIndex >= 0) {
if (action === FocusAction.MovePrev) {
newFocusIndex = currentFocusIndex - 1;
} else if (action === FocusAction.MoveNext) {
newFocusIndex = currentFocusIndex + 1;
}
// handle wrap around
newFocusIndex = (newFocusIndex + elements.length) % elements.length;
}
}
for (const [i, e] of elements.entries()) {
e.current?.setAttribute('tabindex', i === newFocusIndex ? '0' : '-1');
if (i === newFocusIndex) {
e.current?.focus();
}
}
}
return move;
}, [...elements]);
}