From 7abdcf85e71c0c91df8675ab2e86a7ac10a5d57f Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 19 Mar 2022 21:18:04 -0500 Subject: [PATCH] LicenseDialog: make license list keyboard accessible --- src/explorer/Explorer.tsx | 23 +++++----- src/licenses/LicenseDialog.tsx | 77 ++++++++++++++++++++++++++-------- src/licenses/license.scss | 4 +- src/utils/tree-renderer.tsx | 5 +-- 4 files changed, 75 insertions(+), 34 deletions(-) diff --git a/src/explorer/Explorer.tsx b/src/explorer/Explorer.tsx index 40bf088b..6a4d8704 100644 --- a/src/explorer/Explorer.tsx +++ b/src/explorer/Explorer.tsx @@ -79,9 +79,13 @@ const ActionButton: React.VoidFunctionComponent = ({ ); }; +type FileTreeItemData = TreeItemData & { fileName: string }; + +type FileTreeItem = TreeItem; + type ActionButtonGroupProps = { /** The name of the file (displayed to user) */ - item: TreeItem; + item: TreeItem; }; const FileActionButtonGroup: React.VoidFunctionComponent = ({ @@ -236,7 +240,7 @@ const FileTree: React.VFC = () => { obj[index] = { index, data: { - label: fileName, + fileName, icon: 'document', secondaryLabel: ( @@ -251,19 +255,16 @@ const FileTree: React.VFC = () => { { [rootItemIndex]: { index: rootItemIndex, - data: { label: '/' }, + data: { fileName: '/' }, hasChildren: true, children: debouncedFileNames.map((n) => `/${n}`), }, - } as Record>, + } as Record, ), [debouncedFileNames], ); - const getItemTitle = useCallback( - (item: TreeItem) => item.data.label, - [], - ); + const getItemTitle = useCallback((item: FileTreeItem) => item.data.fileName, []); const [renameFileName, setRenameFileName] = useState(''); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); @@ -328,7 +329,7 @@ const FileTree: React.VFC = () => { ); // override default renderRenameInput since we have a separate rename dialog - const renderRenameInput = useCallback( + const renderRenameInput = useCallback( ({ item }) => ( {getItemTitle(item)} @@ -338,7 +339,7 @@ const FileTree: React.VFC = () => { ); const handleStartRenamingItem = useCallback( - (item: TreeItem) => { + (item: FileTreeItem) => { // we are ignoring most of the props since we are opening a dialog // instead of using an inline input and button setRenameFileName(getItemTitle(item)); @@ -380,7 +381,7 @@ const FileTree: React.VFC = () => { ); return ( - + {...renderers} renderTreeContainer={renderTreeContainer} renderRenameInput={renderRenameInput} diff --git a/src/licenses/LicenseDialog.tsx b/src/licenses/LicenseDialog.tsx index 7e51833c..a68db1cf 100644 --- a/src/licenses/LicenseDialog.tsx +++ b/src/licenses/LicenseDialog.tsx @@ -10,20 +10,25 @@ import { Dialog, NonIdealState, Spinner, - Tree, - TreeEventHandler, - TreeNodeInfo, } from '@blueprintjs/core'; import { useI18n } from '@shopify/react-i18n'; import React, { useCallback, useMemo, useState } from 'react'; +import { + ControlledTreeEnvironment, + Tree, + TreeItem, + TreeItemIndex, + TreeViewState, +} from 'react-complex-tree'; import { useFetch } from 'usehooks-ts'; import { appName } from '../app/constants'; +import { TreeItemData, renderers } from '../utils/tree-renderer'; import { LicenseStringId } from './i18n'; import en from './i18n.en.json'; import './license.scss'; -interface LicenseInfo { +interface LicenseInfo extends TreeItemData { readonly name: string; readonly version: string; readonly author: string | undefined; @@ -42,27 +47,52 @@ const LicenseListPanel: React.VoidFunctionComponent = ({ }) => { const [i18n] = useI18n({ id: 'license', translations: { en }, fallback: en }); const { data, error } = useFetch('static/oss-licenses.json'); - const [selectedNode, setSelectedNode] = useState(undefined); + const [focusedItem, setFocusedItem] = useState(); + const [activeItem, setActiveItem] = useState(); const contents = useMemo(() => { if (!data) { return undefined; } - return data.map>((info, i) => ({ - id: i, - label: info.name, - isSelected: info.name === selectedNode, - nodeData: info, - })); - }, [data, selectedNode]); + return data.reduce( + (obj, info, i) => { + obj[i] = { + index: i, + data: info, + }; - const handleNodeClick = useCallback>( - (e) => { - setSelectedNode(e.nodeData?.name); - onItemClick(e.nodeData); + return obj; + }, + { + root: { + index: 'root', + data: {} as LicenseInfo, + hasChildren: true, + children: data.map((_info, i) => i), + }, + } as Record>, + ); + }, [data]); + + const handlePrimaryAction = useCallback( + (item: TreeItem) => { + setActiveItem(item.index); + onItemClick(item.data); }, - [setSelectedNode, onItemClick], + [onItemClick], + ); + + const viewState = useMemo( + () => ({ + 'pb-license-list': { + focusedItem, + // REVISIT: it would be nice if there was an active item separate + // from using selected items. + selectedItems: activeItem === undefined ? undefined : [activeItem], + }, + }), + [focusedItem, activeItem], ); return ( @@ -76,7 +106,18 @@ const LicenseListPanel: React.VoidFunctionComponent = ({ )} ) : ( - + + {...renderers} + items={contents} + getItemTitle={(item) => item.data.name} + viewState={viewState} + canRename={false} + showLiveDescription={false} + onFocusItem={(item) => setFocusedItem(item.index)} + onPrimaryAction={handlePrimaryAction} + > + + )} ); diff --git a/src/licenses/license.scss b/src/licenses/license.scss index fae9276a..b53f7020 100644 --- a/src/licenses/license.scss +++ b/src/licenses/license.scss @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors // Custom styling for the LicenseDialog control. @@ -25,6 +25,8 @@ width: 25%; flex-flow: column; overflow: auto; + // to allow for focus outline + padding: 6px; } .pb-license-info { diff --git a/src/utils/tree-renderer.tsx b/src/utils/tree-renderer.tsx index 2c4aea35..90247ea3 100644 --- a/src/utils/tree-renderer.tsx +++ b/src/utils/tree-renderer.tsx @@ -25,7 +25,6 @@ const cx = (...classNames: Array): string => /** Node item data similar to blueprintsjs TreeNodeInfo */ export type TreeItemData = { - readonly label: string; readonly icon?: IconName | MaybeElement; readonly secondaryLabel?: string | MaybeElement; }; @@ -36,9 +35,7 @@ export type TreeItemData = { */ export const TreeItemContext = createContext>({ index: '', - data: { - label: '', - }, + data: {}, }); export const renderers: Omit<