PageSourceSearch

https://acpsmd.org/wp-content/plugins/acps-site-toolkit/assets/js/forms.js?ver=5.0.0

js acpsmd.org collected 2026-10-02 05:35:40 UTC 13,858 bytes, 436 lines download raw bytes

1/**
2 * Cayden Form Manager — form runtime.
3 *
4 * CRITICAL (spec §7.5 / §13): the nonce and time-trap timestamp are fetched
5 * AFTER load from an uncached endpoint and injected into the form. They are
6 * never printed into the (edge-cached) HTML, so they can't go stale. If you
7 * bake a nonce into the markup, submissions fail intermittently in production.
8 *
9 * Also owns accessible validation UI: an error summary that receives focus,
10 * inline messages announced via role="alert", conditional field visibility,
11 * and multi-page navigation.
12 */
13( function () {
14	'use strict';
15
16	var cfg = window.ACPS_ST || {};
17	var strings = cfg.strings || {};
18	var restUrl = ( cfg.restUrl || '' ).replace( /\/$/, '' );
19
20	function ready( fn ) {
21		if ( document.readyState === 'loading' ) {
22			document.addEventListener( 'DOMContentLoaded', fn );
23		} else {
24			fn();
25		}
26	}
27
28	ready( function () {
29		var forms = document.querySelectorAll( '.acps-form' );
30		Array.prototype.forEach.call( forms, function ( form ) {
31			new ACPSForm( form );
32		} );
33	} );
34
35	function ACPSForm( form ) {
36		this.form = form;
37		this.errorSummary = form.querySelector( '.acps-error-summary' );
38		this.statusRegion = form.querySelector( '.acps-status' );
39		this.multipage = form.getAttribute( 'data-multipage' ) === '1';
40		this.pages = form.querySelectorAll( '.acps-page' );
41		this.current = 0;
42
43		this.bindLazyTokens();
44		this.bindHoneypot();
45		this.bindConditional();
46		if ( this.multipage && this.pages.length > 1 ) {
47			this.bindPaging();
48		}
49		this.bindSubmit();
50	}
51
52	/* MINIMAL REQUESTS: the session token and visitor id are free (already in
53	   memory / cookie), so set them now. But the nonce/timestamp need an uncached
54	   request, so DON'T fetch on page load — only when the visitor actually
55	   engages with the form (first focus). Pages where nobody touches a form make
56	   zero extra requests. submit() also guarantees the token is present. */
57	ACPSForm.prototype.bindLazyTokens = function () {
58		var self = this;
59		var form = this.form;
60
61		var rt = window.ACPS_ST_RT || {};
62		if ( rt.token ) {
63			setVal( form, 'acps_session', rt.token );
64		}
65		// Visitor identity is derived server-side (IP + user agent) on submit —
66		// no client id is sent.
67
68		var onFirst = function () {
69			form.removeEventListener( 'focusin', onFirst );
70			self.ensureTokens();
71		};
72		form.addEventListener( 'focusin', onFirst );
73	};
74
75	/* Fetch the nonce + timestamp once (cached promise) and inject them. */
76	ACPSForm.prototype.ensureTokens = function () {
77		if ( this._tokenPromise ) {
78			return this._tokenPromise;
79		}
80		var form = this.form;
81		this._tokenPromise = fetch( restUrl + '/token', { credentials: 'same-origin' } )
82			.then( function ( r ) { return r.json(); } )
83			.then( function ( data ) {
84				setVal( form, 'acps_nonce', data.nonce );
85				setVal( form, 'acps_ts', data.ts );
86				return data;
87			} )
88			.catch( function () { return null; } );
89		return this._tokenPromise;
90	};
91
92	/* The honeypot: give it a submit name only in JS, keep it empty. */
93	ACPSForm.prototype.bindHoneypot = function () {
94		var hp = this.form.querySelector( '[data-acps-hp]' );
95		if ( hp ) {
96			hp.setAttribute( 'name', 'acps_hp' );
97			hp.value = '';
98		}
99	};
100
101	/* Conditional visibility (spec §7.3) — multi-rule AND/OR with operators. */
102	ACPSForm.prototype.bindConditional = function () {
103		var form = this.form;
104		var conditionals = form.querySelectorAll( '[data-acps-cond]' );
105		if ( ! conditionals.length ) {
106			return;
107		}
108
109		function ruleMatches( rule, vals ) {
110			var want = ( rule.value == null ? '' : String( rule.value ) ).toLowerCase();
111			var joined = vals.join( '�' ).toLowerCase();
112			switch ( rule.op ) {
113				case 'is_not':
114					return vals.map( lc ).indexOf( want ) === -1;
115				case 'contains':
116					return joined.indexOf( want ) !== -1;
117				case 'not_contains':
118					return joined.indexOf( want ) === -1;
119				case 'gt':
120					return vals.some( function ( v ) { return parseFloat( v ) > parseFloat( rule.value ); } );
121				case 'lt':
122					return vals.some( function ( v ) { return parseFloat( v ) < parseFloat( rule.value ); } );
123				case 'is_empty':
124					return vals.length === 0 || ( vals.length === 1 && vals[ 0 ] === '' );
125				case 'is_not_empty':
126					return vals.length > 0 && ! ( vals.length === 1 && vals[ 0 ] === '' );
127				case 'is':
128				default:
129					return vals.map( lc ).indexOf( want ) !== -1;
130			}
131		}
132		function lc( s ) { return String( s ).toLowerCase(); }
133
134		var evaluate = function () {
135			Array.prototype.forEach.call( conditionals, function ( el ) {
136				var cfgAttr = el.getAttribute( 'data-acps-cond' );
137				var conf;
138				try { conf = JSON.parse( cfgAttr ); } catch ( e ) { return; }
139				if ( ! conf || ! conf.rules || ! conf.rules.length ) { return; }
140
141				var results = conf.rules.map( function ( rule ) {
142					return ruleMatches( rule, collectValues( form, rule.field ) );
143				} );
144				var conditionMet = ( conf.logic === 'or' )
145					? results.some( Boolean )
146					: results.every( Boolean );
147
148				// action "show": visible only when met. "hide": hidden when met.
149				var visible = ( conf.action === 'hide' ) ? ! conditionMet : conditionMet;
150				el.hidden = ! visible;
151			} );
152		};
153		form.addEventListener( 'change', evaluate );
154		form.addEventListener( 'input', evaluate );
155		evaluate();
156	};
157
158	/* Multi-page navigation with a live step indicator. */
159	ACPSForm.prototype.bindPaging = function () {
160		var self = this;
161		var prev = this.form.querySelector( '.acps-prev' );
162		var next = this.form.querySelector( '.acps-next' );
163		var submit = this.form.querySelector( '.acps-submit' );
164		var stepCur = this.form.querySelector( '.acps-step-current' );
165
166		var show = function ( idx ) {
167			Array.prototype.forEach.call( self.pages, function ( p, i ) {
168				p.hidden = i !== idx;
169			} );
170			self.current = idx;
171			if ( stepCur ) {
172				stepCur.textContent = idx + 1;
173			}
174			if ( prev ) { prev.hidden = idx === 0; }
175			var last = idx === self.pages.length - 1;
176			if ( next ) { next.hidden = last; }
177			if ( submit ) { submit.hidden = ! last; }
178			// Move focus to the first control on the newly shown page.
179			var focusable = self.pages[ idx ].querySelector( 'input, select, textarea, button' );
180			if ( focusable ) { focusable.focus(); }
181		};
182
183		if ( next ) {
184			next.addEventListener( 'click', function () {
185				if ( self.validatePage( self.current ) ) {
186					show( Math.min( self.current + 1, self.pages.length - 1 ) );
187				}
188			} );
189		}
190		if ( prev ) {
191			prev.addEventListener( 'click', function () {
192				show( Math.max( self.current - 1, 0 ) );
193			} );
194		}
195		show( 0 );
196	};
197
198	ACPSForm.prototype.validatePage = function ( idx ) {
199		var page = this.pages[ idx ];
200		var errors = this.collectErrors( page );
201		this.renderErrors( errors );
202		return errors.length === 0;
203	};
204
205	/* Client-side required/format checks — mirrors the server, for fast UX.
206	   The server remains the source of truth. */
207	ACPSForm.prototype.collectErrors = function ( scope ) {
208		scope = scope || this.form;
209		var errors = [];
210		var fields = scope.querySelectorAll( '.acps-field' );
211		Array.prototype.forEach.call( fields, function ( field ) {
212			if ( field.hidden ) {
213				return;
214			}
215			var key = field.getAttribute( 'data-key' );
216			var required = field.querySelector( '[aria-required="true"], [required]' );
217			if ( ! required ) {
218				return;
219			}
220			var controls = field.querySelectorAll( 'input, select, textarea' );
221			var hasValue = false;
222			Array.prototype.forEach.call( controls, function ( c ) {
223				if ( c.type === 'checkbox' || c.type === 'radio' ) {
224					if ( c.checked ) { hasValue = true; }
225				} else if ( c.value && c.value.trim() !== '' ) {
226					hasValue = true;
227				}
228			} );
229			if ( ! hasValue ) {
230				var label = field.querySelector( '.acps-label, .acps-legend' );
231				errors.push( {
232					field: key,
233					message: ( label ? label.textContent.replace( /\s*\(required\)\s*$/, '' ) : 'This field' ) + ' is required.'
234				} );
235			}
236		} );
237		return errors;
238	};
239
240	ACPSForm.prototype.bindSubmit = function () {
241		var self = this;
242		this.form.addEventListener( 'submit', function ( e ) {
243			e.preventDefault();
244			self.submit();
245		} );
246	};
247
248	ACPSForm.prototype.submit = function () {
249		var self = this;
250		var form = this.form;
251
252		var errors = this.collectErrors();
253		if ( errors.length ) {
254			this.renderErrors( errors );
255			return;
256		}
257		this.clearErrors();
258
259		var submitBtn = form.querySelector( '.acps-submit' );
260		if ( submitBtn ) {
261			submitBtn.disabled = true;
262		}
263		this.announce( strings.submitting || 'Sending…' );
264
265		// Make sure the nonce/timestamp are loaded (they may not be if the visitor
266		// never focused the form, e.g. an autofill submit), then send.
267		this.ensureTokens().then( function () {
268			var data = new FormData( form );
269			fetch( restUrl + '/submit', {
270				method: 'POST',
271				body: data,
272				credentials: 'same-origin'
273			} )
274				.then( function ( r ) { return r.json(); } )
275				.then( function ( res ) {
276					if ( submitBtn ) { submitBtn.disabled = false; }
277					if ( res && res.success ) {
278						self.onSuccess( res.confirmation );
279					} else if ( res && res.summary ) {
280						self.renderServerErrors( res );
281					} else {
282						self.announce( strings.genericError || 'Something went wrong.' );
283					}
284				} )
285				.catch( function () {
286					if ( submitBtn ) { submitBtn.disabled = false; }
287					self.announce( strings.genericError || 'Something went wrong.' );
288				} );
289		} );
290	};
291
292	ACPSForm.prototype.onSuccess = function ( confirmation ) {
293		confirmation = confirmation || {};
294		if ( ( confirmation.type === 'redirect' || confirmation.type === 'both' ) && confirmation.redirect ) {
295			if ( confirmation.type === 'both' && confirmation.message ) {
296				this.showMessage( confirmation.message );
297			}
298			window.location.href = confirmation.redirect;
299			return;
300		}
301		this.showMessage( confirmation.message || 'Thank you.' );
302	};
303
304	ACPSForm.prototype.showMessage = function ( msg ) {
305		// Replace the form body with the confirmation, announced via role=status.
306		var wrap = this.form.closest( '.acps-form-wrap' ) || this.form;
307		var box = document.createElement( 'div' );
308		box.className = 'acps-confirmation';
309		box.setAttribute( 'role', 'status' );
310		box.setAttribute( 'tabindex', '-1' );
311		box.innerHTML = msg;
312		this.form.parentNode.replaceChild( box, this.form );
313		box.focus();
314	};
315
316	/* Render a server error payload: field map + summary. */
317	ACPSForm.prototype.renderServerErrors = function ( res ) {
318		this.clearErrors();
319		var errors = res.summary || [];
320		// Inline field errors from res.errors map.
321		if ( res.errors ) {
322			for ( var key in res.errors ) {
323				if ( res.errors.hasOwnProperty( key ) ) {
324					this.setFieldError( key, res.errors[ key ] );
325				}
326			}
327		}
328		this.renderSummary( errors );
329	};
330
331	ACPSForm.prototype.renderErrors = function ( errors ) {
332		this.clearErrors();
333		var self = this;
334		errors.forEach( function ( err ) {
335			if ( err.field ) {
336				self.setFieldError( err.field, err.message );
337			}
338		} );
339		this.renderSummary( errors );
340	};
341
342	ACPSForm.prototype.setFieldError = function ( key, message ) {
343		var field = this.form.querySelector( '.acps-field[data-key="' + cssEscape( key ) + '"]' );
344		if ( ! field ) {
345			return;
346		}
347		var errEl = field.querySelector( '.acps-field-error' );
348		if ( errEl ) {
349			errEl.textContent = message;
350		}
351		field.classList.add( 'acps-has-error' );
352	};
353
354	ACPSForm.prototype.renderSummary = function ( errors ) {
355		if ( ! this.errorSummary || ! errors.length ) {
356			return;
357		}
358		var list = this.errorSummary.querySelector( '.acps-error-summary__list' );
359		list.innerHTML = '';
360		var self = this;
361		errors.forEach( function ( err ) {
362			var li = document.createElement( 'li' );
363			if ( err.field ) {
364				var a = document.createElement( 'a' );
365				a.href = '#';
366				a.textContent = err.message;
367				a.addEventListener( 'click', function ( e ) {
368					e.preventDefault();
369					self.focusField( err.field );
370				} );
371				li.appendChild( a );
372			} else {
373				li.textContent = err.message;
374			}
375			list.appendChild( li );
376		} );
377		this.errorSummary.hidden = false;
378		// Move focus to the summary on failed submit (spec §8.2).
379		this.errorSummary.focus();
380	};
381
382	ACPSForm.prototype.focusField = function ( key ) {
383		var field = this.form.querySelector( '.acps-field[data-key="' + cssEscape( key ) + '"]' );
384		if ( ! field ) {
385			return;
386		}
387		var control = field.querySelector( 'input, select, textarea' );
388		if ( control ) {
389			control.focus();
390		}
391	};
392
393	ACPSForm.prototype.clearErrors = function () {
394		if ( this.errorSummary ) {
395			this.errorSummary.hidden = true;
396			var list = this.errorSummary.querySelector( '.acps-error-summary__list' );
397			if ( list ) { list.innerHTML = ''; }
398		}
399		var errs = this.form.querySelectorAll( '.acps-field-error' );
400		Array.prototype.forEach.call( errs, function ( e ) { e.textContent = ''; } );
401		var flagged = this.form.querySelectorAll( '.acps-has-error' );
402		Array.prototype.forEach.call( flagged, function ( f ) { f.classList.remove( 'acps-has-error' ); } );
403	};
404
405	ACPSForm.prototype.announce = function ( msg ) {
406		if ( this.statusRegion ) {
407			this.statusRegion.textContent = msg;
408		}
409	};
410
411	/* ---- helpers ----------------------------------------------------- */
412	function setVal( form, name, value ) {
413		var el = form.querySelector( '[name="' + name + '"]' );
414		if ( el ) {
415			el.value = value;
416		}
417	}
418	function collectValues( form, key ) {
419		var out = [];
420		var els = form.querySelectorAll( '[name="fields[' + key + ']"], [name="fields[' + key + '][]"]' );
421		Array.prototype.forEach.call( els, function ( el ) {
422			if ( el.type === 'checkbox' || el.type === 'radio' ) {
423				if ( el.checked ) { out.push( el.value ); }
424			} else if ( el.value !== '' ) {
425				out.push( el.value );
426			}
427		} );
428		return out;
429	}
430	function cssEscape( s ) {
431		return String( s ).replace( /"/g, '\\"' );
432	}
433
434	// Expose for feedback.js.
435	window.ACPSForm = ACPSForm;
436} )();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.