mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 09:36:27 +00:00
explorer: use keyboard accessible tree
This commit is contained in:
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user