2023-02-16 16:59:21 +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';
|
|
|
|
|
import './listView.css';
|
|
|
|
|
|
|
|
|
|
export type ListViewProps = {
|
|
|
|
|
items: any[],
|
|
|
|
|
itemRender: (item: any) => React.ReactNode,
|
2023-02-25 00:31:10 +01:00
|
|
|
itemKey?: (item: any) => string,
|
2023-02-16 16:59:21 +01:00
|
|
|
itemIcon?: (item: any) => string | undefined,
|
|
|
|
|
itemIndent?: (item: any) => number | undefined,
|
2023-02-25 00:31:10 +01:00
|
|
|
itemType?: (item: any) => 'error' | undefined,
|
2023-02-16 16:59:21 +01:00
|
|
|
selectedItem?: any,
|
|
|
|
|
onAccepted?: (item: any) => void,
|
|
|
|
|
onSelected?: (item: any) => void,
|
2023-03-02 00:27:23 +01:00
|
|
|
onLeftArrow?: (item: any) => void,
|
|
|
|
|
onRightArrow?: (item: any) => void,
|
2023-02-16 16:59:21 +01:00
|
|
|
onHighlighted?: (item: any | undefined) => void,
|
2023-03-02 00:27:23 +01:00
|
|
|
onIconClicked?: (item: any) => void,
|
2023-02-16 16:59:21 +01:00
|
|
|
showNoItemsMessage?: boolean,
|
2023-02-25 00:31:10 +01:00
|
|
|
dataTestId?: string,
|
2023-02-16 16:59:21 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const ListView: React.FC<ListViewProps> = ({
|
|
|
|
|
items = [],
|
|
|
|
|
itemKey,
|
|
|
|
|
itemRender,
|
|
|
|
|
itemIcon,
|
2023-02-23 18:09:21 +01:00
|
|
|
itemType,
|
2023-02-16 16:59:21 +01:00
|
|
|
itemIndent,
|
|
|
|
|
selectedItem,
|
|
|
|
|
onAccepted,
|
|
|
|
|
onSelected,
|
2023-03-02 00:27:23 +01:00
|
|
|
onLeftArrow,
|
|
|
|
|
onRightArrow,
|
2023-02-16 16:59:21 +01:00
|
|
|
onHighlighted,
|
2023-03-02 00:27:23 +01:00
|
|
|
onIconClicked,
|
2023-02-16 16:59:21 +01:00
|
|
|
showNoItemsMessage,
|
2023-02-25 00:31:10 +01:00
|
|
|
dataTestId,
|
2023-02-16 16:59:21 +01:00
|
|
|
}) => {
|
|
|
|
|
const itemListRef = React.createRef<HTMLDivElement>();
|
|
|
|
|
const [highlightedItem, setHighlightedItem] = React.useState<any>();
|
|
|
|
|
|
2023-02-25 00:31:10 +01:00
|
|
|
return <div className='list-view vbox' data-testid={dataTestId}>
|
2023-02-16 16:59:21 +01:00
|
|
|
<div
|
|
|
|
|
className='list-view-content'
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onDoubleClick={() => onAccepted?.(selectedItem)}
|
|
|
|
|
onKeyDown={event => {
|
|
|
|
|
if (event.key === 'Enter') {
|
|
|
|
|
onAccepted?.(selectedItem);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2023-03-02 00:27:23 +01:00
|
|
|
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')
|
2023-02-16 16:59:21 +01:00
|
|
|
return;
|
2023-03-02 00:27:23 +01:00
|
|
|
|
2023-02-16 16:59:21 +01:00
|
|
|
event.stopPropagation();
|
|
|
|
|
event.preventDefault();
|
2023-03-02 00:27:23 +01:00
|
|
|
|
|
|
|
|
if (event.key === 'ArrowLeft') {
|
|
|
|
|
onLeftArrow?.(selectedItem);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.key === 'ArrowRight') {
|
|
|
|
|
onRightArrow?.(selectedItem);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2023-02-16 16:59:21 +01:00
|
|
|
const index = selectedItem ? items.indexOf(selectedItem) : -1;
|
|
|
|
|
let newIndex = index;
|
|
|
|
|
if (event.key === 'ArrowDown') {
|
|
|
|
|
if (index === -1)
|
|
|
|
|
newIndex = 0;
|
|
|
|
|
else
|
|
|
|
|
newIndex = Math.min(index + 1, items.length - 1);
|
|
|
|
|
}
|
|
|
|
|
if (event.key === 'ArrowUp') {
|
|
|
|
|
if (index === -1)
|
|
|
|
|
newIndex = items.length - 1;
|
|
|
|
|
else
|
|
|
|
|
newIndex = Math.max(index - 1, 0);
|
|
|
|
|
}
|
2023-03-02 00:27:23 +01:00
|
|
|
|
2023-02-16 16:59:21 +01:00
|
|
|
const element = itemListRef.current?.children.item(newIndex);
|
|
|
|
|
scrollIntoViewIfNeeded(element);
|
2023-02-23 23:40:07 +01:00
|
|
|
onHighlighted?.(undefined);
|
2023-02-16 16:59:21 +01:00
|
|
|
onSelected?.(items[newIndex]);
|
|
|
|
|
}}
|
|
|
|
|
ref={itemListRef}
|
|
|
|
|
>
|
|
|
|
|
{showNoItemsMessage && items.length === 0 && <div className='list-view-empty'>No items</div>}
|
2023-02-25 00:31:10 +01:00
|
|
|
{items.map((item, index) => <ListItemView
|
|
|
|
|
key={itemKey ? itemKey(item) : String(index)}
|
|
|
|
|
hasIcons={!!itemIcon}
|
2023-02-16 16:59:21 +01:00
|
|
|
icon={itemIcon?.(item)}
|
2023-02-23 18:09:21 +01:00
|
|
|
type={itemType?.(item)}
|
2023-02-16 16:59:21 +01:00
|
|
|
indent={itemIndent?.(item)}
|
|
|
|
|
isHighlighted={item === highlightedItem}
|
|
|
|
|
isSelected={item === selectedItem}
|
|
|
|
|
onSelected={() => onSelected?.(item)}
|
|
|
|
|
onMouseEnter={() => {
|
|
|
|
|
setHighlightedItem(item);
|
|
|
|
|
onHighlighted?.(item);
|
|
|
|
|
}}
|
|
|
|
|
onMouseLeave={() => {
|
|
|
|
|
setHighlightedItem(undefined);
|
|
|
|
|
onHighlighted?.(undefined);
|
|
|
|
|
}}
|
2023-03-02 00:27:23 +01:00
|
|
|
onIconClicked={() => onIconClicked?.(item)}
|
2023-02-16 16:59:21 +01:00
|
|
|
>
|
|
|
|
|
{itemRender(item)}
|
|
|
|
|
</ListItemView>)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ListItemView: React.FC<{
|
|
|
|
|
key: string,
|
2023-02-25 00:31:10 +01:00
|
|
|
hasIcons: boolean,
|
2023-02-16 16:59:21 +01:00
|
|
|
icon: string | undefined,
|
2023-02-23 18:09:21 +01:00
|
|
|
type: 'error' | undefined,
|
2023-02-16 16:59:21 +01:00
|
|
|
indent: number | undefined,
|
|
|
|
|
isHighlighted: boolean,
|
|
|
|
|
isSelected: boolean,
|
|
|
|
|
onSelected: () => void,
|
|
|
|
|
onMouseEnter: () => void,
|
|
|
|
|
onMouseLeave: () => void,
|
2023-03-02 00:27:23 +01:00
|
|
|
onIconClicked: () => void,
|
2023-02-16 16:59:21 +01:00
|
|
|
children: React.ReactNode | React.ReactNode[],
|
2023-03-02 00:27:23 +01:00
|
|
|
}> = ({ key, hasIcons, icon, type, indent, onSelected, onMouseEnter, onMouseLeave, onIconClicked, isHighlighted, isSelected, children }) => {
|
2023-02-16 16:59:21 +01:00
|
|
|
const selectedSuffix = isSelected ? ' selected' : '';
|
|
|
|
|
const highlightedSuffix = isHighlighted ? ' highlighted' : '';
|
2023-02-23 18:09:21 +01:00
|
|
|
const errorSuffix = type === 'error' ? ' error' : '';
|
2023-02-16 16:59:21 +01:00
|
|
|
const divRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (divRef.current && isSelected)
|
|
|
|
|
scrollIntoViewIfNeeded(divRef.current);
|
|
|
|
|
}, [isSelected]);
|
|
|
|
|
|
|
|
|
|
return <div
|
|
|
|
|
key={key}
|
2023-02-23 18:09:21 +01:00
|
|
|
className={'list-view-entry' + selectedSuffix + highlightedSuffix + errorSuffix}
|
2023-02-16 16:59:21 +01:00
|
|
|
onClick={onSelected}
|
|
|
|
|
onMouseEnter={onMouseEnter}
|
|
|
|
|
onMouseLeave={onMouseLeave}
|
|
|
|
|
ref={divRef}
|
|
|
|
|
>
|
|
|
|
|
{indent ? <div style={{ minWidth: indent * 16 }}></div> : undefined}
|
2023-03-02 00:27:23 +01:00
|
|
|
{hasIcons && <div className={'codicon ' + (icon || 'blank')} style={{ minWidth: 16, marginRight: 4 }} onClick={onIconClicked}></div>}
|
2023-02-16 16:59:21 +01:00
|
|
|
{typeof children === 'string' ? <div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{children}</div> : children}
|
|
|
|
|
</div>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function scrollIntoViewIfNeeded(element?: Element | null) {
|
|
|
|
|
if (!element)
|
|
|
|
|
return;
|
|
|
|
|
if ((element as any)?.scrollIntoViewIfNeeded)
|
|
|
|
|
(element as any).scrollIntoViewIfNeeded(false);
|
|
|
|
|
else
|
|
|
|
|
element?.scrollIntoView();
|
|
|
|
|
}
|