Created
January 16, 2023 16:10
-
-
Save AmazingTurtle/a18c794982f6a4824640e0edc7e12eaf to your computer and use it in GitHub Desktop.
A customizable command autocompletion react component with arrow navigation and history
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { | |
ChangeEvent, | |
KeyboardEvent, | |
forwardRef, | |
HTMLAttributes, | |
InputHTMLAttributes, | |
useCallback, | |
useMemo, | |
useState, | |
FocusEvent, | |
ReactNode, | |
useRef, | |
useEffect, | |
} from 'react'; | |
import { classNames } from 'utils/class-names'; | |
type StringKeys<T, KT = string> = { | |
[K in keyof T]: T[K] extends KT ? K : never; | |
}[keyof T]; | |
export interface RenderSuggestionItemContext<TCommand> { | |
// empty | |
command: TCommand; | |
isHighlighted: boolean; | |
} | |
export interface CommandCompleteProps< | |
TCommand extends { [C in TCommandLabelKey]: string }, | |
TCommandLabelKey extends StringKeys<TCommand>, | |
> { | |
availableCommands: Array<TCommand>; | |
commandLabelKey: TCommandLabelKey; | |
renderSuggestionItem?: ( | |
renderSuggestionItemContext: RenderSuggestionItemContext<TCommand>, | |
) => ReactNode; | |
maxCommandHistory?: boolean; | |
minSuggestions?: number; | |
maxSuggestions?: number; | |
minCharactersToSuggest?: number; | |
keysToConfirmSelection?: Array<string>; | |
appendAfterSelection?: string; | |
keysToSubmit?: Array<string>; | |
clearAfterSubmit?: boolean; | |
onSubmit?: (command: string) => void; | |
containerProps?: HTMLAttributes<HTMLDivElement>; | |
inputProps?: InputHTMLAttributes<HTMLInputElement>; | |
dropdownProps?: HTMLAttributes<HTMLDivElement>; | |
} | |
export const CommandComplete = forwardRef(function CommandCompleteForwarded< | |
TCommand extends { [C in TCommandLabelKey]: string }, | |
TCommandLabelKey extends StringKeys<TCommand>, | |
>( | |
{ | |
minSuggestions = 1, | |
maxSuggestions, | |
minCharactersToSuggest = 1, | |
keysToConfirmSelection = ['Enter', 'Tab'], | |
keysToSubmit = ['Enter'], | |
appendAfterSelection = ' ', | |
onSubmit, | |
availableCommands, | |
commandLabelKey, | |
renderSuggestionItem, | |
clearAfterSubmit = true, | |
containerProps = {}, | |
inputProps = {}, | |
dropdownProps = {}, | |
}: CommandCompleteProps<TCommand, TCommandLabelKey>, | |
ref: React.Ref<HTMLDivElement>, | |
) { | |
const [highlightedOptionIndex, setHighlightedOptionIndex] = useState< | |
number | undefined | |
>(undefined); | |
const [isHistoryMode, setHistoryMode] = useState(false); | |
const [submittedHistory, setSubmittedHistory] = useState<Array<string>>([]); | |
const [isFocused, setIsFocused] = useState(false); | |
const moveCursorRef = useRef(false); | |
const onFocus = useCallback( | |
(event: FocusEvent<HTMLDivElement>) => { | |
setIsFocused(true); | |
containerProps.onFocus?.(event); | |
}, | |
[containerProps], | |
); | |
const onBlur = useCallback( | |
(event: FocusEvent<HTMLDivElement>) => { | |
setIsFocused(false); | |
setHistoryMode(false); | |
setHighlightedOptionIndex(undefined); | |
containerProps.onBlur?.(event); | |
}, | |
[containerProps], | |
); | |
const [inputValue, setInputValue] = useState(''); | |
const onChange = useCallback( | |
(event: ChangeEvent<HTMLInputElement>) => { | |
setInputValue(event.currentTarget.value); | |
moveCursorRef.current = true; | |
setHistoryMode(false); | |
setHighlightedOptionIndex(undefined); | |
inputProps.onChange?.(event); | |
}, | |
[inputProps], | |
); | |
const dropdownDivRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => { | |
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<TCommand>) => { | |
if (renderSuggestionItem) { | |
return renderSuggestionItem(renderContext); | |
} | |
const { command, isHighlighted } = renderContext; | |
return ( | |
<div | |
className={classNames( | |
'p-2 hover:bg-wu-light pointer hover:bg-ci-500', | |
isHighlighted && 'bg-ci-500', | |
)} | |
> | |
{command[commandLabelKey]} | |
</div> | |
); | |
}, | |
[commandLabelKey, renderSuggestionItem], | |
); | |
const inputRef = useRef<HTMLInputElement>(null); | |
useEffect(() => { | |
if (moveCursorRef.current) { | |
moveCursorRef.current = false; | |
if (inputRef.current) { | |
inputRef.current.selectionStart = inputRef.current.value.length; | |
} | |
} | |
}, [inputValue]); | |
return ( | |
<div | |
ref={ref} | |
{...containerProps} | |
className={classNames('relative', containerProps.className)} | |
onFocus={onFocus} | |
onBlur={onBlur} | |
> | |
<input | |
type="text" | |
value={inputValue} | |
{...inputProps} | |
ref={inputRef} | |
className={classNames('w-full', inputProps.className)} | |
onKeyDown={onKeyDown} | |
onChange={onChange} | |
/> | |
{isFocused && | |
!isHistoryMode && | |
filteredCommands.length >= minSuggestions && | |
inputValue.length > minCharactersToSuggest && ( | |
<div | |
{...dropdownProps} | |
className={classNames( | |
'absolute top-full left-0 w-full bg-wu-dark border border-wu-dark-2 rounded-b max-h-[200px] overflow-y-auto', | |
dropdownProps?.className, | |
)} | |
ref={dropdownDivRef} | |
> | |
{filteredCommands.map((command, index) => ( | |
<div | |
key={`${command[commandLabelKey]}_${ | |
highlightedOptionIndex === index ? 'active' : 'not-active' | |
}`} | |
> | |
{onRenderSuggestionItem({ | |
command, | |
isHighlighted: highlightedOptionIndex === index, | |
})} | |
</div> | |
))} | |
</div> | |
)} | |
</div> | |
); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment