Repository: mui/material-ui (TypeScript)
Date: 2019-07-03 15:16:06+00:00
Task type: Feature

Issue:
[FormControl] Expose the form control state
How does one get the Context managed by a `<FormControl/>` into one's own component that is intended to be a child of the `<FormControl/>`. The form control does not seem to pass disabled, focused, error as properties and also does not use React's new context API. Could you add this answers to the documentation somewhere.



Issue discussion:
@serle You can't. It's an implementation detail.
@oliviertassinari If the purpose of the FormControl is to act as the source of filled, focused, error states and  then we should be able to use it as a building block for our own controls. It would be great if it could pass these as props to its direct children as well as expose some callbacks to be able to set these. 
I'm essentially re-using FormLabel and FormHelperText, but creating my own child control for content that does not fit into the standard Input, Select etc. controls. 
@serle I think that we could be exposing the context consumer component once we migrate to the next API.
@oliviertassinari great, could you remember to include an update function in the context as well so that we can update these state variables from the child components, if necessary

What are the use cases for this feature? I'm hesitant to expose internal variables just for the sake of it. Which values would be useful and why?

@serle What are the use cases for this feature? You can already change the values of `disabled`, `error`, `margin` and `required` via props of `FormControl`. Adding an update function to change this values can be problematic as with all cases were you mix potential sources of a value (state, props and context). Values like `focused` and `filled` already have a private update function.
@eps1lon I hit this problem when I wanted to create my own FormControl and have child components notify the form control that they have an error, are filled, are set back to their pristine value. 

On the other hand I have an overall Form component with all of these child FormControl components and I need to enable and disable/enable the save and discard for the overall Form. The Form component then needs a way to either call down to each of its FormControl component to consolidate an overall form state e.g. filled, error etc or have the FormControls notify the parent Form component that there is an internal state change e.g. filled, error etc, so that the Form component can maintain an aggregate version of this. As far as I can tell there is no concept of a Form component. In my case I go a step further and have the following hierarchy Form->Panel->FormControl, with state rollup. 

It would be great for introduce this type of higher-order Form behavior into your library so we can have chunked up Form which cater for more complex data structures.
If you already have your own FormControl why not wrap this in your own context that sets the props. This is similar to controlled vs. uncontrolled components. Our FormControl is currently fully controlled. Mixing in an internal controller via context that can be updated can cause all sorts of issues which is why react docs recommend using either a fully controlled or uncontrolled component.

You're already in control of the props you pass to our FormControl. How you want to update those is a concern for your codebase (redux, context, mobx etc). Filled should already be handled by our input components.
It would be useful for me to have FormControlContext.Provider exposed as part of the API to support implementing custom Input, InputLabel or FormControlLabel.

In my case I agree that there is no need to update FormControlContext from parent without using props. Handlers (onBlur, ...) exposed on FormControlContextProps are enough to communicate from child component to FormControl.

Also reading the documentation it is unclear if FormControl could be used as a building block for custom inputs.
The following sentence makes me think it should be usable by custom inputs:
`Relying on the context provides high flexibilty and ensures that the state always stays consistent across the children of the FormControl.` 



Could you add and example of a custom implementation and why we would need to support this? It's not as simple as simply making something public. 

> The following sentence makes me think it should be usable by custom inputs:
Relying on the context provides high flexibilty and ensures that the state always stays consistent across the children of the FormControl.

Quoting the full paragraph should make it clear that it's used by material components. 

To be honest I have to look into this myself because we state that only one input can be used but we use it in our demos for multiple e.g. radios. So the documentation might have some room for improvements.
@eps1lon I have two concrete usecases as of today.

First is to implement a drop in replacement for InputLabel that embed customization in the label that would integrate with existing Input and FormCotnrolHelperText through FormControl. In my case the customization is adding an additional "documentation" button that is part of the label but still needs to ba aware of the FormControl state to style itself accordingly.

The second is to implement a custom Input that would integrate with existing InputLabel and FormControlHelperText here again through FormControl. In my case the custom input is based on monaco editor. 
I'm implementing a wrapper controlled component (with styling and some additional functionality) around `TextField`. I'm using Storybook for testing/documenting this.

I would like to be able to write a story that "forces" this component in other states (e.g. focus).

