@navikt/ds-react 4.6.1 → 4.7.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 (113) hide show
  1. package/_docs.json +1711 -169
  2. package/cjs/chips/Chips.js +1 -2
  3. package/cjs/form/combobox/ClearButton.js +27 -0
  4. package/cjs/form/combobox/Combobox.js +78 -0
  5. package/cjs/form/combobox/ComboboxProvider.js +99 -0
  6. package/cjs/form/combobox/ComboboxWrapper.js +51 -0
  7. package/cjs/form/combobox/FilteredOptions/CheckIcon.js +11 -0
  8. package/cjs/form/combobox/FilteredOptions/FilteredOptions.js +46 -0
  9. package/cjs/form/combobox/FilteredOptions/filteredOptionsContext.js +208 -0
  10. package/cjs/form/combobox/Input/Input.js +143 -0
  11. package/cjs/form/combobox/Input/inputContext.js +86 -0
  12. package/cjs/form/combobox/SelectedOptions/SelectedOptions.js +27 -0
  13. package/cjs/form/combobox/SelectedOptions/selectedOptionsContext.js +107 -0
  14. package/cjs/form/combobox/ToggleListButton.js +36 -0
  15. package/cjs/form/combobox/customOptionsContext.js +56 -0
  16. package/cjs/form/combobox/index.js +8 -0
  17. package/cjs/form/combobox/package.json +6 -0
  18. package/cjs/form/combobox/types.js +2 -0
  19. package/cjs/form/index.js +3 -1
  20. package/cjs/timeline/AxisLabels.js +12 -12
  21. package/cjs/timeline/Timeline.js +2 -2
  22. package/cjs/util/usePrevious.js +18 -0
  23. package/esm/chips/Chips.js +1 -2
  24. package/esm/chips/Chips.js.map +1 -1
  25. package/esm/date/datepicker/TableHead.d.ts +1 -0
  26. package/esm/form/Fieldset/useFieldset.d.ts +1 -1
  27. package/esm/form/checkbox/useCheckbox.d.ts +4 -4
  28. package/esm/form/combobox/ClearButton.d.ts +7 -0
  29. package/esm/form/combobox/ClearButton.js +21 -0
  30. package/esm/form/combobox/ClearButton.js.map +1 -0
  31. package/esm/form/combobox/Combobox.d.ts +4 -0
  32. package/esm/form/combobox/Combobox.js +50 -0
  33. package/esm/form/combobox/Combobox.js.map +1 -0
  34. package/esm/form/combobox/ComboboxProvider.d.ts +26 -0
  35. package/esm/form/combobox/ComboboxProvider.js +72 -0
  36. package/esm/form/combobox/ComboboxProvider.js.map +1 -0
  37. package/esm/form/combobox/ComboboxWrapper.d.ts +14 -0
  38. package/esm/form/combobox/ComboboxWrapper.js +24 -0
  39. package/esm/form/combobox/ComboboxWrapper.js.map +1 -0
  40. package/esm/form/combobox/FilteredOptions/CheckIcon.d.ts +3 -0
  41. package/esm/form/combobox/FilteredOptions/CheckIcon.js +7 -0
  42. package/esm/form/combobox/FilteredOptions/CheckIcon.js.map +1 -0
  43. package/esm/form/combobox/FilteredOptions/FilteredOptions.d.ts +3 -0
  44. package/esm/form/combobox/FilteredOptions/FilteredOptions.js +42 -0
  45. package/esm/form/combobox/FilteredOptions/FilteredOptions.js.map +1 -0
  46. package/esm/form/combobox/FilteredOptions/filteredOptionsContext.d.ts +27 -0
  47. package/esm/form/combobox/FilteredOptions/filteredOptionsContext.js +178 -0
  48. package/esm/form/combobox/FilteredOptions/filteredOptionsContext.js.map +1 -0
  49. package/esm/form/combobox/Input/Input.d.ts +10 -0
  50. package/esm/form/combobox/Input/Input.js +116 -0
  51. package/esm/form/combobox/Input/Input.js.map +1 -0
  52. package/esm/form/combobox/Input/inputContext.d.ts +19 -0
  53. package/esm/form/combobox/Input/inputContext.js +59 -0
  54. package/esm/form/combobox/Input/inputContext.js.map +1 -0
  55. package/esm/form/combobox/SelectedOptions/SelectedOptions.d.ts +8 -0
  56. package/esm/form/combobox/SelectedOptions/SelectedOptions.js +23 -0
  57. package/esm/form/combobox/SelectedOptions/SelectedOptions.js.map +1 -0
  58. package/esm/form/combobox/SelectedOptions/selectedOptionsContext.d.ts +17 -0
  59. package/esm/form/combobox/SelectedOptions/selectedOptionsContext.js +77 -0
  60. package/esm/form/combobox/SelectedOptions/selectedOptionsContext.js.map +1 -0
  61. package/esm/form/combobox/ToggleListButton.d.ts +6 -0
  62. package/esm/form/combobox/ToggleListButton.js +11 -0
  63. package/esm/form/combobox/ToggleListButton.js.map +1 -0
  64. package/esm/form/combobox/customOptionsContext.d.ts +11 -0
  65. package/esm/form/combobox/customOptionsContext.js +29 -0
  66. package/esm/form/combobox/customOptionsContext.js.map +1 -0
  67. package/esm/form/combobox/index.d.ts +2 -0
  68. package/esm/form/combobox/index.js +2 -0
  69. package/esm/form/combobox/index.js.map +1 -0
  70. package/esm/form/combobox/types.d.ts +119 -0
  71. package/esm/form/combobox/types.js +2 -0
  72. package/esm/form/combobox/types.js.map +1 -0
  73. package/esm/form/index.d.ts +1 -0
  74. package/esm/form/index.js +1 -0
  75. package/esm/form/index.js.map +1 -1
  76. package/esm/form/radio/useRadio.d.ts +4 -4
  77. package/esm/form/useFormField.d.ts +11 -10
  78. package/esm/form/useFormField.js.map +1 -1
  79. package/esm/timeline/AxisLabels.d.ts +7 -5
  80. package/esm/timeline/AxisLabels.js +12 -12
  81. package/esm/timeline/AxisLabels.js.map +1 -1
  82. package/esm/timeline/Timeline.d.ts +6 -0
  83. package/esm/timeline/Timeline.js +2 -2
  84. package/esm/timeline/Timeline.js.map +1 -1
  85. package/esm/timeline/utils/types.external.d.ts +5 -0
  86. package/esm/util/usePrevious.d.ts +2 -0
  87. package/esm/util/usePrevious.js +17 -0
  88. package/esm/util/usePrevious.js.map +1 -0
  89. package/package.json +2 -2
  90. package/src/chips/Chips.tsx +1 -1
  91. package/src/form/combobox/ClearButton.tsx +29 -0
  92. package/src/form/combobox/Combobox.tsx +136 -0
  93. package/src/form/combobox/ComboboxProvider.tsx +99 -0
  94. package/src/form/combobox/ComboboxWrapper.tsx +63 -0
  95. package/src/form/combobox/FilteredOptions/CheckIcon.tsx +23 -0
  96. package/src/form/combobox/FilteredOptions/FilteredOptions.tsx +106 -0
  97. package/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx +266 -0
  98. package/src/form/combobox/Input/Input.tsx +170 -0
  99. package/src/form/combobox/Input/inputContext.tsx +127 -0
  100. package/src/form/combobox/SelectedOptions/SelectedOptions.tsx +45 -0
  101. package/src/form/combobox/SelectedOptions/selectedOptionsContext.tsx +147 -0
  102. package/src/form/combobox/ToggleListButton.tsx +37 -0
  103. package/src/form/combobox/combobox.stories.tsx +413 -0
  104. package/src/form/combobox/combobox.test.tsx +123 -0
  105. package/src/form/combobox/customOptionsContext.tsx +57 -0
  106. package/src/form/combobox/index.ts +2 -0
  107. package/src/form/combobox/types.ts +122 -0
  108. package/src/form/index.ts +1 -0
  109. package/src/form/useFormField.ts +19 -1
  110. package/src/timeline/AxisLabels.tsx +23 -13
  111. package/src/timeline/Timeline.tsx +18 -2
  112. package/src/timeline/utils/types.external.ts +6 -0
  113. package/src/util/usePrevious.ts +19 -0
