Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
[Autocomplete] Add controllable input value API (#18285)
  • Loading branch information
oliviertassinari committed Nov 11, 2019
1 parent e18b10d commit 575776f
Show file tree
Hide file tree
Showing 11 changed files with 151 additions and 66 deletions.
2 changes: 2 additions & 0 deletions docs/pages/api/autocomplete.md
Expand Up @@ -46,13 +46,15 @@ You can learn more about the difference by [reading this guide](/guides/minimizi
| <span class="prop-name">groupBy</span> | <span class="prop-type">func</span> | | If provided, the options will be grouped under the returned string. The groupBy value is also used as the text for group headings when `renderGroup` is not provided.<br><br>**Signature:**<br>`function(options: any) => string`<br>*options:* The option to group. |
| <span class="prop-name">id</span> | <span class="prop-type">string</span> | | This prop is used to help implement the accessibility logic. If you don't provide this prop. It falls back to a randomly generated id. |
| <span class="prop-name">includeInputInList</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | If `true`, the highlight can move to the input. |
| <span class="prop-name">inputValue</span> | <span class="prop-type">string</span> | | The input value. |
| <span class="prop-name">ListboxComponent</span> | <span class="prop-type">elementType</span> | <span class="prop-default">'ul'</span> | The component used to render the listbox. |
| <span class="prop-name">loading</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | If `true`, the component is in a loading state. |
| <span class="prop-name">loadingText</span> | <span class="prop-type">node</span> | <span class="prop-default">'Loading…'</span> | Text to display when in a loading state. |
| <span class="prop-name">multiple</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | If true, `value` must be an array and the menu will support multiple selections. |
| <span class="prop-name">noOptionsText</span> | <span class="prop-type">node</span> | <span class="prop-default">'No options'</span> | Text to display when there are no options. |
| <span class="prop-name">onChange</span> | <span class="prop-type">func</span> | | Callback fired when the value changes.<br><br>**Signature:**<br>`function(event: object, value: any) => void`<br>*event:* The event source of the callback<br>*value:* null |
| <span class="prop-name">onClose</span> | <span class="prop-type">func</span> | | Callback fired when the popup requests to be closed. Use in controlled mode (see open).<br><br>**Signature:**<br>`function(event: object) => void`<br>*event:* The event source of the callback. |
| <span class="prop-name">onInputChange</span> | <span class="prop-type">func</span> | | Callback fired when the input value changes.<br><br>**Signature:**<br>`function(event: object, value: string) => void`<br>*event:* The event source of the callback.<br>*value:* null |
| <span class="prop-name">onOpen</span> | <span class="prop-type">func</span> | | Callback fired when the popup requests to be opened. Use in controlled mode (see open).<br><br>**Signature:**<br>`function(event: object) => void`<br>*event:* The event source of the callback. |
| <span class="prop-name">open</span> | <span class="prop-type">bool</span> | | Control the popup` open state. |
| <span class="prop-name">options</span> | <span class="prop-type">array</span> | <span class="prop-default">[]</span> | Array of options. |
Expand Down
13 changes: 13 additions & 0 deletions packages/material-ui-lab/src/Autocomplete/Autocomplete.js
Expand Up @@ -181,13 +181,15 @@ const Autocomplete = React.forwardRef(function Autocomplete(props, ref) {
groupBy,
id: idProp,
includeInputInList = false,
inputValue: inputValueProp,
ListboxComponent = 'ul',
loading = false,
loadingText = 'Loading…',
multiple = false,
noOptionsText = 'No options',
onChange,
onClose,
onInputChange,
onOpen,
open,
options = [],
Expand Down Expand Up @@ -487,6 +489,10 @@ Autocomplete.propTypes = {
* If `true`, the highlight can move to the input.
*/
includeInputInList: PropTypes.bool,
/**
* The input value.
*/
inputValue: PropTypes.string,
/**
* The component used to render the listbox.
*/
Expand Down Expand Up @@ -521,6 +527,13 @@ Autocomplete.propTypes = {
* @param {object} event The event source of the callback.
*/
onClose: PropTypes.func,
/**
* Callback fired when the input value changes.
*
* @param {object} event The event source of the callback.
* @param {string} value
*/
onInputChange: PropTypes.func,
/**
* Callback fired when the popup requests to be opened.
* Use in controlled mode (see open).
Expand Down
30 changes: 30 additions & 0 deletions packages/material-ui-lab/src/Autocomplete/Autocomplete.test.js
Expand Up @@ -549,4 +549,34 @@ describe('<Autocomplete />', () => {
expect(textbox.selectionEnd).to.equal(3);
});
});

describe('controlled input', () => {
it('controls the input value', () => {
const handleChange = spy();
function MyComponent() {
const [, setInputValue] = React.useState('');
const handleInputChange = (event, value) => {
handleChange(value);
setInputValue(value);
};
return (
<Autocomplete
inputValue=""
onInputChange={handleInputChange}
renderInput={params => <TextField autoFocus {...params} />}
/>
);
}

const { getByRole } = render(<MyComponent />);

const textbox = getByRole('textbox');
expect(handleChange.callCount).to.equal(1);
expect(handleChange.args[0][0]).to.equal('');
fireEvent.change(textbox, { target: { value: 'a' } });
expect(handleChange.callCount).to.equal(2);
expect(handleChange.args[1][0]).to.equal('a');
expect(textbox.value).to.equal('');
});
});
});
4 changes: 2 additions & 2 deletions packages/material-ui-lab/src/TreeView/TreeView.js
Expand Up @@ -39,7 +39,6 @@ const TreeView = React.forwardRef(function TreeView(props, ref) {
onNodeToggle,
...other
} = props;
const [expandedState, setExpandedState] = React.useState(defaultExpanded);
const [tabable, setTabable] = React.useState(null);
const [focused, setFocused] = React.useState(null);

Expand All @@ -48,7 +47,8 @@ const TreeView = React.forwardRef(function TreeView(props, ref) {
const firstCharMap = React.useRef({});

const { current: isControlled } = React.useRef(expandedProp !== undefined);
const expanded = (isControlled ? expandedProp : expandedState) || [];
const [expandedState, setExpandedState] = React.useState(defaultExpanded);
const expanded = (isControlled ? expandedProp : expandedState) || defaultExpandedDefault;

if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
Expand Down
Expand Up @@ -107,6 +107,10 @@ export interface UseAutocompleteProps {
* If `true`, the highlight can move to the input.
*/
includeInputInList?: boolean;
/**
* The input value.
*/
inputValue?: string;
/**
* If true, `value` must be an array and the menu will support multiple selections.
*/
Expand All @@ -127,8 +131,11 @@ export interface UseAutocompleteProps {
onClose?: (event: React.ChangeEvent<{}>) => void;
/**
* Callback fired when the input value changes.
*
* @param {object} event The event source of the callback.
* @param {string} value
*/
onInputChange?: React.ChangeEventHandler<HTMLInputElement>;
onInputChange?: (event: React.ChangeEvent<{}>, value: any) => void;
/**
* Callback fired when the popup requests to be opened.
* Use in controlled mode (see open).
Expand Down
23 changes: 18 additions & 5 deletions packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js
Expand Up @@ -68,10 +68,12 @@ export default function useAutocomplete(props) {
groupBy,
id: idProp,
includeInputInList = false,
inputValue: inputValueProp,
multiple = false,
onChange,
onClose,
onOpen,
onInputChange,
open: openProp,
options = [],
value: valueProp,
Expand Down Expand Up @@ -165,10 +167,13 @@ export default function useAutocomplete(props) {
});
const value = isControlled ? valueProp : valueState;

const [inputValue, setInputValue] = React.useState('');
const { current: isInputValueControlled } = React.useRef(inputValueProp != null);
const [inputValueState, setInputValue] = React.useState('');
const inputValue = isInputValueControlled ? inputValueProp : inputValueState;

const [focused, setFocused] = React.useState(false);

const resetInputValue = useEventCallback(newValue => {
const resetInputValue = useEventCallback((event, newValue) => {
let newInputValue;
if (multiple) {
newInputValue = '';
Expand All @@ -195,10 +200,14 @@ export default function useAutocomplete(props) {
}

setInputValue(newInputValue);

if (onInputChange) {
onInputChange(event, newInputValue);
}
});

React.useEffect(() => {
resetInputValue(value);
resetInputValue(null, value);
}, [value, resetInputValue]);

const { current: isOpenControlled } = React.useRef(openProp != null);
Expand Down Expand Up @@ -404,7 +413,7 @@ export default function useAutocomplete(props) {
handleClose(event);
}

resetInputValue(newValue);
resetInputValue(event, newValue);

selectedIndexRef.current = -1;
};
Expand Down Expand Up @@ -590,7 +599,7 @@ export default function useAutocomplete(props) {
if (autoSelect && selectedIndexRef.current !== -1) {
handleValue(event, filteredOptions[selectedIndexRef.current]);
} else if (!freeSolo) {
resetInputValue(value);
resetInputValue(event, value);
}

handleClose(event);
Expand All @@ -612,6 +621,10 @@ export default function useAutocomplete(props) {
}

setInputValue(newValue);

if (onInputChange) {
onInputChange(event, newValue);
}
};

const handleOptionMouseOver = event => {
Expand Down
55 changes: 27 additions & 28 deletions packages/material-ui/src/RadioGroup/RadioGroup.js
Expand Up @@ -7,28 +7,10 @@ import RadioGroupContext from './RadioGroupContext';
const RadioGroup = React.forwardRef(function RadioGroup(props, ref) {
const { actions, children, name, value: valueProp, onChange, ...other } = props;
const rootRef = React.useRef(null);
const { current: isControlled } = React.useRef(valueProp != null);
const [valueState, setValue] = React.useState(() => {
return !isControlled ? props.defaultValue : null;
});

React.useImperativeHandle(
actions,
() => ({
focus: () => {
let input = rootRef.current.querySelector('input:not(:disabled):checked');

if (!input) {
input = rootRef.current.querySelector('input:not(:disabled)');
}

if (input) {
input.focus();
}
},
}),
[],
);
const { current: isControlled } = React.useRef(valueProp != null);
const [valueState, setValue] = React.useState(props.defaultValue);
const value = isControlled ? valueProp : valueState;

if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
Expand All @@ -49,7 +31,25 @@ const RadioGroup = React.forwardRef(function RadioGroup(props, ref) {
}, [valueProp, isControlled]);
}

const value = isControlled ? valueProp : valueState;
React.useImperativeHandle(
actions,
() => ({
focus: () => {
let input = rootRef.current.querySelector('input:not(:disabled):checked');

if (!input) {
input = rootRef.current.querySelector('input:not(:disabled)');
}

if (input) {
input.focus();
}
},
}),
[],
);

const handleRef = useForkRef(ref, rootRef);

const handleChange = event => {
if (!isControlled) {
Expand All @@ -60,14 +60,13 @@ const RadioGroup = React.forwardRef(function RadioGroup(props, ref) {
onChange(event, event.target.value);
}
};
const context = { name, onChange: handleChange, value };

const handleRef = useForkRef(ref, rootRef);

return (
<FormGroup role="radiogroup" ref={handleRef} {...other}>
<RadioGroupContext.Provider value={context}>{children}</RadioGroupContext.Provider>
</FormGroup>
<RadioGroupContext.Provider value={{ name, onChange: handleChange, value }}>
<FormGroup role="radiogroup" ref={handleRef} {...other}>
{children}
</FormGroup>
</RadioGroupContext.Provider>
);
});

Expand Down
23 changes: 22 additions & 1 deletion packages/material-ui/src/Slider/Slider.js
Expand Up @@ -369,15 +369,36 @@ const Slider = React.forwardRef(function Slider(props, ref) {
...other
} = props;
const theme = useTheme();
const { current: isControlled } = React.useRef(valueProp != null);
const touchId = React.useRef();
// We can't use the :active browser pseudo-classes.
// - The active state isn't triggered when clicking on the rail.
// - The active state isn't transfered when inversing a range slider.
const [active, setActive] = React.useState(-1);
const [open, setOpen] = React.useState(-1);

const { current: isControlled } = React.useRef(valueProp != null);
const [valueState, setValueState] = React.useState(defaultValue);
const valueDerived = isControlled ? valueProp : valueState;

if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useEffect(() => {
if (isControlled !== (valueProp != null)) {
console.error(
[
`Material-UI: A component is changing ${
isControlled ? 'a ' : 'an un'
}controlled Slider to be ${isControlled ? 'un' : ''}controlled.`,
'Elements should not switch from uncontrolled to controlled (or vice versa).',
'Decide between using a controlled or uncontrolled Slider ' +
'element for the lifetime of the component.',
'More info: https://fb.me/react-controlled-components',
].join('\n'),
);
}
}, [valueProp, isControlled]);
}

const range = Array.isArray(valueDerived);
const instanceRef = React.useRef();
let values = range ? [...valueDerived].sort(asc) : [valueDerived];
Expand Down

0 comments on commit 575776f

Please sign in to comment.