- {children}
+
+
+ {children}
+
);
};
-
-export default Toolbar;
diff --git a/src/components/toolbar/ToolbarButton.tsx b/src/components/toolbar/ToolbarButton.tsx
new file mode 100644
index 00000000..33fc283a
--- /dev/null
+++ b/src/components/toolbar/ToolbarButton.tsx
@@ -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
= (props) => {
+ const { className, children } = props;
+ const ref = useRef(null);
+
+ const { toolbarItemFocusProps, excludeFromTabOrder } = useToolbarItemFocus(props);
+
+ const { buttonProps, isPressed } = useButton(
+ mergeProps(props, {
+ excludeFromTabOrder,
+ }),
+ ref,
+ );
+
+ return (
+
+
+
+ );
+};
diff --git a/src/components/toolbar/aria.ts b/src/components/toolbar/aria.ts
new file mode 100644
index 00000000..c34b2946
--- /dev/null
+++ b/src/components/toolbar/aria.ts
@@ -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;
+};
+
+/** 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;
+ 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>(
+ (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>(
+ (e) => {
+ state.setLastFocusedItem(e.target.id);
+ },
+ [state.setLastFocusedItem],
+ );
+ const excludeFromTabOrder = id !== state.lastFocusedItem;
+
+ return { toolbarItemFocusProps: { onKeyDown, onFocus }, excludeFromTabOrder };
+}
diff --git a/src/components/toolbar/state.ts b/src/components/toolbar/state.ts
new file mode 100644
index 00000000..4ec50b17
--- /dev/null
+++ b/src/components/toolbar/state.ts
@@ -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({
+ lastFocusedItem: 'default',
+ setLastFocusedItem: () => undefined,
+});
diff --git a/src/components/toolbar/types.ts b/src/components/toolbar/types.ts
new file mode 100644
index 00000000..6b20a727
--- /dev/null
+++ b/src/components/toolbar/types.ts
@@ -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;
diff --git a/src/explorer/Explorer.tsx b/src/explorer/Explorer.tsx
index 0faf57c7..4f91332d 100644
--- a/src/explorer/Explorer.tsx
+++ b/src/explorer/Explorer.tsx
@@ -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 `