Skip to content

Instantly share code, notes, and snippets.

@jdanyow
Last active May 19, 2021 18:42
Show Gist options
  • Save jdanyow/15269c6dccfa228edbb8bac645e7ccab to your computer and use it in GitHub Desktop.
Save jdanyow/15269c6dccfa228edbb8bac645e7ccab to your computer and use it in GitHub Desktop.

Code to tack on custom validation UX to HTML annotated with HTML5 validation attributes as well as custom attributes for validations specific to our domain (eg max tag count).

Error alert is generated, each error links to the form input that is invalid.

invalid

This code is coupled to the design system's structure for form html and custom validation. In this case it's primer: https://primer.style/css/components/forms#error

<form action="/hello/world" method="POST" novalidate="">
  <!-- no need to enclose the content of the form with the custom element -->
  <form-behavior new></form-behavior>
  
  <!-- wrapping in a fieldset makes it easy to disable the entire form by disabling the fieldset -->
  <fieldset class="row gap-3">
    <!-- primer form-group markup -->
    <div class="form-group ">
      <div class="form-group-header">
        <label for="s-0">Name</label>
      </div>
      <div class="form-group-body">
        <input class="form-control" id="s-0" name="name" type="text" value="" autocomplete="name" autocapitalize="words" maxlength="100" required="" autofocus="">
      </div>
    </div> 
    <div class="form-group ">
      <div class="form-group-header">
        <label for="s-1">Email</label>
      </div>
      <div class="form-group-body">
        <input class="form-control" id="s-1" name="email" type="email" value="" autocomplete="email" minlength="4" maxlength="256" required="">
      </div>
    </div> 
    <div>
      <button class="btn btn-primary" type="submit">Submit</button>
    </div>
  </fieldset>
