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; 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"; 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 a3dc74fdd..fb03a7a63 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. @@ -88,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 -
+
@@ -109,6 +110,28 @@ 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). + + +### 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 @@ -130,7 +153,11 @@ ValidationPattern.prototype.error_template = (message) => | 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. +
+ +
+
+ + + + +
+
+ + + +
+
+
+ + +
+
+
{ + (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 @@ -58,53 +62,93 @@ class Pattern extends BasePattern { { capture: true } ); - this.initialize_inputs(); - $(this.el).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. - this.el.setAttribute("novalidate", ""); + 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) + ); + } + + siblings(input) { + // Get all siblings of an input with the same name. + return this.inputs.filter((_input) => _input.name === input.name); } - initialize_inputs() { - this.inputs = [ - ...this.el.querySelectorAll("input[name], select[name], textarea[name]"), - ]; - this.disabled_elements = [ - ...this.el.querySelectorAll(this.options.disableSelector), - ]; - - 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) - ); + validate_all(event) { + // Check all inputs. + for (const input of this.inputs) { + this.check_input({ input: input, event: event, stop: true }); } } - 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; @@ -128,7 +172,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 = @@ -139,7 +183,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}"; @@ -243,9 +293,55 @@ class Pattern extends BasePattern { } } + 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; + + let number_values = 0; + for (const _inp of this.siblings(input)) { + // Check if checkboxes or radios are checked ... + if (_inp.type === "checkbox" || _inp.type === "radio") { + if (_inp.checked) { + number_values++; + } + continue; + } + + // Select, if select is selected. + if (_inp.tagName === "SELECT") { + number_values += _inp.selectedOptions.length; + continue; + } + + // For the rest a value must be set. + if (_inp.value === 0 || _inp.value) { + number_values++; + } + } + + if (max_values !== null && number_values > 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) { // No error to handle. Return. - this.remove_error(input, true); + this.remove_error({ input }); return; } } else { @@ -331,7 +427,10 @@ 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. + for (const _input of this.siblings(input)) { + _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). @@ -342,23 +441,31 @@ 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) { // Get all inputs with the same name - e.g. radio buttons, checkboxes. - inputs = this.inputs.filter((it) => it.name === input.name); + inputs = this.siblings(input); } 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(); } // disable selector - if (this.el.checkValidity()) { - for (const it of this.disabled_elements) { + if (this.form.checkValidity()) { + for (const it of this.disableable) { if (it.disabled) { it.removeAttribute("disabled"); it.classList.remove("disabled"); @@ -373,11 +480,16 @@ 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. - const inputs = this.inputs.filter((it) => it.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; @@ -389,19 +501,13 @@ 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; - 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. // ``. @@ -413,16 +519,12 @@ 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)) { - this.check_input({ input: _input, stop: true }); - } + this.validate_all(); } } diff --git a/src/pat/validation/validation.test.js b/src/pat/validation/validation.test.js index f9a0eaa62..564fdd7f2 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); @@ -538,6 +538,155 @@ 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); + }); + + 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); + }); + + 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); + }); + + 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 () { @@ -1509,4 +1658,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); + }); + + }); + });