Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Combobox complex options #2716

Merged
merged 44 commits into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from 43 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
db3cd51
Remove unused function
it-vegard Feb 6, 2024
64fece3
Support for complex options, using objects with label and value inste…
it-vegard Feb 7, 2024
148deef
Better typing for isTextInSelectedOptions value (boolean instead of t…
it-vegard Feb 7, 2024
d3019ac
Stricter value type: Only accept string values. This will work better…
it-vegard Feb 7, 2024
8930055
Merge branch 'main' into combobox-complex-options
KenAJoh Feb 9, 2024
048c614
More JSDoc
it-vegard Feb 8, 2024
4bc9653
Add test for complex options with label and value
it-vegard Feb 9, 2024
74a8441
Add example for complex options on the aksel-website
it-vegard Feb 21, 2024
dcdb0b0
yarn.lock
it-vegard Feb 21, 2024
6ab5c5f
Remove other properties than label and value from Combobox options in…
it-vegard Feb 27, 2024
9e955b6
Merge branch 'main' into combobox-complex-options
it-vegard Feb 27, 2024
40257de
Array.includes does not work with objects, so should use isInList uti…
it-vegard Feb 27, 2024
8b70503
Add changeset
it-vegard Feb 27, 2024
709e116
Remove navds-dependencies in playroom that break playwright tests. Th…
it-vegard Feb 27, 2024
ee885df
Merge branch 'main' into combobox-complex-options
it-vegard Feb 27, 2024
36a67f0
Do not try to remove an undefined option
it-vegard Mar 1, 2024
0c9d90b
"it" is from jest. Use "test" instead.
it-vegard Mar 1, 2024
474381d
Update @navikt/core/react/src/form/combobox/FilteredOptions/filteredO…
it-vegard Mar 5, 2024
8cc8280
Update @navikt/core/react/src/form/combobox/FilteredOptions/filteredO…
it-vegard Mar 5, 2024
e2b6025
Update @navikt/core/react/src/form/combobox/Input/Input.tsx
it-vegard Mar 5, 2024
aa98292
Update @navikt/core/react/src/form/combobox/types.ts
it-vegard Mar 5, 2024
0623b9c
Raise change level to minor, to signal the new feature
it-vegard Mar 5, 2024
e9d2a04
Spread props instead of adding them separately
it-vegard Mar 5, 2024
44df849
JSDoc @returns doesn't do anything without a value specified, and nei…
it-vegard Mar 5, 2024
90d1c7a
Options are used for multiple features, so will just skip specifying …
it-vegard Mar 5, 2024
ebfe967
Doesn't need a guaranteed unique ID for storybook examples. Use hard-…
it-vegard Mar 8, 2024
1fd6bfd
selectedOptions is a list of strings anyhow, so should just use the v…
it-vegard Mar 8, 2024
1ce7fdd
More realistic example for complex options, where the option label is…
it-vegard Mar 8, 2024
57250f1
Add tests for mapToComboboxOptionArray (renamed from mapFromStringArr…
it-vegard Mar 8, 2024
c4f7b71
Add test for toComboboxOption and use this function in mapToComboboxO…
it-vegard Mar 8, 2024
9209d63
Update combobox unit test for complex options to use a more realistic…
it-vegard Mar 8, 2024
96a1c49
Don't say anything about what might be added in the future. That does…
it-vegard Mar 8, 2024
e765c2b
Oppdater beskrivelsen for complex options-eksempelet (var kopiert fra…
it-vegard Mar 8, 2024
c750def
Merge branch 'main' into combobox-complex-options
it-vegard Mar 8, 2024
24dde30
Fix Storybook example bug where de-selecting a value selected it an e…
it-vegard Mar 8, 2024
8b64348
Merge branch 'main' into combobox-complex-options
KenAJoh Mar 11, 2024
0dd06f8
Fix example
it-vegard Mar 11, 2024
ef0cac8
Remove listing options because it breaks with allowNewValues
it-vegard Mar 11, 2024
838a27b
Revert earlier change that always showed selected values in FilteredO…
it-vegard Mar 11, 2024
2958c55
Merge branch 'main' into combobox-complex-options
it-vegard Mar 11, 2024
5504f4b
Remove unneccessary optional check
it-vegard Mar 11, 2024
2706cd3
yarn.lock
it-vegard Mar 11, 2024
3ff0430
Don't need to use toLocaleLowerCase
it-vegard Mar 11, 2024
d24b149
Merge branch 'main' into combobox-complex-options
it-vegard Mar 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fuzzy-mangos-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@navikt/ds-react": minor
---

Allow Combobox options as objects to support separate display text and value
10 changes: 7 additions & 3 deletions @navikt/core/react/src/form/combobox/ComboboxProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -36,22 +37,25 @@ const ComboboxProvider = forwardRef<HTMLInputElement, ComboboxProps>(
defaultValue,
error,
errorId,
filteredOptions,
filteredOptions: externalFilteredOptions,
id,
isListOpen,
isLoading = false,
isMultiSelect,
onToggleSelected,
selectedOptions,
selectedOptions: externalSelectedOptions,
maxSelected,
options,
options: externalOptions,
value,
onChange,
onClear,
shouldAutocomplete,
size,
...rest
} = props;
const options = mapToComboboxOptionArray(externalOptions) || [];
const filteredOptions = mapToComboboxOptionArray(externalFilteredOptions);
const selectedOptions = mapToComboboxOptionArray(externalSelectedOptions);
return (
<InputContextProvider
value={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Loader } from "../../../loader";
import { BodyShort, Label } from "../../../typography";
import { useInputContext } from "../Input/inputContext";
import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
import { isInList, toComboboxOption } from "../combobox-utils";
import { ComboboxOption } from "../types";
import filteredOptionsUtil from "./filtered-options-util";
import { useFilteredOptionsContext } from "./filteredOptionsContext";

Expand All @@ -30,8 +32,8 @@ const FilteredOptions = () => {
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
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -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);
}
Expand All @@ -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}
>
<BodyShort size={size}>{option}</BodyShort>
{selectedOptions.includes(option) && <CheckmarkIcon />}
<BodyShort size={size}>{option.label}</BodyShort>
{isInList(option.value, selectedOptions) && <CheckmarkIcon />}
</li>
))}
</ul>
Expand Down
Original file line number Diff line number Diff line change
@@ -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`;
Expand All @@ -34,7 +30,6 @@ const getMaxSelectedOptionsId = (comboboxId: string) =>
export default {
normalizeText,
isPartOfText,
isValueInList,
getMatchingValuesFromList,
getFilteredOptionsId,
getAddNewOptionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComboboxProps, "allowNewValues" | "isListOpen" | "isLoading"> & {
filteredOptions?: ComboboxOption[];
options: ComboboxOption[];
};
};

type FilteredOptionsContextType = {
Expand All @@ -36,12 +33,12 @@ type FilteredOptionsContextType = {
>;
isListOpen: boolean;
isLoading?: boolean;
filteredOptions: string[];
filteredOptions: ComboboxOption[];
isMouseLastUsedInputDevice: boolean;
setIsMouseLastUsedInputDevice: React.Dispatch<SetStateAction<boolean>>;
isValueNew: boolean;
toggleIsListOpen: (newState?: boolean) => void;
currentOption?: string;
currentOption?: ComboboxOption;
shouldAutocomplete?: boolean;
virtualFocus: VirtualFocusType;
};
Expand Down Expand Up @@ -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();
Expand All @@ -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);

Expand All @@ -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,
},
),
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down
22 changes: 14 additions & 8 deletions @navikt/core/react/src/form/combobox/Input/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,17 @@ const Input = forwardRef<HTMLInputElement, InputProps>(

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)) {
Expand All @@ -64,11 +64,15 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
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);
}
Expand Down Expand Up @@ -120,7 +124,9 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -21,11 +22,13 @@ const Option = ({ option }: { option: string }) => {

if (!isMultiSelect) {
return (
<div className="navds-combobox__selected-options--no-bg">{option}</div>
<div className="navds-combobox__selected-options--no-bg">
{option.label}
</div>
);
}

return <Chips.Removable onClick={onClick}>{option}</Chips.Removable>;
return <Chips.Removable onClick={onClick}>{option.label}</Chips.Removable>;
};

const SelectedOptions: React.FC<SelectedOptionsProps> = ({
Expand All @@ -37,7 +40,7 @@ const SelectedOptions: React.FC<SelectedOptionsProps> = ({
<Chips className="navds-combobox__selected-options" size={size}>
{selectedOptions.length
? selectedOptions.map((option, i) => (
<Option key={option + i} option={option} />
<Option key={option.label + i} option={option} />
))
: []}
{children}
Expand Down
Loading
Loading