@navikt/ds-react 5.15.1 → 5.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/_docs.json +44 -1
  2. package/cjs/form/combobox/Combobox.js +1 -1
  3. package/cjs/form/combobox/ComboboxProvider.js +2 -1
  4. package/cjs/form/combobox/ComboboxWrapper.js +1 -1
  5. package/cjs/form/combobox/FilteredOptions/FilteredOptions.js +59 -41
  6. package/cjs/form/combobox/FilteredOptions/filtered-options-util.js +3 -1
  7. package/cjs/form/combobox/FilteredOptions/filteredOptionsContext.js +15 -3
  8. package/cjs/form/combobox/FilteredOptions/useVirtualFocus.js +52 -32
  9. package/cjs/form/combobox/Input/Input.js +3 -1
  10. package/cjs/form/combobox/SelectedOptions/selectedOptionsContext.js +3 -1
  11. package/cjs/util/hooks/descendants/descendant.js +117 -0
  12. package/cjs/util/hooks/descendants/useDescendant.js +108 -0
  13. package/cjs/util/hooks/descendants/utils.js +53 -0
  14. package/esm/form/combobox/Combobox.js +1 -1
  15. package/esm/form/combobox/Combobox.js.map +1 -1
  16. package/esm/form/combobox/ComboboxProvider.js +2 -1
  17. package/esm/form/combobox/ComboboxProvider.js.map +1 -1
  18. package/esm/form/combobox/ComboboxWrapper.js +1 -1
  19. package/esm/form/combobox/ComboboxWrapper.js.map +1 -1
  20. package/esm/form/combobox/FilteredOptions/FilteredOptions.js +59 -41
  21. package/esm/form/combobox/FilteredOptions/FilteredOptions.js.map +1 -1
  22. package/esm/form/combobox/FilteredOptions/filtered-options-util.d.ts +2 -1
  23. package/esm/form/combobox/FilteredOptions/filtered-options-util.js +3 -1
  24. package/esm/form/combobox/FilteredOptions/filtered-options-util.js.map +1 -1
  25. package/esm/form/combobox/FilteredOptions/filteredOptionsContext.js +15 -3
  26. package/esm/form/combobox/FilteredOptions/filteredOptionsContext.js.map +1 -1
  27. package/esm/form/combobox/FilteredOptions/useVirtualFocus.d.ts +2 -4
  28. package/esm/form/combobox/FilteredOptions/useVirtualFocus.js +52 -32
  29. package/esm/form/combobox/FilteredOptions/useVirtualFocus.js.map +1 -1
  30. package/esm/form/combobox/Input/Input.js +3 -1
  31. package/esm/form/combobox/Input/Input.js.map +1 -1
  32. package/esm/form/combobox/SelectedOptions/selectedOptionsContext.d.ts +5 -2
  33. package/esm/form/combobox/SelectedOptions/selectedOptionsContext.js +3 -1
  34. package/esm/form/combobox/SelectedOptions/selectedOptionsContext.js.map +1 -1
  35. package/esm/form/combobox/types.d.ts +14 -0
  36. package/esm/util/hooks/descendants/descendant.d.ts +47 -0
  37. package/esm/util/hooks/descendants/descendant.js +114 -0
  38. package/esm/util/hooks/descendants/descendant.js.map +1 -0
  39. package/esm/util/hooks/descendants/useDescendant.d.ts +14 -0
  40. package/esm/util/hooks/descendants/useDescendant.js +82 -0
  41. package/esm/util/hooks/descendants/useDescendant.js.map +1 -0
  42. package/esm/util/hooks/descendants/utils.d.ts +12 -0
  43. package/esm/util/hooks/descendants/utils.js +46 -0
  44. package/esm/util/hooks/descendants/utils.js.map +1 -0
  45. package/package.json +3 -3
  46. package/src/form/combobox/Combobox.tsx +1 -1
  47. package/src/form/combobox/ComboboxProvider.tsx +2 -0
  48. package/src/form/combobox/ComboboxWrapper.tsx +0 -1
  49. package/src/form/combobox/FilteredOptions/FilteredOptions.tsx +131 -92
  50. package/src/form/combobox/FilteredOptions/filtered-options-util.ts +9 -2
  51. package/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx +22 -3
  52. package/src/form/combobox/FilteredOptions/useVirtualFocus.ts +63 -45
  53. package/src/form/combobox/Input/Input.tsx +3 -1
  54. package/src/form/combobox/SelectedOptions/selectedOptionsContext.tsx +11 -1
  55. package/src/form/combobox/combobox.stories.tsx +36 -1
  56. package/src/form/combobox/combobox.test.tsx +1 -3
  57. package/src/form/combobox/types.ts +15 -0
  58. package/src/util/hooks/descendants/descendant.stories.tsx +147 -0
  59. package/src/util/hooks/descendants/descendant.ts +161 -0
  60. package/src/util/hooks/descendants/useDescendant.tsx +111 -0
  61. package/src/util/hooks/descendants/utils.ts +56 -0
