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