问题
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?
回答1:
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.
回答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
回答3:
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
回答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
来源:https://stackoverflow.com/questions/61240217/how-to-stop-cursor-from-jumping-to-the-end-of-input