@navikt/ds-react 5.15.0 → 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 +145 -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/help-text/HelpText.js +1 -1
- package/cjs/util/create-context.js +72 -0
- 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/help-text/HelpText.js +1 -1
- package/esm/help-text/HelpText.js.map +1 -1
- package/esm/util/create-context.d.ts +23 -0
- package/esm/util/create-context.js +46 -0
- package/esm/util/create-context.js.map +1 -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/help-text/HelpText.tsx +1 -1
- package/src/util/create-context.tsx +67 -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
|
@@ -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
|
*
|
|
@@ -57,7 +57,7 @@ export const HelpText = forwardRef<HTMLButtonElement, HelpTextProps>(
|
|
|
57
57
|
<button
|
|
58
58
|
{...rest}
|
|
59
59
|
ref={mergedRef}
|
|
60
|
-
onClick={composeEventHandlers(onClick, () => setOpen((x) => x))}
|
|
60
|
+
onClick={composeEventHandlers(onClick, () => setOpen((x) => !x))}
|
|
61
61
|
className={cl(className, "navds-help-text__button")}
|
|
62
62
|
type="button"
|
|
63
63
|
aria-expanded={open}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom createContext to consolidate context-implementation across the system
|
|
3
|
+
* Inspired by:
|
|
4
|
+
* - https://github.com/radix-ui/primitives/blob/main/packages/react/context/src/createContext.tsx
|
|
5
|
+
* - https://github.com/chakra-ui/chakra-ui/blob/5ec0be610b5a69afba01a9c22365155c1b519136/packages/hooks/context/src/index.ts
|
|
6
|
+
*/
|
|
7
|
+
import React, {
|
|
8
|
+
createContext as createReactContext,
|
|
9
|
+
useContext as useReactContext,
|
|
10
|
+
} from "react";
|
|
11
|
+
|
|
12
|
+
export interface CreateContextOptions<T> {
|
|
13
|
+
hookName?: string;
|
|
14
|
+
providerName?: string;
|
|
15
|
+
errorMessage?: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
defaultValue?: T;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type ProviderProps<T> = T & { children: React.ReactNode };
|
|
21
|
+
|
|
22
|
+
export type CreateContextReturn<T> = [
|
|
23
|
+
(contextValues: ProviderProps<T>) => React.JSX.Element,
|
|
24
|
+
() => T,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function getErrorMessage(hook: string, provider: string) {
|
|
28
|
+
return `${hook} returned \`undefined\`. Seems you forgot to wrap component within ${provider}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createContext<T>(options: CreateContextOptions<T> = {}) {
|
|
32
|
+
const {
|
|
33
|
+
name,
|
|
34
|
+
hookName = "useContext",
|
|
35
|
+
providerName = "Provider",
|
|
36
|
+
errorMessage,
|
|
37
|
+
defaultValue,
|
|
38
|
+
} = options;
|
|
39
|
+
|
|
40
|
+
const Context = createReactContext<T | undefined>(defaultValue);
|
|
41
|
+
|
|
42
|
+
function Provider({ children, ...context }: ProviderProps<T>) {
|
|
43
|
+
// Only re-memoize when prop values change
|
|
44
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
45
|
+
const value = React.useMemo(() => context, Object.values(context)) as T;
|
|
46
|
+
return <Context.Provider value={value}>{children}</Context.Provider>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function useContext() {
|
|
50
|
+
const context = useReactContext(Context);
|
|
51
|
+
|
|
52
|
+
if (!context) {
|
|
53
|
+
const error = new Error(
|
|
54
|
+
errorMessage ?? getErrorMessage(hookName, providerName),
|
|
55
|
+
);
|
|
56
|
+
error.name = "ContextError";
|
|
57
|
+
Error.captureStackTrace?.(error, useContext);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return context;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
Context.displayName = name;
|
|
65
|
+
|
|
66
|
+
return [Provider, useContext] as CreateContextReturn<T>;
|
|
67
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Box } from "../../../layout/box";
|
|
3
|
+
import { HStack } from "../../../layout/stack";
|
|
4
|
+
import { createDescendantContext } from "./useDescendant";
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
title: "Utilities/Descendants",
|
|
8
|
+
parameters: {
|
|
9
|
+
chromatic: { disable: true },
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const [
|
|
14
|
+
DescendantsProvider,
|
|
15
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
16
|
+
_useDescendantsContext,
|
|
17
|
+
useDescendants,
|
|
18
|
+
useDescendant,
|
|
19
|
+
] = createDescendantContext<HTMLDivElement, { value?: string }>();
|
|
20
|
+
|
|
21
|
+
function Select({ children }: { children?: React.ReactNode }) {
|
|
22
|
+
const descendants = useDescendants();
|
|
23
|
+
const count = descendants.count();
|
|
24
|
+
|
|
25
|
+
React.useEffect(() => {
|
|
26
|
+
descendants.last()?.node.focus();
|
|
27
|
+
}, [descendants, count]);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<DescendantsProvider value={descendants}>{children}</DescendantsProvider>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function Option({ value, disabled }: { value?: string; disabled?: boolean }) {
|
|
35
|
+
const { register, index, descendants } = useDescendant({
|
|
36
|
+
disabled,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Box
|
|
41
|
+
ref={register}
|
|
42
|
+
role="button"
|
|
43
|
+
tabIndex={0}
|
|
44
|
+
data-value={value}
|
|
45
|
+
onKeyDown={(event) => {
|
|
46
|
+
if (event.key === "ArrowDown") {
|
|
47
|
+
descendants.next(index)?.node.focus();
|
|
48
|
+
} else if (event.key === "ArrowUp") {
|
|
49
|
+
descendants.prev(index)?.node.focus();
|
|
50
|
+
}
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
Option {index + 1}
|
|
54
|
+
</Box>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const DynamicUpdates = () => {
|
|
59
|
+
const [done, setDone] = React.useState(false);
|
|
60
|
+
|
|
61
|
+
React.useEffect(() => {
|
|
62
|
+
const interval = setInterval(() => setDone((x) => !x), 3000);
|
|
63
|
+
|
|
64
|
+
return () => window.clearInterval(interval);
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Select>
|
|
69
|
+
<Option value="option 1" />
|
|
70
|
+
<div>
|
|
71
|
+
<div>
|
|
72
|
+
<Option value="option 2" />
|
|
73
|
+
{done && (
|
|
74
|
+
<div>
|
|
75
|
+
<Option value="option 3" />
|
|
76
|
+
<Option value="option 4" />
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
<Option value="option 5" disabled />
|
|
81
|
+
</div>
|
|
82
|
+
{done && (
|
|
83
|
+
<div>
|
|
84
|
+
<Option value="option 6" />
|
|
85
|
+
<Option value="option 7" />
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</Select>
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function NumberInputWrapper({ children }: { children?: React.ReactNode }) {
|
|
93
|
+
const descendants = useDescendants();
|
|
94
|
+
|
|
95
|
+
React.useEffect(() => {
|
|
96
|
+
descendants.first()?.node.focus();
|
|
97
|
+
}, [descendants]);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<DescendantsProvider value={descendants}>
|
|
101
|
+
<HStack gap="1">{children}</HStack>
|
|
102
|
+
</DescendantsProvider>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function Input() {
|
|
107
|
+
const [focused, setFocused] = React.useState(false);
|
|
108
|
+
const { register, index, descendants } = useDescendant();
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<input
|
|
112
|
+
style={{
|
|
113
|
+
width: "3rem",
|
|
114
|
+
height: "3rem",
|
|
115
|
+
borderRadius: "4px",
|
|
116
|
+
textAlign: "center",
|
|
117
|
+
border: "1px solid var(--a-border-default)",
|
|
118
|
+
}}
|
|
119
|
+
placeholder={focused ? "" : "0"}
|
|
120
|
+
onFocus={() => setFocused(true)}
|
|
121
|
+
onBlur={() => setFocused(false)}
|
|
122
|
+
ref={register}
|
|
123
|
+
type="tel"
|
|
124
|
+
autoCapitalize="none"
|
|
125
|
+
autoComplete="false"
|
|
126
|
+
inputMode="numeric"
|
|
127
|
+
onKeyDown={(event) => {
|
|
128
|
+
if (event.key === "ArrowRight") {
|
|
129
|
+
descendants.next(index, false)?.node.focus();
|
|
130
|
+
}
|
|
131
|
+
if (event.key === "ArrowLeft") {
|
|
132
|
+
descendants.prev(index, false)?.node.focus();
|
|
133
|
+
}
|
|
134
|
+
}}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const NumberInput = () => {
|
|
140
|
+
return (
|
|
141
|
+
<NumberInputWrapper>
|
|
142
|
+
<Input />
|
|
143
|
+
<Input />
|
|
144
|
+
<Input />
|
|
145
|
+
</NumberInputWrapper>
|
|
146
|
+
);
|
|
147
|
+
};
|