Skip to content

Commit 5baa76f

Browse files
authored
Prevent menu flicker when closing ComboBox via blur (#4060)
* Revert "Prevent ComboBox item selection if menu is closing (#3999)" This reverts commit 8c61397. * Prevent flicker when menu is closed via clicking outside the popover * move from ref to state tracker for filtered collection also memos useFilter return so that filteredCollection isnt updated every single render * adding controlled test * replace useEffect in favor of updating the lastCollection just before close * Only updating lastCollection if we are going from open to close * fixing tests
1 parent f847cd0 commit 5baa76f

File tree

5 files changed

+172
-46
lines changed

5 files changed

+172
-46
lines changed

packages/@react-aria/i18n/src/useFilter.ts

Lines changed: 41 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {useCallback, useMemo} from 'react';
1314
import {useCollator} from './useCollator';
1415

1516
export interface Filter {
@@ -32,46 +33,51 @@ export function useFilter(options?: Intl.CollatorOptions): Filter {
3233
});
3334

3435
// TODO(later): these methods don't currently support the ignorePunctuation option.
36+
let startsWith = useCallback((string, substring) => {
37+
if (substring.length === 0) {
38+
return true;
39+
}
3540

36-
return {
37-
startsWith(string, substring) {
38-
if (substring.length === 0) {
39-
return true;
40-
}
41+
// Normalize both strings so we can slice safely
42+
// TODO: take into account the ignorePunctuation option as well...
43+
string = string.normalize('NFC');
44+
substring = substring.normalize('NFC');
45+
return collator.compare(string.slice(0, substring.length), substring) === 0;
46+
}, [collator]);
4147

42-
// Normalize both strings so we can slice safely
43-
// TODO: take into account the ignorePunctuation option as well...
44-
string = string.normalize('NFC');
45-
substring = substring.normalize('NFC');
46-
return collator.compare(string.slice(0, substring.length), substring) === 0;
47-
},
48-
endsWith(string, substring) {
49-
if (substring.length === 0) {
50-
return true;
51-
}
48+
let endsWith = useCallback((string, substring) => {
49+
if (substring.length === 0) {
50+
return true;
51+
}
5252

53-
string = string.normalize('NFC');
54-
substring = substring.normalize('NFC');
55-
return collator.compare(string.slice(-substring.length), substring) === 0;
56-
},
57-
contains(string, substring) {
58-
if (substring.length === 0) {
59-
return true;
60-
}
53+
string = string.normalize('NFC');
54+
substring = substring.normalize('NFC');
55+
return collator.compare(string.slice(-substring.length), substring) === 0;
56+
}, [collator]);
6157

62-
string = string.normalize('NFC');
63-
substring = substring.normalize('NFC');
58+
let contains = useCallback((string, substring) => {
59+
if (substring.length === 0) {
60+
return true;
61+
}
6462

65-
let scan = 0;
66-
let sliceLen = substring.length;
67-
for (; scan + sliceLen <= string.length; scan++) {
68-
let slice = string.slice(scan, scan + sliceLen);
69-
if (collator.compare(substring, slice) === 0) {
70-
return true;
71-
}
72-
}
63+
string = string.normalize('NFC');
64+
substring = substring.normalize('NFC');
7365

74-
return false;
66+
let scan = 0;
67+
let sliceLen = substring.length;
68+
for (; scan + sliceLen <= string.length; scan++) {
69+
let slice = string.slice(scan, scan + sliceLen);
70+
if (collator.compare(substring, slice) === 0) {
71+
return true;
72+
}
7573
}
76-
};
74+
75+
return false;
76+
}, [collator]);
77+
78+
return useMemo(() => ({
79+
startsWith,
80+
endsWith,
81+
contains
82+
}), [startsWith, endsWith, contains]);
7783
}

packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,11 @@ describe('SearchAutocomplete', function () {
873873
act(() => {
874874
fireEvent.change(searchAutocomplete, {target: {value: 'Bulba'}});
875875
jest.runAllTimers();
876+
877+
});
878+
expect(onOpenChange).toHaveBeenLastCalledWith(true, 'input');
879+
880+
act(() => {
876881
searchAutocomplete.blur();
877882
jest.runAllTimers();
878883
});

packages/@react-spectrum/combobox/test/ComboBox.test.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,9 +1532,14 @@ describe('ComboBox', function () {
15321532
userEvent.click(combobox);
15331533
jest.runAllTimers();
15341534
});
1535+
15351536
act(() => {
15361537
fireEvent.change(combobox, {target: {value: 'Bulba'}});
15371538
jest.runAllTimers();
1539+
});
1540+
expect(onOpenChange).toHaveBeenLastCalledWith(true, 'input');
1541+
1542+
act(() => {
15381543
combobox.blur();
15391544
jest.runAllTimers();
15401545
});

packages/@react-stately/combobox/src/useComboBoxState.ts

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import {Collection, FocusStrategy, Node} from '@react-types/shared';
1414
import {ComboBoxProps, MenuTriggerAction} from '@react-types/combobox';
1515
import {ListCollection, useSingleSelectListState} from '@react-stately/list';
1616
import {SelectState} from '@react-stately/select';
17+
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
1718
import {useControlledState} from '@react-stately/utils';
18-
import {useEffect, useMemo, useRef, useState} from 'react';
1919
import {useMenuTriggerState} from '@react-stately/menu';
2020

2121
export interface ComboBoxState<T> extends SelectState<T> {
@@ -75,7 +75,7 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
7575
// (scenario: user clicks on already selected option)
7676
if (key === selectedKey) {
7777
resetInputValue();
78-
triggerState.close();
78+
closeMenu();
7979
}
8080
};
8181

@@ -93,6 +93,7 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
9393
? collection
9494
: filterCollection(collection, inputValue, defaultFilter)
9595
), [collection, inputValue, defaultFilter, props.items]);
96+
let [lastCollection, setLastCollection] = useState(filteredCollection);
9697

9798
// Track what action is attempting to open the menu
9899
let menuOpenTrigger = useRef('focus' as MenuTriggerAction);
@@ -136,9 +137,26 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
136137
menuOpenTrigger.current = trigger;
137138
}
138139

