diff --git a/src/core/dom.js b/src/core/dom.js index f61c0f617..c0c901bd9 100644 --- a/src/core/dom.js +++ b/src/core/dom.js @@ -102,16 +102,11 @@ const is_visible = (el) => { /** * Test, if a element is a input-type element. * - * This is taken from Sizzle/jQuery at: - * https://github.com/jquery/sizzle/blob/f2a2412e5e8a5d9edf168ae3b6633ac8e6bd9f2e/src/sizzle.js#L139 - * https://github.com/jquery/sizzle/blob/f2a2412e5e8a5d9edf168ae3b6633ac8e6bd9f2e/src/sizzle.js#L1773 - * * @param {Node} el - The DOM node to test. * @returns {Boolean} - True if the element is a input-type element. */ const is_input = (el) => { - const re_input = /^(?:input|select|textarea|button)$/i; - return re_input.test(el.nodeName); + return el.matches("button, input, select, textarea"); }; /** diff --git a/src/core/registry.js b/src/core/registry.js index 03fd0430c..cbd57d5ef 100644 --- a/src/core/registry.js +++ b/src/core/registry.js @@ -133,6 +133,16 @@ const registry = { }, orderPatterns(patterns) { + // Resort patterns and set those with `sort_early` to the beginning. + // NOTE: Only use when necessary and it's not guaranteed that a pattern + // with `sort_early` is set to the beginning. Last come, first serve. + for (const name of [...patterns]) { + if (registry[name]?.sort_early) { + patterns.splice(patterns.indexOf(name), 1); + patterns.unshift(name); + } + } + // Always add pat-validation as first pattern, so that it can prevent // other patterns from reacting to submit events if form validation // fails. @@ -140,6 +150,7 @@ const registry = { patterns.splice(patterns.indexOf("validation"), 1); patterns.unshift("validation"); } + // Add clone-code to the very beginning - we want to copy the markup // before any other patterns changed the markup. if (patterns.includes("clone-code")) { @@ -180,17 +191,16 @@ const registry = { ); matches = matches.filter((el) => { // Filter out patterns: - // - with class ``.disable-patterns`` - // - wrapped in ``.disable-patterns`` elements + // - with class ``.disable-patterns`` or wrapped within. // - wrapped in ``
`` elements // - wrapped in ```` elements return ( - !el.matches(".disable-patterns") && - !el?.parentNode?.closest?.(".disable-patterns") && + !el?.closest?.(".disable-patterns") && !el?.parentNode?.closest?.("pre") && - !el?.parentNode?.closest?.("template") && // NOTE: not strictly necessary. Template is a DocumentFragment and not reachable except for IE. - !el.matches(".cant-touch-this") && // BBB. TODO: Remove with next major version. - !el?.parentNode?.closest?.(".cant-touch-this") // BBB. TODO: Remove with next major version. + // BBB. TODO: Remove with next major version. + !el?.closest?.(".cant-touch-this") + // NOTE: templates are not reachabne anyways. + //!el?.parentNode?.closest?.("template") ); }); diff --git a/src/lib/input-change-events.js b/src/lib/input-change-events.js index 3048c4f18..5bd38fb1f 100644 --- a/src/lib/input-change-events.js +++ b/src/lib/input-change-events.js @@ -1,25 +1,30 @@ // helper functions to make all input elements import $ from "jquery"; +import dom from "../core/dom"; import logging from "../core/logging"; -var namespace = "input-change-events"; + +const namespace = "input-change-events"; const log = logging.getLogger(namespace); -var _ = { - setup: function ($el, pat) { +const _ = { + setup($el, pat) { if (!pat) { log.error("The name of the calling pattern has to be set."); return; } + // list of patterns that installed input-change-event handlers - var patterns = $el.data(namespace) || []; + const patterns = $el.data(namespace) || []; log.debug("setup handlers for " + pat); + const el = $el[0]; + if (!patterns.length) { log.debug("installing handlers"); - _.setupInputHandlers($el); + this.setupInputHandlers(el); - $el.on("patterns-injected." + namespace, function (event) { - _.setupInputHandlers($(event.target)); + $el.on("patterns-injected." + namespace, (event) => { + this.setupInputHandlers(event.target); }); } if (patterns.indexOf(pat) === -1) { @@ -28,53 +33,69 @@ var _ = { } }, - setupInputHandlers: function ($el) { - if (!$el.is(":input")) { + setupInputHandlers(el) { + if (dom.is_input(el)) { + // The element itself is an input, se we simply register a + // handler fot it. + console.log("1"); + this.registerHandlersForElement({ trigger_source: el, trigger_target: el }); + } else { // We've been given an element that is not a form input. We // therefore assume that it's a container of form inputs and // register handlers for its children. - $el.findInclusive(":input").each(_.registerHandlersForElement); - } else { - // The element itself is an input, se we simply register a - // handler fot it. - _.registerHandlersForElement.bind($el)(); + console.log("2"); + const form = el.closest("form"); + for (const _el of form.elements) { + console.log("3", _el); + // Search for all form elements, also those outside the form + // container. + if (!dom.is_input(_el)) { + // form.elements also catches fieldsets, object, output, + // which we do not want to handle here. + continue; + } + this.registerHandlersForElement({ + trigger_source: _el, + trigger_target: form, + }); + } } }, - registerHandlersForElement: function () { - var $el = $(this), - isNumber = $el.is("input[type=number]"), - isText = $el.is("input:text, input[type=search], textarea"); + registerHandlersForElement({ trigger_source, trigger_target }) { + const $trigger_source = $(trigger_source); + const $trigger_target = $(trigger_target); + const isNumber = trigger_source.matches("input[type=number]"); + const isText = trigger_source.matches( + "input:not(type), input[type=text], input[type=search], textarea" + ); if (isNumber) { - // for we want to trigger the change - // on keyup - if ("onkeyup" in window) { - $el.on("keyup." + namespace, function () { - log.debug("translating keyup"); - $el.trigger("input-change"); - }); - } + // for number inputs we want to trigger the change on keyup + $trigger_source.on("keyup." + namespace, function () { + log.debug("translating keyup"); + $trigger_target.trigger("input-change"); + }); } if (isText || isNumber) { - $el.on("input." + namespace, function () { + $trigger_source.on("input." + namespace, function () { log.debug("translating input"); - $el.trigger("input-change"); + $trigger_target.trigger("input-change"); }); } else { - $el.on("change." + namespace, function () { + $trigger_source.on("change." + namespace, function () { log.debug("translating change"); - $el.trigger("input-change"); + $trigger_target.trigger("input-change"); }); } - $el.on("blur", function () { - $el.trigger("input-defocus"); + $trigger_source.on("blur", function () { + $trigger_target.trigger("input-defocus"); }); }, - remove: function ($el, pat) { - var patterns = $el.data(namespace) || []; + remove($el, pat) { + let patterns = $el.data(namespace) || []; if (patterns.indexOf(pat) === -1) { log.warn("input-change-events were never installed for " + pat); } else { diff --git a/src/pat/auto-submit/auto-submit.js b/src/pat/auto-submit/auto-submit.js index 6786ef050..1e129670c 100644 --- a/src/pat/auto-submit/auto-submit.js +++ b/src/pat/auto-submit/auto-submit.js @@ -118,6 +118,7 @@ export default Base.extend({ }, onInputChange(e) { + console.log("onInputChange", e); e.stopPropagation(); this.$el.submit(); log.debug("triggered by " + e.type); diff --git a/src/pat/auto-submit/auto-submit.test.js b/src/pat/auto-submit/auto-submit.test.js index c4fe59a85..853aa8265 100644 --- a/src/pat/auto-submit/auto-submit.test.js +++ b/src/pat/auto-submit/auto-submit.test.js @@ -62,14 +62,16 @@ describe("pat-autosubmit", function () { `; const el = document.querySelector(".pat-autosubmit"); const instance = new Pattern(el); - const spy = jest.spyOn(instance, "refreshListeners"); + const spy = jest + .spyOn(instance, "refreshListeners") + .mockImplementation(() => {}); $(el).trigger("pat-update", { pattern: "clone" }); expect(spy).toHaveBeenCalled(); }); }); describe("2 - Trigger a submit", function () { - it("when a change on a single input happens", async function () { + it("2.1 - when a change on a single input happens", async function () { document.body.innerHTML = ` @@ -128,7 +130,7 @@ describe("pat-autosubmit", function () { expect(spy).toHaveBeenCalled(); }); - it("when pat-sortable changes the sorting", function () { + it("2.4 - when pat-sortable changes the sorting", function () { document.body.innerHTML = ` @@ -139,9 +141,84 @@ describe("pat-autosubmit", function () { $(el).trigger("pat-update", { pattern: "sortable" }); expect(spy).toHaveBeenCalled(); }); + + it("2.5 - input outside form: change on input 1", async function () { + document.body.innerHTML = ` + + + `; + const input = document.querySelector("[name=name]"); + const form = document.querySelector("form"); + + let submit_input = false; + let submit_form = false; + input.addEventListener("submit", () => { + submit_input = true; + // NOTE: In a real browser a submit on an input outside a form + // would submit the form too. In jsdom this is not the case, so + // we need to trigger it manually. This is making this test a + // bit useless. + form.dispatchEvent(events.submit_event()); + }); + form.addEventListener("submit", () => { + submit_form = true; + }); + + const instance = new Pattern(input); + await events.await_pattern_init(instance); + + jest.spyOn(instance.$el, "submit").mockImplementation(() => { + input.dispatchEvent(events.submit_event()); + }); + + input.dispatchEvent(events.input_event()); + + expect(submit_input).toBe(true); + expect(submit_form).toBe(true); + }); + + it("2.6 - input outside form: change on input 2", async function () { + document.body.innerHTML = ` + + + `; + const input = document.querySelector("[name=name]"); + const form = document.querySelector("form"); + + let submit_input = false; + let submit_form = false; + input.addEventListener("submit", () => (submit_input = true)); + form.addEventListener("submit", () => (submit_form = true)); + + const instance = new Pattern(form); + await events.await_pattern_init(instance); + + jest.spyOn(instance.$el, "submit").mockImplementation(() => { + input.dispatchEvent(events.submit_event()); + }); + + input.dispatchEvent(events.input_event()); + + expect(submit_input).toBe(true); + expect(submit_form).toBe(true); + }); }); - describe("3 - Parsing of the delay option", function () { + describe("3 - Input outside form: Trigger a submit", function () {}); + + describe("4 - Parsing of the delay option", function () { it("can be done in shorthand notation", function () { let pat = new Pattern(``); expect(pat.options.delay).toBe(500); diff --git a/src/pat/validation/validation.js b/src/pat/validation/validation.js index 2a2d152b2..d00cddfdd 100644 --- a/src/pat/validation/validation.js +++ b/src/pat/validation/validation.js @@ -71,12 +71,12 @@ class Pattern extends BasePattern { } initialize_inputs() { - this.inputs = [ - ...this.el.querySelectorAll("input[name], select[name], textarea[name]"), - ]; - this.disabled_elements = [ - ...this.el.querySelectorAll(this.options.disableSelector), - ]; + this.inputs = [...this.el.elements].filter((el) => + el.matches("input[name], select[name], textarea[name]") + ); + this.disabled_elements = [...this.el.elements].filter((el) => + el.matches(this.options.disableSelector) + ); for (const [cnt, input] of this.inputs.entries()) { // Cancelable debouncer. diff --git a/src/pat/validation/validation.test.js b/src/pat/validation/validation.test.js index 56471f449..a9fa1decc 100644 --- a/src/pat/validation/validation.test.js +++ b/src/pat/validation/validation.test.js @@ -1455,4 +1455,80 @@ describe("pat-validation", function () { expect(el.querySelectorAll("em.warning").length).toBe(0); expect(el.querySelector("#form-buttons-create").disabled).toBe(false); }); + + it("8.1 - input ouside form: validates inputs part of the form but outside the form container", async function () { + document.body.innerHTML = ` + + + `; + const el = document.querySelector(".pat-validation"); + const inp = document.querySelector("[name=name]"); + + const instance = new Pattern(el); + await events.await_pattern_init(instance); + + inp.value = ""; + inp.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(document.querySelectorAll("em.warning").length).toBe(1); + }); + + it("8.2 - input outside form: removes the error when the field becomes valid.", async function () { + document.body.innerHTML = ` + + + `; + const el = document.querySelector(".pat-validation"); + const inp = document.querySelector("[name=name]"); + + const instance = new Pattern(el); + await events.await_pattern_init(instance); + + inp.value = ""; + inp.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(document.querySelectorAll("em.warning").length).toBe(1); + + inp.value = "abc"; + inp.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(document.querySelectorAll("em.warning").length).toBe(0); + }); + + it("8.3 - input outside form: can disable certain form elements when validation fails", async function () { + // Tests the disable-selector argument + document.body.innerHTML = ` + + + + `; + + const el = document.querySelector(".pat-validation"); + const inp = document.querySelector("[name=input]"); + const but = document.querySelector("button"); + + const instance = new Pattern(el); + await events.await_pattern_init(instance); + + inp.value = ""; + inp.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + expect(document.querySelectorAll("em.warning").length).toBe(1); + expect(but.disabled).toBe(true); + + inp.value = "ok"; + inp.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + expect(document.querySelectorAll("em.warning").length).toBe(0); + expect(but.disabled).toBe(false); + }); });