simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit b29468f52db172037b861e7c6a907d2d66dfaf11
parent 5d1c5a978a2a638cb40796798d8053d9a3ea934e
Author: Silas Brack <silasbrack@gmail.com>
Date:   Wed, 10 Jun 2026 22:52:50 +0200

feat: add validation-enhancer web component for inline form validation

Vendor validation-enhancer v1.0.3 and wire it into login, register,
submit, and edit forms with aria-errormessage targets and custom
validation messages. Adds .field-error, .invalid, and .valid CSS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
Msrc/routes.rs | 3+++
Mstatic/style.css | 19+++++++++++++++++++
Mstatic/style.css.gz | 0
Astatic/validation-enhancer.js | 396+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtemplates/application.html | 1+
Mtemplates/edit.html | 5++++-
Mtemplates/login.html | 30+++++++++++++++++-------------
Mtemplates/register.html | 50++++++++++++++++++++++++++++----------------------
Mtemplates/submit.html | 5++++-
9 files changed, 472 insertions(+), 37 deletions(-)

diff --git a/src/routes.rs b/src/routes.rs @@ -14,6 +14,7 @@ use crate::state::AppState; const STYLE_CSS: &[u8] = include_bytes!("../static/style.css"); const STYLE_CSS_GZ: &[u8] = include_bytes!("../static/style.css.gz"); const DATASTAR_JS: &[u8] = include_bytes!("../static/datastar.js"); +const VALIDATION_ENHANCER_JS: &[u8] = include_bytes!("../static/validation-enhancer.js"); async fn serve_style(headers: axum::http::HeaderMap) -> Response { // Check if client accepts gzip @@ -51,6 +52,7 @@ async fn serve_js(body: &'static [u8]) -> Response { } async fn serve_datastar() -> Response { serve_js(DATASTAR_JS).await } +async fn serve_validation_enhancer() -> Response { serve_js(VALIDATION_ENHANCER_JS).await } async fn static_404() -> impl IntoResponse { (StatusCode::NOT_FOUND, "Not found") @@ -108,6 +110,7 @@ pub fn create_router(state: AppState) -> Router { // Static files .route("/static/style.css", get(serve_style)) .route("/static/datastar.js", get(serve_datastar)) + .route("/static/validation-enhancer.js", get(serve_validation_enhancer)) .route("/static/{*path}", get(static_404)) .with_state(state) } diff --git a/static/style.css b/static/style.css @@ -544,6 +544,25 @@ a:hover { color: var(--color-accent); } font-size: 13px; } +.field-error { + color: var(--color-error); + font-size: 12px; + margin-top: 4px; + min-height: 0; +} + +input.invalid, +textarea.invalid, +select.invalid { + border-color: var(--color-error); +} + +input.valid, +textarea.valid, +select.valid { + border-color: var(--color-success); +} + .empty-message { color: var(--color-text-secondary); font-style: italic; diff --git a/static/style.css.gz b/static/style.css.gz Binary files differ. diff --git a/static/validation-enhancer.js b/static/validation-enhancer.js @@ -0,0 +1,396 @@ +/** + * validation-enhancer v1.0.3 + * https://www.npmjs.com/package/validation-enhancer + * + * Custom element that displays inline constraint-validation feedback. + * Applies `.valid` / `.invalid` classes to each input and scrolls the + * first invalid input into view when the user tries to submit. + * + * Usage: wrap a `<form>` with `<validation-enhancer>`. + * + * Each input's error text is rendered inside the element referenced by + * that input's `aria-errormessage` attribute (customisable via + * `message-target-attr`). + * + * Per-input custom messages are supported via `validation-[validityKey]` + * or `data-validation-[validityKey]` attributes, for example + * `validation-valueMissing="Please fill this out"`. The prefix defaults + * to `"validation"` and can be changed with the `message-prefix` attribute. + * + * See the constraint-validation API for supported validity keys and elements: + * https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Form_validation#the_constraint_validation_api + * + * Note: when changing a input's value in code, dispatch a bubbling change + * event so this component picks it up: + * `input.dispatchEvent(new Event('change', { bubbles: true }))` + */ +class ValidationEnhancer extends HTMLElement { + // Class applied to a valid input, default `valid` + get validClass() { + return this.getAttribute("valid-class") || "valid"; + } + // Class applied to an invalid input, default `invalid` + get invalidClass() { + return this.getAttribute("invalid-class") || "invalid"; + } + // The name of the attribute of the input element that holds the id of the error message element + // default `aria-errormessage`, eg `<input aria-errormessage='err' /><div id='err' />` + get messageTargetAttr() { + return this.getAttribute("message-target-attr") || "aria-errormessage"; + } + // Prefix for attributes containing custom validation messages, default `validation` + get messagePrefix() { + return this.getAttribute("message-prefix") || "validation"; + } + // The aria-live urgency of error messages, default `polite` + get messageAriaLive() { + return this.getAttribute("message-aria-live") || "polite"; + } + // Scroll-into-view options for auto-scrolling to the first invalid input + // https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView + // defaults `scroll-align-to-top` and `smooth` + get scrollIntoViewOptions() { + const alignToTop = this.getAttribute("scroll-align-to-top"); + if (alignToTop !== null) { + return alignToTop !== "false"; + } + const options = { + behavior: this.getAttribute("scroll-behavior") || "smooth", + }; + const block = this.getAttribute("scroll-block"); + if (block) + options.block = block; + const inline = this.getAttribute("scroll-inline"); + if (inline) + options.inline = inline; + return options; + } + // Tracks changes to the element's children + mutationObserver = null; + constructor() { + super(); + // Bind event listeners + this.handleChangeAndFocusOut = this.handleChangeAndFocusOut.bind(this); + this.handleKeyUp = this.handleKeyUp.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleMutation = this.handleMutation.bind(this); + } + connectedCallback() { + // Register event listeners + this.addEventListener("keyup", this.handleKeyUp); + this.addEventListener("change", this.handleChangeAndFocusOut); + this.addEventListener("focusout", this.handleChangeAndFocusOut); + this.addEventListener("submit", this.handleSubmit); + const inputs = this.querySelectorAll(`[${this.messageTargetAttr}]`); + this.setupValidationTargetElements(Array.from(inputs)); + const forms = this.querySelectorAll("form"); + this.setupForms(Array.from(forms)); + this.mutationObserver = new MutationObserver(this.handleMutation); + this.mutationObserver.observe(this, { + childList: true, + subtree: true, + }); + } + disconnectedCallback() { + this.removeEventListener("keyup", this.handleKeyUp); + this.removeEventListener("focusout", this.handleChangeAndFocusOut); + this.removeEventListener("change", this.handleChangeAndFocusOut); + this.removeEventListener("submit", this.handleSubmit); + this.mutationObserver?.disconnect(); + this.mutationObserver = null; + } + /** + * On mutation, add novalidate to forms and aria-live to validation message target elements + * @param mutations Array of MutationRecord from the MutationObserver + */ + handleMutation(mutations) { + mutations.forEach((mutation) => { + const nodes = Array.from(mutation.addedNodes).filter((node) => node instanceof Element); + this.setupValidationTargetElements(nodes); + const forms = nodes.filter((node) => node instanceof HTMLFormElement); + this.setupForms(forms); + }); + } + /** + * Add novalidate to a list of forms, disabling browser validation + * @param forms Array of form elements to set novalidate on + */ + setupForms(forms) { + forms.forEach((form) => (form.noValidate = true)); + } + /** + * Add aria-live attribute to an element if it's referenced by an input's message target attribute + * @param element The potential message target element to add aria-live to + */ + ensureAriaLive(element) { + if (element.id && + this.querySelector(`[${this.messageTargetAttr}="${CSS.escape(element.id)}"]`)) { + if (!element.hasAttribute("aria-live")) { + element.setAttribute("aria-live", this.messageAriaLive); + } + } + } + /** + * Add aria-live attribute to message target elements for inputs that lack it + * @param elements Array of input elements to check for message targets + */ + setupValidationTargetElements(elements) { + elements.forEach((element) => { + const validationMessageTargetElement = this.getValidationMessageTargetElement(element); + if (validationMessageTargetElement) { + if (!validationMessageTargetElement.hasAttribute("aria-live")) { + validationMessageTargetElement.setAttribute("aria-live", this.messageAriaLive); + } + } + }); + } + /** + * Run polite validation for the targeted input whenever the user keys up + * @param event The keyup event from the input + */ + handleKeyUp(event) { + const input = event.target; + if (input) { + this.validateInputPolitely(input); + } + } + /** + * Run validation for the targeted input whenever its value changes or it loses focus + * @param event The change or focusout event from the input + */ + handleChangeAndFocusOut(event) { + const input = event.target; + if (this.isValidatableInput(input)) { + this.validateInput(input); + } + } + /** + * Validate the whole form, and prevent submission if it's invalid + * @param event The submit event from the form + */ + handleSubmit(event) { + const form = event.target; + if (!form) { + return; + } + const valid = this.validateAllInputs(form); + if (!valid) { + event.preventDefault(); + event.stopPropagation(); + } + } + /** + * Check a single input's validity. Toggles `.valid` / `.invalid` + * classes and updates the associated message element. + * @param input The input element to validate + * @returns boolean, whether the input has passed validation + */ + validateInput(input) { + if (!this.isValidatableInput(input)) { + return true; + } + if (input.checkValidity()) { + this.makeInputValid(input); + return true; + } + else { + this.makeInputInvalid(input); + return false; + } + } + /** + * Validates an input, and may apply a valid class, but never applies the invalid class. + * This is for use while the user is typing - it avoids that "nagging" sensation of the + * validation turning red all the time. + * @param input The input element to validate + * @returns boolean, whether the input has passed validation + */ + validateInputPolitely(input) { + if (!this.isValidatableInput(input)) { + return true; + } + if (input.checkValidity()) { + this.makeInputValid(input); + return true; + } + this.removeInputValidity(input); + return false; + } + /** + * Walk every input owned by the form, validate it, and scroll the + * first invalid one into view. Inputs without an `aria-errormessage` + * target fall back to the browser's native reporting. + * @param form The form element to validate + * @returns boolean, whether all inputs have passed validation + */ + validateAllInputs(form) { + let scrolledToFirst = false; + const inputs = this.getValidatableInputs(form); + let formIsValid = true; + for (const input of inputs) { + const isValid = this.validateInput(input); + if (!isValid) { + formIsValid = false; + if (!scrolledToFirst) { + scrolledToFirst = true; + const scrollTarget = this.getLabel(input) || input; + this.scrollToElement(scrollTarget); + input.focus(); + } + } + } + return formIsValid; + } + /** + * Checks whether an element participates in validation + * @param el The element to check + * @returns boolean, whether the element has the validation API + */ + isValidatableInput(el) { + return !!el && !(el instanceof HTMLButtonElement) && "checkValidity" in el; + } + /** + * Set classes and attributes on one input to valid. + * Hides any validation message. + * @param input The input element to mark as valid + */ + makeInputValid(input) { + input.classList.add(this.validClass); + input.classList.remove(this.invalidClass); + input.removeAttribute("aria-invalid"); + this.clearMessage(input); + } + /** + * Set classes and attributes on one input to invalid. + * Shows validation message. + * @param input The input element to mark as invalid + */ + makeInputInvalid(input) { + input.classList.add(this.invalidClass); + input.classList.remove(this.validClass); + input.setAttribute("aria-invalid", "true"); + this.showMessage(input); + } + /** + * Removes visual validity from an input, but leaves invalidity and aria validity + * @param input The input element to remove valid class from + */ + removeInputValidity(input) { + input.classList.remove(this.validClass); + } + /** + * Write the appropriate error string into the input's companion + * message element. Prefers a custom message attribute when one exists. + * @param input The input element to show validation message for + */ + showMessage(input) { + const textContent = this.getCustomMessage(input) || input.validationMessage; + const validationMessageTargetElement = this.getValidationMessageTargetElement(input); + if (validationMessageTargetElement) { + validationMessageTargetElement.textContent = textContent; + } + } + /** + * Empties the companion message element for a given input. + * @param input The input element to clear validation message for + */ + clearMessage(input) { + const validationMessageTargetElement = this.getValidationMessageTargetElement(input); + if (validationMessageTargetElement) { + validationMessageTargetElement.textContent = ""; + } + } + /** + * Resolve the element pointed to by the input's message target + * attribute (defaults to `aria-errormessage`), or `null` if the + * attribute is missing / the target doesn't exist. + * @param input The input element to find the message target for + * @returns The message target element, or null if not found + */ + getValidationMessageTargetElement(input) { + const targetId = input.getAttribute(this.messageTargetAttr); + if (!targetId) { + return null; + } + return this.querySelector(`#${CSS.escape(targetId)}`) || null; + } + /** + * Validity keys checked in spec order to locate a matching + * override attribute on the input. + */ + validityKeys = [ + "valueMissing", + "typeMismatch", + "patternMismatch", + "tooLong", + "tooShort", + "rangeUnderflow", + "rangeOverflow", + "stepMismatch", + "badInput", + ]; + /** + * Look up a developer-supplied message for the first failing + * validity key. Returns `null` when no override is present or + * the input is completely valid. + * @param input The input element to get custom message for + * @returns The custom validation message, or null if not found + */ + getCustomMessage(input) { + if (!input.validity) { + return null; + } + const prefix = this.messagePrefix; + for (const key of this.validityKeys) { + if (key in input.validity && input.validity[key]) { + return (input.getAttribute(`${prefix}-${key}`) ?? input.getAttribute(`data-${prefix}-${key}`)); + } + } + return null; + } + /** + * Gets all validatable inputs on the form + * @param form The form element to get inputs from + * @returns Array of validatable input elements + */ + getValidatableInputs(form) { + return Array.from(form.elements ?? []).filter(this.isValidatableInput); + } + /** + * Scrolls an element into view, respecting the configured scroll options. + * @param element The element to scroll into view + */ + scrollToElement(element) { + const containerSelector = this.getAttribute("scroll-container"); + if (containerSelector) { + const container = document.querySelector(containerSelector); + if (container) { + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + const options = this.scrollIntoViewOptions; + const behavior = typeof options === "boolean" ? "auto" : options.behavior || "auto"; + container.scrollTo({ + top: container.scrollTop + (elementRect.top - containerRect.top), + left: container.scrollLeft + (elementRect.left - containerRect.left), + behavior, + }); + return; + } + } + element.scrollIntoView(this.scrollIntoViewOptions); + } + /** + * Gets an input's label element + * @param input The input element to find the label for + * @returns The label element, or null if not found + */ + getLabel(input) { + if (input.id) { + return document.querySelector(`label[for=${CSS.escape(input.id)}]`); + } + return null; + } +} +customElements.define("validation-enhancer", ValidationEnhancer); + +export { ValidationEnhancer }; diff --git a/templates/application.html b/templates/application.html @@ -5,6 +5,7 @@ <title>News</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <script type="module" src="/static/datastar.js"></script> + <script type="module" src="/static/validation-enhancer.js"></script> <link rel="stylesheet" href="/static/style.css" /> </head> <body> diff --git a/templates/edit.html b/templates/edit.html @@ -7,10 +7,12 @@ {% when None %} {% endmatch %} + <validation-enhancer> <form action="/story/{{ story_id }}/edit" method="post" class="submit-form"> <div class="form-group"> <label for="title">Title</label> - <input type="text" id="title" name="title" value="{{ title }}" required maxlength="200" /> + <input type="text" id="title" name="title" value="{{ title }}" required maxlength="200" aria-errormessage="title-error" validation-valueMissing="Title is required" /> + <div id="title-error" class="field-error"></div> </div> <div class="form-group"> @@ -53,6 +55,7 @@ <a href="/story/{{ story_id }}">Cancel</a> </div> </form> + </validation-enhancer> <script> function updateTags(form) { var checked = form.querySelectorAll('.tag-checkboxes input[type=checkbox]:checked'); diff --git a/templates/login.html b/templates/login.html @@ -7,21 +7,25 @@ {% when None %} {% endmatch %} - <form method="post" action="/login" class="auth-form"> - <div class="form-group"> - <label for="email">Email</label> - <input type="email" id="email" name="email" required autocomplete="email" /> - </div> + <validation-enhancer> + <form method="post" action="/login" class="auth-form"> + <div class="form-group"> + <label for="email">Email</label> + <input type="email" id="email" name="email" required autocomplete="email" aria-errormessage="email-error" validation-valueMissing="Email is required" validation-typeMismatch="Please enter a valid email address" /> + <div id="email-error" class="field-error"></div> + </div> - <div class="form-group"> - <label for="password">Password</label> - <input type="password" id="password" name="password" required autocomplete="current-password" /> - </div> + <div class="form-group"> + <label for="password">Password</label> + <input type="password" id="password" name="password" required autocomplete="current-password" aria-errormessage="password-error" validation-valueMissing="Password is required" /> + <div id="password-error" class="field-error"></div> + </div> - <div class="form-actions"> - <button type="submit">Login</button> - </div> - </form> + <div class="form-actions"> + <button type="submit">Login</button> + </div> + </form> + </validation-enhancer> <p class="auth-link">Don't have an account? <a href="/register">Register</a></p> <p class="auth-link">Registered but can't login? <a href="/resend-verification">Resend verification email</a></p> diff --git a/templates/register.html b/templates/register.html @@ -7,32 +7,38 @@ {% when None %} {% endmatch %} - <form method="post" action="/register" class="auth-form"> - <div class="form-group"> - <label for="username">Username</label> - <input type="text" id="username" name="username" value="{{ username }}" required minlength="2" maxlength="30" pattern="[a-zA-Z0-9_]+" autocomplete="username" /> - <small>2-30 characters, letters, numbers, and underscores only</small> - </div> + <validation-enhancer> + <form method="post" action="/register" class="auth-form"> + <div class="form-group"> + <label for="username">Username</label> + <input type="text" id="username" name="username" value="{{ username }}" required minlength="2" maxlength="30" pattern="[a-zA-Z0-9_]+" autocomplete="username" aria-errormessage="username-error" validation-valueMissing="Username is required" validation-tooShort="Username must be at least 2 characters" validation-patternMismatch="Letters, numbers, and underscores only" /> + <small>2-30 characters, letters, numbers, and underscores only</small> + <div id="username-error" class="field-error"></div> + </div> - <div class="form-group"> - <label for="email">Email</label> - <input type="email" id="email" name="email" value="{{ email }}" required autocomplete="email" /> - </div> + <div class="form-group"> + <label for="email">Email</label> + <input type="email" id="email" name="email" value="{{ email }}" required autocomplete="email" aria-errormessage="email-error" validation-valueMissing="Email is required" validation-typeMismatch="Please enter a valid email address" /> + <div id="email-error" class="field-error"></div> + </div> - <div class="form-group"> - <label for="password">Password</label> - <input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" /> - </div> + <div class="form-group"> + <label for="password">Password</label> + <input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" aria-errormessage="password-error" validation-valueMissing="Password is required" validation-tooShort="Password must be at least 8 characters" /> + <div id="password-error" class="field-error"></div> + </div> - <div class="form-group"> - <label for="password_confirm">Confirm Password</label> - <input type="password" id="password_confirm" name="password_confirm" required minlength="8" autocomplete="new-password" /> - </div> + <div class="form-group"> + <label for="password_confirm">Confirm Password</label> + <input type="password" id="password_confirm" name="password_confirm" required minlength="8" autocomplete="new-password" aria-errormessage="password-confirm-error" validation-valueMissing="Please confirm your password" validation-tooShort="Password must be at least 8 characters" /> + <div id="password-confirm-error" class="field-error"></div> + </div> - <div class="form-actions"> - <button type="submit">Register</button> - </div> - </form> + <div class="form-actions"> + <button type="submit">Register</button> + </div> + </form> + </validation-enhancer> <p class="auth-link">Already have an account? <a href="/login">Login</a></p> </div> diff --git a/templates/submit.html b/templates/submit.html @@ -7,10 +7,12 @@ {% when None %} {% endmatch %} + <validation-enhancer> <form action="/submit" method="post" class="submit-form"> <div class="form-group"> <label for="title">Title</label> - <input type="text" id="title" name="title" value="{{ title }}" required maxlength="200" /> + <input type="text" id="title" name="title" value="{{ title }}" required maxlength="200" aria-errormessage="title-error" validation-valueMissing="Title is required" /> + <div id="title-error" class="field-error"></div> </div> <div class="form-group"> @@ -52,6 +54,7 @@ <button type="submit">Submit</button> </div> </form> + </validation-enhancer> <script> function updateTags(form) { var checked = form.querySelectorAll('.tag-checkboxes input[type=checkbox]:checked');