139-
triggerState.toggle(focusStrategy);
140+
toggleMenu(focusStrategy);
140141
};
141142

143+
// If menu is going to close, save the current collection so we can freeze the displayed collection when the
144+
// user clicks outside the popover to close the menu. Prevents the menu contents from updating as the menu closes.
145+
let toggleMenu = useCallback((focusStrategy) => {
146+
if (triggerState.isOpen) {
147+
setLastCollection(filteredCollection);
148+
}
149+
150+
triggerState.toggle(focusStrategy);
151+
}, [triggerState, filteredCollection]);
152+
153+
let closeMenu = useCallback(() => {
154+
if (triggerState.isOpen) {
155+
setLastCollection(filteredCollection);
156+
triggerState.close();
157+
}
158+
}, [triggerState, filteredCollection]);
159+
142160
let lastValue = useRef(inputValue);
143161
let resetInputValue = () => {
144162
let itemText = collection.getItem(selectedKey)?.textValue ?? '';
@@ -172,15 +190,15 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
172190
triggerState.isOpen &&
173191
filteredCollection.size === 0
174192
) {
175-
triggerState.close();
193+
closeMenu();
176194
}
177195

178196
// Close when an item is selected.
179197
if (
180198
selectedKey != null &&
181199
selectedKey !== lastSelectedKey.current
182200
) {
183-
triggerState.close();
201+
closeMenu();
184202
}
185203

186204
// Clear focused key when input value changes and display filtered collection again.
@@ -248,7 +266,7 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
248266
let commitCustomValue = () => {
249267
lastSelectedKey.current = null;
250268
setSelectedKey(null);
251-
triggerState.close();
269+
closeMenu();
252270
};
253271

254272
let commitSelection = () => {
@@ -259,11 +277,11 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
259277
// Stop menu from reopening from useEffect
260278
let itemText = collection.getItem(selectedKey)?.textValue ?? '';
261279
lastValue.current = itemText;
262-
triggerState.close();
280+
closeMenu();
263281
} else {
264282
// If only a single aspect of combobox is controlled, reset input value and close menu for the user
265283
resetInputValue();
266-
triggerState.close();
284+
closeMenu();
267285
}
268286
};
269287

@@ -291,7 +309,7 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
291309
} else {
292310
commitSelection();
293311
}
294-
triggerState.close();
312+
closeMenu();
295313
};
296314

297315
let setFocused = (isFocused: boolean) => {
@@ -306,6 +324,18 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
306324
setFocusedState(isFocused);
307325
};
308326

