2

I'm trying to customize the behavior of the HTML5 validation. I want invalid fields to scroll to the middle of the page when the user hits submit. My first attempt was to do this but it doesn't work:

    $.each($("input, select"), function (index, input) {
        input.addEventListener("invalid", function () {
            this.scrollIntoView({
                block: 'center',
                behavior: 'smooth'
            });
        });
    });

https://jsfiddle.net/gib65/j1ar87yq/

Calling scrollIntoView(...) with block center not working

It doesn't work because the default scrolling behavior, which is to scroll the invalid field to the top of the page instantly (i.e. not smooth), overrides the behavior I'm trying to specify in the scrollIntoView(...) options.

So then I tried this:

    $.each($("input, select"), function (index, input) {
        input.addEventListener("invalid", function (e) {
            e.preventDefault();
            this.scrollIntoView({
                block: 'center',
                behavior: 'smooth'
            });
        });
    });

https://jsfiddle.net/gib65/527u1cm3

Adding preventDefault() allows the scrolling behavior I specified to happen. But it also prevents the invalid field from focusing and the validation message from appearing.

So my question is: is there a way to get the validation message to pop up WITHOUT scrolling?

Something like this:

    $.each($("input, select"), function (index, input) {
        input.addEventListener("invalid", function (e) {
            e.preventDefault();
            this.scrollIntoView({
                block: 'center',
                behavior: 'smooth'
            });
            this.focus();
            this.showValidationMessage();
        });
    });

I've tried reportValidity() but that triggers the whole validation process which of course defeats the purpose (i.e. it will invoke the default scrolling which overrides my custom scrolling). I've also tried checkValidity() but this results in a "Maximum call stack size exceeded" probably because it fires the "invalid" event which causes the listener to pick it up again and repeat indefinitely.

Any help would be appreciated. Thanks.

gib65
  • 1,709
  • 3
  • 24
  • 58

2 Answers2

2

There doesn't seem to be any way to block only the scrolling of the default behavior, and actually, even if there was, the different behaviors of UAs may make this an inappropriate option: Chrome does hide the tooltip when you scroll away of the invalid element, so it wouldn't be shown, Firefox uses a fixed position for this tooltip and thus the initial positioning would be off...

So the only solution I can think of is indeed to fire manually the reportValidity, once the smooth scroll has finished.

