1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2025 Per Cederberg. All rights reserved.
4 *
5 * This program is free software: you can redistribute it and/or
6 * modify it under the terms of the BSD license.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11 *
12 * See the RapidContext LICENSE for more details.
13 */
14
15// Namespace initialization
16if (typeof(RapidContext) == "undefined") {
17 RapidContext = {};
18}
19RapidContext.Widget = RapidContext.Widget || { Classes: {} };
20
21/**
22 * Creates a new form widget.
23 *
24 * @constructor
25 * @param {Object} attrs the widget and node attributes
26 * @param {boolean} [attrs.hidden] the hidden widget flag, defaults to false
27 * @param {...(string|Node|Array)} [child] the child widgets or DOM nodes
28 *
29 * @return {Widget} the widget DOM node
30 *
31 * @class The form widget class. Provides a grouping for form fields, using the
32 * `<form>` HTML element. The form widget supports form reset, validation
33 * and data retrieval.
34 * @extends RapidContext.Widget
35 *
36 * @example <caption>JavaScript</caption>
37 * var field = RapidContext.Widget.TextField({ name: "name", placeholder: "Your Name Here" });
38 * var attrs = { name: "name", message: "Please enter your name to proceed." };
39 * var valid = RapidContext.Widget.FormValidator(attrs);
40 * var exampleForm = RapidContext.Widget.Form({}, field, valid);
41 *
42 * @example <caption>User Interface XML</caption>
43 * <Form id="exampleForm">
44 * <TextField name="name" placeholder="Your Name Here" />
45 * <FormValidator name="name" message="Please enter your name to proceed." />
46 * </Form>
47 */
48RapidContext.Widget.Form = function (attrs/*, ...*/) {
49 const o = RapidContext.UI.FORM(attrs);
50 o._validators = {};
51 o._originalReset = o.reset;
52 RapidContext.Widget._widgetMixin(o, RapidContext.Widget.Form);
53 o.addClass("widgetForm");
54 o.setAttrs(attrs);
55 o.addAll(Array.from(arguments).slice(1));
56 o.on("input", o._handleInput);
57 o.on("invalid", o._handleInvalid, { capture: true });
58 o.on("submit", o._handleSubmit);
59 return o;
60};
61
62// Register widget class
63RapidContext.Widget.Classes.Form = RapidContext.Widget.Form;
64
65/**
66 * Applies custom validators on field input.
67 *
68 * @param {Event} evt the DOM event object
69 */
70RapidContext.Widget.Form.prototype._handleInput = function (evt) {
71 this._callValidators(evt.target);
72};
73
74/**
75 * Debounces the input invalid events and validates the form.
76 */
77RapidContext.Widget.Form.prototype._handleInvalid = function () {
78 if (this._validationTimer !== false) {
79 this._validationTimer && clearTimeout(this._validationTimer);
80 this._validationTimer = setTimeout(() => this.validate(), 10);
81 }
82};
83
84/**
85 * Prevents the default submit action and validates the form.
86 *
87 * @param {Event} evt the DOM event object
88 */
89RapidContext.Widget.Form.prototype._handleSubmit = function (evt) {
90 evt.preventDefault();
91 if (!this.validate()) {
92 evt.stopImmediatePropagation();
93 }
94};
95
96RapidContext.Widget.Form.prototype._fieldValue = function (field) {
97 if (field.disabled) {
98 return null;
99 } else if (field.type === "radio" || field.type === "checkbox") {
100 return field.checked ? (field.value || true) : null;
101 } else if (typeof(field.getValue) == "function") {
102 return field.getValue();
103 } else {
104 return field.value;
105 }
106};
107
108/**
109 * Returns an array with all child DOM nodes containing form fields.
110 *
111 * @return {Array} the array of form field elements
112 *
113 * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements
114 */
115RapidContext.Widget.Form.prototype.fields = function () {
116 const basics = Array.from(this.elements);
117 const extras = Array.from(this.querySelectorAll(".widgetField"));
118 return basics.concat(extras);
119};
120
121/**
122 * Returns a map with all child DOM nodes containing form fields with
123 * a name attribute. If multiple fields have the same name, the
124 * returned map will contain an array with all matching fields.
125 *
126 * @return {Object} the map of form field elements
127 */
128RapidContext.Widget.Form.prototype.fieldMap = function () {
129 function update(o, field) {
130 const k = field.name;
131 if (k && k != "*") {
132 o[k] = (k in o) ? [].concat(o[k], field) : field;
133 }
134 return o;
135 }
136 return this.fields().reduce(update, {});
137};
138
139/**
140 * Resets all fields and validations to their original state.
141 */
142RapidContext.Widget.Form.prototype.reset = function () {
143 this._originalReset();
144 Array.from(this.querySelectorAll(".widgetField")).forEach((f) => f.reset());
145 this.validateReset();
146};
147
148/**
149 * Returns a map with all form field values. If multiple fields have
150 * the same name, the value will be set to an array of all values.
151 * Disabled fields and unchecked checkboxes or radiobuttons will be
152 * ignored.
153 *
154 * @return {Object} the map of form field values
155 */
156RapidContext.Widget.Form.prototype.valueMap = function () {
157 const update = (o, field) => {
158 const k = field.name;
159 const v = this._fieldValue(field);
160 if (k && k != "*" && v != null) {
161 o[k] = (k in o) ? [].concat(o[k], v) : v;
162 }
163 return o;
164 };
165 return this.fields().reduce(update, {});
166};
167
168/**
169 * Updates the fields in this form with a specified map of values.
170 * If multiple fields have the same name, the value will be set to
171 * all of them.
172 *
173 * @param {Object} values the map of form field values
174 */
175RapidContext.Widget.Form.prototype.update = function (values) {
176 function setValue(field) {
177 const v = values[field.name];
178 if (field.name == "*" && typeof(field.setAttrs) == "function") {
179 field.setAttrs({ value: values });
180 } else if (!(field.name in values)) {
181 // Don't change omitted fields
182 } else if (field.type === "radio" || field.type === "checkbox") {
183 const found = Array.isArray(v) && v.includes(field.value);
184 field.checked = found || v === field.value || v === true;
185 } else if (typeof(field.setAttrs) == "function") {
186 field.setAttrs({ value: v });
187 } else {
188 field.value = MochiKit.Base.isArrayLike(v) ? v.join(", ") : v;
189 }
190 }
191 this.fields().forEach(setValue);
192};
193
194/**
195 * Adds a custom form validator for a named form field. The function will be
196 * called as `[field].validator([value], [field], [form])` and should return
197 * `true`, `false` or a validation error message. The validator will be called
198 * on each `input` event and before form submission for enabled fields.
199 *
200 * Note: Checkbox validators will be called once for each `<input>` element,
201 * regardless of checked state. Radio validators will only be called with
202 * either the first or the checked `<input>` element.
203 *
204 * @param {string|Element} field the form field or name
205 * @param {function} validator the validator function
206 */
207RapidContext.Widget.Form.prototype.addValidator = function (field, validator) {
208 const name = String(field.name || field);
209 const arr = [].concat(this._validators[name], validator).filter(Boolean);
210 this._validators[name] = arr;
211};
212
213/**
214 * Removes all custom form validators for a named form field.
215 *
216 * @param {string|Element} [field] the form field, name, or null for all
217 */
218RapidContext.Widget.Form.prototype.removeValidators = function (field) {
219 if (field) {
220 const name = String(field.name || field);
221 delete this._validators[name];
222 } else {
223 this._validators = {};
224 }
225};
226
227/**
228 * Calls all custom validators for a form field. The validation result will
229 * update the `setCustomValidity()` on the field.
230 *
231 * @param {Element} field the form field
232 */
233RapidContext.Widget.Form.prototype._callValidators = function (field) {
234 const validators = this._validators[field.name];
235 if (!field.disabled && validators) {
236 let res = true;
237 validators.forEach((validator) => {
238 if (res === true) {
239 res = validator.call(field, this._fieldValue(field), field, self);
240 }
241 });
242 field.setCustomValidity((res === true) ? "" : (res || "Validation failed"));
243 }
244};
245
246/**
247 * Returns an array with all child DOM nodes containing form validator widgets.
248 *
249 * @return {Array} the array of form validator widgets
250 */
251RapidContext.Widget.Form.prototype.validators = function () {
252 return Array.from(this.querySelectorAll(".widgetFormValidator"));
253};
254
255/**
256 * Validates this form using the form validators found.
257 *
258 * @return {boolean} `true` if the form validated successfully, or
259 * `false` if the validation failed
260 */
261RapidContext.Widget.Form.prototype.validate = function () {
262 this._validationTimer && clearTimeout(this._validationTimer);
263 this._validationTimer = false;
264 const fields = this.fieldMap();
265 const values = this.valueMap();
266 let success = true;
267 this.validateReset();
268 Object.keys(this._validators).forEach((name) => {
269 [].concat(fields[name]).filter(Boolean).forEach((f) => {
270 if (f.type !== "radio" || f.checked) {
271 this._callValidators(f);
272 }
273 });
274 });
275 this.validators().forEach((validator) => {
276 [].concat(fields[validator.name]).filter(Boolean).forEach((f) => {
277 success = validator.verify(f, values[f.name] || "") && success;
278 });
279 });
280 success = this.checkValidity() && success;
281 success || this.addClass("invalid");
282 delete this._validationTimer;
283 this.emit("validate", { detail: success, bubbles: true });
284 return success;
285};
286
287/**
288 * Resets all form validations. This method is automatically called when form
289 * is reset.
290 *
291 * @see #reset
292 */
293RapidContext.Widget.Form.prototype.validateReset = function () {
294 const fields = this.fieldMap();
295 Object.keys(fields).forEach((name) => {
296 [].concat(fields[name]).filter(Boolean).forEach((f) => {
297 f.setCustomValidity && f.setCustomValidity("");
298 });
299 });
300 [this, ...this.querySelectorAll(".invalid")].forEach((el) => {
301 el.classList.remove("invalid");
302 });
303 this.validators().forEach((validator) => validator.reset());
304};
305
RapidContext
Access · Discovery · Insight
www.rapidcontext.com