13

I have a controlled React input component and I am formatting the input as shown in onChange code.

<input type="TEL" id="applicantCellPhone" onChange={this.formatPhone} name="applicant.cellPhone" value={this.state["applicant.cellPhone"]}/>

And then my formatPhone function is like this

formatPhone(changeEvent) {
let val = changeEvent.target.value;
let r = /(\D+)/g,
  first3 = "",
  next3 = "",
  last4 = "";
val = val.replace(r, "");
if (val.length > 0) {
  first3 = val.substr(0, 3);
  next3 = val.substr(3, 3);
  last4 = val.substr(6, 4);
  if (val.length > 6) {
    this.setState({ [changeEvent.target.name]: first3 + "-" + next3 + "-" + last4 });
  } else if (val.length > 3) {
    this.setState({ [changeEvent.target.name]: first3 + "-" + next3 });
  } else if (val.length < 4) {
    this.setState({ [changeEvent.target.name]: first3 });
  }
} else this.setState({ [changeEvent.target.name]: val });

}

I start facing the problem when I try to delete/add a digit somewhere in the middle and then cursor immediately moves to the end of the string.

I saw a solution at solution by Sophie, but I think that doesn't apply here as setState will cause render anyways. I also tried to manipulate caret position by setSelectionRange(start, end), but that didn't help either. I think setState that causes render is making the component treat the edited value as final value and causing cursor to move to the end.

Can anyone help me figuring out how to fix this problem?

sideshowbarker
  • 81,827
  • 26
  • 193
  • 197
abhi
  • 349
  • 2
  • 8
  • 24

5 Answers5

6

I am afraid that given you relinquish the control to React it's unavoidable that a change of state discards the caret position and hence the only solution is to handle it yourself.

On top of it preserving the "current position" given your string manipulation is not that trivial...

To try and better break down the problem I spinned up a solution with react hooks where you can better see which state changes take place

function App() {

  const [state, setState] = React.useState({});
  const inputRef = React.useRef(null);
  const [selectionStart, setSelectionStart] = React.useState(0);

  function formatPhone(changeEvent) {

    let r = /(\D+)/g, first3 = "", next3 = "", last4 = "";
    let old = changeEvent.target.value;
    let val = changeEvent.target.value.replace(r, "");

    if (val.length > 0) {
      first3 = val.substr(0, 3);
      next3 = val.substr(3, 3);
      last4 = val.substr(6, 4);
      if (val.length > 6) {
        val = first3 + "-" + next3 + "-" + last4;
      } else if (val.length > 3) {
        val = first3 + "-" + next3;
      } else if (val.length < 4) {
        val = first3;
      }
    }

    setState({ [changeEvent.target.name]: val });

    let ss = 0;
    while (ss<val.length) {
      if (old.charAt(ss)!==val.charAt(ss)) {
        if (val.charAt(ss)==='-') {
            ss+=2;
        }
        break;
      }
      ss+=1;
    }

    setSelectionStart(ss);
  }  

  React.useEffect(function () {
    const cp = selectionStart;
    inputRef.current.setSelectionRange(cp, cp);
  });

  return (
    <form autocomplete="off">
      <label for="cellPhone">Cell Phone: </label>
      <input id="cellPhone" ref={inputRef} onChange={formatPhone} name="cellPhone" value={state.cellPhone}/>
    </form>
  )  
}

ReactDOM.render(<App />, document.getElementById('root'))

link to codepen

I hope it helps

  • Thanks for the response @user1602937. By "only solution is to handle it yourself", you mean something like setSelectionRange? I did try that as well. I see that you have also used it in your code, but I am still seeing the issue in your codepen example. Or do you mean something else by handling it myself? – abhi Apr 20 '20 at 14:59
  • By handling it I mean to come up with a logic that detects where to place the cursor upon modification. My draft was a bit buggy so I updated the logic but you might want to refine it to detect that the keypress was a delete and move the cursor left by one in that case. Have another look at my codepen now and see if it might work better for you. – Gianluca Romeo Apr 21 '20 at 16:46
  • I added onKeyDown and detected key press and that did help with handling the delete scenario. But, that's not the problem anymore. Problem is that when I delete or add a new number somewhere in middle of the string, then cursor jumps few places to the right. I am going to try to find a way to fix that. – abhi Apr 23 '20 at 03:26
5

onChange alone won't be enough.

