Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
211 changes: 211 additions & 0 deletions packages/mui-material/src/Select/Select.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2215,6 +2215,217 @@ describe('<Select />', () => {
});
});

describe('typeahead keyboard navigation', () => {
async function focusTrigger(trigger) {
await act(async () => {
trigger.focus();
});

expect(trigger).toHaveFocus();
}

it('selects an option by typing a single character', async () => {
render(
<Select defaultValue="" name="test-select">
<MenuItem value="apple">Apple</MenuItem>
<MenuItem value="banana">Banana</MenuItem>
<MenuItem value="carrot">Carrot</MenuItem>
</Select>,
);

const trigger = screen.getByRole('combobox');

await focusTrigger(trigger);

fireEvent.keyDown(trigger, { key: 'b' });

expect(trigger).to.have.text('Banana');
});

it('matches using accumulated prefix characters', async () => {
render(
<Select defaultValue="">
<MenuItem value="apple">Apple</MenuItem>
<MenuItem value="banana">Banana</MenuItem>
<MenuItem value="blueberry">Blueberry</MenuItem>
</Select>,
);

const trigger = screen.getByRole('combobox');

await focusTrigger(trigger);

fireEvent.keyDown(trigger, { key: 'b' });
fireEvent.keyDown(trigger, { key: 'l' });

expect(trigger).to.have.text('Blueberry');
});

it('cycles through options when the same character is pressed repeatedly', async () => {
render(
<Select defaultValue="">
<MenuItem value="apple">Apple</MenuItem>
<MenuItem value="apricot">Apricot</MenuItem>
<MenuItem value="avocado">Avocado</MenuItem>
</Select>,
);

const trigger = screen.getByRole('combobox');

await focusTrigger(trigger);

fireEvent.keyDown(trigger, { key: 'a' });
expect(trigger).to.have.text('Apple');

fireEvent.keyDown(trigger, { key: 'a' });
expect(trigger).to.have.text('Apricot');

fireEvent.keyDown(trigger, { key: 'a' });
expect(trigger).to.have.text('Avocado');

fireEvent.keyDown(trigger, { key: 'a' });
expect(trigger).to.have.text('Apple');
});

it('resets the typeahead buffer after timeout', async () => {
render(
<Select defaultValue="">
<MenuItem value="apple">Apple</MenuItem>
<MenuItem value="banana">Banana</MenuItem>
<MenuItem value="blueberry">Blueberry</MenuItem>
</Select>,
);

const trigger = screen.getByRole('combobox');

await focusTrigger(trigger);

fireEvent.keyDown(trigger, { key: 'b' });

await act(async () => {
clock.tick(600);
});

fireEvent.keyDown(trigger, { key: 'l' });

// should stay on Banana because buffer reset
expect(trigger).to.have.text('Banana');
});

it('calls onChange with correct target value during typeahead selection', async () => {
const onChange = spy();

render(
<Select defaultValue="" name="food" onChange={onChange}>
<MenuItem value="apple">Apple</MenuItem>
<MenuItem value="banana">Banana</MenuItem>
</Select>,
);

const trigger = screen.getByRole('combobox');

await focusTrigger(trigger);

fireEvent.keyDown(trigger, { key: 'b' });

expect(onChange.callCount).to.equal(1);
expect(onChange.firstCall.args[0].target.value).to.equal('banana');
expect(onChange.firstCall.args[0].target.name).to.equal('food');
});

it('does not trigger typeahead when select is open', async () => {
render(
<Select open value="">
<MenuItem value="apple">Apple</MenuItem>
<MenuItem value="banana">Banana</MenuItem>
</Select>,
);

const trigger = screen.getByRole('combobox', { hidden: true });

await focusTrigger(trigger);

fireEvent.keyDown(trigger, { key: 'b' });

expect(trigger).not.to.have.text('Banana');
});

it('does not trigger typeahead for multiple select', async () => {
render(
<Select multiple defaultValue={[]}>
<MenuItem value="apple">Apple</MenuItem>
<MenuItem value="banana">Banana</MenuItem>
</Select>,
);

const trigger = screen.getByRole('combobox');

await focusTrigger(trigger);

fireEvent.keyDown(trigger, { key: 'b' });

expect(trigger).not.to.have.text('Banana');
});

it('does not trigger typeahead when readOnly', async () => {
render(
<Select readOnly defaultValue="">
<MenuItem value="apple">Apple</MenuItem>
<MenuItem value="banana">Banana</MenuItem>
</Select>,
);

const trigger = screen.getByRole('combobox');

await focusTrigger(trigger);

fireEvent.keyDown(trigger, { key: 'b' });

expect(trigger).not.to.have.text('Banana');
});

it('supports nested children text in options', async () => {
render(
<Select defaultValue="">
<MenuItem value="apple">
<span>Apple</span>
</MenuItem>

<MenuItem value="banana">
<div>
<span>Banana</span>
</div>
</MenuItem>
</Select>,
);

const trigger = screen.getByRole('combobox');

await focusTrigger(trigger);

fireEvent.keyDown(trigger, { key: 'b' });

expect(trigger).to.have.text('Banana');
});

it('ignores non-character keys for typeahead', async () => {
render(
<Select defaultValue="">
<MenuItem value="apple">Apple</MenuItem>
<MenuItem value="banana">Banana</MenuItem>
</Select>,
);

const trigger = screen.getByRole('combobox');

await focusTrigger(trigger);

fireEvent.keyDown(trigger, { key: 'Shift' });

expect(trigger).not.to.have.text('Banana');
});
});

