@navikt/ds-react 5.8.0 → 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 (118) hide show
  1. package/_docs.json +1794 -1749
  2. package/cjs/date/context/useDateInputContext.js +1 -5
  3. package/cjs/date/datepicker/DatePicker.js +26 -25
  4. package/cjs/date/hooks/useDatepicker.js +9 -17
  5. package/cjs/date/hooks/useMonthPicker.js +9 -17
  6. package/cjs/date/hooks/useRangeDatepicker.js +9 -20
  7. package/cjs/date/monthpicker/MonthPicker.js +11 -6
  8. package/cjs/date/{DateInput.js → parts/DateInput.js} +14 -10
  9. package/cjs/date/parts/DateWrapper.js +55 -0
  10. package/cjs/date/utils/labels.js +77 -1
  11. package/cjs/form/combobox/Combobox.js +2 -2
  12. package/cjs/form/combobox/ComboboxProvider.js +1 -2
  13. package/cjs/form/combobox/FilteredOptions/FilteredOptions.js +15 -14
  14. package/cjs/form/combobox/FilteredOptions/filtered-options-util.js +24 -0
  15. package/cjs/form/combobox/FilteredOptions/filteredOptionsContext.js +23 -106
  16. package/cjs/form/combobox/FilteredOptions/useVirtualFocus.js +55 -0
  17. package/cjs/form/combobox/Input/Input.js +22 -13
  18. package/cjs/form/combobox/customOptionsContext.js +2 -3
  19. package/cjs/modal/Modal.js +4 -1
  20. package/cjs/popover/Popover.js +5 -7
  21. package/cjs/util/useMedia.js +30 -0
  22. package/esm/date/context/useDateInputContext.d.ts +6 -2
  23. package/esm/date/context/useDateInputContext.js +1 -5
  24. package/esm/date/context/useDateInputContext.js.map +1 -1
  25. package/esm/date/datepicker/DatePicker.d.ts +1 -1
  26. package/esm/date/datepicker/DatePicker.js +28 -27
  27. package/esm/date/datepicker/DatePicker.js.map +1 -1
  28. package/esm/date/datepicker/types.d.ts +0 -5
  29. package/esm/date/hooks/useDatepicker.d.ts +8 -5
  30. package/esm/date/hooks/useDatepicker.js +10 -18
  31. package/esm/date/hooks/useDatepicker.js.map +1 -1
  32. package/esm/date/hooks/useMonthPicker.d.ts +7 -4
  33. package/esm/date/hooks/useMonthPicker.js +10 -18
  34. package/esm/date/hooks/useMonthPicker.js.map +1 -1
  35. package/esm/date/hooks/useRangeDatepicker.d.ts +9 -3
  36. package/esm/date/hooks/useRangeDatepicker.js +10 -21
  37. package/esm/date/hooks/useRangeDatepicker.js.map +1 -1
  38. package/esm/date/index.d.ts +1 -1
  39. package/esm/date/index.js.map +1 -1
  40. package/esm/date/monthpicker/MonthPicker.d.ts +1 -1
  41. package/esm/date/monthpicker/MonthPicker.js +13 -8
  42. package/esm/date/monthpicker/MonthPicker.js.map +1 -1
  43. package/esm/date/monthpicker/types.d.ts +0 -5
  44. package/esm/date/{DateInput.d.ts → parts/DateInput.d.ts} +5 -1
  45. package/esm/date/{DateInput.js → parts/DateInput.js} +15 -11
  46. package/esm/date/parts/DateInput.js.map +1 -0
  47. package/esm/date/parts/DateWrapper.d.ts +15 -0
  48. package/esm/date/parts/DateWrapper.js +26 -0
  49. package/esm/date/parts/DateWrapper.js.map +1 -0
  50. package/esm/date/utils/labels.d.ts +2 -0
  51. package/esm/date/utils/labels.js +74 -0
  52. package/esm/date/utils/labels.js.map +1 -1
  53. package/esm/form/combobox/Combobox.js +2 -2
  54. package/esm/form/combobox/Combobox.js.map +1 -1
  55. package/esm/form/combobox/ComboboxProvider.js +1 -2
  56. package/esm/form/combobox/ComboboxProvider.js.map +1 -1
  57. package/esm/form/combobox/FilteredOptions/FilteredOptions.js +15 -14
  58. package/esm/form/combobox/FilteredOptions/FilteredOptions.js.map +1 -1
  59. package/esm/form/combobox/FilteredOptions/filtered-options-util.d.ts +12 -0
  60. package/esm/form/combobox/FilteredOptions/filtered-options-util.js +23 -0
  61. package/esm/form/combobox/FilteredOptions/filtered-options-util.js.map +1 -0
  62. package/esm/form/combobox/FilteredOptions/filteredOptionsContext.d.ts +10 -13
  63. package/esm/form/combobox/FilteredOptions/filteredOptionsContext.js +24 -107
  64. package/esm/form/combobox/FilteredOptions/filteredOptionsContext.js.map +1 -1
  65. package/esm/form/combobox/FilteredOptions/useVirtualFocus.d.ts +15 -0
  66. package/esm/form/combobox/FilteredOptions/useVirtualFocus.js +54 -0
  67. package/esm/form/combobox/FilteredOptions/useVirtualFocus.js.map +1 -0
  68. package/esm/form/combobox/Input/Input.js +22 -13
  69. package/esm/form/combobox/Input/Input.js.map +1 -1
  70. package/esm/form/combobox/customOptionsContext.d.ts +4 -1
  71. package/esm/form/combobox/customOptionsContext.js +2 -3
  72. package/esm/form/combobox/customOptionsContext.js.map +1 -1
  73. package/esm/modal/Modal.js +4 -1
  74. package/esm/modal/Modal.js.map +1 -1
  75. package/esm/popover/Popover.d.ts +0 -5
  76. package/esm/popover/Popover.js +5 -7
  77. package/esm/popover/Popover.js.map +1 -1
  78. package/esm/util/useMedia.d.ts +8 -0
  79. package/esm/util/useMedia.js +27 -0
  80. package/esm/util/useMedia.js.map +1 -0
  81. package/package.json +3 -3
  82. package/src/date/context/useDateInputContext.tsx +5 -5
  83. package/src/date/datepicker/DatePicker.tsx +58 -65
  84. package/src/date/datepicker/datepicker.stories.tsx +37 -46
  85. package/src/date/datepicker/types.ts +0 -5
  86. package/src/date/hooks/useDatepicker.tsx +20 -25
  87. package/src/date/hooks/useMonthPicker.tsx +18 -24
  88. package/src/date/hooks/useRangeDatepicker.tsx +27 -30
  89. package/src/date/index.ts +1 -1
  90. package/src/date/monthpicker/MonthPicker.tsx +39 -43
  91. package/src/date/monthpicker/types.ts +0 -5
  92. package/src/date/{DateInput.tsx → parts/DateInput.tsx} +23 -12
  93. package/src/date/parts/DateWrapper.tsx +80 -0
  94. package/src/date/utils/labels.ts +83 -0
  95. package/src/form/combobox/Combobox.tsx +2 -2
  96. package/src/form/combobox/ComboboxProvider.tsx +1 -2
  97. package/src/form/combobox/FilteredOptions/FilteredOptions.tsx +28 -16
  98. package/src/form/combobox/FilteredOptions/filtered-options-util.ts +38 -0
  99. package/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx +70 -140
  100. package/src/form/combobox/FilteredOptions/useVirtualFocus.ts +87 -0
  101. package/src/form/combobox/Input/Input.tsx +22 -18
  102. package/src/form/combobox/customOptionsContext.tsx +10 -5
  103. package/src/guide-panel/guidepanel.stories.tsx +2 -2
  104. package/src/modal/Modal.tsx +4 -1
  105. package/src/popover/Popover.tsx +4 -12
  106. package/src/util/__tests__/useMedia.test.tsx +19 -0
  107. package/src/util/useMedia.ts +38 -0
  108. package/cjs/date/hooks/useEscape.js +0 -23
  109. package/cjs/date/hooks/useOutsideClickHandler.js +0 -26
  110. package/esm/date/DateInput.js.map +0 -1
  111. package/esm/date/hooks/useEscape.d.ts +0 -2
  112. package/esm/date/hooks/useEscape.js +0 -20
  113. package/esm/date/hooks/useEscape.js.map +0 -1
  114. package/esm/date/hooks/useOutsideClickHandler.d.ts +0 -1
  115. package/esm/date/hooks/useOutsideClickHandler.js +0 -23
  116. package/esm/date/hooks/useOutsideClickHandler.js.map +0 -1
  117. package/src/date/hooks/useEscape.tsx +0 -30
  118. 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,10 +88,26 @@ 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
