Skip to content

Commit 83baefd

Browse files
committed
handle async case with loading spinners
the collection might change to a loading state and thus have a size of 0. Dont reset focus first flag in that case
1 parent 216dbbc commit 83baefd

File tree

5 files changed

+108
-38
lines changed

5 files changed

+108
-38
lines changed

packages/@react-aria/selection/src/useSelectableCollection.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
411411

412412
let updateActiveDescendant = useEffectEvent(() => {
413413
let keyToFocus = delegate.getFirstKey?.() ?? null;
414+
414415
// If no focusable items exist in the list, make sure to clear any activedescendant that may still exist
415416
if (keyToFocus == null) {
416417
ref.current?.dispatchEvent(
@@ -419,27 +420,36 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
419420
bubbles: true
420421
})
421422
);
423+
} else {
424+
manager.setFocusedKey(keyToFocus);
425+
// Only set shouldVirtualFocusFirst to false if we've successfully set the first key as the focused key
426+
// If there wasn't a key to focus, we might be in a temporary loading state so we'll want to still focus the first key
427+
// after the collection updates after load
428+
shouldVirtualFocusFirst.current = false;
422429
}
423-
424-
manager.setFocusedKey(keyToFocus);
425430
});
426431

427432
let lastCollection = useRef(manager.collection);
428433
useEffect(() => {
429-
if (shouldVirtualFocusFirst.current && lastCollection !== manager.collection) {
434+
if (shouldVirtualFocusFirst.current && lastCollection.current !== manager.collection) {
430435
updateActiveDescendant();
431-
shouldVirtualFocusFirst.current = false;
432436
}
433437

434438
lastCollection.current = manager.collection;
435439
}, [manager.collection, updateActiveDescendant]);
436440

437-
// TODO: need to track last key
438-
useEffect(() => {
441+
let resetFocusFirstFlag = useEffectEvent(() => {
439442
// If user causes the focused key to change in any other way, clear shouldVirtualFocusFirst so we don't
440-
// accidentally move focus from under them
441-
shouldVirtualFocusFirst.current = false;
442-
}, [manager.focusedKey]);
443+
// accidentally move focus from under them. Skip this if the collection was empty because we might be in a load
444+
// state and will still want to focus the first item after load
445+
if (manager.collection.size > 0) {
446+
shouldVirtualFocusFirst.current = false;
447+
}
448+
});
449+
450+
useEffect(() => {
451+
resetFocusFirstFlag();
452+
}, [manager.focusedKey, resetFocusFirstFlag]);
443453

444454
useEvent(ref, CLEAR_FOCUS_EVENT, !shouldUseVirtualFocus ? undefined : (e) => {
445455
e.stopPropagation();

packages/@react-stately/selection/src/types.ts

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

13-
import {DisabledBehavior, FocusStrategy, Key, LongPressEvent, PressEvent, Selection, SelectionBehavior, SelectionMode} from '@react-types/shared';
13+
import {Collection, DisabledBehavior, FocusStrategy, Key, LongPressEvent, Node, PressEvent, Selection, SelectionBehavior, SelectionMode} from '@react-types/shared';
1414

1515

1616
export interface FocusState {
@@ -107,5 +107,6 @@ export interface MultipleSelectionManager extends FocusState {
107107
/** Returns whether the given key is a hyperlink. */
108108
isLink(key: Key): boolean,
109109
/** Returns the props for the given item. */
110-
getItemProps(key: Key): any
110+
getItemProps(key: Key): any,
111+
collection: Collection<Node<unknown>>
111112
}

packages/react-aria-components/stories/Autocomplete.stories.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,12 @@ const AsyncExample = (args) => {
218218
};
219219
}
220220
});
221-
let {onAction, onSelectionChange, selectionMode} = args;
221+
let {onSelectionChange, selectionMode, includeLoadState} = args;
222+
let renderEmptyState;
223+
if (includeLoadState) {
224+
renderEmptyState = list.isLoading ? () => 'Loading' : () => 'No results found.';
225+
}
226+
222227
return (
223228
<Autocomplete inputValue={list.filterText} onInputChange={list.setFilterText}>
224229
<div>
@@ -227,14 +232,14 @@ const AsyncExample = (args) => {
227232
<Input />
228233
<Text style={{display: 'block'}} slot="description">Please select an option below.</Text>
229234
</SearchField>
230-
<Menu<AutocompleteItem>
231-
items={list.items}
235+
<ListBox<AutocompleteItem>
236+
renderEmptyState={renderEmptyState}
237+
items={includeLoadState && list.isLoading ? [] : list.items}
232238
className={styles.menu}
233-
onAction={onAction}
234239
onSelectionChange={onSelectionChange}
235240
selectionMode={selectionMode}>
236-
{item => <MyMenuItem>{item.name}</MyMenuItem>}
237-
</Menu>
241+
{item => <MyListBoxItem>{item.name}</MyListBoxItem>}
242+
</ListBox>
238243
</div>
239244
</Autocomplete>
240245
);
@@ -244,7 +249,10 @@ export const AutocompleteAsyncLoadingExample = {
244249
render: (args) => {
245250
return <AsyncExample {...args} />;
246251
},
247-
name: 'Autocomplete, useAsync level filtering'
252+
name: 'Autocomplete, useAsync level filtering with load state',
253+
args: {
254+
includeLoadState: true
255+
}
248256
};
249257

250258
const CaseSensitiveFilter = (args) => {

packages/react-aria-components/test/AriaAutocomplete.test-util.tsx

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ interface AriaAutocompleteTestProps extends AriaBaseTestProps {
4545
disabledItems?: () => ReturnType<typeof render>,
4646
// should set a default value of "Ba" on the autocomplete. Uses the same collection items as the standard renderer
4747
defaultValue?: () => ReturnType<typeof render>,
48-
// should allow the user to simply provide a list of items to the wrapped collection without a filter. Uses the same collection items as the standard renderer
49-
noFilter?: () => ReturnType<typeof render>
48+
// should allow the user to filter the items themselves in a async manner. The items should be Foo, Bar, and Baz with ids 1, 2, and 3 respectively.
49+
// The filtering can take any amount of time but should be standard non-case sensitive contains matching
50+
asyncFiltering?: () => ReturnType<typeof render>
5051
// TODO, add tests for this when we support it
5152
// submenus?: (props?: {name: string}) => ReturnType<typeof render>
5253
},
@@ -431,23 +432,38 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = '
431432
});
432433
}
433434

