explorer: use keyboard accessible tree

This commit is contained in:
David Lechner
2022-03-19 18:21:00 -05:00
parent d6d5b8826e
commit 9de4227ec1
12 changed files with 675 additions and 133 deletions
+71 -10
View File
@@ -2,6 +2,7 @@
// Copyright (c) 2022 The Pybricks Authors
import { getByLabelText, waitFor } from '@testing-library/dom';
import { cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { testRender } from '../../test';
@@ -12,6 +13,12 @@ import {
import Explorer from './Explorer';
import { explorerDeleteFile, explorerImportFiles } from './actions';
afterEach(async () => {
cleanup();
jest.clearAllMocks();
localStorage.clear();
});
describe('archive button', () => {
it('should be enabled if there are files', () => {
const [explorer, dispatch] = testRender(<Explorer />, {
@@ -65,24 +72,48 @@ describe('new file button', () => {
});
});
describe('list item', () => {
it('should show/hide buttons on hover', () => {
describe('tree item', () => {
it('should show rename dialog when button is clicked', async () => {
const [explorer] = testRender(<Explorer />, {
fileStorage: { fileNames: ['test.file'] },
});
expect(
explorer.queryByRole('dialog', { name: "Rename 'test.file'" }),
).toBeNull();
// NB: this button is intentionally not accessible (by role) since
// there is a keyboard shortcut.
const button = explorer.getByTitle('Rename test.file');
// by default, the buttons are hidden
expect(button).not.toBeVisible();
userEvent.click(button);
// but are visible when hovered
userEvent.hover(button);
expect(button).toBeVisible();
const dialog = await explorer.findByRole('dialog', {
name: "Rename 'test.file'",
});
// and hide again when unhovered
userEvent.unhover(button);
expect(button).not.toBeVisible();
expect(dialog).toBeVisible();
});
it('should show rename dialog when key is pressed', async () => {
const [explorer] = testRender(<Explorer />, {
fileStorage: { fileNames: ['test.file'] },
});
expect(
explorer.queryByRole('dialog', { name: "Rename 'test.file'" }),
).toBeNull();
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
userEvent.click(treeItem);
userEvent.keyboard('{f2}');
const dialog = await explorer.findByRole('dialog', {
name: "Rename 'test.file'",
});
expect(dialog).toBeVisible();
});
it('should dispatch delete action when button is clicked', async () => {
@@ -90,6 +121,8 @@ describe('list item', () => {
fileStorage: { fileNames: ['test.file'] },
});
// NB: this button is intentionally not accessible (by role) since
// there is a keyboard shortcut.
const button = explorer.getByTitle('Delete test.file');
userEvent.click(button);
@@ -97,15 +130,43 @@ describe('list item', () => {
expect(dispatch).toHaveBeenCalledWith(explorerDeleteFile('test.file'));
});
it('should dispatch delete action when key is pressed', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
fileStorage: { fileNames: ['test.file'] },
});
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
userEvent.click(treeItem);
userEvent.keyboard('{del}');
expect(dispatch).toHaveBeenCalledWith(explorerDeleteFile('test.file'));
});
it('should dispatch export action when button is clicked', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
fileStorage: { fileNames: ['test.file'] },
});
// NB: this button is intentionally not accessible (by role) since
// there is a keyboard shortcut.
const button = explorer.getByTitle('Export test.file');
userEvent.click(button);
expect(dispatch).toHaveBeenCalledWith(fileStorageExportFile('test.file'));
});
it('should dispatch export action when key is pressed', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
fileStorage: { fileNames: ['test.file'] },
});
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
userEvent.click(treeItem);
userEvent.keyboard('{ctrl}e');
expect(dispatch).toHaveBeenCalledWith(fileStorageExportFile('test.file'));
});
});
+271 -73
View File
@@ -6,32 +6,41 @@
import {
Button,
ButtonGroup,
Classes,
Divider,
HotkeyConfig,
IconName,
Tree,
TreeNodeInfo,
useHotkeys,
} from '@blueprintjs/core';
import { useI18n } from '@shopify/react-i18n';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import {
ControlledTreeEnvironment,
LiveDescriptors,
Tree,
TreeItem,
TreeItemIndex,
TreeRef,
useTree,
useTreeEnvironment,
} from 'react-complex-tree';
import { useDispatch } from 'react-redux';
import { useDebounce } from 'usehooks-ts';
import {
fileStorageArchiveAllFiles,
fileStorageExportFile,
fileStorageRenameFile,
} from '../fileStorage/actions';
import { useSelector } from '../reducers';
import { isMacOS } from '../utils/os';
import { preventBrowserNativeContextMenu } from '../utils/react';
import { TreeItemContext, TreeItemData, renderers } from '../utils/tree-renderer';
import NewFileWizard from './NewFileWizard';
import RenameFileDialog from './RenameFileDialog';
import { explorerDeleteFile, explorerImportFiles } from './actions';
import { ExplorerStringId } from './i18n';
import en from './i18n.en.json';
import './explorer.scss';
type ActionButtonProps = {
/** The icon to use for the button. */
@@ -42,6 +51,8 @@ type ActionButtonProps = {
toolTipReplacements?: { [key: string]: string };
/** If provided, controls button disabled state. */
disabled?: boolean;
/** If false, prevent focus. Default is true. */
focusable?: boolean;
/** Callback for button click event. */
onClick: () => void;
};
@@ -51,6 +62,7 @@ const ActionButton: React.VoidFunctionComponent<ActionButtonProps> = ({
toolTipId,
toolTipReplacements,
disabled,
focusable,
onClick,
}) => {
const [i18n] = useI18n({ id: 'explorer', translations: { en }, fallback: en });
@@ -60,55 +72,45 @@ const ActionButton: React.VoidFunctionComponent<ActionButtonProps> = ({
icon={icon}
title={i18n.translate(toolTipId, toolTipReplacements)}
disabled={disabled}
tabIndex={focusable === false ? -1 : undefined}
onFocus={focusable === false ? (e) => e.preventDefault() : undefined}
onClick={onClick}
/>
);
};
type FileActionButtonGroupRef = {
/** Sets button group internal visible state. */
setVisible: (visible: boolean) => void;
};
type ActionButtonGroupProps = {
/** The name of the file (displayed to user) */
fileName: string;
item: TreeItem<TreeItemData>;
};
const FileActionButtonGroup = forwardRef<
FileActionButtonGroupRef,
ActionButtonGroupProps
>(({ fileName }, ref) => {
const FileActionButtonGroup: React.VoidFunctionComponent<ActionButtonGroupProps> = ({
item,
}) => {
const dispatch = useDispatch();
const [visible, setVisible] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const { treeId, setRenamingItem } = useTree();
const environment = useTreeEnvironment();
// HACK: Hide buttons if file is removed from storage. Without this, if a
// file is renamed to a new name then renamed again to the original name,
// the buttons will be showing even though the list item is not hovered
// because the list item was removed before the mouseleave event was
// received.
const fileNames = useSelector((s) => s.fileStorage.fileNames);
useEffect(() => {
if (!fileNames.includes(fileName)) {
setVisible(false);
}
}, [fileNames, fileName, setVisible]);
const fileName = environment.getItemTitle(item);
useImperativeHandle(ref, () => ({ setVisible }), [setVisible]);
// this is essentially the same implementation as the keyboard shortcut
const handleRename = useCallback(() => {
environment.onStartRenamingItem?.(item, treeId);
setRenamingItem(item.index);
}, [environment, item, treeId, setRenamingItem]);
return (
<ButtonGroup minimal={true} style={visible ? {} : { display: 'none' }}>
<ButtonGroup
aria-hidden={true}
className="pb-explorer-file-action-button-group"
minimal={true}
>
<ActionButton
icon="edit"
toolTipId={ExplorerStringId.TreeItemRenameTooltip}
toolTipReplacements={{ fileName: fileName }}
onClick={() => setIsRenameDialogOpen(true)}
/>
<RenameFileDialog
oldName={fileName}
isOpen={isRenameDialogOpen}
onClose={() => setIsRenameDialogOpen(false)}
toolTipReplacements={{ fileName }}
focusable={false}
onClick={handleRename}
/>
<ActionButton
// NB: the "import" icon has an arrow pointing down, which is
@@ -119,19 +121,19 @@ const FileActionButtonGroup = forwardRef<
icon="import"
toolTipId={ExplorerStringId.TreeItemExportTooltip}
toolTipReplacements={{ fileName: fileName }}
focusable={false}
onClick={() => dispatch(fileStorageExportFile(fileName))}
/>
<ActionButton
icon="trash"
toolTipId={ExplorerStringId.TreeItemDeleteTooltip}
toolTipReplacements={{ fileName: fileName }}
focusable={false}
onClick={() => dispatch(explorerDeleteFile(fileName))}
/>
</ButtonGroup>
);
});
FileActionButtonGroup.displayName = 'FileActionButtonGroup';
};
const Header: React.VFC = () => {
const [isNewFileWizardOpen, setIsNewFileWizardOpen] = useState(false);
@@ -169,45 +171,241 @@ const Header: React.VFC = () => {
);
};
/**
* Accessibility live descriptors.
*/
function useLiveDescriptors(): LiveDescriptors {
const [i18n] = useI18n({ id: 'explorer', translations: { en }, fallback: en });
return useMemo(
() => ({
introduction: `
<p>${i18n.translate(
ExplorerStringId.TreeLiveDescriptorIntroAccessibilityGuide,
{ treeLabel: '{treeLabel}' },
)}</p>
<p>${i18n.translate(
ExplorerStringId.TreeLiveDescriptorIntroNavigation,
)}</p>
<ul>
<li>${i18n.translate(
ExplorerStringId.TreeLiveDescriptorIntroKeybindingsPrimaryAction,
{ key: '{keybinding:primaryAction}' },
)}</li>
<li>${i18n.translate(
ExplorerStringId.TreeLiveDescriptorIntroKeybindingsRename,
{ key: '{keybinding:renameItem}' },
)}</li>
<li>${i18n.translate(
ExplorerStringId.TreeLiveDescriptorIntroKeybindingsExport,
{ key: `${isMacOS() ? 'cmd' : 'ctrl'}+e` },
)}</li>
<li>${i18n.translate(
ExplorerStringId.TreeLiveDescriptorIntroKeybindingsDelete,
{ key: 'delete' },
)}</li>
</ul>
`,
renamingItem: 'not used',
searching: `<p>${i18n.translate(
ExplorerStringId.TreeLiveDescriptorSearching,
)}</p>`,
programmaticallyDragging: 'not used',
programmaticallyDraggingTarget: 'not used',
}),
[i18n],
);
}
const FileTree: React.VFC = () => {
const [i18n] = useI18n({ id: 'explorer', translations: { en }, fallback: en });
const [focusedItem, setFocusedItem] = useState<TreeItemIndex>();
const fileNames = useSelector((s) => s.fileStorage.fileNames);
const debouncedFileNames = useDebounce(fileNames);
const liveDescriptors = useLiveDescriptors();
const dispatch = useDispatch();
const treeContents = useMemo(
const rootItemIndex = '/';
const treeItems = useMemo(
() =>
[...debouncedFileNames].map<
TreeNodeInfo<{
actionButtonGroupRef: React.RefObject<FileActionButtonGroupRef>;
}>
>((item, i) => {
const actionButtonGroupRef =
React.createRef<FileActionButtonGroupRef>();
debouncedFileNames.reduce(
(obj, fileName) => {
const index = `/${fileName}`;
return {
id: i,
label: item,
secondaryLabel: (
<FileActionButtonGroup
fileName={item}
ref={actionButtonGroupRef}
/>
),
nodeData: { actionButtonGroupRef },
};
}),
obj[index] = {
index,
data: {
label: fileName,
icon: 'document',
secondaryLabel: (
<TreeItemContext.Consumer>
{(item) => <FileActionButtonGroup item={item} />}
</TreeItemContext.Consumer>
),
},
};
return obj;
},
{
[rootItemIndex]: {
index: rootItemIndex,
data: { label: '/' },
hasChildren: true,
children: debouncedFileNames.map((n) => `/${n}`),
},
} as Record<TreeItemIndex, TreeItem<TreeItemData>>,
),
[debouncedFileNames],
);
const getItemTitle = useCallback(
(item: TreeItem<TreeItemData>) => item.data.label,
[],
);
const [renameFileName, setRenameFileName] = useState('');
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const renderTreeContainer = useCallback<typeof renderers.renderTreeContainer>(
(props) => {
const { treeId, renamingItem } = useTree();
const environment = useTreeEnvironment();
const isActiveTree = environment.activeTreeId === treeId;
const isRenaming = !!renamingItem;
const hotKeyActive =
isActiveTree && /*!dnd.isProgrammaticallyDragging &&*/ !isRenaming;
const handleDeleteKeyDown = useCallback(() => {
if (focusedItem) {
const fileName = environment.getItemTitle(
environment.items[focusedItem],
);
dispatch(explorerDeleteFile(fileName));
}
}, [environment]);
const handleExportKeyDown = useCallback(() => {
if (focusedItem) {
const fileName = environment.getItemTitle(
environment.items[focusedItem],
);
dispatch(fileStorageExportFile(fileName));
}
}, [environment]);
const hotkeys = useMemo<readonly HotkeyConfig[]>(
() => [
{
combo: 'del',
label: 'Delete',
disabled: !hotKeyActive,
preventDefault: true,
onKeyDown: handleDeleteKeyDown,
},
{
combo: 'mod+e',
label: 'Export',
disabled: !hotKeyActive,
preventDefault: true,
onKeyDown: handleExportKeyDown,
},
],
[hotKeyActive, handleDeleteKeyDown],
);
const { handleKeyDown } = useHotkeys(hotkeys);
return (
<div onKeyDown={handleKeyDown}>
{renderers.renderTreeContainer(props)}
</div>
);
},
[renderers, focusedItem, dispatch],
);
// override default renderRenameInput since we have a separate rename dialog
const renderRenameInput = useCallback<typeof renderers.renderRenameInput>(
({ item }) => (
<span className={[Classes.TREE_NODE_LABEL, Classes.TEXT_MUTED].join(' ')}>
{getItemTitle(item)}
</span>
),
[getItemTitle],
);
const handleStartRenamingItem = useCallback(
(item: TreeItem<TreeItemData>) => {
// we are ignoring most of the props since we are opening a dialog
// instead of using an inline input and button
setRenameFileName(getItemTitle(item));
setIsRenameDialogOpen(true);
},
[getItemTitle, setRenameFileName, setIsRenameDialogOpen],
);
const treeRef = useRef<TreeRef<TreeItemData>>(null);
const handleRenameDialogAccept = useCallback(
(oldName: string, newName: string) => {
setIsRenameDialogOpen(false);
// completeRenamingItem is not implemented
treeRef.current?.stopRenamingItem();
dispatch(fileStorageRenameFile(oldName, newName));
// HACK: This is fragile, ideally we would rename an existing node
// rather than removing and replacing the node. The delay has to
// be long enough to avoid the debounce.
setTimeout(() => treeRef.current?.focusItem(`/${newName}`), 1000);
},
[setIsRenameDialogOpen, treeRef],
);
const handleRenameDialogCancel = useCallback(() => {
setIsRenameDialogOpen(false);
treeRef.current?.abortRenamingItem();
if (focusedItem) {
requestAnimationFrame(() => treeRef.current?.focusItem(focusedItem));
}
}, [setIsRenameDialogOpen, treeRef, focusedItem]);
const treeId = 'pb-explorer-file-tree';
const viewState = useMemo(
() => ({ [treeId]: { focusedItem } }),
[treeId, focusedItem],
);
return (
<Tree
contents={treeContents}
onNodeMouseEnter={(info) =>
info.nodeData?.actionButtonGroupRef.current?.setVisible(true)
}
onNodeMouseLeave={(info) =>
info.nodeData?.actionButtonGroupRef.current?.setVisible(false)
}
/>
<ControlledTreeEnvironment<TreeItemData>
{...renderers}
renderTreeContainer={renderTreeContainer}
renderRenameInput={renderRenameInput}
items={treeItems}
getItemTitle={getItemTitle}
viewState={viewState}
liveDescriptors={liveDescriptors}
onStartRenamingItem={handleStartRenamingItem}
onFocusItem={(item) => setFocusedItem(item.index)}
>
<div className="pb-explorer-file-tree">
<Tree
treeId={treeId}
rootItem={rootItemIndex}
treeLabel={i18n.translate(ExplorerStringId.TreeLabel)}
ref={treeRef}
/>
<RenameFileDialog
oldName={renameFileName}
isOpen={isRenameDialogOpen}
onAccept={handleRenameDialogAccept}
onCancel={handleRenameDialogCancel}
/>
</div>
</ControlledTreeEnvironment>
);
};
+41 -13
View File
@@ -5,17 +5,22 @@ import { waitFor } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { testRender } from '../../test';
import { fileStorageRenameFile } from '../fileStorage/actions';
import RenameFileDialog from './RenameFileDialog';
describe('rename button', () => {
it('should close the dialog and dispatch an action when Rename is clicked', async () => {
const onClose = jest.fn();
const [dialog, dispatch] = testRender(
<RenameFileDialog oldName="old.file" isOpen={true} onClose={onClose} />,
it('should accept the dialog Rename is clicked', async () => {
const onAccept = jest.fn();
const onCancel = jest.fn();
const [dialog] = testRender(
<RenameFileDialog
oldName="old.file"
isOpen={true}
onAccept={onAccept}
onCancel={onCancel}
/>,
);
const button = dialog.getByLabelText('Rename');
const button = dialog.getByRole('button', { name: 'Rename' });
// have to type a new file name before Rename button is enabled
const input = dialog.getByLabelText('File name');
@@ -24,25 +29,48 @@ describe('rename button', () => {
await waitFor(() => expect(button).not.toBeDisabled());
userEvent.click(button);
expect(onClose).toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith(
fileStorageRenameFile('old.file', 'new.file'),
expect(onAccept).toHaveBeenCalledWith('old.file', 'new.file');
});
it('should accept the dialog when enter is pressed in the text input', async () => {
const onAccept = jest.fn();
const onCancel = jest.fn();
const [dialog] = testRender(
<RenameFileDialog
oldName="old.file"
isOpen={true}
onAccept={onAccept}
onCancel={onCancel}
/>,
);
// have to type a new file name before Rename button is enabled
const input = dialog.getByLabelText('File name');
await waitFor(() => expect(input).toHaveFocus());
userEvent.type(input, 'new{enter}');
expect(onAccept).toHaveBeenCalledWith('old.file', 'new.file');
});
it('should be cancellable', async () => {
const onClose = jest.fn();
const onAccept = jest.fn();
const onCancel = jest.fn();
const [dialog, dispatch] = testRender(
<RenameFileDialog oldName="old.file" isOpen={true} onClose={onClose} />,
<RenameFileDialog
oldName="old.file"
isOpen={true}
onAccept={onAccept}
onCancel={onCancel}
/>,
);
const button = dialog.getByLabelText('Close');
const button = dialog.getByRole('button', { name: 'Close' });
await waitFor(() => expect(button).toBeVisible());
userEvent.click(button);
expect(onClose).toHaveBeenCalled();
expect(onCancel).toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
});
});
+37 -36
View File
@@ -3,9 +3,7 @@
import { Button, Classes, Dialog } from '@blueprintjs/core';
import { useI18n } from '@shopify/react-i18n';
import React, { useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { fileStorageRenameFile } from '../fileStorage/actions';
import React, { useCallback, useRef, useState } from 'react';
import { FileNameValidationResult } from '../pybricksMicropython/lib';
import FileNameFormGroup from './FileNameFormGroup';
import { RenameFileStringId } from './i18n';
@@ -16,17 +14,19 @@ type RenameFileDialogProps = {
oldName: string;
/** Controls the dialog open state. */
isOpen: boolean;
/** Called when the dialog is closed. */
onClose: () => void;
/** Called when the dialog is accepted. */
onAccept: (oldName: string, newName: string) => void;
/** Called when the dialog is canceled. */
onCancel: () => void;
};
const RenameFileDialog: React.VoidFunctionComponent<RenameFileDialogProps> = ({
oldName,
isOpen,
onClose,
onAccept,
onCancel,
}) => {
const [i18n] = useI18n({ id: 'explorer', translations: { en }, fallback: en });
const dispatch = useDispatch();
const [baseName, extension] = oldName.split(/(\.\w+)$/);
@@ -35,6 +35,14 @@ const RenameFileDialog: React.VoidFunctionComponent<RenameFileDialogProps> = ({
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = useCallback<React.FormEventHandler>(
(e) => {
e.preventDefault();
onAccept(oldName, `${newName}${extension}`);
},
[onAccept, oldName, newName, extension],
);
return (
<Dialog
title={i18n.translate(RenameFileStringId.Title, {
@@ -46,37 +54,30 @@ const RenameFileDialog: React.VoidFunctionComponent<RenameFileDialogProps> = ({
inputRef.current?.select();
inputRef.current?.focus();
}}
onClose={() => onClose()}
onClose={onCancel}
>
<div className={Classes.DIALOG_BODY}>
<FileNameFormGroup
fileName={newName}
fileExtension={extension}
inputRef={inputRef}
onChange={setNewName}
onValidation={setResult}
/>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button
aria-label="Rename"
intent="primary"
disabled={result !== FileNameValidationResult.IsOk}
onClick={() => {
onClose();
dispatch(
fileStorageRenameFile(
oldName,
`${newName}${extension}`,
),
);
}}
>
{i18n.translate(RenameFileStringId.ActionRename)}
</Button>
<form onSubmit={handleSubmit}>
<div className={Classes.DIALOG_BODY}>
<FileNameFormGroup
fileName={newName}
fileExtension={extension}
inputRef={inputRef}
onChange={setNewName}
onValidation={setResult}
/>
</div>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button
intent="primary"
disabled={result !== FileNameValidationResult.IsOk}
type="submit"
>
{i18n.translate(RenameFileStringId.ActionRename)}
</Button>
</div>
</div>
</form>
</Dialog>
);
};
+14
View File
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
@import '../variables.scss';
.pb-explorer-file-tree {
// to allow for focus outline
padding: 6px;
}
// reveal file action buttons on hover
.#{$ns}-tree-node-content:not(:hover) .pb-explorer-file-action-button-group {
display: none;
}
+16
View File
@@ -5,6 +5,22 @@
"importTooltip": "Import a file",
"addNewTooltip": "Create a new file"
},
"tree": {
"label": "File Explorer",
"liveDescriptor": {
"intro": {
"accessibilityGuide": "Accessibility guide for tree {treeLabel}.",
"navigation": "Navigate the tree with the arrow keys. Start typing the name of a file to search for a file. Additional keybindings are available:",
"keybindings": {
"primaryAction": "{key} to open the file in the code editor",
"rename": "{key} to start renaming the focused file",
"export": "{key} to export the focused file",
"delete": "{key} to delete the focused file"
}
},
"searching": "Searching."
}
},
"treeItem": {
"deleteTooltip": "Delete {fileName}",
"exportTooltip": "Export {fileName}",
+8
View File
@@ -7,6 +7,14 @@ export enum ExplorerStringId {
HeaderExportAllTooltip = 'explorer.header.exportAllTooltip',
HeaderImportTooltip = 'explorer.header.importTooltip',
HeaderAddNewTooltip = 'explorer.header.addNewTooltip',
TreeLabel = 'explorer.tree.label',
TreeLiveDescriptorIntroAccessibilityGuide = 'explorer.tree.liveDescriptor.intro.accessibilityGuide',
TreeLiveDescriptorIntroNavigation = 'explorer.tree.liveDescriptor.intro.navigation',
TreeLiveDescriptorIntroKeybindingsPrimaryAction = 'explorer.tree.liveDescriptor.intro.keybindings.primaryAction',
TreeLiveDescriptorIntroKeybindingsRename = 'explorer.tree.liveDescriptor.intro.keybindings.rename',
TreeLiveDescriptorIntroKeybindingsExport = 'explorer.tree.liveDescriptor.intro.keybindings.export',
TreeLiveDescriptorIntroKeybindingsDelete = 'explorer.tree.liveDescriptor.intro.keybindings.delete',
TreeLiveDescriptorSearching = 'explorer.tree.liveDescriptor.searching',
TreeItemDeleteTooltip = 'explorer.treeItem.deleteTooltip',
TreeItemExportTooltip = 'explorer.treeItem.exportTooltip',
TreeItemRenameTooltip = 'explorer.treeItem.renameTooltip',
+7
View File
@@ -5,6 +5,7 @@
@import '~normalize.css';
@import '~@blueprintjs/core/src/blueprint.scss';
@import '~@blueprintjs/popover2/src/blueprint-popover2.scss';
@import '~react-complex-tree/lib/style.css';
:root {
--pb-vh: 100vh;
@@ -105,3 +106,9 @@ a.#{$ns}-button {
.#{$ns}-input-group .#{$ns}-icon {
margin: 7px;
}
// don't take up so much space when there is no caret
.#{$ns}-tree-node-caret-none {
// $pt-grid-size / 2 matches padding on .#{$ns}-tree-node-conent
min-width: $pt-grid-size / 2;
}
+197
View File
@@ -0,0 +1,197 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
/***/
// react-complex-tree renderers for bluetprintjs integration
// based on https://github.com/lukasbach/react-complex-tree/blob/239fb0c5f49f3c24e307142fb3d7e828440c3f55/packages/blueprintjs-renderers/src/renderers.tsx
// Copyright (c) 2021 Lukas Bach
import {
Button,
Classes,
Colors,
FocusStyleManager,
Icon,
IconName,
InputGroup,
MaybeElement,
} from '@blueprintjs/core';
import React, { createContext } from 'react';
import { TreeItem, TreeRenderProps } from 'react-complex-tree';
/** Combines class names into a string. */
const cx = (...classNames: Array<string | undefined | false>): string =>
classNames.filter((cn) => !!cn).join(' ');
/** Node item data similar to blueprintsjs TreeNodeInfo */
export type TreeItemData = {
readonly label: string;
readonly icon?: IconName | MaybeElement;
readonly secondaryLabel?: string | MaybeElement;
};
/**
* Tree item context that can be used to get a reference to the tree item in
* elements passed to TreeItemData.
*/
export const TreeItemContext = createContext<TreeItem<TreeItemData>>({
index: '<default>',
data: {
label: '<default>',
},
});
export const renderers: Omit<
Required<TreeRenderProps<TreeItemData>>,
'renderDraggingItem' | 'renderDraggingItemTitle' | 'renderLiveDescriptorContainer'
> = {
renderTreeContainer: (props) => (
<div
className={cx(Classes.TREE)}
onFocus={FocusStyleManager.alwaysShowFocus}
onBlur={FocusStyleManager.onlyShowFocusOnTabs}
{...props.containerProps}
>
{props.children}
</div>
),
renderItemsContainer: (props) => (
<ul
className={cx(Classes.TREE_NODE_LIST, Classes.TREE_ROOT)}
{...props.containerProps}
// containerProps sets role="group", which is incorrect, so this
// has to be after
role={undefined}
>
{props.children}
</ul>
),
renderItem: (props) => (
<TreeItemContext.Provider value={props.item}>
<li
className={cx(
Classes.TREE_NODE,
// TODO: include Classes.DISABLED if disabled
props.context.isExpanded && Classes.TREE_NODE_EXPANDED,
(props.context.isSelected || props.context.isDraggingOver) &&
Classes.TREE_NODE_SELECTED,
)}
{...props.context.itemContainerWithChildrenProps}
{...props.context.interactiveElementProps}
>
<div
className={cx(
Classes.TREE_NODE_CONTENT,
`${Classes.TREE_NODE_CONTENT}-${props.depth}`,
)}
{...props.context.itemContainerWithoutChildrenProps}
>
{props.item.hasChildren ? (
props.arrow
) : (
<span className={Classes.TREE_NODE_CARET_NONE} />
)}
<Icon
className={Classes.TREE_NODE_ICON}
icon={props.item.data.icon}
aria-hidden={true}
tabIndex={-1}
/>
{props.title}
{props.item.data.secondaryLabel && (
<span className={Classes.TREE_NODE_SECONDARY_LABEL}>
{props.item.data.secondaryLabel}
</span>
)}
</div>
{props.context.isExpanded && props.children}
</li>
</TreeItemContext.Provider>
),
renderItemArrow: (props) => (
<Icon
icon="chevron-right"
className={cx(
Classes.TREE_NODE_CARET,
props.context.isExpanded
? Classes.TREE_NODE_CARET_OPEN
: Classes.TREE_NODE_CARET_CLOSED,
)}
{...(props.context.arrowProps as unknown)}
/>
),
renderItemTitle: ({ title, context, info }) => {
if (!info.isSearching || !context.isSearchMatching || !info.search) {
return <span className={Classes.TREE_NODE_LABEL}>{title}</span>;
} else {
const startIndex = title.toLowerCase().indexOf(info.search.toLowerCase());
return (
<React.Fragment>
{startIndex > 0 && <span>{title.slice(0, startIndex)}</span>}
<span className="rct-tree-item-search-highlight">
{title.slice(startIndex, startIndex + info.search.length)}
</span>
{startIndex + info.search.length < title.length && (
<span>
{title.slice(startIndex + info.search.length, title.length)}
</span>
)}
</React.Fragment>
);
}
},
renderDragBetweenLine: ({ draggingPosition, lineProps }) => (
<div
{...lineProps}
style={{
position: 'absolute',
right: '0',
top:
draggingPosition.targetType === 'between-items' &&
draggingPosition.linePosition === 'top'
? '0px'
: draggingPosition.targetType === 'between-items' &&
draggingPosition.linePosition === 'bottom'
? '-4px'
: '-2px',
left: `${draggingPosition.depth * 23}px`,
height: '4px',
backgroundColor: Colors.BLUE3,
}}
/>
),
renderRenameInput: (props) => (
<form {...props.formProps} style={{ display: 'contents' }}>
<span className={Classes.TREE_NODE_LABEL}>
<input
{...props.inputProps}
ref={props.inputRef}
className="rct-tree-item-renaming-input"
/>
</span>
<span className={Classes.TREE_NODE_SECONDARY_LABEL}>
<Button
icon="tick"
{...props.submitButtonProps}
type="submit"
minimal={true}
small={true}
/>
</span>
</form>
),
renderSearchInput: (props) => (
<div className={cx('rct-tree-search-input-container')}>
<InputGroup {...(props.inputProps as unknown)} placeholder="Search..." />
</div>
),
renderDepthOffset: 1,
};