@navikt/ds-react 5.7.6 → 5.9.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 (164) hide show
  1. package/_docs.json +1824 -1758
  2. package/cjs/accordion/AccordionHeader.js +2 -2
  3. package/cjs/date/context/useDateInputContext.js +1 -5
  4. package/cjs/date/datepicker/DatePicker.js +26 -25
  5. package/cjs/date/hooks/useDatepicker.js +9 -17
  6. package/cjs/date/hooks/useMonthPicker.js +9 -17
  7. package/cjs/date/hooks/useRangeDatepicker.js +9 -20
  8. package/cjs/date/monthpicker/MonthPicker.js +11 -6
  9. package/cjs/date/{DateInput.js → parts/DateInput.js} +14 -10
  10. package/cjs/date/parts/DateWrapper.js +55 -0
  11. package/cjs/date/utils/labels.js +77 -1
  12. package/cjs/form/combobox/Combobox.js +2 -2
  13. package/cjs/form/combobox/ComboboxProvider.js +1 -2
  14. package/cjs/form/combobox/FilteredOptions/FilteredOptions.js +15 -14
  15. package/cjs/form/combobox/FilteredOptions/filtered-options-util.js +24 -0
  16. package/cjs/form/combobox/FilteredOptions/filteredOptionsContext.js +24 -108
  17. package/cjs/form/combobox/FilteredOptions/useVirtualFocus.js +55 -0
  18. package/cjs/form/combobox/Input/Input.js +33 -16
  19. package/cjs/form/combobox/customOptionsContext.js +2 -3
  20. package/cjs/layout/sidemal-test/Sidebar.js +1 -1
  21. package/cjs/loader/Loader.js +1 -1
  22. package/cjs/modal/Modal.js +39 -15
  23. package/cjs/popover/Popover.js +5 -7
  24. package/cjs/tooltip/Tooltip.js +14 -3
  25. package/cjs/util/useMedia.js +30 -0
  26. package/esm/accordion/AccordionHeader.js +2 -2
  27. package/esm/accordion/AccordionHeader.js.map +1 -1
  28. package/esm/date/context/useDateInputContext.d.ts +6 -2
  29. package/esm/date/context/useDateInputContext.js +1 -5
  30. package/esm/date/context/useDateInputContext.js.map +1 -1
  31. package/esm/date/datepicker/DatePicker.d.ts +1 -1
  32. package/esm/date/datepicker/DatePicker.js +28 -27
  33. package/esm/date/datepicker/DatePicker.js.map +1 -1
  34. package/esm/date/datepicker/types.d.ts +0 -5
  35. package/esm/date/hooks/useDatepicker.d.ts +8 -5
  36. package/esm/date/hooks/useDatepicker.js +10 -18
  37. package/esm/date/hooks/useDatepicker.js.map +1 -1
  38. package/esm/date/hooks/useMonthPicker.d.ts +7 -4
  39. package/esm/date/hooks/useMonthPicker.js +10 -18
  40. package/esm/date/hooks/useMonthPicker.js.map +1 -1
  41. package/esm/date/hooks/useRangeDatepicker.d.ts +9 -3
  42. package/esm/date/hooks/useRangeDatepicker.js +10 -21
  43. package/esm/date/hooks/useRangeDatepicker.js.map +1 -1
  44. package/esm/date/index.d.ts +1 -1
  45. package/esm/date/index.js.map +1 -1
  46. package/esm/date/monthpicker/MonthPicker.d.ts +1 -1
  47. package/esm/date/monthpicker/MonthPicker.js +13 -8
  48. package/esm/date/monthpicker/MonthPicker.js.map +1 -1
  49. package/esm/date/monthpicker/types.d.ts +0 -5
  50. package/esm/date/{DateInput.d.ts → parts/DateInput.d.ts} +5 -1
  51. package/esm/date/{DateInput.js → parts/DateInput.js} +15 -11
  52. package/esm/date/parts/DateInput.js.map +1 -0
  53. package/esm/date/parts/DateWrapper.d.ts +15 -0
  54. package/esm/date/parts/DateWrapper.js +26 -0
  55. package/esm/date/parts/DateWrapper.js.map +1 -0
  56. package/esm/date/utils/labels.d.ts +2 -0
  57. package/esm/date/utils/labels.js +74 -0
  58. package/esm/date/utils/labels.js.map +1 -1
  59. package/esm/form/combobox/Combobox.js +2 -2
  60. package/esm/form/combobox/Combobox.js.map +1 -1
  61. package/esm/form/combobox/ComboboxProvider.js +1 -2
  62. package/esm/form/combobox/ComboboxProvider.js.map +1 -1
  63. package/esm/form/combobox/FilteredOptions/FilteredOptions.js +15 -14
  64. package/esm/form/combobox/FilteredOptions/FilteredOptions.js.map +1 -1
  65. package/esm/form/combobox/FilteredOptions/filtered-options-util.d.ts +12 -0
  66. package/esm/form/combobox/FilteredOptions/filtered-options-util.js +23 -0
  67. package/esm/form/combobox/FilteredOptions/filtered-options-util.js.map +1 -0
  68. package/esm/form/combobox/FilteredOptions/filteredOptionsContext.d.ts +10 -13
  69. package/esm/form/combobox/FilteredOptions/filteredOptionsContext.js +25 -109
  70. package/esm/form/combobox/FilteredOptions/filteredOptionsContext.js.map +1 -1
  71. package/esm/form/combobox/FilteredOptions/useVirtualFocus.d.ts +15 -0
  72. package/esm/form/combobox/FilteredOptions/useVirtualFocus.js +54 -0
  73. package/esm/form/combobox/FilteredOptions/useVirtualFocus.js.map +1 -0
  74. package/esm/form/combobox/Input/Input.js +33 -16
  75. package/esm/form/combobox/Input/Input.js.map +1 -1
  76. package/esm/form/combobox/customOptionsContext.d.ts +4 -1
  77. package/esm/form/combobox/customOptionsContext.js +2 -3
  78. package/esm/form/combobox/customOptionsContext.js.map +1 -1
  79. package/esm/layout/bleed/Bleed.d.ts +1 -1
  80. package/esm/layout/bleed/Bleed.js +1 -1
  81. package/esm/layout/bleed/Bleed.js.map +1 -1
  82. package/esm/layout/box/Box.d.ts +1 -2
  83. package/esm/layout/box/Box.js +1 -1
  84. package/esm/layout/box/Box.js.map +1 -1
  85. package/esm/layout/grid/HGrid.d.ts +1 -1
  86. package/esm/layout/grid/HGrid.js +1 -1
  87. package/esm/layout/grid/HGrid.js.map +1 -1
  88. package/esm/layout/responsive/Responsive.d.ts +1 -1
  89. package/esm/layout/sidemal-test/Sidebar.js +1 -1
  90. package/esm/layout/sidemal-test/Sidebar.js.map +1 -1
  91. package/esm/layout/stack/Stack.d.ts +1 -1
  92. package/esm/layout/stack/Stack.js +1 -1
  93. package/esm/layout/stack/Stack.js.map +1 -1
  94. package/esm/layout/utilities/css.d.ts +1 -8
  95. package/esm/layout/utilities/css.js.map +1 -1
  96. package/esm/layout/utilities/types.d.ts +9 -0
  97. package/esm/loader/Loader.d.ts +1 -1
  98. package/esm/loader/Loader.js +1 -1
  99. package/esm/modal/Modal.js +39 -15
  100. package/esm/modal/Modal.js.map +1 -1
  101. package/esm/modal/ModalContext.d.ts +1 -0
  102. package/esm/modal/ModalContext.js.map +1 -1
  103. package/esm/modal/types.d.ts +7 -0
  104. package/esm/popover/Popover.d.ts +0 -5
  105. package/esm/popover/Popover.js +5 -7
  106. package/esm/popover/Popover.js.map +1 -1
  107. package/esm/tooltip/Tooltip.js +16 -5
  108. package/esm/tooltip/Tooltip.js.map +1 -1
  109. package/esm/util/useMedia.d.ts +8 -0
  110. package/esm/util/useMedia.js +27 -0
  111. package/esm/util/useMedia.js.map +1 -0
  112. package/package.json +3 -3
  113. package/src/accordion/AccordionHeader.tsx +3 -3
  114. package/src/date/context/useDateInputContext.tsx +5 -5
  115. package/src/date/datepicker/DatePicker.tsx +58 -65
  116. package/src/date/datepicker/datepicker.stories.tsx +37 -46
  117. package/src/date/datepicker/types.ts +0 -5
  118. package/src/date/hooks/useDatepicker.tsx +20 -25
  119. package/src/date/hooks/useMonthPicker.tsx +18 -24
  120. package/src/date/hooks/useRangeDatepicker.tsx +27 -30
  121. package/src/date/index.ts +1 -1
  122. package/src/date/monthpicker/MonthPicker.tsx +39 -43
  123. package/src/date/monthpicker/types.ts +0 -5
  124. package/src/date/{DateInput.tsx → parts/DateInput.tsx} +23 -12
  125. package/src/date/parts/DateWrapper.tsx +80 -0
  126. package/src/date/utils/labels.ts +83 -0
  127. package/src/form/combobox/Combobox.tsx +2 -2
  128. package/src/form/combobox/ComboboxProvider.tsx +1 -2
  129. package/src/form/combobox/FilteredOptions/FilteredOptions.tsx +28 -16
  130. package/src/form/combobox/FilteredOptions/filtered-options-util.ts +38 -0
  131. package/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx +71 -142
  132. package/src/form/combobox/FilteredOptions/useVirtualFocus.ts +87 -0
  133. package/src/form/combobox/Input/Input.tsx +40 -21
  134. package/src/form/combobox/combobox.stories.tsx +44 -0
  135. package/src/form/combobox/customOptionsContext.tsx +10 -5
  136. package/src/guide-panel/guidepanel.stories.tsx +2 -2
  137. package/src/layout/bleed/Bleed.tsx +2 -5
  138. package/src/layout/box/Box.tsx +1 -3
  139. package/src/layout/grid/HGrid.tsx +2 -6
  140. package/src/layout/responsive/Responsive.tsx +1 -1
  141. package/src/layout/sidemal-test/Sidebar.tsx +1 -1
  142. package/src/layout/stack/Stack.tsx +2 -6
  143. package/src/layout/utilities/css.ts +1 -36
  144. package/src/layout/utilities/types.ts +16 -0
  145. package/src/loader/Loader.tsx +1 -1
  146. package/src/modal/Modal.tsx +54 -21
  147. package/src/modal/ModalContext.ts +1 -0
  148. package/src/modal/modal.stories.tsx +30 -2
  149. package/src/modal/types.ts +7 -0
  150. package/src/popover/Popover.tsx +4 -12
  151. package/src/tooltip/Tooltip.tsx +18 -6
  152. package/src/util/__tests__/useMedia.test.tsx +19 -0
  153. package/src/util/useMedia.ts +38 -0
  154. package/cjs/date/hooks/useEscape.js +0 -23
  155. package/cjs/date/hooks/useOutsideClickHandler.js +0 -26
  156. package/esm/date/DateInput.js.map +0 -1
  157. package/esm/date/hooks/useEscape.d.ts +0 -2
  158. package/esm/date/hooks/useEscape.js +0 -20
  159. package/esm/date/hooks/useEscape.js.map +0 -1
  160. package/esm/date/hooks/useOutsideClickHandler.d.ts +0 -1
  161. package/esm/date/hooks/useOutsideClickHandler.js +0 -23
  162. package/esm/date/hooks/useOutsideClickHandler.js.map +0 -1
  163. package/src/date/hooks/useEscape.tsx +0 -30
  164. package/src/date/hooks/useOutsideClickHandler.tsx +0 -34
