|
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' |
|
); |
|
} |