score:1

Accepted answer

I think that update the placeholder property using the input ref is a good solution, that way you don't need to update the input value (avoid component re renders), and you can clear the placeholder text on click event:

Home.js

class Home extends Component {
  constructor(props, context) {
    super(props, context);
    this.searchInputRef = React.createRef(null);
    this.state = { initial_search_term: { search_term: "" } };
  }

  componentDidMount() {
    var self = this;
    typeAnimation("Start typing...", 100, function (msg) {
      if (document.activeElement !== self.searchInputRef.current) {
        self.searchInputRef.current.setAttribute("placeholder", msg);
      } else {
        return true; // stop typings
      }
    });
  }


  render() {
    //...

    let fieldsSearchForm = [
      {
        id: "search-field",
        type: "text",
        label: "Search Term",
        name: ["search_term"],
        options: [],
        fieldRef: this.searchInputRef,
        onClick: () => (this.searchInputRef.current.placeholder = "")
      }
    ];
   //...
  }
}

FieldMaker.js

class FieldMaker extends Component {
  //...

  
  render() {
   
    return (
      <>
        {this.state.fields.map((item, j) => {
          if (item.visibility) {
            if (item.type !== "checkbox") {
              return (
                <Field
                  id={item.id}
                  //...other props
                  fieldRef={item.fieldRef}
                  onClick={item.onClick}
                />
              );
            } else {
              //...
            }
          } else {
            //...
          }
        })}
      </>
    );
  }
}

renderTextField.js

const renderTextField = ({
  id,
  input,
  rows,
  multiline,
  label,
  type,
  meta: { touched, error, warning },
  onClick,
  fieldRef
}) => (
  <FormControl
    component="fieldset"
    fullWidth={true}
    className={multiline === true ? "has-multiline" : null}
  >
    <TextField
      id={id}
      inputRef={fieldRef}
      onClick={onClick}
      // other props
    />
  </FormControl>
);

Utility.js

export async function typeAnimation(text, timing, callback) {
  let concatStr = "";
  for (const char of text) {
    concatStr += char;
    await sleep(timing);
    const shouldStop = callback(concatStr);
    if (shouldStop) break; // stop the loop
  }
}

styles.css // to keep the placeholder visible

#search-field-label {
  transform: translate(0, 1.5px) scale(0.75);
  transform-origin: top left;
}

#search-field::-webkit-input-placeholder {
  opacity: 1 !important;
}

score:1

I know this isn't the answer you are looking for, but the easiest path would be to animate the placeholder text instead of the main input text. Then you don't have to worry about anything and can just let the animation play out regardless of the user's actions.

Now the problem I have -- is I need to create an onClick on the textField -- and if its an automated typetext field - reset the value to an empty string -- pass this onclick back up to the parent shells -- and even back up to the typetext function to break the timeout --- so if the user loads the page they see the text typing - but with UI functionality improvements - if I click on the field during mid-animation - I want the animation to stop/break, I want the field to clear.

I want to have control over also which fields should be cleared -- so in this case - have a param - that indicates onClickClear: true -- so as not to break user edit profile pre-filled forms.