@@ -0,0 +1,38 @@
1
+ const normalizeText = (text: string): string =>
2
+ typeof text === "string" ? text.toLocaleLowerCase().trim() : "";
3
+
4
+ const isPartOfText = (value, text) =>
5
+ normalizeText(text).startsWith(normalizeText(value ?? ""));
6
+
7
+ const isValueInList = (value, list) =>
8
+ list?.find((listItem) => normalizeText(value) === normalizeText(listItem));
9
+
10
+ const getMatchingValuesFromList = (value, list) =>
11
+ list?.filter((listItem) => isPartOfText(value, listItem));
12
+
13
+ const getFilteredOptionsId = (comboboxId: string) =>
14
+ `${comboboxId}-filtered-options`;
15
+
16
+ const getOptionId = (comboboxId: string, option: string) =>
17
+ `${comboboxId.toLocaleLowerCase()}-option-${option
18
+ .replace(" ", "-")
19
+ .toLocaleLowerCase()}`;
20
+
21
+ const getAddNewOptionId = (comboboxId: string) =>
22
+ `${comboboxId}-combobox-new-option`;
23
+
24
+ const getIsLoadingId = (comboboxId: string) => `${comboboxId}-is-loading`;
25
+
26
+ const getNoHitsId = (comboboxId: string) => `${comboboxId}-no-hits`;
27
+
28
+ export default {
29
+ normalizeText,
30
+ isPartOfText,
31
+ isValueInList,
32
+ getMatchingValuesFromList,
33
+ getFilteredOptionsId,
34
+ getAddNewOptionId,
35
+ getOptionId,
36
+ getIsLoadingId,
37
+ getNoHitsId,
38
+ };
@@ -1,11 +1,9 @@
1
1
  import React, {
2
2
  useState,
3
- useEffect,
4
3
  useMemo,
5
4
  createContext,
6
5
  useContext,
7
6
  useCallback,
8
- useRef,
9
7
  SetStateAction,
10
8
  } from "react";
