Skip to content

Commit

Permalink
Merge branch 'main' into bugfix/stop-closing-modal-too-when-closing-c…
Browse files Browse the repository at this point in the history
…ombobox-with-escape

# Conflicts:
#	@navikt/core/react/src/form/combobox/Input/Input.tsx
#	@navikt/core/react/src/form/combobox/combobox.stories.tsx
  • Loading branch information
it-vegard committed Aug 2, 2024
2 parents ad7ee59 + 72c8f31 commit fb19ffb
Show file tree
Hide file tree
Showing 17 changed files with 370 additions and 138 deletions.
5 changes: 5 additions & 0 deletions .changeset/cyan-carrots-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@navikt/ds-react": patch
---

Prevent NullPointer when adding a new/custom option in Combobox single-select
5 changes: 5 additions & 0 deletions .changeset/hungry-spies-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@navikt/ds-react": minor
---

Breaking change: Combobox.onChange now receive only value as argument, instead of ChangeEvent
5 changes: 5 additions & 0 deletions .changeset/nice-ligers-breathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@navikt/ds-react": patch
---

Combobox: Entering an already selected option and pressing enter no longer removes it
5 changes: 5 additions & 0 deletions .changeset/plenty-ants-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@navikt/ds-react": patch
---

Combobox: Description is now connected to the input field via aria-describedby
6 changes: 6 additions & 0 deletions .changeset/wild-owls-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@navikt/ds-react": minor
"@navikt/ds-css": minor
---

Improved search in Combobox - find hits anywhere in the label
5 changes: 4 additions & 1 deletion @navikt/core/react/src/form/combobox/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import { ComboboxProps } from "./types";