From code, I inferred this could be done by exposing FormControlContext, which is why I'm writing my use case in this issue. :)

It would be easiest if I could write something like:

```
<FormControlContext.Provider value={{focused: true}}>
  <TextField ... />
</FormControlContext.Provider>
```
@oliviertassinari - continuing conversation from #16049.

Here's a very high level implementation of how we use `FormControlContext` in a custom component composed of MUI components.

![image](https://user-images.githubusercontent.com/6355370/58903763-06a51800-86bb-11e9-8e93-6c12401ffe91.png)

```js
function ColorInput(props) {
    const { muiFormControl, onChange, value } = props;
    const fcs = formControlState({
        props: props,
        muiFormControl,
        states: ["disabled"],
    });

    return (
        <div>
            <Input onChange={onChange} value={value} />
            <Button disabled={fcs.disabled} />
            <Popover>...</Popover>
        </div>
    );
}

export default withFormControlContext(ColorInput)
```

Usage:
```js
<FormControl disabled={disabled} required={required}>
  <FormLabel htmlFor="input-color">Normal Width Color</FormLabel>
  <ColorInput
    id="input-color"
    value={this.state.colorValue}
    onChange={this.handleColorChange}
  />
</FormControl>
```

Solution patch:
diff --git a/packages/material-ui/src/FormControl/FormControlContext.d.ts b/packages/material-ui/src/FormControl/FormControlContext.d.ts
--- a/packages/material-ui/src/FormControl/FormControlContext.d.ts
+++ b/packages/material-ui/src/FormControl/FormControlContext.d.ts
@@ -1,9 +1,12 @@
 import { Context } from 'react';
 import { FormControlProps } from './FormControl';
 
+// shut off automatic exporting
+export {};
+
 type ContextFromPropsKey = 'disabled' | 'error' | 'margin' | 'required' | 'variant';
 
-export interface FormControlContextProps extends Pick<FormControlProps, ContextFromPropsKey> {
+export interface FormControl extends Pick<FormControlProps, ContextFromPropsKey> {
   adornedStart: boolean;
   filled: boolean;
   focused: boolean;
@@ -13,6 +16,4 @@ export interface FormControlContextProps extends Pick<FormControlProps, ContextF
   onFocus: () => void;
 }
 
-declare const FormControlContext: Context<FormControlContextProps | null | undefined>;
-
-export default FormControlContext;
+export function useFormControl(): FormControl;
diff --git a/packages/material-ui/src/FormControl/FormControlContext.js b/packages/material-ui/src/FormControl/FormControlContext.js
--- a/packages/material-ui/src/FormControl/FormControlContext.js
+++ b/packages/material-ui/src/FormControl/FormControlContext.js
@@ -5,4 +5,8 @@ import React from 'react';
  */
 const FormControlContext = React.createContext();
 
+export function useFormControl() {
+  return React.useContext(FormControlContext);
+}
+
 export default FormControlContext;
diff --git a/packages/material-ui/src/FormControl/index.d.ts b/packages/material-ui/src/FormControl/index.d.ts
--- a/packages/material-ui/src/FormControl/index.d.ts
+++ b/packages/material-ui/src/FormControl/index.d.ts
@@ -1,2 +1,3 @@
 export { default } from './FormControl';
 export * from './FormControl';
+export { useFormControl, FormControl } from './FormControlContext';
diff --git a/packages/material-ui/src/FormControl/index.js b/packages/material-ui/src/FormControl/index.js
--- a/packages/material-ui/src/FormControl/index.js
+++ b/packages/material-ui/src/FormControl/index.js
@@ -1 +1,2 @@
 export { default } from './FormControl';
+export { useFormControl } from './FormControlContext';
diff --git a/packages/material-ui/src/InputBase/InputBase.js b/packages/material-ui/src/InputBase/InputBase.js
--- a/packages/material-ui/src/InputBase/InputBase.js
+++ b/packages/material-ui/src/InputBase/InputBase.js
@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
 import warning from 'warning';
 import clsx from 'clsx';
 import formControlState from '../FormControl/formControlState';
-import FormControlContext from '../FormControl/FormControlContext';
+import FormControlContext, { useFormControl } from '../FormControl/FormControlContext';
 import withStyles from '../styles/withStyles';
 import { useForkRef } from '../utils/reactHelpers';
 import TextareaAutosize from '../TextareaAutosize';
@@ -205,7 +205,7 @@ const InputBase = React.forwardRef(function InputBase(props, ref) {
   const handleInputRef = useForkRef(inputRef, handleInputRefProp);
 
   const [focused, setFocused] = React.useState(false);
-  const muiFormControl = React.useContext(FormControlContext);
+  const muiFormControl = useFormControl();
 
   const fcs = formControlState({
     props,


Test patch:
diff --git a/packages/material-ui/src/FormControl/FormControl.test.js b/packages/material-ui/src/FormControl/FormControl.test.js
--- a/packages/material-ui/src/FormControl/FormControl.test.js
+++ b/packages/material-ui/src/FormControl/FormControl.test.js
@@ -7,7 +7,7 @@ import { act, cleanup, createClientRender } from 'test/utils/createClientRender'
 import Input from '../Input';
 import Select from '../Select';
 import FormControl from './FormControl';
-import FormControlContext from './FormControlContext';
+import FormControlContext, { useFormControl } from './FormControlContext';
 
 describe('<FormControl />', () => {
   let mount;
@@ -15,7 +15,7 @@ describe('<FormControl />', () => {
   let classes;
 
   function TestComponent(props) {
-    const context = React.useContext(FormControlContext);
+    const context = useFormControl();
     props.contextCallback(context);
     return null;
   }
diff --git a/packages/material-ui/src/InputBase/InputBase.test.js b/packages/material-ui/src/InputBase/InputBase.test.js
--- a/packages/material-ui/src/InputBase/InputBase.test.js
+++ b/packages/material-ui/src/InputBase/InputBase.test.js
@@ -5,7 +5,7 @@ import { spy } from 'sinon';
 import { createMount, getClasses } from '@material-ui/core/test-utils';
 import describeConformance from '../test-utils/describeConformance';
 import { act, cleanup, createClientRender, fireEvent } from 'test/utils/createClientRender';
-import FormControlContext from '../FormControl/FormControlContext';
+import FormControl, { useFormControl } from '../FormControl';
 import InputAdornment from '../InputAdornment';
 import TextareaAutosize from '../TextareaAutosize';
 import InputBase from './InputBase';
@@ -244,44 +244,28 @@ describe('<InputBase />', () => {
     });
   });
 
-  describe('with muiFormControl context', () => {
-    function InputBaseWithContext(props) {
-      const { context, ...other } = props;
-
-      return (
-        <FormControlContext.Provider value={context}>
-          <InputBase {...other} />
-        </FormControlContext.Provider>
-      );
-    }
-    InputBaseWithContext.propTypes = {
-      context: PropTypes.object,
-    };
-
+  describe('with FormControl', () => {
     it('should have the formControl class', () => {
-      const { container } = render(<InputBaseWithContext context={{}} />);
-      expect(container.firstChild).to.have.class(classes.formControl);
+      const { getByTestId } = render(
+        <FormControl>
+          <InputBase data-testid="root" />
+        </FormControl>,
+      );
+      expect(getByTestId('root')).to.have.class(classes.formControl);
     });
 
     describe('callbacks', () => {
-      it('should fire the onFilled muiFormControl and props callback when dirtied', () => {
+      it('should fire the onFilled props callback when dirtied', () => {
         const handleFilled = spy();
-        const muiFormControl = { onFilled: spy() };
-        const { container } = render(
-          <InputBaseWithContext context={muiFormControl} onFilled={handleFilled} />,
-        );
+        const { container } = render(<InputBase onFilled={handleFilled} />);
 
         fireEvent.change(container.querySelector('input'), { target: { value: 'hello' } });
         expect(handleFilled.callCount).to.equal(1);
-        expect(muiFormControl.onFilled.callCount).to.equal(1);
       });
 
-      it('should fire the onEmpty muiFormControl and props callback when cleaned', () => {
+      it('should fire the and props callback when cleaned', () => {
         const handleEmpty = spy();
-        const muiFormControl = { onEmpty: spy() };
-        const { container } = render(
-          <InputBaseWithContext context={muiFormControl} onEmpty={handleEmpty} />,
-        );
+        const { container } = render(<InputBase onEmpty={handleEmpty} />);
 
         // Set value to be cleared
         fireEvent.change(container.querySelector('input'), { target: { value: 'test' } });
@@ -290,46 +274,18 @@ describe('<InputBase />', () => {
         // Clear value
         fireEvent.change(container.querySelector('input'), { target: { value: '' } });
         expect(handleEmpty.callCount).to.equal(2);
-        expect(muiFormControl.onEmpty.callCount).to.equal(2);
-      });
-
-      it('should fire the onFocus muiFormControl', () => {
-        const handleFocus = spy();
-        const muiFormControl = { onFocus: spy() };
-        const { container } = render(
-          <InputBaseWithContext context={muiFormControl} onFocus={handleFocus} />,
-        );
-
-        act(() => {
-          container.querySelector('input').focus();
-        });
-        expect(handleFocus.callCount).to.equal(1);
-        expect(muiFormControl.onFocus.callCount).to.equal(1);
-      });
-
-      it('should fire the onBlur muiFormControl', () => {
-        const handleBlur = spy();
-        const muiFormControl = { onBlur: spy() };
-        const { container } = render(
-          <InputBaseWithContext context={muiFormControl} onBlur={handleBlur} />,
-        );
-
-        act(() => {
-          container.querySelector('input').focus();
-          container.querySelector('input').blur();
-        });
-        expect(handleBlur.callCount).to.equal(1);
-        expect(muiFormControl.onBlur.callCount).to.equal(1);
       });
 
       it('should fire the onClick prop', () => {
         const handleClick = spy();
         const handleFocus = spy();
-        const { container } = render(
-          <InputBaseWithContext onClick={handleClick} onFocus={handleFocus} />,
+        const { getByTestId } = render(
+          <FormControl>
+            <InputBase data-testid="root" onClick={handleClick} onFocus={handleFocus} />
+          </FormControl>,
         );
 
-        fireEvent.click(container.firstChild);
+        fireEvent.click(getByTestId('root'));
         expect(handleClick.callCount).to.equal(1);
         expect(handleFocus.callCount).to.equal(1);
       });
@@ -337,29 +293,44 @@ describe('<InputBase />', () => {
 
     describe('error', () => {
       it('should be overridden by props', () => {
-        const { container, setProps } = render(<InputBaseWithContext context={{ error: true }} />);
-        expect(container.firstChild).to.have.class(classes.error);
+        function InputBaseInErrorForm(props) {
+          return (
+            <FormControl error>
+              <InputBase data-testid="root" {...props} />
+            </FormControl>
+          );
+        }
+
+        const { getByTestId, setProps } = render(<InputBaseInErrorForm />);
+        expect(getByTestId('root')).to.have.class(classes.error);
 
         setProps({ error: false });
-        expect(container.firstChild).not.to.have.class(classes.error);
+        expect(getByTestId('root')).not.to.have.class(classes.error);
 
         setProps({ error: true });
-        expect(container.firstChild).to.have.class(classes.error);
+        expect(getByTestId('root')).to.have.class(classes.error);
       });
     });
 
     describe('margin', () => {
-      describe('context margin: dense', () => {
-        it('should have the inputMarginDense class', () => {
-          const { container } = render(<InputBaseWithContext context={{ margin: 'dense' }} />);
-          expect(container.querySelector('input')).to.have.class(classes.inputMarginDense);
-        });
+      it('should have the inputMarginDense class in a dense context', () => {
+        const { container } = render(
+          <FormControl margin="dense">
+            <InputBase />
+          </FormControl>,
+        );
+        expect(container.querySelector('input')).to.have.class(classes.inputMarginDense);
       });
 
       it('should be overridden by props', () => {
-        const { container, setProps } = render(
-          <InputBaseWithContext context={{ margin: 'none' }} />,
-        );
+        function InputBaseInFormWithMargin(props) {
+          return (
+            <FormControl margin="none">
+              <InputBase {...props} />
+            </FormControl>
+          );
+        }
+        const { container, setProps } = render(<InputBaseInFormWithMargin />);
         expect(container.querySelector('input')).not.to.have.class(classes.inputMarginDense);
 
         setProps({ margin: 'dense' });
@@ -369,7 +340,11 @@ describe('<InputBase />', () => {
 
     describe('required', () => {
       it('should have the aria-required prop with value true', () => {
-        const { container } = render(<InputBaseWithContext context={{ required: true }} />);
+        const { container } = render(
+          <FormControl required>
+            <InputBase />
+          </FormControl>,
+        );
         const input = container.querySelector('input');
         expect(input).to.have.property('required', true);
       });
@@ -377,19 +352,105 @@ describe('<InputBase />', () => {
 
     describe('focused', () => {
       it('prioritizes context focus', () => {
-        const { container, setProps } = render(<InputBaseWithContext />);
+        const FormController = React.forwardRef((props, ref) => {
+          const { onBlur, onFocus } = useFormControl();
+
+          React.useImperativeHandle(ref, () => ({ onBlur, onFocus }), [onBlur, onFocus]);
+
+          return null;
+        });
+        const controlRef = React.createRef();
+        const { getByRole, getByTestId } = render(
+          <FormControl>
+            <FormController ref={controlRef} />
+            <InputBase data-testid="root" />
+          </FormControl>,
+        );
 
         act(() => {
-          container.querySelector('input').focus();
+          getByRole('textbox').focus();
         });
-        expect(container.firstChild).to.have.class(classes.focused);
+        expect(getByTestId('root')).to.have.class(classes.focused);
 
-        setProps({ context: { focused: false } });
-        expect(container.firstChild).not.to.have.class(classes.focused);
+        controlRef.current.onBlur();
+        expect(getByTestId('root')).not.to.have.class(classes.focused);
 
-        setProps({ context: { focused: true } });
-        expect(container.firstChild).to.have.class(classes.focused);
+        controlRef.current.onFocus();
+        expect(getByTestId('root')).to.have.class(classes.focused);
       });
+
+      it('propagates focused state', () => {
+        function FocusedStateLabel(props) {
+          const { focused } = useFormControl();
+          return <label {...props}>focused: {String(focused)}</label>;
+        }
+        const { getByRole, getByTestId } = render(
+          <FormControl>
+            <FocusedStateLabel data-testid="label" htmlFor="input" />
+            <InputBase id="input" />
+          </FormControl>,
+        );
+        expect(getByTestId('label')).to.have.text('focused: false');
+
+        act(() => {
+          getByRole('textbox').focus();
+        });
+        expect(getByTestId('label')).to.have.text('focused: true');
+
+        act(() => {
+          getByRole('textbox').blur();
+        });
+        expect(getByTestId('label')).to.have.text('focused: false');
+      });
+    });
+
+    it('propagates filled state when uncontrolled', () => {
+      function FilledStateLabel(props) {
+        const { filled } = useFormControl();
+        return <label {...props}>filled: {String(filled)}</label>;
+      }
+      const { getByRole, getByTestId } = render(
+        <FormControl>
+          <FilledStateLabel data-testid="label" />
+          <InputBase />
+        </FormControl>,
+      );
+      expect(getByTestId('label')).to.have.text('filled: false');
+
+      fireEvent.change(getByRole('textbox'), { target: { value: 'material' } });
+      expect(getByTestId('label')).to.have.text('filled: true');
+
+      fireEvent.change(getByRole('textbox'), { target: { value: '0' } });
+      expect(getByTestId('label')).to.have.text('filled: true');
+
+      fireEvent.change(getByRole('textbox'), { target: { value: '' } });
+      expect(getByTestId('label')).to.have.text('filled: false');
+    });
+
+    it('propagates filled state when controlled', () => {
+      function FilledStateLabel(props) {
+        const { filled } = useFormControl();
+        return <label {...props}>filled: {String(filled)}</label>;
+      }
+      function ControlledInputBase(props) {
+        return (
+          <FormControl>
+            <FilledStateLabel data-testid="label" />
+            <InputBase {...props} />
+          </FormControl>
+        );
+      }
+      const { getByTestId, setProps } = render(<ControlledInputBase value="" />);
+      expect(getByTestId('label')).to.have.text('filled: false');
+
+      setProps({ value: 'material' });
+      expect(getByTestId('label')).to.have.text('filled: true');
+
+      setProps({ value: 0 });
+      expect(getByTestId('label')).to.have.text('filled: true');
+
+      setProps({ value: '' });
+      expect(getByTestId('label')).to.have.text('filled: false');
     });
   });