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">&#8634;</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);
// ...
}
// ...
}