@@ -0,0 +1,106 @@
1
+ import React from "react";
2
+ import cl from "clsx";
3
+ import { BodyShort, Label, Loader } from "../../..";
4
+ import { CheckmarkIcon, PlusIcon } from "@navikt/aksel-icons";
5
+ import { useFilteredOptionsContext } from "./filteredOptionsContext";
6
+ import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
7
+ import { useInputContext } from "../Input/inputContext";
8
+
9
+ const FilteredOptions = () => {
10
+ const {
11
+ inputProps: { id },
12
+ size,
13
+ value,
14
+ } = useInputContext();
15
+ const {
16
+ allowNewValues,
17
+ isLoading,
18
+ isListOpen,
19
+ filteredOptions,
20
+ filteredOptionsIndex,
21
+ filteredOptionsRef,
22
+ isValueNew,
23
+ toggleIsListOpen,
24
+ } = useFilteredOptionsContext();
25
+ const { isMultiSelect, selectedOptions, toggleOption } =
26
+ useSelectedOptionsContext();
27
+
28
+ return (
29
+ <ul
30
+ ref={filteredOptionsRef}
31
+ className={cl("navds-combobox__list", {
32
+ "navds-combobox__list--closed": !isListOpen,
33
+ })}
34
+ id={`${id}-filtered-options`}
35
+ role="listbox"
36
+ tabIndex={-1}
37
+ >
38
+ {isLoading && (
39
+ <li
40
+ className="navds-combobox__list-item--loading"
41
+ role="option"
42
+ aria-selected={false}
43
+ id={`${id}-is-loading`}
44
+ >
45
+ <Loader aria-label="Søker..." />
46
+ </li>
47
+ )}
48
+ {isValueNew && allowNewValues && (
49
+ <li
50
+ tabIndex={-1}
51
+ onPointerUp={(event) => toggleOption(value, event)}
52
+ id={`${id}-combobox-new-option`}
53
+ className={cl("navds-combobox__list-item__new-option", {
54
+ "navds-combobox__list-item__new-option--focus":
55
+ filteredOptionsIndex === -1,
56
+ })}
57
+ role="option"
58
+ aria-selected={false}
59
+ >
60
+ <PlusIcon aria-hidden />
61
+ <BodyShort size={size}>
62
+ Legg til{" "}
63
+ <Label as="span" size={size}>
64
+ &#8220;{value}&#8221;
65
+ </Label>
66
+ </BodyShort>
67
+ </li>
68
+ )}
69
+ {!isLoading && filteredOptions.length === 0 && (
70
+ <li
71
+ className="navds-combobox__list-item__no-options"
72
+ role="option"
73
+ aria-selected={false}
74
+ id={`${id}-no-hits`}
75
+ >
76
+ Ingen søketreff
77
+ </li>
78
+ )}
79
+ {filteredOptions.map((option, index) => (
80
+ <li
81
+ className={cl("navds-combobox__list-item", {
82
+ "navds-combobox__list-item--focus": index === filteredOptionsIndex,
83
+ "navds-combobox__list-item--selected":
84
+ selectedOptions.includes(option),
85
+ })}
86
+ id={`${id}-option-${option.replace(" ", "-")}`}
87
+ key={option}
88
+ tabIndex={-1}
89
+ onPointerUp={(event) => {
90
+ toggleOption(option, event);
91
+ if (!isMultiSelect) {
92
+ toggleIsListOpen(false);
93
+ }
94
+ }}
95
+ role="option"
96
+ aria-selected={selectedOptions.includes(option)}
97
+ >
98
+ <BodyShort size={size}>{option}</BodyShort>
99
+ {selectedOptions.includes(option) && <CheckmarkIcon />}
100
+ </li>
101
+ ))}
102
+ </ul>
103
+ );
104
+ };
105
+
106
+ export default FilteredOptions;
@@ -0,0 +1,266 @@
1
+ import React, {
2
+ useState,
3
+ useEffect,
4
+ useMemo,
5
+ createContext,
6
+ useContext,
7
+ useCallback,
8
+ useRef,
9
+ useLayoutEffect,
10
+ } from "react";
11
+ import { useCustomOptionsContext } from "../customOptionsContext";
12
+ import { useInputContext } from "../Input/inputContext";
13
+ import usePrevious from "../../../util/usePrevious";
14
+
15
+ const normalizeText = (text: string): string =>
16
+ typeof text === "string" ? `${text}`.toLowerCase().trim() : "";
17
+
18
+ const isPartOfText = (value, text) =>
19
+ normalizeText(text).startsWith(normalizeText(value ?? ""));
20
+
21
+ const isValueInList = (value, list) =>
22
+ list?.find((listItem) => normalizeText(value) === normalizeText(listItem));
23
+
24
+ const getMatchingValuesFromList = (value, list) =>
25
+ list?.filter((listItem) => isPartOfText(value, listItem));
26
+
27
+ type FilteredOptionsContextType = {
28
+ activeDecendantId?: string;
29
+ allowNewValues?: boolean;
30
+ ariaDescribedBy?: string;
31
+ filteredOptionsRef: React.RefObject<HTMLUListElement>;
32
+ filteredOptionsIndex: number | null;
33
+ setFilteredOptionsIndex: (index: number) => void;
34
+ isListOpen: boolean;
35
+ isLoading?: boolean;
36
+ filteredOptions: string[];
37
+ isValueNew: boolean;
38
+ toggleIsListOpen: (newState?: boolean) => void;
39
+ currentOption: string | null;
40
+ resetFilteredOptionsIndex: () => void;
41
+ moveFocusUp: () => void;
42
+ moveFocusDown: () => void;
43
+ moveFocusToInput: () => void;
44
+ moveFocusToEnd: () => void;
45
+ shouldAutocomplete?: boolean;
46
+ };
47
+ const FilteredOptionsContext = createContext<FilteredOptionsContextType>(
48
+ {} as FilteredOptionsContextType
49
+ );
50
+
51
+ export const FilteredOptionsProvider = ({ children, value: props }) => {
52
+ const {
53
+ allowNewValues,
54
+ filteredOptions: externalFilteredOptions,
55
+ isListOpen: isExternalListOpen,
56
+ isLoading,
57
+ options,
58
+ } = props;
59
+ const filteredOptionsRef = useRef<HTMLUListElement | null>(null);
60
+ const {
61
+ inputProps: { id },
62
+ value,
63
+ searchTerm,
64
+ setValue,
65
+ setSearchTerm,
66
+ shouldAutocomplete,
67
+ } = useInputContext();
68
+
69
+ const [filteredOptionsIndex, setFilteredOptionsIndex] = useState<
70
+ number | null
71
+ >(null);
72
+ const [isInternalListOpen, setInternalListOpen] = useState(false);
73
+ const { customOptions } = useCustomOptionsContext();
74
+
75
+ const filteredOptions = useMemo(() => {
76
+ if (externalFilteredOptions) {
77
+ return externalFilteredOptions;
78
+ }
79
+ const opts = [...customOptions, ...options];
80
+ setFilteredOptionsIndex(null);
81
+ return getMatchingValuesFromList(searchTerm, opts);
82
+ }, [customOptions, externalFilteredOptions, options, searchTerm]);
83
+
84
+ const previousSearchTerm = usePrevious(searchTerm);
85
+
86
+ useLayoutEffect(() => {
87
+ if (
88
+ shouldAutocomplete &&
89
+ normalizeText(searchTerm) !== "" &&
90
+ (previousSearchTerm?.length || 0) < searchTerm.length &&
91
+ filteredOptions.length > 0 &&
92
+ !isValueInList(searchTerm, filteredOptions)
93
+ ) {
94
+ setValue(
95
+ `${searchTerm}${filteredOptions[0].substring(searchTerm.length)}`
96
+ );
97
+ setSearchTerm(searchTerm);
98
+ }
99
+ }, [
100
+ filteredOptions,
101
+ previousSearchTerm,
102
+ searchTerm,
103
+ setSearchTerm,
104
+ setValue,
105
+ shouldAutocomplete,
106
+ ]);
107
+
108
+ const isListOpen = useMemo(() => {
109
+ return isExternalListOpen ?? isInternalListOpen;
110
+ }, [isExternalListOpen, isInternalListOpen]);
111
+
112
+ const toggleIsListOpen = useCallback((newState?: boolean) => {
113
+ setFilteredOptionsIndex(null);
114
+ setInternalListOpen((oldState) => newState ?? !oldState);
115
+ }, []);
116
+
117
+ const isValueNew = useMemo(
118
+ () => Boolean(value) && !isValueInList(value, filteredOptions),
119
+ [value, filteredOptions]
120
+ );
121
+
122
+ const getMinimumIndex = useCallback(() => {
123
+ return isValueNew && allowNewValues ? -1 : 0;
124
+ }, [allowNewValues, isValueNew]);
125
+
126
+ const ariaDescribedBy = useMemo(() => {
127
+ if (!isLoading && filteredOptions.length === 0) {
128
+ return `${id}-no-hits`;
129
+ } else if ((value && value !== "") || isLoading) {
130
+ if (shouldAutocomplete && filteredOptions[0]) {
131
+ return `${id}-option-${filteredOptions[0].replace(" ", "-")}`;
132
+ } else if (isLoading) {
133
+ return `${id}-is-loading`;
134
+ }
135
+ } else {
136
+ return undefined;
137
+ }
138
+ }, [isLoading, value, shouldAutocomplete, filteredOptions, id]);
139
+
140
+ const currentOption = useMemo(() => {
141
+ if (filteredOptionsIndex == null) {
142
+ return null;
143
+ }
144
+ if (filteredOptionsIndex === -1) {
145
+ return value;
146
+ }
147
+ return filteredOptions[filteredOptionsIndex];
148
+ }, [filteredOptionsIndex, filteredOptions, value]);
149
+
150
+ const resetFilteredOptionsIndex = () => {
151
+ setFilteredOptionsIndex(getMinimumIndex());
152
+ };
153
+
154
+ const scrollToOption = useCallback((newIndex: number) => {
155
+ if (
156
+ filteredOptionsRef.current &&
157
+ filteredOptionsRef.current.children[newIndex]
158
+ ) {
159
+ const child = filteredOptionsRef.current.children[newIndex];
160
+ const { top, bottom } = child.getBoundingClientRect();
161
+ const parentRect = filteredOptionsRef.current.getBoundingClientRect();
162
+ if (top < parentRect.top || bottom > parentRect.bottom) {
163
+ child.scrollIntoView({ block: "nearest" });
164
+ }
165
+ }
166
+ }, []);
167
+
168
+ useEffect(() => {
169
+ if (filteredOptionsIndex !== null && isListOpen) {
170
+ scrollToOption(filteredOptionsIndex);
171
+ }
172
+ }, [filteredOptionsIndex, isListOpen, scrollToOption]);
173
+
174
+ const moveFocusToInput = useCallback(() => {
175
+ setFilteredOptionsIndex(null);
176
+ toggleIsListOpen(false);
177
+ }, [toggleIsListOpen]);
178
+
179
+ const moveFocusToEnd = useCallback(() => {
180
+ const lastIndex = filteredOptions.length - 1;
181
+ toggleIsListOpen(true);
182
+ setFilteredOptionsIndex(lastIndex);
183
+ }, [filteredOptions.length, toggleIsListOpen]);
184
+
185
+ const moveFocusUp = useCallback(() => {
186
+ if (filteredOptionsIndex === null) {
187
+ return;
188
+ }
189
+ if (filteredOptionsIndex === getMinimumIndex()) {
190
+ toggleIsListOpen(false);
191
+ setFilteredOptionsIndex(null);
192
+ } else {
193
+ const newIndex = Math.max(getMinimumIndex(), filteredOptionsIndex - 1);
194
+ setFilteredOptionsIndex(newIndex);
195
+ }
196
+ }, [filteredOptionsIndex, getMinimumIndex, toggleIsListOpen]);
197
+
198
+ const moveFocusDown = useCallback(() => {
199
+ if (filteredOptionsIndex === null || !isListOpen) {
200
+ toggleIsListOpen(true);
201
+ if (allowNewValues || filteredOptions.length >= 1) {
202
+ setFilteredOptionsIndex(getMinimumIndex());
203
+ }
204
+ return;
205
+ }
206
+ const newIndex = Math.min(
207
+ filteredOptionsIndex + 1,
208
+ Math.max(getMinimumIndex(), filteredOptions.length - 1)
209
+ );
210
+ setFilteredOptionsIndex(newIndex);
211
+ }, [
212
+ allowNewValues,
213
+ filteredOptions.length,
214
+ filteredOptionsIndex,
215
+ getMinimumIndex,
216
+ isListOpen,
217
+ toggleIsListOpen,
218
+ ]);
219
+
220
+ const activeDecendantId = useMemo(() => {
221
+ if (filteredOptionsIndex === null) {
222
+ return undefined;
223
+ } else if (filteredOptionsIndex === -1) {
224
+ return `${id}-combobox-new-option`;
225
+ } else {
226
+ return `${id}-option-${currentOption?.replace(" ", "-")}`;
227
+ }
228
+ }, [filteredOptionsIndex, currentOption, id]);
229
+
230
+ const filteredOptionsState = {
231
+ activeDecendantId,
232
+ allowNewValues,
233
+ filteredOptionsRef,
234
+ filteredOptionsIndex,
235
+ setFilteredOptionsIndex,
236
+ shouldAutocomplete,
237
+ isListOpen,
238
+ isLoading,
239
+ filteredOptions,
240
+ isValueNew,
241
+ toggleIsListOpen,
242
+ currentOption,
243
+ resetFilteredOptionsIndex,
244
+ moveFocusUp,
245
+ moveFocusDown,
246
+ moveFocusToInput,
247
+ moveFocusToEnd,
248
+ ariaDescribedBy,
249
+ };
250
+
251
+ return (
252
+ <FilteredOptionsContext.Provider value={filteredOptionsState}>
253
+ {children}
254
+ </FilteredOptionsContext.Provider>
255
+ );
256
+ };
257
+
258
+ export const useFilteredOptionsContext = () => {
259
+ const context = useContext(FilteredOptionsContext);
260
+ if (!context) {
261
+ throw new Error(
262
+ "useFilteredOptionsContext must be used within a FilteredOptionsProvider"
263
+ );
264
+ }
265
+ return context;
266
+ };
@@ -0,0 +1,170 @@
1
+ import { omit } from "../../..";
2
+ import React, {
3
+ useCallback,
4
+ forwardRef,
5
+ InputHTMLAttributes,
6
+ ChangeEvent,
7
+ } from "react";
8
+ import cl from "clsx";
9
+ import { useSelectedOptionsContext } from "../SelectedOptions/selectedOptionsContext";
10
+ import { useFilteredOptionsContext } from "../FilteredOptions/filteredOptionsContext";
11
+ import { useInputContext } from "./inputContext";
12
+
13
+ interface InputProps
14
+ extends Omit<InputHTMLAttributes<HTMLInputElement>, "value"> {
15
+ ref: React.Ref<HTMLInputElement>;
16
+ inputClassName?: string;
17
+ errorId?: string;
18
+ value?: string;
19
+ error?: React.ReactNode;
20
+ }
21
+
22
+ const Input = forwardRef<HTMLInputElement, InputProps>(
23
+ ({ inputClassName, error, errorId, ...rest }, ref) => {
24
+ const { clearInput, inputProps, onChange, size, value } = useInputContext();
25
+ const { selectedOptions, removeSelectedOption, toggleOption } =
26
+ useSelectedOptionsContext();
27
+ const {
28
+ activeDecendantId,
29
+ allowNewValues,
30
+ currentOption,
31
+ filteredOptions,
32
+ toggleIsListOpen,
33
+ isListOpen,
34
+ filteredOptionsIndex,
35
+ moveFocusUp,
36
+ moveFocusDown,
37
+ ariaDescribedBy,
38
+ moveFocusToInput,
39
+ moveFocusToEnd,
40
+ shouldAutocomplete,
41
+ } = useFilteredOptionsContext();
42
+
43
+ const onEnter = useCallback(
44
+ (event: React.KeyboardEvent) => {
45
+ if (currentOption) {
46
+ event.preventDefault();
47
+ // Selecting a value from the dropdown / FilteredOptions
48
+ toggleOption(currentOption, event);
49
+ clearInput(event);
50
+ } else if (shouldAutocomplete && selectedOptions.includes(value)) {
51
+ event.preventDefault();
52
+ // Trying to set the same value that is already set, so just clearing the input
53
+ clearInput(event);
54
+ } else if ((allowNewValues || shouldAutocomplete) && value !== "") {
55
+ event.preventDefault();
56
+ // Autocompleting or adding a new value
57
+ toggleOption(value, event);
58
+ clearInput(event);
59
+ }
60
+ },
61
+ [
62
+ allowNewValues,
63
+ clearInput,
64
+ currentOption,
65
+ selectedOptions,
66
+ shouldAutocomplete,
67
+ toggleOption,
68
+ value,
69
+ ]
70
+ );
71
+
72
+ const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
73
+ e.preventDefault();
74
+ switch (e.key) {
75
+ case "Escape":
76
+ clearInput(e);
77
+ toggleIsListOpen(false);
78
+ break;
79
+ case "Enter":
80
+ case "Accept":
81
+ onEnter(e);
82
+ break;
83
+ case "Home":
84
+ moveFocusToInput();
85
+ break;
86
+ case "End":
87
+ moveFocusToEnd();
88
+ break;
89
+ default:
90
+ break;
91
+ }
92
+ };
93
+
94
+ const handleKeyDown = useCallback(
95
+ (e) => {
96
+ if (e.key === "Backspace") {
97
+ if (value === "") {
98
+ const lastSelectedOption =
99
+ selectedOptions[selectedOptions.length - 1];
100
+ removeSelectedOption(lastSelectedOption);
101
+ }
102
+ } else if (e.key === "ArrowDown") {
103
+ // Check that cursor position is at the end of the input field,
104
+ // so we don't interfere with text editing
105
+ if (e.target.selectionStart === value?.length) {
106
+ e.preventDefault();
107
+ moveFocusDown();
108
+ }
109
+ } else if (e.key === "ArrowUp") {
110
+ // Check that the FilteredOptions list is open and has virtual focus.
111
+ // Otherwise ignore keystrokes, so it doesn't interfere with text editing
112
+ if (isListOpen && filteredOptionsIndex !== null) {
113
+ e.preventDefault();
114
+ moveFocusUp();
115
+ }
116
+ }
117
+ },
118
+ [
119
+ value,
120
+ selectedOptions,
121
+ removeSelectedOption,
122
+ moveFocusDown,
123
+ isListOpen,
124
+ filteredOptionsIndex,
125
+ moveFocusUp,
126
+ ]
127
+ );
128
+
129
+ const onChangeHandler = useCallback(
130
+ (event: ChangeEvent<HTMLInputElement>) => {
131
+ const newValue = event.target.value;
132
+ if (newValue && newValue !== "") {
133
+ toggleIsListOpen(true);
134
+ } else if (filteredOptions.length === 0) {
135
+ toggleIsListOpen(false);
136
+ }
137
+ onChange(event);
138
+ },
139
+ [filteredOptions.length, onChange, toggleIsListOpen]
140
+ );
141
+
142
+ return (
143
+ <input
144
+ {...rest}
145
+ {...omit(inputProps, ["aria-invalid"])}
146
+ ref={ref}
147
+ value={value}
148
+ onChange={onChangeHandler}
149
+ type="text"
150
+ role="combobox"
151
+ onKeyUp={handleKeyUp}
152
+ onKeyDown={handleKeyDown}
153
+ aria-controls={`${inputProps.id}-filtered-options`}
154
+ aria-expanded={!!isListOpen}
155
+ autoComplete="off"
156
+ aria-autocomplete={shouldAutocomplete ? "both" : "list"}
157
+ aria-activedescendant={activeDecendantId}
158
+ aria-describedby={ariaDescribedBy}
159
+ className={cl(
160
+ inputClassName,
161
+ "navds-combobox__input",
162
+ "navds-body-short",
163
+ `navds-body-${size}`
164
+ )}
165
+ />
166
+ );
167
+ }
168
+ );
169
+
170
+ export default Input;
@@ -0,0 +1,127 @@
1
+ import React, {
2
+ ChangeEvent,
3
+ ChangeEventHandler,
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useLayoutEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import { useFormField, FormFieldType } from "../../useFormField";
13
+
14
+ interface InputContextType extends FormFieldType {
15
+ clearInput: (event: React.PointerEvent | React.KeyboardEvent) => void;
16
+ focusInput: () => void;
17
+ inputRef: React.RefObject<HTMLInputElement>;
18
+ value: string;
19
+ setValue: (text: string) => void;
20
+ onChange: ChangeEventHandler<HTMLInputElement>;
21
+ searchTerm: string;
22
+ setSearchTerm: React.Dispatch<React.SetStateAction<string>>;
23
+ shouldAutocomplete?: boolean;
24
+ }
25
+
26
+ const InputContext = createContext<InputContextType>({} as InputContextType);
27
+
28
+ export const InputContextProvider = ({ children, value: props }) => {
29
+ const {
30
+ defaultValue = "",
31
+ description,
32
+ disabled,
33
+ error,
34
+ errorId,
35
+ id: externalId,
36
+ value: externalValue,
37
+ onChange: externalOnChange,
38
+ onClear,
39
+ shouldAutocomplete,
40
+ size,
41
+ } = props;
42
+ const formFieldProps = useFormField(
43
+ {
44
+ description,
45
+ disabled,
46
+ error,
47
+ errorId,
48
+ id: externalId,
49
+ size,
50
+ },
51
+ "comboboxfield"
52
+ );
53
+ const inputRef = useRef<HTMLInputElement | null>(null);
54
+ const [internalValue, setInternalValue] = useState<string>(defaultValue);
55
+
56
+ const value = useMemo(
57
+ () => String(externalValue ?? internalValue),
58
+ [externalValue, internalValue]
59
+ );
60
+
61
+ const [searchTerm, setSearchTerm] = useState(value);
62
+
63
+ const onChange = useCallback(
64
+ (event: ChangeEvent<HTMLInputElement>) => {
65
+ const value = event.currentTarget.value;
66
+ externalValue ?? setInternalValue(value);
67
+ externalOnChange?.(event);
68
+ setSearchTerm(value);
69
+ },
70
+ [externalValue, externalOnChange]
71
+ );
72
+
73
+ const setValue = useCallback(
74
+ (text) => {
75
+ setInternalValue(text);
76
+ },
77
+ [setInternalValue]
78
+ );
79
+
80
+ const clearInput = useCallback(
81
+ (event: React.PointerEvent | React.KeyboardEvent) => {
82
+ onClear?.(event);
83
+ setValue("");
84
+ setSearchTerm("");
85
+ },
86
+ [onClear, setSearchTerm, setValue]
87
+ );
88
+
89
+ const focusInput = useCallback(() => {
90
+ inputRef.current?.focus?.();
91
+ }, []);
92
+
93
+ useLayoutEffect(() => {
94
+ if (shouldAutocomplete && inputRef && value !== searchTerm) {
95
+ inputRef.current?.setSelectionRange?.(searchTerm.length, value.length);
96
+ }
97
+ }, [value, searchTerm, shouldAutocomplete]);
98
+
99
+ return (
100
+ <InputContext.Provider
101
+ value={{
102
+ ...formFieldProps,
103
+ clearInput,
104
+ focusInput,
105
+ inputRef,
106
+ value,
107
+ setValue,
108
+ onChange,
109
+ searchTerm,
110
+ setSearchTerm,
111
+ shouldAutocomplete,
112
+ }}
113
+ >
114
+ {children}
115
+ </InputContext.Provider>
116
+ );
117
+ };
118
+
119
+ export const useInputContext = () => {
120
+ const context = useContext(InputContext);
121
+ if (!context) {
122
+ throw new Error(
123
+ "useInputContext must be used within an InputContextProvider"
124
+ );
125
+ }
126
+ return context;
127
+ };
@@ -0,0 +1,45 @@
1
+ import React from "react";
2
+ import { Chips } from "../../..";
3
+ import { useSelectedOptionsContext } from "./selectedOptionsContext";
4
+
5
+ interface SelectedOptionsProps {
6
+ selectedOptions?: string[];
7
+ size?: "medium" | "small";
8
+ children: React.ReactNode;
9
+ }
10
+
11
+ const Option = ({ option }: { option: string }) => {
12
+ const { isMultiSelect, removeSelectedOption } = useSelectedOptionsContext();
13
+
14
+ const onClick = (e) => {
15
+ e.stopPropagation();
16
+ removeSelectedOption(option);
17
+ };
18
+
19
+ if (!isMultiSelect) {
20
+ return (
21
+ <div className="navds-combobox__selected-options--no-bg">{option}</div>
22
+ );
23
+ }
24
+
25
+ return <Chips.Removable onClick={onClick}>{option}</Chips.Removable>;
26
+ };
27
+
28
+ const SelectedOptions: React.FC<SelectedOptionsProps> = ({
29
+ selectedOptions = [],
30
+ size,
31
+ children,
32
+ }) => {
33
+ return (
34
+ <Chips className="navds-combobox__selected-options" size={size}>
35
+ {selectedOptions.length
36
+ ? selectedOptions.map((option, i) => (
37
+ <Option key={option + i} option={option} />
38
+ ))
39
+ : []}
40
+ {children}
41
+ </Chips>
42
+ );
43
+ };
44
+
45
+ export default SelectedOptions;