Modifying the string in a text box pushes the cursor to the end

I need to correct text that is input in a text box, character-by-character.
Elm makes this easy with onChange in elm-ui, and onInput in Html.

However, when the corrected string is put into the box (via the model), the cursor (caret) jumps to the end of the string — very annoying for the user if they are editing the string somewhere in the middle.

If the modification process doesn’t actually modify the string, then the cursor stays where it should be. For example:

  SimpleTextBoxChanged string ->
     let
        newString = String.filter (\c -> c /= '#') string 
     in
        ( { model | simpleTextBoxString = newString }, Cmd.none )

Enter (or delete) anything but a ‘#’ and the cursor is where it should be.
Enter a ‘#’ and it is correctly filtered out but the cursor jumps to the end.

My current solution is to use JavaScript via a port to re-adjust the cursor using setSelectionRange.

Which is not ideal. Especially as it’s important to change the text in the box (by updating the model) before moving the cursor (by issuing the port command). And there seems to be no way to run a command after the model update.

I have solved this potential problem by setting the text string in JavaScript (as well as in the Elm model) using the .value function on the textbox element, and then doing the setSelectionRange.

This is a mess. Is there a better way?

2 Likes

A Cmd msg returned from update is dispatched immediately before the call to view. In the case of a Cmd msg calling a port, the port will be called synchronously so the view won’t be updated at the time your JS is run. The general solution to this is to delay the JS from running until the next animation frame which will be after the view has been updated. You can do this in JS with setTimeout() or with requestAnimationFrame() called within the function you’ve subscribed to the port on the JS side.

1 Like

To make it cleaner and more robust/reusable you could probably create a custom-element that takes in “cursor/caret” position as an attribute. Or create that custom element in a way that does not move the cursor to the end on updates.

1 Like

Exactly this. I do the same thing, just wrap your cursor setting code inside requestAnimationFrame.

You can also send selection information to the Elm app as needed if it helps in a port, or dispatching events on the html elements and listen to them from Elm (or even making a custom element too).

Some snippets I have:

  • Setting a selection in some random contenteditable DOM:
        app.ports.setCursor.subscribe(range => {
          requestAnimationFrame(() => {
            let cursorRange = document.createRange();

            let selection = window.getSelection();

            let startContainer = document.getElementById(range.start.id);
            if (!startContainer)
              throw new Error(
                `Couldn't find element with id ${range.start.id}`
              );
            cursorRange.setStart(startContainer.firstChild, range.start.offset);
            if (range.end) {
              let endContainer = document.getElementById(range.end.id);
              if (!endContainer)
                throw new Error(
                  `Couldn't find element with id ${range.end.id}`
                );
              cursorRange.setEnd(endContainer.firstChild, range.end.offset);
            }

            selection.removeAllRanges();
            selection.addRange(cursorRange);
          });
        });
      });
  • Listening to selection changes (which you can then send to the Elm app as needed:
      document.addEventListener("selectionchange", () => {
        console.log("SELECTION CHANGED", document.getSelection());
      });
1 Like

I made an example on how to make a field where it is only possible to type digits a while ago. Maybe it can be of inspiration. https://ellie-app.com/6YfzVxqmtJ3a1

1 Like

Fixing the cursor position afterwards by setting it to its old starting offset seems to do the trick in most cases?

window.addEventListener('input', event => {
    // capture the value the browser set
    const target = event.target
    const { value, selectionStart } = target
    
    // wait for Elm to decide what the new value
    // of the input actually should look like
    requestAnimationFrame(() => {
        const newValue = target.value
        if (value !== newValue) {
            target.selectionEnd = target.selectionStart =
                selectionStart - (value.length - newValue.length)
        }
    })      
}, true)

I haven’t really tested this that much though, I suspect there are some cases where it doesn’t do “the right thing”. Still it seems so simple and more correct than whatever the browser is currently doing, I wonder why this is not just the default…


Every time I deal with “custom” controls and forms in browsers I think about just replacing HTML alltogether

1 Like

That’s very interesting to know. Is this 100% guaranteed? Elm doesn’t check that 16.7ms have passed, so it needs to update the screen in order to maintain framerate for example?

1 Like

Great replies. Thank you to all.


@jessta

That’s good to know. You’ve filled an important gap in my understanding of the Elm runtime timeline.


@Atlewee

Is this a learning curve I see before me? :slight_smile:

Thank you, an interesting idea. Especially the tantalising prospect to “create that custom element in a way that does not move the cursor to the end on updates.”.

One of the benefits of Elm, if I understand correctly, is that it compiles to old JavaScript and therefore runs on quite old browsers. Looking at caniuse.com makes me a little wary of custom elements.


@joakin

Brilliant. Many thanks.


@lydell

That’s neat. Do it all in JS, with a well supported event listener (input) and a class to decide which boxes to apply it to :slight_smile:


@jreusch

Thank you, that’s what I’m doing, just without the requestAnimationFrame — which I will now add having learnt from the replies here that that’s how to get the timing right.

It’s good to know others have largely thought it through the same way as me.

1 Like

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.