diff --git a/package.json b/package.json index e9444e4b..a87f5fd1 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "node-sass": "^6.0.1", "prop-types": "^15.8.1", "react": "^16.13.1", + "react-complex-tree": "^1.1.4", "react-dom": "^16.13.1", "react-dropzone": "^12.0.4", "react-monaco-editor": "^0.47.0", diff --git a/src/explorer/Explorer.test.tsx b/src/explorer/Explorer.test.tsx index 9001578c..db73b75d 100644 --- a/src/explorer/Explorer.test.tsx +++ b/src/explorer/Explorer.test.tsx @@ -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(, { @@ -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(, { 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(, { + 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(, { + 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(, { 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(, { + 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')); + }); }); diff --git a/src/explorer/Explorer.tsx b/src/explorer/Explorer.tsx index 5d684a66..40bf088b 100644 --- a/src/explorer/Explorer.tsx +++ b/src/explorer/Explorer.tsx @@ -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 = ({ toolTipId, toolTipReplacements, disabled, + focusable, onClick, }) => { const [i18n] = useI18n({ id: 'explorer', translations: { en }, fallback: en }); @@ -60,55 +72,45 @@ const ActionButton: React.VoidFunctionComponent = ({ 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; }; -const FileActionButtonGroup = forwardRef< - FileActionButtonGroupRef, - ActionButtonGroupProps ->(({ fileName }, ref) => { +const FileActionButtonGroup: React.VoidFunctionComponent = ({ + 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 ( - + setIsRenameDialogOpen(true)} - /> - setIsRenameDialogOpen(false)} + toolTipReplacements={{ fileName }} + focusable={false} + onClick={handleRename} /> dispatch(fileStorageExportFile(fileName))} /> dispatch(explorerDeleteFile(fileName))} /> ); -}); - -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: ` +

${i18n.translate( + ExplorerStringId.TreeLiveDescriptorIntroAccessibilityGuide, + { treeLabel: '{treeLabel}' }, + )}

+

${i18n.translate( + ExplorerStringId.TreeLiveDescriptorIntroNavigation, + )}

+
    +
  • ${i18n.translate( + ExplorerStringId.TreeLiveDescriptorIntroKeybindingsPrimaryAction, + { key: '{keybinding:primaryAction}' }, + )}
  • +
  • ${i18n.translate( + ExplorerStringId.TreeLiveDescriptorIntroKeybindingsRename, + { key: '{keybinding:renameItem}' }, + )}
  • +
  • ${i18n.translate( + ExplorerStringId.TreeLiveDescriptorIntroKeybindingsExport, + { key: `${isMacOS() ? 'cmd' : 'ctrl'}+e` }, + )}
  • +
  • ${i18n.translate( + ExplorerStringId.TreeLiveDescriptorIntroKeybindingsDelete, + { key: 'delete' }, + )}
  • +
+ `, + renamingItem: 'not used', + searching: `

${i18n.translate( + ExplorerStringId.TreeLiveDescriptorSearching, + )}

`, + programmaticallyDragging: 'not used', + programmaticallyDraggingTarget: 'not used', + }), + [i18n], + ); +} + const FileTree: React.VFC = () => { + const [i18n] = useI18n({ id: 'explorer', translations: { en }, fallback: en }); + const [focusedItem, setFocusedItem] = useState(); 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; - }> - >((item, i) => { - const actionButtonGroupRef = - React.createRef(); + debouncedFileNames.reduce( + (obj, fileName) => { + const index = `/${fileName}`; - return { - id: i, - label: item, - secondaryLabel: ( - - ), - nodeData: { actionButtonGroupRef }, - }; - }), + obj[index] = { + index, + data: { + label: fileName, + icon: 'document', + secondaryLabel: ( + + {(item) => } + + ), + }, + }; + + return obj; + }, + { + [rootItemIndex]: { + index: rootItemIndex, + data: { label: '/' }, + hasChildren: true, + children: debouncedFileNames.map((n) => `/${n}`), + }, + } as Record>, + ), [debouncedFileNames], ); + const getItemTitle = useCallback( + (item: TreeItem) => item.data.label, + [], + ); + + const [renameFileName, setRenameFileName] = useState(''); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + + const renderTreeContainer = useCallback( + (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( + () => [ + { + 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 ( +
+ {renderers.renderTreeContainer(props)} +
+ ); + }, + [renderers, focusedItem, dispatch], + ); + + // override default renderRenameInput since we have a separate rename dialog + const renderRenameInput = useCallback( + ({ item }) => ( + + {getItemTitle(item)} + + ), + [getItemTitle], + ); + + const handleStartRenamingItem = useCallback( + (item: TreeItem) => { + // 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>(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 ( - - info.nodeData?.actionButtonGroupRef.current?.setVisible(true) - } - onNodeMouseLeave={(info) => - info.nodeData?.actionButtonGroupRef.current?.setVisible(false) - } - /> + + {...renderers} + renderTreeContainer={renderTreeContainer} + renderRenameInput={renderRenameInput} + items={treeItems} + getItemTitle={getItemTitle} + viewState={viewState} + liveDescriptors={liveDescriptors} + onStartRenamingItem={handleStartRenamingItem} + onFocusItem={(item) => setFocusedItem(item.index)} + > +
+ + +
+ ); }; diff --git a/src/explorer/RenameFileDialog.test.tsx b/src/explorer/RenameFileDialog.test.tsx index 76898c57..2ff96644 100644 --- a/src/explorer/RenameFileDialog.test.tsx +++ b/src/explorer/RenameFileDialog.test.tsx @@ -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( - , + it('should accept the dialog Rename is clicked', async () => { + const onAccept = jest.fn(); + const onCancel = jest.fn(); + const [dialog] = testRender( + , ); - 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( + , ); + + // 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( - , + , ); - 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(); }); }); diff --git a/src/explorer/RenameFileDialog.tsx b/src/explorer/RenameFileDialog.tsx index 417a0a5e..5702c813 100644 --- a/src/explorer/RenameFileDialog.tsx +++ b/src/explorer/RenameFileDialog.tsx @@ -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 = ({ 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 = ({ const inputRef = useRef(null); + const handleSubmit = useCallback( + (e) => { + e.preventDefault(); + onAccept(oldName, `${newName}${extension}`); + }, + [onAccept, oldName, newName, extension], + ); + return ( = ({ inputRef.current?.select(); inputRef.current?.focus(); }} - onClose={() => onClose()} + onClose={onCancel} > -
- -
-
-
- +
+
+
-
+
+
+ +
+
+
); }; diff --git a/src/explorer/explorer.scss b/src/explorer/explorer.scss new file mode 100644 index 00000000..87a3a224 --- /dev/null +++ b/src/explorer/explorer.scss @@ -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; +} diff --git a/src/explorer/i18n.en.json b/src/explorer/i18n.en.json index bca57c69..c89fa76a 100644 --- a/src/explorer/i18n.en.json +++ b/src/explorer/i18n.en.json @@ -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}", diff --git a/src/explorer/i18n.ts b/src/explorer/i18n.ts index 8c7028dc..24c39c8a 100644 --- a/src/explorer/i18n.ts +++ b/src/explorer/i18n.ts @@ -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', diff --git a/src/index.scss b/src/index.scss index 3bd3dd14..843a57a0 100644 --- a/src/index.scss +++ b/src/index.scss @@ -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; +} diff --git a/src/utils/tree-renderer.tsx b/src/utils/tree-renderer.tsx new file mode 100644 index 00000000..2c4aea35 --- /dev/null +++ b/src/utils/tree-renderer.tsx @@ -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 => + 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>({ + index: '', + data: { + label: '', + }, +}); + +export const renderers: Omit< + Required>, + 'renderDraggingItem' | 'renderDraggingItemTitle' | 'renderLiveDescriptorContainer' +> = { + renderTreeContainer: (props) => ( +
+ {props.children} +
+ ), + + renderItemsContainer: (props) => ( +
    + {props.children} +
+ ), + + renderItem: (props) => ( + +
  • +
    + {props.item.hasChildren ? ( + props.arrow + ) : ( + + )} + + {props.title} + {props.item.data.secondaryLabel && ( + + {props.item.data.secondaryLabel} + + )} +
    + {props.context.isExpanded && props.children} +
  • +
    + ), + + renderItemArrow: (props) => ( + + ), + + renderItemTitle: ({ title, context, info }) => { + if (!info.isSearching || !context.isSearchMatching || !info.search) { + return {title}; + } else { + const startIndex = title.toLowerCase().indexOf(info.search.toLowerCase()); + return ( + + {startIndex > 0 && {title.slice(0, startIndex)}} + + {title.slice(startIndex, startIndex + info.search.length)} + + {startIndex + info.search.length < title.length && ( + + {title.slice(startIndex + info.search.length, title.length)} + + )} + + ); + } + }, + + renderDragBetweenLine: ({ draggingPosition, lineProps }) => ( +
    + ), + + renderRenameInput: (props) => ( +
    + + + + +