@simplybusiness/mobius 6.9.0 → 6.9.2

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 (155) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cjs/index.js +1110 -1185
  3. package/dist/esm/index.js +989 -1079
  4. package/dist/esm/tsconfig.build.tsbuildinfo +1 -1
  5. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  6. package/dist/types/src/components/Accordion/Accordion.d.ts +4 -2
  7. package/dist/types/src/components/Accordion/AccordionLink.d.ts +1 -2
  8. package/dist/types/src/components/AddressLookup/AddressLookup.d.ts +4 -3
  9. package/dist/types/src/components/AddressLookup/types.d.ts +3 -2
  10. package/dist/types/src/components/Alert/Alert.d.ts +5 -4
  11. package/dist/types/src/components/Box/Box.d.ts +5 -5
  12. package/dist/types/src/components/Breadcrumbs/BreadcrumbItem.d.ts +6 -5
  13. package/dist/types/src/components/Breadcrumbs/Breadcrumbs.d.ts +5 -4
  14. package/dist/types/src/components/Button/Button.d.ts +5 -4
  15. package/dist/types/src/components/Checkbox/Checkbox.d.ts +5 -3
  16. package/dist/types/src/components/Checkbox/CheckboxGroup.d.ts +5 -3
  17. package/dist/types/src/components/Checkbox/types.d.ts +1 -3
  18. package/dist/types/src/components/Container/Container.d.ts +5 -4
  19. package/dist/types/src/components/DateField/DateField.d.ts +5 -3
  20. package/dist/types/src/components/Drawer/Content.d.ts +5 -3
  21. package/dist/types/src/components/Drawer/Drawer.d.ts +4 -3
  22. package/dist/types/src/components/Drawer/Header.d.ts +5 -3
  23. package/dist/types/src/components/Drawer/index.d.ts +12 -3
  24. package/dist/types/src/components/Drawer/types.d.ts +3 -2
  25. package/dist/types/src/components/DropdownMenu/DropdownMenu.d.ts +4 -2
  26. package/dist/types/src/components/DropdownMenu/Item.d.ts +4 -2
  27. package/dist/types/src/components/DropdownMenu/index.d.ts +2 -3
  28. package/dist/types/src/components/ExpandableText/ExpandableText.d.ts +7 -5
  29. package/dist/types/src/components/Fieldset/Fieldset.d.ts +5 -4
  30. package/dist/types/src/components/Flex/Flex.d.ts +5 -3
  31. package/dist/types/src/components/Flex/types.d.ts +1 -2
  32. package/dist/types/src/components/Grid/Grid.d.ts +5 -4
  33. package/dist/types/src/components/Grid/Item.d.ts +5 -4
  34. package/dist/types/src/components/Grid/index.d.ts +2 -3
  35. package/dist/types/src/components/Icon/Icon.d.ts +1 -1
  36. package/dist/types/src/components/Icon/types.d.ts +2 -3
  37. package/dist/types/src/components/Image/Image.d.ts +5 -4
  38. package/dist/types/src/components/Label/Label.d.ts +5 -4
  39. package/dist/types/src/components/Link/Link.d.ts +7 -6
  40. package/dist/types/src/components/List/List.d.ts +5 -4
  41. package/dist/types/src/components/List/ListItem.d.ts +5 -4
  42. package/dist/types/src/components/LoadingIndicator/LoadingIndicator.d.ts +5 -4
  43. package/dist/types/src/components/Logo/Logo.d.ts +5 -4
  44. package/dist/types/src/components/MaskedField/MaskedField.d.ts +4 -2
  45. package/dist/types/src/components/Modal/Content.d.ts +5 -3
  46. package/dist/types/src/components/Modal/Header.d.ts +5 -3
  47. package/dist/types/src/components/Modal/Modal.d.ts +4 -3
  48. package/dist/types/src/components/Modal/index.d.ts +12 -3
  49. package/dist/types/src/components/Modal/types.d.ts +3 -2
  50. package/dist/types/src/components/NumberField/NumberField.d.ts +5 -4
  51. package/dist/types/src/components/Option/Option.d.ts +5 -4
  52. package/dist/types/src/components/PasswordField/PasswordField.d.ts +5 -3
  53. package/dist/types/src/components/Popover/Popover.d.ts +1 -2
  54. package/dist/types/src/components/Progress/Progress.d.ts +5 -4
  55. package/dist/types/src/components/Radio/Radio.d.ts +5 -4
  56. package/dist/types/src/components/Radio/RadioGroup.d.ts +5 -4
  57. package/dist/types/src/components/SVG/SVG.d.ts +5 -4
  58. package/dist/types/src/components/Select/Select.d.ts +5 -4
  59. package/dist/types/src/components/Stack/Stack.d.ts +4 -2
  60. package/dist/types/src/components/Switch/Switch.d.ts +5 -3
  61. package/dist/types/src/components/Table/Body.d.ts +4 -2
  62. package/dist/types/src/components/Table/Cell.d.ts +4 -2
  63. package/dist/types/src/components/Table/Foot.d.ts +4 -2
  64. package/dist/types/src/components/Table/Head.d.ts +4 -2
  65. package/dist/types/src/components/Table/HeaderCell.d.ts +4 -2
  66. package/dist/types/src/components/Table/Row.d.ts +4 -2
  67. package/dist/types/src/components/Table/Table.d.ts +4 -2
  68. package/dist/types/src/components/Table/index.d.ts +7 -8
  69. package/dist/types/src/components/Text/Text.d.ts +5 -4
  70. package/dist/types/src/components/TextArea/TextArea.d.ts +5 -4
  71. package/dist/types/src/components/TextAreaInput/TextAreaInput.d.ts +5 -4
  72. package/dist/types/src/components/TextField/TextField.d.ts +4 -2
  73. package/dist/types/src/components/TextOrHTML/TextOrHTML.d.ts +6 -5
  74. package/dist/types/src/components/Title/Title.d.ts +5 -4
  75. package/dist/types/src/components/Trust/Trust.d.ts +2 -3
  76. package/dist/types/src/components/Trust/types.d.ts +1 -2
  77. package/dist/types/src/types/components.d.ts +4 -2
  78. package/dist/types/src/utils/mergeRefs.d.ts +1 -1
  79. package/package.json +1 -1
  80. package/src/components/Accordion/Accordion.tsx +3 -8
  81. package/src/components/Accordion/AccordionLink.tsx +1 -2
  82. package/src/components/AddressLookup/AddressLookup.tsx +55 -64
  83. package/src/components/AddressLookup/types.tsx +10 -8
  84. package/src/components/Alert/Alert.tsx +48 -54
  85. package/src/components/Box/Box.tsx +47 -59
  86. package/src/components/Breadcrumbs/BreadcrumbItem.tsx +7 -11
  87. package/src/components/Breadcrumbs/Breadcrumbs.tsx +4 -10
  88. package/src/components/Button/Button.tsx +60 -65
  89. package/src/components/Checkbox/Checkbox.tsx +4 -8
  90. package/src/components/Checkbox/CheckboxGroup.tsx +4 -14
  91. package/src/components/Checkbox/types.ts +1 -4
  92. package/src/components/Combobox/Combobox.tsx +314 -313
  93. package/src/components/Container/Container.tsx +10 -15
  94. package/src/components/DateField/DateField.tsx +83 -90
  95. package/src/components/Drawer/Content.tsx +5 -9
  96. package/src/components/Drawer/Drawer.tsx +3 -5
  97. package/src/components/Drawer/Header.tsx +18 -22
  98. package/src/components/Drawer/types.ts +3 -2
  99. package/src/components/DropdownMenu/DropdownMenu.stories.tsx +12 -15
  100. package/src/components/DropdownMenu/DropdownMenu.test.tsx +2 -3
  101. package/src/components/DropdownMenu/DropdownMenu.tsx +3 -13
  102. package/src/components/DropdownMenu/Item.tsx +35 -38
  103. package/src/components/DropdownMenu/index.tsx +2 -4
  104. package/src/components/ExpandableText/ExpandableText.tsx +8 -9
  105. package/src/components/Fieldset/Fieldset.tsx +20 -24
  106. package/src/components/Flex/Flex.tsx +23 -27
  107. package/src/components/Flex/types.ts +1 -3
  108. package/src/components/Grid/Grid.tsx +31 -37
  109. package/src/components/Grid/Item.tsx +40 -44
  110. package/src/components/Grid/index.tsx +2 -4
  111. package/src/components/Icon/Icon.mdx +17 -0
  112. package/src/components/Icon/Icon.stories.tsx +6 -8
  113. package/src/components/Icon/Icon.tsx +2 -0
  114. package/src/components/Icon/types.ts +2 -4
  115. package/src/components/Image/Image.tsx +10 -16
  116. package/src/components/Label/Label.tsx +11 -17
  117. package/src/components/Link/Link.tsx +42 -43
  118. package/src/components/List/List.tsx +34 -39
  119. package/src/components/List/ListItem.tsx +27 -32
  120. package/src/components/LoadingIndicator/LoadingIndicator.tsx +3 -10
  121. package/src/components/Logo/Logo.tsx +42 -47
  122. package/src/components/MaskedField/MaskedField.tsx +6 -10
  123. package/src/components/Modal/Content.tsx +5 -9
  124. package/src/components/Modal/Header.tsx +17 -21
  125. package/src/components/Modal/Modal.tsx +3 -5
  126. package/src/components/Modal/types.ts +3 -2
  127. package/src/components/NumberField/NumberField.tsx +3 -16
  128. package/src/components/Option/Option.tsx +9 -14
  129. package/src/components/PasswordField/PasswordField.tsx +26 -27
  130. package/src/components/Popover/Popover.tsx +1 -3
  131. package/src/components/Progress/Progress.tsx +73 -81
  132. package/src/components/Radio/Radio.tsx +166 -174
  133. package/src/components/Radio/RadioGroup.tsx +3 -10
  134. package/src/components/SVG/SVG.tsx +20 -26
  135. package/src/components/Select/Select.tsx +85 -89
  136. package/src/components/Stack/Stack.tsx +12 -15
  137. package/src/components/Switch/Switch.tsx +2 -6
  138. package/src/components/Table/Body.tsx +4 -7
  139. package/src/components/Table/Cell.tsx +8 -12
  140. package/src/components/Table/Foot.tsx +4 -7
  141. package/src/components/Table/Head.tsx +4 -7
  142. package/src/components/Table/HeaderCell.tsx +8 -11
  143. package/src/components/Table/Row.tsx +4 -8
  144. package/src/components/Table/Table.tsx +4 -8
  145. package/src/components/Table/index.tsx +7 -9
  146. package/src/components/Text/Text.tsx +16 -22
  147. package/src/components/TextArea/TextArea.tsx +44 -52
  148. package/src/components/TextAreaInput/TextAreaInput.tsx +3 -10
  149. package/src/components/TextField/TextField.tsx +102 -105
  150. package/src/components/TextOrHTML/TextOrHTML.tsx +35 -41
  151. package/src/components/Title/Title.tsx +21 -27
  152. package/src/components/Trust/Trust.tsx +74 -76
  153. package/src/components/Trust/types.ts +1 -3
  154. package/src/types/components.ts +4 -8
  155. package/src/utils/mergeRefs.ts +1 -1