11
9
  import cl from "clsx";
@@ -13,26 +11,29 @@ import { useCustomOptionsContext } from "../customOptionsContext";
13
11
  import { useInputContext } from "../Input/inputContext";
14
12
  import usePrevious from "../../../util/usePrevious";
15
13
  import { useClientLayoutEffect } from "../../../util";
16
-
17
- const normalizeText = (text: string): string =>
18
- typeof text === "string" ? `${text}`.toLowerCase().trim() : "";
19
-
20
- const isPartOfText = (value, text) =>
21
- normalizeText(text).startsWith(normalizeText(value ?? ""));
22
-
23
- const isValueInList = (value, list) =>
24
- list?.find((listItem) => normalizeText(value) === normalizeText(listItem));
25
-
26
- const getMatchingValuesFromList = (value, list) =>
27
- list?.filter((listItem) => isPartOfText(value, listItem));
14
+ import filteredOptionsUtils from "./filtered-options-util";
15
+ import useVirtualFocus, { VirtualFocusType } from "./useVirtualFocus";
16
+ import { ComboboxProps } from "../types";
17
+
18
+ type FilteredOptionsProps = {
19
+ children: any;
20
+ value: Pick<
21
+ ComboboxProps,
22
+ | "allowNewValues"
23
+ | "filteredOptions"
24
+ | "isListOpen"
25
+ | "isLoading"
26
+ | "options"
27
+ >;
28
+ };
28
29
 
