1

I have a form with a dynamic number of inputs, controlled by AngularJS.

<body ng-app="mainApp" ng-controller="CreatePollController" ng-init="init(3)">
    <form id="createPollForm">
        <input class="create-input" ng-repeat="n in questions" id="q_{{$index}}" name="q_{{$index}}" type="text" ng-keypress="createInputKeypress($event);"/>
        <a href="javascript:void(0);" ng-click="addQuestion()">Add Question</a>
    </form>
</body>

This is being controlled by the following angular code:

app.controller('CreatePollController', function($scope) {
    $scope.questions = [];

    $scope.init = function(numOfInputs){
        for(var i = 0; i < numOfInputs; i++){
            $scope.questions.push({
                "questionText":""
            });
        }
    };

    $scope.addQuestion = function(){
        $scope.questions.push({
            "questionText":""
        });
    };

    $scope.createInputKeypress = function(e){
        if(e.keyCode === 13){
            e.preventDefault();

            var idx = Number(e.target.id.replace("q_", ""));
            if(idx === this.questions.length - 1){
                this.addQuestion();
            }

            // Wait for angular update ????

            var nextId = "#q_" + (++idx);
            $(nextId).focus();
        }
    };
});

Currently, when the user hits the Enter key while focused on a text input, the createInputKeypress function is called and the browser focuses the next input in the form. However, if you are currently focused on the last element in the form, it adds a new question to the questions array, which will cause another input to be generated in the DOM.

However, when this new element is created, the focus() call isn't working. I suspect this is because angular doesn't add the new element right away, so trying to use jQuery to locate and focus the new element isn't working.

Is there a way to wait for the DOM to be updated, and THEN focus the new element?

jros
  • 714
  • 1
  • 10
  • 33

1 Answers1

2

As you might already know, javascript is turn based, that means that browsers will execute JS code in turns (cycles). Currently the way to prepare a callback in the next javascript cycle is by setting a callback with the code we want to run on that next cycle in a timeout, we can do that by calling setTimeout with an interval of 0 miliseconds, that will force the given callback to be called in the next javascript turn, after the browser finishes (gets free from) the current one.

Trying to keep it simple, one browser cycle executes these actions in the given order:

  1. Scripting (where JS turn happen)
  2. Rendering (HTML and DOM renderization)
  3. Painting (Painting the rendered DOM in the window)
  4. Other (internal browser's stuff)

Take a look at this example:

console.log(1);
console.log(2);

setTimeout(function () {
    console.log(3);
    console.log(4);
}, 0);

console.log(5);
console.log(6);

/** prints in the console
 * 1 - in the current JS turn 
 * 2 - in the current JS turn
 * 5 - in the current JS turn
 * 6 - in the current JS turn
 * 3 - in the next JS turn
 * 4 - in the next JS turn
 **/

3 and 4 are printed after 5 and 6, even knowing that there is no interval (0) in the setTimeout, because setTimeout basically prepares the given callback to be called only after the current javascript turn finishes. If in the next turn, the difference between the current time and the time the callback was binded with the setTimeout instruction is lower than the time interval, passed in the setTimeout, the callback will not be called and it will wait for the next turn, the process repeats until the time interval is lower than that difference, only then the callback is called!

Since AngularJS is a framework wrapping all our code, angular updates generally occur after our code execution, in the end of each javascript turn, that means that angular changes to the HTML will only occur after the current javascript turn finishes.

AngularJS also has a timeout service built in, it's called $timeout, the difference between the native setTimeout and angular's $timeout service is that the last is a service function, that happens to call the native setTimeout with an angular's internal callback, this callback in its turn, is responsible to execute the callback we passed in $timeout and then ensure that any changes we made in the $scope will be reflected elsewhere! However, since in our case we don't actually want to update the $scope, we don't need to use this service, a simple setTimeout happens to be more efficient!

Knowing all this information, we can use a setTimeout to solve our problem. like this:

$scope.createInputKeypress = function(e){
    if(e.keyCode === 13){
        e.preventDefault();

        var idx = Number(e.target.id.replace("q_", ""));
        if(idx === this.questions.length - 1){
            this.addQuestion();
        }

        // Wait for the next javascript turn
        setTimeout(function () {
            var nextId = "#q_" + (++idx);
            $(nextId).focus();
        }, 0);
    }
};

To make it more semantic, we can wrap the setTimeout logic in a function with a more contextualized name, like runAfterRender:

function runAfterRender (callback) {
    setTimeout(function () {
        if (angular.isFunction(callback)) {
            callback();
        }
    }, 0);
}

Now we can use this function to prepare code execution in the next javascript turn:

app.controller('CreatePollController', function($scope) {
    // functions
    function runAfterRender (callback) {
        setTimeout(function () {
            if (angular.isFunction(callback)) {
                callback();
            }
        }, 0);
    }

    // $scope
    $scope.questions = [];

    $scope.init = function(numOfInputs){
        for(var i = 0; i < numOfInputs; i++){
            $scope.questions.push({
                "questionText":""
            });
        }
    };

    $scope.addQuestion = function(){
        $scope.questions.push({
            "questionText":""
        });
    };

    $scope.createInputKeypress = function(e){
        if(e.keyCode === 13){
            e.preventDefault();

            var idx = Number(e.target.id.replace("q_", ""));
            if(idx === this.questions.length - 1){
                this.addQuestion();
            }

            runAfterRender(function () {
                var nextId = "#q_" + (++idx);
                $(nextId).focus();
            });
        }
    };
});
Daniel
  • 687
  • 4
  • 10