@@ -1,7 +1,7 @@
1
1
  import classNames from "classnames/dedupe";
2
2
  import type React from "react";
3
3
  import type { FocusEvent } from "react";
4
- import { forwardRef, useEffect, useId, useRef, useState } from "react";
4
+ import { useEffect, useId, useRef, useState } from "react";
5
5
  import { useBreakpoint, useOnUnmount } from "../../hooks";
6
6
  import { TextField } from "../TextField";
7
7
  import { VisuallyHidden } from "../VisuallyHidden";
@@ -11,334 +11,335 @@ import { useComboboxHighlight } from "./useComboboxHighlight";
11
11
  import { useComboboxOptions } from "./useComboboxOptions";
12
12
  import { getOptionLabel, getOptionValue, isOptionGroup } from "./utils";
13
13
 
14
- const ComboboxInner = forwardRef(
15
- <T extends ComboboxOption>(props: ComboboxProps<T>, ref: ComboboxRef) => {
16
- const {
17
- id,
18
- defaultValue,
19
- value,
14
+ const ComboboxInner = <T extends ComboboxOption>({
15
+ ref,
16
+ ...props
17
+ }: ComboboxProps<T>) => {
18
+ const {
19
+ id,
20
+ defaultValue,
21
+ value,
22
+ options,
23
+ asyncOptions,
24
+ delay,
25
+ minSearchLength,
26
+ onSelected,
27
+ className,
28
+ placeholder,
29
+ icon,
30
+ onBlur,
31
+ onFocus,
32
+ onChange,
33
+ // onSearched, // unused prop, consider removing
34
+ optionComponent,
35
+ errorMessage,
36
+ ...otherProps
37
+ } = props;
38
+ // Avoid re-fetching after selecting an option
39
+ const skipNextDebounceRef = useRef(false);
40
+ const fallbackRef = useRef<HTMLInputElement>(null);
41
+ const [inputValue, setInputValue] = useState(defaultValue || "");
42
+ const [isOpen, setIsOpen] = useState(false);
43
+ const [isChanging, setIsChanging] = useState(false);
44
+ const { filteredOptions, updateFilteredOptions, isLoading, error } =
45
+ useComboboxOptions({
20
46
  options,
21
47
  asyncOptions,
48
+ inputValue,
22
49
  delay,
23
50
  minSearchLength,
24
- onSelected,
25
- className,
26
- placeholder,
27
- icon,
28
- onBlur,
29
- onFocus,
30
- onChange,
31
- // onSearched, // unused prop, consider removing
32
- optionComponent,
33
- errorMessage,
34
- ...otherProps
35
- } = props;
36
- // Avoid re-fetching after selecting an option
37
- const skipNextDebounceRef = useRef(false);
38
- const fallbackRef = useRef<HTMLInputElement>(null);
39
- const [inputValue, setInputValue] = useState(defaultValue || "");
40
- const [isOpen, setIsOpen] = useState(false);
41
- const [isChanging, setIsChanging] = useState(false);
42
- const { filteredOptions, updateFilteredOptions, isLoading, error } =
43
- useComboboxOptions({
44
- options,
45
- asyncOptions,
46
- inputValue,
47
- delay,
48
- minSearchLength,
49
- skipNextDebounceRef,
50
- });
51
- const {
52
- highlightedIndex,
53
- highlightedGroupIndex,
54
- highlightNextOption,
55
- highlightPreviousOption,
56
- highlightFirstOption,
57
- highlightLastOption,
58
- clearHighlight,
59
- } = useComboboxHighlight(filteredOptions);
60
-
61
- const inputRef = ref || fallbackRef;
62
- const listboxId = useId();
63
- const statusId = useId();
64
- const blurTimeoutRef = useRef<NodeJS.Timeout | null>(null);
65
- const userInteractedRef = useRef(false);
66
- const justSelectedRef = useRef(false);
67
- const { down } = useBreakpoint();
68
- const isMobile = down("md");
69
-
70
- const handleFocus = (e: FocusEvent) => {
71
- onFocus?.(e);
72
- if (!filteredOptions || filteredOptions.length === 0) return;
73
- if (blurTimeoutRef.current) {
74
- clearTimeout(blurTimeoutRef.current);
75
- blurTimeoutRef.current = null;
76
- }
77
-
78
- // Check if this is natural focus (user click/Tab) or programmatic focus
79
- const isNaturalFocus =
80
- userInteractedRef.current || e.relatedTarget !== null;
81
- if (userInteractedRef.current) {
82
- userInteractedRef.current = false;
83
- }
84
-
85
- // Block opening only if programmatic focus right after selection
86
- if (justSelectedRef.current && !isNaturalFocus) {
87
- return;
88
- }
89
-
90
- // Open dropdown for natural focus
91
- if (isNaturalFocus) {
92
- setIsOpen(true);
93
- justSelectedRef.current = false;
94
- }
95
- };
96
-
97
- useEffect(() => {
98
- if (!inputRef || typeof inputRef === "function") return;
99
- const inputElement = inputRef.current;
100
- if (!inputElement) return;
101
-
102
- const handleMouseDown = () => {
103
- // Track that user clicked/interacted with input
104
- userInteractedRef.current = true;
105
- };
106
-
107
- inputElement.addEventListener("mousedown", handleMouseDown);
108
- return () => {
109
- inputElement.removeEventListener("mousedown", handleMouseDown);
110
- };
111
- }, [inputRef]);
112
-
113
- useOnUnmount(() => {
114
- if (blurTimeoutRef.current) {
115
- clearTimeout(blurTimeoutRef.current);
116
- }
51
+ skipNextDebounceRef,
117
52
  });
118
-
119
- const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
120
- const newValue = e.target.value;
121
- setInputValue(newValue);
53
+ const {
54
+ highlightedIndex,
55
+ highlightedGroupIndex,
56
+ highlightNextOption,
57
+ highlightPreviousOption,
58
+ highlightFirstOption,
59
+ highlightLastOption,
60
+ clearHighlight,
61
+ } = useComboboxHighlight(filteredOptions);
62
+
63
+ const inputRef = ref || fallbackRef;
64
+ const listboxId = useId();
65
+ const statusId = useId();
66
+ const blurTimeoutRef = useRef<NodeJS.Timeout | null>(null);
67
+ const userInteractedRef = useRef(false);
68
+ const justSelectedRef = useRef(false);
69
+ const { down } = useBreakpoint();
70
+ const isMobile = down("md");
71
+
72
+ const handleFocus = (e: FocusEvent) => {
73
+ onFocus?.(e);
74
+ if (!filteredOptions || filteredOptions.length === 0) return;
75
+ if (blurTimeoutRef.current) {
76
+ clearTimeout(blurTimeoutRef.current);
77
+ blurTimeoutRef.current = null;
78
+ }
79
+
80
+ // Check if this is natural focus (user click/Tab) or programmatic focus
81
+ const isNaturalFocus =
82
+ userInteractedRef.current || e.relatedTarget !== null;
83
+ if (userInteractedRef.current) {
84
+ userInteractedRef.current = false;
85
+ }
86
+
87
+ // Block opening only if programmatic focus right after selection
88
+ if (justSelectedRef.current && !isNaturalFocus) {
89
+ return;
90
+ }
91
+
92
+ // Open dropdown for natural focus
93
+ if (isNaturalFocus) {
94
+ setIsOpen(true);
122
95
  justSelectedRef.current = false;
123
- setIsChanging(true);
124
- // Only open immediately for sync options; async options controlled by useEffect
125
- if (!asyncOptions) {
126
- setIsOpen(true);
127
- }
128
- clearHighlight();
129
- onChange?.(e);
130
- };
131
-
132
- const handleOptionSelect = (option: T) => {
133
- const val = getOptionValue(option);
134
- if (!val) return;
135
-
136
- if (
137
- typeof option === "object" &&
138
- "callback" in option &&
139
- option.callback &&
140
- typeof option.callback === "function"
141
- ) {
142
- // @ts-expect-error ref types are hard
143
- setTimeout(() => inputRef.current.focus(), 0);
144
- updateFilteredOptions(option.callback());
145
- return;
146
- }
147
-
148
- // Prevent re-fetching options after selecting an option
149
- skipNextDebounceRef.current = true;
150
- justSelectedRef.current = true;
151
-
152
- setIsChanging(false);
153
- setIsOpen(false);
154
- setInputValue(val);
155
- onSelected?.(option);
156
- };
96
+ }
97
+ };
157
98
 
158
- const getFirstOption = () => {
159
- if (!filteredOptions) return undefined;
160
- if (isOptionGroup(filteredOptions)) {
161
- return filteredOptions[0]?.options[0];
162
- }
99
+ useEffect(() => {
100
+ if (!inputRef || typeof inputRef === "function") return;
101
+ const inputElement = inputRef.current;
102
+ if (!inputElement) return;
163
103
 
164
- return filteredOptions[0];
104
+ const handleMouseDown = () => {
105
+ // Track that user clicked/interacted with input
106
+ userInteractedRef.current = true;
165
107
  };
166
108
 
167
- const getHighlightedOption = () => {
168
- if (!filteredOptions) return undefined;
169
- if (highlightedIndex === -1) return undefined;
170
-
171
- if (isOptionGroup(filteredOptions)) {
172
- const group = filteredOptions[highlightedGroupIndex];
173
- return group?.options[highlightedIndex];
174
- }
175
-
176
- return filteredOptions[highlightedIndex];
109
+ inputElement.addEventListener("mousedown", handleMouseDown);
110
+ return () => {
111
+ inputElement.removeEventListener("mousedown", handleMouseDown);
177
112
  };
178
-
179
- const getHighlightedOptionId = () => {
180
- const option = getHighlightedOption();
181
- if (!option) return undefined;
182
-
183
- if (isOptionGroup(filteredOptions)) {
184
- return `${listboxId}-option-${highlightedGroupIndex}-${highlightedIndex}`;
113
+ }, [inputRef]);
114
+
115
+ useOnUnmount(() => {
116
+ if (blurTimeoutRef.current) {
117
+ clearTimeout(blurTimeoutRef.current);
118
+ }
119
+ });
120
+
121
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
122
+ const newValue = e.target.value;
123
+ setInputValue(newValue);
124
+ justSelectedRef.current = false;
125
+ setIsChanging(true);
126
+ // Only open immediately for sync options; async options controlled by useEffect
127
+ if (!asyncOptions) {
128
+ setIsOpen(true);
129
+ }
130
+ clearHighlight();
131
+ onChange?.(e);
132
+ };
133
+
134
+ const handleOptionSelect = (option: T) => {
135
+ const val = getOptionValue(option);
136
+ if (!val) return;
137
+
138
+ if (
139
+ typeof option === "object" &&
140
+ "callback" in option &&
141
+ option.callback &&
142
+ typeof option.callback === "function"
143
+ ) {
144
+ // @ts-expect-error ref types are hard
145
+ setTimeout(() => inputRef.current.focus(), 0);
146
+ updateFilteredOptions(option.callback());
147
+ return;
148
+ }
149
+
150
+ // Prevent re-fetching options after selecting an option
151
+ skipNextDebounceRef.current = true;
152
+ justSelectedRef.current = true;
153
+
154
+ setIsChanging(false);
155
+ setIsOpen(false);
156
+ setInputValue(val);
157
+ onSelected?.(option);
158
+ };
159
+
160
+ const getFirstOption = () => {
161
+ if (!filteredOptions) return undefined;
162
+ if (isOptionGroup(filteredOptions)) {
163
+ return filteredOptions[0]?.options[0];
164
+ }
165
+
166
+ return filteredOptions[0];
167
+ };
168
+
169
+ const getHighlightedOption = () => {
170
+ if (!filteredOptions) return undefined;
171
+ if (highlightedIndex === -1) return undefined;
172
+
173
+ if (isOptionGroup(filteredOptions)) {
174
+ const group = filteredOptions[highlightedGroupIndex];
175
+ return group?.options[highlightedIndex];
176
+ }
177
+
178
+ return filteredOptions[highlightedIndex];
179
+ };
180
+
181
+ const getHighlightedOptionId = () => {
182
+ const option = getHighlightedOption();
183
+ if (!option) return undefined;
184
+
185
+ if (isOptionGroup(filteredOptions)) {
186
+ return `${listboxId}-option-${highlightedGroupIndex}-${highlightedIndex}`;
187
+ }
188
+
189
+ return `${listboxId}-option-${highlightedIndex}`;
190
+ };
191
+
192
+ const handleBlur = (e: FocusEvent<Element, Element>) => {
193
+ // Force selection if user has matched an entry by typing (not already selected)
194
+ // Defer this to allow natural focus flow to complete first
195
+ if (!justSelectedRef.current) {
196
+ const typedText = inputValue.trim().toLowerCase();
197
+ const highlightedOption = getHighlightedOption();
198
+ const label = getOptionLabel(highlightedOption);
199
+
200
+ if (typedText === label?.toLowerCase()) {
201
+ setTimeout(() => {
202
+ handleOptionSelect(highlightedOption as T);
203
+ }, 0);
185
204
  }
205
+ }
186
206
 
187
- return `${listboxId}-option-${highlightedIndex}`;
188
- };
207
+ blurTimeoutRef.current = setTimeout(() => {
208
+ onBlur?.(e);
209
+ setIsOpen(false);
210
+ }, 150);
211
+ };
189
212
 
190
- const handleBlur = (e: FocusEvent<Element, Element>) => {
191
- // Force selection if user has matched an entry by typing (not already selected)
192
- // Defer this to allow natural focus flow to complete first
193
- if (!justSelectedRef.current) {
194
- const typedText = inputValue.trim().toLowerCase();
195
- const highlightedOption = getHighlightedOption();
196
- const label = getOptionLabel(highlightedOption);
197
-
198
- if (typedText === label?.toLowerCase()) {
199
- setTimeout(() => {
200
- handleOptionSelect(highlightedOption as T);
201
- }, 0);
213
+ const handleKeyDown = (e: React.KeyboardEvent) => {
214
+ switch (e.key) {
215
+ case "ArrowDown":
216
+ e.preventDefault();
217
+ justSelectedRef.current = false;
218
+ setIsOpen(true);
219
+ highlightNextOption();
220
+ break;
221
+ case "ArrowUp":
222
+ e.preventDefault();
223
+ justSelectedRef.current = false;
224
+ setIsOpen(true);
225
+ highlightPreviousOption();
226
+ break;
227
+ case "Home":
228
+ e.preventDefault();
229
+ justSelectedRef.current = false;
230
+ setIsOpen(true);
231
+ highlightFirstOption();
232
+ break;
233
+ case "End":
234
+ e.preventDefault();
235
+ justSelectedRef.current = false;
236
+ setIsOpen(true);
237
+ highlightLastOption();
238
+ break;
239
+ case "Enter":
240
+ e.preventDefault();
241
+ if (isOpen) {
242
+ const selectedOption = getHighlightedOption() || getFirstOption();
243
+ if (selectedOption) {
244
+ handleOptionSelect(selectedOption);
245
+ }
202
246
  }
203
- }
204
-
205
- blurTimeoutRef.current = setTimeout(() => {
206
- onBlur?.(e);
247
+ break;
248
+ case "Escape":
249
+ e.preventDefault();
250
+ setInputValue("");
207
251
  setIsOpen(false);
208
- }, 150);
209
- };
210
-
211
- const handleKeyDown = (e: React.KeyboardEvent) => {
212
- switch (e.key) {
213
- case "ArrowDown":
214
- e.preventDefault();
215
- justSelectedRef.current = false;
216
- setIsOpen(true);
217
- highlightNextOption();
218
- break;
219
- case "ArrowUp":
220
- e.preventDefault();
221
- justSelectedRef.current = false;
222
- setIsOpen(true);
223
- highlightPreviousOption();
224
- break;
225
- case "Home":
226
- e.preventDefault();
227
- justSelectedRef.current = false;
228
- setIsOpen(true);
229
- highlightFirstOption();
230
- break;
231
- case "End":
232
- e.preventDefault();
233
- justSelectedRef.current = false;
234
- setIsOpen(true);
235
- highlightLastOption();
236
- break;
237
- case "Enter":
238
- e.preventDefault();
239
- if (isOpen) {
240
- const selectedOption = getHighlightedOption() || getFirstOption();
241
- if (selectedOption) {
242
- handleOptionSelect(selectedOption);
243
- }
244
- }
245
- break;
246
- case "Escape":
247
- e.preventDefault();
248
- setInputValue("");
249
- setIsOpen(false);
250
- clearHighlight();
251
- break;
252
- default:
253
- // Do nothing
254
- }
255
- };
256
-
257
- useEffect(() => {
258
- if (value) {
259
- setInputValue(value);
260
- }
261
- }, [value]);
262
-
263
- // Open and close the combobox based on async filtered options
264
- useEffect(() => {
265
- if (asyncOptions && isChanging) {
266
- setIsOpen(!!filteredOptions && filteredOptions.length > 0);
267
- }
268
- }, [filteredOptions, asyncOptions, isChanging]);
269
-
270
- const classes = classNames(
271
- "mobius mobius-combobox",
272
- {
273
- "mobius-combobox--is-expanded": isOpen,
274
- "mobius-combobox--is-loading": isLoading,
275
- "mobius-combobox--is-mobile": isMobile,
276
- },
277
- className,
278
- );
279
-
280
- const getStatusMessage = () => {
281
- if (isLoading) return "Loading options";
282
- if (!filteredOptions || filteredOptions.length === 0) {
283
- return isChanging ? "No options found" : "";
284
- }
285
- const count = isOptionGroup(filteredOptions)
286
- ? filteredOptions.reduce((sum, group) => sum + group.options.length, 0)
287
- : filteredOptions.length;
288
- return isOpen && isChanging
289
- ? `${count} option${count === 1 ? "" : "s"} available`
290
- : "";
291
- };
292
-
293
- return (
294
- <div id={id} data-testid="mobius-combobox__wrapper" className={classes}>
295
- <VisuallyHidden
296
- role="status"
297
- aria-live="polite"
298
- id={statusId}
299
- elementType="div"
300
- className="mobius-combobox__status"
301
- >
302
- {getStatusMessage()}
303
- </VisuallyHidden>
304
- <TextField
305
- {...otherProps}
306
- className="mobius-combobox__input"
307
- role="combobox"
308
- value={inputValue}
309
- placeholder={placeholder}
310
- onFocus={handleFocus}
311
- onBlur={handleBlur}
312
- onKeyDown={handleKeyDown}
313
- onChange={handleInputChange}
314
- autoComplete="off"
315
- aria-describedby={isLoading ? statusId : undefined}
316
- aria-autocomplete="list"
317
- aria-haspopup="listbox"
318
- aria-owns={listboxId}
319
- aria-controls={listboxId}
320
- aria-expanded={isOpen}
321
- aria-activedescendant={
322
- highlightedIndex === -1 ? undefined : getHighlightedOptionId()
323
- }
324
- prefixInside={icon}
325
- ref={inputRef}
326
- errorMessage={errorMessage || error?.message || undefined}
327
- />
328
- <Listbox
329
- id={listboxId}
330
- isOpen={isOpen}
331
- isLoading={isLoading}
332
- options={filteredOptions}
333
- highlightedIndex={highlightedIndex}
334
- highlightedGroupIndex={highlightedGroupIndex}
335
- onOptionSelect={handleOptionSelect}
336
- optionComponent={optionComponent}
337
- />
338
- </div>
339
- );
340
- },
341
- );
252
+ clearHighlight();
253
+ break;
254
+ default:
255
+ // Do nothing
256
+ }
257
+ };
258
+
259
+ useEffect(() => {
260
+ if (value) {
261
+ setInputValue(value);
262
+ }
263
+ }, [value]);
264
+
265
+ // Open and close the combobox based on async filtered options
266
+ useEffect(() => {
267
+ if (asyncOptions && isChanging) {
268
+ setIsOpen(!!filteredOptions && filteredOptions.length > 0);
269
+ }
270
+ }, [filteredOptions, asyncOptions, isChanging]);
271
+
272
+ const classes = classNames(
273
+ "mobius mobius-combobox",
274
+ {
275
+ "mobius-combobox--is-expanded": isOpen,
276
+ "mobius-combobox--is-loading": isLoading,
277
+ "mobius-combobox--is-mobile": isMobile,
278
+ },
279
+ className,
280
+ );
281
+
282
+ const getStatusMessage = () => {
283
+ if (isLoading) return "Loading options";
284
+ if (!filteredOptions || filteredOptions.length === 0) {
285
+ return isChanging ? "No options found" : "";
286
+ }
287
+ const count = isOptionGroup(filteredOptions)
288
+ ? filteredOptions.reduce((sum, group) => sum + group.options.length, 0)
289
+ : filteredOptions.length;
290
+ return isOpen && isChanging
291
+ ? `${count} option${count === 1 ? "" : "s"} available`
292
+ : "";
293
+ };
294
+
295
+ return (
296
+ <div id={id} data-testid="mobius-combobox__wrapper" className={classes}>
297
+ <VisuallyHidden
298
+ role="status"
299
+ aria-live="polite"
300
+ id={statusId}
301
+ elementType="div"
302
+ className="mobius-combobox__status"
303
+ >
304
+ {getStatusMessage()}
305
+ </VisuallyHidden>
306
+ <TextField
307
+ {...otherProps}
308
+ className="mobius-combobox__input"
309
+ role="combobox"
310
+ value={inputValue}
311
+ placeholder={placeholder}
312
+ onFocus={handleFocus}
313
+ onBlur={handleBlur}
314
+ onKeyDown={handleKeyDown}
315
+ onChange={handleInputChange}
316
+ autoComplete="off"
317
+ aria-describedby={isLoading ? statusId : undefined}
318
+ aria-autocomplete="list"
319
+ aria-haspopup="listbox"
320
+ aria-owns={listboxId}
321
+ aria-controls={listboxId}
322
+ aria-expanded={isOpen}
323
+ aria-activedescendant={
324
+ highlightedIndex === -1 ? undefined : getHighlightedOptionId()
325
+ }
326
+ prefixInside={icon}
327
+ ref={inputRef}
328
+ errorMessage={errorMessage || error?.message || undefined}
329
+ />
330
+ <Listbox
331
+ id={listboxId}
332
+ isOpen={isOpen}
333
+ isLoading={isLoading}
334
+ options={filteredOptions}
335
+ highlightedIndex={highlightedIndex}
336
+ highlightedGroupIndex={highlightedGroupIndex}
337
+ onOptionSelect={handleOptionSelect}
338
+ optionComponent={optionComponent}
339
+ />
340
+ </div>
341
+ );
342
+ };
342
343
 
343
344
  export const Combobox = ComboboxInner as <T extends ComboboxOption>(
344
345
  props: ComboboxProps<T> & { ref?: ComboboxRef },