434-
if (renderers.noFilter) {
435-
describe('no filter function provided', function () {
436-
it('should controlled items', async function () {
437-
let {getByRole} = (renderers.noFilter!)();
435+
if (renderers.asyncFiltering) {
436+
describe('async filtering performed outside the autocomplete', function () {
437+
it('should properly filter and autofocus the first item when typing forward', async function () {
438+
let {getByRole} = (renderers.asyncFiltering!)();
439+
await act(async () => {
440+
jest.runAllTimers();
441+
});
442+
443+
438444
let input = getByRole('searchbox');
439445
expect(input).toHaveValue('');
440446
let menu = getByRole(collectionNodeRole);
441447
let options = within(menu).getAllByRole(collectionItemRole);
442448
expect(options).toHaveLength(3);
443449

450+
// Does not immediately set aria-activedescendant until the collection updates
444451
await user.tab();
445452
expect(document.activeElement).toBe(input);
446453
await user.keyboard('F');
447-
act(() => jest.runAllTimers());
454+
expect(input).not.toHaveAttribute('aria-activedescendant');
455+
456+
await act(async () => {
457+
jest.advanceTimersToNextTimer();
458+
});
448459
options = within(menu).getAllByRole(collectionItemRole);
449-
expect(options).toHaveLength(3);
460+
expect(options).toHaveLength(1);
450461
expect(options[0]).toHaveTextContent('Foo');
462+
expect(input).not.toHaveAttribute('aria-activedescendant');
463+
464+
// Only sets aria-activedescendant after the collection updates and the delay passes
465+
act(() => jest.advanceTimersToNextTimer());
466+
expect(input).toHaveAttribute('aria-activedescendant', options[0].id);
451467
});
452468
});
453469
}

packages/react-aria-components/test/Autocomplete.test.tsx

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {AriaAutocompleteTests} from './AriaAutocomplete.test-util';
1414
import {Header, Input, Label, ListBox, ListBoxItem, ListBoxSection, Menu, MenuItem, MenuSection, SearchField, Separator, Text, UNSTABLE_Autocomplete} from '..';
1515
import React, {ReactNode} from 'react';
1616
import {render} from '@react-spectrum/test-utils-internal';
17+
import {useAsyncList} from 'react-stately';
1718
import {useFilter} from '@react-aria/i18n';
1819

1920
interface AutocompleteItem {
@@ -99,7 +100,7 @@ let ListBoxWithSections = (props) => (
99100
</ListBox>
100101
);
101102

102-
let AutocompleteWrapper = ({autocompleteProps = {}, inputProps = {}, children}: {autocompleteProps?: any, inputProps?: any, collectionProps?: any, children?: ReactNode}) => {
103+
let AutocompleteWrapper = ({autocompleteProps = {}, inputProps = {}, children}: {autocompleteProps?: any, inputProps?: any, children?: ReactNode}) => {
103104
let {contains} = useFilter({sensitivity: 'base'});
104105
let filter = (textValue, inputValue) => contains(textValue, inputValue);
105106

@@ -115,7 +116,7 @@ let AutocompleteWrapper = ({autocompleteProps = {}, inputProps = {}, children}:
115116
);
116117
};
117118

118-
let ControlledAutocomplete = ({autocompleteProps = {}, inputProps = {}, children}: {autocompleteProps?: any, inputProps?: any, collectionProps?: any, children?: ReactNode}) => {
119+
let ControlledAutocomplete = ({autocompleteProps = {}, inputProps = {}, children}: {autocompleteProps?: any, inputProps?: any, children?: ReactNode}) => {
119120
let [inputValue, setInputValue] = React.useState('');
120121
let {contains} = useFilter({sensitivity: 'base'});
121122
let filter = (textValue, inputValue) => contains(textValue, inputValue);
@@ -132,6 +133,47 @@ let ControlledAutocomplete = ({autocompleteProps = {}, inputProps = {}, children
132133
);
133134
};
134135

136+
let AsyncFiltering = ({autocompleteProps = {}, inputProps = {}}: {autocompleteProps?: any, inputProps?: any, children?: ReactNode}) => {
137+
let list = useAsyncList<AutocompleteItem>({
138+
async load({filterText}) {
139+
let json = await new Promise(resolve => {
140+
setTimeout(() => {
141+
resolve(filterText ? items.filter(item => {
142+
let name = item.name.toLowerCase();
143+
for (let filterChar of filterText.toLowerCase()) {
144+
if (!name.includes(filterChar)) {
145+
return false;
146+
}
147+
name = name.replace(filterChar, '');
148+
}
149+
return true;
150+
}) : items);
151+
}, 300);
152+
}) as AutocompleteItem[];
153+
154+
return {
155+
items: json
156+
};
157+
}
158+
});
159+
160+
return (
161+
<UNSTABLE_Autocomplete inputValue={list.filterText} onInputChange={list.setFilterText} {...autocompleteProps}>
162+
<SearchField {...inputProps}>
163+
<Label style={{display: 'block'}}>Test</Label>
164+
<Input />
165+
<Text style={{display: 'block'}} slot="description">Please select an option below.</Text>
166+
</SearchField>
167+
<Menu
168+
items={list.items}
169+
onAction={onAction}
170+
onSelectionChange={onSelectionChange}>
171+
{item => <MenuItem id={item.id}>{item.name}</MenuItem>}
172+
</Menu>
173+
</UNSTABLE_Autocomplete>
174+
);
175+
};
176+
135177
AriaAutocompleteTests({
136178
prefix: 'rac-static-menu',
137179
renderers: {
@@ -174,11 +216,6 @@ AriaAutocompleteTests({
174216
<AutocompleteWrapper autocompleteProps={{defaultInputValue: 'Ba'}}>
175217
<StaticMenu />
176218
</AutocompleteWrapper>
177-
),
178-
customFiltering: () => render(
179-
<AutocompleteWrapper autocompleteProps={{filter: () => true}}>
180-
<StaticMenu />
181-
</AutocompleteWrapper>
182219
)
183220
},
184221
actionListener: onAction,
@@ -192,6 +229,9 @@ AriaAutocompleteTests({
192229
<AutocompleteWrapper>
193230
<DynamicMenu />
194231
</AutocompleteWrapper>
232+
),
233+
asyncFiltering: () => render(
234+
<AsyncFiltering />
195235
)
196236
}
197237
});
@@ -233,11 +273,6 @@ AriaAutocompleteTests({
233273
<AutocompleteWrapper autocompleteProps={{defaultInputValue: 'Ba'}}>
234274
<StaticListbox />
235275
</AutocompleteWrapper>
236-
),
237-
noFilter: () => render(
238-
<AutocompleteWrapper autocompleteProps={{filter: null}}>
239-
<StaticListbox />
240-
</AutocompleteWrapper>
241276
)
242277
},
243278
ariaPattern: 'listbox',

0 commit comments

Comments
 (0)