There is unfortunately no built-in event that will fire when this occurs, so we need to come up with a handmade solution.
We could just set up a timeout, but that's unreliable:
There is no standard as to what time it should take to perform this operation, for instance in Chrome, that time is relative to the amount of scrolling required (i.e it's more a speed than a duration) with an hard duration limit of 3s (oO).

And if reportValidity's scroll happens before your smooth one completes, then it will jump directly with a behavior: "nearest" mode, breaking your script:

const input = document.querySelector('input');
input.oninvalid = (evt) => {
  if( input._forced_invalidity ) {
    input._forced_invalidity = false;
    return;
  }
  evt.preventDefault();
  
  input.scrollIntoView({
    behavior: "smooth",
    block: "center"
  } )
  setTimeout(() => {
    input._forced_invalidity = true;
    input.reportValidity();  
  }, 200 );  // an hard coded value
};
p {
  height: 1000vh;
  width: 5px;
  background: repeat 0 0 / 5px 10px
    linear-gradient(to bottom, black 50%, white 50%);
}
<form id="myform" action="">
  <button>send</button>
  <p></p>
  <input type="text" required>
  <p></p>
  <button>send</button>
</form>

Here I'll use a detector I made, which will check every frame if the position of our element has changed. As soon as it didn't happen in two frames, it will resolve a Promise. That's still not 100% bullet proof, but that should be better than an hardcoded timeout.

An other complication is that there doesn't seem to be anything letting us know if the event that got fired was triggered by the call to reportValidity or by the form's submit action.
(Actually in Firefox we can check for their non-standard explicitOriginalTarget property, but that's just for FF...)
So we need to raise a flag before calling this reportValidity method. That becomes dirty, but it's required...

const input = document.querySelector('input');
input.oninvalid = (evt) => {
  // that's our flag, see below
  if( input._forced_invalidity ) {
    input._forced_invalidity = false;
    return;
  }
  evt.preventDefault();
  
  smoothScroll( input, {
    block: "center"
  } )
  .then( () => {
    // raise our flag so we know we should not be blocked
    // and we should not fire a new scroll + reportValidity infinitely
    input._forced_invalidity = true;
    input.reportValidity();  
  } );  
};


// Promised based scrollIntoView( { behavior: 'smooth' } )
function smoothScroll( elem, options ) {
  return new Promise( (resolve) => {
    if( !( elem instanceof Element ) ) {
      throw new TypeError( 'Argument 1 must be an Element' );
    }
    let same = 0;
    let lastPos = null;
    const scrollOptions = Object.assign( { behavior: 'smooth' }, options );
    elem.scrollIntoView( scrollOptions ); 
    requestAnimationFrame( check );
    
    function check() {
      const newPos = elem.getBoundingClientRect().top;
      if( newPos === lastPos ) {
        if(same ++ > 2) {
          return resolve();
        }
      }
      else {
        same = 0;
        lastPos = newPos;
      }
      requestAnimationFrame(check);
    }
  });
}
p {
  height: 400vh;
  width: 5px;
  background: repeat 0 0 / 5px 10px
    linear-gradient(to bottom, black 50%, white 50%);
}
<form id="myform" action="">
  <button>send</button>
  <p></p>
  <input type="text" required>
  <p></p>
  <button>send</button>
</form>
Kaiido
  • 123,334
  • 13
  • 219
  • 285
  • Thanks Kaiido. Even though I went with Artur's solution, yours looks like it would work just as well. I'll save it in case I want to try it out in the future. – gib65 Sep 09 '19 at 19:39
  • @gib65 beware though their timeout solution is far from being reliable. At least in Chrome, the scrolling time is not determined by a constant duration, but more by a speed, i.e it's relative to what has to be scrolled. An hardcoded timeout may very fail. – Kaiido Sep 10 '19 at 01:06
1

On Chrome, the default scroll does not happen when calling reportValidity(). So the issue comes up only with the repetitive calls, to which we can always remove the listener and rebind it:

function listener(e) {
   e.preventDefault();
   this.scrollIntoView({
      behavior: 'smooth',
      block: 'center'
   });
   this.focus();
   //removing the listener here
   $("#validateMe")[0].removeEventListener("invalid", listener);
   this.reportValidity();
   $("#validateMe")[0].addEventListener("invalid", listener);

}

$("#validateMe")[0].addEventListener("invalid", listener);
$("form").submit(function () {
   console.log("submitting");
});

This is kinda of a hack... it's gonna get worse though

That does not happen on FireFox or Edge, so we need a different solution... Which I can honestly not find anywhere on the Web. Even in the official doc for HTML5, there is no work on calling the info-bubble separately. I did however experiment with timeouts, which I always turn to when things happen too fast:

function listener(e) {
   e.preventDefault();
   this.scrollIntoView({
      behavior: 'smooth',
      block: 'center'
   });
   //removing the listener here
   $("#validateMe")[0].removeEventListener("invalid", listener);
   setTimeout(() => {
        this.focus();
        this.reportValidity();
        $("#validateMe")[0].addEventListener("invalid", listener);
   }, 500);
}

$("#validateMe")[0].addEventListener("invalid", listener);
$("form").submit(function () {
   console.log("submitting");
});

This solution works on Chrome and Firefox. Not Edge though. It also works only depending on the speed of the scroll and the timeout placed. So, if you put the timeout at a lower value, the error message will pop earlier and freeze before the browser is done scrolling to your input (unless you're on Chrome, which clearly shows that things are implemented fairly differently on these two).

Even though this is not a conclusive answer, I'd say you're better off doing something manual and not native to html5. It doesn't seem to have been thought out to be THE error feedback to end them all. Go with a plug-in, framework, or try some css magic like so.

Artur
  • 334
  • 5
  • 15
  • lol you hacked it to the core. I have a solution to your setTimeout though change method setTimeout to `requestAnimationFrame` and remove the 1000 – joyBlanks Sep 09 '19 at 02:15
  • `requestAnimationFrame(() => { this.focus(); this.reportValidity(); $("#validateMe")[0].addEventListener("invalid", listener); });` – joyBlanks Sep 09 '19 at 02:18
  • @joyBlanks The setTimeout is there not because I want to wait a frame - it's because he wants the scrolling animation to be smooth. That is why the setTimeout is set to 500 now, because I fine tuned it to his fiddle. Though I will take note requestAnimatonFrame - can be pretty useful. – Artur Sep 09 '19 at 02:19
  • when you put inside requestAnimation frame after the scrolling has been completed it will invoke your result. say for example the scrolling was done <500ms you have to wait rest of the time to trigger your validation with RAF it will be done once the scrolling is complete. Agree? – joyBlanks Sep 09 '19 at 02:22
  • Maybe I was using requestAnimationFrame wrong [over here](https://jsfiddle.net/h9jm34ko/4/), but it does not wait for the scroll to finish on Chrome. – Artur Sep 09 '19 at 02:26
  • yup looks like you are right. It doesn't work. never mind, but you hacked it up pretty good bro – joyBlanks Sep 09 '19 at 02:33
  • 2
    Thanks Artur, that works like a charm. I also found this for listening for when scrolling stops: https://stackoverflow.com/questions/46795955/how-to-know-scroll-to-element-is-done-in-javascript – gib65 Sep 09 '19 at 19:37
  • Tis a pleasure! Nice find btw, will definitly work well for your case. – Artur Sep 09 '19 at 20:03