diff --git a/packages/mui-material/src/Select/Select.test.js b/packages/mui-material/src/Select/Select.test.js
index 8ef21c8d2292b1..2b07b1a3e03e76 100644
--- a/packages/mui-material/src/Select/Select.test.js
+++ b/packages/mui-material/src/Select/Select.test.js
@@ -469,6 +469,584 @@ describe('', () => {
});
});
+ describe('closed typeahead', () => {
+ beforeEach(() => {
+ clock.restore();
+ });
+
+ function sleep(duration) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, duration);
+ });
+ }
+
+ async function focusTrigger(user, testId) {
+ const trigger = testId ? screen.getByTestId(testId) : screen.getByRole('combobox');
+
+ if (document.activeElement !== trigger) {
+ await user.tab();
+ }
+
+ expect(trigger).toHaveFocus();
+ return trigger;
+ }
+
+ it('selects a matching option without opening the popup', async () => {
+ const onChange = vi.fn();
+
+ const { user } = render(
+ ,
+ );
+ const trigger = await focusTrigger(user);
+
+ await user.keyboard('c');
+
+ expect(trigger).to.have.text('Cherry');
+ expect(onChange.mock.calls.length).to.equal(1);
+ expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null);
+ });
+
+ it('passes the selected value, name, and matched child to onChange', async () => {
+ const onChange = vi.fn((event, child) => ({
+ childValue: child.props.value,
+ name: event.target.name,
+ value: event.target.value,
+ }));
+
+ const { user } = render(
+ ,
+ );
+ await focusTrigger(user);
+
+ await user.keyboard('c');
+
+ expect(onChange.mock.calls.length).to.equal(1);
+ expect(onChange.mock.results[0].value).to.deep.equal({
+ childValue: 'cherry',
+ name: 'fruit',
+ value: 'cherry',
+ });
+ expect(React.isValidElement(onChange.mock.calls[0][1])).to.equal(true);
+ expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null);
+ });
+
+ it('starts from the first matching option when no value is selected', async () => {
+ const onChange = vi.fn();
+
+ const { user } = render(
+ ,
+ );
+ const trigger = await focusTrigger(user);
+
+ await user.keyboard('a');
+
+ expect(trigger).to.have.text('Apple');
+ expect(onChange.mock.calls.length).to.equal(1);
+ expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null);
+ });
+
+ it('starts before the first option when the controlled value is out of range', async () => {
+ function ControlledSelect() {
+ const [selectedValue, setSelectedValue] = React.useState('missing');
+
+ return (
+
+ );
+ }
+
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ try {
+ const { user } = render();
+
+ await waitFor(() => {
+ expect(warn.mock.calls.length).to.equal(reactMajor >= 18 ? 3 : 2);
+ });
+ warn.mock.calls.forEach(([message]) => {
+ expect(String(message)).to.include(
+ 'MUI: You have provided an out-of-range value `missing` for the select component.',
+ );
+ });
+
+ await focusTrigger(user);
+ await user.keyboard('a');
+
+ expect(screen.getByRole('combobox')).to.have.text('Apple');
+ expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null);
+ } finally {
+ warn.mockRestore();
+ }
+ });
+
+ it('resets closed typeahead after controlled value changes', async () => {
+ function ControlledSelect() {
+ const [selectedValue, setSelectedValue] = React.useState('cat');
+
+ return (
+
+
+
+
+
+ );
+ }
+
+ const { user } = render();
+
+ await user.click(screen.getByRole('button', { name: 'Reset' }));
+ await user.tab({ shift: true });
+ expect(screen.getByRole('combobox')).toHaveFocus();
+
+ await user.keyboard('a');
+ expect(screen.getByRole('combobox')).to.have.text('Apple');
+
+ await user.click(screen.getByRole('button', { name: 'Select car' }));
+ await user.tab({ shift: true });
+ await user.tab({ shift: true });
+ expect(screen.getByRole('combobox')).toHaveFocus();
+
+ await user.keyboard('c');
+ expect(screen.getByRole('combobox')).to.have.text('Cat');
+
+ await user.keyboard('a');
+ expect(screen.getByRole('combobox')).to.have.text('Cat');
+ expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null);
+ });
+
+ it('clears an active buffer when the controlled value resets to no option while focused', async () => {
+ const onChange = vi.fn();
+
+ function ControlledSelect() {
+ const [selectedValue, setSelectedValue] = React.useState('cat');
+
+ return (
+
+
+
+
+ );
+ }
+
+ const { user } = render();
+ await focusTrigger(user);
+
+ await user.keyboard('c');
+ expect(onChange.mock.calls.length).to.equal(0);
+
+ await user.click(screen.getByRole('button', { name: 'Reset without focus change' }));
+ expect(screen.getByRole('combobox')).toHaveFocus();
+
+ await user.keyboard('a');
+
+ expect(onChange.mock.calls.length).to.equal(1);
+ expect(onChange.mock.calls[0][0].target.value).to.equal('apple');
+ expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null);
+ });
+
+ it('cycles repeated characters through matching options', async () => {
+ const { user } = render(
+ ,
+ );
+ const trigger = await focusTrigger(user);
+
+ await user.keyboard('a');
+ expect(trigger).to.have.text('Arizona');
+
+ await user.keyboard('a');
+ expect(trigger).to.have.text('Apricot');
+
+ await user.keyboard('a');
+ expect(trigger).to.have.text('Avocado');
+ });
+
+ it('does not incorrectly cycle repeated-start labels', async () => {
+ const { user } = render(
+ ,
+ );
+ const trigger = await focusTrigger(user);
+
+ await user.keyboard('a');
+ expect(trigger).to.have.text('Aaron');
+
+ await user.keyboard('a');
+ expect(trigger).to.have.text('Aaron');
+ });
+
+ it('cycles repeated characters for unrelated repeated-start labels', async () => {
+ const { user } = render(
+ ,
+ );
+ await focusTrigger(user);
+
+ await user.keyboard('b');
+ expect(screen.getByRole('combobox')).to.have.text('Bobcat');
+
+ await user.keyboard('b');
+ expect(screen.getByRole('combobox')).to.have.text('Banana');
+ });
+
+ it('clears the buffer after a non-Space no-match', async () => {
+ const { user } = render(
+ ,
+ );
+ await focusTrigger(user);
+
+ await user.keyboard('za');
+
+ expect(screen.getByRole('combobox')).to.have.text('Apple');
+ expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null);
+ });
+
+ it('resets the buffer after 750 ms', async () => {
+ const { user } = render(
+ ,
+ );
+ const trigger = await focusTrigger(user);
+
+ await user.keyboard('c');
+ expect(trigger).to.have.text('Cat');
+
+ await sleep(800);
+ await user.keyboard('a');
+
+ expect(trigger).to.have.text('Apple');
+ });
+
+ it('resets the buffer on blur', async () => {
+ const { user } = render(
+
+
+
+ ,
+ );
+ const trigger = await focusTrigger(user);
+
+ await user.keyboard('c');
+ expect(trigger).to.have.text('Cat');
+
+ await user.tab();
+ expect(screen.getByRole('button', { name: 'Outside' })).toHaveFocus();
+
+ await user.tab({ shift: true });
+ expect(screen.getByRole('combobox')).toHaveFocus();
+ await user.keyboard('a');
+
+ expect(trigger).to.have.text('Apple');
+ });
+
+ it('resets the buffer when the popup opens', async () => {
+ const { user } = render(
+ ,
+ );
+ const trigger = await focusTrigger(user);
+
+ await user.keyboard('c');
+ expect(trigger).to.have.text('Cat');
+
+ await user.keyboard('{ArrowDown}');
+ expect(screen.getByRole('listbox', { hidden: false })).not.to.equal(null);
+
+ await user.keyboard('{Escape}');
+ await waitFor(() => {
+ expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null);
+ });
+
+ await focusTrigger(user);
+ await user.keyboard('a');
+
+ expect(trigger).to.have.text('Apple');
+ });
+
+ it('ignores modified printable keys', async () => {
+ const onChange = vi.fn();
+ const { user } = render(
+ ,
+ );
+ await focusTrigger(user);
+
+ await user.keyboard('{Control>}a{/Control}');
+
+ expect(onChange.mock.calls.length).to.equal(0);
+ expect(screen.getByRole('combobox')).to.have.text('Banana');
+ expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null);
+ });
+
+ it('does not call onChange when the matched value is already selected', async () => {
+ const onChange = vi.fn();
+
+ const { user } = render(
+ ,
+ );
+ await focusTrigger(user);
+
+ await user.keyboard('a');
+
+ expect(onChange.mock.calls.length).to.equal(0);
+ expect(screen.getByRole('combobox')).to.have.text('Apple');
+ expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null);
+ });
+
+ it('still calls onKeyDown for printable keys handled by typeahead', async () => {
+ const onKeyDown = vi.fn();
+
+ const { user } = render(
+ ,
+ );
+ await focusTrigger(user);
+
+ await user.keyboard('a');
+
+ expect(onKeyDown.mock.calls.length).to.equal(1);
+ expect(onKeyDown.mock.calls[0][0]).to.have.property('key', 'a');
+ expect(screen.getByRole('combobox')).to.have.text('Apple');
+ });
+
+ it('uses string/number equality for selected-index lookup', async () => {
+ const { user } = render(
+ ,
+ );
+ await focusTrigger(user);
+
+ await user.keyboard('t');
+ expect(screen.getByRole('combobox')).to.have.text('Twenty');
+ });
+
+ it('uses object reference equality for selected-index lookup', async () => {
+ const selectedObject = { id: 1 };
+
+ const { user } = render(
+ ,
+ );
+ await focusTrigger(user);
+
+ await user.keyboard('a');
+ expect(screen.getByRole('combobox')).to.have.text('Avocado');
+ });
+
+ it('matches numeric labels', async () => {
+ const { user } = render(
+ ,
+ );
+ await focusTrigger(user);
+
+ await user.keyboard('7');
+ expect(screen.getByRole('combobox')).to.have.text('7');
+ });
+
+ it('matches nested labels', async () => {
+ const { user } = render(
+ ,
+ );
+ await focusTrigger(user);
+
+ await user.keyboard('d');
+ expect(screen.getByRole('combobox')).to.have.text('Deep Blue');
+ });
+
+ it('skips disabled options and children without their own value prop', async () => {
+ function WrappedListSubheader(props) {
+ return ;
+ }
+
+ const { user } = render(
+ ,
+ );
+ const trigger = await focusTrigger(user);
+
+ await user.keyboard('a');
+
+ expect(trigger).to.have.text('Apricot');
+ expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null);
+ });
+
+ it('skips disabled Select during keyboard navigation', async () => {
+ const { user } = render(
+
+
+
+ ,
+ );
+
+ await user.tab();
+
+ expect(screen.getByRole('button', { name: 'Next' })).toHaveFocus();
+ expect(screen.getByRole('combobox')).to.have.text('Banana');
+ expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null);
+ });
+
+ it('does not typeahead when readOnly', async () => {
+ const onChange = vi.fn();
+ const { user } = render(
+ ,
+ );
+ const trigger = await focusTrigger(user);
+
+ await user.keyboard('a');
+
+ expect(trigger).to.have.text('Banana');
+ expect(trigger).to.have.attribute('aria-readonly', 'true');
+ expect(onChange.mock.calls.length).to.equal(0);
+ expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null);
+ });
+
+ it('does not typeahead when multiple', async () => {
+ const onChange = vi.fn();
+ const { user } = render(
+ ,
+ );
+ await focusTrigger(user);
+
+ await user.keyboard('a');
+
+ expect(screen.getByRole('combobox')).to.have.text('Banana');
+ expect(onChange.mock.calls.length).to.equal(0);
+ expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null);
+ });
+
+ it('uses Space in an active buffer', async () => {
+ const onKeyDown = vi.fn();
+ const { user } = render(
+ ,
+ );
+ await focusTrigger(user);
+
+ await user.keyboard('item t');
+
+ expect(screen.getByRole('combobox')).to.have.text('Item Two');
+ const spaceKeyDown = onKeyDown.mock.calls.find(([event]) => event.key === ' ');
+ expect(spaceKeyDown).not.to.equal(undefined);
+ expect(spaceKeyDown[0]).to.have.property('defaultPrevented', true);
+ expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null);
+ });
+
+ it('opens the popup on initial Space', async () => {
+ const { user } = render(
+ ,
+ );
+ await focusTrigger(user);
+
+ await user.keyboard(' ');
+ expect(screen.getByRole('listbox', { hidden: false })).not.to.equal(null);
+ });
+ });
+
it('should pass "name" as part of the event.target for onBlur', async () => {
const handleBlur = stub().callsFake((event) => event.target.name);
@@ -532,6 +1110,13 @@ describe('', () => {
,
);
+ const selection = window.getSelection();
+ const range = document.createRange();
+
+ range.setStart(document.body, 0);
+ range.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(range);
fireEvent.click(screen.getByTestId('label'));
@@ -607,6 +1192,26 @@ describe('', () => {
expect(onChangeHandler.callCount).to.equal(0);
});
+
+ it('should be called if the selected value is string-equivalent but not strictly equal', async () => {
+ clock.restore();
+ const onChangeHandler = vi.fn();
+ const { user } = render(
+ ,
+ );
+
+ await user.click(screen.getByRole('combobox'));
+ await user.click(screen.getByRole('option', { name: 'Ten' }));
+
+ expect(onChangeHandler.mock.calls.length).to.equal(1);
+ expect(onChangeHandler.mock.calls[0][0].target).to.deep.equal({
+ name: undefined,
+ value: 10,
+ });
+ });
});
describe('prop: defaultOpen', () => {
diff --git a/packages/mui-material/src/Select/SelectInput.js b/packages/mui-material/src/Select/SelectInput.js
index 39b1e0ac3b8536..2148d64fda9159 100644
--- a/packages/mui-material/src/Select/SelectInput.js
+++ b/packages/mui-material/src/Select/SelectInput.js
@@ -19,12 +19,22 @@ import useForkRef from '../utils/useForkRef';
import useControlled from '../utils/useControlled';
import selectClasses, { getSelectUtilityClasses } from './selectClasses';
import { areEqualValues, isEmpty, getOpenInteractionType } from './utils';
+import {
+ canCycleRepeatedCharacter,
+ getMatchingOptionIndex,
+ getTypeaheadOptions,
+} from './utils/closedTypeahead';
import { SelectFocusSourceProvider } from './utils/SelectFocusSourceContext';
const OPENING_MOUSE_UP_BOUNDARY_OFFSET = 2;
// The initial mouseup may land on an item when the menu opens over the trigger.
const SELECTED_MOUSE_UP_DELAY = 400;
const UNSELECTED_MOUSE_UP_DELAY = 200;
+const TYPEAHEAD_RESET_MS = 750;
+const SPACE = ' ';
+const ARROW_UP = 'ArrowUp';
+const ARROW_DOWN = 'ArrowDown';
+const ENTER = 'Enter';
/**
* Returns true when a native mouse event should be treated as happening inside
@@ -187,8 +197,14 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
allowSelectedMouseUp: false,
allowUnselectedMouseUp: false,
});
+ const closedTypeaheadRef = React.useRef({
+ buffer: '',
+ previousSearchIndex: null,
+ matchedIndex: null,
+ });
const selectedMouseUpTimer = useTimeout();
const unselectedMouseUpTimer = useTimeout();
+ const typeaheadResetTimer = useTimeout();
const [displayNode, setDisplayNode] = React.useState(null);
const { current: isOpenControlled } = React.useRef(openProp != null);
const [menuMinWidthState, setMenuMinWidthState] = React.useState();
@@ -219,9 +235,19 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
const open = displayNode !== null && openState;
+ const resetClosedTypeahead = React.useCallback(() => {
+ typeaheadResetTimer.clear();
+ closedTypeaheadRef.current.buffer = '';
+ closedTypeaheadRef.current.previousSearchIndex = null;
+ closedTypeaheadRef.current.matchedIndex = null;
+ }, [typeaheadResetTimer]);
+
useEnhancedEffect(() => {
openRef.current = open;
- }, [open]);
+ if (open) {
+ resetClosedTypeahead();
+ }
+ }, [open, resetClosedTypeahead]);
const clearSelectionTimers = React.useCallback(() => {
selectedMouseUpTimer.clear();
@@ -257,8 +283,9 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
return () => {
resetMouseUpSelection();
clearOpeningMouseUpListener();
+ resetClosedTypeahead();
};
- }, [resetMouseUpSelection, clearOpeningMouseUpListener]);
+ }, [resetMouseUpSelection, clearOpeningMouseUpListener, resetClosedTypeahead]);
React.useEffect(() => {
if (!open || !anchorElement || autoWidth) {
@@ -323,6 +350,7 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
}
if (openParam) {
+ resetClosedTypeahead();
setOpenInteractionType(getOpenInteractionType(event));
if (onOpen) {
@@ -432,6 +460,25 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
}
};
+ const handleValueChange = (event, child, newValue) => {
+ setValueState(newValue);
+
+ if (onChange) {
+ // Redefine target to allow name and value to be read.
+ // This allows seamless integration with the most popular form libraries.
+ // https://github.com/mui/material-ui/issues/13485#issuecomment-676048492
+ // Clone the event to not override `target` of the original event.
+ const nativeEvent = event.nativeEvent || event;
+ const clonedEvent = new nativeEvent.constructor(nativeEvent.type, nativeEvent);
+
+ Object.defineProperty(clonedEvent, 'target', {
+ writable: true,
+ value: { value: newValue, name },
+ });
+ onChange(clonedEvent, child);
+ }
+ };
+
const handleItemClick = (child) => (event) => {
didPointerDownOnItemRef.current = false;
@@ -459,22 +506,7 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
}
if (value !== newValue) {
- setValueState(newValue);
-
- if (onChange) {
- // Redefine target to allow name and value to be read.
- // This allows seamless integration with the most popular form libraries.
- // https://github.com/mui/material-ui/issues/13485#issuecomment-676048492
- // Clone the event to not override `target` of the original event.
- const nativeEvent = event.nativeEvent || event;
- const clonedEvent = new nativeEvent.constructor(nativeEvent.type, nativeEvent);
-
- Object.defineProperty(clonedEvent, 'target', {
- writable: true,
- value: { value: newValue, name },
- });
- onChange(clonedEvent, child);
- }
+ handleValueChange(event, child, newValue);
}
if (!multiple) {
@@ -500,18 +532,89 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
event.currentTarget.click();
};
+ const handleClosedTypeahead = (event) => {
+ const state = closedTypeaheadRef.current;
+ const hasActiveBuffer = state.buffer !== '';
+
+ if (
+ open ||
+ multiple ||
+ disabled ||
+ event.defaultPrevented ||
+ event.nativeEvent?.isComposing ||
+ event.key.length !== 1 ||
+ event.ctrlKey ||
+ event.metaKey ||
+ event.altKey ||
+ (event.key === SPACE && !hasActiveBuffer)
+ ) {
+ return false;
+ }
+
+ if (event.key === SPACE) {
+ event.preventDefault();
+ }
+
+ const isNewSession = state.buffer === '';
+ const { options: searchableOptions, selectedIndex } = getTypeaheadOptions(childrenArray, value);
+
+ if (searchableOptions.length === 0) {
+ if (event.key !== SPACE) {
+ resetClosedTypeahead();
+ }
+
+ return true;
+ }
+
+ if (isNewSession) {
+ state.previousSearchIndex = selectedIndex;
+ }
+
+ const key = event.key.toLowerCase();
+
+ if (state.buffer === key && canCycleRepeatedCharacter(searchableOptions, key)) {
+ state.buffer = '';
+ state.previousSearchIndex = state.matchedIndex;
+ }
+
+ state.buffer += key;
+ typeaheadResetTimer.start(TYPEAHEAD_RESET_MS, resetClosedTypeahead);
+
+ const matchingIndex = getMatchingOptionIndex(
+ searchableOptions,
+ state.buffer,
+ (state.previousSearchIndex ?? -1) + 1,
+ );
+
+ if (matchingIndex !== -1) {
+ const matchedOption = searchableOptions[matchingIndex];
+
+ state.matchedIndex = matchingIndex;
+ if (!areEqualValues(value, matchedOption.value)) {
+ handleValueChange(event, matchedOption.child, matchedOption.value);
+ }
+ return true;
+ }
+
+ if (event.key !== SPACE) {
+ resetClosedTypeahead();
+ }
+
+ return true;
+ };
+
const handleKeyDown = (event) => {
if (!readOnly) {
- const validKeys = [
- ' ',
- 'ArrowUp',
- 'ArrowDown',
- // The native select doesn't respond to enter on macOS, but it's recommended by
- // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/
- 'Enter',
- ];
-
- if (validKeys.includes(event.key)) {
+ const isClosedTypeaheadHandled = handleClosedTypeahead(event);
+ // The native select doesn't respond to Enter on macOS, but it's recommended by
+ // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/
+ const isOpenKey =
+ event.key === SPACE ||
+ event.key === ARROW_UP ||
+ event.key === ARROW_DOWN ||
+ event.key === ENTER;
+
+ if (!isClosedTypeaheadHandled && isOpenKey) {
event.preventDefault();
update(true, event);
}
@@ -520,6 +623,7 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
};
const handleBlur = (event) => {
+ resetClosedTypeahead();
// if open event.stopImmediatePropagation
if (!open && onBlur) {
// Preact support, target is read only property on a native event.
@@ -599,7 +703,7 @@ const SelectInput = React.forwardRef(function SelectInput(props, ref) {
onClick: handleItemClick(child),
onMouseUp: handleItemMouseUp(child, selected),
onKeyUp: (event) => {
- if (event.key === ' ') {
+ if (event.key === SPACE) {
// otherwise our MenuItems dispatches a click event
// it's not behavior of the native