Как остановить курсор от перехода к концу ввода

У меня есть контролируемый компонент ввода React, и я форматирую ввод, как показано в коде onChange.

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

И тогда моя функция formatPhone выглядит так

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 });

}

Я начинаю сталкиваться с проблемой, когда пытаюсь удалить/добавить цифру где-то посередине, а затем курсор сразу перемещается в конец строки.

Я видел решение на странице sophie, но я думаю, что оно здесь не применимо, поскольку setState в любом случае вызовет рендеринг. Я также пытался манипулировать положением каретки с помощью setSelectionRange(start, end), но это тоже не помогло. Я думаю, что setState, который вызывает рендеринг, заставляет компонент рассматривать отредактированное значение как окончательное значение и заставляет курсор двигаться в конец.

Может ли кто-нибудь помочь мне выяснить, как решить эту проблему?


person abhi    schedule 15.04.2020    source источник


Ответы (4)


Я боюсь, что, учитывая, что вы передаете контроль React, неизбежно, что изменение состояния отбрасывает позицию каретки, и, следовательно, единственное решение - справиться с этим самостоятельно.

Кроме того, сохранение «текущей позиции», учитывая ваши манипуляции со строками, не так уж и тривиально...

Чтобы попытаться лучше решить проблему, я придумал решение с хуками реагирования, где вы можете лучше видеть, какие изменения состояния происходят.

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'))

ссылка на codepen

Я надеюсь, что это помогает

person user1602937    schedule 20.04.2020
comment
Спасибо за ответ @user1602937. Под единственным решением - справиться с этим самостоятельно, вы имеете в виду что-то вроде setSelectionRange? Я тоже так пробовал. Я вижу, что вы также использовали его в своем коде, но я все еще вижу проблему в вашем примере кода. Или вы имеете в виду что-то другое, говоря об этом сам? - person abhi; 20.04.2020
comment
Под этим я подразумеваю придумать логику, которая определяет, куда поместить курсор при модификации. Мой черновик был немного ошибочным, поэтому я обновил логику, но вы, возможно, захотите уточнить ее, чтобы определить, что нажатие клавиши было удалением, и в этом случае переместить курсор влево на единицу. Теперь еще раз взгляните на мой codepen и посмотрите, может ли он работать лучше для вас. - person user1602937; 21.04.2020
comment
Я добавил onKeyDown и обнаружил нажатие клавиши, и это помогло справиться со сценарием удаления. Но это уже не проблема. Проблема в том, что когда я удаляю или добавляю новое число где-то в середине строки, курсор перескакивает на несколько мест вправо. Я собираюсь попытаться найти способ исправить это. - person abhi; 23.04.2020

onChange одного будет недостаточно.

Случай 1: если target.value === 123|456, то вы не знаете, как был удален '-'. С <del> или с <backspace>. Таким образом, вы не знаете, должно ли результирующее значение и позиция курсора быть 12|4-56 или 123-|56.

Но что, если вы сохраните предыдущую позицию и значение каретки? Предположим, что на предыдущих onChange у вас было

123-|456

и теперь у вас есть

123|456

это, очевидно, означает, что пользователь нажал <backspace>. Но вот приходит...

Вариант 2: пользователи могут изменять положение курсора с помощью мыши.

onKeyDown для спасения:

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

Вас также может заинтересовать некоторая библиотека для задачи. Есть например https://github.com/nosir/cleave.js Но способ он перемещает каретку, возможно, не на ваш вкус. В любом случае, это, вероятно, не единственная библиотека.

person x00    schedule 23.04.2020
comment
Если ввести 123-456, то курсор удаления 4 будет на -, но 3 будет удален, если снова нажать клавишу Backspace. - person Makan; 27.04.2020
comment
Спасибо @x00. Ваше решение, кажется, охватывает множество вещей, но я заметил одну вещь: оно позволяет мне вводить цифры, даже когда достигается максимальная длина. Например. Если у меня есть 123-456-7890 и я ввожу 1 между 1 и 2, это позволяет мне сделать это, и новый номер становится 112-345-6789. И да, есть библиотека (npmjs.com/package/react-number-format) также выглядит многообещающе. - person abhi; 27.04.2020
comment
Если вы предпочитаете этот способ, то onKeyDown = {event => { if(event.key.length === 1 && (/[^0-9]/.test(event.key) || event.target.value.length >= 12)) event.preventDefault(); else setKey(event.key) } поможет. (Также /[^0-9]/ чек лучше перенести в onKeyDown) - person x00; 27.04.2020
comment
@Макан, я так и написал. Потому что не указано, как должно быть. Есть много возможных настроек решения. Все они субъективны. Основная идея в том, что onChange недостаточно - person x00; 27.04.2020
comment
@x00 Я согласен с вашей идеей, которой onChange недостаточно. На самом деле ваш способ - отличное решение. То, что я сказал, было с точки зрения UX, но это не имеет большого значения. - person Makan; 28.04.2020

Решение, которое вы пробовали, должно работать.

Обратите внимание, что в ответ состояние обновляется асинхронно. Чтобы сделать то, что вам нужно сделать, как только будут выполнены обновления состояния, используйте 2-й аргумент setState.

Согласно документам.

Второй параметр setState() — это необязательная функция обратного вызова, которая будет выполняться после завершения setState и повторного рендеринга компонента.

Так что просто напишите встроенную функцию для выполнения setSelectionRange и передайте ее в качестве второго аргумента setState

Нравится

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

Рабочая копия кода находится здесь:

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

person gdh    schedule 21.04.2020
comment
Спасибо @gdh. Я попробовал ваш пример codepen, но увидел там проблему. Например. Когда я набираю 1234, он форматирует его в 123-4, что хорошо, но курсор перемещается в позицию перед 4, поэтому следующее число становится 123-54, а не 123-45. Итак, я увеличил диапазон в вашем коде на +1, т.е. setSelectionRange(caretStart+1, CaretEnd+1). Это помогло при прямом вводе, но когда я добавляю/удаляю числа в середине, курсор снова начинает вести себя неправильно. Например. Если я хочу удалить 45 из 123-456-7890, я получаю 123-478-90 - person abhi; 21.04.2020

Сохраняя положение курсора в начале обработчика и восстанавливая его после рендеринга нового состояния, положение курсора всегда будет в правильном положении.

Однако, поскольку добавление - изменит положение курсора, необходимо учитывать его влияние на начальную позицию.

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>
  );
}

В приведенном выше коде эта часть сохранит позицию:

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

И это восстановит его:

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

Также есть одна тонкая вещь: с помощью useState({phone:""}) мы обеспечиваем повторный рендеринг ввода, потому что он всегда устанавливает новый объект.

Пример CodeSandbox: https://codesandbox.io/s/tel-formating-m1cg2?file=/src/App.js

person Makan    schedule 26.04.2020
comment
Спасибо @Макан. При тестировании вашего решения codepen я обнаружил небольшую проблему при вводе нечисловых символов. Если у меня есть число 123-456 и я набираю a или любой другой алфавит от 1 до 2, он перемещает позицию курсора на единицу вправо. - person abhi; 27.04.2020
comment
@абхи Ты прав. Спасибо за внимание к деталям. Я думаю, что это исправлено и, надеюсь, работает! - person Makan; 27.04.2020