Case 1: If target.value === 123|456 then you don't know how '-' was deleted. With <del> or with <backspace>. So you don't know should the resulting value and caret position be 12|4-56 or 123-|56.

But what if you'll save previous caret position and value? Let's say that on previous onChange you had

123-|456

and now you have

123|456

that obviously means that user pressed <backspace>. But here comes...

Case 2: Users can change the cursor position with a mouse.

onKeyDown for the rescue:

function App() {

  const [value, setValue] = React.useState("")

  // to distinguish <del> from <backspace>
  const [key, setKey] = React.useState(undefined)

  function formatPhone(event) {
    const element = event.target
    let   caret   = element.selectionStart
    let   value   = element.value.split("")

    // sorry for magical numbers
    // update value and caret around delimiters
    if( (caret === 4 || caret === 8) && key !== "Delete" && key !== "Backspace" ) {
      caret++
    } else if( (caret === 3 || caret === 7) && key === "Backspace" ) {
      value.splice(caret-1,1)
      caret--
    } else if( (caret === 3 || caret === 7) && key === "Delete" ) {
      value.splice(caret,1);
    }

    // update caret for non-digits
    if( key.length === 1 && /[^0-9]/.test(key) ) caret--

    value = value.join("")
      // remove everithing except digits
      .replace(/[^0-9]+/g, "")
      // limit input to 10 digits
      .replace(/(.{10}).*$/,"$1")
      // insert "-" between groups of digits
      .replace(/^(.?.?.?)(.?.?.?)(.?.?.?.?)$/, "$1-$2-$3")
      // remove exescive "-" at the end
      .replace(/-*$/,"")

    setValue(value);

    // "setTimeout" to update caret after setValue
    window.requestAnimationFrame(() => {
      element.setSelectionRange(caret,caret)
    })
  }  
  return (
    <form autocomplete="off">
      <label for="Phone">Phone: </label>
      <input id="Phone" onChange={formatPhone} onKeyDown={event => setKey(event.key)} name="Phone" value={value}/>
    </form>
  )
}

codesandbox

You may also be interested in some library for the task. There is for example https://github.com/nosir/cleave.js But the way it moves the caret may not be up to your taste. Anyway, it's probably not the only library out there.

