@navikt/ds-react 4.6.0 → 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 (133) hide show
  1. package/_docs.json +1711 -169
  2. package/cjs/chips/Chips.js +1 -2
  3. package/cjs/date/DateInput.js +1 -0
  4. package/cjs/form/Select.js +1 -0
  5. package/cjs/form/TextField.js +1 -0
  6. package/cjs/form/Textarea.js +1 -0
  7. package/cjs/form/checkbox/Checkbox.js +1 -1
  8. package/cjs/form/combobox/ClearButton.js +27 -0
  9. package/cjs/form/combobox/Combobox.js +78 -0
  10. package/cjs/form/combobox/ComboboxProvider.js +99 -0
  11. package/cjs/form/combobox/ComboboxWrapper.js +51 -0
  12. package/cjs/form/combobox/FilteredOptions/CheckIcon.js +11 -0
  13. package/cjs/form/combobox/FilteredOptions/FilteredOptions.js +46 -0
  14. package/cjs/form/combobox/FilteredOptions/filteredOptionsContext.js +208 -0
  15. package/cjs/form/combobox/Input/Input.js +143 -0
  16. package/cjs/form/combobox/Input/inputContext.js +86 -0
  17. package/cjs/form/combobox/SelectedOptions/SelectedOptions.js +27 -0
  18. package/cjs/form/combobox/SelectedOptions/selectedOptionsContext.js +107 -0
  19. package/cjs/form/combobox/ToggleListButton.js +36 -0
  20. package/cjs/form/combobox/customOptionsContext.js +56 -0
  21. package/cjs/form/combobox/index.js +8 -0
  22. package/cjs/form/combobox/package.json +6 -0
  23. package/cjs/form/combobox/types.js +2 -0
  24. package/cjs/form/index.js +3 -1
  25. package/cjs/timeline/AxisLabels.js +12 -12
  26. package/cjs/timeline/Timeline.js +2 -2
  27. package/cjs/util/usePrevious.js +18 -0
  28. package/esm/chips/Chips.js +1 -2
  29. package/esm/chips/Chips.js.map +1 -1
  30. package/esm/date/DateInput.js +1 -0
  31. package/esm/date/DateInput.js.map +1 -1
  32. package/esm/date/datepicker/TableHead.d.ts +1 -0
  33. package/esm/form/Fieldset/useFieldset.d.ts +1 -1
  34. package/esm/form/Select.js +1 -0
  35. package/esm/form/Select.js.map +1 -1
  36. package/esm/form/TextField.js +1 -0
  37. package/esm/form/TextField.js.map +1 -1
  38. package/esm/form/Textarea.js +1 -0
  39. package/esm/form/Textarea.js.map +1 -1
  40. package/esm/form/checkbox/Checkbox.js +1 -1
  41. package/esm/form/checkbox/Checkbox.js.map +1 -1
  42. package/esm/form/checkbox/useCheckbox.d.ts +4 -4
  43. package/esm/form/combobox/ClearButton.d.ts +7 -0
  44. package/esm/form/combobox/ClearButton.js +21 -0
  45. package/esm/form/combobox/ClearButton.js.map +1 -0
  46. package/esm/form/combobox/Combobox.d.ts +4 -0
  47. package/esm/form/combobox/Combobox.js +50 -0
  48. package/esm/form/combobox/Combobox.js.map +1 -0
  49. package/esm/form/combobox/ComboboxProvider.d.ts +26 -0
  50. package/esm/form/combobox/ComboboxProvider.js +72 -0
  51. package/esm/form/combobox/ComboboxProvider.js.map +1 -0
  52. package/esm/form/combobox/ComboboxWrapper.d.ts +14 -0
  53. package/esm/form/combobox/ComboboxWrapper.js +24 -0
  54. package/esm/form/combobox/ComboboxWrapper.js.map +1 -0
  55. package/esm/form/combobox/FilteredOptions/CheckIcon.d.ts +3 -0
  56. package/esm/form/combobox/FilteredOptions/CheckIcon.js +7 -0
  57. package/esm/form/combobox/FilteredOptions/CheckIcon.js.map +1 -0
  58. package/esm/form/combobox/FilteredOptions/FilteredOptions.d.ts +3 -0
  59. package/esm/form/combobox/FilteredOptions/FilteredOptions.js +42 -0
  60. package/esm/form/combobox/FilteredOptions/FilteredOptions.js.map +1 -0
  61. package/esm/form/combobox/FilteredOptions/filteredOptionsContext.d.ts +27 -0
  62. package/esm/form/combobox/FilteredOptions/filteredOptionsContext.js +178 -0
  63. package/esm/form/combobox/FilteredOptions/filteredOptionsContext.js.map +1 -0
  64. package/esm/form/combobox/Input/Input.d.ts +10 -0
  65. package/esm/form/combobox/Input/Input.js +116 -0
  66. package/esm/form/combobox/Input/Input.js.map +1 -0
  67. package/esm/form/combobox/Input/inputContext.d.ts +19 -0
  68. package/esm/form/combobox/Input/inputContext.js +59 -0
  69. package/esm/form/combobox/Input/inputContext.js.map +1 -0
  70. package/esm/form/combobox/SelectedOptions/SelectedOptions.d.ts +8 -0
  71. package/esm/form/combobox/SelectedOptions/SelectedOptions.js +23 -0
  72. package/esm/form/combobox/SelectedOptions/SelectedOptions.js.map +1 -0
  73. package/esm/form/combobox/SelectedOptions/selectedOptionsContext.d.ts +17 -0
  74. package/esm/form/combobox/SelectedOptions/selectedOptionsContext.js +77 -0
  75. package/esm/form/combobox/SelectedOptions/selectedOptionsContext.js.map +1 -0
  76. package/esm/form/combobox/ToggleListButton.d.ts +6 -0
  77. package/esm/form/combobox/ToggleListButton.js +11 -0
  78. package/esm/form/combobox/ToggleListButton.js.map +1 -0
  79. package/esm/form/combobox/customOptionsContext.d.ts +11 -0
  80. package/esm/form/combobox/customOptionsContext.js +29 -0
  81. package/esm/form/combobox/customOptionsContext.js.map +1 -0
  82. package/esm/form/combobox/index.d.ts +2 -0
  83. package/esm/form/combobox/index.js +2 -0
  84. package/esm/form/combobox/index.js.map +1 -0
  85. package/esm/form/combobox/types.d.ts +119 -0
  86. package/esm/form/combobox/types.js +2 -0
  87. package/esm/form/combobox/types.js.map +1 -0
  88. package/esm/form/index.d.ts +1 -0
  89. package/esm/form/index.js +1 -0
  90. package/esm/form/index.js.map +1 -1
  91. package/esm/form/radio/useRadio.d.ts +4 -4
  92. package/esm/form/useFormField.d.ts +11 -10
  93. package/esm/form/useFormField.js.map +1 -1
  94. package/esm/timeline/AxisLabels.d.ts +7 -5
  95. package/esm/timeline/AxisLabels.js +12 -12
  96. package/esm/timeline/AxisLabels.js.map +1 -1
  97. package/esm/timeline/Timeline.d.ts +6 -0
  98. package/esm/timeline/Timeline.js +2 -2
  99. package/esm/timeline/Timeline.js.map +1 -1
  100. package/esm/timeline/utils/types.external.d.ts +5 -0
  101. package/esm/util/usePrevious.d.ts +2 -0
  102. package/esm/util/usePrevious.js +17 -0
  103. package/esm/util/usePrevious.js.map +1 -0
  104. package/package.json +2 -2
  105. package/src/chips/Chips.tsx +1 -1
  106. package/src/date/DateInput.tsx +1 -0
  107. package/src/form/Select.tsx +1 -0
  108. package/src/form/TextField.tsx +2 -0
  109. package/src/form/Textarea.tsx +1 -0
  110. package/src/form/checkbox/Checkbox.tsx +5 -1
  111. package/src/form/combobox/ClearButton.tsx +29 -0
  112. package/src/form/combobox/Combobox.tsx +136 -0
  113. package/src/form/combobox/ComboboxProvider.tsx +99 -0
  114. package/src/form/combobox/ComboboxWrapper.tsx +63 -0
  115. package/src/form/combobox/FilteredOptions/CheckIcon.tsx +23 -0
  116. package/src/form/combobox/FilteredOptions/FilteredOptions.tsx +106 -0
  117. package/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx +266 -0
  118. package/src/form/combobox/Input/Input.tsx +170 -0
  119. package/src/form/combobox/Input/inputContext.tsx +127 -0
  120. package/src/form/combobox/SelectedOptions/SelectedOptions.tsx +45 -0
  121. package/src/form/combobox/SelectedOptions/selectedOptionsContext.tsx +147 -0
  122. package/src/form/combobox/ToggleListButton.tsx +37 -0
  123. package/src/form/combobox/combobox.stories.tsx +413 -0
  124. package/src/form/combobox/combobox.test.tsx +123 -0
  125. package/src/form/combobox/customOptionsContext.tsx +57 -0
  126. package/src/form/combobox/index.ts +2 -0
  127. package/src/form/combobox/types.ts +122 -0
  128. package/src/form/index.ts +1 -0
  129. package/src/form/useFormField.ts +19 -1
  130. package/src/timeline/AxisLabels.tsx +23 -13
  131. package/src/timeline/Timeline.tsx +18 -2
  132. package/src/timeline/utils/types.external.ts +6 -0
  133. package/src/util/usePrevious.ts +19 -0
