Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

form-validation-js.tsxBlame139 lines · 1 contributor
c63b860Claude1/**
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
16import type { FC } from "hono/jsx";
17
18export 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
127export 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
134export const FormValidationAssets: FC = () => (
135 <>
136 <style dangerouslySetInnerHTML={{ __html: formValidationCss }} />
137 <script dangerouslySetInnerHTML={{ __html: formValidationScript }} />
138 </>
139);