x00
  • 13,643
  • 3
  • 16
  • 40
  • If enter `123-456` then delete `4` cursor would be on `-` but `3` will be deleted if push backspace again. – Makan Apr 27 '20 at 07:59
  • Thanks @x00. Your solution seems to cover a lot of things, but one thing I noticed is that it keeps letting me enter the digits even when max length is achieved. E.g. If I have 123-456-7890 and I enter "1" between 1 and 2, it lets me do that and the new number becomes 112-345-6789. And yes, there is a library (https://www.npmjs.com/package/react-number-format) also which looks promising. – abhi Apr 27 '20 at 15:18
  • If you prefer this way, then `onKeyDown = {event => { if(event.key.length === 1 && (/[^0-9]/.test(event.key) || event.target.value.length >= 12)) event.preventDefault(); else setKey(event.key) }` will do the trick. (Also `/[^0-9]/` check is better to be moved into `onKeyDown`) – x00 Apr 27 '20 at 17:59
  • 1
    @Makan, that's the way I wrote it. Because it is not specified how it should be. There are a lot of possible tweaks to the solution. All of them are subjective. The main idea is that `onChange` is not enough – x00 Apr 27 '20 at 18:04
  • @x00 I agree with your Idea which `onChange` is not enough. Actually your way is a great solution. What I said was from UX perspective, but it is not big deal. – Makan Apr 28 '20 at 01:18
4

By saving cursor position in the beginning of the handler and restoring it after new state rendered, cursor position will always be in correct position.

However, because adding - will change cursor position, it needs to considered its effect on initial position

import React, { useRef, useState, useLayoutEffect } from "react";

export default function App() {
  const [state, setState] = useState({ phone: "" });
  const cursorPos = useRef(null);
  const inputRef = useRef(null);
  const keyIsDelete = useRef(false);

  const handleChange = e => {
    cursorPos.current = e.target.selectionStart;
    let val = e.target.value;
    cursorPos.current -= (
      val.slice(0, cursorPos.current).match(/-/g) || []
    ).length;
    let r = /(\D+)/g,
      first3 = "",
      next3 = "",
      last4 = "";
    val = val.replace(r, "");
    let newValue;
    if (val.length > 0) {
      first3 = val.substr(0, 3);
      next3 = val.substr(3, 3);
      last4 = val.substr(6, 4);
      if (val.length > 6) {
        newValue = first3 + "-" + next3 + "-" + last4;
      } else if (val.length > 3) {
        newValue = first3 + "-" + next3;
      } else if (val.length < 4) {
        newValue = first3;
      }
    } else newValue = val;
    setState({ phone: newValue });
    for (let i = 0; i < cursorPos.current; ++i) {
      if (newValue[i] === "-") {
        ++cursorPos.current;
      }
    }
    if (newValue[cursorPos.current] === "-" && keyIsDelete.current) {
      cursorPos.current++;
    }
  };

  const handleKeyDown = e => {
    const allowedKeys = [
      "Delete",
      "ArrowLeft",
      "ArrowRight",
      "Backspace",
      "Home",
      "End",
      "Enter",
      "Tab"
    ];
    if (e.key === "Delete") {
      keyIsDelete.current = true;
    } else {
      keyIsDelete.current = false;
    }
    if ("0123456789".includes(e.key) || allowedKeys.includes(e.key)) {
    } else {
      e.preventDefault();
    }
  };

  useLayoutEffect(() => {
    if (inputRef.current) {
      inputRef.current.selectionStart = cursorPos.current;
      inputRef.current.selectionEnd = cursorPos.current;
    }
  });

  return (
    <div className="App">
      <input
        ref={inputRef}
        type="text"
        value={state.phone}
        placeholder="phone"
        onChange={handleChange}
        onKeyDown={handleKeyDown}
      />
    </div>
  );
}

In above code these part will save position:

    cursorPos.current = e.target.selectionStart;
    let val = e.target.value;
    cursorPos.current -= (
      val.slice(0, cursorPos.current).match(/-/g) || []
    ).length;

And these will restore it:

    for (let i = 0; i < cursorPos.current; ++i) {
      if (newValue[i] === "-") {
        ++cursorPos.current;
      }
    }

Also a subtle thing is there, by using useState({phone:""}) we make sure input would re-render because it always set a new object.

CodeSandbox example is https://codesandbox.io/s/tel-formating-m1cg2?file=/src/App.js

Makan
  • 641
  • 8
  • 13
  • Thanks @Makan. While testing your codepen solution, I found a minor issue while typing non-numeric characters. If I have the number 123-456 and I type "a" or any other alphabet between 1 and 2, it moves the cursor position by one to the right. – abhi Apr 27 '20 at 14:59
  • @abhi You are right. Thanks for your attention to details. I think it is fixed now and hopefully works! – Makan Apr 27 '20 at 16:18
  • just a warning that `ref.current` is a readonly property though, so this will not work in Typescript! – ChumiestBucket Apr 28 '22 at 15:25
2

The solution you tried should work.

Note that - In react, state is updated in asynchronously. To do the stuff you need to do as soon as the state updates are done, make use of 2nd argument of setState.

As per docs

The second parameter to setState() is an optional callback function that will be executed once setState is completed and the component is re-rendered.

So just write an inline function to do setSelectionRange and pass it as 2nd argument to setState

Like this

...
this.setState({
    [changeEvent.target.name]: first3 + "-" + next3 + "-" + last4
},
    () => changeEvent.target.setSelectionRange(caretStart, caretEnd)
);
...

Working copy of the code is here:

https://codesandbox.io/s/input-cursor-issue-4b7yg?file=/src/App.js

gdh
  • 13,114
  • 2
  • 16
  • 28
  • Thanks @gdh. I tried your codepen example, but I was seeing an issue there. E.g. When I type "1234" it formats that to "123-4" which is good, but cursor goes to the position before "4", so the next number becomes "123-54" instead of becoming "123-45". So, I incremented the range in your code by +1 i.e. setSelectionRange(caretStart+1, caretEnd+1). That helped with the forward typing but, as I add/delete numbers in the middle, cursor again starts misbehaving. E.g. If I want to delete "45" from 123-456-7890, I end up with 123-478-90 – abhi Apr 21 '20 at 04:34
-1

You can simply add the following lines in your formatPhone function

if (!(event.keyCode == 8 || event.keyCode == 37 || event.keyCode == 39))

add this if condition to whole code written in formatPhone function.

Robert Juneau
  • 652
  • 6
  • 15