diff --git a/docs/data/material/migration/migrating-to-v6/migrating-to-v6.md b/docs/data/material/migration/migrating-to-v6/migrating-to-v6.md index f5cdcb86b76..aa8b17a4cac 100644 --- a/docs/data/material/migration/migrating-to-v6/migrating-to-v6.md +++ b/docs/data/material/migration/migrating-to-v6/migrating-to-v6.md @@ -119,6 +119,19 @@ This results in a reduction of the `@mui/material` package size by 2.5MB or 25% Instead, using ESM-based CDNs such as [esm.sh](https://esm.sh/) is recommended. For alternative installation methods, refer to the [CDN documentation](/material-ui/getting-started/installation/#cdn). +### Autocomplete + +#### New reason values added to onInputChange + +Three new possible values have been added to the `reason` argument in the `onInputChange` callback of the Autocomplete component. +These three were previously treated as `"reset"`, so if you are relying on that, you might need to adjust your code accordingly: + +- `"blur"`: like `"reset"` but triggered when the focus is moved off the input. `clearOnBlur` must be `true`. +- `"selectOption"`: triggered when the input value changes after an option has been selected. +- `"removeOption"`: triggered in multiple selection when a chip gets removed due to the corresponding option being selected. + +These are added on top of the existing ones: `"input"`, `"reset"`, and `"clear"`. + ### Chip Previously, the Chip component lost focus when the escape button was pressed, which differed from how other button-like components work. diff --git a/docs/translations/api-docs-joy/autocomplete/autocomplete.json b/docs/translations/api-docs-joy/autocomplete/autocomplete.json index 1472da2b7fa..f3af4583bd8 100644 --- a/docs/translations/api-docs-joy/autocomplete/autocomplete.json +++ b/docs/translations/api-docs-joy/autocomplete/autocomplete.json @@ -148,7 +148,7 @@ "typeDescriptions": { "event": "The event source of the callback.", "value": "The new value of the text input.", - "reason": "Can be: "input" (user input), "reset" (programmatic change), "clear"." + "reason": "Can be: "input" (user input), "reset" (programmatic change), "clear", "blur", "selectOption", "removeOption"" } }, "onOpen": { diff --git a/docs/translations/api-docs/autocomplete/autocomplete.json b/docs/translations/api-docs/autocomplete/autocomplete.json index 9eaf631c685..6ec4dc19a1d 100644 --- a/docs/translations/api-docs/autocomplete/autocomplete.json +++ b/docs/translations/api-docs/autocomplete/autocomplete.json @@ -143,7 +143,7 @@ "typeDescriptions": { "event": "The event source of the callback.", "value": "The new value of the text input.", - "reason": "Can be: "input" (user input), "reset" (programmatic change), "clear"." + "reason": "Can be: "input" (user input), "reset" (programmatic change), "clear", "blur", "selectOption", "removeOption"" } }, "onOpen": { diff --git a/packages/mui-base/src/useAutocomplete/useAutocomplete.d.ts b/packages/mui-base/src/useAutocomplete/useAutocomplete.d.ts index b9286b06d70..8c4e7eec43b 100644 --- a/packages/mui-base/src/useAutocomplete/useAutocomplete.d.ts +++ b/packages/mui-base/src/useAutocomplete/useAutocomplete.d.ts @@ -257,7 +257,7 @@ export interface UseAutocompleteProps< * * @param {React.SyntheticEvent} event The event source of the callback. * @param {string} value The new value of the text input. - * @param {string} reason Can be: `"input"` (user input), `"reset"` (programmatic change), `"clear"`. + * @param {string} reason Can be: `"input"` (user input), `"reset"` (programmatic change), `"clear"`, `"blur"`, `"selectOption"`, `"removeOption"` */ onInputChange?: ( event: React.SyntheticEvent, @@ -329,7 +329,13 @@ export type AutocompleteCloseReason = | 'selectOption' | 'removeOption' | 'blur'; -export type AutocompleteInputChangeReason = 'input' | 'reset' | 'clear'; +export type AutocompleteInputChangeReason = + | 'input' + | 'reset' + | 'clear' + | 'blur' + | 'selectOption' + | 'removeOption'; export type AutocompleteGetTagProps = ({ index }: { index: number }) => { key: number; diff --git a/packages/mui-base/src/useAutocomplete/useAutocomplete.js b/packages/mui-base/src/useAutocomplete/useAutocomplete.js index 45caa7dd212..2280e75b87e 100644 --- a/packages/mui-base/src/useAutocomplete/useAutocomplete.js +++ b/packages/mui-base/src/useAutocomplete/useAutocomplete.js @@ -152,7 +152,7 @@ export function useAutocomplete(props) { const [focused, setFocused] = React.useState(false); const resetInputValue = React.useCallback( - (event, newValue) => { + (event, newValue, reason) => { // retain current `inputValue` if new option isn't selected and `clearOnBlur` is false // When `multiple` is enabled, `newValue` is an array of all selected items including the newly selected item const isOptionSelected = multiple ? value.length < newValue.length : newValue !== null; @@ -176,7 +176,7 @@ export function useAutocomplete(props) { setInputValueState(newInputValue); if (onInputChange) { - onInputChange(event, newInputValue, 'reset'); + onInputChange(event, newInputValue, reason); } }, [getOptionLabel, inputValue, multiple, onInputChange, setInputValueState, clearOnBlur, value], @@ -236,7 +236,7 @@ export function useAutocomplete(props) { return; } - resetInputValue(null, value); + resetInputValue(null, value, 'reset'); }, [value, resetInputValue, focused, previousProps.value, freeSolo]); const listboxAvailable = open && filteredOptions.length > 0 && !readOnly; @@ -681,7 +681,7 @@ export function useAutocomplete(props) { } } - resetInputValue(event, newValue); + resetInputValue(event, newValue, reason); handleValue(event, newValue, reason, { option }); if (!disableCloseOnSelect && (!event || (!event.ctrlKey && !event.metaKey))) { @@ -939,7 +939,7 @@ export function useAutocomplete(props) { } else if (autoSelect && freeSolo && inputValue !== '') { selectNewValue(event, inputValue, 'blur', 'freeSolo'); } else if (clearOnBlur) { - resetInputValue(event, value); + resetInputValue(event, value, 'blur'); } handleClose(event, 'blur'); diff --git a/packages/mui-joy/src/Autocomplete/Autocomplete.test.tsx b/packages/mui-joy/src/Autocomplete/Autocomplete.test.tsx index 045c7fac2b9..0faa8ba03dc 100644 --- a/packages/mui-joy/src/Autocomplete/Autocomplete.test.tsx +++ b/packages/mui-joy/src/Autocomplete/Autocomplete.test.tsx @@ -1889,10 +1889,10 @@ describe('Joy ', () => { }); describe('prop: onInputChange', () => { - it('provides a reason on input change', () => { + it('provides a reason on input change', async () => { const handleInputChange = spy(); const options = [{ name: 'foo' }]; - render( + const { user } = render( ', () => { autoFocus />, ); - fireEvent.change(document.activeElement as HTMLInputElement, { target: { value: 'a' } }); + + await user.type(document.activeElement as HTMLInputElement, 'a'); + expect(handleInputChange.callCount).to.equal(1); expect(handleInputChange.args[0][1]).to.equal('a'); expect(handleInputChange.args[0][2]).to.equal('input'); }); - it('provides a reason on select reset', () => { + it('provides a reason on select reset', async () => { + const handleInputChange = spy(); + const options = [{ name: 'foo' }, { name: 'bar' }]; + function MyComponent() { + const [value, setValue] = React.useState(options[0]); + return ( + + option.name} + value={value} + /> + + + ); + } + const { user } = render(); + + await user.click(screen.getByText('Reset')); + + expect(handleInputChange.callCount).to.equal(4); + expect(handleInputChange.args[3][1]).to.equal(options[1].name); + expect(handleInputChange.args[3][2]).to.equal('reset'); + }); + + it('provides a reason on clear', async () => { const handleInputChange = spy(); const options = [{ name: 'foo' }]; - render( + const { user } = render( option.name} + defaultValue={options[0]} autoFocus />, ); - const textbox = screen.getByRole('combobox'); - fireEvent.keyDown(textbox, { key: 'ArrowDown' }); - fireEvent.keyDown(textbox, { key: 'Enter' }); + await user.click(screen.getByLabelText('Clear')); - expect(handleInputChange.callCount).to.equal(1); - expect(handleInputChange.args[0][1]).to.equal(options[0].name); - expect(handleInputChange.args[0][2]).to.equal('reset'); + expect(handleInputChange.lastCall.args[1]).to.equal(''); + expect(handleInputChange.lastCall.args[2]).to.equal('clear'); + }); + + it('provides a reason on blur', async () => { + const handleInputChange = spy(); + const options = [{ name: 'foo' }]; + const { user } = render( + option.name} + autoFocus + clearOnBlur + />, + ); + await user.type(screen.getByRole('combobox'), options[0].name); + await user.tab(); + + expect(handleInputChange.lastCall.args[1]).to.equal(''); + expect(handleInputChange.lastCall.args[2]).to.equal('blur'); + }); + + it('provides a reason on select option', async () => { + const handleInputChange = spy(); + const options = [{ name: 'foo' }]; + const { user } = render( + option.name} + autoFocus + />, + ); + + await user.click(screen.getByLabelText('Open')); + await user.click(screen.getByRole('option', { name: options[0].name })); + + expect(handleInputChange.lastCall.args[1]).to.equal(options[0].name); + expect(handleInputChange.lastCall.args[2]).to.equal('selectOption'); + }); + + it('provides a reason on remove option', async () => { + const handleInputChange = spy(); + const options = [{ name: 'foo' }]; + const { user } = render( + option.name} + defaultValue={options} + autoFocus + multiple + />, + ); + + await user.type(screen.getByRole('combobox'), `${options[0].name}{Enter}`); + + expect(handleInputChange.lastCall.args[1]).to.equal(''); + expect(handleInputChange.lastCall.args[2]).to.equal('removeOption'); }); }); diff --git a/packages/mui-joy/src/Autocomplete/Autocomplete.tsx b/packages/mui-joy/src/Autocomplete/Autocomplete.tsx index 96bbf5621d6..d78662a90f3 100644 --- a/packages/mui-joy/src/Autocomplete/Autocomplete.tsx +++ b/packages/mui-joy/src/Autocomplete/Autocomplete.tsx @@ -1049,7 +1049,7 @@ Autocomplete.propTypes /* remove-proptypes */ = { * * @param {React.SyntheticEvent} event The event source of the callback. * @param {string} value The new value of the text input. - * @param {string} reason Can be: `"input"` (user input), `"reset"` (programmatic change), `"clear"`. + * @param {string} reason Can be: `"input"` (user input), `"reset"` (programmatic change), `"clear"`, `"blur"`, `"selectOption"`, `"removeOption"` */ onInputChange: PropTypes.func, /** diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.js b/packages/mui-material/src/Autocomplete/Autocomplete.js index d02f3daaa10..5f3f0014350 100644 --- a/packages/mui-material/src/Autocomplete/Autocomplete.js +++ b/packages/mui-material/src/Autocomplete/Autocomplete.js @@ -1082,7 +1082,7 @@ Autocomplete.propTypes /* remove-proptypes */ = { * * @param {React.SyntheticEvent} event The event source of the callback. * @param {string} value The new value of the text input. - * @param {string} reason Can be: `"input"` (user input), `"reset"` (programmatic change), `"clear"`. + * @param {string} reason Can be: `"input"` (user input), `"reset"` (programmatic change), `"clear"`, `"blur"`, `"selectOption"`, `"removeOption"` */ onInputChange: PropTypes.func, /** diff --git a/packages/mui-material/src/Autocomplete/Autocomplete.test.js b/packages/mui-material/src/Autocomplete/Autocomplete.test.js index 44a037bd962..7fda0169707 100644 --- a/packages/mui-material/src/Autocomplete/Autocomplete.test.js +++ b/packages/mui-material/src/Autocomplete/Autocomplete.test.js @@ -2525,10 +2525,10 @@ describe('', () => { }); describe('prop: onInputChange', () => { - it('provides a reason on input change', () => { + it('provides a reason on input change', async () => { const handleInputChange = spy(); const options = [{ name: 'foo' }]; - render( + const { user } = render( ', () => { renderInput={(params) => } />, ); - fireEvent.change(document.activeElement, { target: { value: 'a' } }); + + await user.type(document.activeElement, 'a'); + expect(handleInputChange.callCount).to.equal(1); expect(handleInputChange.args[0][1]).to.equal('a'); expect(handleInputChange.args[0][2]).to.equal('input'); }); - it('provides a reason on select reset', () => { + it('provides a reason on select reset', async () => { + const handleInputChange = spy(); + const options = [{ name: 'foo' }, { name: 'bar' }]; + function MyComponent() { + const [value, setValue] = React.useState(options[0]); + return ( + + option.name} + renderInput={(params) => } + value={value} + /> + + + ); + } + const { user } = render(); + + await user.click(screen.getByText('Reset')); + + expect(handleInputChange.lastCall.args[1]).to.equal(options[1].name); + expect(handleInputChange.lastCall.args[2]).to.equal('reset'); + }); + + it('provides a reason on clear', async () => { const handleInputChange = spy(); const options = [{ name: 'foo' }]; - render( + const { user } = render( option.name} renderInput={(params) => } + defaultValue={options[0]} />, ); - const textbox = screen.getByRole('combobox'); - fireEvent.keyDown(textbox, { key: 'ArrowDown' }); - fireEvent.keyDown(textbox, { key: 'Enter' }); + await user.click(screen.getByLabelText('Clear')); - expect(handleInputChange.callCount).to.equal(1); - expect(handleInputChange.args[0][1]).to.equal(options[0].name); - expect(handleInputChange.args[0][2]).to.equal('reset'); + expect(handleInputChange.lastCall.args[1]).to.equal(''); + expect(handleInputChange.lastCall.args[2]).to.equal('clear'); + }); + + it('provides a reason on blur', async () => { + const handleInputChange = spy(); + const options = [{ name: 'foo' }]; + const { user } = render( + option.name} + renderInput={(params) => } + clearOnBlur + />, + ); + + await user.type(screen.getByRole('combobox'), options[0].name); + await user.tab(); + + expect(handleInputChange.lastCall.args[1]).to.equal(''); + expect(handleInputChange.lastCall.args[2]).to.equal('blur'); + }); + + it('provides a reason on select option', async () => { + const handleInputChange = spy(); + const options = [{ name: 'foo' }]; + const { user } = render( + option.name} + renderInput={(params) => } + />, + ); + + await user.click(screen.getByLabelText('Open')); + await user.click(screen.getByRole('option', { name: options[0].name })); + + expect(handleInputChange.lastCall.args[1]).to.equal(options[0].name); + expect(handleInputChange.lastCall.args[2]).to.equal('selectOption'); + }); + + it('provides a reason on remove option', async () => { + const handleInputChange = spy(); + const options = [{ name: 'foo' }]; + const { user } = render( + option.name} + renderInput={(params) => } + defaultValue={options} + multiple + />, + ); + + await user.type(screen.getByRole('combobox'), `${options[0].name}{Enter}`); + + expect(handleInputChange.lastCall.args[1]).to.equal(''); + expect(handleInputChange.lastCall.args[2]).to.equal('removeOption'); }); });