it.skipIf(isJsdom())('updates menu minWidth when the trigger resizes while open', async () => {
clock.restore();

Expand Down
98 changes: 98 additions & 0 deletions packages/mui-material/src/Select/SelectInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,26 @@ function isMouseEventInsideElement(event, element) {
);
}

function getOptionText(node) {
if (node == null) {
return '';
}

if (typeof node === 'string' || typeof node === 'number') {
return String(node);
}

if (Array.isArray(node)) {
return node.map(getOptionText).join('');
}

if (React.isValidElement(node) && node.props.children) {
return getOptionText(node.props.children);
}

return '';
}

const SelectSelect = styled(StyledSelectSelect, {
name: 'MuiSelect',
slot: 'Select',
Expand Down Expand Up @@ -183,6 +203,12 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
const hasSelectedItemInListRef = React.useRef(false);
const openingMouseUpListenerCleanupRef = React.useRef(null);
const didPointerDownOnItemRef = React.useRef(false);
const textCriteriaRef = React.useRef({
keys: [],
repeating: true,
previousKeyMatched: true,
lastTime: null,
});
const selectionRef = React.useRef({
allowSelectedMouseUp: false,
allowUnselectedMouseUp: false,
Expand Down Expand Up @@ -500,6 +526,77 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
event.currentTarget.click();
};

const handleTypeaheadKeyDown = (event) => {
Copy link
Copy Markdown
Member

@mj12albert mj12albert May 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates a whole new self-contained chunk of typeahead logic that is inconsistent with the existing (open typeahead) behaviors, I don't think this PR is going in right direction overall @atharva3333

Also noticed it incorrectly makes disabled items and non-interactive subheaders in grouped selects candidates for typeahead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you clarify how you would prefer this issue to be approached?

Should the closed Select typeahead reuse/extend the existing MenuList typeahead behavior directly, rather than introducing separate matching/state logic inside SelectInput?

Copy link
Copy Markdown
Member

@mj12albert mj12albert May 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't thought about this too much yet

If you are still interested in working on this you could dig into the code a little deeper and use the original issue thread (not this PR) for questions/ideas etc

Should the closed Select typeahead reuse/extend the existing MenuList

Have you tried to or considered it yourself?

if (openRef.current || multiple || readOnly || disabled) {
return;
}

if (event.key.length !== 1) {
return;
}

// Build selectable option list — skip disabled items and non-interactive
// subheaders (items without a value prop), consistent with open-popup behavior.
const selectableChildren = React.Children.toArray(children).filter(
(child) =>
React.isValidElement(child) && child.props.value !== undefined && !child.props.disabled,
);

const criteria = textCriteriaRef.current;
const lowerKey = event.key.toLowerCase();
const currTime = performance.now();

if (criteria.keys.length > 0) {
// Reset after 500ms of inactivity — mirrors MenuList reset threshold
if (currTime - criteria.lastTime > 500) {
criteria.keys = [];
criteria.repeating = true;
criteria.previousKeyMatched = true;
} else if (criteria.repeating && lowerKey !== criteria.keys[0]) {
criteria.repeating = false;
}
}

criteria.lastTime = currTime;
criteria.keys.push(lowerKey);

let match;
if (criteria.repeating) {
// Find all matches, then pick the NEXT one after the currently selected value
const charMatches = selectableChildren.filter((child) => {
const text = getOptionText(child.props.children).trim().toLowerCase();
return text.length > 0 && text[0] === criteria.keys[0];
});
const currentIndex = charMatches.findIndex((child) => child.props.value === value);
// If current value not in matches, start from 0, otherwise advance to next
const nextIndex = (currentIndex + 1) % charMatches.length;
match = charMatches[nextIndex];
} else {
// different characters typed — find first match
match = selectableChildren.find((child) => {
const text = getOptionText(child.props.children).trim().toLowerCase();
return text.length > 0 && text.startsWith(criteria.keys.join(''));
});
}

if (criteria.previousKeyMatched && match) {
event.preventDefault();
if (match.props.value !== value) {
setValueState(match.props.value);
if (onChange) {
const nativeEvent = new Event('change', { bubbles: true });
Object.defineProperty(nativeEvent, 'target', {
writable: true,
value: { value: match.props.value, name },
});
onChange(nativeEvent, match);
}
}
} else {
criteria.previousKeyMatched = false;
}
};

const handleKeyDown = (event) => {
if (!readOnly) {
const validKeys = [
Expand All @@ -517,6 +614,7 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
}
onKeyDown?.(event);
}
handleTypeaheadKeyDown(event);
};

const handleBlur = (event) => {
Expand Down
Loading