@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.
- package/_docs.json +1794 -1749
- package/cjs/date/context/useDateInputContext.js +1 -5
- package/cjs/date/datepicker/DatePicker.js +26 -25
- package/cjs/date/hooks/useDatepicker.js +9 -17
- package/cjs/date/hooks/useMonthPicker.js +9 -17
- package/cjs/date/hooks/useRangeDatepicker.js +9 -20
- package/cjs/date/monthpicker/MonthPicker.js +11 -6
- package/cjs/date/{DateInput.js → parts/DateInput.js} +14 -10
- package/cjs/date/parts/DateWrapper.js +55 -0
- package/cjs/date/utils/labels.js +77 -1
- package/cjs/form/combobox/Combobox.js +2 -2
- package/cjs/form/combobox/ComboboxProvider.js +1 -2
- package/cjs/form/combobox/FilteredOptions/FilteredOptions.js +15 -14
- package/cjs/form/combobox/FilteredOptions/filtered-options-util.js +24 -0
- package/cjs/form/combobox/FilteredOptions/filteredOptionsContext.js +23 -106
- package/cjs/form/combobox/FilteredOptions/useVirtualFocus.js +55 -0
- package/cjs/form/combobox/Input/Input.js +22 -13
- package/cjs/form/combobox/customOptionsContext.js +2 -3
- package/cjs/modal/Modal.js +4 -1
- package/cjs/popover/Popover.js +5 -7
- package/cjs/util/useMedia.js +30 -0
- package/esm/date/context/useDateInputContext.d.ts +6 -2
- package/esm/date/context/useDateInputContext.js +1 -5
- package/esm/date/context/useDateInputContext.js.map +1 -1
- package/esm/date/datepicker/DatePicker.d.ts +1 -1
- package/esm/date/datepicker/DatePicker.js +28 -27
- package/esm/date/datepicker/DatePicker.js.map +1 -1
- package/esm/date/datepicker/types.d.ts +0 -5
- package/esm/date/hooks/useDatepicker.d.ts +8 -5
- package/esm/date/hooks/useDatepicker.js +10 -18
- package/esm/date/hooks/useDatepicker.js.map +1 -1
- package/esm/date/hooks/useMonthPicker.d.ts +7 -4
- package/esm/date/hooks/useMonthPicker.js +10 -18
- package/esm/date/hooks/useMonthPicker.js.map +1 -1
- package/esm/date/hooks/useRangeDatepicker.d.ts +9 -3
- package/esm/date/hooks/useRangeDatepicker.js +10 -21
- package/esm/date/hooks/useRangeDatepicker.js.map +1 -1
- package/esm/date/index.d.ts +1 -1
- package/esm/date/index.js.map +1 -1
- package/esm/date/monthpicker/MonthPicker.d.ts +1 -1
- package/esm/date/monthpicker/MonthPicker.js +13 -8
- package/esm/date/monthpicker/MonthPicker.js.map +1 -1
- package/esm/date/monthpicker/types.d.ts +0 -5
- package/esm/date/{DateInput.d.ts → parts/DateInput.d.ts} +5 -1
- package/esm/date/{DateInput.js → parts/DateInput.js} +15 -11
- package/esm/date/parts/DateInput.js.map +1 -0
- package/esm/date/parts/DateWrapper.d.ts +15 -0
- package/esm/date/parts/DateWrapper.js +26 -0
- package/esm/date/parts/DateWrapper.js.map +1 -0
- package/esm/date/utils/labels.d.ts +2 -0
- package/esm/date/utils/labels.js +74 -0
- package/esm/date/utils/labels.js.map +1 -1
- package/esm/form/combobox/Combobox.js +2 -2
- package/esm/form/combobox/Combobox.js.map +1 -1
- package/esm/form/combobox/ComboboxProvider.js +1 -2
- package/esm/form/combobox/ComboboxProvider.js.map +1 -1
- package/esm/form/combobox/FilteredOptions/FilteredOptions.js +15 -14
- package/esm/form/combobox/FilteredOptions/FilteredOptions.js.map +1 -1
- package/esm/form/combobox/FilteredOptions/filtered-options-util.d.ts +12 -0
- package/esm/form/combobox/FilteredOptions/filtered-options-util.js +23 -0
- package/esm/form/combobox/FilteredOptions/filtered-options-util.js.map +1 -0
- package/esm/form/combobox/FilteredOptions/filteredOptionsContext.d.ts +10 -13
- package/esm/form/combobox/FilteredOptions/filteredOptionsContext.js +24 -107
- package/esm/form/combobox/FilteredOptions/filteredOptionsContext.js.map +1 -1
- package/esm/form/combobox/FilteredOptions/useVirtualFocus.d.ts +15 -0
- package/esm/form/combobox/FilteredOptions/useVirtualFocus.js +54 -0
- package/esm/form/combobox/FilteredOptions/useVirtualFocus.js.map +1 -0
- package/esm/form/combobox/Input/Input.js +22 -13
- package/esm/form/combobox/Input/Input.js.map +1 -1
- package/esm/form/combobox/customOptionsContext.d.ts +4 -1
- package/esm/form/combobox/customOptionsContext.js +2 -3
- package/esm/form/combobox/customOptionsContext.js.map +1 -1
- package/esm/modal/Modal.js +4 -1
- package/esm/modal/Modal.js.map +1 -1
- package/esm/popover/Popover.d.ts +0 -5
- package/esm/popover/Popover.js +5 -7
- package/esm/popover/Popover.js.map +1 -1
- package/esm/util/useMedia.d.ts +8 -0
- package/esm/util/useMedia.js +27 -0
- package/esm/util/useMedia.js.map +1 -0
- package/package.json +3 -3
- package/src/date/context/useDateInputContext.tsx +5 -5
- package/src/date/datepicker/DatePicker.tsx +58 -65
- package/src/date/datepicker/datepicker.stories.tsx +37 -46
- package/src/date/datepicker/types.ts +0 -5
- package/src/date/hooks/useDatepicker.tsx +20 -25
- package/src/date/hooks/useMonthPicker.tsx +18 -24
- package/src/date/hooks/useRangeDatepicker.tsx +27 -30
- package/src/date/index.ts +1 -1
- package/src/date/monthpicker/MonthPicker.tsx +39 -43
- package/src/date/monthpicker/types.ts +0 -5
- package/src/date/{DateInput.tsx → parts/DateInput.tsx} +23 -12
- package/src/date/parts/DateWrapper.tsx +80 -0
- package/src/date/utils/labels.ts +83 -0
- package/src/form/combobox/Combobox.tsx +2 -2
- package/src/form/combobox/ComboboxProvider.tsx +1 -2
- package/src/form/combobox/FilteredOptions/FilteredOptions.tsx +28 -16
- package/src/form/combobox/FilteredOptions/filtered-options-util.ts +38 -0
- package/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx +70 -140
- package/src/form/combobox/FilteredOptions/useVirtualFocus.ts +87 -0
- package/src/form/combobox/Input/Input.tsx +22 -18
- package/src/form/combobox/customOptionsContext.tsx +10 -5
- package/src/guide-panel/guidepanel.stories.tsx +2 -2
- package/src/modal/Modal.tsx +4 -1
- package/src/popover/Popover.tsx +4 -12
- package/src/util/__tests__/useMedia.test.tsx +19 -0
- package/src/util/useMedia.ts +38 -0
- package/cjs/date/hooks/useEscape.js +0 -23
- package/cjs/date/hooks/useOutsideClickHandler.js +0 -26
- package/esm/date/DateInput.js.map +0 -1
- package/esm/date/hooks/useEscape.d.ts +0 -2
- package/esm/date/hooks/useEscape.js +0 -20
- package/esm/date/hooks/useEscape.js.map +0 -1
- package/esm/date/hooks/useOutsideClickHandler.d.ts +0 -1
- package/esm/date/hooks/useOutsideClickHandler.js +0 -23
- package/esm/date/hooks/useOutsideClickHandler.js.map +0 -1
- package/src/date/hooks/useEscape.tsx +0 -30
- 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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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 = ({
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
() =>
|
|
125
|
-
|
|
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 =
|
|
150
|
+
activeOption = filteredOptionsUtils.getNoHitsId(id);
|
|
136
151
|
} else if ((value && value !== "") || isLoading) {
|
|
137
152
|
if (shouldAutocomplete && filteredOptions[0]) {
|
|
138
|
-
activeOption =
|
|
153
|
+
activeOption = filteredOptionsUtils.getOptionId(id, filteredOptions[0]);
|
|
139
154
|
} else if (isListOpen && isLoading) {
|
|
140
|
-
activeOption =
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
+
virtualFocus.moveFocusToTop();
|
|
108
105
|
break;
|
|
109
106
|
case "End":
|
|
110
|
-
|
|
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
|
-
|
|
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 &&
|
|
136
|
+
if (isListOpen && activeDecendantId) {
|
|
137
137
|
e.preventDefault();
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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 = ({
|
|
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 } =
|
|
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",
|
package/src/modal/Modal.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/popover/Popover.tsx
CHANGED
|
@@ -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 =
|
|
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
|
+
});
|