@@ -0,0 +1,136 @@
1
+ import cl from "clsx";
2
+ import React, { forwardRef, useMemo, useRef } from "react";
3
+ import { BodyShort, Label, mergeRefs } from "../..";
4
+ import ClearButton from "./ClearButton";
5
+ import FilteredOptions from "./FilteredOptions/FilteredOptions";
6
+ import { useFilteredOptionsContext } from "./FilteredOptions/filteredOptionsContext";
7
+ import SelectedOptions from "./SelectedOptions/SelectedOptions";
8
+ import ToggleListButton from "./ToggleListButton";
9
+ import { ComboboxProps } from "./types";
10
+ import { useSelectedOptionsContext } from "./SelectedOptions/selectedOptionsContext";
11
+ import ComboboxWrapper from "./ComboboxWrapper";
12
+ import { useInputContext } from "./Input/inputContext";
13
+ import Input from "./Input/Input";
14
+
15
+ export const Combobox = forwardRef<
16
+ HTMLInputElement,
17
+ Omit<ComboboxProps, "onChange" | "options" | "size">
18
+ >((props, ref) => {
19
+ const {
20
+ value: externalValue,
21
+ onClear,
22
+ className,
23
+ hideLabel = false,
24
+ description,
25
+ label,
26
+ clearButton = true,
27
+ clearButtonLabel,
28
+ toggleListButton = true,
29
+ toggleListButtonLabel,
30
+ inputClassName,
31
+ shouldShowSelectedOptions = true,
32
+ ...rest
33
+ } = props;
34
+
35
+ const toggleListButtonRef = useRef<HTMLButtonElement>(null);
36
+
37
+ const { currentOption, toggleIsListOpen } = useFilteredOptionsContext();
38
+ const { selectedOptions } = useSelectedOptionsContext();
39
+
40
+ const {
41
+ clearInput,
42
+ focusInput,
43
+ hasError,
44
+ inputDescriptionId,
45
+ inputProps,
46
+ inputRef,
47
+ value,
48
+ size = "medium",
49
+ } = useInputContext();
50
+
51
+ const mergedInputRef = useMemo(
52
+ () => mergeRefs([inputRef, ref]),
53
+ [inputRef, ref]
54
+ );
55
+
56
+ return (
57
+ <ComboboxWrapper
58
+ className={className}
59
+ hasError={hasError}
60
+ inputProps={inputProps}
61
+ inputSize={size}
62
+ toggleIsListOpen={toggleIsListOpen}
63
+ toggleListButtonRef={toggleListButtonRef}
64
+ >
65
+ <Label
66
+ htmlFor={inputProps.id}
67
+ size={size}
68
+ className={cl("navds-form-field__label", {
69
+ "navds-sr-only": hideLabel,
70
+ })}
71
+ >
72
+ {label}
73
+ </Label>
74
+ {!!description && (
75
+ <BodyShort
76
+ as="div"
77
+ className={cl("navds-form-field__description", {
78
+ "navds-sr-only": hideLabel,
79
+ })}
80
+ id={inputDescriptionId}
81
+ size={size}
82
+ >
83
+ {description}
84
+ </BodyShort>
85
+ )}
86
+ <div className="navds-combobox__wrapper">
87
+ <div
88
+ className={cl(
89
+ "navds-combobox__wrapper-inner navds-text-field__input",
90
+ {
91
+ "navds-combobox__wrapper-inner--virtually-unfocused":
92
+ currentOption !== null,
93
+ }
94
+ )}
95
+ onClick={focusInput}
96
+ >
97
+ {!shouldShowSelectedOptions ? (
98
+ <Input
99
+ id={inputProps.id}
100
+ ref={mergedInputRef}
101
+ inputClassName={inputClassName}
102
+ {...rest}
103
+ />
104
+ ) : (
105
+ <SelectedOptions selectedOptions={selectedOptions} size={size}>
106
+ <Input
107
+ id={inputProps.id}
108
+ ref={mergedInputRef}
109
+ inputClassName={inputClassName}
110
+ {...rest}
111
+ />
112
+ </SelectedOptions>
113
+ )}
114
+ <div>
115
+ {value && clearButton && (
116
+ <ClearButton
117
+ handleClear={clearInput}
118
+ clearButtonLabel={clearButtonLabel}
119
+ tabIndex={-1}
120
+ />
121
+ )}
122
+ {toggleListButton && (
123
+ <ToggleListButton
124
+ toggleListButtonLabel={toggleListButtonLabel}
125
+ ref={toggleListButtonRef}
126
+ />
127
+ )}
128
+ </div>
129
+ </div>
130
+ <FilteredOptions />
131
+ </div>
132
+ </ComboboxWrapper>
133
+ );
134
+ });
135
+
136
+ export default Combobox;
@@ -0,0 +1,99 @@
1
+ import React, { forwardRef } from "react";
2
+ import Combobox from "./Combobox";
3
+ import { FilteredOptionsProvider } from "./FilteredOptions/filteredOptionsContext";
4
+ import { CustomOptionsProvider } from "./customOptionsContext";
5
+ import { SelectedOptionsProvider } from "./SelectedOptions/selectedOptionsContext";
6
+ import { InputContextProvider } from "./Input/inputContext";
7
+ import { ComboboxProps } from "./types";
8
+
9
+ /**
10
+ * A component that allows the user to search in a list of options
11
+ *
12
+ * Has options for allowing only one or multiple options to be selected,
13
+ * or adding new, user-submitted values.
14
+ *
15
+ * @see [📝 Documentation](https://aksel.nav.no/komponenter/core/combobox)
16
+ *
17
+ * @example
18
+ * ```jsx
19
+ * const options = ["apple", "banana", "orange"];
20
+ *
21
+ * return (
22
+ * <Combobox
23
+ * label="Velg en verdi"
24
+ * options={options}
25
+ * id="my-combobox"
26
+ * shouldAutoComplete
27
+ * />
28
+ * )
29
+ * ```
30
+ */
31
+ const ComboboxProvider = forwardRef<HTMLInputElement, ComboboxProps>(
32
+ (props, ref) => {
33
+ const {
34
+ allowNewValues = false,
35
+ children,
36
+ defaultValue,
37
+ error,
38
+ errorId,
39
+ filteredOptions,
40
+ id,
41
+ isListOpen,
42
+ isLoading = false,
43
+ isMultiSelect,
44
+ onToggleSelected,
45
+ selectedOptions,
46
+ options,
47
+ value,
48
+ onChange,
49
+ onClear,
50
+ shouldAutocomplete,
51
+ size,
52
+ ...rest
53
+ } = props;
54
+ return (
55
+ <InputContextProvider
56
+ value={{
57
+ defaultValue,
58
+ error,
59
+ errorId,
60
+ id,
61
+ value,
62
+ onChange,
63
+ onClear,
64
+ shouldAutocomplete,
65
+ size,
66
+ }}
67
+ >
68
+ <CustomOptionsProvider>
69
+ <SelectedOptionsProvider
70
+ value={{
71
+ allowNewValues,
72
+ isMultiSelect,
73
+ selectedOptions,
74
+ onToggleSelected,
75
+ options,
76
+ }}
77
+ >
78
+ <FilteredOptionsProvider
79
+ value={{
80
+ allowNewValues,
81
+ filteredOptions,
82
+ isListOpen,
83
+ isLoading,
84
+ isMultiSelect,
85
+ options,
86
+ }}
87
+ >
88
+ <Combobox ref={ref} {...rest}>
89
+ {children}
90
+ </Combobox>
91
+ </FilteredOptionsProvider>
92
+ </SelectedOptionsProvider>
93
+ </CustomOptionsProvider>
94
+ </InputContextProvider>
95
+ );
96
+ }
97
+ );
98
+
99
+ export default ComboboxProvider;
@@ -0,0 +1,63 @@
1
+ import cl from "clsx";
2
+ import React, { useRef } from "react";
3
+
4
+ type ComboboxWrapperProps = {
5
+ children: any;
6
+ className?: string;
7
+ hasError: boolean;
8
+ inputProps: {
9
+ disabled?: boolean;
10
+ };
11
+ inputSize: string;
12
+ toggleIsListOpen: (isListOpen: boolean) => void;
13
+ toggleListButtonRef: React.RefObject<HTMLButtonElement>;
14
+ };
15
+
16
+ const ComboboxWrapper = ({
17
+ children,
18
+ className,
19
+ hasError,
20
+ inputProps,
21
+ inputSize,
22
+ toggleIsListOpen,
23
+ toggleListButtonRef,
24
+ }: ComboboxWrapperProps) => {
25
+ const wrapperRef = useRef<HTMLDivElement | null>(null);
26
+
27
+ function onFocusInsideWrapper(e) {
28
+ if (
29
+ !wrapperRef.current?.contains(e.relatedTarget) &&
30
+ toggleListButtonRef?.current !== e.target
31
+ ) {
32
+ toggleIsListOpen(true);
33
+ }
34
+ }
35
+
36
+ function onBlurWrapper(e) {
37
+ if (!wrapperRef.current?.contains(e.relatedTarget)) {
38
+ toggleIsListOpen(false);
39
+ }
40
+ }
41
+
42
+ return (
43
+ <div
44
+ ref={wrapperRef}
45
+ className={cl(
46
+ className,
47
+ "navds-form-field",
48
+ `navds-form-field--${inputSize}`,
49
+ "navds-search",
50
+ {
51
+ "navds-search--error": hasError,
52
+ "navds-search--disabled": !!inputProps.disabled,
53
+ }
54
+ )}
55
+ onFocus={onFocusInsideWrapper}
56
+ onBlur={onBlurWrapper}
57
+ >
58
+ {children}
59
+ </div>
60
+ );
61
+ };
62
+
63
+ export default ComboboxWrapper;
@@ -0,0 +1,23 @@
1
+ import React from "react";
2
+
3
+ const CheckIcon = () => {
4
+ return (
5
+ <svg
6
+ width="16"
7
+ height="13"
8
+ viewBox="0 0 16 13"
9
+ fill="none"
10
+ xmlns="http://www.w3.org/2000/svg"
11
+ aria-hidden="true"
12
+ >
13
+ <path
14
+ fillRule="evenodd"
15
+ clipRule="evenodd"
16
+ fill="#005B82"
17
+ d="M14.2014 0L16 1.89047L4.77943 13L0 8.39552L1.79361 6.5L4.77418 9.3019L14.2014 0Z"
18
+ />
19
+ </svg>
20
+ );
21
+ };
22
+
23
+ export default CheckIcon;
@@ -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
+ };