From ab962ad252409cc4f10a0272c66ff071095e365e Mon Sep 17 00:00:00 2001 From: upstream Date: Sat, 17 Oct 2026 03:50:37 +0200 Subject: [PATCH] Upstream mui/material-ui state of the touched files before 722d54899a --- docs/pages/material-ui/api/native-select.json | 132 + .../api-docs/native-select/native-select.json | 90 + .../src/NativeSelect/NativeSelectInput.js | 281 ++ .../src/NativeSelect/nativeSelectClasses.ts | 58 + .../mui-material/src/Select/Select.test.js | 2987 +++++++++++++++++ .../mui-material/src/Select/SelectInput.js | 1084 ++++++ 6 files changed, 4632 insertions(+) create mode 100644 docs/pages/material-ui/api/native-select.json create mode 100644 docs/translations/api-docs/native-select/native-select.json create mode 100644 packages/mui-material/src/NativeSelect/NativeSelectInput.js create mode 100644 packages/mui-material/src/NativeSelect/nativeSelectClasses.ts create mode 100644 packages/mui-material/src/Select/Select.test.js create mode 100644 packages/mui-material/src/Select/SelectInput.js diff --git a/docs/pages/material-ui/api/native-select.json b/docs/pages/material-ui/api/native-select.json new file mode 100644 index 0000000..6caa6f0 --- /dev/null +++ b/docs/pages/material-ui/api/native-select.json @@ -0,0 +1,132 @@ +{ + "props": { + "children": { "type": { "name": "node" } }, + "classes": { + "type": { "name": "object" }, + "default": "{}", + "additionalInfo": { "cssApi": true } + }, + "IconComponent": { "type": { "name": "elementType" }, "default": "ArrowDropDownIcon" }, + "input": { "type": { "name": "element" }, "default": "" }, + "inputProps": { "type": { "name": "object" } }, + "onChange": { + "type": { "name": "func" }, + "signature": { + "type": "function(event: React.ChangeEvent) => void", + "describedArgs": ["event"] + } + }, + "sx": { + "type": { + "name": "union", + "description": "Array<func
| object
| bool>
| func
| object" + }, + "additionalInfo": { "sx": true } + }, + "value": { "type": { "name": "any" } }, + "variant": { + "type": { + "name": "enum", + "description": "'filled'
| 'outlined'
| 'standard'" + } + } + }, + "name": "NativeSelect", + "imports": [ + "import NativeSelect from '@mui/material/NativeSelect';", + "import { NativeSelect } from '@mui/material';" + ], + "classes": [ + { + "key": "disabled", + "className": "Mui-disabled", + "description": "State class applied to the select component `disabled` class.", + "isGlobal": true + }, + { + "key": "error", + "className": "Mui-error", + "description": "State class applied to the select component `error` class.", + "isGlobal": true + }, + { + "key": "filled", + "className": "MuiNativeSelect-filled", + "description": "Styles applied to the select component if `variant=\"filled\"`.", + "isGlobal": false + }, + { + "key": "icon", + "className": "MuiNativeSelect-icon", + "description": "Styles applied to the icon component.", + "isGlobal": false + }, + { + "key": "iconFilled", + "className": "MuiNativeSelect-iconFilled", + "description": "Styles applied to the icon component if `variant=\"filled\"`.", + "isGlobal": false + }, + { + "key": "iconOpen", + "className": "MuiNativeSelect-iconOpen", + "description": "Styles applied to the icon component if the popup is open.", + "isGlobal": false + }, + { + "key": "iconOutlined", + "className": "MuiNativeSelect-iconOutlined", + "description": "Styles applied to the icon component if `variant=\"outlined\"`.", + "isGlobal": false + }, + { + "key": "iconStandard", + "className": "MuiNativeSelect-iconStandard", + "description": "Styles applied to the icon component if `variant=\"standard\"`.", + "isGlobal": false + }, + { + "key": "multiple", + "className": "MuiNativeSelect-multiple", + "description": "Styles applied to the select component if `multiple={true}`.", + "isGlobal": false + }, + { + "key": "nativeInput", + "className": "MuiNativeSelect-nativeInput", + "description": "Styles applied to the underlying native input component.", + "isGlobal": false + }, + { + "key": "outlined", + "className": "MuiNativeSelect-outlined", + "description": "Styles applied to the select component if `variant=\"outlined\"`.", + "isGlobal": false + }, + { + "key": "root", + "className": "MuiNativeSelect-root", + "description": "Styles applied to the root element.", + "isGlobal": false + }, + { + "key": "select", + "className": "MuiNativeSelect-select", + "description": "Styles applied to the select component `select` class.", + "isGlobal": false + }, + { + "key": "standard", + "className": "MuiNativeSelect-standard", + "description": "Styles applied to the select component if `variant=\"standard\"`.", + "isGlobal": false + } + ], + "spread": true, + "themeDefaultProps": true, + "muiName": "MuiNativeSelect", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-material/src/NativeSelect/NativeSelect.js", + "inheritance": { "component": "Input", "pathname": "/material-ui/api/input/" }, + "demos": "" +} diff --git a/docs/translations/api-docs/native-select/native-select.json b/docs/translations/api-docs/native-select/native-select.json new file mode 100644 index 0000000..99344b7 --- /dev/null +++ b/docs/translations/api-docs/native-select/native-select.json @@ -0,0 +1,90 @@ +{ + "componentDescription": "An alternative to `', () => { + const { clock, render } = createRenderer({ clock: 'fake' }); + + describeConformance( + Ten + Twenty + , + ); + + const trigger = screen.getByRole('combobox'); + stubRect(trigger, { left: 10, top: 10, width: 100, height: 40 }); + + await user.pointer({ + keys: '[MouseLeft>]', + target: trigger, + coords: { clientX: 20, clientY: 20 }, + }); + expect(screen.getByRole('listbox')).not.to.equal(null); + + await user.pointer({ + keys: '[/MouseLeft]', + target: screen.getByTestId('backdrop'), + coords: { clientX: 200, clientY: 200 }, + }); + + expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null); + }); + + it('closes a controlled menu when releasing the opening mouse gesture outside the trigger and menu', async () => { + const onClose = spy(); + + function ControlledSelect() { + const [open, setOpen] = React.useState(false); + + return ( + + ); + } + + const { user } = render(); + const trigger = screen.getByRole('combobox'); + stubRect(trigger, { left: 10, top: 10, width: 100, height: 40 }); + + await user.pointer({ + keys: '[MouseLeft>]', + target: trigger, + coords: { clientX: 20, clientY: 20 }, + }); + expect(screen.getByRole('listbox')).not.to.equal(null); + + await user.pointer({ + keys: '[/MouseLeft]', + target: screen.getByTestId('backdrop'), + coords: { clientX: 200, clientY: 200 }, + }); + + expect(onClose.callCount).to.equal(1); + expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null); + }); + + it('does not throw when the display ref is unavailable during mouse down', () => { + const handleMouseDown = spy(); + + render( + , + ); + + expect(() => { + fireEvent.mouseDown(screen.getByRole('combobox')); + }).not.to.throw(); + + expect(handleMouseDown.callCount).to.equal(1); + expect(screen.queryByRole('listbox')).to.equal(null); + }); + + it('keeps the menu open when releasing the opening mouse gesture inside the trigger bounds', async () => { + const { user } = render( + , + ); + + const trigger = screen.getByRole('combobox'); + stubRect(trigger, { left: 10, top: 10, width: 100, height: 40 }); + + await user.pointer({ + keys: '[MouseLeft>]', + target: trigger, + coords: { clientX: 20, clientY: 20 }, + }); + expect(screen.getByRole('listbox')).not.to.equal(null); + + await user.pointer({ + keys: '[/MouseLeft]', + target: screen.getByTestId('backdrop'), + coords: { clientX: 20, clientY: 20 }, + }); + + expect(screen.queryByRole('listbox', { hidden: false })).not.to.equal(null); + }); + + it('keeps the menu open when releasing the opening mouse gesture inside the menu paper', async () => { + const { user } = render( + , + ); + + await user.pointer({ keys: '[MouseLeft>]', target: screen.getByRole('combobox') }); + + await user.pointer({ keys: '[/MouseLeft]', target: screen.getByTestId('paper') }); + + expect(screen.queryByRole('listbox', { hidden: false })).not.to.equal(null); + }); + + it('does not select an option when the opening mouseup lands on it before the drag delay', async () => { + const onChange = spy(); + const { user } = render( + , + ); + + const trigger = screen.getByRole('combobox'); + await user.pointer({ keys: '[MouseLeft>]', target: trigger }); + await user.pointer({ + keys: '[/MouseLeft]', + target: screen.getByRole('option', { name: 'Twenty' }), + }); + + expect(trigger).to.have.text('Ten'); + expect(onChange.callCount).to.equal(0); + expect(screen.queryByRole('listbox', { hidden: false })).not.to.equal(null); + }); + + it('does not close when the opening mouseup lands on the selected option before the drag delay', async () => { + const onClose = spy(); + const { user } = render( + , + ); + + await user.pointer({ keys: '[MouseLeft>]', target: screen.getByRole('combobox') }); + await user.pointer({ + keys: '[/MouseLeft]', + target: screen.getByRole('option', { name: 'Ten' }), + }); + + expect(onClose.callCount).to.equal(0); + expect(screen.queryByRole('listbox', { hidden: false })).not.to.equal(null); + }); + + it('closes when the opening mouseup lands on the selected option after the selected-item delay', async () => { + const onClose = spy(); + const { user } = render( + , + ); + + const trigger = screen.getByRole('combobox'); + await user.pointer({ keys: '[MouseLeft>]', target: trigger }); + + await act(async () => { + // Well past the 400ms selected-item window; a tight margin flakes on slow CI. + await sleep(700); + }); + + await user.pointer({ + keys: '[/MouseLeft]', + target: screen.getByRole('option', { name: 'Ten' }), + }); + + expect(onClose.callCount).to.equal(1); + expect(trigger).to.have.text('Ten'); + expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null); + }); + + it('selects an option when dragging from the trigger and releasing after the drag delay', async () => { + const onChange = spy(); + const { user } = render( + , + ); + + const trigger = screen.getByRole('combobox'); + await user.pointer({ keys: '[MouseLeft>]', target: trigger }); + + await act(async () => { + // Well past the 200ms drag window; a tight margin flakes on slow CI. + await sleep(400); + }); + await user.pointer({ + keys: '[/MouseLeft]', + target: screen.getByRole('option', { name: 'Twenty' }), + }); + + expect(trigger).to.have.text('Twenty'); + expect(onChange.callCount).to.equal(1); + expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null); + }); + + it('does not double-toggle a multiple select option on a regular click', async () => { + const onChange = spy(); + const { user } = render( + , + ); + + const trigger = screen.getByRole('combobox'); + await user.click(trigger); + + const option = screen.getByRole('option', { name: 'Twenty' }); + await user.click(option); + + expect(trigger).to.have.text('Ten, Twenty'); + expect(onChange.callCount).to.equal(1); + expect(screen.queryByRole('listbox', { hidden: false })).not.to.equal(null); + }); + + it('toggles a multiple select option when dragging from the trigger and releasing after the drag delay', async () => { + const onChange = spy(); + const { user } = render( + , + ); + + const trigger = screen.getByRole('combobox'); + await user.pointer({ keys: '[MouseLeft>]', target: trigger }); + + await act(async () => { + // Well past the 200ms drag window; a tight margin flakes on slow CI. + await sleep(400); + }); + await user.pointer({ + keys: '[/MouseLeft]', + target: screen.getByRole('option', { name: 'Twenty' }), + }); + + expect(trigger).to.have.text('Ten, Twenty'); + expect(onChange.callCount).to.equal(1); + expect(screen.queryByRole('listbox', { hidden: false })).not.to.equal(null); + }); + }); + + describe('prop: inputProps', () => { + it('should be able to provide a custom classes property', () => { + render( + + + None + + Ten + Twenty + Thirty + , + ); + + expect(container.querySelector('input')).to.have.property('value', '10'); + }); + + it('the trigger is in tab order', () => { + render( + , + ); + + expect(screen.getByRole('combobox')).to.have.property('tabIndex', 0); + }); + + it('should accept null child', () => { + render( + , + ); + }); + + ['', 0, false, undefined, NaN].forEach((value) => + it(`should support conditional rendering with "${value}"`, () => { + render( + , + ); + }), + ); + + it('should have an input with [aria-hidden] by default', () => { + const { container } = render( + , + ); + + expect(container.querySelector('input')).to.have.attribute('aria-hidden', 'true'); + }); + + it('should ignore onBlur when the menu opens', async () => { + // mousedown calls focus while click opens moving the focus to an item + // this means the trigger is blurred immediately + const handleBlur = spy(); + + render( + , + ); + + const trigger = screen.getByRole('combobox'); + + fireEvent.mouseDown(trigger); + + expect(handleBlur.callCount).to.equal(0); + expect(screen.getByRole('listbox')).not.to.equal(null); + + const options = screen.getAllByRole('option'); + fireEvent.mouseDown(options[0]); + + await act(async () => { + options[0].click(); + }); + + expect(handleBlur.callCount).to.equal(0); + expect(screen.queryByRole('listbox', { hidden: false })).to.equal(null); + }); + + it('options should have a data-value attribute', () => { + render( + , + ); + const options = screen.getAllByRole('option'); + + expect(options[0]).to.have.attribute('data-value', '10'); + expect(options[1]).to.have.attribute('data-value', '20'); + }); + + it('should select an option when the space key is pressed', () => { + const handleChange = spy(); + const handleKeyDown = spy(); + render( + , + ); + + const trigger = screen.getByRole('combobox'); + fireEvent.mouseDown(trigger); + + const options = screen.getAllByRole('option'); + fireEvent.keyDown(options[0], { key: 'ArrowDown' }); + fireEvent.keyDown(options[1], { key: 'ArrowDown' }); + fireEvent.keyDown(options[2], { key: ' ' }); + + expect(handleChange.callCount).to.equal(1); + expect(handleKeyDown.callCount).to.equal(3); + expect(handleChange.firstCall.args[0].target.value).to.equal('2'); + }); + + it('should call item onKeyDown before triggering selection on space', () => { + const callOrder = []; + const handleChange = spy(() => callOrder.push('change')); + const handleKeyDown = spy(() => callOrder.push('keydown')); + + render( + , + ); + + const trigger = screen.getByRole('combobox'); + fireEvent.mouseDown(trigger); + + const option = screen.getByRole('option'); + fireEvent.keyDown(option, { key: ' ' }); + + expect(handleKeyDown.callCount).to.equal(1); + expect(handleChange.callCount).to.equal(1); + expect(callOrder).to.deep.equal(['keydown', 'change']); + }); + + it('should not select an option when space is pressed on a non-target element', async () => { + const handleChange = spy(); + const handleKeyDown = spy(); + + render( + , + ); + + const trigger = screen.getByRole('combobox'); + fireEvent.mouseDown(trigger); + + const option = screen.getByRole('option'); + const innerSpan = option.querySelector('span'); + + await act(async () => { + // Dispatch directly to bypass testing-library's active-element guard so that + // event.target (span) !== event.currentTarget (li) inside the React handler. + innerSpan.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }), + ); + }); + + expect(handleChange.callCount).to.equal(0); + }); + + it('should not select an option when onKeyDown calls preventDefault', () => { + const handleChange = spy(); + const handleKeyDown = spy((event) => event.preventDefault()); + + render( + , + ); + + const trigger = screen.getByRole('combobox'); + fireEvent.mouseDown(trigger); + + const option = screen.getByRole('option'); + fireEvent.keyDown(option, { key: ' ' }); + + expect(handleKeyDown.callCount).to.equal(1); + expect(handleChange.callCount).to.equal(0); + }); + + [' ', 'ArrowUp', 'ArrowDown', 'Enter'].forEach((key) => { + it(`should open menu when pressed ${key} key on select`, async () => { + render( + , + ); + const trigger = screen.getByRole('combobox'); + await act(async () => { + trigger.focus(); + }); + + fireEvent.keyDown(trigger, { key }); + expect(screen.getByRole('listbox', { hidden: false })).not.to.equal(null); + + fireEvent.keyUp(screen.getAllByRole('option')[0], { key }); + expect(screen.getByRole('listbox', { hidden: false })).not.to.equal(null); + }); + }); + + 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); + + render( + , + ); + + const button = screen.getByRole('combobox'); + await act(async () => { + button.focus(); + button.blur(); + }); + + expect(handleBlur.callCount).to.equal(1); + expect(handleBlur.firstCall.returnValue).to.equal('blur-testing'); + }); + + it('should call onClose when the backdrop is clicked', async () => { + const handleClose = spy(); + + render( + , + ); + + await act(async () => { + screen.getByTestId('backdrop').click(); + }); + + expect(handleClose.callCount).to.equal(1); + }); + + it('should call onClose when the same option is selected', () => { + const handleChange = spy(); + const handleClose = spy(); + render( + , + ); + + screen.getByRole('option', { selected: true }).click(); + + expect(handleChange.callCount).to.equal(0); + expect(handleClose.callCount).to.equal(1); + }); + + it('should focus select when its label is clicked', () => { + render( + + + ); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + // TODO not matching WAI-ARIA authoring practices. It should focus the first (or selected) item. + expect(screen.getByRole('listbox')).toHaveFocus(); + }); + + describe('prop: onChange', () => { + it('should get selected element from arguments', async () => { + const onChangeHandler = spy(); + + render( + , + ); + + fireEvent.mouseDown(screen.getByRole('combobox')); + await act(async () => { + screen.getAllByRole('option')[1].click(); + }); + + expect(onChangeHandler.calledOnce).to.equal(true); + const selected = onChangeHandler.args[0][1]; + expect(React.isValidElement(selected)).to.equal(true); + }); + + it('should call onChange before onClose', async () => { + const eventLog = []; + const onChangeHandler = spy(() => eventLog.push('CHANGE_EVENT')); + const onCloseHandler = spy(() => eventLog.push('CLOSE_EVENT')); + + render( + , + ); + + fireEvent.mouseDown(screen.getByRole('combobox')); + await act(async () => { + screen.getAllByRole('option')[1].click(); + }); + + expect(eventLog).to.deep.equal(['CHANGE_EVENT', 'CLOSE_EVENT']); + }); + + it('should not be called if selected element has the current value (value did not change)', async () => { + const onChangeHandler = spy(); + + render( + , + ); + + fireEvent.mouseDown(screen.getByRole('combobox')); + await act(async () => { + screen.getAllByRole('option')[1].click(); + }); + + 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', () => { + it('should be open on mount', () => { + render( + Ten + Twenty + Thirty + , + ); + const options = screen.getAllByRole('option'); + + expect(options[0]).not.to.have.attribute('aria-selected', 'true'); + expect(options[1]).to.have.attribute('aria-selected', 'true'); + expect(options[2]).not.to.have.attribute('aria-selected', 'true'); + }); + + it('should select the option based on the string value', () => { + render( + , + ); + const options = screen.getAllByRole('option'); + + expect(options[0]).not.to.have.attribute('aria-selected', 'true'); + expect(options[1]).to.have.attribute('aria-selected', 'true'); + expect(options[2]).not.to.have.attribute('aria-selected', 'true'); + }); + + it('should select only the option that matches the object', () => { + const obj1 = { id: 1 }; + const obj2 = { id: 2 }; + render( + , + ); + const options = screen.getAllByRole('option'); + + expect(options[0]).to.have.attribute('aria-selected', 'true'); + expect(options[1]).not.to.have.attribute('aria-selected', 'true'); + }); + + it('should be able to use an object', () => { + const value = {}; + + render( + , + ); + + expect(screen.getByRole('combobox')).to.have.text('Twenty'); + }); + + describe('warnings', () => { + it('warns when the value is not present in any option', () => { + const errorMessage = + 'MUI: You have provided an out-of-range value `20` for the select component.'; + + let expectedOccurrences = 2; + + if (reactMajor >= 18) { + expectedOccurrences = 3; + } + + expect(() => + render( + , + ), + ).toWarnDev(Array(expectedOccurrences).fill(errorMessage)); + }); + }); + }); + + it('should not have the selectable option selected when inital value provided is empty string on Select with ListSubHeader item', () => { + render( + , + ); + + const options = screen.getAllByRole('option'); + expect(options[1]).not.to.have.class(menuItemClasses.selected); + }); + + describe('SVG icon', () => { + it('should not present an SVG icon when native and multiple are specified', () => { + const { container } = render( + , + ); + expect(container.querySelector('svg')).to.equal(null); + }); + + it('should present an SVG icon', () => { + const { container } = render( + , + ); + expect(container.querySelector('svg')).toBeVisible(); + }); + }); + + describe('accessibility', () => { + it('sets aria-expanded="true" when the listbox is displayed', () => { + render(); + + expect(screen.getByRole('combobox')).to.have.attribute('aria-expanded', 'false'); + }); + + it('sets aria-disabled="true" when component is disabled', () => { + render(); + + expect(container.querySelector('input')).to.have.property('disabled', true); + }); + + it('aria-disabled is not present if component is not disabled', () => { + render(); + + expect(screen.getByRole('combobox')).to.have.attribute('aria-required', 'true'); + }); + + it('aria-required is not present if component is not required', () => { + render(); + + expect(container.querySelector('input')).to.have.property('required', true); + }); + + it('sets aria-invalid="true" when component is in the error state', () => { + render(); + + expect(screen.getByRole('combobox')).not.to.have.attribute('aria-invalid'); + }); + + it('indicates that activating the button displays a listbox', () => { + render(); + + expect(screen.getByRole('listbox')).toBeVisible(); + }); + + it('indicates that input element has combobox role and aria-controls set to id of listbox', () => { + render(); + expect(screen.getByRole('combobox', { hidden: true })).to.not.have.attribute('aria-controls'); + }); + + it('the listbox is focusable', async () => { + render( + First + Second + , + ); + + const options = screen.getAllByRole('option'); + expect(options[0]).to.have.text('First'); + expect(options[1]).to.have.text('Second'); + }); + + it('indicates the selected option', () => { + render( + , + ); + + expect(screen.getAllByRole('option')[1]).to.have.attribute('aria-selected', 'true'); + }); + + describe('when the first child is a ListSubheader', () => { + it('first selectable option is focused to use the arrow', () => { + render( + , + ); + + const options = screen.getAllByRole('option'); + expect(options[1]).to.have.attribute('tabindex', '0'); + + fireEvent.keyDown(options[1], { key: 'ArrowDown' }); + fireEvent.keyDown(options[2], { key: 'ArrowDown' }); + fireEvent.keyDown(options[4], { key: 'Enter' }); + + expect(options[4]).to.have.attribute('aria-selected', 'true'); + }); + + describe('when also the second child is a ListSubheader', () => { + it('first selectable option is focused to use the arrow', () => { + render( + , + ); + + const options = screen.getAllByRole('option'); + expect(options[2]).to.have.attribute('tabindex', '0'); + + fireEvent.keyDown(options[2], { key: 'ArrowDown' }); + fireEvent.keyDown(options[3], { key: 'ArrowDown' }); + fireEvent.keyDown(options[5], { key: 'Enter' }); + + expect(options[5]).to.have.attribute('aria-selected', 'true'); + }); + }); + + describe('when the second child is null', () => { + it('first selectable option is focused to use the arrow', () => { + render( + , + ); + + const options = screen.getAllByRole('option'); + expect(options[1]).to.have.attribute('tabindex', '0'); + + fireEvent.keyDown(options[1], { key: 'ArrowDown' }); + fireEvent.keyDown(options[2], { key: 'ArrowDown' }); + fireEvent.keyDown(options[4], { key: 'Enter' }); + + expect(options[4]).to.have.attribute('aria-selected', 'true'); + }); + }); + + ['', 0, false, undefined, NaN].forEach((value) => + describe(`when the second child is conditionally rendering with "${value}"`, () => { + it('first selectable option is focused to use the arrow', () => { + render( + , + ); + + const options = screen.getAllByRole('option'); + expect(options[1]).to.have.attribute('tabindex', '0'); + + fireEvent.keyDown(options[1], { key: 'ArrowDown' }); + fireEvent.keyDown(options[2], { key: 'ArrowDown' }); + fireEvent.keyDown(options[4], { key: 'Enter' }); + + expect(options[4]).to.have.attribute('aria-selected', 'true'); + }); + }), + ); + }); + + describe('when the first child is a ListSubheader wrapped in a custom component', () => { + function WrappedListSubheader(props) { + return ; + } + + it('highlights the first selectable option below the header without extra skip markers', () => { + render( + , + ); + + const expectedHighlightedOption = screen.getByText('Option 1'); + expect(expectedHighlightedOption).to.have.attribute('tabindex', '0'); + }); + }); + + describe('when the first child is a MenuItem disabled', () => { + it('highlights the first selectable option below the header', () => { + render( + , + ); + + const options = screen.getAllByRole('option'); + expect(options[2]).to.have.attribute('tabindex', '0'); + + fireEvent.keyDown(options[2], { key: 'ArrowDown' }); + fireEvent.keyDown(options[3], { key: 'ArrowDown' }); + fireEvent.keyDown(options[5], { key: 'Enter' }); + + expect(options[5]).to.have.attribute('aria-selected', 'true'); + }); + }); + + it('will be labelled by its visible value when it has no label', () => { + render( + , + ); + + const combobox = screen.getByRole('combobox'); + + expect(combobox).not.to.have.attribute('aria-labelledby'); + expect(combobox).to.have.text('Option 1'); + }); + + it('will be labelled by an additional element if its id is provided in `labelId`', () => { + render( + + Choose one: + + , + ); + + const combobox = screen.getByRole('combobox'); + + expect(combobox).to.have.attribute('aria-labelledby', 'select-label'); + expect(combobox).toHaveAccessibleName('Choose one:'); + expect(combobox).to.have.text('Option 1'); + }); + + it('the list of options is not labelled by default', () => { + render( + , + ); + + expect(screen.getByRole('listbox')).to.have.attribute('aria-labelledby', 'select-label'); + }); + + it('should have appropriate accessible description when provided in props', () => { + render( + + + Ten + Twenty + , + ); + const trigger = screen.getByRole('combobox'); + await act(async () => { + trigger.focus(); + }); + + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + expect(screen.queryByRole('listbox')).to.equal(null); + + fireEvent.keyUp(trigger, { key: 'ArrowDown' }); + expect(screen.queryByRole('listbox')).to.equal(null); + }); + }); + + describe('prop: MenuProps', () => { + it('should apply additional props to the Menu component', () => { + const onEntered = spy(); + + render( + , + ); + + fireEvent.mouseDown(screen.getByRole('combobox')); + clock.tick(99); + + expect(onEntered.callCount).to.equal(0); + + clock.tick(1); + + expect(onEntered.callCount).to.equal(1); + }); + + it('should be able to override slotProps.paper minWidth', () => { + render( + , + ); + + expect(screen.getByTestId('paper').style).to.have.property('minWidth', '12px'); + }); + + // https://github.com/mui/material-ui/issues/38700 + it.skipIf(isJsdom())( + 'should merge `slotProps.paper` with the default Paper props', + function test() { + render( + , + ); + + const paper = screen.getByTestId('paper'); + const selectButton = screen.getByRole('combobox', { hidden: true }); + + expect(paper.style).to.have.property('minWidth', `${selectButton.clientWidth}px`); + }, + ); + + // https://github.com/mui/material-ui/issues/46273 + it('should merge `slotProps.list` with default List props', () => { + render( + , + ); + + const listbox = screen.getByRole('listbox'); + expect(listbox).not.to.have.class(listClasses.padding); + }); + + // https://github.com/mui/material-ui/issues/38949 + it('should forward `slotProps` to menu', function test() { + render( + , + ); + + const backdrop = screen.getByTestId('backdrop'); + + expect(backdrop.style).to.have.property('backgroundColor', 'red'); + }); + + // https://github.com/mui/material-ui/issues/34218 + it('supports keyboard navigation after mouse opening when disablePortal is true', async function test() { + clock.restore(); + + const { user } = render( + , + ); + + await user.click(screen.getByRole('combobox')); + + const options = screen.getAllByRole('option', { hidden: true }); + expect(options[0]).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + expect(options[1]).toHaveFocus(); + + await user.keyboard('{ArrowUp}'); + expect(options[0]).toHaveFocus(); + }); + }); + + describe('prop: SelectDisplayProps', () => { + it('should apply additional props to trigger element', () => { + render( + , + ); + + expect(screen.getByRole('combobox')).to.have.attribute('data-test', 'SelectDisplay'); + }); + }); + + describe('prop: displayEmpty', () => { + it('should display the selected item even if its value is empty', () => { + render( + , + ); + + expect(screen.getByRole('combobox')).to.have.text('Ten'); + }); + + it.skipIf(isJsdom())( + 'should notch the outline to accommodate the label when displayEmpty', + function test() { + const { container } = render( + , + ); + + expect(container.querySelector('legend')).toHaveComputedStyle({ + maxWidth: '100%', + }); + }, + ); + }); + + describe('prop: renderValue', () => { + it('should use the prop to render the value', () => { + const renderValue = (x) => `0b${x.toString(2)}`; + + render( + , + ); + + expect(screen.getByRole('combobox')).to.have.text('0b100'); + }); + }); + + describe('prop: open (controlled)', () => { + it('should not focus on close controlled select', async () => { + function ControlledWrapper() { + const [open, setOpen] = React.useState(false); + + return ( +
+ + +
+ ); + } + const { container } = render(); + const openSelect = container.querySelector('#open-select'); + await act(async () => { + openSelect.focus(); + }); + fireEvent.click(openSelect); + + const option = screen.getByRole('option'); + expect(option).toHaveFocus(); + fireEvent.click(option); + + expect(container.querySelectorAll(classes.focused).length).to.equal(0); + expect(openSelect).toHaveFocus(); + }); + + it('should allow to control closing by passing onClose props', async () => { + function ControlledWrapper() { + const [open, setOpen] = React.useState(false); + + return ( + + ); + } + render(); + + fireEvent.mouseDown(screen.getByRole('combobox')); + expect(screen.getByRole('listbox')).not.to.equal(null); + + await act(async () => { + screen.getByRole('option').click(); + }); + // react-transition-group uses one extra commit for exit to completely remove + // it from the DOM. but it's at least immediately inaccessible. + // It's desired that this fails one day. The additional tick required to remove + // this from the DOM is not a feature + expect(screen.getByRole('listbox', { hidden: true })).toBeInaccessible(); + clock.tick(0); + + expect(screen.queryByRole('listbox', { hidden: true })).to.equal(null); + }); + + it('should be open when initially true', () => { + render( + , + ); + + expect(screen.getByRole('listbox')).not.to.equal(null); + }); + + it('open only with the left mouse button click', () => { + render( + , + ); + + const trigger = screen.getByRole('combobox'); + + // If clicked by the right/middle mouse button, no options list should be opened + fireEvent.mouseDown(trigger, { button: 1 }); + expect(screen.queryByRole('listbox')).to.equal(null); + + fireEvent.mouseDown(trigger, { button: 2 }); + expect(screen.queryByRole('listbox')).to.equal(null); + }); + }); + + describe('prop: autoWidth', () => { + it('should take the trigger parent element width into account by default', () => { + const { container } = render( + , + ); + const parentEl = container.querySelector('.MuiInputBase-root'); + const button = screen.getByRole('combobox'); + stub(parentEl, 'clientWidth').get(() => 14); + + fireEvent.mouseDown(button); + expect(screen.getByTestId('paper').style).to.have.property('minWidth', '14px'); + }); + + it('should not take the trigger parent element width into account when autoWidth is true', () => { + const { container } = render( + , + ); + const parentEl = container.querySelector('.MuiInputBase-root'); + const button = screen.getByRole('combobox'); + stub(parentEl, 'clientWidth').get(() => 14); + + fireEvent.mouseDown(button); + expect(screen.getByTestId('paper').style).to.have.property('minWidth', ''); + }); + }); + + describe('prop: multiple', () => { + it('should serialize multiple select value', () => { + const { container } = render( + , + ); + const options = screen.getAllByRole('option'); + + expect(container.querySelector('input')).to.have.property('value', '10,30'); + expect(options[0]).to.have.attribute('aria-selected', 'true'); + expect(options[1]).not.to.have.attribute('aria-selected', 'true'); + expect(options[2]).to.have.attribute('aria-selected', 'true'); + }); + + it('should have aria-multiselectable=true when multiple is true', () => { + render( + , + ); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + expect(screen.getByRole('listbox')).to.have.attribute('aria-multiselectable', 'true'); + }); + + it('should serialize multiple select display value', () => { + render( + , + ); + + expect(screen.getByRole('combobox')).to.have.text('Ten, Twenty, Thirty'); + }); + + it('should not throw an error if `value` is an empty array', () => { + expect(() => { + render(); + }).not.to.throw(); + }); + + it("selects value based on their stringified equality when they're not objects", () => { + render( + , + ); + + const options = screen.getAllByRole('option'); + + expect(options[0]).to.have.attribute('aria-selected', 'true'); + expect(options[1]).to.have.attribute('aria-selected', 'true'); + expect(options[2]).not.to.have.attribute('aria-selected', 'true'); + }); + + it("selects values based on strict equality if they're objects", () => { + const obj1 = { id: 1 }; + const obj2 = { id: 2 }; + const obj3 = { id: 3 }; + + render( + , + ); + + const options = screen.getAllByRole('option'); + + expect(options[0]).to.have.attribute('aria-selected', 'true'); + expect(options[1]).not.to.have.attribute('aria-selected', 'true'); + expect(options[2]).to.have.attribute('aria-selected', 'true'); + }); + + describe('errors', () => { + // can't catch render errors in the browser for unknown reason + // tried try-catch + error boundary + window onError preventDefault + // TODO is this fixed? + it.skipIf(!isJsdom())('should throw if non array', function test() { + const errorRef = React.createRef(); + expect(() => { + render( + + + , + ); + }).toErrorDev([ + 'MUI: The `value` prop must be an array', + // React 18 Strict Effects run mount effects twice + reactMajor === 18 && 'MUI: The `value` prop must be an array', + reactMajor < 19 && + /The above error occurred in the component/, + ]); + const { + current: { errors }, + } = errorRef; + expect(errors).to.have.length(1); + expect(errors[0].toString()).to.include('MUI: The `value` prop must be an array'); + }); + }); + + describe('prop: onChange', () => { + it('should call onChange when clicking an item', async () => { + function ControlledSelectInput(props) { + const { onChange } = props; + const [values, clickedValue] = React.useReducer((currentValues, valueClicked) => { + if (!currentValues.includes(valueClicked)) { + return currentValues.concat(valueClicked); + } + return currentValues.filter((value) => { + return value !== valueClicked; + }); + }, []); + + const handleChange = (event) => { + onChange(event); + clickedValue(event.target.value); + }; + + return ( + + ); + } + const onChange = stub().callsFake((event) => { + return { + name: event.target.name, + value: event.target.value, + }; + }); + render(); + + fireEvent.mouseDown(screen.getByRole('combobox')); + const options = screen.getAllByRole('option'); + fireEvent.click(options[2]); + + expect(onChange.callCount).to.equal(1); + expect(onChange.firstCall.returnValue).to.deep.equal({ name: 'age', value: [30] }); + + await act(async () => { + options[0].click(); + }); + + expect(onChange.callCount).to.equal(2); + expect(onChange.secondCall.returnValue).to.deep.equal({ name: 'age', value: [30, 10] }); + }); + }); + + it('should apply multiple class to `select` slot', () => { + const { container } = render( + , + ); + + expect(container.querySelector(`.${classes.select}`)).to.have.class(classes.multiple); + }); + + it.skipIf(isJsdom())( + 'should be able to override `multiple` rule name in `select` slot', + function test() { + const selectStyle = { + marginLeft: '10px', + marginTop: '10px', + }; + + const multipleStyle = { + marginTop: '14px', + }; + + const theme = createTheme({ + components: { + MuiSelect: { + styleOverrides: { + select: selectStyle, + multiple: multipleStyle, + }, + }, + }, + }); + + const { container } = render( + + + , + ); + + const combinedStyle = { ...selectStyle, ...multipleStyle }; + + expect(container.getElementsByClassName(classes.select)[0]).to.toHaveComputedStyle( + combinedStyle, + ); + }, + ); + }); + + describe('prop: autoFocus', () => { + it('should focus select after Select did mount', () => { + render(); + + expect(ref.current.node).to.have.tagName('input'); + + setProps({ + value: '', + }); + expect(ref.current.node).to.have.tagName('input'); + }); + + describe('prop: inputRef', () => { + it('should be able to return the input node via a ref object', () => { + const ref = React.createRef(); + render( is still used. + it('should be able focus the trigger imperatively', async () => { + const ref = React.createRef(); + render(); + + expect(screen.getByRole('combobox')).not.to.have.attribute('id'); + }); + + it('should have select-`name` id when name is provided', () => { + render(', () => { + const { container } = render( +
, + ); + + expect(screen.getByRole('combobox', { name: 'A select' })).to.have.property( + 'tagName', + 'SELECT', + ); + }); + }); + + it('prevents the default when releasing Space on the children', () => { + const keyUpSpy = spy(); + render( + , + ); + + fireEvent.keyUp(screen.getAllByRole('option')[0], { key: ' ' }); + + expect(keyUpSpy.callCount).to.equal(1); + expect(keyUpSpy.firstCall.args[0]).to.have.property('defaultPrevented', true); + }); + + it('should pass onClick prop to MenuItem', () => { + const onClick = spy(); + + render( + , + ); + + const options = screen.getAllByRole('option'); + fireEvent.click(options[0]); + + expect(onClick.callCount).to.equal(1); + }); + + // https://github.com/testing-library/react-testing-library/issues/322 + // https://x.com/devongovett/status/1248306411508916224 + it('should handle the browser autofill event and simple testing-library API', () => { + const onChangeHandler = spy(); + const { container } = render( + , + ); + fireEvent.change(container.querySelector('input[name="country"]'), { + target: { + value: 'france', + }, + }); + + expect(onChangeHandler.calledOnce).to.equal(true); + expect(screen.getByRole('combobox')).to.have.text('France'); + }); + + // see https://github.com/jsdom/jsdom/issues/123 + it.skipIf(isJsdom())('should support native form validation', function test() { + const handleSubmit = spy((event) => { + event.preventDefault(); + }); + function Form(props) { + return ( +
+ +
+ , + ); + + const button = screen.getByText('Submit'); + await act(async () => { + button.click(); + }); + }, + ); + }); + + describe('theme styleOverrides:', () => { + it.skipIf(isJsdom())( + 'should override with error style when `native select` has `error` state', + function test() { + const iconStyle = { color: 'rgb(255, 0, 0)' }; + + const theme = createTheme({ + components: { + MuiNativeSelect: { + styleOverrides: { + icon: (props) => ({ + ...(props.ownerState.error && iconStyle), + }), + }, + }, + }, + }); + + const { container } = render( + + + , + ); + + expect(container.querySelector(`.${nativeSelectClasses.icon}`)).toHaveComputedStyle( + iconStyle, + ); + }, + ); + + it.skipIf(isJsdom())( + 'should override with error style when `select` has `error` state', + function test() { + const iconStyle = { color: 'rgb(255, 0, 0)' }; + const selectStyle = { color: 'rgb(255, 192, 203)' }; + + const theme = createTheme({ + components: { + MuiSelect: { + styleOverrides: { + icon: (props) => ({ + ...(props.ownerState.error && iconStyle), + }), + select: (props) => ({ + ...(props.ownerState.error && selectStyle), + }), + }, + }, + }, + }); + + const { container } = render( + + + + + + , + ); + + expect(screen.getByTestId('input')).to.toHaveComputedStyle({ + fontWeight: '200', + }); + }); + }); + + describe('prop: input', () => { + it('merges `ref` of `Select` and `input`', () => { + const Input = React.forwardRef(function Input(props, ref) { + const { inputProps, inputComponent: Component, ...other } = props; + + React.useImperativeHandle(ref, () => { + return { refToInput: true }; + }); + + return ; + }); + const inputRef = React.createRef(); + const selectRef = React.createRef(); + render( + + + None + + Ten + Twenty + Thirty + , + ); + + expect(container.querySelector(`.${classes.root}`)).not.to.equal(null); + }); + + it('should merge the class names', () => { + render( + + , + ); + + fireEvent.click(screen.getByTestId('test-element')); + + expect(screen.getByRole('combobox')).not.toHaveFocus(); + }); + + it('outlined icon should be selectable with sibling selector', () => { + const { container } = render(); + expect(container.querySelector('.MuiSelect-standard ~ .MuiSelect-icon')).not.to.equal(null); + }); + + it('filled icon should be selectable with sibling selector', () => { + const { container } = render( + One + Two + , + ); + + const trigger = screen.getByRole('combobox'); + await act(async () => { + trigger.focus(); + }); + + fireEvent.keyDown(trigger, { key: 'a' }); + + expect(handleKeyDown.callCount).to.equal(1); + const event = handleKeyDown.firstCall.args[0]; + expect(event).to.have.property('key', 'a'); + }); + + it('should call onMouseDown when passed', async () => { + const handleMouseDown = spy(); + + render( + , + ); + + const trigger = screen.getByRole('combobox'); + await act(async () => { + trigger.focus(); + }); + + fireEvent.mouseDown(trigger); + + expect(handleMouseDown.callCount).to.equal(1); + const event = handleMouseDown.firstCall.args[0]; + expect(event.button).to.equal(0); + }); + + describe('keyboard navigation in shadow DOM', () => { + it.skipIf(isJsdom())('should navigate between options using arrow keys', async function test() { + // reset fake timers + clock.restore(); + + // Create a shadow container + const shadowHost = document.createElement('div'); + document.body.appendChild(shadowHost); + const shadowContainer = shadowHost.attachShadow({ mode: 'open' }); + + // Render directly into shadow container + const shadowRoot = document.createElement('div'); + shadowContainer.appendChild(shadowRoot); + + const { unmount, user } = render( + , + { container: shadowRoot }, + ); + + const trigger = shadowRoot.querySelector('[role="combobox"]'); + expect(trigger).not.to.equal(null); + + // Open Select + await user.click(trigger); + + const options = shadowRoot.querySelectorAll('[role="option"]'); + expect(options.length).to.equal(3); + + expect(shadowContainer.activeElement).to.equal(options[0]); + + await user.keyboard('{ArrowDown}'); + + expect(shadowContainer.activeElement).to.equal(options[1]); + + await user.keyboard('{ArrowUp}'); + + expect(shadowContainer.activeElement).to.equal(options[0]); + + // Cleanup + unmount(); + if (shadowHost.parentNode) { + document.body.removeChild(shadowHost); + } + }); + }); + + it.skipIf(isJsdom())('updates menu minWidth when the trigger resizes while open', async () => { + clock.restore(); + + render( + , + ); + + const combobox = screen.getByRole('combobox'); + const anchor = combobox.parentElement; + anchor.style.width = '320px'; + + fireEvent.mouseDown(combobox); + + await waitFor(() => { + const listbox = screen.getByRole('listbox'); + const paper = listbox.parentElement; + expect(paper.style.minWidth).to.equal('320px'); + }); + + anchor.style.width = '180px'; + + // ResizeObserver callbacks are delivered during the browser's rendering pipeline. + // Force at least one complete frame so the RO can detect the size change, + await act(async () => { + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }); + }); + + await waitFor(() => { + const listbox = screen.getByRole('listbox'); + const paper = listbox.parentElement; + expect(paper.style.minWidth).to.equal('180px'); + }); + }); +}); diff --git a/packages/mui-material/src/Select/SelectInput.js b/packages/mui-material/src/Select/SelectInput.js new file mode 100644 index 0000000..806baf3 --- /dev/null +++ b/packages/mui-material/src/Select/SelectInput.js @@ -0,0 +1,1084 @@ +'use client'; +import * as React from 'react'; +import { isFragment } from 'react-is'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import composeClasses from '@mui/utils/composeClasses'; +import useId from '@mui/utils/useId'; +import refType from '@mui/utils/refType'; +import useTimeout from '@mui/utils/useTimeout'; +import ownerDocument from '../utils/ownerDocument'; +import Menu from '../Menu/Menu'; +import { StyledSelectSelect, StyledSelectIcon } from '../NativeSelect/NativeSelectInput'; +import { isFilled } from '../InputBase/utils'; +import { styled } from '../zero-styled'; +import slotShouldForwardProp from '../styles/slotShouldForwardProp'; +import useEnhancedEffect from '../utils/useEnhancedEffect'; +import useEventCallback from '../utils/useEventCallback'; +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 + * the element, even if a portal or backdrop retargeted the event away from it. + * + * Select uses this for the opening mouseup: when the menu opens over the + * trigger, the release can target the backdrop or portaled menu even though the + * pointer is still inside the trigger or menu bounds. + */ +function isMouseEventInsideElement(event, element) { + if (!element) { + return false; + } + + const eventPath = event.composedPath(); + if (eventPath.includes(element)) { + return true; + } + + if (event.target?.nodeType && element.contains(event.target)) { + return true; + } + + const rect = element.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + // Hidden or transition-mounted elements do not have useful bounds to hit-test. + return false; + } + + return ( + event.clientX >= rect.left - OPENING_MOUSE_UP_BOUNDARY_OFFSET && + event.clientX <= rect.right + OPENING_MOUSE_UP_BOUNDARY_OFFSET && + event.clientY >= rect.top - OPENING_MOUSE_UP_BOUNDARY_OFFSET && + event.clientY <= rect.bottom + OPENING_MOUSE_UP_BOUNDARY_OFFSET + ); +} + +const SelectSelect = styled(StyledSelectSelect, { + name: 'MuiSelect', + slot: 'Select', + overridesResolver: (props, styles) => { + const { ownerState } = props; + return [ + // Win specificity over the input base + { [`&.${selectClasses.select}`]: styles.select }, + { [`&.${selectClasses.select}`]: styles[ownerState.variant] }, + { [`&.${selectClasses.error}`]: styles.error }, + { [`&.${selectClasses.multiple}`]: styles.multiple }, + ]; + }, +})({ + // Win specificity over the input base + [`&.${selectClasses.select}`]: { + height: 'auto', // Resets for multiple select with chips + minHeight: '1.4375em', // Required for select\text-field height consistency + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + }, +}); + +const SelectIcon = styled(StyledSelectIcon, { + name: 'MuiSelect', + slot: 'Icon', + overridesResolver: (props, styles) => { + const { ownerState } = props; + return [styles.icon, ownerState.open && styles.iconOpen]; + }, +})({}); + +const SelectNativeInput = styled('input', { + shouldForwardProp: (prop) => slotShouldForwardProp(prop) && prop !== 'classes', + name: 'MuiSelect', + slot: 'NativeInput', +})({ + bottom: 0, + left: 0, + position: 'absolute', + opacity: 0, + pointerEvents: 'none', + width: '100%', + boxSizing: 'border-box', +}); + +const useUtilityClasses = (ownerState) => { + const { classes, variant, disabled, multiple, open, error } = ownerState; + + const slots = { + select: ['select', variant, disabled && 'disabled', multiple && 'multiple', error && 'error'], + icon: ['icon', open && 'iconOpen', disabled && 'disabled'], + nativeInput: ['nativeInput'], + }; + + return composeClasses(slots, getSelectUtilityClasses, classes); +}; + +/** + * @ignore - internal component. + */ +const SelectInput = React.forwardRef(function SelectInput(props, ref) { + const { + 'aria-describedby': ariaDescribedby, + 'aria-label': ariaLabel, + autoFocus, + autoWidth, + children, + className, + defaultOpen, + defaultValue, + disabled, + displayEmpty, + error = false, + IconComponent, + inputRef: inputRefProp, + labelId, + MenuProps = {}, + multiple, + name, + onBlur, + onChange, + onClose, + onFocus, + // eslint-disable-next-line react/prop-types + onKeyDown, + // eslint-disable-next-line react/prop-types + onMouseDown, + onOpen, + open: openProp, + readOnly, + renderValue, + required, + SelectDisplayProps = {}, + tabIndex: tabIndexProp, + // catching `type` from Input which makes no sense for SelectInput + type, + value: valueProp, + variant = 'standard', + ...other + } = props; + + const [value, setValueState] = useControlled({ + controlled: valueProp, + default: defaultValue, + name: 'Select', + }); + const [openState, setOpenState] = useControlled({ + controlled: openProp, + default: defaultOpen, + name: 'Select', + }); + + const inputRef = React.useRef(null); + const displayRef = React.useRef(null); + const paperRef = React.useRef(null); + const openRef = React.useRef(false); + const hasSelectedItemInListRef = React.useRef(false); + const openingMouseUpListenerCleanupRef = React.useRef(null); + const didPointerDownOnItemRef = React.useRef(false); + const selectionRef = React.useRef({ + 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(); + const [openInteractionType, setOpenInteractionType] = React.useState(null); + const handleRef = useForkRef(ref, inputRefProp); + + const handleDisplayRef = React.useCallback((node) => { + displayRef.current = node; + + if (node) { + setDisplayNode(node); + } + }, []); + + const anchorElement = displayNode?.parentNode; + + React.useImperativeHandle( + handleRef, + () => ({ + focus: () => { + displayRef.current.focus(); + }, + node: inputRef.current, + value, + }), + [value], + ); + + 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; + if (open) { + resetClosedTypeahead(); + } + }, [open, resetClosedTypeahead]); + + const clearSelectionTimers = React.useCallback(() => { + selectedMouseUpTimer.clear(); + unselectedMouseUpTimer.clear(); + }, [selectedMouseUpTimer, unselectedMouseUpTimer]); + + const resetMouseUpSelection = React.useCallback(() => { + clearSelectionTimers(); + didPointerDownOnItemRef.current = false; + selectionRef.current = { + allowSelectedMouseUp: false, + allowUnselectedMouseUp: false, + }; + }, [clearSelectionTimers]); + + const clearOpeningMouseUpListener = React.useCallback(() => { + if (openingMouseUpListenerCleanupRef.current) { + openingMouseUpListenerCleanupRef.current(); + openingMouseUpListenerCleanupRef.current = null; + } + }, []); + + React.useEffect(() => { + if (!open) { + resetMouseUpSelection(); + clearOpeningMouseUpListener(); + } + }, [open, resetMouseUpSelection, clearOpeningMouseUpListener]); + + // Keep unmount cleanup separate from the `open` effect. Effect cleanups also run + // before the next effect, which would clear the opening mouseup listener while opening. + React.useEffect(() => { + return () => { + resetMouseUpSelection(); + clearOpeningMouseUpListener(); + resetClosedTypeahead(); + }; + }, [resetMouseUpSelection, clearOpeningMouseUpListener, resetClosedTypeahead]); + + React.useEffect(() => { + if (!open || !anchorElement || autoWidth) { + return undefined; + } + + if (typeof ResizeObserver === 'undefined') { + return undefined; + } + + const observer = new ResizeObserver(() => { + setMenuMinWidthState(anchorElement.clientWidth); + }); + + observer.observe(anchorElement); + + return () => { + observer.disconnect(); + }; + }, [open, anchorElement, autoWidth]); + + // Resize menu on `defaultOpen` automatic toggle. + React.useEffect(() => { + if (defaultOpen && openState && displayNode && !isOpenControlled) { + setMenuMinWidthState(autoWidth ? null : anchorElement.clientWidth); + displayRef.current.focus(); + } + // TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [displayNode, autoWidth]); + // `isOpenControlled` is ignored because the component should never switch between controlled and uncontrolled modes. + // `defaultOpen` and `openState` are ignored to avoid unnecessary callbacks. + React.useEffect(() => { + if (autoFocus) { + displayRef.current.focus(); + } + }, [autoFocus]); + + React.useEffect(() => { + if (!labelId) { + return undefined; + } + const label = ownerDocument(displayRef.current).getElementById(labelId); + if (label) { + const handler = () => { + if (getSelection().isCollapsed) { + displayRef.current.focus(); + } + }; + label.addEventListener('click', handler); + return () => { + label.removeEventListener('click', handler); + }; + } + return undefined; + }, [labelId]); + + const update = useEventCallback((openParam, event) => { + if (!openParam) { + resetMouseUpSelection(); + clearOpeningMouseUpListener(); + } + + if (openParam) { + resetClosedTypeahead(); + setOpenInteractionType(getOpenInteractionType(event)); + + if (onOpen) { + onOpen(event); + } + } else { + setOpenInteractionType(null); + + if (onClose) { + onClose(event); + } + } + + if (!isOpenControlled) { + openRef.current = openParam; + setMenuMinWidthState(autoWidth ? null : anchorElement.clientWidth); + setOpenState(openParam); + } + }); + + const scheduleMouseUpSelection = () => { + resetMouseUpSelection(); + + // When there is no selected item in the list, a mousedown + // on the trigger followed by a quick mouseup over the first option can accidentally select + // within 200ms. Delay unselected mouseup to match the safer 400ms window. + if (!hasSelectedItemInListRef.current) { + selectedMouseUpTimer.start(SELECTED_MOUSE_UP_DELAY, () => { + selectionRef.current.allowSelectedMouseUp = true; + selectionRef.current.allowUnselectedMouseUp = true; + }); + } else { + // Both timers start from the mousedown. Chaining the selected timer inside the + // unselected callback would compound setTimeout jitter past the 400ms window. + // mousedown -> move to unselected item -> mouseup should not select within 200ms. + unselectedMouseUpTimer.start(UNSELECTED_MOUSE_UP_DELAY, () => { + selectionRef.current.allowUnselectedMouseUp = true; + }); + + // mousedown -> mouseup on selected item should not select within 400ms. + selectedMouseUpTimer.start(SELECTED_MOUSE_UP_DELAY, () => { + selectionRef.current.allowSelectedMouseUp = true; + }); + } + }; + + const handleMouseDown = (event) => { + onMouseDown?.(event); + // Ignore everything but left-click + if (event.button !== 0) { + return; + } + // Hijack the default focus behavior. + event.preventDefault(); + + if (!displayRef.current) { + return; + } + + displayRef.current.focus(); + + const doc = ownerDocument(event.currentTarget); + + scheduleMouseUpSelection(); + clearOpeningMouseUpListener(); + + const handleMouseUp = (mouseEvent) => { + openingMouseUpListenerCleanupRef.current = null; + + if (!displayRef.current) { + return; + } + + if ( + isMouseEventInsideElement(mouseEvent, displayRef.current) || + isMouseEventInsideElement(mouseEvent, paperRef.current) + ) { + return; + } + + if (!openRef.current && isOpenControlled) { + return; + } + + update(false, mouseEvent); + }; + + doc.addEventListener('mouseup', handleMouseUp, { capture: true, once: true }); + openingMouseUpListenerCleanupRef.current = () => { + doc.removeEventListener('mouseup', handleMouseUp, true); + }; + + update(true, event); + }; + + const handleClose = (event) => { + update(false, event); + }; + + const childrenArray = React.Children.toArray(children); + + // Support autofill. + const handleChange = (event) => { + const child = childrenArray.find((childItem) => childItem.props.value === event.target.value); + + if (child === undefined) { + return; + } + + setValueState(child.props.value); + + if (onChange) { + onChange(event, child); + } + }; + + 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; + // eslint-disable-next-line new-cap + 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; + + let newValue; + + // We use the tabindex attribute to signal the available options. + if (!event.currentTarget.hasAttribute('tabindex')) { + return; + } + + if (multiple) { + newValue = Array.isArray(value) ? value.slice() : []; + const itemIndex = value.indexOf(child.props.value); + if (itemIndex === -1) { + newValue.push(child.props.value); + } else { + newValue.splice(itemIndex, 1); + } + } else { + newValue = child.props.value; + } + + if (child.props.onClick) { + child.props.onClick(event); + } + + if (value !== newValue) { + handleValueChange(event, child, newValue); + } + + if (!multiple) { + update(false, event); + } + }; + + const handleItemMouseUp = (child, selected) => (event) => { + child.props.onMouseUp?.(event); + + if (didPointerDownOnItemRef.current) { + didPointerDownOnItemRef.current = false; + return; + } + + const disallowSelectedMouseUp = !selectionRef.current.allowSelectedMouseUp && selected; + const disallowUnselectedMouseUp = !selectionRef.current.allowUnselectedMouseUp && !selected; + + if (disallowSelectedMouseUp || disallowUnselectedMouseUp) { + return; + } + + 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 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); + } + onKeyDown?.(event); + } + }; + + const handleBlur = (event) => { + resetClosedTypeahead(); + // if open event.stopImmediatePropagation + if (!open && onBlur) { + // Preact support, target is read only property on a native event. + Object.defineProperty(event, 'target', { writable: true, value: { value, name } }); + onBlur(event); + } + }; + + const handleItemKeyDown = (child) => (event) => { + child?.props?.onKeyDown?.(event); + + if (event.key === SPACE && event.target === event.currentTarget && !event.defaultPrevented) { + // Prevent the browser from scrolling the page + event.preventDefault(); + // Ignore auto-repeated keydowns to avoid toggling multiple times + if (!event.repeat) { + // Trigger via click so that onClick receives a click event, + // consistent with Enter and pointer interactions. + event.currentTarget.click(); + } + } + }; + + delete other['aria-invalid']; + + let display; + let displaySingle; + const displayMultiple = []; + let computeDisplay = false; + let foundMatch = false; + + // No need to display any value if the field is empty. + if (isFilled({ value }) || displayEmpty) { + if (renderValue) { + display = renderValue(value); + } else { + computeDisplay = true; + } + } + + const items = childrenArray.map((child) => { + if (!React.isValidElement(child)) { + return null; + } + + if (process.env.NODE_ENV !== 'production') { + if (isFragment(child)) { + console.error( + [ + "MUI: The Select component doesn't accept a Fragment as a child.", + 'Consider providing an array instead.', + ].join('\n'), + ); + } + } + + let selected; + + if (multiple) { + if (!Array.isArray(value)) { + throw /* minify-error */ new Error( + 'MUI: The `value` prop must be an array ' + + 'when using the `Select` component with `multiple`.', + ); + } + + selected = value.some((v) => areEqualValues(v, child.props.value)); + if (selected && computeDisplay) { + displayMultiple.push(child.props.children); + } + } else { + selected = areEqualValues(value, child.props.value); + if (selected && computeDisplay) { + displaySingle = child.props.children; + } + } + + if (selected) { + foundMatch = true; + } + + return React.cloneElement(child, { + 'aria-selected': selected ? 'true' : 'false', + onMouseDown: (event) => { + didPointerDownOnItemRef.current = true; + child.props.onMouseDown?.(event); + }, + onPointerDown: (event) => { + didPointerDownOnItemRef.current = true; + child.props.onPointerDown?.(event); + }, + onClick: handleItemClick(child), + onMouseUp: handleItemMouseUp(child, selected), + onKeyUp: (event) => { + if (event.key === SPACE) { + // otherwise our MenuItems dispatches a click event + // it's not behavior of the native