@react-stately/combobox 3.0.0-beta.1 → 3.0.0-nightly-4980928d3-240906

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.
@@ -10,29 +10,36 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {Collection, FocusStrategy, Node} from '@react-types/shared';
13
+ import {Collection, CollectionStateBase, FocusStrategy, Node} from '@react-types/shared';
14
14
  import {ComboBoxProps, MenuTriggerAction} from '@react-types/combobox';
15
+ import {FormValidationState, useFormValidationState} from '@react-stately/form';
16
+ import {getChildNodes} from '@react-stately/collections';
15
17
  import {ListCollection, useSingleSelectListState} from '@react-stately/list';
16
18
  import {SelectState} from '@react-stately/select';
19
+ import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
17
20
  import {useControlledState} from '@react-stately/utils';
18
- import {useEffect, useMemo, useRef, useState} from 'react';
19
- import {useMenuTriggerState} from '@react-stately/menu';
21
+ import {useOverlayTriggerState} from '@react-stately/overlays';
20
22
 
21
- export interface ComboBoxState<T> extends SelectState<T> {
23
+ export interface ComboBoxState<T> extends SelectState<T>, FormValidationState{
22
24
  /** The current value of the combo box input. */
23
25
  inputValue: string,
24
26
  /** Sets the value of the combo box input. */
25
27
  setInputValue(value: string): void,
26
28
  /** Selects the currently focused item and updates the input value. */
27
29
  commit(): void,
30
+ /** Controls which item will be auto focused when the menu opens. */
31
+ readonly focusStrategy: FocusStrategy | null,
28
32
  /** Opens the menu. */
29
33
  open(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void,
30
34
  /** Toggles the menu. */
31
- toggle(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void
35
+ toggle(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void,
36
+ /** Resets the input value to the previously selected item's text if any and closes the menu. */
37
+ revert(): void
32
38
  }
33
39
 
34
40
  type FilterFn = (textValue: string, inputValue: string) => boolean;
35
- interface ComboBoxStateProps<T> extends ComboBoxProps<T> {
41
+
42
+ export interface ComboBoxStateOptions<T> extends Omit<ComboBoxProps<T>, 'children'>, CollectionStateBase<T> {
36
43
  /** The filter function used to determine if a option should be included in the combo box list. */
37
44
  defaultFilter?: FilterFn,
38
45
  /** Whether the combo box allows the menu to be open when the collection is empty. */
@@ -46,7 +53,7 @@ interface ComboBoxStateProps<T> extends ComboBoxProps<T> {
46
53
  * of items from props and manages the option selection state of the combo box. In addition, it tracks the input value,
47
54
  * focus state, and other properties of the combo box.
48
55
  */
49
- export function useComboBoxState<T extends object>(props: ComboBoxStateProps<T>): ComboBoxState<T> {
56
+ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T>): ComboBoxState<T> {
50
57
  let {
51
58
  defaultFilter,
52
59
  menuTrigger = 'input',
@@ -57,32 +64,46 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateProps<T>)
57
64
 
58
65
  let [showAllItems, setShowAllItems] = useState(false);
59
66
  let [isFocused, setFocusedState] = useState(false);
60
- let [inputValue, setInputValue] = useControlledState(
61
- props.inputValue,
62
- props.defaultInputValue ?? '',
63
- props.onInputChange
64
- );
67
+ let [focusStrategy, setFocusStrategy] = useState<FocusStrategy | null>(null);
65
68
 
66
69
  let onSelectionChange = (key) => {
67
70
  if (props.onSelectionChange) {
68
71
  props.onSelectionChange(key);
69
72
  }
70
73
 
71
- // If open state or selectedKey is uncontrolled and key is the same, reset the inputValue and close the menu
74
+ // If key is the same, reset the inputValue and close the menu
72
75
  // (scenario: user clicks on already selected option)
73
- if (props.isOpen === undefined || props.selectedKey === undefined) {
74
- if (key === selectedKey) {
75
- resetInputValue();
76
- triggerState.close();
77
- }
76
+ if (key === selectedKey) {
77
+ resetInputValue();
78
+ closeMenu();
78
79
  }
79
80
  };
80
81
 
81
- let {collection, selectionManager, selectedKey, setSelectedKey, selectedItem, disabledKeys} = useSingleSelectListState({
82
+ let {collection,
83
+ selectionManager,
84
+ selectedKey,
85
+ setSelectedKey,
86
+ selectedItem,
87
+ disabledKeys
88
+ } = useSingleSelectListState({
82
89
  ...props,
83
90
  onSelectionChange,
84
91
  items: props.items ?? props.defaultItems
85
92
  });
93
+ let defaultInputValue: string | null | undefined = props.defaultInputValue;
94
+ if (defaultInputValue == null) {
95
+ if (selectedKey == null) {
96
+ defaultInputValue = '';
97
+ } else {
98
+ defaultInputValue = collection.getItem(selectedKey)?.textValue ?? '';
99
+ }
100
+ }
101
+
102
+ let [inputValue, setInputValue] = useControlledState(
103
+ props.inputValue,
104
+ defaultInputValue!,
105
+ props.onInputChange
106
+ );
86
107
 
87
108
  // Preserve original collection so we can show all items on demand
88
109
  let originalCollection = collection;
@@ -92,17 +113,23 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateProps<T>)
92
113
  ? collection
93
114
  : filterCollection(collection, inputValue, defaultFilter)
94
115
  ), [collection, inputValue, defaultFilter, props.items]);
116
+ let [lastCollection, setLastCollection] = useState(filteredCollection);
95
117
 
96
118
  // Track what action is attempting to open the menu
97
- let menuOpenTrigger = useRef('focus' as MenuTriggerAction);
119
+ let menuOpenTrigger = useRef<MenuTriggerAction | undefined>('focus');
98
120
  let onOpenChange = (open: boolean) => {
99
121
  if (props.onOpenChange) {
100
122
  props.onOpenChange(open, open ? menuOpenTrigger.current : undefined);
101
123
  }
124
+
125
+ selectionManager.setFocused(open);
126
+ if (!open) {
127
+ selectionManager.setFocusedKey(null);
128
+ }
102
129
  };
103
130
 
104
- let triggerState = useMenuTriggerState({...props, onOpenChange});
105
- let open = (focusStrategy?: FocusStrategy, trigger?: MenuTriggerAction) => {
131
+ let triggerState = useOverlayTriggerState({...props, onOpenChange, isOpen: undefined, defaultOpen: undefined});
132
+ let open = (focusStrategy: FocusStrategy | null = null, trigger?: MenuTriggerAction) => {
106
133
  let displayAllItems = (trigger === 'manual' || (trigger === 'focus' && menuTrigger === 'focus'));
107
134
  // Prevent open operations from triggering if there is nothing to display
108
135
  // Also prevent open operations from triggering if items are uncontrolled but defaultItems is empty, even if displayAllItems is true.
@@ -114,11 +141,12 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateProps<T>)
114
141
  }
115
142
 
116
143
  menuOpenTrigger.current = trigger;
117
- triggerState.open(focusStrategy);
144
+ setFocusStrategy(focusStrategy);
145
+ triggerState.open();
118
146
  }
119
147
  };
120
148
 
121
- let toggle = (focusStrategy?: FocusStrategy, trigger?: MenuTriggerAction) => {
149
+ let toggle = (focusStrategy: FocusStrategy | null = null, trigger?: MenuTriggerAction) => {
122
150
  let displayAllItems = (trigger === 'manual' || (trigger === 'focus' && menuTrigger === 'focus'));
123
151
  // If the menu is closed and there is nothing to display, early return so toggle isn't called to prevent extraneous onOpenChange
124
152
  if (!(allowsEmptyCollection || filteredCollection.size > 0 || (displayAllItems && originalCollection.size > 0) || props.items) && !triggerState.isOpen) {
@@ -135,58 +163,78 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateProps<T>)
135
163
  menuOpenTrigger.current = trigger;
136
164
  }
137
165
 
138
- triggerState.toggle(focusStrategy);
166
+ toggleMenu(focusStrategy);
139
167
  };
140
168
 
141
- let lastValue = useRef(inputValue);
169
+ let updateLastCollection = useCallback(() => {
170
+ setLastCollection(showAllItems ? originalCollection : filteredCollection);
171
+ }, [showAllItems, originalCollection, filteredCollection]);
172
+
173
+ // If menu is going to close, save the current collection so we can freeze the displayed collection when the
174
+ // user clicks outside the popover to close the menu. Prevents the menu contents from updating as the menu closes.
175
+ let toggleMenu = useCallback((focusStrategy: FocusStrategy | null = null) => {
176
+ if (triggerState.isOpen) {
177
+ updateLastCollection();
178
+ }
179
+
180
+ setFocusStrategy(focusStrategy);
181
+ triggerState.toggle();
182
+ }, [triggerState, updateLastCollection]);
183
+
184
+ let closeMenu = useCallback(() => {
185
+ if (triggerState.isOpen) {
186
+ updateLastCollection();
187
+ triggerState.close();
188
+ }
189
+ }, [triggerState, updateLastCollection]);
190
+
191
+ let [lastValue, setLastValue] = useState(inputValue);
142
192
  let resetInputValue = () => {
143
- let itemText = collection.getItem(selectedKey)?.textValue ?? '';
144
- lastValue.current = itemText;
193
+ let itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : '';
194
+ setLastValue(itemText);
145
195
  setInputValue(itemText);
146
196
  };
147
197
 
148
- let isInitialRender = useRef(true);
149
198
  let lastSelectedKey = useRef(props.selectedKey ?? props.defaultSelectedKey ?? null);
150
- let lastSelectedKeyText = useRef(collection.getItem(selectedKey)?.textValue ?? '');
199
+ let lastSelectedKeyText = useRef(
200
+ selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : ''
201
+ );
151
202
  // intentional omit dependency array, want this to happen on every render
152
203
  // eslint-disable-next-line react-hooks/exhaustive-deps
153
204
  useEffect(() => {
154
- // If open state or inputValue is uncontrolled, open and close automatically when the input value changes,
155
- // the input is if focused, and there are items in the collection or allowEmptyCollection is true.
205
+ // Open and close menu automatically when the input value changes if the input is focused,
206
+ // and there are items in the collection or allowEmptyCollection is true.
156
207
  if (
157
208
  isFocused &&
158
209
  (filteredCollection.size > 0 || allowsEmptyCollection) &&
159
210
  !triggerState.isOpen &&
160
- inputValue !== lastValue.current &&
161
- menuTrigger !== 'manual' &&
162
- (props.isOpen === undefined || props.inputValue === undefined)
211
+ inputValue !== lastValue &&
212
+ menuTrigger !== 'manual'
163
213
  ) {
164
214
  open(null, 'input');
165
215
  }
166
216
 
167
- // Close the menu if the collection is empty and either open state or items are uncontrolled.
168
- // Don't close menu if filtered collection size is 0 but we are currently showing all items via button press (only applies to uncontrolled items)
217
+ // Close the menu if the collection is empty. Don't close menu if filtered collection size is 0
218
+ // but we are currently showing all items via button press
169
219
  if (
170
220
  !showAllItems &&
171
221
  !allowsEmptyCollection &&
172
222
  triggerState.isOpen &&
173
- filteredCollection.size === 0 &&
174
- (props.isOpen === undefined || props.items === undefined)
223
+ filteredCollection.size === 0
175
224
  ) {
176
- triggerState.close();
225
+ closeMenu();
177
226
  }
178
227
 
179
- // Close when an item is selected, if open state or selectedKey is uncontrolled.
228
+ // Close when an item is selected.
180
229
  if (
181
230
  selectedKey != null &&
182
- selectedKey !== lastSelectedKey.current &&
183
- (props.isOpen === undefined || props.selectedKey === undefined)
231
+ selectedKey !== lastSelectedKey.current
184
232
  ) {
185
- triggerState.close();
233
+ closeMenu();
186
234
  }
187
235
 
188
236
  // Clear focused key when input value changes and display filtered collection again.
189
- if (inputValue !== lastValue.current) {
237
+ if (inputValue !== lastValue) {
190
238
  selectionManager.setFocusedKey(null);
191
239
  setShowAllItems(false);
192
240
 
@@ -197,11 +245,6 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateProps<T>)
197
245
  }
198
246
  }
199
247
 
200
- // If it is the intial render and inputValue isn't controlled nor has an intial value, set input to match current selected key if any
201
- if (isInitialRender.current && (props.inputValue === undefined && props.defaultInputValue === undefined)) {
202
- resetInputValue();
203
- }
204
-
205
248
  // If the selectedKey changed, update the input value.
206
249
  // Do nothing if both inputValue and selectedKey are controlled.
207
250
  // In this case, it's the user's responsibility to update inputValue in onSelectionChange.
@@ -210,81 +253,69 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateProps<T>)
210
253
  (props.inputValue === undefined || props.selectedKey === undefined)
211
254
  ) {
212
255
  resetInputValue();
213
- } else {
214
- lastValue.current = inputValue;
256
+ } else if (lastValue !== inputValue) {
257
+ setLastValue(inputValue);
215
258
  }
216
259
 
217
260
  // Update the inputValue if the selected item's text changes from its last tracked value.
218
261
  // This is to handle cases where a selectedKey is specified but the items aren't available (async loading) or the selected item's text value updates.
219
262
  // Only reset if the user isn't currently within the field so we don't erroneously modify user input.
220
263
  // If inputValue is controlled, it is the user's responsibility to update the inputValue when items change.
221
- let selectedItemText = collection.getItem(selectedKey)?.textValue ?? '';
264
+ let selectedItemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : '';
222
265
  if (!isFocused && selectedKey != null && props.inputValue === undefined && selectedKey === lastSelectedKey.current) {
223
266
  if (lastSelectedKeyText.current !== selectedItemText) {
224
- lastValue.current = selectedItemText;
267
+ setLastValue(selectedItemText);
225
268
  setInputValue(selectedItemText);
226
269
  }
227
270
  }
228
271
 
229
- isInitialRender.current = false;
230
272
  lastSelectedKey.current = selectedKey;
231
273
  lastSelectedKeyText.current = selectedItemText;
232
274
  });
233
275
 
234
- useEffect(() => {
235
- // Reset focused key when the menu closes
236
- if (!triggerState.isOpen) {
237
- selectionManager.setFocusedKey(null);
276
+ let validation = useFormValidationState({
277
+ ...props,
278
+ value: useMemo(() => ({inputValue, selectedKey}), [inputValue, selectedKey])
279
+ });
280
+
281
+ // Revert input value and close menu
282
+ let revert = () => {
283
+ if (allowsCustomValue && selectedKey == null) {
284
+ commitCustomValue();
285
+ } else {
286
+ commitSelection();
238
287
  }
239
- }, [triggerState.isOpen, selectionManager]);
288
+ };
240
289
 
241
290
  let commitCustomValue = () => {
242
- let shouldClose = false;
243
291
  lastSelectedKey.current = null;
244
292
  setSelectedKey(null);
245
-
246
- // If previous key was already null, need to manually call onSelectionChange since it won't be triggered by a setSelectedKey call
247
- // This allows the application to control whether or not to close the menu on custom value commit
248
- if (selectedKey === null && props.onSelectionChange) {
249
- props.onSelectionChange(null);
250
- }
251
-
252
- // Should close menu ourselves if component open state or selected key is uncontrolled and therefore won't be closed by a user defined event handler
253
- shouldClose = props.isOpen == null || props.selectedKey === undefined;
254
-
255
- // Close if no other event will be fired. Otherwise, allow the
256
- // application to control this based on that event.
257
- if (shouldClose) {
258
- triggerState.close();
259
- }
293
+ closeMenu();
260
294
  };
261
295
 
262
296
  let commitSelection = () => {
263
297
  // If multiple things are controlled, call onSelectionChange
264
- // Note that users who control open state and input value MUST also have a onSelectionChange handler to properly close the menu and reset input value
265
- if (
266
- (props.selectedKey !== undefined && props.inputValue !== undefined) ||
267
- (props.inputValue !== undefined && props.isOpen !== undefined) ||
268
- (props.selectedKey !== undefined && props.isOpen !== undefined)
269
- ) {
270
- props.onSelectionChange(selectedKey);
271
-
272
- // If multiple things are controlled but inputValue isn't, reset the input value for the user
273
- if (props.inputValue === undefined) {
274
- resetInputValue();
275
- }
298
+ if (props.selectedKey !== undefined && props.inputValue !== undefined) {
299
+ props.onSelectionChange?.(selectedKey);
276
300
 
277
- // If multiple things are controlled but isOpen isn't, close the menu for the user
278
- if (props.isOpen === undefined) {
279
- // Stop menu from reopening from useEffect
280
- let itemText = collection.getItem(selectedKey)?.textValue ?? '';
281
- lastValue.current = itemText;
282
- triggerState.close();
283
- }
301
+ // Stop menu from reopening from useEffect
302
+ let itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : '';
303
+ setLastValue(itemText);
304
+ closeMenu();
284
305
  } else {
285
306
  // If only a single aspect of combobox is controlled, reset input value and close menu for the user
286
307
  resetInputValue();
287
- triggerState.close();
308
+ closeMenu();
309
+ }
310
+ };
311
+
312
+ const commitValue = () => {
313
+ if (allowsCustomValue) {
314
+ const itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : '';
315
+ (inputValue === itemText) ? commitSelection() : commitCustomValue();
316
+ } else {
317
+ // Reset inputValue and close menu
318
+ commitSelection();
288
319
  }
289
320
  };
290
321
 
@@ -297,32 +328,50 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateProps<T>)
297
328
  } else {
298
329
  setSelectedKey(selectionManager.focusedKey);
299
330
  }
300
- } else if (allowsCustomValue) {
301
- commitCustomValue();
331
+ } else {
332
+ commitValue();
302
333
  }
303
334
  };
304
335
 
336
+ let valueOnFocus = useRef(inputValue);
305
337
  let setFocused = (isFocused: boolean) => {
306
338
  if (isFocused) {
307
- if (menuTrigger === 'focus') {
339
+ valueOnFocus.current = inputValue;
340
+ if (menuTrigger === 'focus' && !props.isReadOnly) {
308
341
  open(null, 'focus');
309
342
  }
310
- } else if (shouldCloseOnBlur) {
311
- let itemText = collection.getItem(selectedKey)?.textValue ?? '';
312
- if (allowsCustomValue && inputValue !== itemText) {
313
- commitCustomValue();
314
- } else {
315
- commitSelection();
343
+ } else {
344
+ if (shouldCloseOnBlur) {
345
+ commitValue();
346
+ }
347
+
348
+ if (inputValue !== valueOnFocus.current) {
349
+ validation.commitValidation();
316
350
  }
317
351
  }
318
352
 
319
353
  setFocusedState(isFocused);
320
354
  };
321
355
 
356
+ let displayedCollection = useMemo(() => {
357
+ if (triggerState.isOpen) {
358
+ if (showAllItems) {
359
+ return originalCollection;
360
+ } else {
361
+ return filteredCollection;
362
+ }
363
+ } else {
364
+ return lastCollection;
365
+ }
366
+ }, [triggerState.isOpen, originalCollection, filteredCollection, showAllItems, lastCollection]);
367
+
322
368
  return {
369
+ ...validation,
323
370
  ...triggerState,
371
+ focusStrategy,
324
372
  toggle,
325
373
  open,
374
+ close: commitValue,
326
375
  selectionManager,
327
376
  selectedKey,
328
377
  setSelectedKey,
@@ -330,26 +379,29 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateProps<T>)
330
379
  isFocused,
331
380
  setFocused,
332
381
  selectedItem,
333
- collection: showAllItems ? originalCollection : filteredCollection,
382
+ collection: displayedCollection,
334
383
  inputValue,
335
384
  setInputValue,
336
- commit
385
+ commit,
386
+ revert
337
387
  };
338
388
  }
339
389
 
340
390
  function filterCollection<T extends object>(collection: Collection<Node<T>>, inputValue: string, filter: FilterFn): Collection<Node<T>> {
341
- return new ListCollection(filterNodes(collection, inputValue, filter));
391
+ return new ListCollection(filterNodes(collection, collection, inputValue, filter));
342
392
  }
343
393
 
344
- function filterNodes<T>(nodes: Iterable<Node<T>>, inputValue: string, filter: FilterFn): Iterable<Node<T>> {
345
- let filteredNode = [];
394
+ function filterNodes<T>(collection: Collection<Node<T>>, nodes: Iterable<Node<T>>, inputValue: string, filter: FilterFn): Iterable<Node<T>> {
395
+ let filteredNode: Node<T>[] = [];
346
396
  for (let node of nodes) {
347
397
  if (node.type === 'section' && node.hasChildNodes) {
348
- let filtered = filterNodes(node.childNodes, inputValue, filter);
349
- if ([...filtered].length > 0) {
398
+ let filtered = filterNodes(collection, getChildNodes(node, collection), inputValue, filter);
399
+ if ([...filtered].some(node => node.type === 'item')) {
350
400
  filteredNode.push({...node, childNodes: filtered});
351
401
  }
352
- } else if (node.type !== 'section' && filter(node.textValue, inputValue)) {
402
+ } else if (node.type === 'item' && filter(node.textValue, inputValue)) {
403
+ filteredNode.push({...node});
404
+ } else if (node.type !== 'item') {
353
405
  filteredNode.push({...node});
354
406
  }
355
407
  }