Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[base-ui][material-ui][joy-ui][useAutocomplete] Correct keyboard navigation with multiple disabled options #38788

Merged
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