29
30
  type FilteredOptionsContextType = {
30
31
  activeDecendantId?: string;
31
32
  allowNewValues?: boolean;
32
33
  ariaDescribedBy?: string;
33
- filteredOptionsRef: React.RefObject<HTMLUListElement>;
34
- filteredOptionsIndex: number | null;
35
- setFilteredOptionsIndex: (index: number) => void;
34
+ setFilteredOptionsRef: React.Dispatch<
35
+ React.SetStateAction<HTMLUListElement | null>
36
+ >;
36
37
  isListOpen: boolean;
37
38
  isLoading?: boolean;
38
39
  filteredOptions: string[];
@@ -40,19 +41,18 @@ type FilteredOptionsContextType = {
40
41
  setIsMouseLastUsedInputDevice: React.Dispatch<SetStateAction<boolean>>;
41
42
  isValueNew: boolean;
42
43
  toggleIsListOpen: (newState?: boolean) => void;
43
- currentOption: string | null;
44
- resetFilteredOptionsIndex: () => void;
45
- moveFocusUp: () => void;
46
- moveFocusDown: () => void;
47
- moveFocusToInput: () => void;
48
- moveFocusToEnd: () => void;
44
+ currentOption?: string;
49
45
  shouldAutocomplete?: boolean;
46
+ virtualFocus: VirtualFocusType;
50
47
  };
51
48
  const FilteredOptionsContext = createContext<FilteredOptionsContextType>(
52
49
  {} as FilteredOptionsContextType
53
50
  );
54
51
 
55
- export const FilteredOptionsProvider = ({ children, value: props }) => {
52
+ export const FilteredOptionsProvider = ({
53
+ children,
54
+ value: props,
55
+ }: FilteredOptionsProps) => {
56
56
  const {
57
57
  allowNewValues,
58
58
  filteredOptions: externalFilteredOptions,
@@ -60,7 +60,9 @@ export const FilteredOptionsProvider = ({ children, value: props }) => {
60
60
  isLoading,
61
61
  options,
62
62
  } = props;
63
- const filteredOptionsRef = useRef<HTMLUListElement | null>(null);
63
+ const [filteredOptionsRef, setFilteredOptionsRef] =
64
+ useState<HTMLUListElement | null>(null);
65
+ const virtualFocus = useVirtualFocus(filteredOptionsRef);
64
66
  const {
65
67
  inputProps: { "aria-describedby": partialAriaDescribedBy, id },
66
68
  value,
@@ -70,9 +72,6 @@ export const FilteredOptionsProvider = ({ children, value: props }) => {
70
72
  shouldAutocomplete,
71
73
  } = useInputContext();
72
74
 
73
- const [filteredOptionsIndex, setFilteredOptionsIndex] = useState<
74
- number | null
75
- >(null);
76
75
  const [isInternalListOpen, setInternalListOpen] = useState(false);
77
76
  const { customOptions } = useCustomOptionsContext();
78
77
 
@@ -81,8 +80,7 @@ export const FilteredOptionsProvider = ({ children, value: props }) => {
81
80
  return externalFilteredOptions;
82
81
  }
83
82
  const opts = [...customOptions, ...options];
84
- setFilteredOptionsIndex(null);
85
- return getMatchingValuesFromList(searchTerm, opts);
83
+ return filteredOptionsUtils.getMatchingValuesFromList(searchTerm, opts);
86
84
  }, [customOptions, externalFilteredOptions, options, searchTerm]);
87
85
 
88
86
  const previousSearchTerm = usePrevious(searchTerm);
@@ -90,13 +88,28 @@ export const FilteredOptionsProvider = ({ children, value: props }) => {
90
88
  const [isMouseLastUsedInputDevice, setIsMouseLastUsedInputDevice] =
91
89
  useState(false);
92
90
 
91
+ const filteredOptionsMap = useMemo(
92
+ () =>
93
+ options.reduce(
94
+ (map, _option) => ({
95
+ ...map,
96
+ [filteredOptionsUtils.getOptionId(id, _option)]: _option,
97
+ }),
98
+ {
99
+ [filteredOptionsUtils.getAddNewOptionId(id)]: allowNewValues
100
+ ? value
101
+ : undefined,
102
+ }
103
+ ),
104
+ [allowNewValues, id, options, value]
105
+ );
106
+
93
107
  useClientLayoutEffect(() => {
94
108
  if (
95
109
  shouldAutocomplete &&
96
- normalizeText(searchTerm) !== "" &&
110
+ filteredOptionsUtils.normalizeText(searchTerm) !== "" &&
97
111
  (previousSearchTerm?.length || 0) < searchTerm.length &&
98
- filteredOptions.length > 0 &&
99
- !isValueInList(searchTerm, filteredOptions)
112
+ filteredOptions.length > 0
100
113
  ) {
101
114
  setValue(
102
115
  `${searchTerm}${filteredOptions[0].substring(searchTerm.length)}`
@@ -116,29 +129,30 @@ export const FilteredOptionsProvider = ({ children, value: props }) => {
116
129
  return isExternalListOpen ?? isInternalListOpen;
117
130
  }, [isExternalListOpen, isInternalListOpen]);
118
131
 
119
- const toggleIsListOpen = useCallback((newState?: boolean) => {
120
- setFilteredOptionsIndex(null);
121
- setInternalListOpen((oldState) => newState ?? !oldState);
122
- }, []);
132
+ const toggleIsListOpen = useCallback(
133
+ (newState?: boolean) => {
134
+ virtualFocus.moveFocusToTop();
135
+ setInternalListOpen((oldState) => newState ?? !oldState);
136
+ },
137
+ [virtualFocus]
138
+ );
123
139
 
124
140
  const isValueNew = useMemo(
125
- () => Boolean(value) && !isValueInList(value, filteredOptions),
126
- [value, filteredOptions]
141
+ () =>
142
+ Boolean(value) &&
143
+ !filteredOptionsMap[filteredOptionsUtils.getOptionId(id, value)],
144
+ [filteredOptionsMap, id, value]
127
145
  );
128
146
 
129
- const getMinimumIndex = useCallback(() => {
130
- return isValueNew && allowNewValues ? -1 : 0;
131
- }, [allowNewValues, isValueNew]);
132
-
133
147
  const ariaDescribedBy = useMemo(() => {
134
148
  let activeOption;
135
149
  if (!isLoading && filteredOptions.length === 0) {
136
- activeOption = `${id}-no-hits`;
150
+ activeOption = filteredOptionsUtils.getNoHitsId(id);
137
151
  } else if ((value && value !== "") || isLoading) {
138
152
  if (shouldAutocomplete && filteredOptions[0]) {
139
- activeOption = `${id}-option-${filteredOptions[0].replace(" ", "-")}`;
153
+ activeOption = filteredOptionsUtils.getOptionId(id, filteredOptions[0]);
140
154
  } else if (isListOpen && isLoading) {
141
- activeOption = `${id}-is-loading`;
155
+ activeOption = filteredOptionsUtils.getIsLoadingId(id);
142
156
  }
143
157
  }
144
158
  return cl(activeOption, partialAriaDescribedBy) || undefined;
@@ -152,102 +166,21 @@ export const FilteredOptionsProvider = ({ children, value: props }) => {
152
166
  id,
153
167
  ]);
154
168
 
155
- const currentOption = useMemo(() => {
156
- if (filteredOptionsIndex == null) {
157
- return null;
158
- }
159
- if (filteredOptionsIndex === -1) {
160
- return value;
161
- }
162
- return filteredOptions[filteredOptionsIndex];
163
- }, [filteredOptionsIndex, filteredOptions, value]);
164
-
165
- const resetFilteredOptionsIndex = () => {
166
- setFilteredOptionsIndex(getMinimumIndex());
167
- };
168
-
169
- const scrollToOption = useCallback((newIndex: number) => {
170
- if (
171
- filteredOptionsRef.current &&
172
- filteredOptionsRef.current.children[newIndex]
173
- ) {
174
- const child = filteredOptionsRef.current.children[newIndex];
175
- const { top, bottom } = child.getBoundingClientRect();
176
- const parentRect = filteredOptionsRef.current.getBoundingClientRect();
177
- if (top < parentRect.top || bottom > parentRect.bottom) {
178
- child.scrollIntoView({ block: "nearest" });
179
- }
180
- }
181
- }, []);
182
-
183
- useEffect(() => {
184
- if (filteredOptionsIndex !== null && isListOpen) {
185
- scrollToOption(filteredOptionsIndex);
186
- }
187
- }, [filteredOptionsIndex, isListOpen, scrollToOption]);
188
-
189
- const moveFocusToInput = useCallback(() => {
190
- setFilteredOptionsIndex(null);
191
- toggleIsListOpen(false);
192
- }, [toggleIsListOpen]);
193
-
194
- const moveFocusToEnd = useCallback(() => {
195
- const lastIndex = filteredOptions.length - 1;
196
- toggleIsListOpen(true);
197
- setFilteredOptionsIndex(lastIndex);
198
- }, [filteredOptions.length, toggleIsListOpen]);
199
-
200
- const moveFocusUp = useCallback(() => {
201
- if (filteredOptionsIndex === null) {
202
- return;
203
- }
204
- if (filteredOptionsIndex === getMinimumIndex()) {
205
- toggleIsListOpen(false);
206
- setFilteredOptionsIndex(null);
207
- } else {
208
- const newIndex = Math.max(getMinimumIndex(), filteredOptionsIndex - 1);
209
- setFilteredOptionsIndex(newIndex);
210
- }
211
- }, [filteredOptionsIndex, getMinimumIndex, toggleIsListOpen]);
212
-
213
- const moveFocusDown = useCallback(() => {
214
- if (filteredOptionsIndex === null || !isListOpen) {
215
- toggleIsListOpen(true);
216
- if (allowNewValues || filteredOptions.length >= 1) {
217
- setFilteredOptionsIndex(getMinimumIndex());
218
- }
219
- return;
220
- }
221
- const newIndex = Math.min(
222
- filteredOptionsIndex + 1,
223
- Math.max(getMinimumIndex(), filteredOptions.length - 1)
224
- );
225
- setFilteredOptionsIndex(newIndex);
226
- }, [
227
- allowNewValues,
228
- filteredOptions.length,
229
- filteredOptionsIndex,
230
- getMinimumIndex,
231
- isListOpen,
232
- toggleIsListOpen,
233
- ]);
169
+ const currentOption = useMemo(
170
+ () =>
171
+ filteredOptionsMap[virtualFocus.activeElement?.getAttribute("id") || -1],
172
+ [filteredOptionsMap, virtualFocus]
173
+ );
234
174
 
235
- const activeDecendantId = useMemo(() => {
236
- if (filteredOptionsIndex === null) {
237
- return undefined;
238
- } else if (filteredOptionsIndex === -1) {
239
- return `${id}-combobox-new-option`;
240
- } else {
241
- return `${id}-option-${currentOption?.replace(" ", "-")}`;
242
- }
243
- }, [filteredOptionsIndex, currentOption, id]);
175
+ const activeDecendantId = useMemo(
176
+ () => virtualFocus.activeElement?.getAttribute("id") || undefined,
177
+ [virtualFocus.activeElement]
178
+ );
244
179
 
245
180
  const filteredOptionsState = {
246
181
  activeDecendantId,
247
182
  allowNewValues,
248
- filteredOptionsRef,
249
- filteredOptionsIndex,
250
- setFilteredOptionsIndex,
183
+ setFilteredOptionsRef,
251
184
  shouldAutocomplete,
252
185
  isListOpen,
253
186
  isLoading,
@@ -257,11 +190,7 @@ export const FilteredOptionsProvider = ({ children, value: props }) => {
257
190
  isValueNew,
258
191
  toggleIsListOpen,
259
192
  currentOption,
260
- resetFilteredOptionsIndex,
261
- moveFocusUp,
262
- moveFocusDown,
263
- moveFocusToInput,
264
- moveFocusToEnd,
193
+ virtualFocus,
265
194
  ariaDescribedBy,
266
195
  };
267
196
 
@@ -0,0 +1,87 @@
1
+ import { Dispatch, SetStateAction, useState } from "react";
2
+
3
+ export type VirtualFocusType = {
4
+ activeElement: HTMLElement | undefined;
5
+ getElementById: (id: string) => HTMLElement | undefined;
6
+ isFocusOnTheTop: boolean;
7
+ isFocusOnTheBottom: boolean;
8
+ setIndex: Dispatch<SetStateAction<number>>;
9
+ moveFocusUp: () => void;
10
+ moveFocusDown: () => void;
11
+ moveFocusToElement: (id: string) => void;
12
+ moveFocusToTop: () => void;
13
+ moveFocusToBottom: () => void;
14
+ };
15
+
16
+ const useVirtualFocus = (
17
+ containerRef: HTMLElement | null
18
+ ): VirtualFocusType => {
19
+ const [index, setIndex] = useState(-1);
20
+
21
+ const listOfAllChildren: Array<HTMLElement> = containerRef?.children
22
+ ? Array.prototype.slice.call(containerRef?.children)
23
+ : [];
24
+ const elementsAbleToReceiveFocus = listOfAllChildren.filter(
25
+ (child) => child.getAttribute("data-no-focus") !== "true"
26
+ );
27
+
28
+ const activeElement = elementsAbleToReceiveFocus[index];
29
+ const getElementById = (id: string) =>
30
+ listOfAllChildren.find((element) => element.id === id);
31
+ const isFocusOnTheTop = index === 0;
32
+ const isFocusOnTheBottom = index === elementsAbleToReceiveFocus.length - 1;
33
+
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
+ }
46
+ }
47
+ };
48
+
49
+ const _moveFocusAndScrollTo = (_index: number) => {
50
+ setIndex(_index);
51
+ scrollToOption(_index);
52
+ };
53
+ const moveFocusUp = () => _moveFocusAndScrollTo(Math.max(index - 1, -1));
54
+ const moveFocusDown = () =>
55
+ _moveFocusAndScrollTo(
56
+ Math.min(index + 1, elementsAbleToReceiveFocus.length - 1)
57
+ );
58
+ const moveFocusToTop = () => _moveFocusAndScrollTo(-1);
59
+ const moveFocusToBottom = () =>
60
+ _moveFocusAndScrollTo(elementsAbleToReceiveFocus.length - 1);
61
+ const moveFocusToElement = (id: string) => {
62
+ const thisElement = elementsAbleToReceiveFocus.find(
63
+ (_element) => _element.getAttribute("id") === id
64
+ );
65
+ const indexOfElement = thisElement
66
+ ? elementsAbleToReceiveFocus.indexOf(thisElement)
67
+ : -1;
68
+ if (indexOfElement >= 0) {
69
+ setIndex(indexOfElement);
70
+ }
71
+ };
72
+
73
+ return {
74
+ activeElement,
75
+ getElementById,
76
+ isFocusOnTheTop,
77
+ isFocusOnTheBottom,
78
+ setIndex,
79
+ moveFocusUp,
80
+ moveFocusDown,
81
+ moveFocusToElement,
82
+ moveFocusToTop,
83
+ moveFocusToBottom,
84
+ };
85
+ };
86
+
87
+ export default useVirtualFocus;
@@ -9,6 +9,7 @@ import cl from "clsx";
9
9
  import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
10
10
  import { useFilteredOptionsContext } from "../FilteredOptions/filteredOptionsContext";
11
11
  import { useInputContext } from "./inputContext";
12
+ import filteredOptionsUtil from "../FilteredOptions/filtered-options-util";
12
13
 
13
14
  interface InputProps
14
15
  extends Omit<InputHTMLAttributes<HTMLInputElement>, "value"> {
@@ -31,44 +32,55 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
31
32
  allowNewValues,
32
33
  currentOption,
33
34
  filteredOptions,
35
+ isValueNew,
34
36
  toggleIsListOpen,
35
37
  isListOpen,
36
- filteredOptionsIndex,
37
- moveFocusUp,
38
- moveFocusDown,
39
38
  ariaDescribedBy,
40
- moveFocusToInput,
41
- moveFocusToEnd,
42
- setFilteredOptionsIndex,
43
39
  setIsMouseLastUsedInputDevice,
44
40
  shouldAutocomplete,
41
+ virtualFocus,
45
42
  } = useFilteredOptionsContext();
46
43
 
47
44
  const onEnter = useCallback(
48
45
  (event: React.KeyboardEvent) => {
46
+ const isTextInSelectedOptions = (text: string) => {
47
+ return selectedOptions.find(
48
+ (item) => item.toLocaleLowerCase() === text.toLocaleLowerCase()
49
+ );
50
+ };
51
+
49
52
  if (currentOption) {
50
53
  event.preventDefault();
51
54
  // Selecting a value from the dropdown / FilteredOptions
52
55
  toggleOption(currentOption, event);
53
- if (!isMultiSelect && !selectedOptions.includes(currentOption))
56
+ if (!isMultiSelect && !isTextInSelectedOptions(currentOption)) {
54
57
  toggleIsListOpen(false);
55
- } else if (shouldAutocomplete && selectedOptions.includes(value)) {
58
+ }
59
+ } else if (shouldAutocomplete && isTextInSelectedOptions(value)) {
56
60
  event.preventDefault();
57
61
  // Trying to set the same value that is already set, so just clearing the input
58
62
  clearInput(event);
59
63
  } else if ((allowNewValues || shouldAutocomplete) && value !== "") {
60
64
  event.preventDefault();
61
65
  // Autocompleting or adding a new value
62
- toggleOption(value, event);
63
- if (!isMultiSelect && !selectedOptions.includes(value))
66
+ const selectedValue =
67
+ allowNewValues && isValueNew ? value : filteredOptions[0];
68
+ toggleOption(selectedValue, event);
69
+ if (
70
+ !isMultiSelect &&
71
+ !isTextInSelectedOptions(filteredOptions[0] || selectedValue)
72
+ ) {
64
73
  toggleIsListOpen(false);
74
+ }
65
75
  }
66
76
  },
67
77
  [
68
78
  allowNewValues,
69
79
  clearInput,
70
80
  currentOption,
81
+ filteredOptions,
71
82
  isMultiSelect,
83
+ isValueNew,
72
84
  selectedOptions,
73
85
  shouldAutocomplete,
74
86
  toggleIsListOpen,
@@ -89,10 +101,10 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
89
101
  onEnter(e);
90
102
  break;
91
103
  case "Home":
92
- moveFocusToInput();
104
+ virtualFocus.moveFocusToTop();
93
105
  break;
94
106
  case "End":
95
- moveFocusToEnd();
107
+ virtualFocus.moveFocusToBottom();
96
108
  break;
97
109
  default:
98
110
  break;
@@ -113,14 +125,20 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
113
125
  // so we don't interfere with text editing
114
126
  if (e.target.selectionStart === value?.length) {
115
127
  e.preventDefault();
116
- moveFocusDown();
128
+ if (virtualFocus.activeElement === null || !isListOpen) {
129
+ toggleIsListOpen(true);
130
+ }
131
+ virtualFocus.moveFocusDown();
117
132
  }
118
133
  } else if (e.key === "ArrowUp") {
119
134
  // Check that the FilteredOptions list is open and has virtual focus.
120
135
  // Otherwise ignore keystrokes, so it doesn't interfere with text editing
121
- if (isListOpen && filteredOptionsIndex !== null) {
136
+ if (isListOpen && activeDecendantId) {
122
137
  e.preventDefault();
123
- moveFocusUp();
138
+ if (virtualFocus.isFocusOnTheTop) {
139
+ toggleIsListOpen(false);
140
+ }
141
+ virtualFocus.moveFocusUp();
124
142
  }
125
143
  }
126
144
  },
@@ -128,11 +146,11 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
128
146
  value,
129
147
  selectedOptions,
130
148
  removeSelectedOption,
131
- moveFocusDown,
132
149
  isListOpen,
133
- filteredOptionsIndex,
134
- moveFocusUp,
150
+ activeDecendantId,
135
151
  setIsMouseLastUsedInputDevice,
152
+ toggleIsListOpen,
153
+ virtualFocus,
136
154
  ]
137
155
  );
138
156
 
@@ -144,13 +162,14 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
144
162
  } else if (filteredOptions.length === 0) {
145
163
  toggleIsListOpen(false);
146
164
  }
165
+ virtualFocus.moveFocusToTop();
147
166
  onChange(event);
148
167
  },
149
- [filteredOptions.length, onChange, toggleIsListOpen]
168
+ [filteredOptions.length, virtualFocus, onChange, toggleIsListOpen]
150
169
  );
151
170
 
152
171
  const onBlur = () => {
153
- setFilteredOptionsIndex(-1);
172
+ virtualFocus.moveFocusToTop();
154
173
  };
155
174
 
156
175
  return (
@@ -165,7 +184,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
165
184
  onBlur={onBlur}
166
185
  onKeyUp={handleKeyUp}
167
186
  onKeyDown={handleKeyDown}
168
- aria-controls={`${inputProps.id}-filtered-options`}
187
+ aria-controls={filteredOptionsUtil.getFilteredOptionsId(inputProps.id)}
169
188
  aria-expanded={!!isListOpen}
170
189
  autoComplete="off"
171
190
  aria-autocomplete={shouldAutocomplete ? "both" : "list"}
@@ -467,6 +467,50 @@ export const TestThatCallbacksOnlyFireWhenExpected: StoryObj<{
467
467
  },
468
468
  };
469
469
 
470
+ export const TestCasingWhenAutoCompleting = {
471
+ args: {
472
+ onChange: jest.fn(),
473
+ onClear: jest.fn(),
474
+ onToggleSelected: jest.fn(),
475
+ },
476
+ render: (props) => {
477
+ return (
478
+ <UNSAFE_Combobox
479
+ options={["Camel Case", "lowercase", "UPPERCASE"]}
480
+ label="Liker du best store eller små bokstaver?"
481
+ shouldAutocomplete
482
+ allowNewValues
483
+ {...props}
484
+ />
485
+ );
486
+ },
487
+ play: async ({ canvasElement }) => {
488
+ const canvas = within(canvasElement);
489
+ const input = canvas.getByRole<HTMLInputElement>("combobox");
490
+
491
+ // With exisiting option
492
+ userEvent.click(input);
493
+ await userEvent.type(input, "cAmEl CaSe", { delay: 250 });
494
+ await sleep(250);
495
+ expect(input.value).toBe("cAmEl CaSe");
496
+ await userEvent.type(input, "{Enter}");
497
+ await sleep(250);
498
+ const chips = canvas.getAllByRole("list")[0];
499
+ const selectedUpperCaseChip = within(chips).getAllByRole("listitem")[0];
500
+ expect(selectedUpperCaseChip).toHaveTextContent("Camel Case"); // A weird issue is preventing the accessible name from being used in the test, even if it works in VoiceOver
501
+
502
+ // With custom option
503
+ userEvent.click(input);
504
+ await userEvent.type(input, "cAmEl{Backspace}", { delay: 250 });
505
+ await sleep(250);
506
+ expect(input.value).toBe("cAmEl");
507
+ await userEvent.type(input, "{Enter}");
508
+ await sleep(250);
509
+ const selectedNewValueChip = within(chips).getAllByRole("listitem")[0];
510
+ expect(selectedNewValueChip).toHaveTextContent("cAmEl"); // A weird issue is preventing the accessible name from being used in the test, even if it works in VoiceOver
511
+ },
512
+ };
513
+
470
514
  export const TestHoverAndFocusSwitching: StoryObject = {
471
515
  render: () => {
472
516
  return (
@@ -1,6 +1,5 @@
1
1
  import React, { useState, useCallback, createContext, useContext } from "react";
2
2
  import { useInputContext } from "./Input/inputContext";
3
- import { useSelectedOptionsContext } from "./SelectedOptions/selectedOptionsContext";
4
3
 
5
4
  type CustomOptionsContextType = {
6
5
  customOptions: string[];
@@ -13,13 +12,19 @@ const CustomOptionsContext = createContext<CustomOptionsContextType>(
13
12
  {} as CustomOptionsContextType
14
13
  );
15
14
 
16
- export const CustomOptionsProvider = ({ children }) => {
15
+ export const CustomOptionsProvider = ({
16
+ children,
17
+ value,
18
+ }: {
19
+ children: any;
20
+ value: { isMultiSelect?: boolean };
21
+ }) => {
17
22
  const [customOptions, setCustomOptions] = useState<string[]>([]);
18
23
  const { focusInput } = useInputContext();
19
- const { isMultiSelect } = useSelectedOptionsContext();
24
+ const { isMultiSelect } = value;
20
25
 
21
26
  const removeCustomOption = useCallback(
22
- (option) => {
27
+ (option: string) => {
23
28
  setCustomOptions((prevCustomOptions) =>
24
29
  prevCustomOptions.filter((o) => o !== option)
25
30
  );
@@ -29,7 +34,7 @@ export const CustomOptionsProvider = ({ children }) => {
29
34
  );
30
35
 
31
36
  const addCustomOption = useCallback(
32
- (option) => {
37
+ (option: string) => {
33
38
  if (isMultiSelect) {
34
39
  setCustomOptions((prevOptions) => [...prevOptions, option]);
35
40
  } else {
@@ -1,7 +1,7 @@
1
+ import { InformationIcon } from "@navikt/aksel-icons";
2
+ import { Meta } from "@storybook/react";
1
3
  import React from "react";
2
4
  import { BodyLong, GuidePanel, VStack } from "../index";
3
- import { Meta } from "@storybook/react";
4
- import { InformationIcon } from "@navikt/aksel-icons";
5
5
 
6
6
  export default {
7
7
  title: "ds-react/GuidePanel",