mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
utils: add Toolbar component
This adds a generic toolbar component that follow aria guidelines.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
}
|
||||
|
||||
.pb-explorer {
|
||||
&-header {
|
||||
&-header-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user