Skip to content

Commit 4b8b33a

Browse files
authored
fix: Autocomplete 'tab' key forwarding (#7724)
* fix: Autocomplete 'tab' key forwarding * fix react 16 compatibility * make better story name * use daniel's fix * Update packages/react-aria-components/test/Autocomplete.test.tsx
1 parent e8de3f8 commit 4b8b33a

File tree

3 files changed

+220
-3
lines changed

3 files changed

+220
-3
lines changed

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,13 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state:
171171
break;
172172
case ' ':
173173
// Space shouldn't trigger onAction so early return.
174-
174+
return;
175+
case 'Tab':
176+
// Don't propogate Tab down to the collection, otherwise we will try to focus the collection via useSelectableCollection's Tab handler (aka shift tab logic)
177+
// We want FocusScope to handle Tab if one exists (aka sub dialog), so special casepropogate
178+
if ('continuePropagation' in e) {
179+
e.continuePropagation();
180+
}
175181
return;
176182
case 'Home':
177183
case 'End':

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

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import {action} from '@storybook/addon-actions';
14-
import {UNSTABLE_Autocomplete as Autocomplete, Button, Collection, Dialog, DialogTrigger, Header, Input, Keyboard, Label, ListBox, ListBoxSection, UNSTABLE_ListLayout as ListLayout, Menu, MenuSection, MenuTrigger, Popover, SearchField, Separator, Text, TextField, UNSTABLE_Virtualizer as Virtualizer} from 'react-aria-components';
14+
import {UNSTABLE_Autocomplete as Autocomplete, Button, Collection, Dialog, DialogTrigger, Header, Input, Keyboard, Label, ListBox, ListBoxSection, UNSTABLE_ListLayout as ListLayout, Menu, MenuItem, MenuSection, MenuTrigger, Popover, SearchField, Separator, Text, TextField, UNSTABLE_Virtualizer as Virtualizer} from 'react-aria-components';
1515
import {MyListBoxItem, MyMenuItem} from './utils';
1616
import React, {useMemo} from 'react';
1717
import styles from '../example/index.css';
@@ -517,3 +517,84 @@ export const AutocompleteInPopoverDialogTrigger = {
517517
}
518518
}
519519
};
520+
521+
const MyMenu = () => {
522+
let {contains} = useFilter({sensitivity: 'base'});
523+
524+
return (
525+
<DialogTrigger>
526+
<Button aria-label="Menu"></Button>
527+
<Popover>
528+
<Dialog>
529+
<Button>First</Button>
530+
<Button>Second</Button>
531+
<Autocomplete filter={contains}>
532+
<TextField autoFocus>
533+
<Input />
534+
</TextField>
535+
<Menu>
536+
<MenuItem onAction={() => console.log('open')}>Open</MenuItem>
537+
<MenuItem onAction={() => console.log('rename')}>
538+
Rename…
539+
</MenuItem>
540+
<MenuItem onAction={() => console.log('duplicate')}>
541+
Duplicate
542+
</MenuItem>
543+
<MenuItem onAction={() => console.log('share')}>Share…</MenuItem>
544+
<MenuItem onAction={() => console.log('delete')}>
545+
Delete…
546+
</MenuItem>
547+
</Menu>
548+
</Autocomplete>
549+
</Dialog>
550+
</Popover>
551+
</DialogTrigger>
552+
);
553+
};
554+
555+
const MyMenu2 = () => {
556+
let {contains} = useFilter({sensitivity: 'base'});
557+
558+
return (
559+
<DialogTrigger>
560+
<Button aria-label="Menu"></Button>
561+
<Popover>
562+
<Dialog>
563+
<Autocomplete filter={contains}>
564+
<TextField autoFocus>
565+
<Input />
566+
</TextField>
567+
<Menu>
568+
<MenuItem onAction={() => console.log('open')}>Open</MenuItem>
569+
<MenuItem onAction={() => console.log('rename')}>
570+
Rename…
571+
</MenuItem>
572+
<MenuItem onAction={() => console.log('duplicate')}>
573+
Duplicate
574+
</MenuItem>
575+
<MenuItem onAction={() => console.log('share')}>Share…</MenuItem>
576+
<MenuItem onAction={() => console.log('delete')}>
577+
Delete…
578+
</MenuItem>
579+
</Menu>
580+
</Autocomplete>
581+
<Button>First</Button>
582+
<Button>Second</Button>
583+
</Dialog>
584+
</Popover>
585+
</DialogTrigger>
586+
);
587+
};
588+
589+
export function AutocompleteWithExtraButtons() {
590+
return (
591+
<div>
592+
<input />
593+
<div style={{display: 'flex', gap: '200px'}}>
594+
<MyMenu />
595+
<MyMenu2 />
596+
</div>
597+
<input />
598+
</div>
599+
);
600+
}

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

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import {AriaAutocompleteTests} from './AriaAutocomplete.test-util';
14-
import {Button, Header, Input, Label, ListBox, ListBoxItem, ListBoxSection, Menu, MenuItem, MenuSection, SearchField, Separator, Text, UNSTABLE_Autocomplete} from '..';
14+
import {Button, Dialog, DialogTrigger, Header, Input, Label, ListBox, ListBoxItem, ListBoxSection, Menu, MenuItem, MenuSection, Popover, SearchField, Separator, Text, TextField, UNSTABLE_Autocomplete} from '..';
1515
import {pointerMap, render, within} from '@react-spectrum/test-utils-internal';
1616
import React, {ReactNode} from 'react';
1717
import {useAsyncList} from 'react-stately';
@@ -244,6 +244,136 @@ describe('Autocomplete', () => {
244244
expect(options[1]).toHaveAttribute('data-focused');
245245
expect(options[1]).not.toHaveAttribute('data-focus-visible');
246246
});
247+
248+
it('should be able to tab inside a focus scope that contains', async () => {
249+
const MyMenu = () => {
250+
let {contains} = useFilter({sensitivity: 'base'});
251+
252+
return (
253+
<DialogTrigger>
254+
<Button aria-label="Menu"></Button>
255+
<Popover>
256+
<Dialog>
257+
<Button>First</Button>
258+
<Button>Second</Button>
259+
<UNSTABLE_Autocomplete filter={contains}>
260+
<TextField autoFocus aria-label="Search">
261+
<Input />
262+
</TextField>
263+
<Menu>
264+
<MenuItem>Open</MenuItem>
265+
<MenuItem>
266+
Rename…
267+
</MenuItem>
268+
<MenuItem>
269+
Duplicate
270+
</MenuItem>
271+
</Menu>
272+
</UNSTABLE_Autocomplete>
273+
</Dialog>
274+
</Popover>
275+
</DialogTrigger>
276+
);
277+
};
278+
279+
function App() {
280+
return (
281+
<div>
282+
<input />
283+
<div>
284+
<MyMenu />
285+
</div>
286+
<input />
287+
</div>
288+
);
289+
}
290+
291+
let {getByRole} = render(<App />);
292+
let trigger = getByRole('button', {name: 'Menu'});
293+
await user.click(trigger);
294+
let firstButton = getByRole('button', {name: 'First'});
295+
let secondButton = getByRole('button', {name: 'Second'});
296+
let input = getByRole('textbox');
297+
298+
expect(document.activeElement).toBe(input);
299+
300+
await user.tab();
301+
302+
expect(document.activeElement).toBe(firstButton);
303+
304+
await user.tab({shift: true});
305+
306+
expect(document.activeElement).toBe(input);
307+
308+
await user.tab({shift: true});
309+
310+
expect(document.activeElement).toBe(secondButton);
311+
});
312+
313+
it('should be able to tab inside a focus scope that contains with buttons after the autocomplete', async () => {
314+
const MyMenu = () => {
315+
let {contains} = useFilter({sensitivity: 'base'});
316+
317+
return (
318+
<DialogTrigger>
319+
<Button aria-label="Menu"></Button>
320+
<Popover>
321+
<Dialog>
322+
<UNSTABLE_Autocomplete filter={contains}>
323+
<TextField autoFocus aria-label="Search">
324+
<Input />
325+
</TextField>
326+
<Menu>
327+
<MenuItem>Open</MenuItem>
328+
<MenuItem>
329+
Rename…
330+
</MenuItem>
331+
<MenuItem>
332+
Duplicate
333+
</MenuItem>
334+
</Menu>
335+
</UNSTABLE_Autocomplete>
336+
<Button>First</Button>
337+
<Button>Second</Button>
338+
</Dialog>
339+
</Popover>
340+
</DialogTrigger>
341+
);
342+
};
343+
344+
function App() {
345+
return (
346+
<div>
347+
<input />
348+
<div>
349+
<MyMenu />
350+
</div>
351+
<input />
352+
</div>
353+
);
354+
}
355+
356+
let {getByRole} = render(<App />);
357+
let trigger = getByRole('button', {name: 'Menu'});
358+
await user.click(trigger);
359+
let firstButton = getByRole('button', {name: 'First'});
360+
let secondButton = getByRole('button', {name: 'Second'});
361+
let input = getByRole('textbox');
362+
363+
expect(document.activeElement).toBe(input);
364+
365+
await user.tab();
366+
367+
expect(document.activeElement).toBe(firstButton);
368+
369+
await user.tab({shift: true});
370+
371+
expect(document.activeElement).toBe(input);
372+
373+
await user.tab({shift: true});
374+
375+
expect(document.activeElement).toBe(secondButton);
376+
});
247377
});
248378

249379
AriaAutocompleteTests({

0 commit comments

Comments
 (0)