Stop cursor/caret jumping when formatting number in React

ghz 6months ago ⋅ 115 views

I have an input field on my react component that shows the line price for an item (two decimal places with thousands separators). I want the value shown to be in money format when the component first renders and also to be kept in money format as user types in the field.

At the moment I have the following code in my component:

var React = require('react');
import accounting from 'accounting';
    
MoneyInput = React.createClass({
    propTypes: {
        name: React.PropTypes.string.isRequired,
        onChange: React.PropTypes.func.isRequired,
        value: React.PropTypes.number,
        error: React.PropTypes.string,
    },
    
    onChange(event) {
        // get rid of any money formatting
        event.target.value = accounting.unformat(event.target.value);
    
        // pass the value on
        this.props.onChange(event);
    },
    
    getValue() {
        return accounting.formatNumber(this.props.value, 2)
    },
    
    render() {
    
        return (
                <div className="field">
                    <input type="text"
                           name={this.props.name}
                           className="form-control"
                           value={this.getValue()}
                           onChange={this.onChange} />
                    <div className="input">{this.props.error}</div>
                </div>
        );
    }
});

module.exports = MoneyInput;

That code displays the data correctly formatted, but every time I enter a value the cursor/caret jumps to the end of the number.

I understand why that's happening (I think) and I've read several questions here related to not losing cursor position in JavaScript (here and here for example).

My question is what's the best way to deal with this in React?

I think that ideally I wouldn't want to store the cursor position in state (e.g. I would want these to be Presentation Components in Dan Abramov syntax) so is there another way?

Answers

Handling the cursor position issue in a React component, especially with formatted input fields like money inputs, can be a bit tricky due to how React handles the rendering of input fields and the natural behavior of the browser.

Approach to Preserve Cursor Position

To maintain the cursor position while formatting the input dynamically, you can utilize a technique that involves managing the selection range of the input field. Here’s a refined approach to achieve this:

  1. Using getSelection and setSelectionRange:

    • These are standard DOM methods that allow you to get and set the selection (cursor position) within an input element.
  2. Refactoring onChange Handler:

    • Update the onChange handler to preserve the cursor position when the input value changes due to formatting.

Here’s how you can refactor your MoneyInput component to preserve the cursor position:

import React, { Component } from 'react';
import accounting from 'accounting';

class MoneyInput extends Component {
  static propTypes = {
    name: React.PropTypes.string.isRequired,
    onChange: React.PropTypes.func.isRequired,
    value: React.PropTypes.number,
    error: React.PropTypes.string,
  };

  onChange = (event) => {
    // Get the current cursor position
    const cursorPosition = event.target.selectionStart;

    // Remove any money formatting
    const unformattedValue = accounting.unformat(event.target.value);

    // Pass the value on
    this.props.onChange({
      ...event,
      target: {
        ...event.target,
        value: unformattedValue,
      },
    });

    // Restore cursor position
    setTimeout(() => {
      const newPosition = cursorPosition - (event.target.value.length - unformattedValue.length);
      event.target.setSelectionRange(newPosition, newPosition);
    }, 0);
  };

  getValue() {
    return accounting.formatNumber(this.props.value, 2);
  }

  render() {
    return (
      <div className="field">
        <input
          type="text"
          name={this.props.name}
          className="form-control"
          value={this.getValue()}
          onChange={this.onChange}
        />
        <div className="input">{this.props.error}</div>
      </div>
    );
  }
}

export default MoneyInput;

Explanation:

  • onChange Handler:

    • Cursor Position: Before updating the input value, capture the current cursor position using event.target.selectionStart.
    • Formatting: Remove any formatting using accounting.unformat.
    • Updating Value: Update event.target.value with the unformatted value and pass the modified event to this.props.onChange.
    • Restoring Cursor Position: After the input value updates (due to formatting), use setTimeout to asynchronously set the cursor position back to its original location using setSelectionRange.
  • getValue Method:

    • Formats this.props.value into a money format with two decimal places using accounting.formatNumber.

Key Points:

  • Asynchronous Update: Using setTimeout ensures that the cursor position is set after the input value has been updated, avoiding conflicts with React's internal rendering cycle.

  • Preservation of Cursor Position: By calculating the new cursor position based on the difference in length between the formatted and unformatted values, you ensure that the cursor stays where the user expects it.

This approach allows you to maintain a clean separation between presentation (formatting) and behavior (cursor management), adhering to the principles of smart/dumb components in React.