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