Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a0239d1
[Autocomplete] Fix ArrowLeft behavior in multiple mode with input text
jnbain Dec 2, 2025
a800fcf
Resolve merge conflict - keep cursor position check fix
jnbain Dec 3, 2025
1be6bb9
Merge upstream/master and apply prettier formatting
jnbain Dec 3, 2025
16c2c51
Merge branch 'master' into fix-autocomplete-arrowleft-issue-47241
ZeeshanTamboli Dec 4, 2025
8c91375
Fix logic
ZeeshanTamboli Dec 4, 2025
76871a3
Merge branch 'master' into fix-autocomplete-arrowleft-issue-47241
ZeeshanTamboli Dec 4, 2025
5cf6bb0
Close dropdown when focusing chip with ArrowLeft
jnbain Dec 4, 2025
33af06d
Merge maintainer's changes
jnbain Dec 4, 2025
e1606ec
fix code
ZeeshanTamboli Dec 5, 2025
c5cf478
Merge branch 'fix-autocomplete-arrowleft-issue-47241' of https://gith…
ZeeshanTamboli Dec 5, 2025
a733c75
fix Backspace keydown in single value rendering
ZeeshanTamboli Dec 5, 2025
290fa10
Fix Delete logic in single value rendering
ZeeshanTamboli Dec 5, 2025
812b5bf
Merge branch 'master' into fix-autocomplete-arrowleft-issue-47241
ZeeshanTamboli Dec 5, 2025
1d14fde
ensure removeOption is called in single value rendering when pressing…
ZeeshanTamboli Dec 5, 2025
cdc3736
add code comments
ZeeshanTamboli Dec 6, 2025
3d3a7b3
Add tests with ArrowLeft
ZeeshanTamboli Dec 6, 2025
7da04ae
Modify Backspace and Delete tests
ZeeshanTamboli Dec 6, 2025
1bcb69a
Merge branch 'master' into fix-autocomplete-arrowleft-issue-47241
ZeeshanTamboli Dec 8, 2025
df22a42
fix input text is not cleared when doing arrowLeft when freeSolo
ZeeshanTamboli Dec 8, 2025
5c42660
modify query
ZeeshanTamboli Dec 8, 2025
60afc1f
fix querying last chip
ZeeshanTamboli Dec 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 142 additions & 2 deletions packages/mui-material/src/Autocomplete/Autocomplete.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,71 @@ describe('<Autocomplete />', () => {
expect(handleSubmit.callCount).to.equal(0);
},
);

it('should move focus to the last chip with ArrowLeft only when caret is at the start when multiple', () => {
const options = ['one', 'two', 'three'];
render(
<Autocomplete
multiple
options={options}
defaultValue={[options[0], options[1]]}
renderInput={(params) => <TextField {...params} autoFocus />}
/>,
);

const textbox = screen.getByRole('combobox');
const [chipOne, chipTwo] = screen.getAllByRole('button');

// Type something so the input has content.
fireEvent.change(textbox, { target: { value: 'foo' } });

// Caret not at start: ArrowLeft should just move the caret, not focus the chip.
textbox.setSelectionRange(2, 2);
fireEvent.keyDown(textbox, { key: 'ArrowLeft' });
expect(textbox).toHaveFocus();

// Caret at start: ArrowLeft should now move focus to the second chip.
textbox.setSelectionRange(0, 0);
fireEvent.keyDown(textbox, { key: 'ArrowLeft' });
expect(chipTwo).toHaveFocus();

// ArrowLeft should now move focus to the first chip.
fireEvent.keyDown(chipTwo, { key: 'ArrowLeft' });
expect(chipOne).toHaveFocus();
});

