@navikt/ds-react 5.7.6 → 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 +1824 -1758
- package/cjs/accordion/AccordionHeader.js +2 -2
- 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 +24 -108
- package/cjs/form/combobox/FilteredOptions/useVirtualFocus.js +55 -0
- package/cjs/form/combobox/Input/Input.js +33 -16
- package/cjs/form/combobox/customOptionsContext.js +2 -3
- package/cjs/layout/sidemal-test/Sidebar.js +1 -1
- package/cjs/loader/Loader.js +1 -1
- package/cjs/modal/Modal.js +39 -15
- package/cjs/popover/Popover.js +5 -7
- package/cjs/tooltip/Tooltip.js +14 -3
- package/cjs/util/useMedia.js +30 -0
- package/esm/accordion/AccordionHeader.js +2 -2
- package/esm/accordion/AccordionHeader.js.map +1 -1
- 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 +25 -109
- 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 +33 -16
- 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/layout/bleed/Bleed.d.ts +1 -1
- package/esm/layout/bleed/Bleed.js +1 -1
- package/esm/layout/bleed/Bleed.js.map +1 -1
- package/esm/layout/box/Box.d.ts +1 -2
- package/esm/layout/box/Box.js +1 -1
- package/esm/layout/box/Box.js.map +1 -1
- package/esm/layout/grid/HGrid.d.ts +1 -1
- package/esm/layout/grid/HGrid.js +1 -1
- package/esm/layout/grid/HGrid.js.map +1 -1
- package/esm/layout/responsive/Responsive.d.ts +1 -1
- package/esm/layout/sidemal-test/Sidebar.js +1 -1
- package/esm/layout/sidemal-test/Sidebar.js.map +1 -1
- package/esm/layout/stack/Stack.d.ts +1 -1
- package/esm/layout/stack/Stack.js +1 -1
- package/esm/layout/stack/Stack.js.map +1 -1
- package/esm/layout/utilities/css.d.ts +1 -8
- package/esm/layout/utilities/css.js.map +1 -1
- package/esm/layout/utilities/types.d.ts +9 -0
- package/esm/loader/Loader.d.ts +1 -1
- package/esm/loader/Loader.js +1 -1
- package/esm/modal/Modal.js +39 -15
- package/esm/modal/Modal.js.map +1 -1
- package/esm/modal/ModalContext.d.ts +1 -0
- package/esm/modal/ModalContext.js.map +1 -1
- package/esm/modal/types.d.ts +7 -0
- 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/tooltip/Tooltip.js +16 -5
- package/esm/tooltip/Tooltip.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/accordion/AccordionHeader.tsx +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 +71 -142
- package/src/form/combobox/FilteredOptions/useVirtualFocus.ts +87 -0
- package/src/form/combobox/Input/Input.tsx +40 -21
- package/src/form/combobox/combobox.stories.tsx +44 -0
- package/src/form/combobox/customOptionsContext.tsx +10 -5
- package/src/guide-panel/guidepanel.stories.tsx +2 -2
- package/src/layout/bleed/Bleed.tsx +2 -5
- package/src/layout/box/Box.tsx +1 -3
- package/src/layout/grid/HGrid.tsx +2 -6
- package/src/layout/responsive/Responsive.tsx +1 -1
- package/src/layout/sidemal-test/Sidebar.tsx +1 -1
- package/src/layout/stack/Stack.tsx +2 -6
- package/src/layout/utilities/css.ts +1 -36
- package/src/layout/utilities/types.ts +16 -0
- package/src/loader/Loader.tsx +1 -1
- package/src/modal/Modal.tsx +54 -21
- package/src/modal/ModalContext.ts +1 -0
- package/src/modal/modal.stories.tsx +30 -2
- package/src/modal/types.ts +7 -0
- package/src/popover/Popover.tsx +4 -12
- package/src/tooltip/Tooltip.tsx +18 -6
- 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,13 +88,28 @@ 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
|
-
filteredOptions.length > 0
|
|
99
|
-
!isValueInList(searchTerm, filteredOptions)
|
|
112
|
+
filteredOptions.length > 0
|
|
100
113
|
) {
|
|
101
114
|
setValue(
|
|
102
115
|
`${searchTerm}${filteredOptions[0].substring(searchTerm.length)}`
|
|
@@ -116,29 +129,30 @@ export const FilteredOptionsProvider = ({ children, value: props }) => {
|
|
|
116
129
|
return isExternalListOpen ?? isInternalListOpen;
|
|
117
130
|
}, [isExternalListOpen, isInternalListOpen]);
|
|
118
131
|
|
|
119
|
-
const toggleIsListOpen = useCallback(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
132
|
+
const toggleIsListOpen = useCallback(
|
|
133
|
+
(newState?: boolean) => {
|
|
134
|
+
virtualFocus.moveFocusToTop();
|
|
135
|
+
setInternalListOpen((oldState) => newState ?? !oldState);
|
|
136
|
+
},
|
|
137
|
+
[virtualFocus]
|
|
138
|
+
);
|
|
123
139
|
|
|
124
140
|
const isValueNew = useMemo(
|
|
125
|
-
() =>
|
|
126
|
-
|
|
141
|
+
() =>
|
|
142
|
+
Boolean(value) &&
|
|
143
|
+
!filteredOptionsMap[filteredOptionsUtils.getOptionId(id, value)],
|
|
144
|
+
[filteredOptionsMap, id, value]
|
|
127
145
|
);
|
|
128
146
|
|
|
129
|
-
const getMinimumIndex = useCallback(() => {
|
|
130
|
-
return isValueNew && allowNewValues ? -1 : 0;
|
|
131
|
-
}, [allowNewValues, isValueNew]);
|
|
132
|
-
|
|
133
147
|
const ariaDescribedBy = useMemo(() => {
|
|
134
148
|
let activeOption;
|
|
135
149
|
if (!isLoading && filteredOptions.length === 0) {
|
|
136
|
-
activeOption =
|
|
150
|
+
activeOption = filteredOptionsUtils.getNoHitsId(id);
|
|
137
151
|
} else if ((value && value !== "") || isLoading) {
|
|
138
152
|
if (shouldAutocomplete && filteredOptions[0]) {
|
|
139
|
-
activeOption =
|
|
153
|
+
activeOption = filteredOptionsUtils.getOptionId(id, filteredOptions[0]);
|
|
140
154
|
} else if (isListOpen && isLoading) {
|
|
141
|
-
activeOption =
|
|
155
|
+
activeOption = filteredOptionsUtils.getIsLoadingId(id);
|
|
142
156
|
}
|
|
143
157
|
}
|
|
144
158
|
return cl(activeOption, partialAriaDescribedBy) || undefined;
|
|
@@ -152,102 +166,21 @@ export const FilteredOptionsProvider = ({ children, value: props }) => {
|
|
|
152
166
|
id,
|
|
153
167
|
]);
|
|
154
168
|
|
|
155
|
-
const currentOption = useMemo(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return value;
|
|
161
|
-
}
|
|
162
|
-
return filteredOptions[filteredOptionsIndex];
|
|
163
|
-
}, [filteredOptionsIndex, filteredOptions, value]);
|
|
164
|
-
|
|
165
|
-
const resetFilteredOptionsIndex = () => {
|
|
166
|
-
setFilteredOptionsIndex(getMinimumIndex());
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
const scrollToOption = useCallback((newIndex: number) => {
|
|
170
|
-
if (
|
|
171
|
-
filteredOptionsRef.current &&
|
|
172
|
-
filteredOptionsRef.current.children[newIndex]
|
|
173
|
-
) {
|
|
174
|
-
const child = filteredOptionsRef.current.children[newIndex];
|
|
175
|
-
const { top, bottom } = child.getBoundingClientRect();
|
|
176
|
-
const parentRect = filteredOptionsRef.current.getBoundingClientRect();
|
|
177
|
-
if (top < parentRect.top || bottom > parentRect.bottom) {
|
|
178
|
-
child.scrollIntoView({ block: "nearest" });
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}, []);
|
|
182
|
-
|
|
183
|
-
useEffect(() => {
|
|
184
|
-
if (filteredOptionsIndex !== null && isListOpen) {
|
|
185
|
-
scrollToOption(filteredOptionsIndex);
|
|
186
|
-
}
|
|
187
|
-
}, [filteredOptionsIndex, isListOpen, scrollToOption]);
|
|
188
|
-
|
|
189
|
-
const moveFocusToInput = useCallback(() => {
|
|
190
|
-
setFilteredOptionsIndex(null);
|
|
191
|
-
toggleIsListOpen(false);
|
|
192
|
-
}, [toggleIsListOpen]);
|
|
193
|
-
|
|
194
|
-
const moveFocusToEnd = useCallback(() => {
|
|
195
|
-
const lastIndex = filteredOptions.length - 1;
|
|
196
|
-
toggleIsListOpen(true);
|
|
197
|
-
setFilteredOptionsIndex(lastIndex);
|
|
198
|
-
}, [filteredOptions.length, toggleIsListOpen]);
|
|
199
|
-
|
|
200
|
-
const moveFocusUp = useCallback(() => {
|
|
201
|
-
if (filteredOptionsIndex === null) {
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
if (filteredOptionsIndex === getMinimumIndex()) {
|
|
205
|
-
toggleIsListOpen(false);
|
|
206
|
-
setFilteredOptionsIndex(null);
|
|
207
|
-
} else {
|
|
208
|
-
const newIndex = Math.max(getMinimumIndex(), filteredOptionsIndex - 1);
|
|
209
|
-
setFilteredOptionsIndex(newIndex);
|
|
210
|
-
}
|
|
211
|
-
}, [filteredOptionsIndex, getMinimumIndex, toggleIsListOpen]);
|
|
212
|
-
|
|
213
|
-
const moveFocusDown = useCallback(() => {
|
|
214
|
-
if (filteredOptionsIndex === null || !isListOpen) {
|
|
215
|
-
toggleIsListOpen(true);
|
|
216
|
-
if (allowNewValues || filteredOptions.length >= 1) {
|
|
217
|
-
setFilteredOptionsIndex(getMinimumIndex());
|
|
218
|
-
}
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
const newIndex = Math.min(
|
|
222
|
-
filteredOptionsIndex + 1,
|
|
223
|
-
Math.max(getMinimumIndex(), filteredOptions.length - 1)
|
|
224
|
-
);
|
|
225
|
-
setFilteredOptionsIndex(newIndex);
|
|
226
|
-
}, [
|
|
227
|
-
allowNewValues,
|
|
228
|
-
filteredOptions.length,
|
|
229
|
-
filteredOptionsIndex,
|
|
230
|
-
getMinimumIndex,
|
|
231
|
-
isListOpen,
|
|
232
|
-
toggleIsListOpen,
|
|
233
|
-
]);
|
|
169
|
+
const currentOption = useMemo(
|
|
170
|
+
() =>
|
|
171
|
+
filteredOptionsMap[virtualFocus.activeElement?.getAttribute("id") || -1],
|
|
172
|
+
[filteredOptionsMap, virtualFocus]
|
|
173
|
+
);
|
|
234
174
|
|
|
235
|
-
const activeDecendantId = useMemo(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
return `${id}-combobox-new-option`;
|
|
240
|
-
} else {
|
|
241
|
-
return `${id}-option-${currentOption?.replace(" ", "-")}`;
|
|
242
|
-
}
|
|
243
|
-
}, [filteredOptionsIndex, currentOption, id]);
|
|
175
|
+
const activeDecendantId = useMemo(
|
|
176
|
+
() => virtualFocus.activeElement?.getAttribute("id") || undefined,
|
|
177
|
+
[virtualFocus.activeElement]
|
|
178
|
+
);
|
|
244
179
|
|
|
245
180
|
const filteredOptionsState = {
|
|
246
181
|
activeDecendantId,
|
|
247
182
|
allowNewValues,
|
|
248
|
-
|
|
249
|
-
filteredOptionsIndex,
|
|
250
|
-
setFilteredOptionsIndex,
|
|
183
|
+
setFilteredOptionsRef,
|
|
251
184
|
shouldAutocomplete,
|
|
252
185
|
isListOpen,
|
|
253
186
|
isLoading,
|
|
@@ -257,11 +190,7 @@ export const FilteredOptionsProvider = ({ children, value: props }) => {
|
|
|
257
190
|
isValueNew,
|
|
258
191
|
toggleIsListOpen,
|
|
259
192
|
currentOption,
|
|
260
|
-
|
|
261
|
-
moveFocusUp,
|
|
262
|
-
moveFocusDown,
|
|
263
|
-
moveFocusToInput,
|
|
264
|
-
moveFocusToEnd,
|
|
193
|
+
virtualFocus,
|
|
265
194
|
ariaDescribedBy,
|
|
266
195
|
};
|
|
267
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"> {
|
|
@@ -31,44 +32,55 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
|
31
32
|
allowNewValues,
|
|
32
33
|
currentOption,
|
|
33
34
|
filteredOptions,
|
|
35
|
+
isValueNew,
|
|
34
36
|
toggleIsListOpen,
|
|
35
37
|
isListOpen,
|
|
36
|
-
filteredOptionsIndex,
|
|
37
|
-
moveFocusUp,
|
|
38
|
-
moveFocusDown,
|
|
39
38
|
ariaDescribedBy,
|
|
40
|
-
moveFocusToInput,
|
|
41
|
-
moveFocusToEnd,
|
|
42
|
-
setFilteredOptionsIndex,
|
|
43
39
|
setIsMouseLastUsedInputDevice,
|
|
44
40
|
shouldAutocomplete,
|
|
41
|
+
virtualFocus,
|
|
45
42
|
} = useFilteredOptionsContext();
|
|
46
43
|
|
|
47
44
|
const onEnter = useCallback(
|
|
48
45
|
(event: React.KeyboardEvent) => {
|
|
46
|
+
const isTextInSelectedOptions = (text: string) => {
|
|
47
|
+
return selectedOptions.find(
|
|
48
|
+
(item) => item.toLocaleLowerCase() === text.toLocaleLowerCase()
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
49
52
|
if (currentOption) {
|
|
50
53
|
event.preventDefault();
|
|
51
54
|
// Selecting a value from the dropdown / FilteredOptions
|
|
52
55
|
toggleOption(currentOption, event);
|
|
53
|
-
if (!isMultiSelect && !
|
|
56
|
+
if (!isMultiSelect && !isTextInSelectedOptions(currentOption)) {
|
|
54
57
|
toggleIsListOpen(false);
|
|
55
|
-
|
|
58
|
+
}
|
|
59
|
+
} else if (shouldAutocomplete && isTextInSelectedOptions(value)) {
|
|
56
60
|
event.preventDefault();
|
|
57
61
|
// Trying to set the same value that is already set, so just clearing the input
|
|
58
62
|
clearInput(event);
|
|
59
63
|
} else if ((allowNewValues || shouldAutocomplete) && value !== "") {
|
|
60
64
|
event.preventDefault();
|
|
61
65
|
// Autocompleting or adding a new value
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
const selectedValue =
|
|
67
|
+
allowNewValues && isValueNew ? value : filteredOptions[0];
|
|
68
|
+
toggleOption(selectedValue, event);
|
|
69
|
+
if (
|
|
70
|
+
!isMultiSelect &&
|
|
71
|
+
!isTextInSelectedOptions(filteredOptions[0] || selectedValue)
|
|
72
|
+
) {
|
|
64
73
|
toggleIsListOpen(false);
|
|
74
|
+
}
|
|
65
75
|
}
|
|
66
76
|
},
|
|
67
77
|
[
|
|
68
78
|
allowNewValues,
|
|
69
79
|
clearInput,
|
|
70
80
|
currentOption,
|
|
81
|
+
filteredOptions,
|
|
71
82
|
isMultiSelect,
|
|
83
|
+
isValueNew,
|
|
72
84
|
selectedOptions,
|
|
73
85
|
shouldAutocomplete,
|
|
74
86
|
toggleIsListOpen,
|
|
@@ -89,10 +101,10 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
|
89
101
|
onEnter(e);
|
|
90
102
|
break;
|
|
91
103
|
case "Home":
|
|
92
|
-
|
|
104
|
+
virtualFocus.moveFocusToTop();
|
|
93
105
|
break;
|
|
94
106
|
case "End":
|
|
95
|
-
|
|
107
|
+
virtualFocus.moveFocusToBottom();
|
|
96
108
|
break;
|
|
97
109
|
default:
|
|
98
110
|
break;
|
|
@@ -113,14 +125,20 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
|
113
125
|
// so we don't interfere with text editing
|
|
114
126
|
if (e.target.selectionStart === value?.length) {
|
|
115
127
|
e.preventDefault();
|
|
116
|
-
|
|
128
|
+
if (virtualFocus.activeElement === null || !isListOpen) {
|
|
129
|
+
toggleIsListOpen(true);
|
|
130
|
+
}
|
|
131
|
+
virtualFocus.moveFocusDown();
|
|
117
132
|
}
|
|
118
133
|
} else if (e.key === "ArrowUp") {
|
|
119
134
|
// Check that the FilteredOptions list is open and has virtual focus.
|
|
120
135
|
// Otherwise ignore keystrokes, so it doesn't interfere with text editing
|
|
121
|
-
if (isListOpen &&
|
|
136
|
+
if (isListOpen && activeDecendantId) {
|
|
122
137
|
e.preventDefault();
|
|
123
|
-
|
|
138
|
+
if (virtualFocus.isFocusOnTheTop) {
|
|
139
|
+
toggleIsListOpen(false);
|
|
140
|
+
}
|
|
141
|
+
virtualFocus.moveFocusUp();
|
|
124
142
|
}
|
|
125
143
|
}
|
|
126
144
|
},
|
|
@@ -128,11 +146,11 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
|
128
146
|
value,
|
|
129
147
|
selectedOptions,
|
|
130
148
|
removeSelectedOption,
|
|
131
|
-
moveFocusDown,
|
|
132
149
|
isListOpen,
|
|
133
|
-
|
|
134
|
-
moveFocusUp,
|
|
150
|
+
activeDecendantId,
|
|
135
151
|
setIsMouseLastUsedInputDevice,
|
|
152
|
+
toggleIsListOpen,
|
|
153
|
+
virtualFocus,
|
|
136
154
|
]
|
|
137
155
|
);
|
|
138
156
|
|
|
@@ -144,13 +162,14 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
|
144
162
|
} else if (filteredOptions.length === 0) {
|
|
145
163
|
toggleIsListOpen(false);
|
|
146
164
|
}
|
|
165
|
+
virtualFocus.moveFocusToTop();
|
|
147
166
|
onChange(event);
|
|
148
167
|
},
|
|
149
|
-
[filteredOptions.length, onChange, toggleIsListOpen]
|
|
168
|
+
[filteredOptions.length, virtualFocus, onChange, toggleIsListOpen]
|
|
150
169
|
);
|
|
151
170
|
|
|
152
171
|
const onBlur = () => {
|
|
153
|
-
|
|
172
|
+
virtualFocus.moveFocusToTop();
|
|
154
173
|
};
|
|
155
174
|
|
|
156
175
|
return (
|
|
@@ -165,7 +184,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
|
165
184
|
onBlur={onBlur}
|
|
166
185
|
onKeyUp={handleKeyUp}
|
|
167
186
|
onKeyDown={handleKeyDown}
|
|
168
|
-
aria-controls={
|
|
187
|
+
aria-controls={filteredOptionsUtil.getFilteredOptionsId(inputProps.id)}
|
|
169
188
|
aria-expanded={!!isListOpen}
|
|
170
189
|
autoComplete="off"
|
|
171
190
|
aria-autocomplete={shouldAutocomplete ? "both" : "list"}
|
|
@@ -467,6 +467,50 @@ export const TestThatCallbacksOnlyFireWhenExpected: StoryObj<{
|
|
|
467
467
|
},
|
|
468
468
|
};
|
|
469
469
|
|
|
470
|
+
export const TestCasingWhenAutoCompleting = {
|
|
471
|
+
args: {
|
|
472
|
+
onChange: jest.fn(),
|
|
473
|
+
onClear: jest.fn(),
|
|
474
|
+
onToggleSelected: jest.fn(),
|
|
475
|
+
},
|
|
476
|
+
render: (props) => {
|
|
477
|
+
return (
|
|
478
|
+
<UNSAFE_Combobox
|
|
479
|
+
options={["Camel Case", "lowercase", "UPPERCASE"]}
|
|
480
|
+
label="Liker du best store eller små bokstaver?"
|
|
481
|
+
shouldAutocomplete
|
|
482
|
+
allowNewValues
|
|
483
|
+
{...props}
|
|
484
|
+
/>
|
|
485
|
+
);
|
|
486
|
+
},
|
|
487
|
+
play: async ({ canvasElement }) => {
|
|
488
|
+
const canvas = within(canvasElement);
|
|
489
|
+
const input = canvas.getByRole<HTMLInputElement>("combobox");
|
|
490
|
+
|
|
491
|
+
// With exisiting option
|
|
492
|
+
userEvent.click(input);
|
|
493
|
+
await userEvent.type(input, "cAmEl CaSe", { delay: 250 });
|
|
494
|
+
await sleep(250);
|
|
495
|
+
expect(input.value).toBe("cAmEl CaSe");
|
|
496
|
+
await userEvent.type(input, "{Enter}");
|
|
497
|
+
await sleep(250);
|
|
498
|
+
const chips = canvas.getAllByRole("list")[0];
|
|
499
|
+
const selectedUpperCaseChip = within(chips).getAllByRole("listitem")[0];
|
|
500
|
+
expect(selectedUpperCaseChip).toHaveTextContent("Camel Case"); // A weird issue is preventing the accessible name from being used in the test, even if it works in VoiceOver
|
|
501
|
+
|
|
502
|
+
// With custom option
|
|
503
|
+
userEvent.click(input);
|
|
504
|
+
await userEvent.type(input, "cAmEl{Backspace}", { delay: 250 });
|
|
505
|
+
await sleep(250);
|
|
506
|
+
expect(input.value).toBe("cAmEl");
|
|
507
|
+
await userEvent.type(input, "{Enter}");
|
|
508
|
+
await sleep(250);
|
|
509
|
+
const selectedNewValueChip = within(chips).getAllByRole("listitem")[0];
|
|
510
|
+
expect(selectedNewValueChip).toHaveTextContent("cAmEl"); // A weird issue is preventing the accessible name from being used in the test, even if it works in VoiceOver
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
|
|
470
514
|
export const TestHoverAndFocusSwitching: StoryObject = {
|
|
471
515
|
render: () => {
|
|
472
516
|
return (
|
|
@@ -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",
|