CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
form-validation-js.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| c63b860 | 1 | /** |
| 2 | * BLOCK O2 — Inline client-side form validation. | |
| 3 | * | |
| 4 | * Vanilla JS that mounts a `<span class="field-error" aria-live="polite">` | |
| 5 | * under every matched form input. Fires on blur AND on input (after the | |
| 6 | * first blur). Per-field override via data-validation-message attribute. | |
| 7 | * | |
| 8 | * Visual states: | |
| 9 | * .input-invalid → red border | |
| 10 | * .input-valid → green border + checkmark icon | |
| 11 | * | |
| 12 | * Server-side `?error=…` redirect validation still works — this is | |
| 13 | * additive UX polish. | |
| 14 | */ | |
| 15 | ||
| 16 | import type { FC } from "hono/jsx"; | |
| 17 | ||
| 18 | export const formValidationScript = /* js */ ` | |
| 19 | (function () { | |
| 20 | if (window.__gluecronFormValidationMounted) return; | |
| 21 | window.__gluecronFormValidationMounted = true; | |
| 22 | ||
| 23 | function ready(fn) { | |
| 24 | if (document.readyState === "loading") { | |
| 25 | document.addEventListener("DOMContentLoaded", fn); | |
| 26 | } else { fn(); } | |
| 27 | } | |
| 28 | ||
| 29 | function validateInput(el) { | |
| 30 | var v = el.value; | |
| 31 | if (el.disabled || el.readOnly) return ""; | |
| 32 | if (el.hasAttribute("required") && v.trim() === "") { | |
| 33 | return el.getAttribute("data-validation-required") | |
| 34 | || (el.labels && el.labels[0] ? el.labels[0].textContent + " is required" : "This field is required"); | |
| 35 | } | |
| 36 | if (v === "") return ""; | |
| 37 | if (el.type === "email") { | |
| 38 | var emailRe = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/; | |
| 39 | if (!emailRe.test(v)) { | |
| 40 | return el.getAttribute("data-validation-message") || "Enter a valid email address"; | |
| 41 | } | |
| 42 | } | |
| 43 | var pat = el.getAttribute("pattern"); | |
| 44 | if (pat) { | |
| 45 | try { | |
| 46 | if (!new RegExp("^(?:" + pat + ")$").test(v)) { | |
| 47 | return el.getAttribute("data-validation-message") || "Doesn't match the required format"; | |
| 48 | } | |
| 49 | } catch (_e) {} | |
| 50 | } | |
| 51 | var minL = parseInt(el.getAttribute("minlength") || "0", 10); | |
| 52 | if (minL > 0 && v.length < minL) { | |
| 53 | return el.getAttribute("data-validation-message") || ("Must be at least " + minL + " characters"); | |
| 54 | } | |
| 55 | var maxL = parseInt(el.getAttribute("maxlength") || "0", 10); | |
| 56 | if (maxL > 0 && v.length > maxL) { | |
| 57 | return el.getAttribute("data-validation-message") || ("Must be at most " + maxL + " characters"); | |
| 58 | } | |
| 59 | return ""; | |
| 60 | } | |
| 61 | ||
| 62 | function ensureErrorSpan(el) { | |
| 63 | var id = el.id || el.name; | |
| 64 | if (!id) { id = "fv-" + Math.random().toString(36).slice(2, 8); el.id = id; } | |
| 65 | var errId = id + "-fv-error"; | |
| 66 | var span = document.getElementById(errId); | |
| 67 | if (!span) { | |
| 68 | span = document.createElement("span"); | |
| 69 | span.id = errId; | |
| 70 | span.className = "field-error"; | |
| 71 | span.setAttribute("aria-live", "polite"); | |
| 72 | el.parentNode && el.parentNode.insertBefore(span, el.nextSibling); | |
| 73 | var describedBy = el.getAttribute("aria-describedby") || ""; | |
| 74 | var parts = describedBy.split(/\\s+/).filter(Boolean); | |
| 75 | if (parts.indexOf(errId) < 0) { | |
| 76 | parts.push(errId); | |
| 77 | el.setAttribute("aria-describedby", parts.join(" ")); | |
| 78 | } | |
| 79 | } | |
| 80 | return span; | |
| 81 | } | |
| 82 | ||
| 83 | function applyState(el, msg) { | |
| 84 | var span = ensureErrorSpan(el); | |
| 85 | if (msg) { | |
| 86 | el.classList.add("input-invalid"); | |
| 87 | el.classList.remove("input-valid"); | |
| 88 | el.setAttribute("aria-invalid", "true"); | |
| 89 | span.textContent = msg; | |
| 90 | span.classList.add("field-error-shown"); | |
| 91 | } else if (el.value === "") { | |
| 92 | el.classList.remove("input-invalid"); | |
| 93 | el.classList.remove("input-valid"); | |
| 94 | el.removeAttribute("aria-invalid"); | |
| 95 | span.textContent = ""; | |
| 96 | span.classList.remove("field-error-shown"); | |
| 97 | } else { | |
| 98 | el.classList.remove("input-invalid"); | |
| 99 | el.classList.add("input-valid"); | |
| 100 | el.removeAttribute("aria-invalid"); | |
| 101 | span.textContent = ""; | |
| 102 | span.classList.remove("field-error-shown"); | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | function wire(el) { | |
| 107 | if (el.__fvWired) return; | |
| 108 | el.__fvWired = true; | |
| 109 | el.addEventListener("blur", function () { el.__fvBlurred = true; applyState(el, validateInput(el)); }); | |
| 110 | el.addEventListener("input", function () { if (!el.__fvBlurred) return; applyState(el, validateInput(el)); }); | |
| 111 | } | |
| 112 | ||
| 113 | function scan(root) { | |
| 114 | var sel = "input[required],input[pattern],input[minlength],input[maxlength],input[type=email]"; | |
| 115 | var nodes = (root || document).querySelectorAll(sel); | |
| 116 | for (var i = 0; i < nodes.length; i++) { | |
| 117 | var el = nodes[i]; | |
| 118 | if (el.type === "hidden" || el.type === "submit" || el.type === "button") continue; | |
| 119 | wire(el); | |
| 120 | } | |
| 121 | } | |
| 122 | ||
| 123 | ready(function () { scan(document); }); | |
| 124 | })(); | |
| 125 | `; | |
| 126 | ||
| 127 | export const formValidationCss = ` | |
| 128 | .input-invalid { border-color: var(--red, #ff6a6a) !important; box-shadow: 0 0 0 2px rgba(255,106,106,0.18) !important; } | |
| 129 | .input-valid { border-color: var(--green, #4ade80) !important; box-shadow: 0 0 0 2px rgba(74,222,128,0.18) !important; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%234ade80' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 8.5l3.5 3.5L13 4.5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; background-size: 14px 14px; padding-right: 32px !important; } | |
| 130 | .field-error { display: block; margin-top: 4px; font-size: 12px; color: var(--red, #ff6a6a); line-height: 1.4; min-height: 0; transition: min-height 120ms ease; } | |
| 131 | .field-error-shown { min-height: 1.4em; } | |
| 132 | `; | |
| 133 | ||
| 134 | export const FormValidationAssets: FC = () => ( | |
| 135 | <> | |
| 136 | <style dangerouslySetInnerHTML={{ __html: formValidationCss }} /> | |
| 137 | <script dangerouslySetInnerHTML={{ __html: formValidationScript }} /> | |
| 138 | </> | |
| 139 | ); |