import React, {
InputHTMLAttributes,
forwardRef,
useCallback,
useRef,
} from "react";
import { omit } from "../../../utils-external";
import { cl, composeEventHandlers } from "../../../utils/helpers";
import { useMergeRefs } from "../../../utils/hooks";
import filteredOptionsUtil from "../FilteredOptions/filtered-options-util";
import { useFilteredOptionsContext } from "../FilteredOptions/filteredOptionsContext";
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
import { ComboboxOption } from "../types";
import { useInputContext } from "./Input.context";
interface InputProps extends Omit<
InputHTMLAttributes,
| "value"
| "disabled"
| "onClick"
| "onInput"
| "type"
| "role"
| "onKeyUp"
| "onKeyDown"
| "autoComplete"
> {
ref: React.Ref;
inputClassName?: string;
shouldShowSelectedOptions?: boolean;
value?: string;
}
const Input = forwardRef(
(
{ inputClassName, shouldShowSelectedOptions, placeholder, onBlur, ...rest },
ref,
) => {
const internalRef = useRef(null);
const mergedRefs = useMergeRefs(ref, internalRef);
const {
clearInput,
inputProps,
onChange,
size,
value,
searchTerm,
setValue,
hideCaret,
setHideCaret,
readOnly,
} = useInputContext();
const {
selectedOptions,
removeSelectedOption,
toggleOption,
isMultiSelect,
maxSelected,
} = useSelectedOptionsContext();
const {
activeDecendantId,
allowNewValues,
currentOption,
filteredOptions,
isValueNew,
toggleIsListOpen,
isListOpen,
ariaDescribedBy,
setIsMouseLastUsedInputDevice,
shouldAutocomplete,
virtualFocus,
} = useFilteredOptionsContext();
const onEnter = useCallback(
(event: React.KeyboardEvent) => {
const isSelected = (text: string) =>
selectedOptions.some(
(option) =>
option.label.toLocaleLowerCase() === text.toLocaleLowerCase(),
);
if (currentOption) {
event.preventDefault();
// Selecting a value from the dropdown / FilteredOptions
toggleOption(currentOption, event);
if (!isMultiSelect && !isSelected(currentOption.label)) {
toggleIsListOpen(false);
}
} else if (isSelected(value)) {
event.preventDefault();
// Trying to set the same value that is already set, so just clearing the input
clearInput(event);
} else if ((allowNewValues || shouldAutocomplete) && value !== "") {
event.preventDefault();
const autoCompletedOption =
filteredOptionsUtil.getFirstValueStartingWith(
value,
filteredOptions,
);
/*
* User can have matching results, while not using the autocomplete result
* E.g. User types "Oslo", list has is "Oslo kommune", but user hits backspace, canceling autocomplete.
*/
const autoCompleteMatchesValue =
filteredOptionsUtil.normalizeText(value) ===
filteredOptionsUtil.normalizeText(autoCompletedOption?.label ?? "");
let optionToToggle: ComboboxOption | undefined;
if (
shouldAutocomplete &&
autoCompletedOption &&
autoCompleteMatchesValue
) {
optionToToggle = autoCompletedOption;
} else if (allowNewValues && isValueNew) {
optionToToggle = { label: value, value };
}
if (!optionToToggle) {
return;
}
toggleOption(optionToToggle, event);
if (!isMultiSelect && !isSelected(optionToToggle.label)) {
toggleIsListOpen(false);
}
}
},
[
allowNewValues,
clearInput,
currentOption,
filteredOptions,
isMultiSelect,
isValueNew,
selectedOptions,
shouldAutocomplete,
toggleIsListOpen,
toggleOption,
value,
],
);
const handleKeyUp = (e: React.KeyboardEvent) => {
e.preventDefault();
switch (e.key) {
case "Enter":
case "Accept":
onEnter(e);
break;
default:
break;
}
};
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
setIsMouseLastUsedInputDevice(false);
if (readOnly) {
return;
}
if (e.key === "Backspace") {
if (value === "" && shouldShowSelectedOptions) {
const lastSelectedOption =
selectedOptions[selectedOptions.length - 1];
if (lastSelectedOption) {
removeSelectedOption(lastSelectedOption);
}
}
} else if (e.key === "Enter" || e.key === "Accept") {
if (activeDecendantId || value) {
e.preventDefault();
}
} else if (e.key === "Escape") {
if (isListOpen || value) {
e.preventDefault(); // Prevents closing an encasing Modal, as Combobox reacts on keyup.
clearInput(e);
toggleIsListOpen(false);
}
} else if (["ArrowLeft", "ArrowRight"].includes(e.key)) {
/**
* In case user has an active selection and 'completes' the selection with ArrowLeft or ArrowRight
* we need to make sure to update the filter.
*/
if (value !== "" && value !== searchTerm) {
onChange(value);
}
} else if (e.key === "ArrowDown") {
// Reset the value to the search term to cancel autocomplete
// if the user moves focus down to the FilteredOptions
if (value !== searchTerm) {
setValue(searchTerm);
}
if (!isListOpen) {
toggleIsListOpen(true);
setTimeout(virtualFocus.moveFocusDown, 0); // Wait until list is visible so that scrollIntoView works
} else {
virtualFocus.moveFocusDown();
}
} else if (e.key === "ArrowUp") {
if (value !== "" && value !== searchTerm) {
onChange(value);
}
// Check that the FilteredOptions list is open and has virtual focus.
// Otherwise ignore keystrokes, so it doesn't interfere with text editing
if (isListOpen && activeDecendantId) {
e.preventDefault();
if (virtualFocus.isFocusOnTheTop()) {
toggleIsListOpen(false);
}
virtualFocus.moveFocusUp();
}
} else if (e.key === "Home") {
e.preventDefault();
virtualFocus.moveFocusToTop();
} else if (e.key === "End") {
e.preventDefault();
if (!isListOpen) {
toggleIsListOpen(true);
setTimeout(virtualFocus.moveFocusToBottom, 0); // Wait until list is visible so that scrollIntoView works
} else {
virtualFocus.moveFocusToBottom();
}
} else if (e.key === "PageUp") {
e.preventDefault();
virtualFocus.moveFocusUpBy(6);
} else if (e.key === "PageDown") {
e.preventDefault();
if (!isListOpen) {
toggleIsListOpen(true);
setTimeout(() => virtualFocus.moveFocusDownBy(6), 0); // Wait until list is visible so that scrollIntoView works
} else {
virtualFocus.moveFocusDownBy(6);
}
}
},
[
value,
selectedOptions,
removeSelectedOption,
isListOpen,
activeDecendantId,
setIsMouseLastUsedInputDevice,
clearInput,
toggleIsListOpen,
onChange,
virtualFocus,
setValue,
searchTerm,
shouldShowSelectedOptions,
readOnly,
],
);
const onChangeHandler = useCallback(
(event: React.InputEvent) => {
const newValue = event.currentTarget.value;
if (newValue && newValue !== "") {
toggleIsListOpen(true);
} else if (filteredOptions.length === 0) {
toggleIsListOpen(false);
}
onChange(newValue);
},
[filteredOptions.length, onChange, toggleIsListOpen],
);
return (
{
setHideCaret(maxSelected.isLimitReached);
value !== searchTerm && onChange(value);
}}
onInput={onChangeHandler}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
autoComplete="off"
placeholder={selectedOptions.length ? undefined : placeholder}
className={cl(
inputClassName,
"aksel-combobox__input",
"aksel-body-short",
`aksel-body-short--${size}`,
{ "aksel-combobox__input--hide-caret": hideCaret },
)}
aria-controls={filteredOptionsUtil.getFilteredOptionsId(inputProps.id)}
aria-expanded={!!isListOpen}
aria-autocomplete={shouldAutocomplete ? "both" : "list"}
aria-activedescendant={activeDecendantId}
aria-describedby={ariaDescribedBy}
aria-invalid={inputProps["aria-invalid"]}
readOnly={readOnly}
/>
);
},
);
export default Input;