All of this is satisfied by using the placeholder of the field instead (although the typetext won't stop because there's no need to as the user's text / prefilled text will hide the placeholders). The only thing I haven't hooked up is to stop the typetext on Home's componentWillUnmount as without that, it would throw warning messages that setState is being called on an unmounted component.

I had to do some refactoring as there were some issues with things like mutating React state (toggleFieldVisibility in FieldMaker.js) and not updating this.state.fields when new props are passed down as the state was only being set in the constructor. I also renamed some things within FieldMaker.js while I was at it (mostly due to personal preference in this case).

There are definitely problems with trying to derive state from props regardless of how you do it: You Probably Don't Need Derived State

Running code:

https://codesandbox.io/s/busy-davinci-mk0dq?file=/src/Home.js

Home.js

  state = {
    initial_search_term: { search_term: "" },
    searchPlaceholder: "",
    textPlaceholder: "",
    valPlaceholder: ""
  };

  componentDidMount() {
    typeAnimation("Search Text...", 100, (msg) => {
      this.setState({
        searchPlaceholder: msg
      });
    });
    typeAnimation("Just some super long text you used to know", 100, (msg) => {
      this.setState({
        textPlaceholder: msg
      });
    });
    typeAnimation("I'm a value, but am I valuable??", 100, (msg) => {
      this.setState({
        valPlaceholder: msg
      });
    });
  }

// Render funct:
  let fieldsSearchForm = [
      {
        type: "text",
        label: "Search Term",
        name: ["search_term"],
        props: { placeholder: this.state.searchPlaceholder },
        options: []
      },
      {
        type: "text",
        label: "Text",
        name: ["test"],
        props: { placeholder: this.state.textPlaceholder },
        options: []
      },
      {
        type: "text",
        label: "Value",
        name: ["test2"],
        props: { placeholder: this.state.valPlaceholder }
      }
    ];

FieldMaker.js

The getDerivedStateFromProps is the real main difference here, This is to populate the subs array based on the fields whenever the fields change (and to set visibility). I have no idea how much of that is really necessary as there's no notion of what any of that is actually supposed to do in this. So it likely needs more work to get it fully working.

The other difference is a bit of a refactor to having a separate visiblity object in state rather than modifying the fields in state.

The main reason for modifying this file is to make sure that updates to the fields prop translates to updates to the children Fields so that the placeholder can be passed down via props to the Field and thus the renderTextField

  state = {
    visibility: {}
  };

  static getDerivedStateFromProps(props, state) {
    let newState = { prevFields: props.fields };
    if (props.fields !== state.prevFields) {
      let visibility = state.visibility;
      let subs = props.fields.reduce((subs, field) => {
        if (field.sub) {
          subs.push(field.sub);
          visibility[field.name] = false;
        } else {
          visibility[field.name] = true;
        }
        return subs;
      }, []);
      newState.subs = subs;
    }
    return newState;
  }

  toggleFieldVisibility(pos, isVisibile) {
    let field = this.props.fields[pos].name;
    this.setState((prev) => {
      return { ...prev, [field]: isVisibile };
    });
    // This directly manipulates state, and is likely problematic in React
    // let fields = { ...this.state.fields };
    // fields[pos]["visibility"] = isVisibile;
  }

  componentDidMount() {
    this.hideSubs();
  }

// In render:
    return (
      <>
        {this.props.fields.map((item, j) => {
          if (this.state.visibility[item.name]) {
            if (item.type !== "checkbox") {
              return (
                <Field
                  key={j}                  
                  name={item.name[0]}
                  props={item.props}
                  label={item.label}
// ...

renderTextField.js

In this, the goal of the change is just to pass the placeholder down to the MUI TextField, and to make the MUI TextField's label shrink back by setting InputLabelProps = {shrink: true}

const renderTextField = ({
  input,
  rows,
  multiline,
  label,
  type,
  meta: { touched, error, warning },
  placeholder,
  InputLabelProps
}) => {
  // Ensure that the label is shrunk to the top of the input
  // whenever there's a placeholder set
  InputLabelProps = placeholder
    ? { ...(InputLabelProps ?? {}), shrink: true }
    : InputLabelProps;
  return (
    <FormControl
      component="fieldset"
      fullWidth={true}
      className={multiline === true ? "has-multiline" : null}
    >
      <TextField
        InputLabelProps={InputLabelProps}
        placeholder={placeholder}
        label={label}
        multiline={multiline}
        rows={rows}
        type={type}
        error={touched && (error && error.length > 0 ? true : false)}
        helperText={
          touched &&
          ((error && error.length > 0 ? error : null) ||
            (warning && warning.length > 0 ? warning : null))
        }
        {...input}
      />
    </FormControl>
  );
};


I redid the solution in a very quick and dirty way to avoid the pitfalls that exist in the FieldMaker file that initially caused issues in the original solution:

https://codesandbox.io/s/fervent-moser-0qtvu?file=/src/Home.js

I modified typeAnimation to support a semblance of cancellation by returning a cancel function that stops the looping and sets uses the callback to set the value to the end state.

export function typeAnimation(text, timing, callback) {
  let concatStr = "";
  let canceled = false;
  function cancel() {
    canceled = true;
  }
  async function runAnimation() {
    for (const char of text) {
      concatStr += char;
      await sleep(timing);
      if (canceled) {
        break;
      }
      callback(concatStr);
    }
    if (canceled) {
      callback(text);
    }
  }
  runAnimation();
  return cancel;
}

Then in Home.js, I modified intitial state and the componentDidMount to work with the placeholders and give me a location to store the cancel functions.

  constructor(props, context) {
    super(props, context);
    this.state = {
      initial_search_term: { search_term: "" },
      placeholders: { search_term: "" }
    };
  }
  cancelAnimations = {};
  componentDidMount() {
    var self = this;
    this.cancelAnimations.search_term = typeAnimation(
      "Start typing...",
      100,
      function (msg) {
        self.setState((state) => ({
          placeholders: { ...state.placeholders, search_term: msg }
        }));
      }
    );
  }

I also add fieldsExtras and pass that all the way down to the FieldMaker component to add extra props to the Field in that component via the index matching the fieldsSearchForm array.

    let fieldsExtras = [
      {
        placeholder: this.state.placeholders.search_term,
        onClick: this.cancelAnimations.search_term
      }
    ];

Then once the extra props have been passed all the way down into the Field, in renderTextField, I do the same sort of thing as before, but I also added the onClick to call the passed in onClick function

const renderTextField = ({
  input,
  rows,
  multiline,
  label,
  type,
  meta: { touched, error, warning },
  placeholder,
  onClick,
  InputLabelProps
}) => {
  InputLabelProps = placeholder
    ? { ...(InputLabelProps ?? {}), shrink: true }
    : InputLabelProps;
  return (
    <FormControl
      component="fieldset"
      fullWidth={true}
      className={multiline === true ? "has-multiline" : null}
    >
      <TextField
        placeholder={placeholder}
        InputLabelProps={InputLabelProps}
        onClick={(e, value) => {
          onClick && onClick(e);
        }}
        label={label}
        multiline={multiline}
        rows={rows}
        type={type}
        error={touched && (error && error.length > 0 ? true : false)}
        helperText={
          touched &&
          ((error && error.length > 0 ? error : null) ||
            (warning && warning.length > 0 ? warning : null))
        }

        {...input}
      />
    </FormControl>
  );
};

Related Query

More Query from same tag