import * as React from 'react'; import { useState, useCallback, CSSProperties, useEffect, useRef, useId } from 'react'; import { _ } from '@joplin/lib/locale'; import { focus } from '@joplin/lib/utils/focusHandler'; import ItemList from './ItemList'; interface Props { inputType?: string; inputStyle: CSSProperties; value: string; onChange: (newValue: string)=> void; suggestedValues: string[]; renderOption: (suggestedValue: string)=> React.ReactElement; controls?: React.ReactNode; inputId: string; } const suggestionMatchesFilter = (suggestion: string, filter: string) => { return suggestion.toLowerCase().startsWith(filter.toLowerCase()); }; const InlineCombobox: React.FC = ({ inputType, controls, inputStyle, value, suggestedValues, renderOption, onChange, inputId }) => { const [showList, setShowList] = useState(false); const containerRef = useRef(null); const inputRef = useRef(null); const listboxRef = useRef|null>(null); const [filteredSuggestions, setFilteredSuggestions] = useState(suggestedValues); useEffect(() => { setFilteredSuggestions(suggestedValues); }, [suggestedValues]); const selectedIndex = filteredSuggestions.indexOf(value); useEffect(() => { if (selectedIndex >= 0 && showList) { listboxRef.current?.makeItemIndexVisible(selectedIndex); } }, [selectedIndex, showList]); const focusInput = useCallback(() => { focus('ComboBox/focus input', inputRef.current); }, []); const onTextChange: React.ChangeEventHandler = useCallback((event) => { const newValue = event.target.value; onChange(newValue); setShowList(true); const filteredSuggestions = suggestedValues.filter((suggestion: string) => suggestionMatchesFilter(suggestion, newValue), ); // If no suggestions, show all fonts setFilteredSuggestions(filteredSuggestions.length > 0 ? filteredSuggestions : suggestedValues); }, [onChange, suggestedValues]); const onFocus: React.FocusEventHandler = useCallback(() => { setShowList(true); }, []); const onBlur = useCallback((event: React.FocusEvent) => { const hasHoverOrFocus = !!containerRef.current.querySelector(':focus-within, :hover'); const movesToContainedItem = containerRef.current.contains(event.relatedTarget); if (!hasHoverOrFocus && !movesToContainedItem) { setShowList(false); } }, []); const onItemClick: React.MouseEventHandler = useCallback((event) => { const newValue = event.currentTarget.getAttribute('data-key'); if (!newValue) return; focusInput(); onChange(newValue); setFilteredSuggestions(suggestedValues); setShowList(false); }, [onChange, suggestedValues, focusInput]); const onKeyDown: React.KeyboardEventHandler = useCallback(event => { if (event.nativeEvent.isComposing) return; let closestIndex = selectedIndex; if (selectedIndex === -1) { closestIndex = filteredSuggestions.findIndex(suggestion => { return suggestionMatchesFilter(suggestion, value); }); } const isGoToNext = event.code === 'ArrowDown'; if (isGoToNext || event.code === 'ArrowUp') { event.preventDefault(); if (!event.altKey) { let newSelectedIndex; if (isGoToNext) { newSelectedIndex = (selectedIndex + 1) % filteredSuggestions.length; } else { newSelectedIndex = selectedIndex - 1; if (newSelectedIndex < 0) { newSelectedIndex += filteredSuggestions.length; } } const newKey = filteredSuggestions[newSelectedIndex]; onChange(newKey); } setShowList(true); } else if (event.code === 'Enter') { event.preventDefault(); onChange(filteredSuggestions[closestIndex]); setShowList(false); } else if (event.code === 'Escape') { event.preventDefault(); setShowList(false); } }, [filteredSuggestions, value, selectedIndex, onChange]); const valuesListId = useId(); const itemId = (index: number) => { if (index < 0) { return undefined; } else { return `combobox-${valuesListId}-option-${index}`; } }; const onRenderItem = (key: string, index: number) => { const selected = key === value; const id = itemId(index); return (
{renderOption(key)}
); }; return (
{ // Custom controls controls } = 0 ? selectedIndex : undefined} items={filteredSuggestions} itemRenderer={onRenderItem} id={valuesListId} ref={listboxRef} />
); }; export default InlineCombobox;