it('should clear freeSolo input when moving focus from input to chip with ArrowLeft and not restore it on ArrowRight', () => {
const options = ['one', 'two'];
render(
<Autocomplete
multiple
freeSolo
options={options}
defaultValue={options}
renderInput={(params) => <TextField {...params} autoFocus />}
/>,
);

const textbox = screen.getByRole('combobox');
const lastChip = screen.getByRole('button', { name: 'two' });

// Type some freeSolo text
fireEvent.change(textbox, { target: { value: 'foo' } });
expect(textbox).to.have.property('value', 'foo');

// Caret at start: ArrowLeft should move focus to the last chip
textbox.setSelectionRange(0, 0);
fireEvent.keyDown(textbox, { key: 'ArrowLeft' });
expect(lastChip).toHaveFocus();

// Input text should be cleared and stay cleared
expect(textbox).to.have.property('value', '');

// ArrowRight should move focus back to the input, without restoring the old text
fireEvent.keyDown(lastChip, { key: 'ArrowRight' });
expect(textbox).toHaveFocus();
expect(textbox).to.have.property('value', '');
});
});

it('should trigger a form expectedly', () => {
Expand Down Expand Up @@ -3600,11 +3665,13 @@ describe('<Autocomplete />', () => {
expect(view.container.querySelectorAll(`.${chipClasses.root}`)).to.have.length(1);
});

it('should delete using Backspace key', () => {
it('should delete using Backspace key with empty input text', () => {
const handleChange = spy();
const view = render(
<Autocomplete
options={['one', 'two']}
defaultValue="one"
onChange={handleChange}
renderValue={(value, getItemProps) => {
return <Chip label={value} {...getItemProps()} />;
}}
Expand All @@ -3618,14 +3685,21 @@ describe('<Autocomplete />', () => {

fireEvent.keyDown(textbox, { key: 'Backspace' });

expect(handleChange.callCount).to.equal(1);
expect(handleChange.args[0][1]).to.equal(null);
expect(handleChange.args[0][2]).to.equal('removeOption');
expect(handleChange.args[0][3]).to.deep.equal({ option: 'one' });

expect(view.container.querySelectorAll(`.${chipClasses.root}`)).to.have.length(0);
});

it('should delete using Delete key', () => {
it('should delete using Delete key with empty input text', () => {
const handleChange = spy();
const view = render(
<Autocomplete
options={['one', 'two']}
defaultValue="one"
onChange={handleChange}
renderValue={(value, getItemProps) => {
return <Chip label={value} {...getItemProps()} />;
}}
Expand All @@ -3639,6 +3713,11 @@ describe('<Autocomplete />', () => {

fireEvent.keyDown(textbox, { key: 'Delete' });

expect(handleChange.callCount).to.equal(1);
expect(handleChange.args[0][1]).to.equal(null);
expect(handleChange.args[0][2]).to.equal('removeOption');
expect(handleChange.args[0][3]).to.deep.equal({ option: 'one' });

expect(view.container.querySelectorAll(`.${chipClasses.root}`)).to.have.length(0);
});

Expand Down Expand Up @@ -3750,6 +3829,67 @@ describe('<Autocomplete />', () => {
expect(textbox).to.have.property('value', 'on');
expect(textbox).toHaveFocus();
});

it('should move focus to the rendered value with ArrowLeft only when caret is at the start', () => {
const options = ['one', 'two'];
const view = render(
<Autocomplete
options={options}
defaultValue={options[0]}
renderValue={(value, getItemProps) => <Chip label={value} {...getItemProps()} />}
renderInput={(params) => <TextField {...params} autoFocus />}
/>,
);

const textbox = screen.getByRole('combobox');
const chip = view.container.querySelector(`.${chipClasses.root}`);

// Type something so the input has content.
fireEvent.change(textbox, { target: { value: 'foo' } });

// Caret not at start: ArrowLeft should just move the caret, not focus the chip.
textbox.setSelectionRange(2, 2);
fireEvent.keyDown(textbox, { key: 'ArrowLeft' });
expect(textbox).toHaveFocus();

// Caret at start: ArrowLeft should now move focus to the rendered value.
textbox.setSelectionRange(0, 0);
fireEvent.keyDown(textbox, { key: 'ArrowLeft' });
expect(chip).toHaveFocus();
});

it('should clear freeSolo input when moving focus to the rendered value with ArrowLeft and not restore it on ArrowRight', () => {
const options = ['one', 'two'];
render(
<Autocomplete
freeSolo
options={options}
defaultValue={options[0]}
renderValue={(value, getItemProps) => <Chip label={value} {...getItemProps()} />}
renderInput={(params) => <TextField {...params} autoFocus />}
/>,
);

const textbox = screen.getByRole('combobox');
const chip = screen.getByRole('button', { name: 'one' });

// Type some freeSolo text
fireEvent.change(textbox, { target: { value: 'foo' } });
expect(textbox).to.have.property('value', 'foo');

// Caret at start: ArrowLeft should move focus to the rendered value
textbox.setSelectionRange(0, 0);
fireEvent.keyDown(textbox, { key: 'ArrowLeft' });
expect(chip).toHaveFocus();

// Input text should be cleared
expect(textbox).to.have.property('value', '');

// ArrowRight should move focus back to input without restoring text
fireEvent.keyDown(chip, { key: 'ArrowRight' });
expect(textbox).toHaveFocus();
expect(textbox).to.have.property('value', '');
});
});

it('should not shrink the input label when value is an empty array in multiple mode using renderValue', () => {
Expand Down
54 changes: 41 additions & 13 deletions packages/mui-material/src/useAutocomplete/useAutocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -753,9 +753,16 @@ function useAutocomplete(props) {

let nextItem = focusedItem;

if (focusedItem === -1) {
if (inputValue === '' && direction === 'previous') {
nextItem = value.length - 1;
// When moving focus from the input to tags with ArrowLeft,
// always jump to the last tag (if any) from the input.
if (focusedItem === -1 && direction === 'previous') {
nextItem = value.length - 1;
// In freeSolo, clear any draft text so it doesn't "come back" later.
if (freeSolo && inputValue !== '') {
setInputValueState('');
if (onInputChange) {
onInputChange(event, '', 'reset');
}
}
} else {
nextItem += direction === 'next' ? 1 : -1;
Expand Down Expand Up @@ -851,14 +858,35 @@ function useAutocomplete(props) {
changeHighlightedIndex({ diff: -1, direction: 'previous', reason: 'keyboard', event });
handleOpen(event);
break;
case 'ArrowLeft':
case 'ArrowLeft': {
const input = inputRef.current;
// Only handle ArrowLeft when the caret is at the start of the input.
// Otherwise let the browser move the caret normally.
const caretAtStart = input && input.selectionStart === 0 && input.selectionEnd === 0;

if (!caretAtStart) {
// Let the browser handle normal cursor movement
return;
}

// Single-value rendering: move focus from input to the single tag.
if (!multiple && renderValue && value != null) {
// Moving from input to single tag; clear freeSolo draft text,
// so it doesn't reappear when we move back.
if (freeSolo && inputValue !== '') {
setInputValueState('');
if (onInputChange) {
onInputChange(event, '', 'reset');
}
}
setFocusedItem(0);
focusItem(0);
} else {
// Multi-value: delegate to tag navigation helper.
handleFocusItem(event, 'previous');
}
break;
}
case 'ArrowRight':
if (!multiple && renderValue) {
setFocusedItem(-1);
Expand Down Expand Up @@ -924,10 +952,8 @@ function useAutocomplete(props) {
option: value[index],
});
}
if (!multiple && renderValue && !readOnly) {
setValueState(null);
setFocusedItem(-1);
focusItem(-1);
if (!multiple && renderValue && !readOnly && inputValue === '') {
handleValue(event, null, 'removeOption', { option: value });
}
break;
case 'Delete':
Expand All @@ -946,10 +972,10 @@ function useAutocomplete(props) {
option: value[index],
});
}
if (!multiple && renderValue && !readOnly) {
setValueState(null);
setFocusedItem(-1);
focusItem(-1);
if (!multiple && renderValue && !readOnly && inputValue === '') {
// Single-value rendering: Delete on empty input removes
// the single rendered option, same "removeOption" reason as multiple.
handleValue(event, null, 'removeOption', { option: value });
}
break;
default:
Expand Down Expand Up @@ -1009,7 +1035,9 @@ function useAutocomplete(props) {
}

if (newValue === '') {
if (!disableClearable && !multiple) {
// For normal single-select, clearing the input clears the value.
// For renderValue (chip-style single), only Backspace/Delete clear the value.
if (!disableClearable && !multiple && !renderValue) {
handleValue(event, null, 'clear');
}
} else {
Expand Down
Loading