@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.
Files changed (70) hide show
  1. package/_docs.json +145 -1
  2. package/cjs/form/combobox/Combobox.js +1 -1
  3. package/cjs/form/combobox/ComboboxProvider.js +2 -1
  4. package/cjs/form/combobox/ComboboxWrapper.js +1 -1
  5. package/cjs/form/combobox/FilteredOptions/FilteredOptions.js +59 -41
  6. package/cjs/form/combobox/FilteredOptions/filtered-options-util.js +3 -1
  7. package/cjs/form/combobox/FilteredOptions/filteredOptionsContext.js +15 -3
  8. package/cjs/form/combobox/FilteredOptions/useVirtualFocus.js +52 -32
  9. package/cjs/form/combobox/Input/Input.js +3 -1
  10. package/cjs/form/combobox/SelectedOptions/selectedOptionsContext.js +3 -1
  11. package/cjs/help-text/HelpText.js +1 -1
  12. package/cjs/util/create-context.js +72 -0
  13. package/cjs/util/hooks/descendants/descendant.js +117 -0
  14. package/cjs/util/hooks/descendants/useDescendant.js +108 -0
  15. package/cjs/util/hooks/descendants/utils.js +53 -0
  16. package/esm/form/combobox/Combobox.js +1 -1
  17. package/esm/form/combobox/Combobox.js.map +1 -1
  18. package/esm/form/combobox/ComboboxProvider.js +2 -1
  19. package/esm/form/combobox/ComboboxProvider.js.map +1 -1
  20. package/esm/form/combobox/ComboboxWrapper.js +1 -1
  21. package/esm/form/combobox/ComboboxWrapper.js.map +1 -1
  22. package/esm/form/combobox/FilteredOptions/FilteredOptions.js +59 -41
  23. package/esm/form/combobox/FilteredOptions/FilteredOptions.js.map +1 -1
  24. package/esm/form/combobox/FilteredOptions/filtered-options-util.d.ts +2 -1
  25. package/esm/form/combobox/FilteredOptions/filtered-options-util.js +3 -1
  26. package/esm/form/combobox/FilteredOptions/filtered-options-util.js.map +1 -1
  27. package/esm/form/combobox/FilteredOptions/filteredOptionsContext.js +15 -3
  28. package/esm/form/combobox/FilteredOptions/filteredOptionsContext.js.map +1 -1
  29. package/esm/form/combobox/FilteredOptions/useVirtualFocus.d.ts +2 -4
  30. package/esm/form/combobox/FilteredOptions/useVirtualFocus.js +52 -32
  31. package/esm/form/combobox/FilteredOptions/useVirtualFocus.js.map +1 -1
  32. package/esm/form/combobox/Input/Input.js +3 -1
  33. package/esm/form/combobox/Input/Input.js.map +1 -1
  34. package/esm/form/combobox/SelectedOptions/selectedOptionsContext.d.ts +5 -2
  35. package/esm/form/combobox/SelectedOptions/selectedOptionsContext.js +3 -1
  36. package/esm/form/combobox/SelectedOptions/selectedOptionsContext.js.map +1 -1
  37. package/esm/form/combobox/types.d.ts +14 -0
  38. package/esm/help-text/HelpText.js +1 -1
  39. package/esm/help-text/HelpText.js.map +1 -1
  40. package/esm/util/create-context.d.ts +23 -0
  41. package/esm/util/create-context.js +46 -0
  42. package/esm/util/create-context.js.map +1 -0
  43. package/esm/util/hooks/descendants/descendant.d.ts +47 -0
  44. package/esm/util/hooks/descendants/descendant.js +114 -0
  45. package/esm/util/hooks/descendants/descendant.js.map +1 -0
  46. package/esm/util/hooks/descendants/useDescendant.d.ts +14 -0
  47. package/esm/util/hooks/descendants/useDescendant.js +82 -0
  48. package/esm/util/hooks/descendants/useDescendant.js.map +1 -0
  49. package/esm/util/hooks/descendants/utils.d.ts +12 -0
  50. package/esm/util/hooks/descendants/utils.js +46 -0
  51. package/esm/util/hooks/descendants/utils.js.map +1 -0
  52. package/package.json +3 -3
  53. package/src/form/combobox/Combobox.tsx +1 -1
  54. package/src/form/combobox/ComboboxProvider.tsx +2 -0
  55. package/src/form/combobox/ComboboxWrapper.tsx +0 -1
  56. package/src/form/combobox/FilteredOptions/FilteredOptions.tsx +131 -92
  57. package/src/form/combobox/FilteredOptions/filtered-options-util.ts +9 -2
  58. package/src/form/combobox/FilteredOptions/filteredOptionsContext.tsx +22 -3
  59. package/src/form/combobox/FilteredOptions/useVirtualFocus.ts +63 -45
  60. package/src/form/combobox/Input/Input.tsx +3 -1
  61. package/src/form/combobox/SelectedOptions/selectedOptionsContext.tsx +11 -1
  62. package/src/form/combobox/combobox.stories.tsx +36 -1
  63. package/src/form/combobox/combobox.test.tsx +1 -3
  64. package/src/form/combobox/types.ts +15 -0
  65. package/src/help-text/HelpText.tsx +1 -1
  66. package/src/util/create-context.tsx +67 -0
  67. package/src/util/hooks/descendants/descendant.stories.tsx +147 -0
  68. package/src/util/hooks/descendants/descendant.ts +161 -0
  69. package/src/util/hooks/descendants/useDescendant.tsx +111 -0
  70. package/src/util/hooks/descendants/utils.ts +56 -0
@@ -1,11 +1,10 @@
1
- import { Dispatch, SetStateAction, useState } from "react";
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 [index, setIndex] = useState(-1);
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 activeElement = elementsAbleToReceiveFocus[index];
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
- listOfAllChildren.find((element) => element.id === id);
31
- const isFocusOnTheTop = index === 0;
32
- const isFocusOnTheBottom = index === elementsAbleToReceiveFocus.length - 1;
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 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
- }
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 _moveFocusAndScrollTo = (_index: number) => {
50
- setIndex(_index);
51
- scrollToOption(_index);
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
- const moveFocusUp = () => _moveFocusAndScrollTo(Math.max(index - 1, -1));
54
- const moveFocusDown = () =>
55
- _moveFocusAndScrollTo(
56
- Math.min(index + 1, elementsAbleToReceiveFocus.length - 1),
75
+
76
+ const moveFocusToTop = () => _moveFocusAndScrollTo(undefined);
77
+ const moveFocusToBottom = () => {
78
+ const elementsAbleToReceiveFocus = getElementsAbleToReceiveFocus();
79
+ return _moveFocusAndScrollTo(
80
+ elementsAbleToReceiveFocus[elementsAbleToReceiveFocus.length - 1],
57
81
  );
58
- const moveFocusToTop = () => _moveFocusAndScrollTo(-1);
59
- const moveFocusToBottom = () =>
60
- _moveFocusAndScrollTo(elementsAbleToReceiveFocus.length - 1);
82
+ };
61
83
  const moveFocusToElement = (id: string) => {
62
- const thisElement = elementsAbleToReceiveFocus.find(
63
- (_element) => _element.getAttribute("id") === id,
84
+ const _element = getElementsAbleToReceiveFocus().find(
85
+ (_focusableElement) => _focusableElement.getAttribute("id") === id,
64
86
  );
65
- const indexOfElement = thisElement
66
- ? elementsAbleToReceiveFocus.indexOf(thisElement)
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
+ };