112
  filteredOptions.length > 0
99
113
  ) {
@@ -115,29 +129,30 @@ export const FilteredOptionsProvider = ({ children, value: props }) => {
115
129
  return isExternalListOpen ?? isInternalListOpen;
116
130
  }, [isExternalListOpen, isInternalListOpen]);
117
131
 
118
- const toggleIsListOpen = useCallback((newState?: boolean) => {
119
- setFilteredOptionsIndex(null);
120
- setInternalListOpen((oldState) => newState ?? !oldState);
121
- }, []);
132
+ const toggleIsListOpen = useCallback(
133
+ (newState?: boolean) => {
134
+ virtualFocus.moveFocusToTop();
135
+ setInternalListOpen((oldState) => newState ?? !oldState);
136
+ },
137
+ [virtualFocus]
138
+ );
122
139
 
123
140
  const isValueNew = useMemo(
124
- () => Boolean(value) && !isValueInList(value, filteredOptions),
125
- [value, filteredOptions]
141
+ () =>
142
+ Boolean(value) &&
143
+ !filteredOptionsMap[filteredOptionsUtils.getOptionId(id, value)],
144
+ [filteredOptionsMap, id, value]
126
145
  );
127
146
 
128
- const getMinimumIndex = useCallback(() => {
129
- return isValueNew && allowNewValues ? -1 : 0;
130
- }, [allowNewValues, isValueNew]);
131
-
132
147
  const ariaDescribedBy = useMemo(() => {
133
148
  let activeOption;
134
149
  if (!isLoading && filteredOptions.length === 0) {
135
- activeOption = `${id}-no-hits`;
150
+ activeOption = filteredOptionsUtils.getNoHitsId(id);
136
151
  } else if ((value && value !== "") || isLoading) {
137
152
  if (shouldAutocomplete && filteredOptions[0]) {
138
- activeOption = `${id}-option-${filteredOptions[0].replace(" ", "-")}`;
153
+ activeOption = filteredOptionsUtils.getOptionId(id, filteredOptions[0]);
139
154
  } else if (isListOpen && isLoading) {
140
- activeOption = `${id}-is-loading`;
155
+ activeOption = filteredOptionsUtils.getIsLoadingId(id);
141
156
  }
142
157
  }
143
158
  return cl(activeOption, partialAriaDescribedBy) || undefined;
@@ -151,102 +166,21 @@ export const FilteredOptionsProvider = ({ children, value: props }) => {
151
166
  id,
152
167
  ]);
153
168
 
154
- const currentOption = useMemo(() => {
155
- if (filteredOptionsIndex == null) {
156
- return null;
157
- }
158
- if (filteredOptionsIndex === -1) {
159
- return value;
160
- }
161
- return filteredOptions[filteredOptionsIndex];
162
- }, [filteredOptionsIndex, filteredOptions, value]);
163
-
164
- const resetFilteredOptionsIndex = () => {
165
- setFilteredOptionsIndex(getMinimumIndex());
166
- };
167
-
168
- const scrollToOption = useCallback((newIndex: number) => {
169
- if (
170
- filteredOptionsRef.current &&
171
- filteredOptionsRef.current.children[newIndex]
172
- ) {
173
- const child = filteredOptionsRef.current.children[newIndex];
174
- const { top, bottom } = child.getBoundingClientRect();
175
- const parentRect = filteredOptionsRef.current.getBoundingClientRect();
176
- if (top < parentRect.top || bottom > parentRect.bottom) {
177
- child.scrollIntoView({ block: "nearest" });
178
- }
179
- }
180
- }, []);
181
-
182
- useEffect(() => {
183
- if (filteredOptionsIndex !== null && isListOpen) {
184
- scrollToOption(filteredOptionsIndex);
185
- }
186
- }, [filteredOptionsIndex, isListOpen, scrollToOption]);
187
-
188
- const moveFocusToInput = useCallback(() => {
189
- setFilteredOptionsIndex(null);
190
- toggleIsListOpen(false);
191
- }, [toggleIsListOpen]);
192
-
193
- const moveFocusToEnd = useCallback(() => {
194
- const lastIndex = filteredOptions.length - 1;
195
- toggleIsListOpen(true);
196
- setFilteredOptionsIndex(lastIndex);
197
- }, [filteredOptions.length, toggleIsListOpen]);
198
-
199
- const moveFocusUp = useCallback(() => {
200
- if (filteredOptionsIndex === null) {
201
- return;
202
- }
203
- if (filteredOptionsIndex === getMinimumIndex()) {
204
- toggleIsListOpen(false);
205
- setFilteredOptionsIndex(null);
206
- } else {
207
- const newIndex = Math.max(getMinimumIndex(), filteredOptionsIndex - 1);
208
- setFilteredOptionsIndex(newIndex);
209
- }
210
- }, [filteredOptionsIndex, getMinimumIndex, toggleIsListOpen]);
211
-
212
- const moveFocusDown = useCallback(() => {
213
- if (filteredOptionsIndex === null || !isListOpen) {
214
- toggleIsListOpen(true);
215
- if (allowNewValues || filteredOptions.length >= 1) {
216
- setFilteredOptionsIndex(getMinimumIndex());
217
- }
218
- return;
219
- }
220
- const newIndex = Math.min(
221
- filteredOptionsIndex + 1,
222
- Math.max(getMinimumIndex(), filteredOptions.length - 1)
223
- );
224
- setFilteredOptionsIndex(newIndex);
225
- }, [
226
- allowNewValues,
227
- filteredOptions.length,
228
- filteredOptionsIndex,
229
- getMinimumIndex,
230
- isListOpen,
231
- toggleIsListOpen,
232
- ]);
169
+ const currentOption = useMemo(
170
+ () =>
171
+ filteredOptionsMap[virtualFocus.activeElement?.getAttribute("id") || -1],
172
+ [filteredOptionsMap, virtualFocus]
173
+ );
233
174
 
234
- const activeDecendantId = useMemo(() => {
235
- if (filteredOptionsIndex === null) {
236
- return undefined;
237
- } else if (filteredOptionsIndex === -1) {
238
- return `${id}-combobox-new-option`;
239
- } else {
240
- return `${id}-option-${currentOption?.replace(" ", "-")}`;
241
- }
242
- }, [filteredOptionsIndex, currentOption, id]);
175
+ const activeDecendantId = useMemo(
176
+ () => virtualFocus.activeElement?.getAttribute("id") || undefined,
177
+ [virtualFocus.activeElement]
178
+ );
243
179
 
244
180
  const filteredOptionsState = {
245
181
  activeDecendantId,
246
182
  allowNewValues,
247
- filteredOptionsRef,
248
- filteredOptionsIndex,
249
- setFilteredOptionsIndex,
183
+ setFilteredOptionsRef,
250
184
  shouldAutocomplete,
251
185
  isListOpen,
252
186
  isLoading,
@@ -256,11 +190,7 @@ export const FilteredOptionsProvider = ({ children, value: props }) => {
256
190
  isValueNew,
257
191
  toggleIsListOpen,
258
192
  currentOption,
259
- resetFilteredOptionsIndex,
260
- moveFocusUp,
261
- moveFocusDown,
262
- moveFocusToInput,
263
- moveFocusToEnd,
193
+ virtualFocus,
264
194
  ariaDescribedBy,
265
195
  };
266
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"> {
@@ -34,15 +35,10 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
34
35
  isValueNew,
35
36
  toggleIsListOpen,
36
37
  isListOpen,
37
- filteredOptionsIndex,
38
- moveFocusUp,
39
- moveFocusDown,
40
38
  ariaDescribedBy,
41
- moveFocusToInput,
42
- moveFocusToEnd,
43
- setFilteredOptionsIndex,
44
39
  setIsMouseLastUsedInputDevice,
45
40
  shouldAutocomplete,
41
+ virtualFocus,
46
42
  } = useFilteredOptionsContext();
47
43
 
48
44
  const onEnter = useCallback(
@@ -57,8 +53,9 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
57
53
  event.preventDefault();
58
54
  // Selecting a value from the dropdown / FilteredOptions
59
55
  toggleOption(currentOption, event);
60
- if (!isMultiSelect && !isTextInSelectedOptions(currentOption))
56
+ if (!isMultiSelect && !isTextInSelectedOptions(currentOption)) {
61
57
  toggleIsListOpen(false);
58
+ }
62
59
  } else if (shouldAutocomplete && isTextInSelectedOptions(value)) {
63
60
  event.preventDefault();
64
61
  // Trying to set the same value that is already set, so just clearing the input
@@ -104,10 +101,10 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
104
101
  onEnter(e);
105
102
  break;
106
103
  case "Home":
107
- moveFocusToInput();
104
+ virtualFocus.moveFocusToTop();
108
105
  break;
109
106
  case "End":
110
- moveFocusToEnd();
107
+ virtualFocus.moveFocusToBottom();
111
108
  break;
112
109
  default:
113
110
  break;
@@ -128,14 +125,20 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
128
125
  // so we don't interfere with text editing
129
126
  if (e.target.selectionStart === value?.length) {
130
127
  e.preventDefault();
131
- moveFocusDown();
128
+ if (virtualFocus.activeElement === null || !isListOpen) {
129
+ toggleIsListOpen(true);
130
+ }
131
+ virtualFocus.moveFocusDown();
132
132
  }
133
133
  } else if (e.key === "ArrowUp") {
134
134
  // Check that the FilteredOptions list is open and has virtual focus.
135
135
  // Otherwise ignore keystrokes, so it doesn't interfere with text editing
136
- if (isListOpen && filteredOptionsIndex !== null) {
136
+ if (isListOpen && activeDecendantId) {
137
137
  e.preventDefault();
138
- moveFocusUp();
138
+ if (virtualFocus.isFocusOnTheTop) {
139
+ toggleIsListOpen(false);
140
+ }
141
+ virtualFocus.moveFocusUp();
139
142
  }
140
143
  }
141
144
  },
@@ -143,11 +146,11 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
143
146
  value,
144
147
  selectedOptions,
145
148
  removeSelectedOption,
146
- moveFocusDown,
147
149
  isListOpen,
148
- filteredOptionsIndex,
149
- moveFocusUp,
150
+ activeDecendantId,
150
151
  setIsMouseLastUsedInputDevice,
152
+ toggleIsListOpen,
153
+ virtualFocus,
151
154
  ]
