Skip to content

Commit c246c2e

Browse files
authored
fix: Autocomplete event leak and erroneous item focus after backspacing (#7584)
* Prevent keydown events from leaking out of the autocomplete * Fix edge case where item was getting autofocused when backspacing
1 parent d969d73 commit c246c2e

File tree

4 files changed

+67
-6
lines changed

4 files changed

+67
-6
lines changed

packages/@react-aria/autocomplete/src/useAutocomplete.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state:
223223
}
224224
};
225225

226-
let onKeyUp = useEffectEvent((e) => {
226+
let onKeyUpCapture = useEffectEvent((e) => {
227227
// Dispatch simulated key up events for things like triggering links in listbox
228228
// Make sure to stop the propagation of the input keyup event so that the simulated keyup/down pair
229229
// is detected by usePress instead of the original keyup originating from the input
@@ -243,11 +243,11 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state:
243243
});
244244

245245
useEffect(() => {
246-
document.addEventListener('keyup', onKeyUp, true);
246+
document.addEventListener('keyup', onKeyUpCapture, true);
247247
return () => {
248-
document.removeEventListener('keyup', onKeyUp, true);
248+
document.removeEventListener('keyup', onKeyUpCapture, true);
249249
};
250-
}, [inputRef, onKeyUp]);
250+
}, [inputRef, onKeyUpCapture]);
251251

252252
let {keyboardProps} = useKeyboard({onKeyDown});
253253

@@ -284,7 +284,10 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state:
284284
collectionProps: mergeProps(collectionProps, {
285285
// TODO: shouldFocusOnHover? shouldFocusWrap? Should it be up to the wrapped collection?
286286
shouldUseVirtualFocus: true,
287-
disallowTypeAhead: true
287+
disallowTypeAhead: true,
288+
// Prevent the emulated keyboard events that were dispatched on the wrapped collection from propagating outside of the autocomplete since techincally
289+
// they've been handled by the input already
290+
onKeyDown: (e) => e.stopPropagation()
288291
}),
289292
collectionRef: mergedCollectionRef,
290293
filterFn: filter != null ? filterFn : undefined

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,12 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
420420
bubbles: true
421421
})
422422
);
423+
424+
// If there wasn't a focusable key but the collection had items, then that means we aren't in an intermediate load state and all keys are disabled.
425+
// Reset shouldVirtualFocusFirst so that we don't erronously autofocus an item when the collection is filtered again.
426+
if (manager.collection.size > 0) {
427+
shouldVirtualFocusFirst.current = false;
428+
}
423429
} else {
424430
manager.setFocusedKey(keyToFocus);
425431
// Only set shouldVirtualFocusFirst to false if we've successfully set the first key as the focused key

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,31 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = '
395395
expect(actionListener).toHaveBeenCalledTimes(0);
396396
});
397397
});
398+
399+
it('should not autofocus the first item if backspacing from a list state where there are only disabled items', async function () {
400+
let {getByRole} = (renderers.disabledItems!)();
401+
let input = getByRole('searchbox');
402+
let menu = getByRole(collectionNodeRole);
403+
let options = within(menu).getAllByRole(collectionItemRole);
404+
expect(options[1]).toHaveAttribute('aria-disabled', 'true');
405+
406+
await user.tab();
407+
expect(document.activeElement).toBe(input);
408+
await user.keyboard('r');
409+
act(() => jest.runAllTimers());
410+
options = within(menu).getAllByRole(collectionItemRole);
411+
expect(options).toHaveLength(1);
412+
expect(input).not.toHaveAttribute('aria-activedescendant');
413+
expect(options[0]).toHaveAttribute('aria-disabled', 'true');
414+
415+
await user.keyboard('{Backspace}');
416+
act(() => jest.runAllTimers());
417+
options = within(menu).getAllByRole(collectionItemRole);
418+
expect(input).not.toHaveAttribute('aria-activedescendant');
419+
await user.keyboard('{ArrowDown}');
420+
act(() => jest.runAllTimers());
421+
expect(input).toHaveAttribute('aria-activedescendant', options[0].id);
422+
});
398423
}
399424

400425
let filterTests = (renderer) => {

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@
1212

1313
import {AriaAutocompleteTests} from './AriaAutocomplete.test-util';
1414
import {Header, Input, Label, ListBox, ListBoxItem, ListBoxSection, Menu, MenuItem, MenuSection, SearchField, Separator, Text, UNSTABLE_Autocomplete} from '..';
15+
import {pointerMap, render} from '@react-spectrum/test-utils-internal';
1516
import React, {ReactNode} from 'react';
16-
import {render} from '@react-spectrum/test-utils-internal';
1717
import {useAsyncList} from 'react-stately';
1818
import {useFilter} from '@react-aria/i18n';
19+
import userEvent from '@testing-library/user-event';
1920

2021
interface AutocompleteItem {
2122
id: string,
@@ -174,6 +175,32 @@ let AsyncFiltering = ({autocompleteProps = {}, inputProps = {}}: {autocompletePr
174175
);
175176
};
176177

178+
describe('Autocomplete', () => {
179+
let user;
180+
beforeAll(() => {
181+
user = userEvent.setup({delay: null, pointerMap});
182+
});
183+
184+
it('should prevent key presses from leaking out of the Autocomplete', async () => {
185+
let onKeyDown = jest.fn();
186+
let {getByRole} = render(
187+
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
188+
<div onKeyDown={onKeyDown}>
189+
<AutocompleteWrapper>
190+
<StaticMenu />
191+
</AutocompleteWrapper>
192+
</div>
193+
);
194+
195+
let input = getByRole('searchbox');
196+
await user.tab();
197+
expect(document.activeElement).toBe(input);
198+
await user.keyboard('{ArrowDown}');
199+
expect(onKeyDown).not.toHaveBeenCalled();
200+
onKeyDown.mockReset();
201+
});
202+
});
203+
177204
AriaAutocompleteTests({
178205
prefix: 'rac-static-menu',
179206
renderers: {

0 commit comments

Comments
 (0)