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(' + Apple + Banana + Cherry + , + ); + 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(' + Ten + Twenty + , + ); + + 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