diff --git a/.changeset/cyan-carrots-return.md b/.changeset/cyan-carrots-return.md new file mode 100644 index 0000000000..ad6e48f951 --- /dev/null +++ b/.changeset/cyan-carrots-return.md @@ -0,0 +1,5 @@ +--- +"@navikt/ds-react": patch +--- + +Prevent NullPointer when adding a new/custom option in Combobox single-select diff --git a/.changeset/hungry-spies-allow.md b/.changeset/hungry-spies-allow.md new file mode 100644 index 0000000000..9031b80c3f --- /dev/null +++ b/.changeset/hungry-spies-allow.md @@ -0,0 +1,5 @@ +--- +"@navikt/ds-react": minor +--- + +Breaking change: Combobox.onChange now receive only value as argument, instead of ChangeEvent diff --git a/.changeset/nice-ligers-breathe.md b/.changeset/nice-ligers-breathe.md new file mode 100644 index 0000000000..fb4ca849e3 --- /dev/null +++ b/.changeset/nice-ligers-breathe.md @@ -0,0 +1,5 @@ +--- +"@navikt/ds-react": patch +--- + +Combobox: Entering an already selected option and pressing enter no longer removes it diff --git a/.changeset/plenty-ants-tickle.md b/.changeset/plenty-ants-tickle.md new file mode 100644 index 0000000000..9a3d668a1b --- /dev/null +++ b/.changeset/plenty-ants-tickle.md @@ -0,0 +1,5 @@ +--- +"@navikt/ds-react": patch +--- + +Combobox: Description is now connected to the input field via aria-describedby diff --git a/.changeset/wild-owls-tan.md b/.changeset/wild-owls-tan.md new file mode 100644 index 0000000000..d6a6271f34 --- /dev/null +++ b/.changeset/wild-owls-tan.md @@ -0,0 +1,6 @@ +--- +"@navikt/ds-react": minor +"@navikt/ds-css": minor +--- + +Improved search in Combobox - find hits anywhere in the label diff --git a/@navikt/core/react/src/form/combobox/Combobox.tsx b/@navikt/core/react/src/form/combobox/Combobox.tsx index 185f0b45d7..5923f23d00 100644 --- a/@navikt/core/react/src/form/combobox/Combobox.tsx +++ b/@navikt/core/react/src/form/combobox/Combobox.tsx @@ -10,7 +10,10 @@ import { ComboboxProps } from "./types"; export const Combobox = forwardRef< HTMLInputElement, - Omit + Omit< + ComboboxProps, + "onChange" | "options" | "size" | "onClear" | "value" | "disabled" + > >((props, ref) => { const { className, hideLabel = false, description, label, ...rest } = props; diff --git a/@navikt/core/react/src/form/combobox/ComboboxProvider.tsx b/@navikt/core/react/src/form/combobox/ComboboxProvider.tsx index 7301f30c39..974c6e498d 100644 --- a/@navikt/core/react/src/form/combobox/ComboboxProvider.tsx +++ b/@navikt/core/react/src/form/combobox/ComboboxProvider.tsx @@ -35,6 +35,7 @@ const ComboboxProvider = forwardRef( allowNewValues = false, children, defaultValue, + disabled, error, errorId, filteredOptions: externalFilteredOptions, @@ -60,6 +61,8 @@ const ComboboxProvider = forwardRef( 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`; @@ -37,4 +44,5 @@ export default { getIsLoadingId, getNoHitsId, getMaxSelectedOptionsId, + getFirstValueStartingWith, }; diff --git a/@navikt/core/react/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx b/@navikt/core/react/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx index e232088c78..f6dc789c80 100644 --- a/@navikt/core/react/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx +++ b/@navikt/core/react/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx @@ -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, diff --git a/@navikt/core/react/src/form/combobox/Input/Input.context.tsx b/@navikt/core/react/src/form/combobox/Input/Input.context.tsx index 54f8eda924..29a9f283f8 100644 --- a/@navikt/core/react/src/form/combobox/Input/Input.context.tsx +++ b/@navikt/core/react/src/form/combobox/Input/Input.context.tsx @@ -1,11 +1,4 @@ -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"; @@ -13,12 +6,12 @@ import { ComboboxProps } from "../types"; interface InputContextValue extends FormFieldType { clearInput: NonNullable; - error?: string; + error?: ComboboxProps["error"]; focusInput: () => void; inputRef: React.RefObject; value: string; setValue: (text: string) => void; - onChange: ChangeEventHandler; + onChange: (newValue: string) => void; searchTerm: string; setSearchTerm: React.Dispatch>; shouldAutocomplete?: boolean; @@ -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, @@ -68,30 +78,22 @@ const InputProvider = ({ children, value: props }) => { const [searchTerm, setSearchTerm] = useState(value); const onChange = useCallback( - (event: ChangeEvent) => { - 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(() => { @@ -111,7 +113,7 @@ const InputProvider = ({ children, value: props }) => { focusInput, inputRef, value, - setValue, + setValue: setInternalValue, onChange, searchTerm, setSearchTerm, diff --git a/@navikt/core/react/src/form/combobox/Input/Input.tsx b/@navikt/core/react/src/form/combobox/Input/Input.tsx index eeba178fe6..8d4f10908e 100644 --- a/@navikt/core/react/src/form/combobox/Input/Input.tsx +++ b/@navikt/core/react/src/form/combobox/Input/Input.tsx @@ -1,18 +1,19 @@ 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, "value"> { + extends Omit, "value" | "disabled"> { ref: React.Ref; inputClassName?: string; value?: string; @@ -20,7 +21,17 @@ interface InputProps const Input = forwardRef( ({ inputClassName, ...rest }, ref) => { - const { clearInput, inputProps, onChange, size, value } = useInputContext(); + const internalRef = useRef(null); + const mergedRefs = useMergeRefs(ref, internalRef); + const { + clearInput, + inputProps, + onChange, + size, + value, + searchTerm, + setValue, + } = useInputContext(); const { selectedOptions, removeSelectedOption, @@ -56,7 +67,7 @@ const Input = forwardRef( 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); @@ -67,13 +78,13 @@ const Input = forwardRef( 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); } } @@ -114,7 +125,7 @@ const Input = forwardRef( }; const handleKeyDown = useCallback( - (e) => { + (e: React.KeyboardEvent) => { setIsMouseLastUsedInputDevice(false); if (e.key === "Backspace") { if (value === "") { @@ -134,17 +145,28 @@ const Input = forwardRef( 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) { @@ -165,12 +187,15 @@ const Input = forwardRef( setIsMouseLastUsedInputDevice, clearInput, toggleIsListOpen, + onChange, virtualFocus, + setValue, + searchTerm, ], ); const onChangeHandler = useCallback( - (event: ChangeEvent) => { + (event: React.ChangeEvent) => { const newValue = event.target.value; if (newValue && newValue !== "") { toggleIsListOpen(true); @@ -178,7 +203,7 @@ const Input = forwardRef( toggleIsListOpen(false); } virtualFocus.moveFocusToTop(); - onChange(event); + onChange(newValue); }, [filteredOptions.length, virtualFocus, onChange, toggleIsListOpen], ); @@ -187,10 +212,11 @@ const Input = forwardRef( virtualFocus.moveFocusToTop()} - onChange={onChangeHandler} + onClick={() => value !== searchTerm && onChange(value)} + onInput={onChangeHandler} type="text" role="combobox" onKeyUp={handleKeyUp} diff --git a/@navikt/core/react/src/form/combobox/Input/InputController.tsx b/@navikt/core/react/src/form/combobox/Input/InputController.tsx index 07db7efea0..aed9d8a985 100644 --- a/@navikt/core/react/src/form/combobox/Input/InputController.tsx +++ b/@navikt/core/react/src/form/combobox/Input/InputController.tsx @@ -24,6 +24,7 @@ export const InputController = forwardRef< | "size" | "onClear" | "value" + | "disabled" > >((props, ref) => { const { diff --git a/@navikt/core/react/src/form/combobox/__tests__/combobox.test.tsx b/@navikt/core/react/src/form/combobox/__tests__/combobox.test.tsx index cab8de6aac..efce009c24 100644 --- a/@navikt/core/react/src/form/combobox/__tests__/combobox.test.tsx +++ b/@navikt/core/react/src/form/combobox/__tests__/combobox.test.tsx @@ -8,6 +8,7 @@ import { UNSAFE_Combobox } from "../index"; const options = [ "banana", "apple", + "apple pie", "tangerine", "pear", "grape", @@ -71,86 +72,193 @@ describe("Render combobox", () => { }); }); - test("Should show loading icon when loading (used for async search)", async () => { - render(); + describe("Combobox state-handling", () => { + test("Should show loading icon when loading (used for async search)", async () => { + render(); - expect(await screen.findByText("Søker...")).toBeInTheDocument(); - }); -}); + expect(await screen.findByText("Søker...")).toBeInTheDocument(); + }); -describe("Combobox state-handling", () => { - test("Should not select previous focused element when closes", async () => { - render(); + test("Should not select previous focused element when closes", async () => { + render(); - await act(async () => { - await userEvent.click( - screen.getByRole("combobox", { name: "Hva er dine favorittfrukter?" }), - ); - }); - await act(async () => { - await userEvent.type( - screen.getByRole("combobox", { name: "Hva er dine favorittfrukter?" }), - "ban", - ); - await userEvent.keyboard("{ArrowDown}"); - await userEvent.keyboard("{ArrowUp}"); - await userEvent.keyboard("{Enter}"); + await act(async () => { + await userEvent.click( + screen.getByRole("combobox", { + name: "Hva er dine favorittfrukter?", + }), + ); + }); + await act(async () => { + await userEvent.type( + screen.getByRole("combobox", { + name: "Hva er dine favorittfrukter?", + }), + "ban", + ); + await userEvent.keyboard("{ArrowDown}"); + await userEvent.keyboard("{ArrowUp}"); + await userEvent.keyboard("{Enter}"); + }); + + expect(screen.queryByRole("button", { name: "banana slett" })).toBeNull(); }); - expect(screen.queryByRole("button", { name: "banana slett" })).toBeNull(); - }); + test("Should reset list when resetting input (ESC)", async () => { + render(); - test("Should reset list when resetting input (ESC)", async () => { - render(); + await act(async () => { + await userEvent.click( + screen.getByRole("combobox", { + name: "Hva er dine favorittfrukter?", + }), + ); + }); + await act(async () => { + await userEvent.type( + screen.getByRole("combobox", { + name: "Hva er dine favorittfrukter?", + }), + "apple", + ); + await userEvent.keyboard("{ArrowDown}"); + await userEvent.keyboard("{Escape}"); + await userEvent.keyboard("{ArrowDown}"); + }); + + expect( + await screen.findByRole("option", { name: "banana" }), + ).toBeInTheDocument(); + }); - await act(async () => { - await userEvent.click( - screen.getByRole("combobox", { name: "Hva er dine favorittfrukter?" }), + test("Should handle complex options with label and value", async () => { + const onToggleSelected = vi.fn(); + render( + , ); + + expect(screen.getByRole("combobox")).toBeInTheDocument(); + const bananaOption = screen.getByRole("option", { + name: "Hjelpemidler [HJE]", + selected: false, + }); + await act(async () => { + await userEvent.click(bananaOption); + }); + expect(onToggleSelected).toHaveBeenCalledWith("HJE", true, false); + expect( + screen.getByRole("option", { + name: "Hjelpemidler [HJE]", + selected: true, + }), + ).toBeInTheDocument(); }); - await act(async () => { - await userEvent.type( - screen.getByRole("combobox", { name: "Hva er dine favorittfrukter?" }), - "apple", + + test("should trigger onChange for every character typed or removed", async () => { + const onChange = vi.fn(); + const onToggleSelected = vi.fn(); + render( + , ); - await userEvent.keyboard("{ArrowDown}"); - await userEvent.keyboard("{Escape}"); - await userEvent.keyboard("{ArrowDown}"); + const combobox = screen.getByRole("combobox"); + expect(combobox).toBeInTheDocument(); + + await act(async () => { + await userEvent.click(combobox); + await userEvent.type(combobox, "Lemon"); + }); + expect(onChange).toHaveBeenNthCalledWith(1, "L"); + expect(onChange).toHaveBeenNthCalledWith(2, "Le"); + expect(onChange).toHaveBeenNthCalledWith(3, "Lem"); + expect(onChange).toHaveBeenNthCalledWith(4, "Lemo"); + expect(onChange).toHaveBeenNthCalledWith(5, "Lemon"); }); - expect( - await screen.findByRole("option", { name: "banana" }), - ).toBeInTheDocument(); - }); + test("should trigger onChange while typing and on accepting autocomplete suggestions", async () => { + const onChange = vi.fn(); + const onToggleSelected = vi.fn(); + render( + , + ); + const combobox = screen.getByRole("combobox"); + expect(combobox).toBeInTheDocument(); - test("Should handle complex options with label and value", async () => { - const onToggleSelected = vi.fn(); - render( - , - ); - - expect(screen.getByRole("combobox")).toBeInTheDocument(); - const bananaOption = screen.getByRole("option", { - name: "Hjelpemidler [HJE]", - selected: false, + await act(async () => { + await userEvent.click(combobox); + await userEvent.type(combobox, "Syke"); + await userEvent.keyboard("{ArrowRight}"); + await userEvent.keyboard("{ArrowDown}"); + await userEvent.keyboard("{Enter}"); + }); + expect(onChange).toHaveBeenNthCalledWith(1, "S"); + expect(onChange).toHaveBeenNthCalledWith(2, "Sy"); + expect(onChange).toHaveBeenNthCalledWith(3, "Syk"); + expect(onChange).toHaveBeenNthCalledWith(4, "Syke"); + expect(onChange).toHaveBeenNthCalledWith(5, "Sykepenger [SYK]"); + expect(onChange).toHaveBeenCalledWith(""); + expect(onToggleSelected).toHaveBeenCalledOnce(); + expect(onToggleSelected).toHaveBeenCalledWith( + "Sykepenger [SYK]", + true, + false, + ); }); - await act(async () => { - await userEvent.click(bananaOption); + }); + + describe("search", () => { + test("should find matched anywhere in the label", async () => { + render(); + + const combobox = screen.getByRole("combobox", { + name: "Hva er dine favorittfrukter?", + }); + + await act(async () => { + await userEvent.click(combobox); + + await userEvent.type(combobox, "p"); + }); + + const searchHits = [ + "apple", + "apple pie", + "pear", + "grape", + "passion fruit", + "pineapple", + "grape fruit", + ]; + searchHits.forEach((label) => { + expect(screen.getByRole("option", { name: label })).toBeInTheDocument(); + }); + screen.getAllByRole("option").forEach((option) => { + expect( + option.textContent && searchHits.includes(option.textContent), + ).toBe(true); + }); }); - expect(onToggleSelected).toHaveBeenCalledWith("HJE", true, false); - expect( - screen.getByRole("option", { - name: "Hjelpemidler [HJE]", - selected: true, - }), - ).toBeInTheDocument(); }); }); diff --git a/@navikt/core/react/src/form/combobox/combobox.stories.tsx b/@navikt/core/react/src/form/combobox/combobox.stories.tsx index 399dac242a..995ad8e441 100644 --- a/@navikt/core/react/src/form/combobox/combobox.stories.tsx +++ b/@navikt/core/react/src/form/combobox/combobox.stories.tsx @@ -45,8 +45,15 @@ Default.args = { isLoading: false, isMultiSelect: false, allowNewValues: false, + onChange: console.log, }; Default.argTypes = { + description: { + control: { type: "text" }, + }, + disabled: { + control: { type: "boolean" }, + }, isListOpen: { control: { type: "boolean" }, }, @@ -136,7 +143,7 @@ export const WithAddNewOptions: StoryFn = ({ open }: { open?: boolean }) => { allowNewValues={true} shouldAutocomplete={true} value={value} - onChange={(event) => setValue(event?.currentTarget.value)} + onChange={(newValue) => setValue(newValue)} isListOpen={open ?? (comboboxRef.current ? true : undefined)} ref={comboboxRef} /> @@ -160,7 +167,7 @@ export const MultiSelectWithAddNewOptions: StoryFn = ({ allowNewValues={true} value={value} selectedOptions={selectedOptions} - onChange={(event) => setValue(event?.currentTarget.value)} + onChange={(newValue) => setValue(newValue)} onToggleSelected={(option, isSelected) => isSelected ? setSelectedOptions([...selectedOptions, option]) @@ -205,7 +212,7 @@ export const MultiSelectWithExternalChips: StoryFn = () => { onToggleSelected={(option) => toggleSelected(option)} isMultiSelect value={value} - onChange={(event) => setValue(event?.currentTarget.value || "")} + onChange={(newValue) => setValue(newValue || "")} label="Komboboks" size="medium" shouldShowSelectedOptions={false} @@ -233,7 +240,7 @@ export const ComboboxWithNoHits: StoryFn = () => { label="Komboboks (uten søketreff)" options={options} value={value} - onChange={(event) => setValue(event?.currentTarget.value)} + onChange={(newValue) => setValue(newValue)} isListOpen={true} /> ); @@ -272,7 +279,7 @@ export const Controlled: StoryFn = () => { filteredOptions={filteredOptions} isMultiSelect options={options} - onChange={(event) => setValue(event?.target.value || "")} + onChange={(newValue) => setValue(newValue || "")} onToggleSelected={onToggleSelected} selectedOptions={selectedOptions} value={value} @@ -379,7 +386,7 @@ export const MaxSelectedOptions: StoryFn = ({ open }: { open?: boolean }) => { allowNewValues isListOpen={open ?? (comboboxRef.current ? undefined : true)} value={value} - onChange={(event) => setValue(event?.target.value)} + onChange={(newValue) => setValue(newValue)} ref={comboboxRef} /> ); @@ -436,6 +443,16 @@ export const InModal: StoryFn = () => { ); }; +export const Disabled: StoryFn = () => { + return ( + + ); +}; + export const Chromatic: StoryFn = () => { const H2 = (props: { children: string; style?: React.CSSProperties }) => (

@@ -466,6 +483,8 @@ export const Chromatic: StoryFn = () => {

WithError

+

Disabled

+ ); }; diff --git a/@navikt/core/react/src/form/combobox/combobox.tests.stories.tsx b/@navikt/core/react/src/form/combobox/combobox.tests.stories.tsx index 83d4d2472d..3b9f192764 100644 --- a/@navikt/core/react/src/form/combobox/combobox.tests.stories.tsx +++ b/@navikt/core/react/src/form/combobox/combobox.tests.stories.tsx @@ -116,10 +116,11 @@ export const RemoveSelectedMultiSelect: StoryObject = { }, }; -export const AllowNewValues: StoryObject = { +export const AllowNewValuesMultiSelect: StoryObject = { render: () => { return ( { + return ( + + ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const input = canvas.getByLabelText("Hva er dine favorittfrukter?"); + + userEvent.click(input); + await userEvent.type(input, "aaa", { delay: 200 }); + await sleep(250); + expect( + canvas.getByRole("option", { name: "Legg til “aaa”" }), + ).toBeVisible(); + userEvent.keyboard("{ArrowDown}"); await sleep(250); userEvent.keyboard("{Enter}"); await sleep(250); - userEvent.keyboard("{Escape}"); - await sleep(250); const invalidSelect = canvas.queryByLabelText("aaa slett"); expect(invalidSelect).not.toBeInTheDocument(); diff --git a/@navikt/core/react/src/form/combobox/types.ts b/@navikt/core/react/src/form/combobox/types.ts index feecb2fd1b..2cd14756e6 100644 --- a/@navikt/core/react/src/form/combobox/types.ts +++ b/@navikt/core/react/src/form/combobox/types.ts @@ -1,4 +1,4 @@ -import React, { ChangeEvent, InputHTMLAttributes } from "react"; +import React, { InputHTMLAttributes } from "react"; import { FormFieldProps } from "../useFormField"; /** @@ -29,7 +29,10 @@ export type MaxSelected = { export interface ComboboxProps extends FormFieldProps, - Omit, "size" | "onChange" | "value"> { + Omit< + InputHTMLAttributes, + "size" | "onChange" | "value" | "defaultValue" + > { /** * Combobox label. */ @@ -89,12 +92,9 @@ export interface ComboboxProps /** * Callback function triggered whenever the value of the input field is triggered. * - * @param event + * @param value The value after change */ - onChange?: ( - event: ChangeEvent | null, - value?: string, - ) => void; + onChange?: (value: string) => void; /** * Callback function triggered whenever the input field is cleared. * @@ -156,4 +156,8 @@ export interface ComboboxProps * This converts the input to a controlled input, so you have to use onChange to update the value. */ value?: string; + /** + * Initial value of the input field. Only works when the input is uncontrolled. + */ + defaultValue?: string; } diff --git a/aksel.nav.no/website/pages/eksempler/combobox/multi-select-controlled.tsx b/aksel.nav.no/website/pages/eksempler/combobox/multi-select-controlled.tsx index ba07845a92..b472d85caa 100644 --- a/aksel.nav.no/website/pages/eksempler/combobox/multi-select-controlled.tsx +++ b/aksel.nav.no/website/pages/eksempler/combobox/multi-select-controlled.tsx @@ -38,7 +38,7 @@ const Example = () => { label="Hvilke land har du besøkt de siste 6 ukene? Velg opptil flere." filteredOptions={filteredOptions} isMultiSelect - onChange={(event) => setValue(event?.target.value || "")} + onChange={setValue} onToggleSelected={onToggleSelected} selectedOptions={selectedOptions} options={initialOptions}