Skip to content

Commit

Permalink
[base-ui][material-ui][joy-ui][useAutocomplete] Correct keyboard navi…
Browse files Browse the repository at this point in the history
…gation with multiple disabled options (#38788)

Co-authored-by: ZeeshanTamboli <zeeshan.tamboli@gmail.com>
  • Loading branch information
VadimZvf and ZeeshanTamboli authored Oct 19, 2023
1 parent 1f4633e commit 52eaa03
Show file tree
Hide file tree
Showing 2 changed files with 94 additions and 13 deletions.
30 changes: 17 additions & 13 deletions packages/mui-base/src/useAutocomplete/useAutocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -293,34 +293,38 @@ export function useAutocomplete(props) {
}, [value, multiple, focusedTag, focusTag]);

function validOptionIndex(index, direction) {
if (!listboxRef.current || index === -1) {
if (!listboxRef.current || index < 0 || index >= filteredOptions.length) {
return -1;
}

let nextFocus = index;

while (true) {
// Out of range
if (
(direction === 'next' && nextFocus === filteredOptions.length) ||
(direction === 'previous' && nextFocus === -1)
) {
return -1;
}

const option = listboxRef.current.querySelector(`[data-option-index="${nextFocus}"]`);

// Same logic as MenuList.js
const nextFocusDisabled = disabledItemsFocusable
? false
: !option || option.disabled || option.getAttribute('aria-disabled') === 'true';

if ((option && !option.hasAttribute('tabindex')) || nextFocusDisabled) {
// Move to the next element.
nextFocus += direction === 'next' ? 1 : -1;
} else {
if (option && option.hasAttribute('tabindex') && !nextFocusDisabled) {
// The next option is available
return nextFocus;
}

// The next option is disabled, move to the next element.
// with looped index
if (direction === 'next') {
nextFocus = (nextFocus + 1) % filteredOptions.length;
} else {
nextFocus = (nextFocus - 1 + filteredOptions.length) % filteredOptions.length;
}

// We end up with initial index, that means we don't have available options.
// All of them are disabled
if (nextFocus === index) {
return -1;
}
}
}

Expand Down
77 changes: 77 additions & 0 deletions packages/mui-material/src/Autocomplete/Autocomplete.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,83 @@ describe('<Autocomplete />', () => {
expect(handleSubmit.callCount).to.equal(0);
expect(handleChange.callCount).to.equal(1);
});

it('should skip disabled options when navigating via keyboard', () => {
const { getByRole } = render(
<Autocomplete
getOptionDisabled={(option) => option === 'two'}
openOnFocus
options={['one', 'two', 'three']}
renderInput={(props) => <TextField {...props} autoFocus />}
/>,
);
const textbox = getByRole('combobox');

fireEvent.keyDown(textbox, { key: 'ArrowDown' });
checkHighlightIs(getByRole('listbox'), 'one');
fireEvent.keyDown(textbox, { key: 'ArrowDown' });
checkHighlightIs(getByRole('listbox'), 'three');
fireEvent.keyDown(textbox, { key: 'ArrowDown' });
checkHighlightIs(getByRole('listbox'), 'one');
});

it('should skip disabled options at the end of the list when navigating via keyboard', () => {
const { getByRole } = render(
<Autocomplete
getOptionDisabled={(option) => option === 'three' || option === 'four'}
openOnFocus
options={['one', 'two', 'three', 'four']}
renderInput={(props) => <TextField {...props} autoFocus />}
/>,
);
const textbox = getByRole('combobox');

fireEvent.keyDown(textbox, { key: 'ArrowDown' });
checkHighlightIs(getByRole('listbox'), 'one');
fireEvent.keyDown(textbox, { key: 'ArrowDown' });
checkHighlightIs(getByRole('listbox'), 'two');
fireEvent.keyDown(textbox, { key: 'ArrowDown' });
checkHighlightIs(getByRole('listbox'), 'one');
});

it('should skip the first and last disabled options in the list when navigating via keyboard', () => {
const { getByRole } = render(
<Autocomplete
getOptionDisabled={(option) => option === 'one' || option === 'five'}
openOnFocus
options={['one', 'two', 'three', 'four', 'five']}
renderInput={(props) => <TextField {...props} autoFocus />}
/>,
);
const textbox = getByRole('combobox');

fireEvent.keyDown(textbox, { key: 'ArrowDown' });
checkHighlightIs(getByRole('listbox'), 'two');
fireEvent.keyDown(textbox, { key: 'ArrowDown' });
fireEvent.keyDown(textbox, { key: 'ArrowDown' });
checkHighlightIs(getByRole('listbox'), 'four');
fireEvent.keyDown(textbox, { key: 'ArrowDown' });
checkHighlightIs(getByRole('listbox'), 'two');
fireEvent.keyDown(textbox, { key: 'ArrowUp' });
checkHighlightIs(getByRole('listbox'), 'four');
});

it('should not focus any option when all the options are disabled', () => {
const { getByRole } = render(
<Autocomplete
getOptionDisabled={() => true}
openOnFocus
options={['one', 'two', 'three']}
renderInput={(props) => <TextField {...props} autoFocus />}
/>,
);
const textbox = getByRole('combobox');

fireEvent.keyDown(textbox, { key: 'ArrowDown' });
checkHighlightIs(getByRole('listbox'), null);
fireEvent.keyDown(textbox, { key: 'ArrowUp' });
checkHighlightIs(getByRole('listbox'), null);
});
});

describe('WAI-ARIA conforming markup', () => {
Expand Down

0 comments on commit 52eaa03

Please sign in to comment.