import React, { ChangeEvent, KeyboardEvent, forwardRef, HTMLAttributes, InputHTMLAttributes, useCallback, useMemo, useState, FocusEvent, ReactNode, useRef, useEffect, } from 'react'; import { classNames } from 'utils/class-names'; type StringKeys = { [K in keyof T]: T[K] extends KT ? K : never; }[keyof T]; export interface RenderSuggestionItemContext { // empty command: TCommand; isHighlighted: boolean; } export interface CommandCompleteProps< TCommand extends { [C in TCommandLabelKey]: string }, TCommandLabelKey extends StringKeys, > { availableCommands: Array; commandLabelKey: TCommandLabelKey; renderSuggestionItem?: ( renderSuggestionItemContext: RenderSuggestionItemContext, ) => ReactNode; maxCommandHistory?: boolean; minSuggestions?: number; maxSuggestions?: number; minCharactersToSuggest?: number; keysToConfirmSelection?: Array; appendAfterSelection?: string; keysToSubmit?: Array; clearAfterSubmit?: boolean; onSubmit?: (command: string) => void; containerProps?: HTMLAttributes; inputProps?: InputHTMLAttributes; dropdownProps?: HTMLAttributes; } export const CommandComplete = forwardRef(function CommandCompleteForwarded< TCommand extends { [C in TCommandLabelKey]: string }, TCommandLabelKey extends StringKeys, >( { minSuggestions = 1, maxSuggestions, minCharactersToSuggest = 1, keysToConfirmSelection = ['Enter', 'Tab'], keysToSubmit = ['Enter'], appendAfterSelection = ' ', onSubmit, availableCommands, commandLabelKey, renderSuggestionItem, clearAfterSubmit = true, containerProps = {}, inputProps = {}, dropdownProps = {}, }: CommandCompleteProps, ref: React.Ref, ) { const [highlightedOptionIndex, setHighlightedOptionIndex] = useState< number | undefined >(undefined); const [isHistoryMode, setHistoryMode] = useState(false); const [submittedHistory, setSubmittedHistory] = useState>([]); const [isFocused, setIsFocused] = useState(false); const moveCursorRef = useRef(false); const onFocus = useCallback( (event: FocusEvent) => { setIsFocused(true); containerProps.onFocus?.(event); }, [containerProps], ); const onBlur = useCallback( (event: FocusEvent) => { setIsFocused(false); setHistoryMode(false); setHighlightedOptionIndex(undefined); containerProps.onBlur?.(event); }, [containerProps], ); const [inputValue, setInputValue] = useState(''); const onChange = useCallback( (event: ChangeEvent) => { setInputValue(event.currentTarget.value); moveCursorRef.current = true; setHistoryMode(false); setHighlightedOptionIndex(undefined); inputProps.onChange?.(event); }, [inputProps], ); const dropdownDivRef = useRef(null); // todo: debounce to prevent excessive rerenders const filteredCommands = useMemo(() => { const searchValue = inputValue.toLowerCase(); return availableCommands .filter((command) => command[commandLabelKey].indexOf(searchValue) !== -1) .sort( (commandA, commandB) => commandB[commandLabelKey].localeCompare(searchValue) - commandA[commandLabelKey].localeCompare(searchValue), ) .slice( 0, maxSuggestions === undefined ? availableCommands.length : maxSuggestions, ); }, [availableCommands, commandLabelKey, inputValue, maxSuggestions]); const onKeyDown = useCallback( (event: KeyboardEvent) => { const localHistoryMode = isHistoryMode || inputValue.length === 0; if (event.key === 'ArrowUp') { if (localHistoryMode) { setHistoryMode(localHistoryMode); const nextIndex = Math.max( highlightedOptionIndex === undefined ? submittedHistory.length - 1 : highlightedOptionIndex - 1, 0, ); setInputValue(submittedHistory[nextIndex]); moveCursorRef.current = true; setHighlightedOptionIndex(nextIndex); } else { const nextIndex = Math.max( highlightedOptionIndex === undefined ? filteredCommands.length - 1 : highlightedOptionIndex - 1, 0, ); setHighlightedOptionIndex(nextIndex); dropdownDivRef.current?.children[nextIndex].scrollIntoView({ block: 'center', }); } event.preventDefault(); } else if (event.key === 'ArrowDown') { if (localHistoryMode) { setHistoryMode(localHistoryMode); const nextIndex = Math.min( highlightedOptionIndex === undefined ? 0 : highlightedOptionIndex + 1, submittedHistory.length - 1, ); setInputValue(submittedHistory[nextIndex]); moveCursorRef.current = true; setHighlightedOptionIndex(nextIndex); } else { const nextIndex = Math.min( highlightedOptionIndex === undefined ? 0 : highlightedOptionIndex + 1, filteredCommands.length - 1, ); setHighlightedOptionIndex(nextIndex); dropdownDivRef.current?.children[nextIndex].scrollIntoView({ block: 'center', }); } event.preventDefault(); } else if ( keysToConfirmSelection.includes(event.key) && highlightedOptionIndex !== undefined ) { setInputValue( `${filteredCommands[highlightedOptionIndex][commandLabelKey]}${appendAfterSelection}`, ); moveCursorRef.current = true; setIsFocused(false); setHighlightedOptionIndex(undefined); } else if ( keysToSubmit.includes(event.key) && highlightedOptionIndex === undefined ) { onSubmit?.(inputValue); if (submittedHistory[submittedHistory.length - 1] !== inputValue) { setSubmittedHistory([...submittedHistory, inputValue]); } if (clearAfterSubmit) { setInputValue(''); } } else { setIsFocused(true); } if ( (event.key === 'Tab' && keysToConfirmSelection?.includes(event.key)) || keysToSubmit?.includes(event.key) ) { // do not leave input event.preventDefault(); } inputProps.onKeyDown?.(event); }, [ appendAfterSelection, clearAfterSubmit, commandLabelKey, filteredCommands, highlightedOptionIndex, inputProps, inputValue, isHistoryMode, keysToConfirmSelection, keysToSubmit, onSubmit, submittedHistory, ], ); const onRenderSuggestionItem = useCallback( (renderContext: RenderSuggestionItemContext) => { if (renderSuggestionItem) { return renderSuggestionItem(renderContext); } const { command, isHighlighted } = renderContext; return (
{command[commandLabelKey]}
); }, [commandLabelKey, renderSuggestionItem], ); const inputRef = useRef(null); useEffect(() => { if (moveCursorRef.current) { moveCursorRef.current = false; if (inputRef.current) { inputRef.current.selectionStart = inputRef.current.value.length; } } }, [inputValue]); return (
{isFocused && !isHistoryMode && filteredCommands.length >= minSuggestions && inputValue.length > minCharactersToSuggest && (
{filteredCommands.map((command, index) => (
{onRenderSuggestionItem({ command, isHighlighted: highlightedOptionIndex === index, })}
))}
)}
); });