diff --git a/src/core/dom.js b/src/core/dom.js index aade013cf..01c9ef8b8 100644 --- a/src/core/dom.js +++ b/src/core/dom.js @@ -141,6 +141,24 @@ const is_input = (el) => { return re_input.test(el.nodeName); }; +/** + * Test, if a element is a button-like input type. + * + * @param {Node} el - The DOM node to test. + * @returns {Boolean} - True if the element is a input-type element. + */ +const is_button = (el) => { + return el.matches(` + button, + input[type=image], + input[type=button], + input[type=reset], + input[type=submit] + `); +}; + + + /** * Return all direct parents of ``el`` matching ``selector``. * This matches against all parents but not the element itself. @@ -613,6 +631,7 @@ const dom = { acquire_attribute: acquire_attribute, is_visible: is_visible, is_input: is_input, + is_button: is_button, create_from_string: create_from_string, get_css_value: get_css_value, find_scroll_container: find_scroll_container, diff --git a/src/core/dom.test.js b/src/core/dom.test.js index ef6f58e8b..d470b63b2 100644 --- a/src/core/dom.test.js +++ b/src/core/dom.test.js @@ -547,6 +547,47 @@ describe("core.dom tests", () => { }); }); + describe("is_button", () => { + it("checks, if an element is a button-like element or not.", (done) => { + + const button = document.createElement("button"); + const button_button = document.createElement("button"); + button_button.setAttribute("type", "button"); + const button_submit = document.createElement("button"); + button_submit.setAttribute("type", "submit"); + + const input_button = document.createElement("input"); + input_button.setAttribute("type", "button"); + const input_submit = document.createElement("input"); + input_submit.setAttribute("type", "submit"); + const input_reset = document.createElement("input"); + input_reset.setAttribute("type", "reset"); + const input_image = document.createElement("input"); + input_image.setAttribute("type", "image"); + + expect(dom.is_button(button)).toBe(true); + expect(dom.is_button(button_button)).toBe(true); + expect(dom.is_button(button_submit)).toBe(true); + expect(dom.is_button(input_button)).toBe(true); + expect(dom.is_button(input_image)).toBe(true); + expect(dom.is_button(input_reset)).toBe(true); + expect(dom.is_button(input_submit)).toBe(true); + + const input_text = document.createElement("input"); + input_text.setAttribute("type", "text"); + + expect(dom.is_button(input_text)).toBe(false); + expect(dom.is_button(document.createElement("input"))).toBe(false); + expect(dom.is_button(document.createElement("select"))).toBe(false); + expect(dom.is_button(document.createElement("textarea"))).toBe(false); + expect(dom.is_button(document.createElement("form"))).toBe(false); + expect(dom.is_button(document.createElement("div"))).toBe(false); + + done(); + }); + }); + + describe("create_from_string", () => { it("Creates a DOM element from a string", (done) => { const res = dom.create_from_string(` diff --git a/src/pat/depends/depends.js b/src/pat/depends/depends.js index e1805447a..8cd3ed40b 100644 --- a/src/pat/depends/depends.js +++ b/src/pat/depends/depends.js @@ -16,6 +16,20 @@ parser.addArgument("transition", "none", ["none", "css", "fade", "slide"]); parser.addArgument("effect-duration", "fast"); parser.addArgument("effect-easing", "swing"); + +// A custom input event which differs from the one in `core/events` in that it +// accepts a `detail` object to pass arbitrary information around. +// TODO: The events in `core/events` should be refactored to accept a `detail` +// object. +const input_event = (detail = {}) => { + return new CustomEvent("input", { + bubbles: true, + cancelable: false, + detail: detail, + }); +}; + + class Pattern extends BasePattern { static name = "depends"; static trigger = ".pat-depends"; @@ -44,7 +58,14 @@ class Pattern extends BasePattern { input, "input", `pat-depends--input--${this.uuid}`, - this.set_state.bind(this) + (e) => { + if (e?.detail?.pattern_uuid === this.uuid) { + // Ignore input events invoked from this pattern + // instance to avoid infinite loops. + return; + } + this.set_state(); + } ); if (input.form) { @@ -74,15 +95,40 @@ class Pattern extends BasePattern { enable() { const inputs = dom.find_inputs(this.el); for (const input of inputs) { + if (input.disabled === false) { + // Do not re-enable an already enabled input. + continue; + } + + // Now, enable the input element. input.disabled = false; - // Trigger the input after disabling so that any other bound + + if (input === this.el) { + // Do not re-trigger this pattern on it's own element to avoid + // infinite loops. + continue; + } + + if (dom.is_button(input)) { + // Do not trigger the input event on buttons as they do not + // support it. + continue; + } + + // Trigger the input after enabling so that any other bound // actions can react on that. - input.dispatchEvent(events.input_event()); + input.dispatchEvent(input_event({ pattern_uuid: this.uuid })); } + + // Restore the original click behavior for anchor elements. if (this.el.tagName === "A") { events.remove_event_listener(this.el, "pat-depends--click"); } + + // Remove the disabled class from the element. this.el.classList.remove("disabled"); + + // Trigger the pat-update event to notify other patterns about enabling. this.$el.trigger("pat-update", { pattern: "depends", action: "attribute-changed", @@ -94,17 +140,42 @@ class Pattern extends BasePattern { disable() { const inputs = dom.find_inputs(this.el); for (const input of inputs) { + if (input.disabled === true) { + // Do not re-disable an already disabled input. + continue; + } + + // Now, disable the input element. input.disabled = true; + + if (input === this.el) { + // Do not re-trigger this pattern on it's own element to avoid + // infinite loops. + continue; + } + + if (dom.is_button(input)) { + // Do not trigger the input event on buttons as they do not + // support it. + continue; + } + // Trigger the input after disabling so that any other bound // actions can react on that. - input.dispatchEvent(events.input_event()); + input.dispatchEvent(input_event({ pattern_uuid: this.uuid })); } + + // Prevent the default click behavior for anchor elements. if (this.el.tagName === "A") { events.add_event_listener(this.el, "click", "pat-depends--click", (e) => e.preventDefault() ); } + + // Add the disabled class to the element. this.el.classList.add("disabled"); + + // Trigger the pat-update event to notify other patterns about disabling. this.$el.trigger("pat-update", { pattern: "depends", action: "attribute-changed", diff --git a/src/pat/depends/depends.test.js b/src/pat/depends/depends.test.js index 9d9595cc9..eda575381 100644 --- a/src/pat/depends/depends.test.js +++ b/src/pat/depends/depends.test.js @@ -50,7 +50,7 @@ describe("pat-depends", function () { }); afterEach(function () { - $("#lab").remove(); + document.body.innerHTML = ""; }); it("Input element", async function () { @@ -85,6 +85,65 @@ describe("pat-depends", function () { instance.disable(); expect(el.classList.contains("disabled")).toBe(true); }); + + it("Throw an input event for any contained inputs.", async function () { + document.body.innerHTML = ` + +
+ +
+ `; + + const el = document.querySelector(".pat-depends"); + const instance = new pattern(el); + await utils.timeout(1); // wait a tick for async to settle. + + let called = false; + el.addEventListener("input", () => { + called = true; + }); + + instance.enable(); + utils.timeout(1); // wait a tick for async to settle. + + expect(called).toBe(true); + }); + + it("Do not throw an input event on the element itself.", async function () { + document.body.innerHTML = ` + + + `; + + const el = document.querySelector(".pat-depends"); + const instance = new pattern(el); + await utils.timeout(1); // wait a tick for async to settle. + + let called = false; + el.addEventListener("input", () => { + called = true; + }); + + instance.enable(); + utils.timeout(1); // wait a tick for async to settle. + + expect(called).toBe(false); + }); }); describe("3 - enable", function () { @@ -93,7 +152,7 @@ describe("pat-depends", function () { }); afterEach(function () { - $("#lab").remove(); + document.body.innerHTML = ""; }); it("Input element", async function () { @@ -128,6 +187,66 @@ describe("pat-depends", function () { instance.enable(); expect(el.classList.contains("disabled")).toBe(false); }); + + it("Throw an input event for any contained inputs.", async function () { + document.body.innerHTML = ` + +
+ +
+ `; + + const el = document.querySelector(".pat-depends"); + const instance = new pattern(el); + await utils.timeout(1); // wait a tick for async to settle. + + let called = false; + el.addEventListener("input", () => { + called = true; + }); + + instance.disable(); + utils.timeout(1); // wait a tick for async to settle. + + expect(called).toBe(true); + }); + + it("Do not throw an input event on the element itself.", async function () { + document.body.innerHTML = ` + + + `; + + const el = document.querySelector(".pat-depends"); + const instance = new pattern(el); + await utils.timeout(1); // wait a tick for async to settle. + + let called = false; + el.addEventListener("input", () => { + called = true; + }); + + instance.disable(); + utils.timeout(1); // wait a tick for async to settle. + + expect(called).toBe(false); + }); }); describe("4 - pat-update", function () { @@ -257,4 +376,161 @@ describe("pat-depends", function () { }); }); + + describe("6 - Prevent various infinite loop situations", function () { + + it("6.1 - Do not call set_state multiple times.", async function () { + document.body.innerHTML = ` + + + `; + + const el = document.querySelector(".pat-depends"); + const instance = new pattern(el); + await utils.timeout(1); // wait a tick for async to settle. + const spy = jest.spyOn(instance, "set_state"); + + const checkbox = document.querySelector("#control"); + checkbox.click(); + + utils.timeout(1); // wait a tick for async to settle. + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("6.2 - Do not enable already enabled inputs.", async function () { + + document.body.innerHTML = ` + +
+ +
+ `; + + const el = document.querySelector(".pat-depends"); + const instance = new pattern(el); + await utils.timeout(1); // wait a tick for async to settle. + + let called = false; + el.addEventListener("input", () => { + called = true; + }); + + instance.enable(); + utils.timeout(1); // wait a tick for async to settle. + + expect(called).toBe(false); + }); + + it("6.3 - Do not disable already disabled inputs.", async function () { + + document.body.innerHTML = ` + +
+ +
+ `; + + const el = document.querySelector(".pat-depends"); + const instance = new pattern(el); + await utils.timeout(1); // wait a tick for async to settle. + + let called = false; + el.addEventListener("input", () => { + called = true; + }); + + instance.disable(); + utils.timeout(1); // wait a tick for async to settle. + + expect(called).toBe(false); + }); + + it("6.4 - Do not throw the input event on buttons when enabling.", async function () { + + document.body.innerHTML = ` + +
+
+ `; + + const el = document.querySelector(".pat-depends"); + const instance = new pattern(el); + await utils.timeout(1); // wait a tick for async to settle. + + let called = false; + el.addEventListener("input", () => { + called = true; + }); + + instance.enable(); + utils.timeout(1); // wait a tick for async to settle. + + expect(called).toBe(false); + }); + + it("6.5 - Do not throw the input event on buttons when disabling.", async function () { + + document.body.innerHTML = ` + +
+
+ `; + + const el = document.querySelector(".pat-depends"); + const instance = new pattern(el); + await utils.timeout(1); // wait a tick for async to settle. + + let called = false; + el.addEventListener("input", () => { + called = true; + }); + + instance.disable(); + utils.timeout(1); // wait a tick for async to settle. + + expect(called).toBe(false); + }); + + + }); }); diff --git a/src/pat/depends/index.html b/src/pat/depends/index.html index c7e6095aa..ec23f549c 100644 --- a/src/pat/depends/index.html +++ b/src/pat/depends/index.html @@ -9,7 +9,7 @@ -
+

pat-depends with checkboxes, radiobuttons and multiselects

@@ -83,7 +83,7 @@

pat-depends with checkboxes, radiobuttons and multiselects

Extra toppings
-
+

pat-depends with optional date/time inputs