Skip to content

Instantly share code, notes, and snippets.

@AmazingTurtle
Created January 16, 2023 16:10
Show Gist options
  • Save AmazingTurtle/a18c794982f6a4824640e0edc7e12eaf to your computer and use it in GitHub Desktop.
Save AmazingTurtle/a18c794982f6a4824640e0edc7e12eaf to your computer and use it in GitHub Desktop.
A customizable command autocompletion react component with arrow navigation and history
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