mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 09:36:27 +00:00
rework roving tab index to use react-aria
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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())}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -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())}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user