export const Combobox = forwardRef<
HTMLInputElement,
Omit<ComboboxProps, "onChange" | "options" | "size" | "onClear" | "value">
Omit<
ComboboxProps,
"onChange" | "options" | "size" | "onClear" | "value" | "disabled"
>
>((props, ref) => {
const { className, hideLabel = false, description, label, ...rest } = props;

Expand Down
3 changes: 3 additions & 0 deletions @navikt/core/react/src/form/combobox/ComboboxProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const ComboboxProvider = forwardRef<HTMLInputElement, ComboboxProps>(
allowNewValues = false,
children,
defaultValue,
disabled,
error,
errorId,
filteredOptions: externalFilteredOptions,
Expand All @@ -60,6 +61,8 @@ const ComboboxProvider = forwardRef<HTMLInputElement, ComboboxProps>(
<InputContextProvider
value={{
defaultValue,
description: rest.description,
disabled,
error,
errorId,
id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@ const normalizeText = (text: string): string =>
typeof text === "string" ? text.toLocaleLowerCase().trim() : "";

const isPartOfText = (value: string, text: string) =>
normalizeText(text).startsWith(normalizeText(value ?? ""));
normalizeText(text).includes(normalizeText(value ?? ""));

const getMatchingValuesFromList = (value: string, list: ComboboxOption[]) =>
list.filter((listItem) => isPartOfText(value, listItem.label));

const getFirstValueStartingWith = (text: string, list: ComboboxOption[]) => {
const normalizedText = normalizeText(text);
return list.find((listItem) =>
normalizeText(listItem.label).startsWith(normalizedText),
);
};

const getFilteredOptionsId = (comboboxId: string) =>
`${comboboxId}-filtered-options`;

Expand Down Expand Up @@ -37,4 +44,5 @@ export default {
getIsLoadingId,
getNoHitsId,
getMaxSelectedOptionsId,
getFirstValueStartingWith,
};
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,19 @@ const FilteredOptionsProvider = ({
}, [allowNewValues, customOptions, id, options, value]);

useClientLayoutEffect(() => {
const autoCompleteCandidate =
filteredOptionsUtils.getFirstValueStartingWith(
searchTerm,
filteredOptions,
)?.label;
if (
shouldAutocomplete &&
filteredOptionsUtils.normalizeText(searchTerm) !== "" &&
(previousSearchTerm?.length || 0) < searchTerm.length &&
filteredOptions.length > 0
autoCompleteCandidate &&
(previousSearchTerm?.length || 0) < searchTerm.length
) {
setValue(
`${searchTerm}${filteredOptions[0].label.substring(searchTerm.length)}`,
`${searchTerm}${autoCompleteCandidate.substring(searchTerm.length)}`,
);
setSearchTerm(searchTerm);
}
}, [
filteredOptions,
Expand Down
52 changes: 27 additions & 25 deletions @navikt/core/react/src/form/combobox/Input/Input.context.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,17 @@
import React, {
ChangeEvent,
ChangeEventHandler,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { createContext } from "../../../util/create-context";
import { useClientLayoutEffect } from "../../../util/hooks";
import { FormFieldType, useFormField } from "../../useFormField";
import { ComboboxProps } from "../types";

interface InputContextValue extends FormFieldType {
clearInput: NonNullable<ComboboxProps["onClear"]>;
error?: string;
error?: ComboboxProps["error"];
focusInput: () => void;
inputRef: React.RefObject<HTMLInputElement>;
value: string;
setValue: (text: string) => void;
onChange: ChangeEventHandler<HTMLInputElement>;
onChange: (newValue: string) => void;
searchTerm: string;
setSearchTerm: React.Dispatch<React.SetStateAction<string>>;
shouldAutocomplete?: boolean;
Expand All @@ -31,7 +24,24 @@ const [InputContextProvider, useInputContext] =
errorMessage: "useInputContext must be used within an InputContextProvider",
});

const InputProvider = ({ children, value: props }) => {
interface Props {
children: React.ReactNode;
value: {
defaultValue: ComboboxProps["defaultValue"];
description: ComboboxProps["description"];
disabled: ComboboxProps["disabled"];
error: ComboboxProps["error"];
errorId: ComboboxProps["errorId"];
id: ComboboxProps["id"];
value: ComboboxProps["value"];
onChange: ComboboxProps["onChange"];
onClear: ComboboxProps["onClear"];
shouldAutocomplete: ComboboxProps["shouldAutocomplete"];
size: ComboboxProps["size"];
};
}

const InputProvider = ({ children, value: props }: Props) => {
const {
defaultValue = "",
description,
Expand Down Expand Up @@ -68,30 +78,22 @@ const InputProvider = ({ children, value: props }) => {
const [searchTerm, setSearchTerm] = useState(value);

const onChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const newValue = event.currentTarget.value;
(newValue: string) => {
externalValue ?? setInternalValue(newValue);
externalOnChange?.(event);
setSearchTerm(newValue);
externalOnChange?.(newValue);
},
[externalValue, externalOnChange],
);

const setValue = useCallback(
(text) => {
setInternalValue(text);
},
[setInternalValue],
);

const clearInput = useCallback(
(event: React.PointerEvent | React.KeyboardEvent | React.MouseEvent) => {
onClear?.(event);
externalOnChange?.(null, "");
setValue("");
externalOnChange?.("");
setInternalValue("");
setSearchTerm("");
},
[externalOnChange, onClear, setValue],
[externalOnChange, onClear, setInternalValue],
);

const focusInput = useCallback(() => {
Expand All @@ -111,7 +113,7 @@ const InputProvider = ({ children, value: props }) => {
focusInput,
inputRef,
value,
setValue,
setValue: setInternalValue,
onChange,
searchTerm,
setSearchTerm,
Expand Down
72 changes: 49 additions & 23 deletions @navikt/core/react/src/form/combobox/Input/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,37 @@
import cl from "clsx";
import React, {
ChangeEvent,
InputHTMLAttributes,
forwardRef,
useCallback,
useRef,
} from "react";
import { omit } from "../../../util";
import { useMergeRefs } from "../../../util/hooks";
import filteredOptionsUtil from "../FilteredOptions/filtered-options-util";
import { useFilteredOptionsContext } from "../FilteredOptions/filteredOptionsContext";
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
import { useInputContext } from "./Input.context";

interface InputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "value"> {
extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "disabled"> {
ref: React.Ref<HTMLInputElement>;
inputClassName?: string;
value?: string;
}

const Input = forwardRef<HTMLInputElement, InputProps>(
({ inputClassName, ...rest }, ref) => {
const { clearInput, inputProps, onChange, size, value } = useInputContext();
const internalRef = useRef<HTMLInputElement>(null);
const mergedRefs = useMergeRefs(ref, internalRef);
const {
clearInput,
inputProps,
onChange,
size,
value,
searchTerm,
setValue,
} = useInputContext();
const {
selectedOptions,
removeSelectedOption,
Expand Down Expand Up @@ -56,7 +67,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
if (!isMultiSelect && !isTextInSelectedOptions(currentOption.label)) {
toggleIsListOpen(false);
}
} else if (shouldAutocomplete && isTextInSelectedOptions(value)) {
} else if (isTextInSelectedOptions(value)) {
event.preventDefault();
// Trying to set the same value that is already set, so just clearing the input
clearInput(event);
Expand All @@ -67,13 +78,13 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
allowNewValues && isValueNew
? { label: value, value }
: filteredOptions[0];

if (!selectedValue) {
return;
}

toggleOption(selectedValue, event);
if (
!isMultiSelect &&
!isTextInSelectedOptions(
filteredOptions[0].label || selectedValue.label,
)
) {
if (!isMultiSelect && !isTextInSelectedOptions(selectedValue.label)) {
toggleIsListOpen(false);
}
}
Expand Down Expand Up @@ -114,7 +125,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
};

const handleKeyDown = useCallback(
(e) => {
(e: React.KeyboardEvent<HTMLInputElement>) => {
setIsMouseLastUsedInputDevice(false);
if (e.key === "Backspace") {
if (value === "") {
Expand All @@ -134,17 +145,28 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
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") {
// Check that cursor position is at the end of the input field,
// so we don't interfere with text editing
if (e.target.selectionStart === value?.length) {
e.preventDefault();
if (virtualFocus.activeElement === null || !isListOpen) {
toggleIsListOpen(true);
}
virtualFocus.moveFocusDown();
// 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 (virtualFocus.activeElement === null || !isListOpen) {
toggleIsListOpen(true);
}
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) {
Expand All @@ -165,20 +187,23 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
setIsMouseLastUsedInputDevice,
clearInput,
toggleIsListOpen,
onChange,
virtualFocus,
setValue,
searchTerm,
],
);

const onChangeHandler = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
(event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;
if (newValue && newValue !== "") {
toggleIsListOpen(true);
} else if (filteredOptions.length === 0) {
toggleIsListOpen(false);
}
virtualFocus.moveFocusToTop();
onChange(event);
onChange(newValue);
},
[filteredOptions.length, virtualFocus, onChange, toggleIsListOpen],
);
Expand All @@ -187,10 +212,11 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
<input
{...rest}
{...omit(inputProps, ["aria-invalid"])}
ref={ref}
ref={mergedRefs}
value={value}
onBlur={() => virtualFocus.moveFocusToTop()}
onChange={onChangeHandler}
onClick={() => value !== searchTerm && onChange(value)}
onInput={onChangeHandler}
type="text"
role="combobox"
onKeyUp={handleKeyUp}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const InputController = forwardRef<
| "size"
| "onClear"
| "value"
| "disabled"
>
>((props, ref) => {
const {
Expand Down
Loading

0 comments on commit fb19ffb

Please sign in to comment.