1

It's not a big deal to focus on first invalid form field by querying the DOM. Here are good answers how to do that:

Set focus on first invalid input in AngularJs form

Focus on the first field that is .ng-invalid at Submit - not working for radios-inline

The reason why they don't work for me is that I want to produce a unit-testable solution decoupled from DOM.

The second reason is that I want to code more declarative-way (if this is worth the effort, of course).

I was trying to use a ready autofocus directive, but you can propose your own similar solution.

Community
  • 1
  • 1
Dan
  • 55,715
  • 40
  • 116
  • 154

1 Answers1

0

Great idea to make a unit testable focusable form.

Solution angularjs < 1.6

HTML :

<div ng-app="myApp">
  <form novalidate focus-first>
    <div>
      <input focusable ng-model="f1" name="f1" type="text" required />
    </div>
    <input focusable ng-model="f2" name="f2" type="text" required />
    <input type="submit" />
  </form>
</div>

JS :

const app = angular.module('myApp', []);

app.directive('focusable', () => ({
  restrict: 'A', 
  require: '?ngModel',
  link: function(scope, element, attrs, ngModel) {
    ngModel.$$boundElement = element;
  }
}));

app.directive('focusFirst', () => ({
  restrict: 'A',
  link: (scope, element, attrs) => {
    element.on('submit', (event) => {
      event.preventDefault();
      const formCtrl = angular.element(event.target).controller("form");
      const errorKeys = Object.keys(formCtrl.$error);
      if (errorKeys.length > 0) {      
        errorKeys.forEach((errorKey) => {
          // Pretty ugly, it needs to be improved
          const boundElement = formCtrl.$error[errorKeys[0]][0].$$boundElement[0];
          if (boundElement) {
            boundElement.focus();
            // boundElement.scrollIntoView(); // Drive Fiddle crazy
          }
        });
      } else {
        alert('OK !!!');
      }      
    })
  }
}));

Here is the fiddle : https://jsfiddle.net/jtassin/60gwL3eh/3/ This way you will avoid to use the scope in a focusMe directive.

Solution angularjs > 1.6

If you are using angularjs > 1.6, You can also use $$controls to get the inputs and you won't need the focusable directive anymore. Here is the angular 1.6+ fiddle : https://jsfiddle.net/jtassin/60gwL3eh/5/

HTML :

<div ng-app="myApp">
  <form novalidate focus-first>
    <div>
      <input ng-model="f1" name="f1" type="text" required />
    </div>
    <input ng-model="f2" name="f2" type="text" required />
    <input type="submit" />
  </form>
</div>

JS :

const app = angular.module('myApp', []);

app.directive('focusFirst', () => ({
    restrict: 'A',
  link: (scope, element, attrs) => {
    element.on('submit', (event) => {
      event.preventDefault();
      const formCtrl = angular.element(event.target).controller("form");
      formCtrl.$$controls.some((input) => {
        if (input.$invalid) {
          input.$$element[0].focus();
          return true;
        }
      });            
    })
  }
}));

In both solutions, during unit tests you can mock focus method of elements to check the focus call.

Julien TASSIN
  • 5,004
  • 1
  • 25
  • 40
  • I've also noticed that $$controls contains an array of inputs on the form. But this is an undocumented feature – Dan Mar 17 '17 at 13:15
  • You're right. I Think we can use it to avoid the focusable directive. Here is an exemple of code found in angularjs source code ([FormController.$setPristine](https://github.com/angular/angular.js/blob/03043839d5a540b02208001fe12e812dfde00a8e/src/ng/directive/form.js#L237)). `forEach(this.$$controls, function(control) { control.$setPristine(); });`. $$controls seems to have been added in [september 2016](https://github.com/angular/angular.js/commit/9e24e774a558143b3478536911a3a4c1714564ba) – Julien TASSIN Mar 17 '17 at 20:29
  • Great, it works like a charm. I updated the fiddle : https://jsfiddle.net/jtassin/60gwL3eh/4/ – Julien TASSIN Mar 17 '17 at 20:39