in webview-ui/src/components/TextWithDropdown.tsx [69:244]
function NonLazyTextWithDropdown(props: NonLazyTextWithDropdownProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [searchText, setSearchText] = useState("");
const [allItems, setAllItems] = useState([...props.items]);
const listboxRef = useRef<HTMLOListElement>(null);
useEffect(() => {
// If there are any items (including the selected item) in props that aren't in allItems, reset allItems
const propsHasExtraItems = props.items.some((item) => !allItems.includes(item));
const selectedItemNotInAllItems = props.selectedItem && !allItems.includes(props.selectedItem);
if (propsHasExtraItems || selectedItemNotInAllItems) {
const updatedAllItems = selectedItemNotInAllItems
? [props.selectedItem!, ...props.items]
: [...props.items];
setAllItems(updatedAllItems);
}
}, [props.items, props.selectedItem, allItems]);
const itemLookup = new Map(allItems.map((item) => [item.toLowerCase(), item]));
const canAddItem = searchText ? !itemLookup.has(searchText.toLowerCase()) : false;
const addItems = createAddItems(canAddItem, props.getAddItemText(searchText), searchText, props.selectedItem);
const filteredItems = createFilteredItems(allItems, searchText, props.selectedItem);
const selectionItems: SelectionItem[] = [...addItems, ...filteredItems];
const inputText = props.selectedItem || searchText;
function setSelected(value: string | null) {
const isNew = value !== null && !props.items.includes(value);
props.onSelect(value, isNew);
}
function handleTextFieldClick() {
// For consistency with the VS Code dropdown, we toggle the dropdown when the text field is clicked.
setIsExpanded(!isExpanded);
}
function handleFocus() {
// Work around the fact that focus events are fired before click events.
// If we don't delay the expansion, the dropdown will be expanded and then immediately collapsed
// by the click event handler.
// A 250ms delay is enough to be reasonably sure that the click event has been processed.
// This is admittedly not completely robust, but:
// 1. The consequences of getting the timing wrong are minor (user might need to expand the listbox again).
// 2. The delay is only noticable when tabbing into the field (click events are processed immediately).
setTimeout(() => {
setIsExpanded(true);
}, 250);
}
function handleBlur(e: React.FocusEvent) {
const selectedItem = selectionItems.find((item) => item.isSelected) || null;
selectItem(selectedItem);
// The relatedTarget property is the form element that took the focus away.
const newFocusTargetIsOutsideListbox =
e.relatedTarget === null || (listboxRef.current && !listboxRef.current.contains(e.relatedTarget));
// If the selected item was an "add" item, the fact that we just selected it will have caused
// it to disappear from the listbox, meaning it won't receive a 'click' event, which we rely on to
// collapse the listbox. If this is the case, we collapse the listbox here.
const collapseListbox = selectedItem?.isAddItem || newFocusTargetIsOutsideListbox;
if (collapseListbox) {
setIsExpanded(false);
}
}
function handleListboxFocus(e: React.FocusEvent) {
// Listbox focus events can be fired when users click on items in the listbox, and in these cases are
// followed by a click event, which will toggle the expanded state and hide the listbox.
// If we allow this event to bubble up to the container, it will set the state back to expanded, so we
// prevent this from happening here.
e.stopPropagation();
}
function handleTextChange(e: ChangeEvent) {
const newText = (e.currentTarget as HTMLInputElement).value.trim();
const newSelectedValue = itemLookup.get(newText.toLowerCase()) || null;
setSearchText(newSelectedValue ? "" : newText);
setSelected(newSelectedValue);
}
function handleItemClick(e: React.MouseEvent, item: SelectionItem) {
e.preventDefault();
e.stopPropagation();
selectItem(item);
setIsExpanded(false);
}
function handleKeyDown(e: React.KeyboardEvent) {
const currentIndex = selectionItems.findIndex((item) => item.isSelected);
if (e.key === "ArrowDown") {
const newIndex = Math.min(selectionItems.length - 1, currentIndex + 1);
const newValue = selectionItems[newIndex].value || null;
setSelected(newValue);
} else if (e.key === "ArrowUp") {
const newIndex = Math.max(0, currentIndex - 1);
const newValue = selectionItems[newIndex].value;
setSelected(newValue);
} else if (e.key === "Enter" && currentIndex !== -1) {
selectItem(selectionItems[currentIndex]);
setIsExpanded(!isExpanded);
} else if (e.key === "Escape") {
setIsExpanded(false);
}
}
function selectItem(item: SelectionItem | null) {
if (item === null) {
setSelected(null);
} else if (item.isAddItem) {
const newItemValue = searchText;
setAllItems([newItemValue, ...allItems]);
setSelected(newItemValue);
} else {
setSelected(item.value);
}
setSearchText("");
}
const displayListbox = isExpanded && selectionItems.length > 0;
return (
<div
role="combobox"
style={{ position: "relative" }}
className={props.className}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
>
<div className={styles.inputField}>
<input
type="text"
className={styles.selectedValue}
onInput={handleTextChange}
value={inputText}
onClick={handleTextFieldClick}
></input>
<svg
className={styles.dropDownButton}
width="16"
height="16"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.976 10.072l4.357-4.357.62.618L8.284 11h-.618L3 6.333l.619-.618 4.357 4.357z"
></path>
</svg>
</div>
<ol
className={`${styles.listbox} ${displayListbox ? "" : styles.hidden}`}
tabIndex={-1}
onFocus={handleListboxFocus}
ref={listboxRef}
>
{selectionItems.map((item) => (
<li
className={`${styles.listboxItem} ${item.isSelected ? styles.selected : ""}`}
onClick={(e) => handleItemClick(e, item)}
key={item.value}
>
{item.displayText}
</li>
))}
</ol>
</div>
);
}