@@ -27,111 +27,150 @@ const FilteredOptions = () => {
27
27
  activeDecendantId,
28
28
  virtualFocus,
29
29
  } = useFilteredOptionsContext();
30
- const { isMultiSelect, selectedOptions, toggleOption } =
30
+ const { isMultiSelect, selectedOptions, toggleOption, maxSelected } =
31
31
  useSelectedOptionsContext();
32
32
 
33
+ const isDisabled = (option) =>
34
+ maxSelected?.isLimitReached && !selectedOptions.includes(option);
35
+
36
+ const shouldRenderNonSelectables =
37
+ maxSelected?.isLimitReached || // Render maxSelected message
38
+ isLoading || // Render loading message
39
+ (!isLoading && filteredOptions.length === 0); // Render no hits message
40
+
41
+ const shouldRenderFilteredOptionsList =
42
+ (allowNewValues && isValueNew && !maxSelected?.isLimitReached) || // Render add new option
43
+ filteredOptions.length > 0; // Render filtered options
44
+
33
45
  return (
34
- <ul
35
- ref={setFilteredOptionsRef}
46
+ <div
36
47
  className={cl("navds-combobox__list", {
37
48
  "navds-combobox__list--closed": !isListOpen,
38
49
  "navds-combobox__list--with-hover": isMouseLastUsedInputDevice,
39
50
  })}
40
51
  id={filteredOptionsUtil.getFilteredOptionsId(id)}
41
- role="listbox"
42
52
  tabIndex={-1}
43
53
  >
44
- {isLoading && (
45
- <li
46
- className="navds-combobox__list-item--loading"
47
- role="option"
48
- aria-selected={false}
49
- id={filteredOptionsUtil.getIsLoadingId(id)}
50
- data-no-focus="true"
51
- >
52
- <Loader aria-label="Søker..." />
53
- </li>
54
+ {shouldRenderNonSelectables && (
55
+ <div className="navds-combobox__list_non-selectables" role="status">
56
+ {maxSelected?.isLimitReached && (
57
+ <div
58
+ className="navds-combobox__list-item--max-selected"
59
+ id={filteredOptionsUtil.getMaxSelectedOptionsId(id)}
60
+ >
61
+ {maxSelected.message ??
62
+ `${selectedOptions.length} av ${maxSelected.limit} er valgt.`}
63
+ </div>
64
+ )}
65
+ {isLoading && (
66
+ <div
67
+ className="navds-combobox__list-item--loading"
68
+ id={filteredOptionsUtil.getIsLoadingId(id)}
69
+ >
70
+ <Loader title="Søker..." />
71
+ </div>
72
+ )}
73
+ {!isLoading && filteredOptions.length === 0 && (
74
+ <div
75
+ className="navds-combobox__list-item--no-options"
76
+ id={filteredOptionsUtil.getNoHitsId(id)}
77
+ >
78
+ Ingen søketreff
79
+ </div>
80
+ )}
81
+ </div>
54
82
  )}
55
- {isValueNew && allowNewValues && (
56
- <li
57
- tabIndex={-1}
58
- onMouseMove={() => {
59
- if (
60
- activeDecendantId !== filteredOptionsUtil.getAddNewOptionId(id)
61
- ) {
62
- virtualFocus.moveFocusToElement(
63
- filteredOptionsUtil.getAddNewOptionId(id),
64
- );
65
- setIsMouseLastUsedInputDevice(true);
66
- }
67
- }}
68
- onPointerUp={(event) => {
69
- toggleOption(value, event);
70
- if (!isMultiSelect && !selectedOptions.includes(value))
71
- toggleIsListOpen(false);
72
- }}
73
- id={filteredOptionsUtil.getAddNewOptionId(id)}
74
- className={cl("navds-combobox__list-item__new-option", {
75
- "navds-combobox__list-item__new-option--focus":
76
- activeDecendantId === filteredOptionsUtil.getAddNewOptionId(id),
77
- })}
78
- role="option"
79
- aria-selected={false}
80
- >
81
- <PlusIcon aria-hidden />
82
- <BodyShort size={size}>
83
- Legg til{" "}
84
- <Label as="span" size={size}>
85
- &#8220;{value}&#8221;
86
- </Label>
87
- </BodyShort>
88
- </li>
89
- )}
90
- {!isLoading && filteredOptions.length === 0 && (
91
- <li
92
- className="navds-combobox__list-item__no-options"
93
- role="option"
94
- aria-selected={false}
95
- id={filteredOptionsUtil.getNoHitsId(id)}
96
- data-no-focus="true"
83
+
84
+ {shouldRenderFilteredOptionsList && (
85
+ <ul
86
+ ref={setFilteredOptionsRef}
87
+ role="listbox"
88
+ className="navds-combobox__list-options"
97
89
  >
98
- Ingen søketreff
99
- </li>
90
+ {isValueNew && !maxSelected?.isLimitReached && allowNewValues && (
91
+ <li
92
+ tabIndex={-1}
93
+ onMouseMove={() => {
94
+ if (
95
+ activeDecendantId !==
96
+ filteredOptionsUtil.getAddNewOptionId(id)
97
+ ) {
98
+ virtualFocus.moveFocusToElement(
99
+ filteredOptionsUtil.getAddNewOptionId(id),
100
+ );
101
+ setIsMouseLastUsedInputDevice(true);
102
+ }
103
+ }}
104
+ onPointerUp={(event) => {
105
+ toggleOption(value, event);
106
+ if (!isMultiSelect && !selectedOptions.includes(value))
107
+ toggleIsListOpen(false);
108
+ }}
109
+ id={filteredOptionsUtil.getAddNewOptionId(id)}
110
+ className={cl(
111
+ "navds-combobox__list-item navds-combobox__list-item--new-option",
112
+ {
113
+ "navds-combobox__list-item--new-option--focus":
114
+ activeDecendantId ===
115
+ filteredOptionsUtil.getAddNewOptionId(id),
116
+ },
117
+ )}
118
+ role="option"
119
+ aria-selected={false}
120
+ >
121
+ <PlusIcon aria-hidden />
122
+ <BodyShort size={size}>
123
+ Legg til{" "}
124
+ <Label as="span" size={size}>
125
+ &#8220;{value}&#8221;
126
+ </Label>
127
+ </BodyShort>
128
+ </li>
129
+ )}
130
+ {filteredOptions.map((option) => (
131
+ <li
132
+ className={cl("navds-combobox__list-item", {
133
+ "navds-combobox__list-item--focus":
134
+ activeDecendantId ===
135
+ filteredOptionsUtil.getOptionId(id, option),
136
+ "navds-combobox__list-item--selected":
137
+ selectedOptions.includes(option),
138
+ })}
139
+ data-no-focus={isDisabled(option) || undefined}
140
+ id={filteredOptionsUtil.getOptionId(id, option)}
141
+ key={option}
142
+ tabIndex={-1}
143
+ onMouseMove={() => {
144
+ if (
145
+ activeDecendantId !==
146
+ filteredOptionsUtil.getOptionId(id, option)
147
+ ) {
148
+ virtualFocus.moveFocusToElement(
149
+ filteredOptionsUtil.getOptionId(id, option),
150
+ );
151
+ setIsMouseLastUsedInputDevice(true);
152
+ }
153
+ }}
154
+ onPointerUp={(event) => {
155
+ if (isDisabled(option)) {
156
+ return;
157
+ }
158
+ toggleOption(option, event);
159
+ if (!isMultiSelect && !selectedOptions.includes(option)) {
160
+ toggleIsListOpen(false);
161
+ }
162
+ }}
163
+ role="option"
164
+ aria-selected={selectedOptions.includes(option)}
165
+ aria-disabled={isDisabled(option) || undefined}
166
+ >
167
+ <BodyShort size={size}>{option}</BodyShort>
168
+ {selectedOptions.includes(option) && <CheckmarkIcon />}
169
+ </li>
170
+ ))}
171
+ </ul>
100
172
  )}
101
- {filteredOptions.map((option) => (
102
- <li
103
- className={cl("navds-combobox__list-item", {
104
- "navds-combobox__list-item--focus":
105
- activeDecendantId === filteredOptionsUtil.getOptionId(id, option),
106
- "navds-combobox__list-item--selected":
107
- selectedOptions.includes(option),
108
- })}
109
- id={filteredOptionsUtil.getOptionId(id, option)}
110
- key={option}
111
- tabIndex={-1}
112
- onMouseMove={() => {
113
- if (
114
- activeDecendantId !== filteredOptionsUtil.getOptionId(id, option)
115
- ) {
116
- virtualFocus.moveFocusToElement(
117
- filteredOptionsUtil.getOptionId(id, option),
118
- );
119
- setIsMouseLastUsedInputDevice(true);
120
- }
121
- }}
122
- onPointerUp={(event) => {
123
- toggleOption(option, event);
124
- if (!isMultiSelect && !selectedOptions.includes(option))
125
- toggleIsListOpen(false);
126
- }}
127
- role="option"
128
- aria-selected={selectedOptions.includes(option)}
129
- >
130
- <BodyShort size={size}>{option}</BodyShort>
131
- {selectedOptions.includes(option) && <CheckmarkIcon />}
132
- </li>
133
- ))}
134
- </ul>
173
+ </div>
135
174
  );
136
175
  };
137
176
 
@@ -7,8 +7,11 @@ const isPartOfText = (value, text) =>
7
7
  const isValueInList = (value, list) =>
8
8
  list?.find((listItem) => normalizeText(value) === normalizeText(listItem));
9
9
 
10
- const getMatchingValuesFromList = (value, list) =>
11
- list?.filter((listItem) => isPartOfText(value, listItem));
10
+ const getMatchingValuesFromList = (value, list, alwaysIncluded) =>
11
+ list?.filter(
12
+ (listItem) =>
13
+ isPartOfText(value, listItem) || alwaysIncluded.includes(listItem),
14
+ );
12
15
 
13
16
  const getFilteredOptionsId = (comboboxId: string) =>
14
17
  `${comboboxId}-filtered-options`;
@@ -25,6 +28,9 @@ const getIsLoadingId = (comboboxId: string) => `${comboboxId}-is-loading`;
25
28
 
26
29
  const getNoHitsId = (comboboxId: string) => `${comboboxId}-no-hits`;
27
30
 
31
+ const getMaxSelectedOptionsId = (comboboxId: string) =>
32
+ `${comboboxId}-max-selected-options`;
33
+
28
34
  export default {
29
35
  normalizeText,
30
36
  isPartOfText,
@@ -35,4 +41,5 @@ export default {
35
41
  getOptionId,
36
42
  getIsLoadingId,
37
43
  getNoHitsId,
44
+ getMaxSelectedOptionsId,
38
45
  };
@@ -9,6 +9,7 @@ import React, {
9
9
  } from "react";
10
10
  import { useClientLayoutEffect, usePrevious } from "../../../util/hooks";
11
11
  import { useInputContext } from "../Input/inputContext";
12
+ import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
12
13
  import { useCustomOptionsContext } from "../customOptionsContext";
13
14
  import { ComboboxProps } from "../types";
14
15
  import filteredOptionsUtils from "./filtered-options-util";
@@ -70,6 +71,7 @@ export const FilteredOptionsProvider = ({
70
71
  setSearchTerm,
71
72
  shouldAutocomplete,
72
73
  } = useInputContext();
74
+ const { selectedOptions, maxSelected } = useSelectedOptionsContext();
73
75
 
74
76
  const [isInternalListOpen, setInternalListOpen] = useState(false);
75
77
  const { customOptions } = useCustomOptionsContext();
@@ -79,8 +81,18 @@ export const FilteredOptionsProvider = ({
79
81
  return externalFilteredOptions;
80
82
  }
81
83
  const opts = [...customOptions, ...options];
82
- return filteredOptionsUtils.getMatchingValuesFromList(searchTerm, opts);
83
- }, [customOptions, externalFilteredOptions, options, searchTerm]);
84
+ return filteredOptionsUtils.getMatchingValuesFromList(
85
+ searchTerm,
86
+ opts,
87
+ selectedOptions,
88
+ );
89
+ }, [
90
+ customOptions,
91
+ externalFilteredOptions,
92
+ options,
93
+ searchTerm,
94
+ selectedOptions,
95
+ ]);
84
96
 
85
97
  const previousSearchTerm = usePrevious(searchTerm);
86
98
 
@@ -154,10 +166,17 @@ export const FilteredOptionsProvider = ({
154
166
  activeOption = filteredOptionsUtils.getIsLoadingId(id);
155
167
  }
156
168
  }
157
- return cl(activeOption, partialAriaDescribedBy) || undefined;
169
+ const maybeMaxSelectedOptionsId =
170
+ maxSelected?.isLimitReached &&
171
+ filteredOptionsUtils.getMaxSelectedOptionsId(id);
172
+ return (
173
+ cl(activeOption, maybeMaxSelectedOptionsId, partialAriaDescribedBy) ||
174
+ undefined
175
+ );
158
176
  }, [
159
177
  isListOpen,
160
178
  isLoading,
179
+ maxSelected?.isLimitReached,
161
180
  value,
162
181
  partialAriaDescribedBy,
163
182
  shouldAutocomplete,
@@ -1,11 +1,10 @@
1
- import { Dispatch, SetStateAction, useState } from "react";
1
+ import { useState } from "react";
2
2
 
3
3
  export type VirtualFocusType = {
4
4
  activeElement: HTMLElement | undefined;
5
5
  getElementById: (id: string) => HTMLElement | undefined;
6
- isFocusOnTheTop: boolean;
7
- isFocusOnTheBottom: boolean;
8
- setIndex: Dispatch<SetStateAction<number>>;
6
+ isFocusOnTheTop: () => boolean;
7
+ isFocusOnTheBottom: () => boolean;
9
8
  moveFocusUp: () => void;
10
9
  moveFocusDown: () => void;
11
10
  moveFocusToElement: (id: string) => void;
@@ -16,57 +15,77 @@ export type VirtualFocusType = {
16
15
  const useVirtualFocus = (
17
16
  containerRef: HTMLElement | null,
18
17
  ): VirtualFocusType => {
19
- const [index, setIndex] = useState(-1);
20
-
21
- const listOfAllChildren: HTMLElement[] = containerRef?.children
22
- ? Array.prototype.slice.call(containerRef?.children)
23
- : [];
24
- const elementsAbleToReceiveFocus = listOfAllChildren.filter(
25
- (child) => child.getAttribute("data-no-focus") !== "true",
18
+ const [activeElement, setActiveElement] = useState<HTMLElement | undefined>(
19
+ undefined,
26
20
  );
27
21
 
28
- const activeElement = elementsAbleToReceiveFocus[index];
22
+ const getListOfAllChildren = (): HTMLElement[] =>
23
+ Array.from(containerRef?.children ?? []) as HTMLElement[];
24
+ const getElementsAbleToReceiveFocus = () =>
25
+ getListOfAllChildren().filter(
26
+ (child) => child.getAttribute("data-no-focus") !== "true",
27
+ );
28
+
29
29
  const getElementById = (id: string) =>
30
- listOfAllChildren.find((element) => element.id === id);
31
- const isFocusOnTheTop = index === 0;
32
- const isFocusOnTheBottom = index === elementsAbleToReceiveFocus.length - 1;
30
+ getListOfAllChildren().find((element) => element.id === id);
31
+ const isFocusOnTheTop = () =>
32
+ activeElement
33
+ ? getElementsAbleToReceiveFocus().indexOf(activeElement) === 0
34
+ : false;
35
+ const isFocusOnTheBottom = () => {
36
+ const elementsAbleToReceiveFocus = getElementsAbleToReceiveFocus();
37
+ return activeElement
38
+ ? elementsAbleToReceiveFocus.indexOf(activeElement) ===
39
+ elementsAbleToReceiveFocus.length - 1
40
+ : false;
41
+ };
33
42
 
34
- const scrollToOption = (newIndex: number) => {
35
- const indexOfElementToScrollTo = Math.min(
36
- Math.max(newIndex, 0),
37
- containerRef?.children.length || 0,
38
- );
39
- if (containerRef?.children[indexOfElementToScrollTo]) {
40
- const child = containerRef.children[indexOfElementToScrollTo];
41
- const { top, bottom } = child.getBoundingClientRect();
42
- const parentRect = containerRef.getBoundingClientRect();
43
- if (top < parentRect.top || bottom > parentRect.bottom) {
44
- child.scrollIntoView({ block: "nearest" });
45
- }
43
+ const _moveFocusAndScrollTo = (_element?: HTMLElement) => {
44
+ setActiveElement(_element);
45
+ _element?.scrollIntoView?.({ block: "nearest" });
46
+ };
47
+
48
+ const moveFocusUp = () => {
49
+ if (!activeElement) {
50
+ return;
51
+ }
52
+ const elementsAbleToReceiveFocus = getElementsAbleToReceiveFocus();
53
+ const _currentIndex = elementsAbleToReceiveFocus.indexOf(activeElement);
54
+ const elementAbove = elementsAbleToReceiveFocus[_currentIndex - 1];
55
+ if (_currentIndex === 0) {
56
+ setActiveElement(undefined);
57
+ } else {
58
+ _moveFocusAndScrollTo(elementAbove);
46
59
  }
47
60
  };
48
61
 
49
- const _moveFocusAndScrollTo = (_index: number) => {
50
- setIndex(_index);
51
- scrollToOption(_index);
62
+ const moveFocusDown = () => {
63
+ const elementsAbleToReceiveFocus = getElementsAbleToReceiveFocus();
64
+ if (!activeElement) {
65
+ _moveFocusAndScrollTo(elementsAbleToReceiveFocus[0]);
66
+ return;
67
+ }
68
+ const _currentIndex = elementsAbleToReceiveFocus.indexOf(activeElement);
69
+ if (_currentIndex === elementsAbleToReceiveFocus.length - 1) {
70
+ return;
71
+ } else {
72
+ _moveFocusAndScrollTo(elementsAbleToReceiveFocus[_currentIndex + 1]);
73
+ }
52
74
  };
53
- const moveFocusUp = () => _moveFocusAndScrollTo(Math.max(index - 1, -1));
54
- const moveFocusDown = () =>
55
- _moveFocusAndScrollTo(
56
- Math.min(index + 1, elementsAbleToReceiveFocus.length - 1),
75
+
76
+ const moveFocusToTop = () => _moveFocusAndScrollTo(undefined);
77
+ const moveFocusToBottom = () => {
78
+ const elementsAbleToReceiveFocus = getElementsAbleToReceiveFocus();
79
+ return _moveFocusAndScrollTo(
80
+ elementsAbleToReceiveFocus[elementsAbleToReceiveFocus.length - 1],
57
81
  );
58
- const moveFocusToTop = () => _moveFocusAndScrollTo(-1);
59
- const moveFocusToBottom = () =>
60
- _moveFocusAndScrollTo(elementsAbleToReceiveFocus.length - 1);
82
+ };
61
83
  const moveFocusToElement = (id: string) => {
62
- const thisElement = elementsAbleToReceiveFocus.find(
63
- (_element) => _element.getAttribute("id") === id,
84
+ const _element = getElementsAbleToReceiveFocus().find(
85
+ (_focusableElement) => _focusableElement.getAttribute("id") === id,
64
86
  );
65
- const indexOfElement = thisElement
66
- ? elementsAbleToReceiveFocus.indexOf(thisElement)
67
- : -1;
68
- if (indexOfElement >= 0) {
69
- setIndex(indexOfElement);
87
+ if (_element) {
88
+ setActiveElement(_element);
70
89
  }
71
90
  };
72
91
 
@@ -75,7 +94,6 @@ const useVirtualFocus = (
75
94
  getElementById,
76
95
  isFocusOnTheTop,
77
96
  isFocusOnTheBottom,
78
- setIndex,
79
97
  moveFocusUp,
80
98
  moveFocusDown,
81
99
  moveFocusToElement,
@@ -101,9 +101,11 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
101
101
  onEnter(e);
102
102
  break;
103
103
  case "Home":
104
+ toggleIsListOpen(false);
104
105
  virtualFocus.moveFocusToTop();
105
106
  break;
106
107
  case "End":
108
+ toggleIsListOpen(true);
107
109
  virtualFocus.moveFocusToBottom();
108
110
  break;
109
111
  default:
@@ -135,7 +137,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
135
137
  // Otherwise ignore keystrokes, so it doesn't interfere with text editing
136
138
  if (isListOpen && activeDecendantId) {
137
139
  e.preventDefault();
138
- if (virtualFocus.isFocusOnTheTop) {
140
+ if (virtualFocus.isFocusOnTheTop()) {
139
141
  toggleIsListOpen(false);
140
142
  }
141
143
  virtualFocus.moveFocusUp();
@@ -8,7 +8,7 @@ import React, {
8
8
  import { usePrevious } from "../../../util/hooks";
9
9
  import { useInputContext } from "../Input/inputContext";
10
10
  import { useCustomOptionsContext } from "../customOptionsContext";
11
- import { ComboboxProps } from "../types";
11
+ import { ComboboxProps, MaxSelected } from "../types";
12
12
 
13
13
  type SelectedOptionsContextType = {
14
14
  addSelectedOption: (option: string) => void;
@@ -16,6 +16,7 @@ type SelectedOptionsContextType = {
16
16
  removeSelectedOption: (option: string) => void;
17
17
  prevSelectedOptions?: string[];
18
18
  selectedOptions: string[];
19
+ maxSelected?: MaxSelected & { isLimitReached: boolean };
19
20
  setSelectedOptions: (any) => void;
20
21
  toggleOption: (
21
22
  option: string,
@@ -39,6 +40,7 @@ export const SelectedOptionsProvider = ({
39
40
  | "options"
40
41
  | "selectedOptions"
41
42
  | "onToggleSelected"
43
+ | "maxSelected"
42
44
  >;
43
45
  }) => {
44
46
  const { clearInput, focusInput } = useInputContext();
@@ -54,6 +56,7 @@ export const SelectedOptionsProvider = ({
54
56
  selectedOptions: externalSelectedOptions,
55
57
  onToggleSelected,
56
58
  options,
59
+ maxSelected,
57
60
  } = value;
58
61
  const [internalSelectedOptions, setSelectedOptions] = useState<string[]>([]);
59
62
  const selectedOptions = useMemo(
@@ -129,6 +132,9 @@ export const SelectedOptionsProvider = ({
129
132
 
130
133
  const prevSelectedOptions = usePrevious<string[]>(selectedOptions);
131
134
 
135
+ const isLimitReached =
136
+ !!maxSelected?.limit && selectedOptions.length >= maxSelected.limit;
137
+
132
138
  const selectedOptionsState = {
133
139
  addSelectedOption,
134
140
  isMultiSelect,
@@ -137,6 +143,10 @@ export const SelectedOptionsProvider = ({
137
143
  selectedOptions,
138
144
  setSelectedOptions,
139
145
  toggleOption,
146
+ maxSelected: maxSelected && {
147
+ ...maxSelected,
148
+ isLimitReached,
149
+ },
140
150
  };
141
151
 
142
152
  return (
@@ -1,6 +1,6 @@
1
1
  import { Meta, StoryFn, StoryObj } from "@storybook/react";
2
2
  import { expect, fn, userEvent, within } from "@storybook/test";
3
- import React, { useId, useMemo, useState } from "react";
3
+ import React, { useId, useMemo, useRef, useState } from "react";
4
4
  import { Chips, ComboboxProps, TextField, UNSAFE_Combobox } from "../../index";
5
5
 
6
6
  export default {
@@ -37,11 +37,16 @@ Default.args = {
37
37
  label: "Hva er dine favorittfrukter?",
38
38
  shouldAutocomplete: true,
39
39
  isLoading: false,
40
+ isMultiSelect: false,
41
+ allowNewValues: false,
40
42
  };
41
43
  Default.argTypes = {
42
44
  isListOpen: {
43
45
  control: { type: "boolean" },
44
46
  },
47
+ maxSelected: {
48
+ control: { type: "number" },
49
+ },
45
50
  size: {
46
51
  options: ["medium", "small"],
47
52
  defaultValue: "medium",
@@ -284,6 +289,36 @@ export const ComboboxSizes = () => (
284
289
  </>
285
290
  );
286
291
 
292
+ export const MaxSelectedOptions: StoryFunction = () => {
293
+ const id = useId();
294
+ const [value, setValue] = useState<string | undefined>("");
295
+ const [selectedOptions, setSelectedOptions] = useState([
296
+ options[0],
297
+ options[1],
298
+ ]);
299
+ const comboboxRef = useRef<HTMLInputElement>(null);
300
+ return (
301
+ <UNSAFE_Combobox
302
+ id={id}
303
+ label="Komboboks med begrenset antall valg"
304
+ options={options}
305
+ maxSelected={{ limit: 2 }}
306
+ selectedOptions={selectedOptions}
307
+ onToggleSelected={(option, isSelected) =>
308
+ isSelected
309
+ ? setSelectedOptions([...selectedOptions, option])
310
+ : setSelectedOptions(selectedOptions.filter((o) => o !== option))
311
+ }
312
+ isMultiSelect
313
+ allowNewValues
314
+ isListOpen={comboboxRef.current ? undefined : true}
315
+ value={value}
316
+ onChange={(event) => setValue(event?.target.value)}
317
+ ref={comboboxRef}
318
+ />
319
+ );
320
+ };
321
+
287
322
  export const WithError: StoryFunction = (props) => {
288
323
  const [hasSelectedValue, setHasSelectedValue] = useState(false);
289
324
  const [isLoading, setIsLoading] = useState(false);
@@ -74,9 +74,7 @@ describe("Render combobox", () => {
74
74
  it("Should show loading icon when loading (used for async search)", async () => {
75
75
  render(<App options={[]} isListOpen isLoading />);
76
76
 
77
- expect(
78
- await screen.findByRole("option", { name: "venter..." }),
79
- ).toBeInTheDocument();
77
+ expect(await screen.findByText("Søker...")).toBeInTheDocument();
80
78
  });
81
79
  });
82
80
 
@@ -1,6 +1,17 @@
1
1
  import React, { ChangeEvent, InputHTMLAttributes } from "react";
2
2
  import { FormFieldProps } from "../useFormField";
3
3
 
4
+ export type MaxSelected = {
5
+ /**
6
+ * The limit for maximum selected options
7
+ */
8
+ limit: number;
9
+ /**
10
+ * Override the message to display when the limit for maximum selected options has been reached
11
+ */
12
+ message?: string;
13
+ };
14
+
4
15
  export interface ComboboxProps
5
16
  extends FormFieldProps,
6
17
  Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "onChange" | "value"> {
@@ -97,6 +108,10 @@ export interface ComboboxProps
97
108
  * e.g. for a filter, where options can be toggled elsewhere/programmatically.
98
109
  */
99
110
  selectedOptions?: string[];
111
+ /**
112
+ * Options for the maximum number of selected options.
113
+ */
114
+ maxSelected?: MaxSelected;
100
115
  /**
101
116
  * Set to "true" to enable inline autocomplete.
102
117
  *