Extra Functionality
Improving Error Highlighting
In the code presented so far we've only added basic handling for errors, where we only present errors to the user when they attempt to submit their details. Here we add extra handling to highlight errors both on the elements themselves, as well as focusing on those errors when the user attempts to submit the form while errors are present.
refresh
We'll first update the refresh
method on StringInput
; the same
implementation will be inherited by all of the other components. refresh
will
be called whenever our inputs change, so in this method we will validate the
current value, and possibly highlight errors if they're present:
class StringInput {// ...// `refresh` updates the UI of this field based on its current and remote// value.+ //+ // Passing `false` for `highlightErrors` allows us to avoid highlighting+ // errors until the user has finished editing the field.- refresh() {+ refresh(highlightErrors) {this.statusEl.innerText = statusSymbols[this.remoteState.status];+ if (!this.isValueChanged()) {+ this.showElement(this.statusEl);++ if (highlightErrors && this.remoteState.status === 'invalid') {+ this.highlightVerificationError("value isn't valid");+ } else {+ this.clearErrors();+ }+ return;+ }++ this.hideElement(this.statusEl);++ const result = this.validatedValue();+ if (highlightErrors && result.error) {+ this.highlightValidationError(result.error);+ } else {+ this.clearErrors();+ }}}
The first piece to notice is that we now provide a highlightErrors
parameter.
This allows us to suppress errors while the field is being edited, but to then
render errors when editing has finished.
Second, if the value of the input field is the same as the remote value of the field, then we'll output the remote status of the field, as well as any error the server may have provided for the field.
If the value of the input field isn't the same as the remote value then we hide
the status, and call validatedValue
to check for errors. If an error is found,
and editing has finished (based on highlightErrors
), then we highlight the
error.
We now define the helpers used by refresh
. These will generally be overridden
by subclasses to handle the different kinds of inputs.
class StringInput {// ...isValueEmpty() {return this.inputEl.value === "";}isValueChanged() {return this.inputEl.value !== this.remoteState.value;}clear() {for (const inputEl of this.inputEls) {inputEl.value = "";}}highlightVerificationError() {this.statusEl.classList.add('error-highlight');this.issueEl.innerText = this.renderVerificationError();this.issueEl.classList.remove('hidden');}renderVerificationError() {return `'${this.inputEl.value}' couldn't be verified`;}clearErrors() {this.statusEl.classList.remove('error-highlight');for (const inputEl of this.inputEls) {inputEl.classList.remove('error-highlight');}this.issueEl.innerText = symbols.NBSP;this.issueEl.classList.add('hidden');}}
Some basic CSS rules for the error-highlight
class can be the following:
.field input.error-highlight,.field select.error-highlight {box-shadow: inset 0 0 0 2px red;}.field .status.error-highlight {color: red;}
We use box-shadow
here instead of border
, because border
changes the size
of the element and can cause other elements on the page to shift, but
box-shadow
doesn't change the size of the element.
Listeners
Now that we have updated refresh
to handle errors, we add event handlers to
listen for input changes:
class StringInput {// ...setup() {+ for (const inputEl of this.inputEls) {+ inputEl.addEventListener("input", (e) => {+ e.preventDefault();+ this.refresh(false);+ });++ inputEl.addEventListener("blur", (e) => {+ e.preventDefault();+ this.refresh(true);+ });+ }this.update(this.remoteState);}// ...}
We add two handlers here. When any change is made to one of the inputs then we
refresh
the rendering of the component, mainly to clear any error that may
have been present previously. We pass false
for highlightErrors
when the
focus is on the element (i.e. while input is changing, based on the input
event), but we pass true
for highlightErrors
when the focus moves away from
the element (i.e. the blur
event), which we take as a signal that editing has
finished.
Note that, because we've used this.inputEls
to abstract over the input
elements, we don't need to re-implement this method for the different component
classes.
Form Submit
Now that we have inline error highlighting, it benefits us to block form submission while inline errors are being shown, and to bring attention to those errors. To achieve this, we replace our error alert with a call to a new method:
async function onSubmit(e) {// ...if (result.error) {- alert(`'${key}' is invalid: ${result.error}`);+ field.highlight();return;
Now we'll implement this new highlight
method on StringInput
:
class StringInput {// ...highlight() {this.inputEls[0].scrollIntoView();for (const inputEl of this.inputEls) {this.shakeElement(inputEl, 25, 10);}}shakeElement(el, speed, duration) {function shake() {duration -= 1;if (duration > 0) {if (duration % 2 === 0) {el.style['margin-left'] = '-2px';el.style['margin-right'] = '2px';} else {el.style['margin-left'] = '2px';el.style['margin-right'] = '-2px';}} else {el.style['margin-left'] = 0;el.style['margin-right'] = 0;}if (duration > 0) {setTimeout(shake, speed);}};setTimeout(shake, speed);}}
Simply put, when we highlight the component then we scroll the page until it
comes into view, and then apply a short "shake" animation to its input elements
to bring attention to them. At this point, the error highlighting will still be
applied to the component from a previous call to refresh
, so we don't need to
do anything extra here to highlight the error.
Resetting Fields
A basic feature that can be added for these fields is the ability to reset the input to its last value. We start with the HTML to add a reset button:
<div class="field string"><!-- ... --><input type="text" id="first-name" name="first-name" />+ <button class="reset" tabindex="-1" type="button" id="first-name-reset">↺</button><!-- ... --></div>
We use tabindex="-1"
so that the reset button won't be highlighted when moving
through fields with Tab, but instead Tab will continue to
move the user from one input field to the next.
Next we add a reference to the element to our constructor:
class StringInput {constructor(id, remoteState) {// ...this.issueEl = document.getElementById(id + '-issue');+ this.resetEl = document.getElementById(id + '-reset');this.inputEls = [this.inputEl];}}
Now we add the handler for the new resetEl
, from which we simply add a
call to our existing reset()
method:
class StringInput {// ...setup() {// ...this.resetEl.addEventListener("click", (e) => {e.preventDefault();this.reset();this.refresh(true);});this.update(this.remoteState);}
As a final UI improvement, we also add logic to hide the reset button when the value is unchanged, and to show it when the value has changed. This is based on error handling improvements added in the previous section:
class StringInput {// ...refresh(highlightErrors) {this.statusEl.innerText = statusSymbols[this.remoteState.status];if (!this.isValueChanged()) {this.showElement(this.statusEl);+ this.hideElement(this.resetEl);// ...}this.hideElement(this.statusEl);+ this.showElement(this.resetEl);// ...}// ...}