</form>
import { html, newElementId, render } from '...';
import { HTMLValueElement, isValueElement } from './utilities';
function getLabel(input: HTMLValueElement): string {
const label =
input.labels && input.labels.length
? input.labels[0].textContent
: input.getAttribute('aria-label');
if (!label) {
throw new Error(
`${input.nodeName} name="${input.name}" id="${input.id}" has no associated label.`
);
}
return label.trim();
}
function getGroup(input: HTMLValueElement) {
const group = input.closest<HTMLElement>('.form-group');
if (!group) {
throw new Error(
`${input.nodeName} name="${input.name}" id="${input.id}" is not within a .form-group`
);
}
return group;
}
function getBody(input: HTMLValueElement) {
const body = input.closest('.form-group-body');
if (!body) {
throw new Error(
`${input.nodeName} name="${input.name}" id="${input.id}" is not within a .form-group-body`
);
}
return body;
}
function createErrorAlert(form: HTMLFormElement) {
const formLayout = form.querySelector('.row') || form;
const id = newElementId();
formLayout.insertAdjacentHTML(
'afterbegin',
render(html`<div
class="flash flash-error py-2 span-12"
role="group"
tabindex="-1"
aria-labelledby="${id}"
hidden
>
<strong id="${id}">There's a problem</strong>
<ul class="pl-3" aria-label="Validation errors"></ul>
</div>`)
);
const errorAlert = formLayout.firstElementChild as HTMLElement;
const errorList = errorAlert.lastElementChild as HTMLElement;
return { errorAlert, errorList };
}
function getErrorAlert(form: HTMLFormElement) {
const errorAlert = form.querySelector<HTMLElement>('.flash.flash-error');
if (errorAlert) {
return {
errorAlert,
errorList: errorAlert.lastElementChild as HTMLElement
};
}
return createErrorAlert(form);
}
function createErrorNote(input: HTMLValueElement) {
const note = document.createElement('p');
note.id = newElementId();
input.setAttribute(
'aria-describedby',
`${note.id} ${input.getAttribute('aria-describedby') || ''}`
);
note.classList.add('note', 'error');
getBody(input).after(note);
return note;
}
function setValidationMessage(element: HTMLValueElement, message: string) {
const group = getGroup(element);
const note = group.querySelector('.note.error') || createErrorNote(element);
note.textContent = message;
}
export interface FormValidationError {
message: string;
input: HTMLValueElement;
}
export type FormValidationResult =
| { valid: true }
| { valid: false; errors: FormValidationError[] };
type Validator = (input: HTMLValueElement, label: string) => string | null;
function validateRequired(
input: HTMLValueElement,
label: string
): string | null {
if (input.validity.valueMissing) {
return `${label} is required.`;
}
return null;
}
function validateStep(input: HTMLValueElement, label: string): string | null {
if (input.validity.stepMismatch) {
const step = input.getAttribute('step');
if (step === '1') {
return `${label} does not permit fractions.`;
}
return `${label} must be a multiple of ${step}.`;
}
return null;
}
function validateMaxTags(
input: HTMLValueElement,
label: string
): string | null {
if (
input.validity.valid &&
input.hasAttribute('validate-max-tags') &&
input.value &&
!getTags(input.value) > parseInt(input.getAttribute('validate-max-tags'))
) {
return `Too many tags.`;
}
return null;
}
function validateEmail(input: HTMLValueElement, label: string): string | null {
if (
input.validity.valid &&
input.type === 'email' &&
input.value &&
input.validity.typeMismatch
) {
return `${label} is invalid.`;
}
return null;
}
function validateMinLength(
input: HTMLValueElement,
label: string
): string | null {
if (
(input instanceof HTMLTextAreaElement ||
input instanceof HTMLInputElement) &&
input.validity.tooShort
) {
return `${label} must be at least ${input.minLength} characters.`;
}
return null;
}
function validateMaxLength(
input: HTMLValueElement,
label: string
): string | null {
if (
(input instanceof HTMLTextAreaElement ||
input instanceof HTMLInputElement) &&
input.validity.tooLong
) {
return `${label} cannot be longer than ${input.maxLength} characters.`;
}
return null;
}
const validators: Validator[] = [
validateRequired,
validateStep,
validateMinLength,
validateMaxLength,
validateMaxTags,
validateEmail
];
function canValidate(target: EventTarget | null): target is HTMLValueElement {
return isValueElement(target) && target.type !== 'hidden';
}
export async function validateForm(
form: HTMLFormElement,
displayValidity = true,
scope: Element = form
): Promise<FormValidationResult> {
const errors: FormValidationError[] = [];
const { errorAlert, errorList } = getErrorAlert(form);
if (displayValidity) {
errorAlert.hidden = true;
errorList.innerHTML = '';
}
for (const input of form.elements) {
if (!scope.contains(input) || !canValidate(input)) {
continue;
}
const label = getLabel(input);
const group = getGroup(input);
if (displayValidity) {
setValidationMessage(input, '');
group.classList.remove('errored');
}
for (const validator of validators) {
const message = validator(input, label);
if (!message) {
continue;
}
errors.push({ input, message });
if (displayValidity) {
setValidationMessage(input, message);
group.classList.add('errored');
errorList.insertAdjacentHTML(
'beforeend',
render(
html`<li>
<a class="Link--error" href="#${input.id}">${message}</a>
</li>`
)
);
}
break;
}
}
if (errors.length === 0) {
return { valid: true };
}
if (displayValidity) {
errorAlert.hidden = false;
errorAlert.focus();
}
return { valid: false, errors };
}
export function clearValidationErrors(target: EventTarget | null) {
if (!canValidate(target)) {
return;
}
setValidationMessage(target, '');
getGroup(target).classList.remove('errored');
const { errorAlert, errorList } = getErrorAlert(target.form!);
errorList
.querySelectorAll(`a[href="#${target.id}"]`)
.forEach(a => a.parentElement!.remove());
if (!errorList.firstElementChild) {
errorAlert.hidden = true;
}
}
export function displayServerValidationError(form: HTMLFormElement, body: any) {
if (!isValidationErrorBody(body)) {
return;
}
const { property, message } = body;
const { errorAlert, errorList } = getErrorAlert(form);
if (property) {
const input = form.querySelector(`[name="${property}"]`);
if (isValueElement(input)) {
setValidationMessage(input, message);
getGroup(input).classList.add('errored');
errorList.insertAdjacentHTML(
'beforeend',
render(
html`<li>
<a class="Link--error" href="#${input.id}">${message}</a>
</li>`
)
);
}
} else {
errorList.insertAdjacentHTML(
`beforeend`,
render(html`<li>${message}</li>`)
);
}
errorAlert.hidden = false;
errorAlert.focus();
}
// utils for server validation errors
export const validationErrorBodyType = 'validation-error';
export interface ValidationError {
/** Error code. */
code: ValidationErrorCode;
/** User-facing message */
message: string;
/** Property associated with the error */
property?: string;
}
export interface ValidationErrorBody extends ValidationError {
/** Body type discriminator. */
type: typeof validationErrorBodyType;
/** Detailed contextual information. */
details?: object;
}
export function isValidationErrorBody(obj: any): obj is ValidationErrorBody {
return (
obj &&
obj.type === validationErrorBodyType &&
typeof obj.code === 'string' &&
typeof obj.message === 'string'
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment