From 587df24e6f44c84dfa014bf45fee6a050459029f Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Tue, 13 May 2025 22:06:10 +0200 Subject: [PATCH 01/14] maint(core dom): Add test to prove that jsDOM supports input elements outside forms. --- src/core/dom.test.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/core/dom.test.js b/src/core/dom.test.js index d470b63b2..3b2564bed 100644 --- a/src/core/dom.test.js +++ b/src/core/dom.test.js @@ -10,6 +10,24 @@ describe("core.dom tests", () => { jest.restoreAllMocks(); }); + describe("jsDOM tests", () => { + it("jsDOM supports input elements outside forms.", () => { + document.body.innerHTML = ` + +
+ +
+ `; + + const outside = document.querySelector("input[name=outside]"); + const inside = document.querySelector("input[name=inside]"); + const form = document.querySelector("form"); + + expect(outside.form).toBe(form); + expect(inside.form).toBe(form); + }); + }); + describe("document_ready", () => { it("calls the callback, once the document is ready.", async () => { let cnt = 0; From a7bee57e29a315e4ad3b4a109055494632564a94 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Tue, 13 May 2025 18:03:15 +0200 Subject: [PATCH 02/14] feat(core events): Add focusin and focusout event factories. --- src/core/events.js | 18 +++++++++++++++++- src/core/events.test.js | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/core/events.js b/src/core/events.js index 724c43546..0c481f962 100644 --- a/src/core/events.js +++ b/src/core/events.js @@ -172,7 +172,7 @@ const await_pattern_init = (pattern) => { * A event factory for a bubbling and cancelable generic event. * * @param {string} name - The event name. - * @returns {Event} - Returns a blur event. + * @returns {Event} - Returns a DOM event. */ const generic_event = (name) => { return new Event(name, { @@ -231,6 +231,20 @@ const focus_event = () => { }); }; +const focusin_event = () => { + return new Event("focusin", { + bubbles: true, + cancelable: false, + }); +}; + +const focusout_event = () => { + return new Event("focusout", { + bubbles: true, + cancelable: false, + }); +}; + const input_event = () => { return new Event("input", { bubbles: true, @@ -293,6 +307,8 @@ export default { click_event: click_event, change_event: change_event, focus_event: focus_event, + focusin_event: focusin_event, + focusout_event: focusout_event, input_event: input_event, mousedown_event: mousedown_event, mouseup_event: mouseup_event, diff --git a/src/core/events.test.js b/src/core/events.test.js index 42f03b98a..9be828bd0 100644 --- a/src/core/events.test.js +++ b/src/core/events.test.js @@ -529,6 +529,24 @@ describe("core.events tests", () => { expect(catched).toBe("inner"); }); + it("focusin event", async () => { + outer.addEventListener("focusin", () => { + catched = "outer"; + }); + inner.dispatchEvent(events.focusin_event()); + await utils.timeout(1); + expect(catched).toBe("outer"); + }); + + it("focusout event", async () => { + outer.addEventListener("focusout", () => { + catched = "outer"; + }); + inner.dispatchEvent(events.focusout_event()); + await utils.timeout(1); + expect(catched).toBe("outer"); + }); + it("input event", async () => { outer.addEventListener("input", () => { catched = "outer"; From 57258bbee77631c3bafe16bf5e2bc7bac251f854 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Tue, 13 May 2025 16:53:51 +0200 Subject: [PATCH 03/14] pat-validation: Minor code cleanup. --- src/pat/validation/validation.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/pat/validation/validation.js b/src/pat/validation/validation.js index 2070a7652..4343dfcc1 100644 --- a/src/pat/validation/validation.js +++ b/src/pat/validation/validation.js @@ -104,7 +104,11 @@ class Pattern extends BasePattern { } } - check_input({ input, event, stop = false }) { + check_input({ + input, // Input to check. + event = null, // Optional event which triggered the check. + stop = false // Stop flag to avoid infinite loops. Will not check dependent inputs. + }) { if (input.disabled) { // No need to check disabled inputs. return; @@ -413,11 +417,10 @@ class Pattern extends BasePattern { } } - // Do an initial check of the whole form when a form element (e.g. the - // submit button) was disabled. We want to show the user all possible - // errors at once and after the submit button is disabled there is no - // way to check the whole form at once. ... well we also do not want to - // check the whole form when one input was changed.... + // Check the whole form when a form element (e.g. the submit button) + // was disabled. We want to show the user all possible errors at once + // and after the submit button is disabled there is no way for the user + // to check the whole form at once. if (did_disable) { logger.debug("Checking whole form after element was disabled."); for (const _input of this.inputs.filter((it) => it !== input)) { From 402d9bb83aa91baae4e8ec7bab3f27c7fa4f3d42 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Tue, 13 May 2025 17:01:56 +0200 Subject: [PATCH 04/14] tech(pat-validation): Use this.form instead of this.el for clarity. --- src/pat/validation/validation.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/pat/validation/validation.js b/src/pat/validation/validation.js index 4343dfcc1..c1a838998 100644 --- a/src/pat/validation/validation.js +++ b/src/pat/validation/validation.js @@ -39,8 +39,12 @@ class Pattern extends BasePattern { static parser = parser; init() { + // The element is the form - make it clearer in the code what we're + // referring to. + this.form = this.el; + events.add_event_listener( - this.el, + this.form, "submit", `pat-validation--submit--validator`, (e) => { @@ -59,21 +63,21 @@ class Pattern extends BasePattern { ); this.initialize_inputs(); - $(this.el).on("pat-update", () => { + $(this.form).on("pat-update", () => { this.initialize_inputs(); }); // Set ``novalidate`` attribute to disable the browser's validation // bubbles but not disable the validation API. - this.el.setAttribute("novalidate", ""); + this.form.setAttribute("novalidate", ""); } initialize_inputs() { this.inputs = [ - ...this.el.querySelectorAll("input[name], select[name], textarea[name]"), + ...this.form.querySelectorAll("input[name], select[name], textarea[name]"), ]; this.disabled_elements = [ - ...this.el.querySelectorAll(this.options.disableSelector), + ...this.form.querySelectorAll(this.options.disableSelector), ]; for (const [cnt, input] of this.inputs.entries()) { @@ -132,7 +136,7 @@ class Pattern extends BasePattern { if ( input_options.equality && - this.el.querySelector(`[name=${input_options.equality}]`)?.value !== + this.form.querySelector(`[name=${input_options.equality}]`)?.value !== input.value ) { const message = @@ -361,7 +365,7 @@ class Pattern extends BasePattern { } // disable selector - if (this.el.checkValidity()) { + if (this.form.checkValidity()) { for (const it of this.disabled_elements) { if (it.disabled) { it.removeAttribute("disabled"); From f9f06d3bee5929e827444f123455ae2aae7d5e59 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Mon, 12 May 2025 11:12:41 +0200 Subject: [PATCH 05/14] tech(pat-validation): Create a validate_all API method. --- src/pat/validation/validation.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/pat/validation/validation.js b/src/pat/validation/validation.js index c1a838998..8066f35c9 100644 --- a/src/pat/validation/validation.js +++ b/src/pat/validation/validation.js @@ -47,14 +47,11 @@ class Pattern extends BasePattern { this.form, "submit", `pat-validation--submit--validator`, - (e) => { + (event) => { // On submit, check all. // Immediate, non-debounced check with submit. Otherwise submit // is not cancelable. - for (const input of this.inputs) { - logger.debug("Checking input for submit", input, e); - this.check_input({ input: input, event: e }); - } + this.validate_all(event); }, // Make sure this event handler is run early, in the capturing // phase in order to be able to cancel later non-capturing submit @@ -72,6 +69,13 @@ class Pattern extends BasePattern { this.form.setAttribute("novalidate", ""); } + validate_all(event) { + // Check all inputs. + for (const input of this.inputs) { + this.check_input({ input: input, event: event, stop: true }); + } + } + initialize_inputs() { this.inputs = [ ...this.form.querySelectorAll("input[name], select[name], textarea[name]"), @@ -426,10 +430,7 @@ class Pattern extends BasePattern { // and after the submit button is disabled there is no way for the user // to check the whole form at once. if (did_disable) { - logger.debug("Checking whole form after element was disabled."); - for (const _input of this.inputs.filter((it) => it !== input)) { - this.check_input({ input: _input, stop: true }); - } + this.validate_all(); } } From 2c4715eb23be8842c8d6f6860a7b3583f78fe6c4 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Mon, 12 May 2025 12:05:32 +0200 Subject: [PATCH 06/14] fix(pat-validation): When removing the error, also clear the error state of all siblings. --- src/pat/validation/validation.js | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/pat/validation/validation.js b/src/pat/validation/validation.js index 8066f35c9..9832bb684 100644 --- a/src/pat/validation/validation.js +++ b/src/pat/validation/validation.js @@ -257,7 +257,7 @@ class Pattern extends BasePattern { if (!validity_state.customError) { // No error to handle. Return. - this.remove_error(input, true); + this.remove_error({ input }); return; } } else { @@ -354,8 +354,13 @@ class Pattern extends BasePattern { } } - remove_error(input, all_of_group = false, skip_event = false) { - // Remove error message and related referencesfrom input. + remove_error({ + input, + all_of_group = true, + clear_state = true, + skip_event = false, + }) { + // Remove error message and related references from input. let inputs = [input]; if (all_of_group) { @@ -363,6 +368,9 @@ class Pattern extends BasePattern { inputs = this.inputs.filter((it) => it.name === input.name); } for (const it of inputs) { + if (clear_state) { + this.set_error({ input: it, msg: "", skip_event: true }); + } const error_node = it[KEY_ERROR_EL]; it[KEY_ERROR_EL] = null; error_node?.remove(); @@ -385,7 +393,12 @@ class Pattern extends BasePattern { set_error_message(input) { // First, remove the old error message. - this.remove_error(input, false, true); + this.remove_error({ + input, + all_of_group: false, + clear_state: false, + skip_event: true + }); // Do not set a error message for a input group like radio buttons or // checkboxes where one has already been set. From 52e6b4780eb54df448b4da3335f37710a80883a5 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Tue, 13 May 2025 17:16:01 +0200 Subject: [PATCH 07/14] feat(pat-validation): Support form elements outside the form. --- src/pat/validation/documentation.md | 16 +++++++++++++++ src/pat/validation/validation.js | 29 +++++++++++++++++---------- src/pat/validation/validation.test.js | 26 ++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/pat/validation/documentation.md b/src/pat/validation/documentation.md index a3dc74fdd..62731c091 100644 --- a/src/pat/validation/documentation.md +++ b/src/pat/validation/documentation.md @@ -109,6 +109,22 @@ ValidationPattern.prototype.error_template = (message) => `${message}`; ``` + +### Form elements outside the form + +Input elements outside of form elements are fully supported. +pat-validation can handle structures like these: + +```html + +
+
+ +``` + +More information on the `form` attribute can be found at [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#form). + + ### Options reference > **_NOTE:_** The form inputs must have a `name` attribute, otherwise the diff --git a/src/pat/validation/validation.js b/src/pat/validation/validation.js index 9832bb684..bdb2c4547 100644 --- a/src/pat/validation/validation.js +++ b/src/pat/validation/validation.js @@ -69,6 +69,20 @@ class Pattern extends BasePattern { this.form.setAttribute("novalidate", ""); } + get inputs() { + // Return all inputs elements + return [...this.form.elements].filter((input) => + input.matches("input[name], select[name], textarea[name]") + ); + } + + get disableable() { + // Return all elements, which should be disabled when there are errors. + return [...this.form.elements].filter((input) => + input.matches(this.options.disableSelector) + ); + } + validate_all(event) { // Check all inputs. for (const input of this.inputs) { @@ -77,13 +91,6 @@ class Pattern extends BasePattern { } initialize_inputs() { - this.inputs = [ - ...this.form.querySelectorAll("input[name], select[name], textarea[name]"), - ]; - this.disabled_elements = [ - ...this.form.querySelectorAll(this.options.disableSelector), - ]; - for (const [cnt, input] of this.inputs.entries()) { // Cancelable debouncer. const debouncer = utils.debounce((e) => { @@ -365,7 +372,7 @@ class Pattern extends BasePattern { let inputs = [input]; if (all_of_group) { // Get all inputs with the same name - e.g. radio buttons, checkboxes. - inputs = this.inputs.filter((it) => it.name === input.name); + inputs = [...this.form.elements].filter((_input) => _input.name === input.name); } for (const it of inputs) { if (clear_state) { @@ -378,7 +385,7 @@ class Pattern extends BasePattern { // disable selector if (this.form.checkValidity()) { - for (const it of this.disabled_elements) { + for (const it of this.disableable) { if (it.disabled) { it.removeAttribute("disabled"); it.classList.remove("disabled"); @@ -402,7 +409,7 @@ class Pattern extends BasePattern { // Do not set a error message for a input group like radio buttons or // checkboxes where one has already been set. - const inputs = this.inputs.filter((it) => it.name === input.name); + const inputs = [...this.form.elements].filter((_input) => _input.name === input.name); if (inputs.length > 1 && inputs.some((it) => !!it[KEY_ERROR_EL])) { // error message for input group already set. return; @@ -426,7 +433,7 @@ class Pattern extends BasePattern { input[KEY_ERROR_EL] = error_node; let did_disable = false; - for (const it of this.disabled_elements) { + for (const it of this.disableable) { // Disable for melements if they are not already disabled and which // do not have set the `formnovalidate` attribute, e.g. // ``. diff --git a/src/pat/validation/validation.test.js b/src/pat/validation/validation.test.js index f9a0eaa62..3f1d6cde2 100644 --- a/src/pat/validation/validation.test.js +++ b/src/pat/validation/validation.test.js @@ -538,6 +538,32 @@ describe("pat-validation", function () { expect(event.detail.dom).toBe(el); expect(event.detail.action).toBe("valid"); }); + + it("1.22 - Supports validation of inputs outside forms.", async function () { + document.body.innerHTML = ` + +
+
+ + `; + const form = document.querySelector(".pat-validation"); + const input = document.querySelector("[name=outside]"); + const button = document.querySelector("button"); + + const instance = new Pattern(form); + await events.await_pattern_init(instance); + + input.value = ""; + input.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(document.querySelectorAll("em.warning").length).toBe(1); + // Skip, as jsDOM does not support the `:invalid` or `:valid` + // pseudo selectors on forms. + //expect(form.matches(":invalid")).toBe(true); + expect(input.matches(":invalid")).toBe(true); + expect(button.matches(":disabled")).toBe(true); + }); }); describe("2 - required inputs", function () { From 760924db397b1c10cb238b58cbd5af34549ce414 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Tue, 13 May 2025 11:43:44 +0200 Subject: [PATCH 08/14] feat(pat-validation): Support definition of minimum or maximum number of selections. --- src/pat/validation/documentation.md | 5 + src/pat/validation/index.html | 153 ++++++++++++++++++- src/pat/validation/validation.js | 60 +++++++- src/pat/validation/validation.test.js | 209 ++++++++++++++++++++++++++ 4 files changed, 422 insertions(+), 5 deletions(-) diff --git a/src/pat/validation/documentation.md b/src/pat/validation/documentation.md index 62731c091..99c08df04 100644 --- a/src/pat/validation/documentation.md +++ b/src/pat/validation/documentation.md @@ -20,6 +20,7 @@ These extra validation rules are: - Equality checking between two fields (e.g. password confirmation). - Date and datetime validation for before and after a given date or another input field. +- Minimum and maximum number of checked, selected or filled-out fields. Most useful for checkboxes, but also works for text-inputs, selects and other form elements. ### HTML form validation framework integration. @@ -146,7 +147,11 @@ More information on the `form` attribute can be found at [MDN](https://developer | message-number | The error message for numbers. | This value must be a number. | String | | message-required | The error message for required fields. | This field is required. | String | | message-equality | The error message for fields required to be equal | is not equal to %{attribute} | String | +| message-min-values | The error message when the minimim number of checked, selected or filled-out fields has not been reached. | You need to select at least %{count} item(s). | String | +| message-max-values | The error message when the maximum number of checked, selected or filled-out fields has not been reached. | You need to select at most %{count} item(s). | String | | equality | Field-specific extra rule. The name of another input this input should equal to (useful for password confirmation). | | String | | not-after | Field-specific extra rule. A lower time limit restriction for date and datetime fields. | | CSS Selector or a ISO8601 date string. | | not-before | Field-specific extra rule. An upper time limit restriction for date and datetime fields. | | CSS Selector or a ISO8601 date string. | +| min-values | Minimum number of checked, selected or filled out form elements. | null | Integer (or null) | +| max-values | Maximum number of checked, selected or filled out form elements. | null | Integer (or null) | | delay | Time in milliseconds before validation starts to avoid validating while typing. | 100 | Integer | diff --git a/src/pat/validation/index.html b/src/pat/validation/index.html index 595844667..8dd852e7f 100644 --- a/src/pat/validation/index.html +++ b/src/pat/validation/index.html @@ -115,8 +115,6 @@ yellow -
- @@ -250,6 +247,154 @@ +

Demo with max-values / min-values support

+
+
+ Multi select + +
+ +
+ Multiple checkboxes + + + + +
+ +
+ Demo with mixed inputs and max/min values support. +
+ +
+
+ + + + +
+
+ + + +
+
+
+ + +
+
+
max_values) { + this.set_error({ + input: input, + msg: input_options.message["max-values"], + max: max_values, + }) + } + if (min_values !== null && number_values < min_values) { + this.set_error({ + input: input, + msg: input_options.message["min-values"], + min: min_values, + }) + } } if (!validity_state.customError) { @@ -350,7 +404,11 @@ class Pattern extends BasePattern { } msg = msg.replace(/%{value}/g, JSON.stringify(input.value)); - input.setCustomValidity(msg); + // Set the error state the input itself and on all siblings, if any. + const inputs = [...this.form.elements].filter((_input) => _input.name === input.name); + for (const _input of inputs) { + _input.setCustomValidity(msg); + } // Store the error message on the input. // Hidden inputs do not participate in validation but we need this // (e.g. styled date input). diff --git a/src/pat/validation/validation.test.js b/src/pat/validation/validation.test.js index 3f1d6cde2..0dabf6c90 100644 --- a/src/pat/validation/validation.test.js +++ b/src/pat/validation/validation.test.js @@ -1535,4 +1535,213 @@ describe("pat-validation", function () { expect(el.querySelector("#form-buttons-create").disabled).toBe(false); }); }); + + describe("8 - min/max value validation", function () { + it("8.1 - validate min and max number of checked checkboxes", async function () { + document.body.innerHTML = ` + +
+ + + + +
+
+ `; + + const form = document.querySelector("form"); + const check1 = form.querySelector("[name=check][value='1']"); + const check2 = form.querySelector("[name=check][value='2']"); + const check3 = form.querySelector("[name=check][value='3']"); + const check4 = form.querySelector("[name=check][value='4']"); + + const instance = new Pattern(form); + await events.await_pattern_init(instance); + + check1.checked = true; + check1.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(check1.matches(":invalid")).toBe(true); + expect(check2.matches(":invalid")).toBe(true); + expect(check3.matches(":invalid")).toBe(true); + expect(check4.matches(":invalid")).toBe(true); + expect(form.querySelectorAll("em.warning").length).toBe(1); + expect(form.querySelectorAll("em.warning")[0].textContent).toBe( + "Please select at least 2 options" + ); + + check2.checked = true; + check2.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(check1.matches(":invalid")).toBe(false); + expect(check2.matches(":invalid")).toBe(false); + expect(check3.matches(":invalid")).toBe(false); + expect(check4.matches(":invalid")).toBe(false); + expect(form.querySelectorAll("em.warning").length).toBe(0); + + check3.checked = true; + check3.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(check1.matches(":invalid")).toBe(false); + expect(check2.matches(":invalid")).toBe(false); + expect(check3.matches(":invalid")).toBe(false); + expect(check4.matches(":invalid")).toBe(false); + expect(form.querySelectorAll("em.warning").length).toBe(0); + + check4.checked = true; + check4.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(check1.matches(":invalid")).toBe(true); + expect(check2.matches(":invalid")).toBe(true); + expect(check3.matches(":invalid")).toBe(true); + expect(check4.matches(":invalid")).toBe(true); + expect(form.querySelectorAll("em.warning").length).toBe(1); + expect(form.querySelectorAll("em.warning")[0].textContent).toBe( + "Please select at most 3 options" + ); + + check4.checked = false; + check4.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(check1.matches(":invalid")).toBe(false); + expect(check2.matches(":invalid")).toBe(false); + expect(check3.matches(":invalid")).toBe(false); + expect(check4.matches(":invalid")).toBe(false); + expect(form.querySelectorAll("em.warning").length).toBe(0); + }); + + it("8.2 - validate min and max number with mixed inputs", async function () { + document.body.innerHTML = ` +
+
+ + + + + +
+
+ `; + + const form = document.querySelector("form"); + const id1 = form.querySelector("#i1"); + const id2 = form.querySelector("#i2"); + const id3 = form.querySelector("#i3"); + const id31 = form.querySelector("#i31"); + const id4 = form.querySelector("#i4"); + const id5 = form.querySelector("#i5"); + + const instance = new Pattern(form); + await events.await_pattern_init(instance); + + id1.checked = true; + id1.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(id1.matches(":invalid")).toBe(true); + expect(id2.matches(":invalid")).toBe(true); + expect(id3.matches(":invalid")).toBe(true); + expect(id4.matches(":invalid")).toBe(true); + expect(id5.matches(":invalid")).toBe(true); + expect(form.querySelectorAll("em.warning").length).toBe(1); + expect(form.querySelectorAll("em.warning")[0].textContent).toBe( + "Please select at least 2 options" + ); + + id2.checked = true; + id2.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(id1.matches(":invalid")).toBe(false); + expect(id2.matches(":invalid")).toBe(false); + expect(id3.matches(":invalid")).toBe(false); + expect(id4.matches(":invalid")).toBe(false); + expect(id5.matches(":invalid")).toBe(false); + expect(form.querySelectorAll("em.warning").length).toBe(0); + + id31.selected = true; + id3.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(id1.matches(":invalid")).toBe(false); + expect(id2.matches(":invalid")).toBe(false); + expect(id3.matches(":invalid")).toBe(false); + expect(id4.matches(":invalid")).toBe(false); + expect(id5.matches(":invalid")).toBe(false); + expect(form.querySelectorAll("em.warning").length).toBe(0); + + id4.value = "okay"; + id4.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(id1.matches(":invalid")).toBe(true); + expect(id2.matches(":invalid")).toBe(true); + expect(id3.matches(":invalid")).toBe(true); + expect(id4.matches(":invalid")).toBe(true); + expect(id5.matches(":invalid")).toBe(true); + expect(form.querySelectorAll("em.warning").length).toBe(1); + expect(form.querySelectorAll("em.warning")[0].textContent).toBe( + "Please select at most 3 options" + ); + + id2.checked = false; + id2.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(id1.matches(":invalid")).toBe(false); + expect(id2.matches(":invalid")).toBe(false); + expect(id3.matches(":invalid")).toBe(false); + expect(id4.matches(":invalid")).toBe(false); + expect(id5.matches(":invalid")).toBe(false); + expect(form.querySelectorAll("em.warning").length).toBe(0); + + id5.value = "okay"; + id4.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(id1.matches(":invalid")).toBe(true); + expect(id2.matches(":invalid")).toBe(true); + expect(id3.matches(":invalid")).toBe(true); + expect(id4.matches(":invalid")).toBe(true); + expect(id5.matches(":invalid")).toBe(true); + expect(form.querySelectorAll("em.warning").length).toBe(1); + expect(form.querySelectorAll("em.warning")[0].textContent).toBe( + "Please select at most 3 options" + ); + + id4.value = ""; + id4.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(id1.matches(":invalid")).toBe(false); + expect(id2.matches(":invalid")).toBe(false); + expect(id3.matches(":invalid")).toBe(false); + expect(id4.matches(":invalid")).toBe(false); + expect(id5.matches(":invalid")).toBe(false); + expect(form.querySelectorAll("em.warning").length).toBe(0); + }); + + }); + }); From 43a3f6c8a0994ba5860b95567edc2d7400017513 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Tue, 13 May 2025 16:58:14 +0200 Subject: [PATCH 09/14] fix(pat-validation): Put error messages always at the end of a group of inputs with the same name. --- src/pat/validation/documentation.md | 4 ++-- src/pat/validation/validation.js | 12 +++--------- src/pat/validation/validation.test.js | 21 +++++++++++++++++++++ 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/pat/validation/documentation.md b/src/pat/validation/documentation.md index 99c08df04..00f72e0dd 100644 --- a/src/pat/validation/documentation.md +++ b/src/pat/validation/documentation.md @@ -89,10 +89,10 @@ In addition both the input element and its label will get an `warning` class. ``` -Checkboxes and radio buttons are treated differently: if they are contained in a fieldset with class `checklist` error messages are added at the end of the fieldset. +Checkboxes and radio buttons are treated differently: The error message is alywas set after the last element of the inputs with the same name. ```html -
+
diff --git a/src/pat/validation/validation.js b/src/pat/validation/validation.js index 198cf2a70..1d7a588e4 100644 --- a/src/pat/validation/validation.js +++ b/src/pat/validation/validation.js @@ -479,15 +479,9 @@ class Pattern extends BasePattern { this.error_template(validation_message) ).firstChild; - let fieldset; - if (input.type === "radio" || input.type === "checkbox") { - fieldset = input.closest("fieldset.pat-checklist"); - } - if (fieldset) { - fieldset.append(error_node); - } else { - input.after(error_node); - } + // Put error messge after the erronous input or - in case of multiple + // inputs with the same name - after the last one of the group. + inputs.pop().after(error_node); input[KEY_ERROR_EL] = error_node; let did_disable = false; diff --git a/src/pat/validation/validation.test.js b/src/pat/validation/validation.test.js index 0dabf6c90..4c2241e0f 100644 --- a/src/pat/validation/validation.test.js +++ b/src/pat/validation/validation.test.js @@ -564,6 +564,27 @@ describe("pat-validation", function () { expect(input.matches(":invalid")).toBe(true); expect(button.matches(":disabled")).toBe(true); }); + + it("1.23 - Puts the warning at the end of same-name inputs.", async function () { + document.body.innerHTML = ` +
+ + + +
+ `; + const form = document.querySelector(".pat-validation"); + + const instance = new Pattern(form); + await events.await_pattern_init(instance); + + form.dispatchEvent(events.submit_event()); + await utils.timeout(1); // wait a tick for async to settle. + + const warning = form.querySelector("em.warning"); + expect(warning).toBeTruthy(); + expect(warning.matches("input:nth-child(3) + em.warning")).toBe(true); + }); }); describe("2 - required inputs", function () { From 5e9e0daf3fa16ef0bdc6f9221cbde613a37d81b7 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Tue, 13 May 2025 18:05:33 +0200 Subject: [PATCH 10/14] feat(pat-validation): Support dynamic forms. Introduce event delegation and register input, change and focusout event handlers on the document level. This reduces the amount of registered event handlers which potentially improves performance and also supports dynamic forms where form elements can be added at any time. --- src/pat/auto-suggest/auto-suggest.js | 6 +- src/pat/date-picker/date-picker.js | 6 +- src/pat/validation/documentation.md | 6 ++ src/pat/validation/validation.js | 84 ++++++++++++++++----------- src/pat/validation/validation.test.js | 39 ++++++++++++- 5 files changed, 99 insertions(+), 42 deletions(-) diff --git a/src/pat/auto-suggest/auto-suggest.js b/src/pat/auto-suggest/auto-suggest.js index a322dcb70..1a484cea5 100644 --- a/src/pat/auto-suggest/auto-suggest.js +++ b/src/pat/auto-suggest/auto-suggest.js @@ -122,9 +122,9 @@ export default Base.extend({ const val = $sel2.select2("val"); if (val?.length === 0) { // catches "" and [] - // blur the input field so that pat-validate can kick in when - // nothing was selected. - this.el.dispatchEvent(events.blur_event()); + // focus-out the input field so that pat-validate can kick in + // when nothing was selected. + this.el.dispatchEvent(events.focusout_event()); } }; this.$el.on("select2-close", initiate_empty_check.bind(this)); diff --git a/src/pat/date-picker/date-picker.js b/src/pat/date-picker/date-picker.js index 85142b5f6..8336620ad 100644 --- a/src/pat/date-picker/date-picker.js +++ b/src/pat/date-picker/date-picker.js @@ -173,9 +173,9 @@ export default Base.extend({ onSelect: () => this.dispatch_change_event(), onClose: () => { if (this.options.behavior === "styled" && !this.el.value) { - // blur the input field so that pat-validate can kick in when - // nothing was selected. - el.dispatchEvent(events.blur_event()); + // focus-out the input field so that pat-validate can kick + // in when nothing was selected. + el.dispatchEvent(events.focusout_event()); } }, }; diff --git a/src/pat/validation/documentation.md b/src/pat/validation/documentation.md index 00f72e0dd..fb03a7a63 100644 --- a/src/pat/validation/documentation.md +++ b/src/pat/validation/documentation.md @@ -126,6 +126,12 @@ pat-validation can handle structures like these: More information on the `form` attribute can be found at [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#form). +### Dynamic forms + +pat-validation supports dynamic forms where form elements are added after the Pattern was initialized. +There is no need to re-initialize the pattern of to dispatch a special event. + + ### Options reference > **_NOTE:_** The form inputs must have a `name` attribute, otherwise the diff --git a/src/pat/validation/validation.js b/src/pat/validation/validation.js index 1d7a588e4..a03d7c517 100644 --- a/src/pat/validation/validation.js +++ b/src/pat/validation/validation.js @@ -1,6 +1,5 @@ // Patterns validate - Form vlidation import "../../core/polyfills"; // SubmitEvent.submitter for Safari < 15.4 and jsDOM -import $ from "jquery"; import { BasePattern } from "../../core/basepattern"; import Parser from "../../core/parser"; import dom from "../../core/dom"; @@ -63,10 +62,56 @@ class Pattern extends BasePattern { { capture: true } ); - this.initialize_inputs(); - $(this.form).on("pat-update", () => { - this.initialize_inputs(); - }); + // Input debouncer map: + // - key: input element + // - value: debouncer function + // 1) We want do debounce the validation checks to avoid validating + // while typing. + // 2) We want to debounce the input events individually, so that we can + // do multiple checks in parallel and show multiple errors at once. + const input_debouncer_map = new Map(); + const debounce_filter = (e) => { + const input = e.target; + if (input?.form !== this.form || ! this.inputs.includes(input)) { + // Ignore events from other forms or from elements which are + // not inputs. + return; + } + + if (! input_debouncer_map.has(input)) { + // Create a new cancelable debouncer for this input. + input_debouncer_map.set(input, utils.debounce((e) => { + logger.debug("Checking input for event", input, e); + this.check_input({ input: input, event: e }); + }, this.options.delay)); + } + + // Get the debouncer for this input. + const debouncer = input_debouncer_map.get(input); + // Debounce the validation check. + debouncer(input, e); + }; + + events.add_event_listener( + document, + "input", + `pat-validation--${this.uuid}--input--validator`, + (e) => debounce_filter(e) + ); + + events.add_event_listener( + document, + "change", + `pat-validation--${this.uuid}--change--validator`, + (e) => debounce_filter(e) + ); + + events.add_event_listener( + document, + "focusout", + `pat-validation--${this.uuid}--focusout--validator`, + (e) => debounce_filter(e) + ); // Set ``novalidate`` attribute to disable the browser's validation // bubbles but not disable the validation API. @@ -94,35 +139,6 @@ class Pattern extends BasePattern { } } - initialize_inputs() { - for (const [cnt, input] of this.inputs.entries()) { - // Cancelable debouncer. - const debouncer = utils.debounce((e) => { - logger.debug("Checking input for event", input, e); - this.check_input({ input: input, event: e }); - }, this.options.delay); - - events.add_event_listener( - input, - "input", - `pat-validation--input-${input.name}--${cnt}--validator`, - (e) => debouncer(e) - ); - events.add_event_listener( - input, - "change", - `pat-validation--change-${input.name}--${cnt}--validator`, - (e) => debouncer(e) - ); - events.add_event_listener( - input, - "blur", - `pat-validation--blur-${input.name}--${cnt}--validator`, - (e) => debouncer(e) - ); - } - } - check_input({ input, // Input to check. event = null, // Optional event which triggered the check. diff --git a/src/pat/validation/validation.test.js b/src/pat/validation/validation.test.js index 4c2241e0f..9b685517f 100644 --- a/src/pat/validation/validation.test.js +++ b/src/pat/validation/validation.test.js @@ -477,7 +477,7 @@ describe("pat-validation", function () { const instance = new Pattern(el); await events.await_pattern_init(instance); - document.querySelector("[name=i1]").dispatchEvent(events.blur_event()); + document.querySelector("[name=i1]").dispatchEvent(events.focusout_event()); await utils.timeout(1); // wait a tick for async to settle. expect(el.querySelectorAll("em.warning").length).toBe(2); @@ -498,7 +498,7 @@ describe("pat-validation", function () { const instance = new Pattern(el); await events.await_pattern_init(instance); - document.querySelector("[name=i1]").dispatchEvent(events.blur_event()); + document.querySelector("[name=i1]").dispatchEvent(events.focusout_event()); await utils.timeout(1); // wait a tick for async to settle. expect(el.querySelectorAll("em.warning").length).toBe(1); @@ -585,6 +585,41 @@ describe("pat-validation", function () { expect(warning).toBeTruthy(); expect(warning.matches("input:nth-child(3) + em.warning")).toBe(true); }); + + it("1.24 - Supports dynamic forms.", async function () { + document.body.innerHTML = ` +
+ +
+ `; + const form = document.querySelector(".pat-validation"); + const input1 = form.querySelector("[name=input1]"); + + const instance = new Pattern(form); + await events.await_pattern_init(instance); + + form.dispatchEvent(events.submit_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(document.querySelectorAll("em.warning").length).toBe(1); + + input1.value = "ok"; + input1.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(document.querySelectorAll("em.warning").length).toBe(0); + + const input2 = document.createElement("input"); + input2.name = "input2"; + input2.required = true; + form.appendChild(input2); + + form.dispatchEvent(events.submit_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(document.querySelectorAll("em.warning").length).toBe(1); + expect(document.querySelector("em.warning").matches("input[name=input2] + em.warning")).toBe(true); + }); }); describe("2 - required inputs", function () { From d10e21c4c111913409912d9605f5ed2500000928 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Wed, 14 May 2025 22:38:24 +0200 Subject: [PATCH 11/14] fix(pat-validation): Support the combination of multiple custom validation constraints. --- src/pat/validation/validation.js | 16 ++++++- src/pat/validation/validation.test.js | 67 +++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/pat/validation/validation.js b/src/pat/validation/validation.js index a03d7c517..14fba9b34 100644 --- a/src/pat/validation/validation.js +++ b/src/pat/validation/validation.js @@ -178,7 +178,13 @@ class Pattern extends BasePattern { msg: message, attribute: input_options.equality, }); - } else if (input_options.not.after || input_options.not.before) { + } + + if ( + ! validity_state.customError && // No error from previous checks. + input_options.not.after || + input_options.not.before + ) { const msg = input_options.message.date || input_options.message.datetime; const msg_default_not_before = "The date must be after %{attribute}"; const msg_default_not_after = "The date must be before %{attribute}"; @@ -280,7 +286,13 @@ class Pattern extends BasePattern { logger.debug("Check `no-before` input.", not_after_el); this.check_input({ input: not_before_el, stop: true }); } - } else if (input_options.minValues || input_options.maxValues) { + } + + if ( + ! validity_state.customError && // No error from previous checks. + input_options.minValues || + input_options.maxValues + ) { const min_values = input_options.minValues !== null && parseInt(input_options.minValues, 10) || null; const max_values = input_options.maxValues !== null && parseInt(input_options.maxValues, 10) || null; diff --git a/src/pat/validation/validation.test.js b/src/pat/validation/validation.test.js index 9b685517f..564fdd7f2 100644 --- a/src/pat/validation/validation.test.js +++ b/src/pat/validation/validation.test.js @@ -620,6 +620,73 @@ describe("pat-validation", function () { expect(document.querySelectorAll("em.warning").length).toBe(1); expect(document.querySelector("em.warning").matches("input[name=input2] + em.warning")).toBe(true); }); + + it("1.25 - Can combine multiple custom validation rules.", async function () { + document.body.innerHTML = ` +
+
+ + +
+ +
+ `; + const form = document.querySelector(".pat-validation"); + const input1 = document.querySelector("#i1"); + const input2 = document.querySelector("#i2"); + const input3 = document.querySelector("#i3"); + const button = document.querySelector("button"); + + const instance = new Pattern(form); + await events.await_pattern_init(instance); + + input1.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + // form is invalid due to input1's required attribute + expect(input1.matches(":invalid")).toBe(true); + expect(document.querySelectorAll("em.warning").length).toBe(1); + expect(document.querySelectorAll("em.warning")[0].textContent).toBe( + "is required" + ); + + input1.value = "ok"; + input1.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + // form is invalid due to input1's equality constraint + expect(input1.matches(":invalid")).toBe(true); + expect(document.querySelectorAll("em.warning").length).toBe(1); + expect(document.querySelectorAll("em.warning")[0].textContent).toBe( + "not equal" + ); + + input3.value = "ok"; + input1.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + // form is invalid due to input1's equality constraint + expect(input1.matches(":invalid")).toBe(true); + expect(document.querySelectorAll("em.warning").length).toBe(1); + expect(document.querySelectorAll("em.warning")[0].textContent).toBe( + "too few" + ); + + input2.value = "ok"; + form.dispatchEvent(events.submit_event()); + await utils.timeout(1); // wait a tick for async to settle. + + // form is now valid. + expect(input1.matches(":invalid")).toBe(false); + expect(document.querySelectorAll("em.warning").length).toBe(0); + }); }); describe("2 - required inputs", function () { From 6f08bd7c5996759dd85249e8a011efb03cb99698 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Wed, 14 May 2025 22:52:05 +0200 Subject: [PATCH 12/14] tech(pat-validation): Generalize the method to retrieve siblings. --- src/pat/validation/validation.js | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/pat/validation/validation.js b/src/pat/validation/validation.js index 14fba9b34..f04a64167 100644 --- a/src/pat/validation/validation.js +++ b/src/pat/validation/validation.js @@ -132,6 +132,11 @@ class Pattern extends BasePattern { ); } + siblings(input) { + // Get all siblings of an input with the same name. + return this.inputs.filter((_input) => _input.name === input.name); + } + validate_all(event) { // Check all inputs. for (const input of this.inputs) { @@ -297,17 +302,7 @@ class Pattern extends BasePattern { const max_values = input_options.maxValues !== null && parseInt(input_options.maxValues, 10) || null; let number_values = 0; - for (const _inp of this.form.elements) { - // Filter for siblings with same name. - if ( - // Keep only inputs with same name - _inp.name !== input.name - // Skip all form elements which are no input elements - || ! ["INPUT", "SELECT", "TEXTAREA"].includes(_inp.tagName) - ) { - continue; - } - + for (const _inp of this.siblings(input)) { // Check if checkboxes or radios are checked ... if (_inp.type === "checkbox" || _inp.type === "radio") { if (_inp.checked) { @@ -433,8 +428,7 @@ class Pattern extends BasePattern { msg = msg.replace(/%{value}/g, JSON.stringify(input.value)); // Set the error state the input itself and on all siblings, if any. - const inputs = [...this.form.elements].filter((_input) => _input.name === input.name); - for (const _input of inputs) { + for (const _input of this.siblings(input)) { _input.setCustomValidity(msg); } // Store the error message on the input. @@ -458,7 +452,7 @@ class Pattern extends BasePattern { let inputs = [input]; if (all_of_group) { // Get all inputs with the same name - e.g. radio buttons, checkboxes. - inputs = [...this.form.elements].filter((_input) => _input.name === input.name); + inputs = this.siblings(input); } for (const it of inputs) { if (clear_state) { @@ -495,7 +489,7 @@ class Pattern extends BasePattern { // Do not set a error message for a input group like radio buttons or // checkboxes where one has already been set. - const inputs = [...this.form.elements].filter((_input) => _input.name === input.name); + const inputs = this.siblings(input); if (inputs.length > 1 && inputs.some((it) => !!it[KEY_ERROR_EL])) { // error message for input group already set. return; From 3225dbdef84cdf34d55dfd24cd2bbb667eb31393 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Mon, 19 May 2025 13:58:33 +0200 Subject: [PATCH 13/14] tech(pat-validation): Cleanup - better variable names. --- src/pat/validation/validation.js | 42 ++++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/pat/validation/validation.js b/src/pat/validation/validation.js index f04a64167..82ea5887a 100644 --- a/src/pat/validation/validation.js +++ b/src/pat/validation/validation.js @@ -125,7 +125,7 @@ class Pattern extends BasePattern { ); } - get disableable() { + get disableables() { // Return all elements, which should be disabled when there are errors. return [...this.form.elements].filter((input) => input.matches(this.options.disableSelector) @@ -302,23 +302,23 @@ class Pattern extends BasePattern { const max_values = input_options.maxValues !== null && parseInt(input_options.maxValues, 10) || null; let number_values = 0; - for (const _inp of this.siblings(input)) { + for (const _input of this.siblings(input)) { // Check if checkboxes or radios are checked ... - if (_inp.type === "checkbox" || _inp.type === "radio") { - if (_inp.checked) { + if (_input.type === "checkbox" || _input.type === "radio") { + if (_input.checked) { number_values++; } continue; } // Select, if select is selected. - if (_inp.tagName === "SELECT") { - number_values += _inp.selectedOptions.length; + if (_input.tagName === "SELECT") { + number_values += _input.selectedOptions.length; continue; } // For the rest a value must be set. - if (_inp.value === 0 || _inp.value) { + if (_input.value === 0 || _input.value) { number_values++; } } @@ -454,21 +454,21 @@ class Pattern extends BasePattern { // Get all inputs with the same name - e.g. radio buttons, checkboxes. inputs = this.siblings(input); } - for (const it of inputs) { + for (const _input of inputs) { if (clear_state) { - this.set_error({ input: it, msg: "", skip_event: true }); + this.set_error({ input: _input, msg: "", skip_event: true }); } - const error_node = it[KEY_ERROR_EL]; - it[KEY_ERROR_EL] = null; + const error_node = _input[KEY_ERROR_EL]; + _input[KEY_ERROR_EL] = null; error_node?.remove(); } // disable selector if (this.form.checkValidity()) { - for (const it of this.disableable) { - if (it.disabled) { - it.removeAttribute("disabled"); - it.classList.remove("disabled"); + for (const _input of this.disableables) { + if (_input.disabled) { + _input.removeAttribute("disabled"); + _input.classList.remove("disabled"); } } } @@ -490,7 +490,7 @@ class Pattern extends BasePattern { // Do not set a error message for a input group like radio buttons or // checkboxes where one has already been set. const inputs = this.siblings(input); - if (inputs.length > 1 && inputs.some((it) => !!it[KEY_ERROR_EL])) { + if (inputs.length > 1 && inputs.some((_input) => !!_input[KEY_ERROR_EL])) { // error message for input group already set. return; } @@ -507,15 +507,15 @@ class Pattern extends BasePattern { input[KEY_ERROR_EL] = error_node; let did_disable = false; - for (const it of this.disableable) { + for (const _input of this.disableables) { // Disable for melements if they are not already disabled and which // do not have set the `formnovalidate` attribute, e.g. // ``. - if (!it.disabled && !it.formNoValidate) { + if (!_input.disabled && !_input.formNoValidate) { did_disable = true; - it.setAttribute("disabled", "disabled"); - it.classList.add("disabled"); - logger.debug("Disable element", it); + _input.setAttribute("disabled", "disabled"); + _input.classList.add("disabled"); + logger.debug("Disable element", _input); } } From cc1e043bedc101550a87fe6ec73e6a678892f3a1 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Mon, 19 May 2025 14:49:40 +0200 Subject: [PATCH 14/14] tech(pat-validation): Cleanup. --- src/pat/validation/validation.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pat/validation/validation.js b/src/pat/validation/validation.js index 82ea5887a..e8be90a81 100644 --- a/src/pat/validation/validation.js +++ b/src/pat/validation/validation.js @@ -338,12 +338,6 @@ class Pattern extends BasePattern { }) } } - - if (!validity_state.customError) { - // No error to handle. Return. - this.remove_error({ input }); - return; - } } else { // Default error cases with custom messages. @@ -405,6 +399,12 @@ class Pattern extends BasePattern { } + if (validity_state.valid) { + // No error to handle. Remove eventual error and return. + this.remove_error({ input }); + return; + } + if (event?.type === "submit") { // Do not submit in error case and prevent other handlers to take action. event.preventDefault();