function NonLazyTextWithDropdown()

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>
    );
}