diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 000000000..b215ac2c2
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,13 @@
+name: test
+on: [push]
+jobs:
+ build:
+ name: test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - uses: actions/setup-node@v1
+ with:
+ node-version: '12'
+ - run: npm install yarn
+ - run: make check
diff --git a/CHANGES.md b/CHANGES.md
index 2cd14e7ca..f73ada77b 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -45,6 +45,10 @@
- core dom: Add ``querySelectorAllAndMe`` to do a querySelectorAll including the starter element.
- core dom: Add ``wrap`` wrap an element with a wrapper element.
- core dom: Add ``hide`` and ``show`` for DOM elements which retain the original display value.
+- core dom: Add ``find_parents`` to find all parents of an element matching a CSS selector.
+- core dom: Add ``find_scoped`` to search for elements matching the given selector within the current scope of the given element
+- core dom: Add ``is_visible`` to check if an element is visible or not.
+ unless an ``id`` selector is given - in that case the search is done globally.
- pat date picker: Support updating a date if it is before another dependent date.
- pat tabs: Refactor based on ``ResizeObserver`` and fix problems calculating the with with transitions.
- pat tabs: When clicking on the ``extra-tabs`` element, toggle between ``open`` and ``closed`` classes to allow opening/closing an extra-tabs menu via CSS.
@@ -89,6 +93,7 @@
- pat autofocus: Implement documented behavior to not focus on prefilled element, if there is another autofocus element which is empty.
- pat autofocus: Instead of calling autofocus for each element call it only once.
- pat autofocus: Register event handler only once.
+- pat-checklist: For global de/select buttons, do not change any other checkboxes than the ones the de/select button belongs to.
## 3.0.0-dev - unreleased
diff --git a/package.json b/package.json
index 54bc45de7..4c7f44f07 100644
--- a/package.json
+++ b/package.json
@@ -45,7 +45,7 @@
"spectrum-colorpicker": "^1.8.0",
"stickyfilljs": "git+https://github.com/syslabcom/stickyfill.git",
"tippy.js": "^6.2.7",
- "underscore": "^1.11.0",
+ "underscore": "^1.12.0",
"url-polyfill": "^1.1.9",
"validate.js": "^0.13.1",
"whatwg-fetch": "^3.4.0"
diff --git a/src/core/dom.js b/src/core/dom.js
index 93e527b91..3514a1ed0 100644
--- a/src/core/dom.js
+++ b/src/core/dom.js
@@ -52,12 +52,43 @@ const show = (el) => {
delete el[DATA_STYLE_DISPLAY];
};
+const find_parents = (el, selector) => {
+ // Return all direct parents of ``el`` matching ``selector``.
+ // This matches against all parents but not the element itself.
+ // The order of elements is from the search starting point up to higher
+ // DOM levels.
+ let parent = el.parentNode?.closest(selector) || null;
+ const ret = [];
+ while (parent) {
+ ret.push(parent);
+ parent = parent.parentNode?.closest(selector) || null;
+ }
+ return ret;
+};
+
+const find_scoped = (el, selector) => {
+ // If the selector starts with an object id do a global search,
+ // otherwise do a local search.
+ return (selector.indexOf("#") === 0 ? document : el).querySelectorAll(
+ selector
+ );
+};
+
+const is_visible = (el) => {
+ // Check, if element is visible in DOM.
+ // https://stackoverflow.com/a/19808107/1337474
+ return el.offsetWidth > 0 && el.offsetHeight > 0;
+};
+
const dom = {
toNodeArray: toNodeArray,
querySelectorAllAndMe: querySelectorAllAndMe,
wrap: wrap,
hide: hide,
show: show,
+ find_parents: find_parents,
+ find_scoped: find_scoped,
+ is_visible: is_visible,
};
export default dom;
diff --git a/src/core/dom.test.js b/src/core/dom.test.js
index 59d871fa5..baf851fa4 100644
--- a/src/core/dom.test.js
+++ b/src/core/dom.test.js
@@ -4,6 +4,10 @@ import dom from "./dom";
describe("core.dom tests", () => {
// Tests from the core.dom module
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
describe("toNodeArray tests", () => {
it("returns an array of nodes, if a jQuery object was passed.", (done) => {
const html = document.createElement("div");
@@ -146,4 +150,103 @@ describe("core.dom tests", () => {
done();
});
});
+
+ describe("find_parents", () => {
+ it("it finds all parents matching a selector.", (done) => {
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+
+ `;
+ const res = dom.find_parents(
+ document.querySelector(".starthere"),
+ ".findme"
+ );
+
+ // level4 is not found - it's about to find parents.
+ expect(res.length).toEqual(2);
+ expect(res[0]).toEqual(document.querySelector(".level3")); // inner dom levels first // prettier-ignore
+ expect(res[1]).toEqual(document.querySelector(".level1"));
+
+ done();
+ });
+ });
+
+ describe("find_scoped", () => {
+ it("Find all instances within the current structure.", (done) => {
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+
+ `;
+
+ const res = dom.find_scoped(
+ document.querySelector(".starthere"),
+ ".findme"
+ );
+
+ expect(res.length).toEqual(2);
+ expect(res[0]).toEqual(document.querySelector(".level3")); // outer dom levels first // prettier-ignore
+ expect(res[1]).toEqual(document.querySelector(".level4"));
+
+ done();
+ });
+
+ it("Find all instances within the current structure.", (done) => {
+ document.body.innerHTML = `
+
+
+
+
+
+
+ `;
+
+ const res = dom.find_scoped(
+ document.querySelector(".starthere"),
+ "#findme"
+ );
+
+ expect(res.length).toEqual(1);
+ expect(res[0]).toEqual(document.querySelector(".level1"));
+
+ done();
+ });
+ });
+
+ describe("is_visible", () => {
+ it.skip("checks, if an element is visible or not.", (done) => {
+ const div1 = document.createElement("div");
+ div1.setAttribute("id", "div1");
+
+ const div2 = document.createElement("div");
+ div2.setAttribute("id", "div2");
+
+ const div3 = document.createElement("div");
+ div3.setAttribute("id", "div3");
+
+ div2.style.display = "none";
+ div3.style.visibility = "hidden";
+
+ document.body.appendChild(div1);
+ document.body.appendChild(div2);
+ document.body.appendChild(div3);
+
+ expect(dom.is_visible(document.querySelector("#div1"))).toBeTruthy(); // prettier-ignore
+ expect(dom.is_visible(document.querySelector("#div2"))).toBeFalsy();
+ expect(dom.is_visible(document.querySelector("#div3"))).toBeFalsy();
+
+ done();
+ });
+ });
});
diff --git a/src/pat/checklist/checklist.js b/src/pat/checklist/checklist.js
index 348396711..0b584a707 100644
--- a/src/pat/checklist/checklist.js
+++ b/src/pat/checklist/checklist.js
@@ -1,246 +1,152 @@
-/**
- * Patterns checklist - Easily (un)check all checkboxes
- *
- * Copyright 2012-2013 Simplon B.V. - Wichert Akkerman
- * Copyright 2012-2013 Florian Friesdorf
- */
-import $ from "jquery";
+import Base from "../../core/base";
import Parser from "../../core/parser";
-import registry from "../../core/registry";
+import dom from "../../core/dom";
import utils from "../../core/utils";
import "../../core/jquery-ext";
-var parser = new Parser("checklist");
+const parser = new Parser("checklist");
parser.addArgument("select", ".select-all");
parser.addArgument("deselect", ".deselect-all");
-var _ = {
+export default Base.extend({
name: "checklist",
trigger: ".pat-checklist",
jquery_plugin: true,
+ all_selects: [],
+ all_deselects: [],
+ all_checkboxes: [],
+ all_radios: [],
+
+ init() {
+ this.options = parser.parse(this.el, this.options, false);
+ this.$el.on("patterns-injected", this._init.bind(this));
+ this._init();
+ },
+
+ _init() {
+ this.all_checkboxes = this.el.querySelectorAll("input[type=checkbox]");
+ this.all_radios = this.el.querySelectorAll("input[type=radio]");
- init: function ($el, opts) {
- function _init() {
- return $el.each(function () {
- var $trigger = $(this),
- options = parser.parse($trigger, opts, false);
-
- $trigger.data("patternChecklist", options);
- $trigger
- .scopedFind(options.select)
- .on(
- "click.pat-checklist",
- { trigger: $trigger },
- _.onSelectAll
- );
- $trigger
- .scopedFind(options.deselect)
- .on(
- "click.pat-checklist",
- { trigger: $trigger },
- _.onDeselectAll
- );
-
- $trigger.on("change", () => _.onChange($trigger));
- // update select/deselect button status
- _.onChange($trigger);
-
- $trigger
- .find("input[type=checkbox]")
- .each(_._onChangeCheckbox)
- .on("change.pat-checklist", _._onChangeCheckbox);
-
- $trigger
- .find("input[type=radio]")
- .each(_._initRadio)
- .on("change.pat-checklist", _._onChangeRadio);
- });
+ this.all_selects = dom.find_scoped(this.el, this.options.select);
+ for (const btn of this.all_selects) {
+ btn.addEventListener("click", this.select_all.bind(this));
}
- $el.on("patterns-injected", _init);
- return _init();
- },
- destroy: function ($el) {
- return $el.each(function () {
- var $trigger = $(this),
- options = $trigger.data("patternChecklist");
- $trigger.scopedFind(options.select).off(".pat-checklist");
- $trigger.scopedFind(options.deselect).off(".pat-checklist");
- $trigger.off(".pat-checklist", "input[type=checkbox]");
- $trigger.data("patternChecklist", null);
- });
+ this.all_deselects = dom.find_scoped(this.el, this.options.deselect);
+ for (const btn of this.all_deselects) {
+ btn.addEventListener("click", this.deselect_all.bind(this));
+ }
+
+ // update select/deselect button status
+ this.el.addEventListener("change", this._handler_change.bind(this));
+ this.change_buttons();
+ this.change_checked();
},
- _findSiblings: function (elem, sel) {
- // Looks for the closest elements that match the `sel` selector
- var checkbox_children, $parent;
- var parents = $(elem).parents();
- for (var i = 0; i < parents.length; i++) {
- $parent = $(parents[i]);
- checkbox_children = $parent.find(sel);
- if (checkbox_children.length != 0) {
- return checkbox_children;
- }
- if ($parent.hasClass("pat-checklist")) {
- // we reached the top node and did not find any match,
- // return an empty match
- return $([]);
- }
- }
- // This should not happen because because we expect `elem` to have
- // a .pat-checklist parent
- return $([]);
+ _handler_change() {
+ utils.debounce(() => this.change_buttons(), 50)();
+ utils.debounce(() => this.change_checked(), 50)();
},
- onChange: function (trigger) {
- const $trigger = $(trigger);
- const options = $trigger.data("patternChecklist");
- let siblings;
-
- let all_selects = $trigger.find(options.select);
- if (all_selects.length === 0) {
- all_selects = $(options.select);
+
+ destroy() {
+ for (const it of this.all_selects) {
+ it.removeEventListener("click", this.select_all);
}
- let all_deselects = $trigger.find(options.deselect);
- if (all_deselects.length === 0) {
- all_deselects = $(options.deselect);
+ for (const it of this.all_deselects) {
+ it.removeEventListener("click", this.deselect_all);
}
- for (const select of all_selects) {
- siblings = _._findSiblings(select, "input[type=checkbox]:visible");
- if (siblings && siblings.filter(":not(:checked)").length === 0) {
- select.disabled = true;
- } else {
- select.disabled = false;
+ this.el.removeEventListener("change", this._handler_change);
+ this.$el.off("patterns_injected");
+ },
+
+ find_siblings(el, sel) {
+ // Looks for the closest elements within the `el` tree that match the
+ // `sel` selector
+ let res;
+ let parent = el.parentNode;
+ while (parent) {
+ res = parent.querySelectorAll(sel);
+ if (res.length || parent === this.el) {
+ // return if results were found or we reached the pattern top
+ return res;
}
+ parent = parent.parentNode;
}
- for (const deselect of all_deselects) {
- siblings = _._findSiblings(
- deselect,
- "input[type=checkbox]:visible"
- );
- if (siblings && siblings.filter(":checked").length === 0) {
- deselect.disabled = true;
- } else {
- deselect.disabled = false;
- }
+ },
+
+ find_checkboxes(ref_el, sel) {
+ let chkbxs = [];
+ if (this.options.select.indexOf("#") === 0) {
+ chkbxs = this.el.querySelectorAll(sel);
+ } else {
+ chkbxs = this.find_siblings(ref_el, sel);
}
+ return chkbxs;
},
- onSelectAll: function (event) {
- event.preventDefault();
+ change_buttons() {
+ let chkbxs;
+ for (const btn of this.all_selects) {
+ chkbxs = this.find_checkboxes(btn, "input[type=checkbox]");
+ btn.disabled = [...chkbxs]
+ .map((el) => el.matches(":checked"))
+ .every((it) => it === true);
+ }
+ for (const btn of this.all_deselects) {
+ chkbxs = this.find_checkboxes(btn, "input[type=checkbox]");
+ btn.disabled = [...chkbxs]
+ .map((el) => el.matches(":checked"))
+ .every((it) => it === false);
+ }
+ },
- /* look up checkboxes which are related to my button by going up one parent
- at a time until I find some for the first time */
- const checkbox_siblings = _._findSiblings(
- event.currentTarget,
+ select_all(e) {
+ e.preventDefault();
+ const chkbxs = this.find_checkboxes(
+ e.target,
"input[type=checkbox]:not(:checked)"
);
-
- for (const box of checkbox_siblings) {
+ for (const box of chkbxs) {
box.checked = true;
- $(box).trigger("change");
- box.dispatchEvent(new Event("change"));
+ box.dispatchEvent(new Event("change", { bubbles: true }));
}
},
- onDeselectAll: function (event) {
- event.preventDefault();
-
- /* look up checkboxes which are related to my button by going up one parent
- at a time until I find some for the first time */
- const checkbox_siblings = _._findSiblings(
- event.currentTarget,
+ deselect_all(e) {
+ e.preventDefault();
+ const chkbxs = this.find_checkboxes(
+ e.target,
"input[type=checkbox]:checked"
);
-
- for (const box of checkbox_siblings) {
+ for (const box of chkbxs) {
box.checked = false;
- $(box).trigger("change");
- box.dispatchEvent(new Event("change"));
+ box.dispatchEvent(new Event("change", { bubbles: true }));
}
},
- /* The following methods are moved here from pat-checked-flag, which is being deprecated */
- _getLabelAndFieldset: function (el) {
- var result = new Set();
- result.add($(utils.findLabel(el)));
- result.add($(el).closest("fieldset"));
- return result;
- },
-
- _getSiblingsWithLabelsAndFieldsets: function (el) {
- var selector = 'input[name="' + el.name + '"]',
- $related = el.form === null ? $(selector) : $(selector, el.form);
- var result = new Set();
- var label_and_fieldset;
- $related = $related.not(el);
- $related.each(function (idx, item) {
- result.add(item);
- label_and_fieldset = _._getLabelAndFieldset(item);
- label_and_fieldset.forEach(function (item) {
- result.add(item);
- });
- });
- return result;
- },
-
- _onChangeCheckbox: function () {
- var $el = $(this),
- $label = $(utils.findLabel(this)),
- $fieldset = $el.closest("fieldset");
-
- if ($el.closest("ul.radioList").length) {
- $label = $label.add($el.closest("li"));
- }
-
- if (this.checked) {
- $label.add($fieldset).removeClass("unchecked").addClass("checked");
- } else {
- $label.addClass("unchecked").removeClass("checked");
- if ($fieldset.find("input:checked").length) {
- $fieldset.removeClass("unchecked").addClass("checked");
- } else $fieldset.addClass("unchecked").removeClass("checked");
- }
- },
-
- _initRadio: function () {
- _._updateRadio(this, false);
- },
-
- _onChangeRadio: function () {
- _._updateRadio(this, true);
- },
-
- _updateRadio: function (input, update_siblings) {
- var $el = $(input),
- $label = $(utils.findLabel(input)),
- $fieldset = $el.closest("fieldset"),
- siblings = _._getSiblingsWithLabelsAndFieldsets(input);
- if ($el.closest("ul.radioList").length) {
- $label = $label.add($el.closest("li"));
- var newset = new Set();
- siblings.forEach(function (sibling) {
- newset.add($(sibling).closest("li"));
- });
- siblings = newset;
+ change_checked() {
+ for (const it of [...this.all_checkboxes].concat([
+ ...this.all_radios,
+ ])) {
+ for (const label of it.labels) {
+ label.classList.remove("unchecked");
+ label.classList.remove("checked");
+ label.classList.add(it.checked ? "checked" : "unchecked");
+ }
}
- if (update_siblings) {
- siblings.forEach(function (sibling) {
- $(sibling).removeClass("checked").addClass("unchecked");
- });
- }
- if (input.checked) {
- $label.add($fieldset).removeClass("unchecked").addClass("checked");
- } else {
- $label.addClass("unchecked").removeClass("checked");
- if ($fieldset.find("input:checked").length) {
- $fieldset.removeClass("unchecked").addClass("checked");
+ for (const fieldset of dom.querySelectorAllAndMe(this.el, "fieldset")) {
+ if (
+ fieldset.querySelectorAll(
+ "input[type=checkbox]:checked, input[type=radio]:checked"
+ ).length
+ ) {
+ fieldset.classList.remove("unchecked");
+ fieldset.classList.add("checked");
} else {
- $fieldset.addClass("unchecked").removeClass("checked");
+ fieldset.classList.remove("checked");
+ fieldset.classList.add("unchecked");
}
}
},
-};
-registry.register(_);
-
-export default _;
+});
diff --git a/src/pat/checklist/checklist.test.js b/src/pat/checklist/checklist.test.js
index 39d157055..ac424c74f 100644
--- a/src/pat/checklist/checklist.test.js
+++ b/src/pat/checklist/checklist.test.js
@@ -1,246 +1,564 @@
-import "./checklist";
-import $ from "jquery";
+import Pattern from "./checklist";
+import registry from "../../core/registry";
+import utils from "../../core/utils";
-describe("pat-checklist", function () {
- beforeEach(function () {
- $("", { id: "lab" }).appendTo(document.body);
- });
- afterEach(function () {
- $("#lab").remove();
+describe("pat-checklist", () => {
+ afterEach(() => {
+ document.body.innerHTML = "";
});
- var utils = {
- createCheckList: function () {
- $("#lab").html("