@react-md/autocomplete
Version:
Create an accessible autocomplete component that allows a user to get real-time suggestions as they type within an input. This component can also be hooked up to a backend API that handles additional filtering or sorting.
493 lines (451 loc) • 12.5 kB
text/typescript
import type {
ChangeEventHandler,
CSSProperties,
FocusEventHandler,
HTMLAttributes,
KeyboardEventHandler,
MouseEventHandler,
Ref,
} from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ListElement } from "@react-md/list";
import type { FixedPositioningTransitionCallbacks } from "@react-md/transition";
import { useFixedPositioning } from "@react-md/transition";
import type { ItemRefList } from "@react-md/utils";
import {
MovementPresets,
scrollIntoView,
useActiveDescendantMovement,
useCloseOnOutsideClick,
useEnsuredRef,
useIsUserInteractionMode,
useToggle,
} from "@react-md/utils";
import type {
AutoCompleteData,
AutoCompleteListboxPositionOptions,
AutoCompleteProps,
} from "./types";
import { getFilterFunction } from "./utils";
type EventHandlers = Pick<
HTMLAttributes<HTMLInputElement>,
"onBlur" | "onFocus" | "onChange" | "onClick" | "onKeyDown"
>;
export type RequiredAutoCompleteProps = Required<
Pick<
AutoCompleteProps,
| "data"
| "filter"
| "filterOptions"
| "filterOnNoValue"
| "valueKey"
| "getResultId"
| "getResultValue"
| "clearOnAutoComplete"
>
>;
export type OptionalAutoCompleteProps = Pick<
AutoCompleteProps,
"onAutoComplete" | "disableShowOnFocus"
>;
export interface AutoCompleteOptions
extends EventHandlers,
OptionalAutoCompleteProps,
RequiredAutoCompleteProps,
AutoCompleteListboxPositionOptions {
isListAutocomplete: boolean;
isInlineAutocomplete: boolean;
forwardedRef?: Ref<HTMLInputElement>;
suggestionsId: string;
propValue?: string;
defaultValue?: string;
}
export interface AutoCompleteReturnValue {
ref: (instance: HTMLInputElement | null) => void;
match: string;
value: string;
visible: boolean;
activeId: string;
itemRefs: ItemRefList<HTMLLIElement>;
filteredData: readonly AutoCompleteData[];
listboxRef: Ref<ListElement>;
handleBlur: FocusEventHandler<HTMLInputElement>;
handleFocus: FocusEventHandler<HTMLInputElement>;
handleClick: MouseEventHandler<HTMLInputElement>;
handleChange: ChangeEventHandler<HTMLInputElement>;
handleKeyDown: KeyboardEventHandler<HTMLInputElement>;
handleAutoComplete: (index: number) => void;
fixedStyle: CSSProperties | undefined;
transitionHooks: Required<FixedPositioningTransitionCallbacks>;
}
/**
* This hook handles all the autocomplete's "logic" and behavior.
*
* @internal
*/
export function useAutoComplete({
suggestionsId,
data,
propValue,
defaultValue = "",
filter: filterFn,
filterOptions,
filterOnNoValue,
valueKey,
getResultId,
getResultValue,
onBlur,
onFocus,
onClick,
onChange,
onKeyDown,
forwardedRef,
onAutoComplete,
clearOnAutoComplete,
anchor,
xMargin,
yMargin,
vwMargin,
vhMargin,
transformOrigin,
listboxWidth,
listboxStyle,
preventOverlap,
disableSwapping,
disableVHBounds,
closeOnResize,
closeOnScroll,
disableShowOnFocus: propDisableShowOnFocus,
isListAutocomplete,
isInlineAutocomplete,
}: AutoCompleteOptions): AutoCompleteReturnValue {
const [ref, refHandler] = useEnsuredRef(forwardedRef);
const filter = getFilterFunction(filterFn);
const [
{ value: stateValue, match, filteredData: stateFilteredData },
setState,
] = useState(() => {
const options = {
...filterOptions,
valueKey,
getItemValue: getResultValue,
startsWith: filterOptions?.startsWith ?? isInlineAutocomplete,
};
const value = propValue ?? defaultValue;
const filteredData =
filterOnNoValue || value ? filter(value, data, options) : data;
let match = value;
if (isInlineAutocomplete && filteredData.length) {
match = getResultValue(filteredData[0], valueKey);
}
return {
value,
match,
filteredData,
};
});
const filteredData = filterFn === "none" ? data : stateFilteredData;
const startsWith = filterOptions?.startsWith ?? isInlineAutocomplete;
const value = propValue ?? stateValue;
const setValue = useCallback(
(nextValue: string) => {
const isBackspace =
value.length > nextValue.length ||
(!!match && value.length === nextValue.length);
let filtered = data;
if (nextValue || filterOnNoValue) {
const options = {
...filterOptions,
valueKey,
getItemValue: getResultValue,
startsWith,
};
filtered = filter(nextValue, data, options);
}
let nextMatch = nextValue;
if (isInlineAutocomplete && filtered.length && !isBackspace) {
nextMatch = getResultValue(filtered[0], valueKey);
const input = ref.current;
if (input && !isBackspace) {
input.value = nextMatch;
input.setSelectionRange(nextValue.length, nextMatch.length);
}
}
setState({ value: nextValue, match: nextMatch, filteredData: filtered });
},
[
ref,
data,
filter,
filterOnNoValue,
filterOptions,
isInlineAutocomplete,
getResultValue,
value,
match,
startsWith,
valueKey,
]
);
// this is really just a hacky way to make sure that once a value has been
// autocompleted, the menu doesn't immediately re-appear due to the hook below
// for showing when the value/ filtered data list change
const autocompleted = useRef(false);
const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (onChange) {
onChange(event);
}
autocompleted.current = false;
setValue(event.currentTarget.value);
},
[setValue, onChange]
);
const [visible, show, hide] = useToggle(false);
const isTouch = useIsUserInteractionMode("touch");
const disableShowOnFocus = propDisableShowOnFocus ?? isTouch;
const focused = useRef(false);
const handleBlur = useCallback(
(event: React.FocusEvent<HTMLInputElement>) => {
if (onBlur) {
onBlur(event);
}
focused.current = false;
},
[onBlur]
);
const handleFocus = useCallback(
(event: React.FocusEvent<HTMLInputElement>) => {
if (onFocus) {
onFocus(event);
}
if (disableShowOnFocus) {
return;
}
focused.current = true;
if (isListAutocomplete && filteredData.length) {
show();
}
},
[filteredData, isListAutocomplete, onFocus, show, disableShowOnFocus]
);
const handleClick = useCallback(
(event: React.MouseEvent<HTMLInputElement>) => {
if (onClick) {
onClick(event);
}
// since click events also trigger focus events right beforehand, want to
// skip the first click handler and require a second click to show it.
// this is why the focused.current isn't set onFocus for
// disableShowOnFocus
if (disableShowOnFocus && !focused.current) {
focused.current = true;
return;
}
if (isListAutocomplete && filteredData.length) {
show();
}
},
[disableShowOnFocus, filteredData.length, isListAutocomplete, onClick, show]
);
const handleAutoComplete = useCallback(
(index: number) => {
const result = filteredData[index];
const resultValue = getResultValue(result, valueKey);
if (onAutoComplete) {
onAutoComplete({
value: resultValue,
index,
result,
dataIndex: data.findIndex(
(datum) => getResultValue(datum, valueKey) === resultValue
),
filteredData,
});
}
setValue(clearOnAutoComplete ? "" : resultValue);
autocompleted.current = true;
},
[
clearOnAutoComplete,
data,
filteredData,
getResultValue,
onAutoComplete,
valueKey,
setValue,
]
);
const nodeRef = useRef<ListElement | null>(null);
const {
activeId,
itemRefs,
onKeyDown: handleKeyDown,
focusedIndex,
setFocusedIndex,
} = useActiveDescendantMovement<
AutoCompleteData,
HTMLInputElement,
HTMLLIElement
>({
...MovementPresets.VERTICAL_COMBOBOX,
getId: getResultId,
items: filteredData,
baseId: suggestionsId,
onChange({ index, items, target }, itemRefs) {
// the default scroll into view behavior for aria-activedescendant
// movement won't work here since the "target" element will actually be
// the input element instead of the listbox. So need to implement the
// scroll into view behavior manually from the listbox instead.
const item = itemRefs[index] && itemRefs[index].current;
const { current: listbox } = nodeRef;
if (item && listbox && listbox.scrollHeight > listbox.offsetHeight) {
scrollIntoView(listbox, item);
}
if (!isInlineAutocomplete) {
return;
}
const nextMatch = getResultValue(items[index], valueKey);
target.value = nextMatch;
target.setSelectionRange(0, nextMatch.length);
setState((prevState) => ({
...prevState,
value: nextMatch,
match: nextMatch,
}));
},
onKeyDown(event) {
if (onKeyDown) {
onKeyDown(event);
}
const input = event.currentTarget;
switch (event.key) {
case "ArrowDown":
if (
isListAutocomplete &&
event.altKey &&
!visible &&
filteredData.length
) {
// don't want the cursor to move if there is text
event.preventDefault();
event.stopPropagation();
show();
setFocusedIndex(-1);
}
break;
case "ArrowUp":
if (isListAutocomplete && event.altKey && visible) {
// don't want the cursor to move if there is text
event.preventDefault();
event.stopPropagation();
hide();
}
break;
case "Tab":
event.stopPropagation();
hide();
break;
case "ArrowRight":
if (
isInlineAutocomplete &&
input.selectionStart !== input.selectionEnd
) {
const index = focusedIndex !== -1 ? focusedIndex : 0;
hide();
handleAutoComplete(index);
}
break;
case "Enter":
if (visible && focusedIndex >= 0) {
event.preventDefault();
event.stopPropagation();
handleAutoComplete(focusedIndex);
hide();
}
break;
case "Escape":
if (visible) {
event.stopPropagation();
hide();
} else if (value) {
event.stopPropagation();
setValue("");
}
break;
// no default
}
},
});
useCloseOnOutsideClick({
enabled: visible,
element: ref.current,
onOutsideClick: hide,
});
const {
ref: listboxRef,
style,
callbacks,
updateStyle,
} = useFixedPositioning({
fixedTo: ref,
nodeRef,
anchor,
onScroll(_event, { visible }) {
if (closeOnScroll || !visible) {
hide();
}
},
onResize: closeOnResize ? hide : undefined,
width: listboxWidth,
xMargin,
yMargin,
vwMargin,
vhMargin,
transformOrigin,
preventOverlap,
disableSwapping,
disableVHBounds,
});
useEffect(() => {
if (!focused.current || autocompleted.current) {
return;
}
if (filteredData.length && !visible && value.length && isListAutocomplete) {
show();
} else if (!filteredData.length && visible) {
hide();
}
// this effect is just for toggling the visibility states as needed if the
// value or filter data list changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filteredData, value]);
useEffect(() => {
if (!visible) {
setFocusedIndex(-1);
return;
}
updateStyle();
// only want to trigger on data changes and setFocusedIndex shouldn't change
// anyways
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, filteredData]);
return {
ref: refHandler,
value,
match,
visible,
activeId,
itemRefs,
filteredData,
fixedStyle: { ...style, ...listboxStyle },
transitionHooks: callbacks,
listboxRef,
handleBlur,
handleFocus,
handleClick,
handleChange,
handleKeyDown,
handleAutoComplete,
};
}