rework roving tab index to use react-aria

This commit is contained in:
David Lechner
2022-05-17 23:20:09 -05:00
parent f7dcf8b0f7
commit 0839392183
22 changed files with 495 additions and 355 deletions
+205
View File
@@ -0,0 +1,205 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { RenderResult, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { testRender } from '../../../test';
import { Toolbar } from './Toolbar';
import { ToolbarButton } from './ToolbarButton';
afterEach(() => {
cleanup();
});
const TestToolbar: React.VoidFunctionComponent = () => {
return (
<Toolbar
className="test-class"
aria-label="Test Toolbar"
firstFocusableItemId="button1"
>
<ToolbarButton id="button1" aria-label="Button 1" />
<ToolbarButton id="button2" aria-label="Button 2" />
<ToolbarButton id="button3" aria-label="Button 3" />
</Toolbar>
);
};
function getButtons(toolbar: RenderResult): {
button1: HTMLElement;
button2: HTMLElement;
button3: HTMLElement;
} {
const button1 = toolbar.getByRole('button', { name: 'Button 1' });
const button2 = toolbar.getByRole('button', { name: 'Button 2' });
const button3 = toolbar.getByRole('button', { name: 'Button 3' });
return { button1, button2, button3 };
}
describe('Toolbar', () => {
it('should have toolbar role', () => {
const [toolbar] = testRender(<TestToolbar />);
expect(toolbar.getByRole('toolbar', { name: 'Test Toolbar' })).toHaveClass(
'test-class',
);
});
it('should focus the first element by default', async () => {
const [toolbar] = testRender(<TestToolbar />);
const { button1, button2, button3 } = getButtons(toolbar);
expect(button1).not.toHaveAttribute('tabindex');
expect(button2).toHaveAttribute('tabindex', '-1');
expect(button3).toHaveAttribute('tabindex', '-1');
userEvent.tab();
expect(button1).toHaveFocus();
expect(button1).not.toHaveAttribute('tabindex');
expect(button2).toHaveAttribute('tabindex', '-1');
expect(button3).toHaveAttribute('tabindex', '-1');
});
it('should focus next with right arrow key', () => {
const [toolbar] = testRender(<TestToolbar />);
const { button1, button2, button3 } = getButtons(toolbar);
button1.focus();
userEvent.keyboard('[ArrowRight]');
expect(button2).toHaveFocus();
expect(button1).toHaveAttribute('tabindex', '-1');
expect(button2).not.toHaveAttribute('tabindex');
expect(button3).toHaveAttribute('tabindex', '-1');
});
it('should focus previous with left arrow key', () => {
const [toolbar] = testRender(<TestToolbar />);
const { button1, button2, button3 } = getButtons(toolbar);
button3.focus();
userEvent.keyboard('[ArrowLeft]');
expect(button2).toHaveFocus();
expect(button1).toHaveAttribute('tabindex', '-1');
expect(button2).not.toHaveAttribute('tabindex');
expect(button3).toHaveAttribute('tabindex', '-1');
});
it('should wrap focus next with right arrow key', () => {
const [toolbar] = testRender(<TestToolbar />);
const { button1, button2, button3 } = getButtons(toolbar);
button3.focus();
userEvent.keyboard('[ArrowRight]');
expect(button1).toHaveFocus();
expect(button1).not.toHaveAttribute('tabindex');
expect(button2).toHaveAttribute('tabindex', '-1');
expect(button3).toHaveAttribute('tabindex', '-1');
});
it('should wrap focus previous with left arrow key', () => {
const [toolbar] = testRender(<TestToolbar />);
const { button1, button2, button3 } = getButtons(toolbar);
button1.focus();
userEvent.keyboard('[ArrowLeft]');
expect(button3).toHaveFocus();
expect(button1).toHaveAttribute('tabindex', '-1');
expect(button2).toHaveAttribute('tabindex', '-1');
expect(button3).not.toHaveAttribute('tabindex');
});
it('should focus first with home key', () => {
const [toolbar] = testRender(<TestToolbar />);
const { button1, button2, button3 } = getButtons(toolbar);
button3.focus();
userEvent.keyboard('[Home]');
expect(button1).toHaveFocus();
expect(button1).not.toHaveAttribute('tabindex');
expect(button2).toHaveAttribute('tabindex', '-1');
expect(button3).toHaveAttribute('tabindex', '-1');
});
it('should focus last with end key', () => {
const [toolbar] = testRender(<TestToolbar />);
const { button1, button2, button3 } = getButtons(toolbar);
button1.focus();
userEvent.keyboard('[End]');
expect(button3).toHaveFocus();
expect(button1).toHaveAttribute('tabindex', '-1');
expect(button2).toHaveAttribute('tabindex', '-1');
expect(button3).not.toHaveAttribute('tabindex');
});
it('should not change focus with up arrow key', () => {
const [toolbar] = testRender(<TestToolbar />);
const { button1, button2, button3 } = getButtons(toolbar);
button2.focus();
userEvent.keyboard('[ArrowUp]');
expect(button2).toHaveFocus();
expect(button1).toHaveAttribute('tabindex', '-1');
expect(button2).not.toHaveAttribute('tabindex');
expect(button3).toHaveAttribute('tabindex', '-1');
});
it('should not change focus with down arrow key', () => {
const [toolbar] = testRender(<TestToolbar />);
const { button1, button2, button3 } = getButtons(toolbar);
button2.focus();
userEvent.keyboard('[ArrowDown]');
expect(button2).toHaveFocus();
expect(button1).toHaveAttribute('tabindex', '-1');
expect(button2).not.toHaveAttribute('tabindex');
expect(button3).toHaveAttribute('tabindex', '-1');
});
it('should not focus next item with tab key', () => {
const [toolbar] = testRender(<TestToolbar />);
const { button1, button2, button3 } = getButtons(toolbar);
button2.focus();
userEvent.tab();
expect(document.body).toHaveFocus();
expect(button1).toHaveAttribute('tabindex', '-1');
expect(button2).not.toHaveAttribute('tabindex');
expect(button3).toHaveAttribute('tabindex', '-1');
});
it('should focus on click', () => {
const [toolbar] = testRender(<TestToolbar />);
const { button1, button2, button3 } = getButtons(toolbar);
userEvent.click(button2);
expect(button2).toHaveFocus();
expect(button1).toHaveAttribute('tabindex', '-1');
expect(button2).not.toHaveAttribute('tabindex');
expect(button3).toHaveAttribute('tabindex', '-1');
});
});
+17 -49
View File
@@ -1,18 +1,19 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import React, { AriaAttributes, KeyboardEventHandler, useCallback } from 'react';
import { FocusAction } from '../../utils/react';
import React from 'react';
import { FocusScope } from 'react-aria';
import { useToolbar } from './aria';
import { ToolbarStateContext, useToolbarState } from './state';
import { AriaToolbarProps } from './types';
type ToolbarProps = Pick<AriaAttributes, 'aria-label' | 'aria-labelledby'> & {
type ToolbarProps = Pick<
AriaToolbarProps,
'aria-label' | 'aria-labelledby' | 'firstFocusableItemId'
> & {
/** 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.
*
@@ -21,49 +22,16 @@ type ToolbarProps = Pick<AriaAttributes, 'aria-label' | 'aria-labelledby'> & {
*
* 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],
);
export const Toolbar: React.FunctionComponent<ToolbarProps> = (props) => {
const { className, children } = props;
const state = useToolbarState(props);
const { toolbarProps } = useToolbar(props);
return (
<div
role="toolbar"
aria-orientation={vertical ? 'vertical' : undefined}
{...divProps}
onKeyDown={handleKeyDown}
>
{children}
<div className={className} {...toolbarProps}>
<ToolbarStateContext.Provider value={state}>
<FocusScope>{children}</FocusScope>
</ToolbarStateContext.Provider>
</div>
);
};
export default Toolbar;
+47
View File
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Classes } from '@blueprintjs/core';
import { mergeProps } from '@react-aria/utils';
import { AriaButtonProps } from '@react-types/button';
import classNames from 'classnames';
import React, { useRef } from 'react';
import { FocusRing, useButton } from 'react-aria';
import { useToolbarItemFocus } from './aria';
type ToolbarButtonProps = Pick<
AriaButtonProps,
'aria-label' | 'aria-describedby' | 'id' | 'children'
> & { id: string; className?: string };
/** React component for toolbar item buttons. */
export const ToolbarButton: React.FunctionComponent<ToolbarButtonProps> = (props) => {
const { className, children } = props;
const ref = useRef<HTMLButtonElement>(null);
const { toolbarItemFocusProps, excludeFromTabOrder } = useToolbarItemFocus(props);
const { buttonProps, isPressed } = useButton(
mergeProps(props, {
excludeFromTabOrder,
}),
ref,
);
return (
<FocusRing focusRingClass="pb-focus-ring">
<button
className={classNames(
Classes.BUTTON,
isPressed && Classes.ACTIVE,
'pb-focus-managed',
className,
)}
ref={ref}
{...mergeProps(toolbarItemFocusProps, buttonProps)}
>
{children}
</button>
</FocusRing>
);
};
+76
View File
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { filterDOMProps } from '@react-aria/utils';
import {
FocusEventHandler,
HTMLAttributes,
KeyboardEventHandler,
useCallback,
useContext,
} from 'react';
import { FocusScope, mergeProps, useFocusManager } from 'react-aria';
import { ToolbarStateContext } from './state';
import { AriaToolbarProps } from './types';
// for doc comment link
FocusScope;
type ToolbarAria = {
toolbarProps: HTMLAttributes<HTMLElement>;
};
/** React hook for creating toolbar element props. */
export function useToolbar(props: AriaToolbarProps): ToolbarAria {
const domProps = filterDOMProps(props, { labelable: true });
return { toolbarProps: mergeProps(domProps, { role: 'toolbar' }) };
}
type ToolbarItemFocusAria = {
toolbarItemFocusProps: HTMLAttributes<HTMLElement>;
excludeFromTabOrder: boolean;
};
/**
* React hook for creating toolbar item element props for focus management.
*
* Using this hook requires the current element to be inside of an
* {@link ToolbarStateContext} and to be inside of a {@link FocusScope}.
*/
export function useToolbarItemFocus(props: { id: string }): ToolbarItemFocusAria {
const { id } = props;
const state = useContext(ToolbarStateContext);
const focusManager = useFocusManager();
const onKeyDown = useCallback<KeyboardEventHandler<HTMLElement>>(
(e) => {
switch (e.key) {
case 'ArrowLeft':
focusManager.focusPrevious({ wrap: true });
break;
case 'ArrowRight':
focusManager.focusNext({ wrap: true });
break;
case 'Home':
focusManager.focusFirst();
break;
case 'End':
focusManager.focusLast();
break;
default:
return;
}
},
[focusManager],
);
const onFocus = useCallback<FocusEventHandler<HTMLElement>>(
(e) => {
state.setLastFocusedItem(e.target.id);
},
[state.setLastFocusedItem],
);
const excludeFromTabOrder = id !== state.lastFocusedItem;
return { toolbarItemFocusProps: { onKeyDown, onFocus }, excludeFromTabOrder };
}
+29
View File
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { createContext, useState } from 'react';
import { ToolbarProps } from './types';
export type ToolbarState = Readonly<{
/** The DOM id of the most recently focused item. */
lastFocusedItem?: string;
/**
* Sets the most recently focused item.
* @param id The DOM id of the element.
*/
setLastFocusedItem: (id: string) => void;
}>;
/** React hook for managing toolbar state. */
export function useToolbarState(props: ToolbarProps): ToolbarState {
const { firstFocusableItemId } = props;
const [lastFocusedItem, setLastFocusedItem] = useState(firstFocusableItemId);
return { lastFocusedItem, setLastFocusedItem };
}
/** React context for passing state from toolbar to toolbar items. */
export const ToolbarStateContext = createContext<ToolbarState>({
lastFocusedItem: 'default',
setLastFocusedItem: () => undefined,
});
+9
View File
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { AriaLabelingProps, DOMProps } from '@react-types/shared';
export type ToolbarProps = {
firstFocusableItemId: string;
};
export type AriaToolbarProps = ToolbarProps & DOMProps & AriaLabelingProps;
+50 -49
View File
@@ -13,7 +13,8 @@ import {
useHotkeys,
} from '@blueprintjs/core';
import { I18n, useI18n } from '@shopify/react-i18n';
import React, { RefObject, useCallback, useMemo, useRef, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { useId } from 'react-aria';
import {
ControlledTreeEnvironment,
LiveDescriptors,
@@ -24,10 +25,10 @@ import {
useTreeEnvironment,
} from 'react-complex-tree';
import { useDispatch } from 'react-redux';
import Toolbar from '../components/toolbar/Toolbar';
import { Toolbar } from '../components/toolbar/Toolbar';
import { useToolbarItemFocus } from '../components/toolbar/aria';
import { useSelector } from '../reducers';
import { isMacOS } from '../utils/os';
import { useRovingTabIndex } from '../utils/react';
import { TreeItemContext, TreeItemData, renderers } from '../utils/tree-renderer';
import {
explorerActivateFile,
@@ -44,23 +45,20 @@ import { I18nId } from './i18n';
import NewFileWizard from './newFileWizard/NewFileWizard';
type ActionButtonProps = {
/** The DOM id for this instance. */
id: string;
/** The icon to use for the button. */
icon: IconName;
/** The tooltip/title text. */
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;
};
const ActionButton: React.VoidFunctionComponent<ActionButtonProps> = ({
id,
icon,
tooltip,
focusable,
elementRef,
onClick,
}) => {
const handleClick = useCallback<React.MouseEventHandler>(
@@ -72,15 +70,16 @@ const ActionButton: React.VoidFunctionComponent<ActionButtonProps> = ({
[onClick],
);
const { toolbarItemFocusProps, excludeFromTabOrder } = useToolbarItemFocus({ id });
return (
<Button
id={id}
icon={icon}
title={tooltip}
tabIndex={focusable === false ? -1 : undefined}
elementRef={elementRef}
onFocus={focusable === false ? (e) => e.preventDefault() : undefined}
onClick={handleClick}
onMouseDown={(e) => e.stopPropagation()}
{...toolbarItemFocusProps}
tabIndex={excludeFromTabOrder ? -1 : 0}
/>
);
};
@@ -105,35 +104,43 @@ const FileActionButtonGroup: React.VoidFunctionComponent<ActionButtonGroupProps>
const fileName = environment.getItemTitle(item);
const duplicateButtonId = useId();
const exportButtonId = useId();
const deleteButtonId = useId();
return (
<ButtonGroup
aria-hidden={true}
className="pb-explorer-file-action-button-group"
minimal={true}
>
<ActionButton
icon="duplicate"
tooltip={i18n.translate(I18nId.TreeItemDuplicateTooltip, { fileName })}
focusable={false}
onClick={() => dispatch(explorerDuplicateFile(fileName))}
/>
<ActionButton
// NB: the "import" icon has an arrow pointing down, which is
// what we want here since import is analogous to download
// and it also matches the direction of the arrow on the
// archive icon which is also used to indicate an export/
// download operation
icon="import"
tooltip={i18n.translate(I18nId.TreeItemExportTooltip, { fileName })}
focusable={false}
onClick={() => dispatch(explorerExportFile(fileName))}
/>
<ActionButton
icon="trash"
tooltip={i18n.translate(I18nId.TreeItemDeleteTooltip, { fileName })}
focusable={false}
onClick={() => dispatch(explorerDeleteFile(fileName))}
/>
<Toolbar firstFocusableItemId={duplicateButtonId}>
<ActionButton
id={duplicateButtonId}
icon="duplicate"
tooltip={i18n.translate(I18nId.TreeItemDuplicateTooltip, {
fileName,
})}
onClick={() => dispatch(explorerDuplicateFile(fileName))}
/>
<ActionButton
id={exportButtonId}
// NB: the "import" icon has an arrow pointing down, which is
// what we want here since import is analogous to download
// and it also matches the direction of the arrow on the
// archive icon which is also used to indicate an export/
// download operation
icon="import"
tooltip={i18n.translate(I18nId.TreeItemExportTooltip, { fileName })}
onClick={() => dispatch(explorerExportFile(fileName))}
/>
<ActionButton
id={deleteButtonId}
icon="trash"
tooltip={i18n.translate(I18nId.TreeItemDeleteTooltip, { fileName })}
onClick={() => dispatch(explorerDeleteFile(fileName))}
/>
</Toolbar>
</ButtonGroup>
);
};
@@ -144,43 +151,37 @@ type HeaderProps = {
};
const Header: React.VoidFunctionComponent<HeaderProps> = ({ i18n }) => {
const archiveButtonRef = useRef<HTMLButtonElement>(null);
const exportButtonRef = useRef<HTMLButtonElement>(null);
const newButtonRef = useRef<HTMLButtonElement>(null);
const archiveButtonId = useId();
const exportButtonId = useId();
const newButtonId = useId();
const dispatch = useDispatch();
const moveFocus = useRovingTabIndex(
archiveButtonRef,
exportButtonRef,
newButtonRef,
);
return (
<Toolbar
className="pb-explorer-header-toolbar"
aria-label={i18n.translate(I18nId.HeaderToolbarTitle)}
onKeyboard={moveFocus}
firstFocusableItemId={archiveButtonId}
>
<ButtonGroup minimal={true}>
<ActionButton
id={archiveButtonId}
icon="archive"
tooltip={i18n.translate(I18nId.HeaderToolbarExportAll)}
elementRef={archiveButtonRef}
onClick={() => dispatch(explorerArchiveAllFiles())}
/>
<ActionButton
id={exportButtonId}
// NB: the "export" icon has an arrow pointing up, which is
// what we want here since import is analogous to upload
// even though this is the "import" action
icon="export"
tooltip={i18n.translate(I18nId.HeaderToolbarImport)}
elementRef={exportButtonRef}
onClick={() => dispatch(explorerImportFiles())}
/>
<ActionButton
id={newButtonId}
icon="plus"
tooltip={i18n.translate(I18nId.HeaderToolbarAddNew)}
elementRef={newButtonRef}
onClick={() => dispatch(explorerCreateNewFile())}
/>
</ButtonGroup>
+12 -9
View File
@@ -3,19 +3,22 @@
import {
Button,
IRef,
Intent,
Spinner,
SpinnerSize,
mergeRefs,
useHotkeys,
} from '@blueprintjs/core';
import { Tooltip2 } from '@blueprintjs/popover2';
import React, { RefObject, useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { tooltipDelay } from '../app/constants';
import { useToolbarItemFocus } from '../components/toolbar/aria';
const smallScreenThreshold = 700;
export interface ActionButtonProps {
/** The DOM id for this instance. */
id: string;
/** A unique label for each instance. */
readonly label: string;
/** Keyboard shortcut. */
@@ -30,13 +33,12 @@ export interface ActionButtonProps {
readonly showProgress?: boolean;
/** The progress value (0 to 1) or undefined for indeterminate progress. */
readonly progress?: number;
/** Reference to the <button> HTML element. */
readonly elementRef?: RefObject<HTMLButtonElement>;
/** Callback that is called when the button is activated (clicked). */
readonly onAction: () => void;
}
const ActionButton: React.VoidFunctionComponent<ActionButtonProps> = ({
id,
label,
keyboardShortcut,
tooltip,
@@ -44,7 +46,6 @@ const ActionButton: React.VoidFunctionComponent<ActionButtonProps> = ({
enabled,
showProgress,
progress,
elementRef,
onAction,
}) => {
const [isSmallScreen, setIsSmallScreen] = useState(
@@ -84,6 +85,8 @@ const ActionButton: React.VoidFunctionComponent<ActionButtonProps> = ({
useHotkeys(hotkeys);
const { toolbarItemFocusProps, excludeFromTabOrder } = useToolbarItemFocus({ id });
return (
<Tooltip2
content={tooltip}
@@ -95,17 +98,17 @@ const ActionButton: React.VoidFunctionComponent<ActionButtonProps> = ({
...tooltipTargetProps
}) => (
<Button
id={id}
aria-label={label}
elementRef={mergeRefs<HTMLButtonElement>(
elementRef ?? null,
tooltipTargetRef,
)}
elementRef={tooltipTargetRef as IRef<HTMLButtonElement>}
{...tooltipTargetProps}
// https://github.com/palantir/blueprint/pull/5300
aria-haspopup={undefined}
intent={Intent.PRIMARY}
onClick={onAction}
disabled={enabled === false}
{...toolbarItemFocusProps}
tabIndex={excludeFromTabOrder ? -1 : 0}
>
{showProgress ? (
<Spinner
+13 -10
View File
@@ -1,14 +1,17 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
import { Button, Intent, Spinner, SpinnerSize, mergeRefs } from '@blueprintjs/core';
import { Button, IRef, Intent, Spinner, SpinnerSize } from '@blueprintjs/core';
import { Tooltip2 } from '@blueprintjs/popover2';
import React, { RefObject, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { tooltipDelay } from '../app/constants';
import { useToolbarItemFocus } from '../components/toolbar/aria';
const smallScreenThreshold = 700;
export interface OpenFileButtonProps {
/** A DOM id for this button. */
readonly id: string;
/** A unique label for each instance. */
readonly label: string;
/** The accepted file extension */
@@ -23,8 +26,6 @@ export interface OpenFileButtonProps {
readonly showProgress?: boolean;
/** The progress value (0 to 1) for the progress spinner. */
readonly progress?: number;
/** Reference to the <button> HTML element. */
readonly elementRef?: RefObject<HTMLButtonElement>;
/** Callback that is called when a file has been selected and opened for reading. */
readonly onFile: (data: ArrayBuffer) => void;
/** Callback that is called when a file has been rejected (e.g. bad file extension). */
@@ -37,6 +38,7 @@ export interface OpenFileButtonProps {
* Button that opens a file chooser dialog or accepts files dropped on it.
*/
const OpenFileButton: React.VoidFunctionComponent<OpenFileButtonProps> = ({
id,
label,
fileExtension,
tooltip,
@@ -44,7 +46,6 @@ const OpenFileButton: React.VoidFunctionComponent<OpenFileButtonProps> = ({
enabled,
showProgress,
progress,
elementRef,
onFile,
onReject,
onClick,
@@ -97,6 +98,8 @@ const OpenFileButton: React.VoidFunctionComponent<OpenFileButtonProps> = ({
},
});
const { toolbarItemFocusProps, excludeFromTabOrder } = useToolbarItemFocus({ id });
return (
<Tooltip2
content={tooltip}
@@ -109,18 +112,18 @@ const OpenFileButton: React.VoidFunctionComponent<OpenFileButtonProps> = ({
}) => (
<Button
{...getRootProps({
id,
'aria-label': label,
refKey: 'elementRef',
elementRef: mergeRefs<HTMLButtonElement>(
elementRef ?? null,
tooltipTargetRef,
),
elementRef: tooltipTargetRef as IRef<HTMLButtonElement>,
...tooltipTargetProps,
// https://github.com/palantir/blueprint/pull/5300
'aria-haspopup': undefined,
intent: Intent.PRIMARY,
disabled: enabled === false,
onClick: onClick,
onClick,
...toolbarItemFocusProps,
tabIndex: excludeFromTabOrder ? -1 : 0,
})}
>
<input {...getInputProps()} />
+14 -22
View File
@@ -2,9 +2,9 @@
// Copyright (c) 2020-2022 The Pybricks Authors
import { ButtonGroup } from '@blueprintjs/core';
import React, { useRef } from 'react';
import UtilsToolbar from '../components/toolbar/Toolbar';
import { useRovingTabIndex } from '../utils/react';
import React from 'react';
import { useId } from 'react-aria';
import { Toolbar as UtilsToolbar } from '../components/toolbar/Toolbar';
import BluetoothButton from './buttons/bluetooth/BluetoothButton';
import FlashButton from './buttons/flash/FlashButton';
import ReplButton from './buttons/repl/ReplButton';
@@ -14,30 +14,22 @@ import StopButton from './buttons/stop/StopButton';
import './toolbar.scss';
const Toolbar: React.VFC = () => {
const flashButtonRef = useRef<HTMLButtonElement>(null);
const bluetoothButtonRef = useRef<HTMLButtonElement>(null);
const runButtonRef = useRef<HTMLButtonElement>(null);
const stopButtonRef = useRef<HTMLButtonElement>(null);
const replButtonRef = useRef<HTMLButtonElement>(null);
const moveFocus = useRovingTabIndex(
flashButtonRef,
bluetoothButtonRef,
runButtonRef,
stopButtonRef,
replButtonRef,
);
const flashButtonId = useId();
const bluetoothButtonId = useId();
const runButtonId = useId();
const stopButtonId = useId();
const replButtonId = useId();
return (
<UtilsToolbar className="pb-toolbar" onKeyboard={moveFocus}>
<UtilsToolbar className="pb-toolbar" firstFocusableItemId={flashButtonId}>
<ButtonGroup className="pb-toolbar-group pb-align-left">
<FlashButton elementRef={flashButtonRef} />
<BluetoothButton elementRef={bluetoothButtonRef} />
<FlashButton id={flashButtonId} />
<BluetoothButton id={bluetoothButtonId} />
</ButtonGroup>
<ButtonGroup className="pb-toolbar-group pb-align-left">
<RunButton elementRef={runButtonRef} />
<StopButton elementRef={stopButtonRef} />
<ReplButton elementRef={replButtonRef} />
<RunButton id={runButtonId} />
<StopButton id={stopButtonId} />
<ReplButton id={replButtonId} />
</ButtonGroup>
</UtilsToolbar>
);
@@ -12,7 +12,9 @@ afterEach(() => {
});
it('should dispatch action when clicked', () => {
const [button, dispatch] = testRender(<BluetoothButton />);
const [button, dispatch] = testRender(
<BluetoothButton id="test-bluetooth-button" />,
);
button.getByRole('button', { name: 'Bluetooth' }).click();
@@ -13,11 +13,9 @@ import connectedIcon from './connected.svg';
import disconnectedIcon from './disconnected.svg';
import { I18nId } from './i18n';
type BluetoothButtonProps = Pick<ActionButtonProps, 'elementRef'>;
type BluetoothButtonProps = Pick<ActionButtonProps, 'id'>;
const BluetoothButton: React.VoidFunctionComponent<BluetoothButtonProps> = ({
elementRef,
}) => {
const BluetoothButton: React.VoidFunctionComponent<BluetoothButtonProps> = ({ id }) => {
const bootloaderConnection = useSelector((s) => s.bootloader.connection);
const bleConnection = useSelector((s) => s.ble.connection);
@@ -31,6 +29,7 @@ const BluetoothButton: React.VoidFunctionComponent<BluetoothButtonProps> = ({
return (
<ActionButton
id={id}
label={i18n.translate(I18nId.Label)}
tooltip={i18n.translate(
isDisconnected ? I18nId.TooltipConnect : I18nId.TooltipDisconnect,
@@ -38,7 +37,6 @@ const BluetoothButton: React.VoidFunctionComponent<BluetoothButtonProps> = ({
icon={isDisconnected ? disconnectedIcon : connectedIcon}
enabled={isDisconnected || bleConnection === BleConnectionState.Connected}
showProgress={bleConnection === BleConnectionState.Connecting}
elementRef={elementRef}
onAction={() => dispatch(toggleBluetooth())}
/>
);
@@ -12,7 +12,7 @@ afterEach(() => {
});
it('should dispatch action when clicked', () => {
const [button, dispatch] = testRender(<FlashButton />);
const [button, dispatch] = testRender(<FlashButton id="test-flash-button" />);
button.getByRole('button', { name: 'Flash' }).click();
+3 -3
View File
@@ -17,9 +17,9 @@ import OpenFileButton, { OpenFileButtonProps } from '../../../toolbar/OpenFileBu
import { I18nId } from './i18n';
import icon from './icon.svg';
type FlashButtonProps = Pick<OpenFileButtonProps, 'elementRef'>;
type FlashButtonProps = Pick<OpenFileButtonProps, 'id'>;
const FlashButton: React.VoidFunctionComponent<FlashButtonProps> = ({ elementRef }) => {
const FlashButton: React.VoidFunctionComponent<FlashButtonProps> = ({ id }) => {
const bootloaderConnection = useSelector((s) => s.bootloader.connection);
const bleConnection = useSelector((s) => s.ble.connection);
const flashing = useSelector((s) => s.firmware.flashing);
@@ -33,6 +33,7 @@ const FlashButton: React.VoidFunctionComponent<FlashButtonProps> = ({ elementRef
return (
<OpenFileButton
id={id}
label={i18n.translate(I18nId.Label)}
fileExtension=".zip"
icon={icon}
@@ -49,7 +50,6 @@ const FlashButton: React.VoidFunctionComponent<FlashButtonProps> = ({ elementRef
}
showProgress={flashing}
progress={progress === null ? undefined : progress}
elementRef={elementRef}
onFile={(data) =>
dispatch(
flashFirmware(data, isSettingFlashCurrentProgramEnabled, hubName),
+1 -1
View File
@@ -13,7 +13,7 @@ afterEach(() => {
});
it('should dispatch action when clicked', () => {
const [button, dispatch] = testRender(<ReplButton />, {
const [button, dispatch] = testRender(<ReplButton id="test-repl-button" />, {
hub: { runtime: HubRuntimeState.Idle },
});
+3 -3
View File
@@ -11,9 +11,9 @@ import ActionButton, { ActionButtonProps } from '../../ActionButton';
import { I18nId } from './i18n';
import icon from './icon.svg';
type ReplButtonProps = Pick<ActionButtonProps, 'elementRef'>;
type ReplButtonProps = Pick<ActionButtonProps, 'id'>;
const ReplButton: React.VoidFunctionComponent<ReplButtonProps> = ({ elementRef }) => {
const ReplButton: React.VoidFunctionComponent<ReplButtonProps> = ({ id }) => {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useI18n();
const dispatch = useDispatch();
@@ -23,11 +23,11 @@ const ReplButton: React.VoidFunctionComponent<ReplButtonProps> = ({ elementRef }
return (
<ActionButton
id={id}
label={i18n.translate(I18nId.Label)}
tooltip={i18n.translate(I18nId.Tooltip)}
icon={icon}
enabled={enabled}
elementRef={elementRef}
onAction={action}
/>
);
+1 -1
View File
@@ -13,7 +13,7 @@ afterEach(() => {
});
it('should dispatch action when clicked', () => {
const [button, dispatch] = testRender(<RunButton />, {
const [button, dispatch] = testRender(<RunButton id="test-run-button" />, {
editor: { isReady: true },
hub: { runtime: HubRuntimeState.Idle },
});
+3 -3
View File
@@ -11,9 +11,9 @@ import ActionButton, { ActionButtonProps } from '../../ActionButton';
import { I18nId } from './i18n';
import icon from './icon.svg';
type RunButtonProps = Pick<ActionButtonProps, 'elementRef'>;
type RunButtonProps = Pick<ActionButtonProps, 'id'>;
const RunButton: React.VoidFunctionComponent<RunButtonProps> = ({ elementRef }) => {
const RunButton: React.VoidFunctionComponent<RunButtonProps> = ({ id }) => {
const downloadProgress = useSelector((s) => s.hub.downloadProgress);
const runtime = useSelector((s) => s.hub.runtime);
const isEditorReady = useSelector((s) => s.editor.isReady);
@@ -25,6 +25,7 @@ const RunButton: React.VoidFunctionComponent<RunButtonProps> = ({ elementRef })
return (
<ActionButton
id={id}
label={i18n.translate(I18nId.Label)}
keyboardShortcut={keyboardShortcut}
tooltip={
@@ -38,7 +39,6 @@ const RunButton: React.VoidFunctionComponent<RunButtonProps> = ({ elementRef })
enabled={isEditorReady && runtime === HubRuntimeState.Idle}
showProgress={runtime === HubRuntimeState.Loading}
progress={downloadProgress === null ? undefined : downloadProgress}
elementRef={elementRef}
onAction={() => dispatch(downloadAndRun())}
/>
);
+1 -1
View File
@@ -13,7 +13,7 @@ afterEach(() => {
});
it('should dispatch action when clicked', () => {
const [button, dispatch] = testRender(<StopButton />, {
const [button, dispatch] = testRender(<StopButton id="test-stop-button" />, {
hub: { runtime: HubRuntimeState.Running },
});
+3 -3
View File
@@ -11,9 +11,9 @@ import ActionButton, { ActionButtonProps } from '../../ActionButton';
import { I18nId } from './i18n';
import icon from './icon.svg';
type StopButtonProps = Pick<ActionButtonProps, 'elementRef'>;
type StopButtonProps = Pick<ActionButtonProps, 'id'>;
const StopButton: React.VoidFunctionComponent<StopButtonProps> = ({ elementRef }) => {
const StopButton: React.VoidFunctionComponent<StopButtonProps> = ({ id }) => {
const runtime = useSelector((s) => s.hub.runtime);
const keyboardShortcut = 'F6';
@@ -23,12 +23,12 @@ const StopButton: React.VoidFunctionComponent<StopButtonProps> = ({ elementRef }
return (
<ActionButton
id={id}
label={i18n.translate(I18nId.Label)}
keyboardShortcut={keyboardShortcut}
tooltip={i18n.translate(I18nId.Tooltip, { key: keyboardShortcut })}
icon={icon}
enabled={runtime === HubRuntimeState.Running}
elementRef={elementRef}
onAction={() => dispatch(stop())}
/>
);
-119
View File
@@ -1,119 +0,0 @@
// 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 '../components/toolbar/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();
});
});
+1 -75
View File
@@ -1,7 +1,6 @@
// helper functions for React components
import { IRefObject } from '@blueprintjs/core';
import { useEffect, useMemo, useState } from 'react';
import { useState } from 'react';
import { createCountFunc } from './iter';
const nextId = createCountFunc();
@@ -17,76 +16,3 @@ 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]);
}