Skip to content

Commit ab188cd

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 a8b6174 commit ab188cd

File tree

7 files changed

+108
-40
lines changed

7 files changed

+108
-40
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/src/ListBox.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {DraggableCollectionState, DroppableCollectionState, ListState, Node, Ori
2020
import {filterDOMProps, mergeRefs, useObjectRef} from '@react-aria/utils';
2121
import {forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared';
2222
import {HeaderContext} from './Header';
23-
import {InternalAutocompleteContext} from './Autocomplete';
2423
import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react';
2524
import {SeparatorContext} from './Separator';
2625
import {TextContext} from './Text';

packages/react-aria-components/src/Menu.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import {ContextValue, Provider, RenderProps, ScrollableProps, SlotProps, StylePr
1818
import {filterDOMProps, mergeRefs, useObjectRef, useResizeObserver} from '@react-aria/utils';
1919
import {FocusStrategy, forwardRefType, HoverEvents, Key, LinkDOMProps, MultipleSelection} from '@react-types/shared';
2020
import {HeaderContext} from './Header';
21-
import {InternalAutocompleteContext} from './Autocomplete';
2221
import {KeyboardContext} from './Keyboard';
2322
import {MultipleSelectionState, SelectionManager, useMultipleSelectionState} from '@react-stately/selection';
2423
import {OverlayTriggerStateContext} from './Dialog';

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)