2023-03-09 02:33:27 +01:00
|
|
|
/*
|
|
|
|
|
Copyright (c) Microsoft Corporation.
|
|
|
|
|
|
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
See the License for the specific language governing permissions and
|
|
|
|
|
limitations under the License.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import * as React from 'react';
|
2024-10-18 01:57:45 +02:00
|
|
|
import { clsx, scrollIntoViewIfNeeded } from '@web/uiUtils';
|
|
|
|
|
import './treeView.css';
|
2023-03-09 02:33:27 +01:00
|
|
|
|
|
|
|
|
export type TreeItem = {
|
|
|
|
|
id: string,
|
2023-03-12 18:42:02 +01:00
|
|
|
parent: TreeItem | undefined,
|
2023-03-09 02:33:27 +01:00
|
|
|
children: TreeItem[],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type TreeState = {
|
|
|
|
|
expandedItems: Map<string, boolean>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type TreeViewProps<T> = {
|
2023-09-08 02:14:39 +02:00
|
|
|
name: string,
|
2023-03-09 02:33:27 +01:00
|
|
|
rootItem: T,
|
|
|
|
|
render: (item: T) => React.ReactNode,
|
2024-10-18 22:50:43 +02:00
|
|
|
title?: (item: T) => string,
|
2023-03-09 02:33:27 +01:00
|
|
|
icon?: (item: T) => string | undefined,
|
|
|
|
|
isError?: (item: T) => boolean,
|
2023-08-17 01:30:17 +02:00
|
|
|
isVisible?: (item: T) => boolean,
|
2023-03-09 02:33:27 +01:00
|
|
|
selectedItem?: T,
|
|
|
|
|
onAccepted?: (item: T) => void,
|
|
|
|
|
onSelected?: (item: T) => void,
|
|
|
|
|
onHighlighted?: (item: T | undefined) => void,
|
|
|
|
|
noItemsMessage?: string,
|
|
|
|
|
dataTestId?: string,
|
|
|
|
|
treeState: TreeState,
|
|
|
|
|
setTreeState: (treeState: TreeState) => void,
|
2023-05-06 00:12:18 +02:00
|
|
|
autoExpandDepth?: number,
|
2023-03-09 02:33:27 +01:00
|
|
|
};
|
|
|
|
|
|
2024-10-18 01:57:45 +02:00
|
|
|
const scrollPositions = new Map<string, number>();
|
2023-03-09 02:33:27 +01:00
|
|
|
|
|
|
|
|
export function TreeView<T extends TreeItem>({
|
2023-09-08 02:14:39 +02:00
|
|
|
name,
|
2023-03-09 02:33:27 +01:00
|
|
|
rootItem,
|
|
|
|
|
render,
|
2024-10-18 22:50:43 +02:00
|
|
|
title,
|
2023-03-09 02:33:27 +01:00
|
|
|
icon,
|
|
|
|
|
isError,
|
2023-08-17 01:30:17 +02:00
|
|
|
isVisible,
|
2023-03-09 02:33:27 +01:00
|
|
|
selectedItem,
|
|
|
|
|
onAccepted,
|
|
|
|
|
onSelected,
|
|
|
|
|
onHighlighted,
|
|
|
|
|
treeState,
|
|
|
|
|
setTreeState,
|
|
|
|
|
noItemsMessage,
|
2023-03-12 23:18:47 +01:00
|
|
|
dataTestId,
|
2023-05-06 00:12:18 +02:00
|
|
|
autoExpandDepth,
|
2023-03-09 02:33:27 +01:00
|
|
|
}: TreeViewProps<T>) {
|
|
|
|
|
const treeItems = React.useMemo(() => {
|
2024-10-18 22:50:43 +02:00
|
|
|
return indexTree<T>(rootItem, selectedItem, treeState.expandedItems, autoExpandDepth || 0, isVisible);
|
|
|
|
|
}, [rootItem, selectedItem, treeState, autoExpandDepth, isVisible]);
|
2023-08-17 01:30:17 +02:00
|
|
|
|
2024-10-18 01:57:45 +02:00
|
|
|
const itemListRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
|
const [highlightedItem, setHighlightedItem] = React.useState<any>();
|
2024-10-18 22:50:43 +02:00
|
|
|
const [isKeyboardNavigation, setIsKeyboardNavigation] = React.useState(false);
|
2024-10-18 01:57:45 +02:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
onHighlighted?.(highlightedItem);
|
|
|
|
|
}, [onHighlighted, highlightedItem]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
const treeElem = itemListRef.current;
|
|
|
|
|
if (!treeElem)
|
|
|
|
|
return;
|
|
|
|
|
const saveScrollPosition = () => {
|
|
|
|
|
scrollPositions.set(name, treeElem.scrollTop);
|
|
|
|
|
};
|
|
|
|
|
treeElem.addEventListener('scroll', saveScrollPosition, { passive: true });
|
|
|
|
|
return () => treeElem.removeEventListener('scroll', saveScrollPosition);
|
|
|
|
|
}, [name]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (itemListRef.current)
|
|
|
|
|
itemListRef.current.scrollTop = scrollPositions.get(name) || 0;
|
|
|
|
|
}, [name]);
|
|
|
|
|
|
|
|
|
|
const toggleExpanded = React.useCallback((item: T) => {
|
|
|
|
|
const { expanded } = treeItems.get(item)!;
|
|
|
|
|
if (expanded) {
|
|
|
|
|
// Move nested selection up.
|
|
|
|
|
for (let i: TreeItem | undefined = selectedItem; i; i = i.parent) {
|
|
|
|
|
if (i === item) {
|
|
|
|
|
onSelected?.(item as T);
|
|
|
|
|
break;
|
|
|
|
|
}
|
2023-03-09 02:33:27 +01:00
|
|
|
}
|
2024-10-18 01:57:45 +02:00
|
|
|
treeState.expandedItems.set(item.id, false);
|
|
|
|
|
} else {
|
|
|
|
|
treeState.expandedItems.set(item.id, true);
|
|
|
|
|
}
|
|
|
|
|
setTreeState({ ...treeState });
|
|
|
|
|
}, [treeItems, selectedItem, onSelected, treeState, setTreeState]);
|
|
|
|
|
|
|
|
|
|
return <div className={clsx(`tree-view vbox`, name + '-tree-view')} role={'tree'} data-testid={dataTestId || (name + '-tree')}>
|
|
|
|
|
<div
|
|
|
|
|
className={clsx('tree-view-content')}
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onKeyDown={event => {
|
|
|
|
|
if (selectedItem && event.key === 'Enter') {
|
|
|
|
|
onAccepted?.(selectedItem);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
|
|
if (selectedItem && event.key === 'ArrowLeft') {
|
|
|
|
|
const { expanded, parent } = treeItems.get(selectedItem)!;
|
|
|
|
|
if (expanded) {
|
|
|
|
|
treeState.expandedItems.set(selectedItem.id, false);
|
|
|
|
|
setTreeState({ ...treeState });
|
|
|
|
|
} else if (parent) {
|
|
|
|
|
onSelected?.(parent as T);
|
2023-03-12 18:42:02 +01:00
|
|
|
}
|
2024-10-18 01:57:45 +02:00
|
|
|
return;
|
2023-03-12 18:42:02 +01:00
|
|
|
}
|
2024-10-18 01:57:45 +02:00
|
|
|
if (selectedItem && event.key === 'ArrowRight') {
|
|
|
|
|
if (selectedItem.children.length) {
|
|
|
|
|
treeState.expandedItems.set(selectedItem.id, true);
|
|
|
|
|
setTreeState({ ...treeState });
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-18 22:50:43 +02:00
|
|
|
let newSelectedItem: T | undefined = selectedItem;
|
2024-10-18 01:57:45 +02:00
|
|
|
if (event.key === 'ArrowDown') {
|
2024-10-18 22:50:43 +02:00
|
|
|
if (selectedItem) {
|
|
|
|
|
const itemData = treeItems.get(selectedItem)!;
|
|
|
|
|
newSelectedItem = itemData.next as T;
|
|
|
|
|
} else if (treeItems.size) {
|
|
|
|
|
const itemList = [...treeItems.keys()];
|
|
|
|
|
newSelectedItem = itemList[0];
|
|
|
|
|
}
|
2024-10-18 01:57:45 +02:00
|
|
|
}
|
|
|
|
|
if (event.key === 'ArrowUp') {
|
2024-10-18 22:50:43 +02:00
|
|
|
if (selectedItem) {
|
|
|
|
|
const itemData = treeItems.get(selectedItem)!;
|
|
|
|
|
newSelectedItem = itemData.prev as T;
|
|
|
|
|
} else if (treeItems.size) {
|
|
|
|
|
const itemList = [...treeItems.keys()];
|
|
|
|
|
newSelectedItem = itemList[itemList.length - 1];
|
|
|
|
|
}
|
2024-10-18 01:57:45 +02:00
|
|
|
}
|
|
|
|
|
|
2024-10-18 22:50:43 +02:00
|
|
|
// scrollIntoViewIfNeeded(element || undefined);
|
2024-10-18 01:57:45 +02:00
|
|
|
onHighlighted?.(undefined);
|
2024-10-18 22:50:43 +02:00
|
|
|
if (newSelectedItem) {
|
|
|
|
|
setIsKeyboardNavigation(true);
|
|
|
|
|
onSelected?.(newSelectedItem);
|
|
|
|
|
}
|
2024-10-18 01:57:45 +02:00
|
|
|
setHighlightedItem(undefined);
|
|
|
|
|
}}
|
|
|
|
|
ref={itemListRef}
|
|
|
|
|
>
|
2024-10-18 22:50:43 +02:00
|
|
|
{noItemsMessage && treeItems.size === 0 && <div className='tree-view-empty'>{noItemsMessage}</div>}
|
|
|
|
|
{rootItem.children.map(child => {
|
|
|
|
|
const itemData = treeItems.get(child as T);
|
|
|
|
|
return itemData && <TreeItemHeader
|
|
|
|
|
key={child.id}
|
2024-12-13 13:52:04 +01:00
|
|
|
item={child as T}
|
2024-10-18 22:50:43 +02:00
|
|
|
treeItems={treeItems}
|
|
|
|
|
selectedItem={selectedItem}
|
|
|
|
|
onSelected={onSelected}
|
|
|
|
|
onAccepted={onAccepted}
|
|
|
|
|
isError={isError}
|
|
|
|
|
toggleExpanded={toggleExpanded}
|
|
|
|
|
highlightedItem={highlightedItem}
|
|
|
|
|
setHighlightedItem={setHighlightedItem}
|
|
|
|
|
render={render}
|
|
|
|
|
icon={icon}
|
|
|
|
|
title={title}
|
|
|
|
|
isKeyboardNavigation={isKeyboardNavigation}
|
|
|
|
|
setIsKeyboardNavigation={setIsKeyboardNavigation} />;
|
2024-10-18 01:57:45 +02:00
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type TreeItemHeaderProps<T> = {
|
|
|
|
|
item: T,
|
2024-10-18 22:50:43 +02:00
|
|
|
treeItems: Map<T, TreeItemData>,
|
2024-10-18 01:57:45 +02:00
|
|
|
selectedItem: T | undefined,
|
|
|
|
|
onSelected?: (item: T) => void,
|
|
|
|
|
toggleExpanded: (item: T) => void,
|
|
|
|
|
highlightedItem: T | undefined,
|
|
|
|
|
isError?: (item: T) => boolean,
|
|
|
|
|
onAccepted?: (item: T) => void,
|
|
|
|
|
setHighlightedItem: (item: T | undefined) => void,
|
|
|
|
|
render: (item: T) => React.ReactNode,
|
2024-10-18 22:50:43 +02:00
|
|
|
title?: (item: T) => string,
|
2024-10-18 01:57:45 +02:00
|
|
|
icon?: (item: T) => string | undefined,
|
2024-10-18 22:50:43 +02:00
|
|
|
isKeyboardNavigation: boolean,
|
|
|
|
|
setIsKeyboardNavigation: (value: boolean) => void,
|
2024-10-18 01:57:45 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function TreeItemHeader<T extends TreeItem>({
|
|
|
|
|
item,
|
2024-10-18 22:50:43 +02:00
|
|
|
treeItems,
|
2024-10-18 01:57:45 +02:00
|
|
|
selectedItem,
|
|
|
|
|
onSelected,
|
|
|
|
|
highlightedItem,
|
|
|
|
|
setHighlightedItem,
|
|
|
|
|
isError,
|
|
|
|
|
onAccepted,
|
|
|
|
|
toggleExpanded,
|
|
|
|
|
render,
|
2024-10-18 22:50:43 +02:00
|
|
|
title,
|
|
|
|
|
icon,
|
|
|
|
|
isKeyboardNavigation,
|
|
|
|
|
setIsKeyboardNavigation }: TreeItemHeaderProps<T>) {
|
2024-11-01 21:38:16 +01:00
|
|
|
const groupId = React.useId();
|
2024-10-18 22:50:43 +02:00
|
|
|
const itemRef = React.useRef(null);
|
2024-10-18 01:57:45 +02:00
|
|
|
|
2024-10-18 22:50:43 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (selectedItem === item && isKeyboardNavigation && itemRef.current) {
|
|
|
|
|
scrollIntoViewIfNeeded(itemRef.current);
|
|
|
|
|
setIsKeyboardNavigation(false);
|
|
|
|
|
}
|
|
|
|
|
}, [item, selectedItem, isKeyboardNavigation, setIsKeyboardNavigation]);
|
|
|
|
|
|
|
|
|
|
const itemData = treeItems.get(item)!;
|
2024-10-18 01:57:45 +02:00
|
|
|
const indentation = itemData.depth;
|
|
|
|
|
const expanded = itemData.expanded;
|
|
|
|
|
let expandIcon = 'codicon-blank';
|
|
|
|
|
if (typeof expanded === 'boolean')
|
|
|
|
|
expandIcon = expanded ? 'codicon-chevron-down' : 'codicon-chevron-right';
|
|
|
|
|
const rendered = render(item);
|
2024-10-18 22:50:43 +02:00
|
|
|
const children = expanded && item.children.length ? item.children as T[] : [];
|
|
|
|
|
const titled = title?.(item);
|
2024-10-22 06:54:06 +02:00
|
|
|
const iconed = icon?.(item) || 'codicon-blank';
|
2024-10-18 01:57:45 +02:00
|
|
|
|
2024-11-01 21:38:16 +01:00
|
|
|
return <div ref={itemRef} role='treeitem' aria-selected={item === selectedItem} aria-expanded={expanded} aria-controls={groupId} title={titled} className='vbox' style={{ flex: 'none' }}>
|
2024-10-18 01:57:45 +02:00
|
|
|
<div
|
2024-10-18 22:50:43 +02:00
|
|
|
onDoubleClick={() => onAccepted?.(item)}
|
|
|
|
|
className={clsx(
|
|
|
|
|
'tree-view-entry',
|
|
|
|
|
selectedItem === item && 'selected',
|
|
|
|
|
highlightedItem === item && 'highlighted',
|
|
|
|
|
isError?.(item) && 'error')}
|
|
|
|
|
onClick={() => onSelected?.(item)}
|
|
|
|
|
onMouseEnter={() => setHighlightedItem(item)}
|
|
|
|
|
onMouseLeave={() => setHighlightedItem(undefined)}
|
|
|
|
|
>
|
|
|
|
|
{indentation ? new Array(indentation).fill(0).map((_, i) => <div key={'indent-' + i} className='tree-view-indent'></div>) : undefined}
|
|
|
|
|
<div
|
|
|
|
|
aria-hidden='true'
|
|
|
|
|
className={'codicon ' + expandIcon}
|
|
|
|
|
style={{ minWidth: 16, marginRight: 4 }}
|
|
|
|
|
onDoubleClick={e => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
}}
|
|
|
|
|
onClick={e => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
toggleExpanded(item);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2024-10-22 06:54:06 +02:00
|
|
|
{icon && <div className={'codicon ' + iconed} style={{ minWidth: 16, marginRight: 4 }} aria-label={'[' + iconed.replace('codicon', 'icon') + ']'}></div>}
|
2024-10-18 22:50:43 +02:00
|
|
|
{typeof rendered === 'string' ? <div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{rendered}</div> : rendered}
|
|
|
|
|
</div>
|
2024-11-01 21:38:16 +01:00
|
|
|
{!!children.length && <div id={groupId} role='group'>
|
2024-10-18 22:50:43 +02:00
|
|
|
{children.map(child => {
|
|
|
|
|
const itemData = treeItems.get(child);
|
|
|
|
|
return itemData && <TreeItemHeader
|
|
|
|
|
key={child.id}
|
|
|
|
|
item={child}
|
|
|
|
|
treeItems={treeItems}
|
|
|
|
|
selectedItem={selectedItem}
|
|
|
|
|
onSelected={onSelected}
|
|
|
|
|
onAccepted={onAccepted}
|
|
|
|
|
isError={isError}
|
|
|
|
|
toggleExpanded={toggleExpanded}
|
|
|
|
|
highlightedItem={highlightedItem}
|
|
|
|
|
setHighlightedItem={setHighlightedItem}
|
|
|
|
|
render={render}
|
|
|
|
|
title={title}
|
|
|
|
|
icon={icon}
|
|
|
|
|
isKeyboardNavigation={isKeyboardNavigation}
|
|
|
|
|
setIsKeyboardNavigation={setIsKeyboardNavigation} />;
|
|
|
|
|
})}
|
|
|
|
|
</div>}
|
2024-10-18 01:57:45 +02:00
|
|
|
</div>;
|
2023-03-09 02:33:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type TreeItemData = {
|
2024-10-18 22:50:43 +02:00
|
|
|
depth: number;
|
|
|
|
|
expanded: boolean | undefined;
|
|
|
|
|
parent: TreeItem | null;
|
|
|
|
|
next: TreeItem | null;
|
|
|
|
|
prev: TreeItem | null;
|
2023-03-09 02:33:27 +01:00
|
|
|
};
|
|
|
|
|
|
2024-10-18 22:50:43 +02:00
|
|
|
function indexTree<T extends TreeItem>(
|
2024-10-18 01:57:45 +02:00
|
|
|
rootItem: T,
|
|
|
|
|
selectedItem: T | undefined,
|
|
|
|
|
expandedItems: Map<string, boolean | undefined>,
|
2024-10-18 22:50:43 +02:00
|
|
|
autoExpandDepth: number,
|
2024-11-28 14:04:34 +01:00
|
|
|
isVisible: (item: T) => boolean = () => true): Map<T, TreeItemData> {
|
|
|
|
|
if (!isVisible(rootItem))
|
|
|
|
|
return new Map();
|
2024-10-18 01:57:45 +02:00
|
|
|
|
2023-03-09 02:33:27 +01:00
|
|
|
const result = new Map<T, TreeItemData>();
|
2023-08-31 00:48:51 +02:00
|
|
|
const temporaryExpanded = new Set<string>();
|
2023-08-23 21:26:11 +02:00
|
|
|
for (let item: TreeItem | undefined = selectedItem?.parent; item; item = item.parent)
|
2023-08-31 00:48:51 +02:00
|
|
|
temporaryExpanded.add(item.id);
|
2024-10-18 22:50:43 +02:00
|
|
|
let lastItem: T | null = null;
|
2023-08-23 21:26:11 +02:00
|
|
|
|
2023-03-09 02:33:27 +01:00
|
|
|
const appendChildren = (parent: T, depth: number) => {
|
|
|
|
|
for (const item of parent.children as T[]) {
|
2024-11-28 14:04:34 +01:00
|
|
|
if (!isVisible(item))
|
|
|
|
|
continue;
|
2023-08-31 00:48:51 +02:00
|
|
|
const expandState = temporaryExpanded.has(item.id) || expandedItems.get(item.id);
|
2023-05-06 00:12:18 +02:00
|
|
|
const autoExpandMatches = autoExpandDepth > depth && result.size < 25 && expandState !== false;
|
2023-08-31 00:48:51 +02:00
|
|
|
const expanded = item.children.length ? expandState ?? autoExpandMatches : undefined;
|
2024-10-18 22:50:43 +02:00
|
|
|
const itemData: TreeItemData = {
|
|
|
|
|
depth,
|
|
|
|
|
expanded,
|
|
|
|
|
parent: rootItem === parent ? null : parent,
|
|
|
|
|
next: null,
|
|
|
|
|
prev: lastItem,
|
|
|
|
|
};
|
|
|
|
|
if (lastItem)
|
|
|
|
|
result.get(lastItem)!.next = item;
|
|
|
|
|
lastItem = item;
|
|
|
|
|
result.set(item, itemData);
|
2023-03-09 02:33:27 +01:00
|
|
|
if (expanded)
|
|
|
|
|
appendChildren(item, depth + 1);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
appendChildren(rootItem, 0);
|
|
|
|
|
return result;
|
|
|
|
|
}
|