152
155
  );
153
156
 
@@ -159,13 +162,14 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
159
162
  } else if (filteredOptions.length === 0) {
160
163
  toggleIsListOpen(false);
161
164
  }
165
+ virtualFocus.moveFocusToTop();
162
166
  onChange(event);
163
167
  },
164
- [filteredOptions.length, onChange, toggleIsListOpen]
168
+ [filteredOptions.length, virtualFocus, onChange, toggleIsListOpen]
165
169
  );
166
170
 
167
171
  const onBlur = () => {
168
- setFilteredOptionsIndex(-1);
172
+ virtualFocus.moveFocusToTop();
169
173
  };
170
174
 
171
175
  return (
@@ -180,7 +184,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
180
184
  onBlur={onBlur}
181
185
  onKeyUp={handleKeyUp}
182
186
  onKeyDown={handleKeyDown}
183
- aria-controls={`${inputProps.id}-filtered-options`}
187
+ aria-controls={filteredOptionsUtil.getFilteredOptionsId(inputProps.id)}
184
188
  aria-expanded={!!isListOpen}
185
189
  autoComplete="off"
186
190
  aria-autocomplete={shouldAutocomplete ? "both" : "list"}
@@ -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",
@@ -8,6 +8,7 @@ import React, {
8
8
  useRef,
9
9
  } from "react";
10
10
  import { createPortal } from "react-dom";
11
+ import { DateContext } from "../date/context";
11
12
  import { useProvider } from "../provider";
12
13
  import { Detail, Heading } from "../typography";
13
14
  import { mergeRefs, useId } from "../util";
@@ -99,7 +100,9 @@ export const Modal = forwardRef<HTMLDialogElement, ModalProps>(
99
100
  const rootElement = useProvider()?.rootElement;
100
101
  const portalNode = useFloatingPortalNode({ root: rootElement });
101
102
 
102
- if (useContext(ModalContext)) {
103
+ const dateContext = useContext(DateContext);
104
+ const modalContext = useContext(ModalContext);
105
+ if (modalContext && !dateContext) {
103
106
  console.error("Modals should not be nested");
104
107
  }
105
108
 
@@ -18,6 +18,7 @@ import React, {
18
18
  useMemo,
19
19
  useRef,
20
20
  } from "react";
21
+ import { DateContext } from "../date/context";
21
22
  import { ModalContext } from "../modal/ModalContext";
22
23
  import { mergeRefs, useClientLayoutEffect, useEventListener } from "../util";
23
24
  import PopoverContent, { PopoverContentType } from "./PopoverContent";
@@ -73,11 +74,6 @@ export interface PopoverProps extends HTMLAttributes<HTMLDivElement> {
73
74
  * @default "absolute"
74
75
  */
75
76
  strategy?: "absolute" | "fixed";
76
- /**
77
- * Bubbles Escape keydown-event up trough DOM-tree. This is set to false by default to prevent closing components like Modal on Escape
78
- * @default false
79
- */
80
- bubbleEscape?: boolean;
81
77
  /**
82
78
  * Changes placement of the floating element in order to keep it in view.
83
79
  * @default true
@@ -124,7 +120,6 @@ export const Popover = forwardRef<HTMLDivElement, PopoverProps>(
124
120
  placement = "top",
125
121
  offset,
126
122
  strategy: userStrategy,
127
- bubbleEscape = false,
128
123
  flip: _flip = true,
129
124
  ...rest
130
125
  },
@@ -132,8 +127,9 @@ export const Popover = forwardRef<HTMLDivElement, PopoverProps>(
132
127
  ) => {
133
128
  const arrowRef = useRef<HTMLDivElement | null>(null);
134
129
  const isInModal = useContext(ModalContext) !== null;
130
+ const isInDatepicker = useContext(DateContext) !== null;
135
131
  const chosenStrategy = userStrategy ?? (isInModal ? "fixed" : "absolute");
136
- const chosenFlip = isInModal ? true : _flip;
132
+ const chosenFlip = isInDatepicker ? false : _flip;
137
133
 
138
134
  const {
139
135
  x,
@@ -160,11 +156,7 @@ export const Popover = forwardRef<HTMLDivElement, PopoverProps>(
160
156
 
161
157
  const { getFloatingProps } = useInteractions([
162
158
  useClick(context),
163
- useDismiss(context, {
164
- bubbles: {
165
- escapeKey: bubbleEscape,
166
- },
167
- }),
159
+ useDismiss(context),
168
160
  ]);
169
161
 
170
162
  useClientLayoutEffect(() => {
@@ -0,0 +1,19 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import React from "react";
3
+ import { useMedia } from "../useMedia";
4
+
5
+ function TestComponent({ fallback }: { fallback?: boolean }) {
6
+ const media = useMedia("screen and (min-width: 1024px)", fallback);
7
+ return <div data-testid="media-id">{`${media}`}</div>;
8
+ }
9
+
10
+ describe("useMedia", () => {
11
+ test("Should return 'undefined' when no fallback is given", async () => {
12
+ render(<TestComponent />);
13
+ expect(screen.getByTestId("media-id").innerHTML).toEqual("undefined");
14
+ });
15
+ test("Should return fallback", async () => {
16
+ render(<TestComponent fallback={true} />);
17
+ expect(screen.getByTestId("media-id").innerHTML).toEqual("true");
18
+ });
19
+ });