327+
let displayedCollection = useMemo(() => {
328+
if (triggerState.isOpen) {
329+
if (showAllItems) {
330+
return originalCollection;
331+
} else {
332+
return filteredCollection;
333+
}
334+
} else {
335+
return lastCollection;
336+
}
337+
}, [triggerState.isOpen, originalCollection, filteredCollection, showAllItems, lastCollection]);
338+
309339
return {
310340
...triggerState,
311341
toggle,
@@ -318,7 +348,7 @@ export function useComboBoxState<T extends object>(props: ComboBoxStateOptions<T
318348
isFocused,
319349
setFocused,
320350
selectedItem,
321-
collection: showAllItems ? originalCollection : filteredCollection,
351+
collection: displayedCollection,
322352
inputValue,
323353
setInputValue,
324354
commit,

packages/@react-stately/combobox/test/useComboBoxState.test.js

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {actHook as act, renderHook} from '@react-spectrum/test-utils';
1414
import {Item} from '@react-stately/collections';
1515
import React from 'react';
1616
import {useComboBoxState} from '../';
17+
import {useFilter} from 'react-aria';
1718

1819
describe('useComboBoxState tests', function () {
1920
describe('open state', function () {
@@ -196,7 +197,7 @@ describe('useComboBoxState tests', function () {
196197
expect(onSelectionChange).toHaveBeenCalledWith('1');
197198
});
198199

199-
it('supports sdefault no selection', function () {
200+
it('supports default no selection', function () {
200201
let initialProps = {...defaultProps};
201202
let {result} = renderHook((props) => useComboBoxState(props), {initialProps});
202203
expect(result.current.selectionManager.selectionMode).toBe('single');
@@ -207,5 +208,84 @@ describe('useComboBoxState tests', function () {
207208
expect(result.current.selectionManager.selectedKeys).not.toContain('0');
208209
expect(onSelectionChange).toHaveBeenCalledWith('1');
209210
});
211+
212+
it('won\'t update the returned collection if the combobox is closed (uncontrolled items)', function () {
213+
let filter = renderHook((props) => useFilter(props), {sensitivity: 'base'});
214+
let initialProps = {...defaultProps, items: null, defaultItems: [{key: '0', name: 'one'}, {key: '1', name: 'onomatopoeia'}], defaultInputValue: '', defaultFilter: filter.result.current.contains};
215+
let {result} = renderHook((props) => useComboBoxState(props), {initialProps});
216+
expect(result.current.collection.size).toEqual(2);
217+
expect(result.current.inputValue).toBe('');
218+
219+
act(() => {result.current.open();});
220+
act(() => result.current.setInputValue('onom'));
221+
expect(result.current.inputValue).toBe('onom');
222+
expect(result.current.collection.size).toEqual(1);
223+
expect(result.current.collection.getItem('1').rendered).toBe('onomatopoeia');
224+
225+
// The input value updates, but the returned collection still only contains onomatopoeia
226+
act(() => {result.current.setFocused(false);});
227+
expect(result.current.collection.size).toEqual(1);
228+
expect(result.current.inputValue).toBe('');
229+
expect(result.current.collection.getItem('1').rendered).toBe('onomatopoeia');
230+
231+
// Subsequent calls that would close the menu don't update the tracked lastCollection
232+
act(() => {result.current.commit();});
233+
expect(result.current.collection.size).toEqual(1);
234+
expect(result.current.inputValue).toBe('');
235+
expect(result.current.collection.getItem('1').rendered).toBe('onomatopoeia');
236+
237+
act(() => {result.current.close();});
238+
expect(result.current.collection.size).toEqual(1);
239+
expect(result.current.inputValue).toBe('');
240+
expect(result.current.collection.getItem('1').rendered).toBe('onomatopoeia');
241+
242+
act(() => {result.current.revert();});
243+
expect(result.current.collection.size).toEqual(1);
244+
expect(result.current.inputValue).toBe('');
245+
expect(result.current.collection.getItem('1').rendered).toBe('onomatopoeia');
246+
247+
act(() => {result.current.open();});
248+
expect(result.current.collection.size).toEqual(2);
249+
});
250+
251+
it('won\'t update the returned collection if the combobox is closed (controlled items)', function () {
252+
let initialProps = {...defaultProps};
253+
let {result, rerender} = renderHook((props) => useComboBoxState(props), {initialProps});
254+
expect(result.current.collection.size).toEqual(2);
255+
256+
act(() => {result.current.open();});
257+
rerender({...initialProps, items: [{key: '1', name: 'onomatopoeia'}]});
258+
// Returned collection reflects the items provided by the user since the combobox is open
259+
expect(result.current.collection.size).toEqual(1);
260+
expect(result.current.collection.getItem('1').rendered).toBe('onomatopoeia');
261+
262+
act(() => {result.current.setFocused(false);});
263+
// Returned collection reflects the old items provided by the user when the combobox is closed
264+
expect(result.current.collection.size).toEqual(1);
265+
expect(result.current.collection.getItem('1').rendered).toBe('onomatopoeia');
266+
rerender(initialProps);
267+
expect(result.current.collection.size).toEqual(1);
268+
expect(result.current.collection.getItem('1').rendered).toBe('onomatopoeia');
269+
270+
// Subsequent calls that would close the menu don't update the tracked lastCollection
271+
act(() => {result.current.commit();});
272+
expect(result.current.collection.size).toEqual(1);
273+
expect(result.current.inputValue).toBe('');
274+
expect(result.current.collection.getItem('1').rendered).toBe('onomatopoeia');
275+
276+
act(() => {result.current.close();});
277+
expect(result.current.collection.size).toEqual(1);
278+
expect(result.current.inputValue).toBe('');
279+
expect(result.current.collection.getItem('1').rendered).toBe('onomatopoeia');
280+
281+
act(() => {result.current.revert();});
282+
expect(result.current.collection.size).toEqual(1);
283+
expect(result.current.inputValue).toBe('');
284+
expect(result.current.collection.getItem('1').rendered).toBe('onomatopoeia');
285+
286+
// When the combobox is opened again, the returned collection of items updates to reflect the items provided by the user
287+
act(() => {result.current.open();});
288+
expect(result.current.collection.size).toEqual(2);
289+
});
210290
});
211291
});

0 commit comments

Comments
 (0)