diff --git a/.changeset/fuzzy-mangos-flow.md b/.changeset/fuzzy-mangos-flow.md new file mode 100644 index 0000000000..c8109c2f6a --- /dev/null +++ b/.changeset/fuzzy-mangos-flow.md @@ -0,0 +1,5 @@ +--- +"@navikt/ds-react": minor +--- + +Allow Combobox options as objects to support separate display text and value diff --git a/@navikt/core/react/src/form/combobox/ComboboxProvider.tsx b/@navikt/core/react/src/form/combobox/ComboboxProvider.tsx index 486c570b29..dc92d742f6 100644 --- a/@navikt/core/react/src/form/combobox/ComboboxProvider.tsx +++ b/@navikt/core/react/src/form/combobox/ComboboxProvider.tsx @@ -3,6 +3,7 @@ import Combobox from "./Combobox"; import { FilteredOptionsProvider } from "./FilteredOptions/filteredOptionsContext"; import { InputContextProvider } from "./Input/inputContext"; import { SelectedOptionsProvider } from "./SelectedOptions/selectedOptionsContext"; +import { mapToComboboxOptionArray } from "./combobox-utils"; import { CustomOptionsProvider } from "./customOptionsContext"; import { ComboboxProps } from "./types"; @@ -36,15 +37,15 @@ const ComboboxProvider = forwardRef( defaultValue, error, errorId, - filteredOptions, + filteredOptions: externalFilteredOptions, id, isListOpen, isLoading = false, isMultiSelect, onToggleSelected, - selectedOptions, + selectedOptions: externalSelectedOptions, maxSelected, - options, + options: externalOptions, value, onChange, onClear, @@ -52,6 +53,9 @@ const ComboboxProvider = forwardRef( size, ...rest } = props; + const options = mapToComboboxOptionArray(externalOptions) || []; + const filteredOptions = mapToComboboxOptionArray(externalFilteredOptions); + const selectedOptions = mapToComboboxOptionArray(externalSelectedOptions); return ( { const { isMultiSelect, selectedOptions, toggleOption, maxSelected } = useSelectedOptionsContext(); - const isDisabled = (option) => - maxSelected?.isLimitReached && !selectedOptions.includes(option); + const isDisabled = (option: ComboboxOption) => + maxSelected?.isLimitReached && !isInList(option.value, selectedOptions); const shouldRenderNonSelectables = maxSelected?.isLimitReached || // Render maxSelected message @@ -102,8 +104,8 @@ const FilteredOptions = () => { } }} onPointerUp={(event) => { - toggleOption(value, event); - if (!isMultiSelect && !selectedOptions.includes(value)) + toggleOption(toComboboxOption(value), event); + if (!isMultiSelect && !isInList(value, selectedOptions)) toggleIsListOpen(false); }} id={filteredOptionsUtil.getAddNewOptionId(id)} @@ -132,21 +134,23 @@ const FilteredOptions = () => { className={cl("navds-combobox__list-item", { "navds-combobox__list-item--focus": activeDecendantId === - filteredOptionsUtil.getOptionId(id, option), - "navds-combobox__list-item--selected": - selectedOptions.includes(option), + filteredOptionsUtil.getOptionId(id, option.label), + "navds-combobox__list-item--selected": isInList( + option.value, + selectedOptions, + ), })} data-no-focus={isDisabled(option) || undefined} - id={filteredOptionsUtil.getOptionId(id, option)} - key={option} + id={filteredOptionsUtil.getOptionId(id, option.label)} + key={option.label} tabIndex={-1} onMouseMove={() => { if ( activeDecendantId !== - filteredOptionsUtil.getOptionId(id, option) + filteredOptionsUtil.getOptionId(id, option.label) ) { virtualFocus.moveFocusToElement( - filteredOptionsUtil.getOptionId(id, option), + filteredOptionsUtil.getOptionId(id, option.label), ); setIsMouseLastUsedInputDevice(true); } @@ -156,16 +160,19 @@ const FilteredOptions = () => { return; } toggleOption(option, event); - if (!isMultiSelect && !selectedOptions.includes(option)) { + if ( + !isMultiSelect && + !isInList(option.value, selectedOptions) + ) { toggleIsListOpen(false); } }} role="option" - aria-selected={selectedOptions.includes(option)} + aria-selected={isInList(option.value, selectedOptions)} aria-disabled={isDisabled(option) || undefined} > - {option} - {selectedOptions.includes(option) && } + {option.label} + {isInList(option.value, selectedOptions) && } ))} diff --git a/@navikt/core/react/src/form/combobox/FilteredOptions/filtered-options-util.ts b/@navikt/core/react/src/form/combobox/FilteredOptions/filtered-options-util.ts index af99b27497..f62a4366d9 100644 --- a/@navikt/core/react/src/form/combobox/FilteredOptions/filtered-options-util.ts +++ b/@navikt/core/react/src/form/combobox/FilteredOptions/filtered-options-util.ts @@ -1,17 +1,13 @@ +import { ComboboxOption } from "../types"; + const normalizeText = (text: string): string => typeof text === "string" ? text.toLocaleLowerCase().trim() : ""; -const isPartOfText = (value, text) => +const isPartOfText = (value: string, text: string) => normalizeText(text).startsWith(normalizeText(value ?? "")); -const isValueInList = (value, list) => - list?.find((listItem) => normalizeText(value) === normalizeText(listItem)); - -const getMatchingValuesFromList = (value, list, alwaysIncluded) => - list?.filter( - (listItem) => - isPartOfText(value, listItem) || alwaysIncluded.includes(listItem), - ); +const getMatchingValuesFromList = (value: string, list: ComboboxOption[]) => + list.filter((listItem) => isPartOfText(value, listItem.label)); const getFilteredOptionsId = (comboboxId: string) => `${comboboxId}-filtered-options`; @@ -34,7 +30,6 @@ const getMaxSelectedOptionsId = (comboboxId: string) => export default { normalizeText, isPartOfText, - isValueInList, getMatchingValuesFromList, getFilteredOptionsId, getAddNewOptionId, diff --git a/@navikt/core/react/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx b/@navikt/core/react/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx index e67d52020e..d4098eb349 100644 --- a/@navikt/core/react/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx +++ b/@navikt/core/react/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx @@ -10,21 +10,18 @@ import React, { import { useClientLayoutEffect, usePrevious } from "../../../util/hooks"; import { useInputContext } from "../Input/inputContext"; import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext"; +import { toComboboxOption } from "../combobox-utils"; import { useCustomOptionsContext } from "../customOptionsContext"; -import { ComboboxProps } from "../types"; +import { ComboboxOption, ComboboxProps } from "../types"; import filteredOptionsUtils from "./filtered-options-util"; import useVirtualFocus, { VirtualFocusType } from "./useVirtualFocus"; type FilteredOptionsProps = { - children: any; - value: Pick< - ComboboxProps, - | "allowNewValues" - | "filteredOptions" - | "isListOpen" - | "isLoading" - | "options" - >; + children: React.ReactNode; + value: Pick & { + filteredOptions?: ComboboxOption[]; + options: ComboboxOption[]; + }; }; type FilteredOptionsContextType = { @@ -36,12 +33,12 @@ type FilteredOptionsContextType = { >; isListOpen: boolean; isLoading?: boolean; - filteredOptions: string[]; + filteredOptions: ComboboxOption[]; isMouseLastUsedInputDevice: boolean; setIsMouseLastUsedInputDevice: React.Dispatch>; isValueNew: boolean; toggleIsListOpen: (newState?: boolean) => void; - currentOption?: string; + currentOption?: ComboboxOption; shouldAutocomplete?: boolean; virtualFocus: VirtualFocusType; }; @@ -71,7 +68,7 @@ export const FilteredOptionsProvider = ({ setSearchTerm, shouldAutocomplete, } = useInputContext(); - const { selectedOptions, maxSelected } = useSelectedOptionsContext(); + const { maxSelected } = useSelectedOptionsContext(); const [isInternalListOpen, setInternalListOpen] = useState(false); const { customOptions } = useCustomOptionsContext(); @@ -81,18 +78,8 @@ export const FilteredOptionsProvider = ({ return externalFilteredOptions; } const opts = [...customOptions, ...options]; - return filteredOptionsUtils.getMatchingValuesFromList( - searchTerm, - opts, - selectedOptions, - ); - }, [ - customOptions, - externalFilteredOptions, - options, - searchTerm, - selectedOptions, - ]); + return filteredOptionsUtils.getMatchingValuesFromList(searchTerm, opts); + }, [customOptions, externalFilteredOptions, options, searchTerm]); const previousSearchTerm = usePrevious(searchTerm); @@ -104,11 +91,11 @@ export const FilteredOptionsProvider = ({ options.reduce( (map, _option) => ({ ...map, - [filteredOptionsUtils.getOptionId(id, _option)]: _option, + [filteredOptionsUtils.getOptionId(id, _option.label)]: _option, }), { [filteredOptionsUtils.getAddNewOptionId(id)]: allowNewValues - ? value + ? toComboboxOption(value) : undefined, }, ), @@ -123,7 +110,7 @@ export const FilteredOptionsProvider = ({ filteredOptions.length > 0 ) { setValue( - `${searchTerm}${filteredOptions[0].substring(searchTerm.length)}`, + `${searchTerm}${filteredOptions[0].label.substring(searchTerm.length)}`, ); setSearchTerm(searchTerm); } @@ -161,7 +148,10 @@ export const FilteredOptionsProvider = ({ activeOption = filteredOptionsUtils.getNoHitsId(id); } else if ((value && value !== "") || isLoading) { if (shouldAutocomplete && filteredOptions[0]) { - activeOption = filteredOptionsUtils.getOptionId(id, filteredOptions[0]); + activeOption = filteredOptionsUtils.getOptionId( + id, + filteredOptions[0].label, + ); } else if (isListOpen && isLoading) { activeOption = filteredOptionsUtils.getIsLoadingId(id); } diff --git a/@navikt/core/react/src/form/combobox/Input/Input.tsx b/@navikt/core/react/src/form/combobox/Input/Input.tsx index a19c0b24b5..b85dcd6754 100644 --- a/@navikt/core/react/src/form/combobox/Input/Input.tsx +++ b/@navikt/core/react/src/form/combobox/Input/Input.tsx @@ -43,17 +43,17 @@ const Input = forwardRef( const onEnter = useCallback( (event: React.KeyboardEvent) => { - const isTextInSelectedOptions = (text: string) => { - return selectedOptions.find( - (item) => item.toLocaleLowerCase() === text.toLocaleLowerCase(), + const isTextInSelectedOptions = (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 && !isTextInSelectedOptions(currentOption)) { + if (!isMultiSelect && !isTextInSelectedOptions(currentOption.label)) { toggleIsListOpen(false); } } else if (shouldAutocomplete && isTextInSelectedOptions(value)) { @@ -64,11 +64,15 @@ const Input = forwardRef( event.preventDefault(); // Autocompleting or adding a new value const selectedValue = - allowNewValues && isValueNew ? value : filteredOptions[0]; + allowNewValues && isValueNew + ? { label: value, value } + : filteredOptions[0]; toggleOption(selectedValue, event); if ( !isMultiSelect && - !isTextInSelectedOptions(filteredOptions[0] || selectedValue) + !isTextInSelectedOptions( + filteredOptions[0].label || selectedValue.label, + ) ) { toggleIsListOpen(false); } @@ -120,7 +124,9 @@ const Input = forwardRef( if (value === "") { const lastSelectedOption = selectedOptions[selectedOptions.length - 1]; - removeSelectedOption(lastSelectedOption); + if (lastSelectedOption) { + removeSelectedOption(lastSelectedOption); + } } } else if (e.key === "ArrowDown") { // Check that cursor position is at the end of the input field, diff --git a/@navikt/core/react/src/form/combobox/SelectedOptions/SelectedOptions.tsx b/@navikt/core/react/src/form/combobox/SelectedOptions/SelectedOptions.tsx index ac79b1dcd7..7e3a7bcfcd 100644 --- a/@navikt/core/react/src/form/combobox/SelectedOptions/SelectedOptions.tsx +++ b/@navikt/core/react/src/form/combobox/SelectedOptions/SelectedOptions.tsx @@ -1,15 +1,16 @@ import React from "react"; import { Chips } from "../../../chips"; import { useInputContext } from "../Input/inputContext"; +import { ComboboxOption } from "../types"; import { useSelectedOptionsContext } from "./selectedOptionsContext"; interface SelectedOptionsProps { - selectedOptions?: string[]; + selectedOptions?: ComboboxOption[]; size?: "medium" | "small"; children: React.ReactNode; } -const Option = ({ option }: { option: string }) => { +const Option = ({ option }: { option: ComboboxOption }) => { const { isMultiSelect, removeSelectedOption } = useSelectedOptionsContext(); const { focusInput } = useInputContext(); @@ -21,11 +22,13 @@ const Option = ({ option }: { option: string }) => { if (!isMultiSelect) { return ( -
{option}
+
+ {option.label} +
); } - return {option}; + return {option.label}; }; const SelectedOptions: React.FC = ({ @@ -37,7 +40,7 @@ const SelectedOptions: React.FC = ({ {selectedOptions.length ? selectedOptions.map((option, i) => ( - )} toggleSelected(option)} @@ -154,7 +196,6 @@ export const MultiSelectWithExternalChips: StoryFn<{ } label="Komboboks" size="medium" - id={id} shouldShowSelectedOptions={false} /> @@ -166,19 +207,16 @@ MultiSelectWithExternalChips.args = { options, }; -export const Loading: StoryFunction = (props) => { - const id = useId(); - return ( - - ); -}; +export const Loading: StoryFunction = (props) => ( + +); Loading.args = { isLoading: true, @@ -186,11 +224,10 @@ Loading.args = { }; export const ComboboxWithNoHits: StoryFunction = (props) => { - const id = useId(); const [value, setValue] = useState(props.value); return ( = (props) => { - const id = useId(); const [value, setValue] = useState(props.value); const [selectedOptions, setSelectedOptions] = useState( props.initialSelectedOptions, @@ -238,7 +274,7 @@ export const Controlled: StoryFn<{
( ); export const MaxSelectedOptions: StoryFunction = () => { - const id = useId(); const [value, setValue] = useState(""); const [selectedOptions, setSelectedOptions] = useState([ options[0], @@ -301,7 +336,7 @@ export const MaxSelectedOptions: StoryFunction = () => { const comboboxRef = useRef(null); return ( { await screen.findByRole("option", { name: "banana" }), ).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(bananaOption); + }); + 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/customOptionsContext.tsx b/@navikt/core/react/src/form/combobox/customOptionsContext.tsx index 2910168c20..f7fdb60b07 100644 --- a/@navikt/core/react/src/form/combobox/customOptionsContext.tsx +++ b/@navikt/core/react/src/form/combobox/customOptionsContext.tsx @@ -1,11 +1,12 @@ import React, { createContext, useCallback, useContext, useState } from "react"; import { useInputContext } from "./Input/inputContext"; +import { ComboboxOption } from "./types"; type CustomOptionsContextType = { - customOptions: string[]; - removeCustomOption: (option: string) => void; - addCustomOption: (option: string) => void; - setCustomOptions: React.Dispatch>; + customOptions: ComboboxOption[]; + removeCustomOption: (option: ComboboxOption) => void; + addCustomOption: (option: ComboboxOption) => void; + setCustomOptions: React.Dispatch>; }; const CustomOptionsContext = createContext( @@ -19,14 +20,14 @@ export const CustomOptionsProvider = ({ children: any; value: { isMultiSelect?: boolean }; }) => { - const [customOptions, setCustomOptions] = useState([]); + const [customOptions, setCustomOptions] = useState([]); const { focusInput } = useInputContext(); const { isMultiSelect } = value; const removeCustomOption = useCallback( - (option: string) => { + (option: ComboboxOption) => { setCustomOptions((prevCustomOptions) => - prevCustomOptions.filter((o) => o !== option), + prevCustomOptions.filter((o) => o.label !== option.label), ); focusInput(); }, @@ -34,7 +35,7 @@ export const CustomOptionsProvider = ({ ); const addCustomOption = useCallback( - (option: string) => { + (option: ComboboxOption) => { if (isMultiSelect) { setCustomOptions((prevOptions) => [...prevOptions, option]); } else { diff --git a/@navikt/core/react/src/form/combobox/types.ts b/@navikt/core/react/src/form/combobox/types.ts index acd1887a3d..184fe336e7 100644 --- a/@navikt/core/react/src/form/combobox/types.ts +++ b/@navikt/core/react/src/form/combobox/types.ts @@ -1,6 +1,21 @@ import React, { ChangeEvent, InputHTMLAttributes } from "react"; import { FormFieldProps } from "../useFormField"; +/** + * A more complex version of options for the Combobox. + * Used for separating the label and the value of the option. + */ +export type ComboboxOption = { + /** + * The label to display in the dropdown list + */ + label: string; + /** + * The programmatic value of the option, for use internally. Will be returned from onToggleSelected. + */ + value: string; +}; + export type MaxSelected = { /** * The limit for maximum selected options @@ -20,9 +35,9 @@ export interface ComboboxProps */ label: React.ReactNode; /** - * List of options to use for autocompletion. + * List of options */ - options: string[]; + options: string[] | ComboboxOption[]; /** * If enabled, adds an option to add the value of the input as an option whenever there are no options matching the value. */ @@ -42,7 +57,7 @@ export interface ComboboxProps * If provided, this overrides the internal search logic in the component. * Useful for e.g. searching on a server or when overriding the search algorithm to search for synonyms or similar. */ - filteredOptions?: string[]; + filteredOptions?: string[] | ComboboxOption[]; /** * Optionally hide the label visually. * Not recommended, but can be considered for e.g. search fields in the top menu. @@ -75,7 +90,6 @@ export interface ComboboxProps * Callback function triggered whenever the value of the input field is triggered. * * @param event - * @returns */ onChange?: ( event: ChangeEvent | null, @@ -85,19 +99,17 @@ export interface ComboboxProps * Callback function triggered whenever the input field is cleared. * * @param event - * @returns */ onClear?: (event: React.PointerEvent | React.KeyboardEvent) => void; /** * Callback function triggered whenever an option is selected or de-selected. * - * @param option The selected option. - * @param isSelected Whether the option has been selected or unselected. - * @param isCustomOption Whether the option comes from user input, instead of from the list. - * @returns + * @param option The option value + * @param isSelected Whether the option has been selected or unselected + * @param isCustomOption Whether the option comes from user input, instead of from the list */ onToggleSelected?: ( - option: string, + option: ComboboxOption["value"], isSelected: boolean, isCustomOption: boolean, ) => void; @@ -107,7 +119,7 @@ export interface ComboboxProps * Use this prop when controlling the selected state outside for the component, * e.g. for a filter, where options can be toggled elsewhere/programmatically. */ - selectedOptions?: string[]; + selectedOptions?: string[] | ComboboxOption[]; /** * Options for the maximum number of selected options. */ diff --git a/aksel.nav.no/website/pages/eksempler/combobox/with-complex-options.tsx b/aksel.nav.no/website/pages/eksempler/combobox/with-complex-options.tsx new file mode 100644 index 0000000000..586a7c5f82 --- /dev/null +++ b/aksel.nav.no/website/pages/eksempler/combobox/with-complex-options.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import { UNSAFE_Combobox } from "@navikt/ds-react"; +import { withDsExample } from "@/web/examples/withDsExample"; + +const Example = () => { + const [selectedOptions, setSelectedOptions] = useState([ + options[0].value, + options[1].value, + ]); + return ( +
+ ({ + label: `${o.label} [${o.value}]`, + value: o.value, + }))} + isMultiSelect + maxSelected={{ limit: 3 }} + selectedOptions={selectedOptions} + onToggleSelected={(option, isSelected) => + isSelected + ? setSelectedOptions([...selectedOptions, option]) + : setSelectedOptions(selectedOptions.filter((o) => o !== option)) + } + /> +
+ ); +}; + +const options = [ + { label: "Hjelpemidler", value: "HJE" }, + { label: "Oppfølging", value: "OPP" }, + { label: "Sykepenger", value: "SYK" }, + { label: "Sykemelding", value: "SYM" }, + { label: "Foreldre- og svangerskapspenger", value: "FOR" }, + { label: "Arbeidsavklaringspenger", value: "AAP" }, + { label: "Uføretrygd", value: "UFO" }, + { label: "Pensjon", value: "PEN" }, + { label: "Barnetrygd", value: "BAR" }, + { label: "Kontantstøtte", value: "KON" }, + { label: "Bostøtte", value: "BOS" }, + { label: "Barnebidrag", value: "BBI" }, + { label: "Bidragsforskudd", value: "BIF" }, + { label: "Grunn- og hjelpestønad", value: "GRU" }, +]; + +// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE +export default withDsExample(Example, { variant: "static" }); + +/* Storybook story */ +export const Demo = { + render: Example, +}; + +export const args = { + index: 1, + desc: "Ved å sende inn options som objekter er det mulig å vise en brukervennlig tekst til brukeren, samtidig som systemet i bakkant kan forholde seg til en ID.", +};