From 7b26f2693980527374d04ac0fd0afd6191440501 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Tue, 11 Apr 2023 16:52:54 +0200 Subject: [PATCH 01/20] Minor pat-inject code cleanup. --- src/pat/inject/inject.js | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index 60b92d548..b751523cd 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -716,10 +716,23 @@ const inject = { } } - $el.on( - "pat-ajax-success.pat-inject", - this._onInjectSuccess.bind(this, $el, cfgs) - ); + $el.on("pat-ajax-success.pat-inject", async (e) => { + this._onInjectSuccess($el, cfgs, e); + + // Wait for the next tick to ensure that the close-panel listener + // is called before this one, even for non-async local injects. + await utils.timeout(1); + // Remove the close-panel event listener. + events.remove_event_listener($el[0], "pat-inject--close-panel"); + // Only close the panel if a close-panel event was catched previously. + if (do_close_panel) { + do_close_panel = false; + // Re-trigger close-panel event if it was caught while injection was in progress. + $el[0].dispatchEvent( + new Event("close-panel", { bubbles: true, cancelable: true }) + ); + } + }); $el.on("pat-ajax-error.pat-inject", this._onInjectError.bind(this, $el, cfgs)); $el.on("pat-ajax-success.pat-inject pat-ajax-error.pat-inject", () => $el.removeData("pat-inject-triggered") @@ -737,21 +750,6 @@ const inject = { do_close_panel = true; } ); - $el.on("pat-ajax-success.pat-inject", async () => { - // Wait for the next tick to ensure that the close-panel listener - // is called before this one, even for non-async local injects. - await utils.timeout(1); - // Remove the close-panel event listener. - events.remove_event_listener($el[0], "pat-inject--close-panel"); - // Only close the panel if a close-panel event was catched previously. - if (do_close_panel) { - do_close_panel = false; - // Re-trigger close-panel event if it was caught while injection was in progress. - $el[0].dispatchEvent( - new Event("close-panel", { bubbles: true, cancelable: true }) - ); - } - }); if (cfgs[0].url.length) { ajax.request($el, { From 411653d0e01bdf659b05da2ca6c67b843c204751 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Tue, 11 Apr 2023 17:06:52 +0200 Subject: [PATCH 02/20] maint(pat-inject): Remove obsolete hooks option. The hooks option allowed to throw custom events after successful injection. It was a multi-value argument but only allowed "raptor" as value. Raptor was a WYSIWYG editor which has not been further developed since 10 years and which we're not supporting anymore since quite some time. Thus this option could be safely removed and this change is not a breaking change. If you need to react on events, see the documented event list in pat-inject's documentation. --- src/pat/inject/documentation.md | 1 - src/pat/inject/inject.js | 4 ---- 2 files changed, 5 deletions(-) diff --git a/src/pat/inject/documentation.md b/src/pat/inject/documentation.md index 86c791d8c..769572e31 100644 --- a/src/pat/inject/documentation.md +++ b/src/pat/inject/documentation.md @@ -372,7 +372,6 @@ You can customise the behaviour of injection through options in the `data-pat-in | `class` | | | A class which will be added to the injected content. Multiple classes can be specified (separated with spaces). | String | | `loading-class` | 'injecting' | | A class which will be added to the injection target while content is still being loaded. Multiple classes can be specified (separated with spaces), or leave empty if no class should be added. | String | | `history` | | `none` `record` | If set to `record` then injection will update the URL history and the title tag of the HTML page. | String or null. | -| `hooks` | `[]` | `["raptor"]` | Once injection has completed successfully, pat-inject will trigger an event for each hook: pat-inject-hook-\$(hook). Useful for other patterns which need to know whether injection relevant to them has finished, for example `pat-raptor`. | String. | | `scroll` | | `none`, `top`, `target`, CSS selector | After injection is done, scroll to to given position. The default or `none` is to not do any scrolling. `top` scrolls to the top of the scroll container. `target` scrolls to the pat-inject target. CSS selector scrolls to the given selector. Note: You have to define a scroll container by setting overflow classes, otherwise `window` is used. | String, CSS selector | diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index b751523cd..7d0debae2 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -30,7 +30,6 @@ parser.addArgument("delay"); // only used in autoload parser.addArgument("browser-cache", "no-cache", ["cache", "no-cache"]); // Cache ajax requests. Pass to pat-ajax. parser.addArgument("confirm", "class", ["never", "always", "form-data", "class"]); parser.addArgument("confirm-message", "Are you sure you want to leave this page?"); -parser.addArgument("hooks", [], ["raptor"], true); // After injection, pat-inject will trigger an event for each hook: pat-inject-hook-$(hook) parser.addArgument("loading-class", "injecting"); // Add a class to the target while content is still loading. parser.addArgument("executing-class", "executing"); // Add a class to the element while content is still loading. parser.addArgument("executed-class", "executed"); // Add a class to the element when content is loaded. @@ -562,9 +561,6 @@ const inject = { // Special case, we want to call something, but we don't want to inject anything data = ""; } - $.each(cfgs[0].hooks || [], (idx, hook) => - $el.trigger("pat-inject-hook-" + hook) - ); const sources$ = await this.callTypeHandler(cfgs[0].dataType, "sources", $el, [ cfgs, data, From ae01e205a9b58aafac71a323c5e36f4f5154f4b8 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Tue, 11 Apr 2023 17:13:11 +0200 Subject: [PATCH 03/20] maint(pat-inject): Remove obsolete raptor-ui trigger. Remove the ".raptor-ui .ui-button.pat-inject" trigger selector which was for raptor WYSIWYG HTML editor support. This editor isn't actively developed since almost 9 years and not supported anymore. This is not a breaking change. --- src/pat/inject/inject.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index 7d0debae2..4327ad7d1 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -44,8 +44,7 @@ parser.addArgument("url"); const inject = { name: "inject", - trigger: - ".raptor-ui .ui-button.pat-inject, a.pat-inject, form.pat-inject, .pat-subform.pat-inject", + trigger: "a.pat-inject, form.pat-inject, .pat-subform.pat-inject", parser: parser, init($el, opts) { From 14af6612c38e79bd073ee08d9e1e1a6048dfe086 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Wed, 12 Apr 2023 18:50:27 +0200 Subject: [PATCH 04/20] maint(pat inject): Use dom.find_scroll_container instead jQuery :scrollable selector. --- src/pat/inject/inject.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index 4327ad7d1..f736c4f49 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -1,4 +1,4 @@ -import "../../core/jquery-ext"; // for :scrollable for autoLoading-visible +import "../../core/jquery-ext"; // for findInclusive import $ from "jquery"; import ajax from "../ajax/ajax"; import dom from "../../core/dom"; @@ -487,8 +487,11 @@ const inject = { } if (cfg.scroll && cfg.scroll !== "none") { - let scroll_container = cfg.$target.parents().addBack().filter(":scrollable"); - scroll_container = scroll_container.length ? scroll_container[0] : window; + const scroll_container = dom.find_scroll_container( + cfg.$target[0], + null, + window + ); // default for scroll===top let top = 0; From 12c980b02ce140e58b9d256520e056561795ac1b Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Tue, 11 Apr 2023 16:34:23 +0200 Subject: [PATCH 05/20] feat(core utils): debouncer - Add postpone option for callback to be run after all debounce calls or in between. If "postpone" is set to "true" (the default and previous behavior) the callback will only be called after no more debouncer calls are done for the given timeout. If "postpone" is set to "false" the callback will be run after the timeout has passed and calls to "debouncer" in between are ignored. --- src/core/utils.js | 27 +++++++++++++++++++++++---- src/core/utils.test.js | 19 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/core/utils.js b/src/core/utils.js index 087ca6c53..c57caf7d9 100644 --- a/src/core/utils.js +++ b/src/core/utils.js @@ -516,7 +516,20 @@ const timeout = (ms) => { return new Promise((resolve) => setTimeout(resolve, ms)); }; -const debounce = (func, ms, timer = { timer: null }) => { +/** + * Returns a function, that, as long as it continues to be invoked, will not + * be triggered. The function will be called after it stops being called for + * N milliseconds. + * From: https://underscorejs.org/#debounce + * + * @param {Function} func - The function to debounce. + * @param {Number} ms - The time in milliseconds to debounce. + * @param {Object} timer - A module-global timer as an object. + * @param {Boolean} postpone - If true, the function will be called after it stops being called for N milliseconds. + * + * @returns {Function} - The debounced function. + */ +const debounce = (func, ms, timer = { timer: null }, postpone = true) => { // Returns a function, that, as long as it continues to be invoked, will not // be triggered. The function will be called after it stops being called for // N milliseconds. @@ -528,11 +541,17 @@ const debounce = (func, ms, timer = { timer: null }) => { // // Pass a module-global timer as an object ``{ timer: null }`` if you want // to also cancel debounced functions from other pattern-invocations. - // + timer.last_run = 0; return function () { - clearTimeout(timer.timer); const args = arguments; - timer.timer = setTimeout(() => func.apply(this, args), ms); + if (!postpone && timer.timer && Date.now() - timer.last_run <= ms) { + return; + } + clearTimeout(timer.timer); + timer.last_run = Date.now(); + timer.timer = setTimeout(() => { + func.apply(this, args); + }, ms); }; }; diff --git a/src/core/utils.test.js b/src/core/utils.test.js index ed672f134..2197c8009 100644 --- a/src/core/utils.test.js +++ b/src/core/utils.test.js @@ -630,6 +630,25 @@ describe("debounce ...", function () { await utils.timeout(1); expect(test_func).toHaveBeenCalledTimes(1); }); + it("ensures to be called every x ms", async () => { + const test_func = jest.fn(); + const debouncer = utils.debounce(test_func, 2, { timer: null }, false); + debouncer(); + await utils.timeout(1); + debouncer(); + await utils.timeout(1); + debouncer(); + await utils.timeout(1); + debouncer(); + await utils.timeout(1); + debouncer(); + await utils.timeout(1); + await utils.timeout(1); + + // There should be 2 or 3 calls, depending on timing corner cases. + const calls = test_func.mock.calls.length; + expect(calls >= 2 && calls < 4).toBe(true); + }); it("incorrect usage by multi instantiation won't cancel previous runs", async () => { const test_func = jest.fn(); utils.debounce(test_func, 1)(); From 52d9ecf1ecb2e14d5f0e4e3fd9f39531b0846dab Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Thu, 13 Apr 2023 18:43:26 +0200 Subject: [PATCH 06/20] feat(core utils): Add threshold_list helper for intersection observers. --- src/core/utils.js | 20 ++++++++++++++++++++ src/core/utils.test.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/core/utils.js b/src/core/utils.js index c57caf7d9..bbb67fe7e 100644 --- a/src/core/utils.js +++ b/src/core/utils.js @@ -689,6 +689,25 @@ const date_diff = (date_1, date_2) => { return Math.floor((utc_1 - utc_2) / _MS_PER_DAY); }; +/** + * Build intersection observer threshold list. + * + * See: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#building_the_array_of_threshold_ratios + * + * @param {Number} num_steps - The number of steps to use. + * + * @returns {Array} - Returns the threshold list. + */ +const threshold_list = (num_steps = 0) => { + let thresholds = []; + + for (let i = 1.0; i <= num_steps; i++) { + thresholds.push(i / num_steps); + } + thresholds.push(0); + return thresholds.sort(); +}; + var utils = { jqueryPlugin: jqueryPlugin, escapeRegExp: escapeRegExp, @@ -720,6 +739,7 @@ var utils = { is_iso_date_time: is_iso_date_time, is_iso_date: is_iso_date, date_diff: date_diff, + threshold_list: threshold_list, getCSSValue: dom.get_css_value, // BBB: moved to dom. TODO: Remove in upcoming version. }; diff --git a/src/core/utils.test.js b/src/core/utils.test.js index 2197c8009..3fd30c8c1 100644 --- a/src/core/utils.test.js +++ b/src/core/utils.test.js @@ -780,3 +780,34 @@ describe("date_diff ...", function () { expect(utils.date_diff(date_1, date_2)).toBe(1); }); }); + +describe("threshold_list ...", function () { + it("returns a list with 0 for num_steps 0", () => { + expect(utils.threshold_list(0)).toEqual([0]); + }); + + it("returns a list of thresholds for num_steps 2", () => { + expect(utils.threshold_list(2)).toEqual([0, 0.5, 1]); + }); + + it("returns a list of thresholds for num_steps 4", () => { + expect(utils.threshold_list(4)).toEqual([0, 0.25, 0.5, 0.75, 1]); + }); + + it("returns a list of thresholds for num_steps 5", () => { + expect(utils.threshold_list(5)).toEqual([0, 0.2, 0.4, 0.6, 0.8, 1]); + }); + + it("returns a list of thresholds for num_steps 10", () => { + expect(utils.threshold_list(10)).toEqual([ + 0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, + ]); + }); + + it("returns a list of thresholds for num_steps 20", () => { + expect(utils.threshold_list(20)).toEqual([ + 0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, + 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1, + ]); + }); +}); From 95c16b8dab1f9324e8751fbd7211da1a9e82d47f Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Sat, 15 Apr 2023 01:49:48 +0200 Subject: [PATCH 07/20] feat(core utils): add parseLength method for parsing px and % lengths. --- src/core/utils.js | 49 +++++++++++++++++++++++++++++ src/core/utils.test.js | 70 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/src/core/utils.js b/src/core/utils.js index bbb67fe7e..1bfcf112e 100644 --- a/src/core/utils.js +++ b/src/core/utils.js @@ -442,6 +442,54 @@ function parseTime(time) { } } +/* + + * parseLength - Parse a length from a string and return the parsed length in + * pixels. + + * @param {String} length - A length string like `1px` or `25%`. + * @param {Number} reference_length - The reference length to use for percentage lengths. + * + * @returns {Number} - A integer which represents the parsed length in pixels. + * + * @throws {Error} - If the length string is invalid. + * + * @example + * parseLength("1px"); // 1 + * parseLength("10%", 100); // 10 + * + */ +function parseLength(length, reference_length = null) { + const m = /^(\d+(?:\.\d+)?)\s*(\%?\w*)/.exec(length); + if (!m) { + throw new Error("Invalid length"); + } + const amount = parseFloat(m[1]); + switch (m[2]) { + case "px": + return Math.round(amount); + case "%": + if (!reference_length) { + return 0; + } + return (reference_length / 100) * Math.round(amount); + case "vw": + return Math.round((amount * window.innerWidth) / 100); + case "vh": + return Math.round((amount * window.innerHeight) / 100); + case "vmin": + return Math.round( + (amount * Math.min(window.innerWidth, window.innerHeight)) / 100 + ); + case "vmax": + return Math.round( + (amount * Math.max(window.innerWidth, window.innerHeight)) / 100 + ); + default: + return null; + } +} + // Return a jQuery object with elements related to an input element. function findRelatives(el) { var $el = $(el), @@ -723,6 +771,7 @@ var utils = { isElementInViewport: isElementInViewport, hasValue: hasValue, parseTime: parseTime, + parseLength: parseLength, findRelatives: findRelatives, get_bounds: get_bounds, checkInputSupport: checkInputSupport, diff --git a/src/core/utils.test.js b/src/core/utils.test.js index 3fd30c8c1..7b3f7ddd6 100644 --- a/src/core/utils.test.js +++ b/src/core/utils.test.js @@ -472,6 +472,76 @@ describe("parseTime", function () { }); }); +describe("parseLength", function () { + it("raises exception for invalid input", function () { + var p = function () { + utils.parseLength("abc"); + }; + expect(p).toThrow(); + }); + + it("handles pixel lengths", function () { + expect(utils.parseLength("10px")).toBe(10); + expect(utils.parseLength("100px")).toBe(100); + expect(utils.parseLength("1000.1px")).toBe(1000); + expect(utils.parseLength("1000.9px")).toBe(1001); + + expect(utils.parseLength("10 px")).toBe(10); + }); + + it("handles percent lengths", function () { + expect(utils.parseLength("10%", 1)).toBe(0.1); + expect(utils.parseLength("10%", 10)).toBe(1); + expect(utils.parseLength("10%", 100)).toBe(10); + expect(utils.parseLength("10%", 1000)).toBe(100); + + expect(utils.parseLength("10.1%", 100)).toBe(10); + expect(utils.parseLength("10.9%", 100)).toBe(11); + + expect(utils.parseLength("10 %", 100)).toBe(10); + }); + + it("handles vw lengths", function () { + jest.replaceProperty(window, "innerWidth", 1000); + + expect(utils.parseLength("1vw")).toBe(10); + expect(utils.parseLength("10vw")).toBe(100); + expect(utils.parseLength("100vw")).toBe(1000); + + expect(utils.parseLength("10 vw")).toBe(100); + }); + + it("handles vh lengths", function () { + jest.replaceProperty(window, "innerHeight", 1000); + + expect(utils.parseLength("1vh")).toBe(10); + expect(utils.parseLength("10vh")).toBe(100); + expect(utils.parseLength("100vh")).toBe(1000); + + expect(utils.parseLength("10 vh")).toBe(100); + }); + + it("handles vmin lengths", function () { + jest.replaceProperty(window, "innerHeight", 100); + jest.replaceProperty(window, "innerWidth", 200); + expect(utils.parseLength("10vmin")).toBe(10); + + jest.replaceProperty(window, "innerHeight", 100); + jest.replaceProperty(window, "innerWidth", 50); + expect(utils.parseLength("10vmin")).toBe(5); + }); + + it("handles vmax lengths", function () { + jest.replaceProperty(window, "innerHeight", 100); + jest.replaceProperty(window, "innerWidth", 200); + expect(utils.parseLength("10vmax")).toBe(20); + + jest.replaceProperty(window, "innerHeight", 100); + jest.replaceProperty(window, "innerWidth", 50); + expect(utils.parseLength("10vmax")).toBe(10); + }); +}); + describe("get_bounds", function () { it("returns the bounds values as integer numbers instead of double/float values.", () => { const el = document.createElement("div"); From a3ecf9350c2720e6ac2cf6a5e50eea28691067a4 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Thu, 13 Apr 2023 12:10:11 +0200 Subject: [PATCH 08/20] feat(core dom): Add get_scroll_x and get_scroll_y helper methods to get the horizontal/vertical scrolling position. --- src/core/dom.js | 34 +++++++++++++++++++++++++++ src/core/dom.test.js | 56 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/core/dom.js b/src/core/dom.js index 31d111e19..34a028cb2 100644 --- a/src/core/dom.js +++ b/src/core/dom.js @@ -270,6 +270,38 @@ const find_scroll_container = (el, direction, fallback = document.body) => { return fallback; }; +/** + * Get the horizontal scroll position. + * + * @param {Node} scroll_reference - The element to get the scroll position from. + * + * @returns {number} The horizontal scroll position. + */ +const get_scroll_x = (scroll_reference) => { + // scroll_listener == window: window.scrollX + // scroll_listener == html: html.scrollLeft == window.scrollX + // scroll_listener == DOM node: node.scrollLeft + return typeof scroll_reference.scrollLeft !== "undefined" + ? scroll_reference.scrollLeft + : scroll_reference.scrollX; +}; + +/** + * Get the vertical scroll position. + * + * @param {Node} scroll_reference - The element to get the scroll position from. + * + * @returns {number} The vertical scroll position. + */ +const get_scroll_y = (scroll_reference) => { + // scroll_listener == window: window.scrollY + // scroll_listener == html: html.scrollTop == window.scrollY + // scroll_listener == DOM node: node.scrollTop + return typeof scroll_reference.scrollTop !== "undefined" + ? scroll_reference.scrollTop + : scroll_reference.scrollY; +}; + /** * Get data stored directly on the node instance. * We are using a prefix to make sure the data doesn't collide with other attributes. @@ -351,6 +383,8 @@ const dom = { create_from_string: create_from_string, get_css_value: get_css_value, find_scroll_container: find_scroll_container, + get_scroll_x: get_scroll_x, + get_scroll_y: get_scroll_y, get_data: get_data, set_data: set_data, delete_data: delete_data, diff --git a/src/core/dom.test.js b/src/core/dom.test.js index 77c39b777..d8b882602 100644 --- a/src/core/dom.test.js +++ b/src/core/dom.test.js @@ -710,6 +710,62 @@ describe("core.dom tests", () => { }); }); + describe("get_scroll_y", function () { + it("get vertical scroll from window", function (done) { + jest.replaceProperty(window, "scrollY", 2000); + expect(dom.get_scroll_y(window)).toBe(2000); + done(); + }); + + it("get vertical scroll from window when scrollY is 0", function (done) { + jest.replaceProperty(window, "scrollY", 0); + expect(dom.get_scroll_y(window)).toBe(0); + done(); + }); + + it("get vertical scroll from an element", function (done) { + const el = document.createElement("div"); + jest.spyOn(el, "scrollTop", "get").mockReturnValue(2000); + expect(dom.get_scroll_y(el)).toBe(2000); + done(); + }); + + it("get vertical scroll from an element when scrollTop is 0", function (done) { + const el = document.createElement("div"); + jest.spyOn(el, "scrollTop", "get").mockReturnValue(0); + expect(dom.get_scroll_y(el)).toBe(0); + done(); + }); + }); + + describe("get_scroll_x", function () { + it("get horizontal scroll from window", function (done) { + jest.replaceProperty(window, "scrollX", 2000); + expect(dom.get_scroll_x(window)).toBe(2000); + done(); + }); + + it("get horizontal scroll from window when scrollX is 0", function (done) { + jest.replaceProperty(window, "scrollX", 0); + expect(dom.get_scroll_x(window)).toBe(0); + done(); + }); + + it("get horizontal scroll from an element", function (done) { + const el = document.createElement("div"); + jest.spyOn(el, "scrollLeft", "get").mockReturnValue(2000); + expect(dom.get_scroll_x(el)).toBe(2000); + done(); + }); + + it("get horizontal scroll from an element when scrollLeft is 0", function (done) { + const el = document.createElement("div"); + jest.spyOn(el, "scrollLeft", "get").mockReturnValue(0); + expect(dom.get_scroll_x(el)).toBe(0); + done(); + }); + }); + describe("set_data, get_data, delete_data", function () { it("can be used to store and retrieve data on DOM nodes.", function () { const el = document.createElement("div"); From 15c6bf97190a3b433802840fa2ec77bff87b38ba Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Thu, 13 Apr 2023 12:47:59 +0200 Subject: [PATCH 09/20] core dom: Cleanup docstrings. --- src/core/dom.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/dom.js b/src/core/dom.js index 34a028cb2..9a5354fb7 100644 --- a/src/core/dom.js +++ b/src/core/dom.js @@ -169,7 +169,7 @@ const get_parents = (el) => { /** * Return the value of the first attribute found in the list of parents. * - * @param {DOM element} el - The DOM element to start the acquisition search for the given attribute. + * @param {Node} el - The DOM element to start the acquisition search for the given attribute. * @param {string} attribute - Name of the attribute to search for. * @param {Boolean} include_empty - Also return empty values. * @param {Boolean} include_all - Return a list of attribute values found in all parents. @@ -245,7 +245,7 @@ function get_css_value(el, property, as_pixels = false, as_float = false) { * @param {String} [direction=] - Not given: Search for any scrollable element up in the DOM tree. * ``x``: Search for a horizontally scrollable element. * ``y``: Search for a vertically scrollable element. - * @param {(DOM Node|null)} [fallback=document.body] - Fallback, if no scroll container can be found. + * @param {(Node|null)} [fallback=document.body] - Fallback, if no scroll container can be found. * The default is to use document.body. * * @returns {Node} - Return the first scrollable element. From 622d5e21bc6295ec93d11224a2ff78ee010a617a Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Fri, 14 Apr 2023 16:53:41 +0200 Subject: [PATCH 10/20] feat(core dom): Implement get_visible_ratio to calculate the visible ratio between an element and a container. --- src/core/dom.js | 39 +++++++++++++++++++++++++++++++++++ src/core/dom.test.js | 49 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/src/core/dom.js b/src/core/dom.js index 9a5354fb7..a0ee50334 100644 --- a/src/core/dom.js +++ b/src/core/dom.js @@ -368,6 +368,44 @@ const template = (template_string, template_variables = {}) => { return new Function("return `" + template_string + "`;").call(template_variables); }; +/** + * Get the visible ratio of an element compared to container. + * If no container is given, the viewport is used. + * + * Note: currently only vertical ratio is supported. + * + * @param {Node} el - The element to get the visible ratio from. + * @param {Node} [container] - The container to compare the element to. + * @returns {number} - The visible ratio of the element. + * 0 means the element is not visible. + * 1 means the element is fully visible. + */ +const get_visible_ratio = (el, container) => { + if (!el) { + return 0; + } + + const rect = el.getBoundingClientRect(); + const container_rect = + container !== window + ? container.getBoundingClientRect() + : { + top: 0, + bottom: window.innerHeight, + }; + + let visible_ratio = 0; + if (rect.top < container_rect.bottom && rect.bottom > container_rect.top) { + const rect_height = rect.bottom - rect.top; + const visible_height = + Math.min(rect.bottom, container_rect.bottom) - + Math.max(rect.top, container_rect.top); + visible_ratio = visible_height / rect_height; + } + + return visible_ratio; +}; + const dom = { toNodeArray: toNodeArray, querySelectorAllAndMe: querySelectorAllAndMe, @@ -389,6 +427,7 @@ const dom = { set_data: set_data, delete_data: delete_data, template: template, + get_visible_ratio: get_visible_ratio, add_event_listener: events.add_event_listener, // BBB export. TODO: Remove in an upcoming version. remove_event_listener: events.remove_event_listener, // BBB export. TODO: Remove in an upcoming version. }; diff --git a/src/core/dom.test.js b/src/core/dom.test.js index d8b882602..8801a0b3f 100644 --- a/src/core/dom.test.js +++ b/src/core/dom.test.js @@ -806,4 +806,53 @@ describe("core.dom tests", () => { done(); }); }); + + describe("get_visible_ratio", () => { + it("returns 0 if the element is not given", (done) => { + expect(dom.get_visible_ratio()).toBe(0); + done(); + }); + + it("container = window, returns 0 if the element is not visible", (done) => { + const el = document.createElement("div"); + jest.spyOn(el, "getBoundingClientRect").mockImplementation(() => { + return { + top: 200, + bottom: 300, + }; + }); + jest.replaceProperty(window, "innerHeight", 100); + + expect(dom.get_visible_ratio(el, window)).toBe(0); + done(); + }); + + it("container = window, returns 0.5 if the element is half-visible", (done) => { + const el = document.createElement("div"); + jest.spyOn(el, "getBoundingClientRect").mockImplementation(() => { + return { + top: 50, + bottom: 150, + }; + }); + jest.replaceProperty(window, "innerHeight", 100); + + expect(dom.get_visible_ratio(el, window)).toBe(0.5); + done(); + }); + + it("container = window, returns 1 if the element is fully visible", (done) => { + const el = document.createElement("div"); + jest.spyOn(el, "getBoundingClientRect").mockImplementation(() => { + return { + top: 0, + bottom: 100, + }; + }); + jest.replaceProperty(window, "innerHeight", 100); + + expect(dom.get_visible_ratio(el, window)).toBe(1); + done(); + }); + }); }); From e9a8f2fa4f2151928fd473c75e339496d93e0d8c Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Tue, 11 Apr 2023 16:47:38 +0200 Subject: [PATCH 11/20] feat(core base): Throw pre-init.PATTERNNAME.patterns event. Throw a bubbling pre-init.PATTERNNAME.patterns event before initializing the event for old prototype based patterns. --- src/core/base.js | 3 +++ src/core/base.test.js | 14 ++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/core/base.js b/src/core/base.js index 7046ca09f..3861435c7 100644 --- a/src/core/base.js +++ b/src/core/base.js @@ -56,6 +56,9 @@ const Base = async function ($el, options, trigger) { this.$el = $el; this.el = $el[0]; this.options = $.extend(true, {}, this.defaults || {}, options || {}); + + this.emit("pre-init"); + await this.init($el, options, trigger); // Store pattern instance on element diff --git a/src/core/base.test.js b/src/core/base.test.js index 4ff05d030..bf44813d6 100644 --- a/src/core/base.test.js +++ b/src/core/base.test.js @@ -219,15 +219,21 @@ describe("pat-base: The Base class for patterns", function () { const node = document.createElement("div"); node.setAttribute("class", "pat-example"); const event_list = []; - node.addEventListener("init_done", () => event_list.push("pat init")); - $(node).on("init.example.patterns", () => event_list.push("base init")); + $(node).on("pre-init.example.patterns", () => + event_list.push("pre-init.example.patterns") + ); + node.addEventListener("init_done", () => event_list.push("init_done")); + $(node).on("init.example.patterns", () => + event_list.push("init.example.patterns") + ); new Tmp(node); // await until all asyncs are settled. 1 event loop should be enough. await utils.timeout(1); - expect(event_list[0]).toBe("pat init"); - expect(event_list[1]).toBe("base init"); + expect(event_list[0]).toBe("pre-init.example.patterns"); + expect(event_list[1]).toBe("init_done"); + expect(event_list[2]).toBe("init.example.patterns"); }); it("adds the pattern instance on the element when manually initialized", async () => { From cacb743268e018563deef2795ccaf992044cf7f9 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Tue, 11 Apr 2023 16:48:22 +0200 Subject: [PATCH 12/20] feat(core basepattern): Throw pre-init.PATTERNNAME.patterns event. Throw a bubbling pre-init.PATTERNNAME.patterns event before initializing the event for new class-based patterns. --- src/core/basepattern.js | 8 ++++++++ src/core/basepattern.test.js | 25 +++++++++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/core/basepattern.js b/src/core/basepattern.js index a5cb18065..9a4f4eae1 100644 --- a/src/core/basepattern.js +++ b/src/core/basepattern.js @@ -31,6 +31,14 @@ class BasePattern { } this.el = el; + // Notify pre-init + this.el.dispatchEvent( + new Event(`pre-init.${this.name}.patterns`, { + bubbles: true, + cancelable: true, + }) + ); + // Initialize asynchronously. // // 1) We need to call the concrete implementation of ``init``, but the diff --git a/src/core/basepattern.test.js b/src/core/basepattern.test.js index ff0d84ea8..eda07bfba 100644 --- a/src/core/basepattern.test.js +++ b/src/core/basepattern.test.js @@ -210,20 +210,37 @@ describe("Basepattern class tests", function () { expect(cnt).toBe(1); }); - it("6.2 - Throws a init event after asynchronous initialization has finished.", async function () { + it("6.2 - Throws bubbling initialization events.", async function () { const events = (await import("./events")).default; class Pat extends BasePattern { static name = "example"; static trigger = ".example"; + + async init() { + this.el.dispatchEvent(new Event("initializing"), { bubbles: true }); + } } - const el = document.createElement("div"); + document.body.innerHTML = "
"; + const el = document.querySelector("div"); + + const event_list = []; + document.body.addEventListener("pre-init.example.patterns", () => + event_list.push("pre-init.example.patterns") + ); + document.body.addEventListener("pre-init.example.patterns", () => + event_list.push("initializing") + ); + document.body.addEventListener("pre-init.example.patterns", () => + event_list.push("init.example.patterns") + ); const pat = new Pat(el); await events.await_pattern_init(pat); - // If test reaches this expect statement, the init event catched. - expect(true).toBe(true); + expect(event_list[0]).toBe("pre-init.example.patterns"); + expect(event_list[1]).toBe("initializing"); + expect(event_list[2]).toBe("init.example.patterns"); }); it("6.3 - Throws a not-init event in case of an double initialization event which is handled by await_pattern_init.", async function () { From eb66159e82ba019a3210d8d862ea65dcbd178df6 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Fri, 14 Apr 2023 13:42:36 +0200 Subject: [PATCH 13/20] feat(core basepattern): Allow to specify parser options on the pattern. --- src/core/basepattern.js | 14 +++++++++- src/core/basepattern.test.js | 53 +++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/core/basepattern.js b/src/core/basepattern.js index 9a4f4eae1..9e6394a07 100644 --- a/src/core/basepattern.js +++ b/src/core/basepattern.js @@ -16,6 +16,11 @@ class BasePattern { static trigger; // A CSS selector to match elements that should trigger the pattern instantiation. static parser; // Options parser. + // Parser options + parser_group_options = true; + parser_multiple = undefined; + parser_inherit = true; + constructor(el, options = {}) { // Make static variables available on instance. this.name = this.constructor.name; @@ -68,7 +73,14 @@ class BasePattern { // Create the options object by parsing the element and using the // optional options as default. - this.options = this.parser?.parse(this.el, options) ?? options; + this.options = + this.parser?.parse( + this.el, + options, + this.parser_multiple, + this.parser_inherit, + this.parser_group_options + ) ?? options; // Store pattern instance on element this.el[`pattern-${this.name}`] = this; diff --git a/src/core/basepattern.test.js b/src/core/basepattern.test.js index eda07bfba..63c61c497 100644 --- a/src/core/basepattern.test.js +++ b/src/core/basepattern.test.js @@ -15,7 +15,7 @@ describe("Basepattern class tests", function () { jest.restoreAllMocks(); }); - it("1 - Trigger, name and parser are statically available on the class.", async function () { + it("1.1 - Trigger, name and parser are statically available on the class.", async function () { class Pat extends BasePattern { static name = "example"; static trigger = ".example"; @@ -37,6 +37,57 @@ describe("Basepattern class tests", function () { expect(typeof pat.parser.parse).toBe("function"); }); + it("1.2 - Options are created with grouping per default.", async function () { + const Parser = (await import("./parser")).default; + + const parser = new Parser("example"); + parser.addArgument("a", 1); + parser.addArgument("camel-b", 2); + parser.addArgument("test-a", 3); + parser.addArgument("test-b", 4); + + class Pat extends BasePattern { + static name = "example"; + static trigger = ".example"; + static parser = parser; + } + + const el = document.createElement("div"); + const pat = new Pat(el); + await utils.timeout(1); + + expect(pat.options.a).toBe(1); + expect(pat.options.camelB).toBe(2); + expect(pat.options.test.a).toBe(3); + expect(pat.options.test.b).toBe(4); + }); + + it("1.3 - Option grouping can be turned off.", async function () { + const Parser = (await import("./parser")).default; + + const parser = new Parser("example"); + parser.addArgument("a", 1); + parser.addArgument("camel-b", 2); + parser.addArgument("test-a", 3); + parser.addArgument("test-b", 4); + + class Pat extends BasePattern { + static name = "example"; + static trigger = ".example"; + static parser = parser; + + parser_group_options = false; + } + + const el = document.createElement("div"); + const pat = new Pat(el); + await utils.timeout(1); + + expect(pat.options.a).toBe(1); + expect(pat.options["camel-b"]).toBe(2); + expect(pat.options["test-a"]).toBe(3); + expect(pat.options["test-b"]).toBe(4); + }); it("2 - Base pattern is class based and does inheritance, polymorphism, encapsulation, ... pt1", async function () { class Pat1 extends BasePattern { some = "thing"; From a66a9f8b24ca18f658a25ecc31ef2762131b8c3c Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Wed, 12 Apr 2023 23:14:14 +0200 Subject: [PATCH 14/20] maint(pat scroll): Code cleanup. --- src/pat/scroll/scroll.js | 43 +++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/src/pat/scroll/scroll.js b/src/pat/scroll/scroll.js index bbb2447f7..d116bc90a 100644 --- a/src/pat/scroll/scroll.js +++ b/src/pat/scroll/scroll.js @@ -74,29 +74,25 @@ export default Base.extend({ }, markIfVisible() { - if (this.$el.hasClass("pat-scroll-animated")) { - // this section is triggered when the scrolling is a result of the animate function - // ie. automatic scrolling as opposed to the user manually scrolling - this.$el.removeClass("pat-scroll-animated"); - } else if (this.el.href) { - const fragment = this.el.href.split("#")?.[1]; - if (fragment) { - const $target = $(`#${fragment}`); - if ($target.length) { - if ( - utils.isElementInViewport($target[0], true, this.options.offset) - ) { - // check that the anchor's target is visible - // if so, mark both the anchor and the target element - $target.addClass("current"); - this.$el.addClass("current"); - this.$el.trigger("pat-update", { - pattern: "scroll", - action: "attribute-changed", - dom: $target[0], - }); - } - } + if (!this.el.href) { + return; + } + const fragment = this.el.href.split("#")?.[1]; + if (!fragment) { + return; + } + const $target = $(`#${fragment}`); + if ($target.length) { + if (utils.isElementInViewport($target[0], true, this.options.offset)) { + // check that the anchor's target is visible + // if so, mark both the anchor and the target element + $target.addClass("current"); + this.$el.addClass("current"); + this.$el.trigger("pat-update", { + pattern: "scroll", + action: "attribute-changed", + dom: $target[0], + }); } } }, @@ -223,5 +219,6 @@ export default Base.extend({ }, }) .promise(); + $(".pat-scroll").removeClass("pat-scroll-animated"); }, }); From e5a4b244c65974619b9716e3baecfe5e7376be58 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Thu, 13 Apr 2023 12:08:10 +0200 Subject: [PATCH 15/20] maint(pat scroll-box): Use dom.scroll_y instead of own implementation. --- src/pat/scroll-box/scroll-box.js | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/pat/scroll-box/scroll-box.js b/src/pat/scroll-box/scroll-box.js index f73ff08ac..64d8019a0 100644 --- a/src/pat/scroll-box/scroll-box.js +++ b/src/pat/scroll-box/scroll-box.js @@ -1,5 +1,6 @@ import Base from "../../core/base"; import Parser from "../../core/parser"; +import dom from "../../core/dom"; import events from "../../core/events"; export const parser = new Parser("scroll-box"); @@ -56,7 +57,7 @@ export default Base.extend({ }, set_scroll_classes() { - const scroll_pos = this.get_scroll_y(); + const scroll_pos = dom.get_scroll_y(this.scroll_listener); const el = this.el; const to_add = []; @@ -104,13 +105,4 @@ export default Base.extend({ // but keep ``scroll-up`` and ``scroll-down``. this.el.classList.remove("scrolling-up", "scrolling-down"); }, - - get_scroll_y() { - if (this.scroll_listener === window) { - // scrolling the window - return window.scrollY !== undefined ? window.scrollY : window.pageYOffset; // pageYOffset for IE - } - // scrolling a DOM element - return this.scroll_listener.scrollTop; - }, }); From 148f79a45b482c78e04f8b5e7e0c4342a567bba7 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Thu, 13 Apr 2023 12:48:23 +0200 Subject: [PATCH 16/20] maint(pat scroll-box): Cleanup code. --- src/pat/scroll-box/scroll-box.js | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/pat/scroll-box/scroll-box.js b/src/pat/scroll-box/scroll-box.js index 64d8019a0..0ab3d5523 100644 --- a/src/pat/scroll-box/scroll-box.js +++ b/src/pat/scroll-box/scroll-box.js @@ -58,7 +58,8 @@ export default Base.extend({ set_scroll_classes() { const scroll_pos = dom.get_scroll_y(this.scroll_listener); - const el = this.el; + const offset = + this.scroll_listener === window ? window.innerHeight : this.el.clientHeight; const to_add = []; @@ -72,22 +73,14 @@ export default Base.extend({ if (scroll_pos <= 0) { to_add.push("scroll-position-top"); - } else if ( - this.scroll_listener === window && - window.innerHeight + scroll_pos >= el.scrollHeight - ) { - to_add.push("scroll-position-bottom"); - } else if ( - this.scroll_listener !== window && - el.clientHeight + scroll_pos >= el.scrollHeight - ) { + } else if (offset + scroll_pos >= this.el.scrollHeight) { to_add.push("scroll-position-bottom"); } // Keep DOM manipulation calls together to let the browser optimize reflow/repaint. // See: https://areknawo.com/dom-performance-case-study/ - el.classList.remove( + this.el.classList.remove( "scroll-up", "scroll-down", "scrolling-up", @@ -95,7 +88,7 @@ export default Base.extend({ "scroll-position-top", "scroll-position-bottom" ); - el.classList.add(...to_add); + this.el.classList.add(...to_add); this.last_known_scroll_position = scroll_pos; }, From 64836491c2591b399fe543bd4aacb1e50b826b3c Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Wed, 12 Apr 2023 13:03:59 +0200 Subject: [PATCH 17/20] feat(pat scroll-marker): Add pattern to set navigation classes based on the scroll position. The new scroll-marker pattern allows you to set classes on the navigation and content elements for hash-links. If a content section with a hash id and a corresponding navigation link with the same hash-url is visible, the navigation and content section are marked with CSS classes. --- src/pat/scroll-marker/documentation.md | 58 +++++ src/pat/scroll-marker/index.html | 151 +++++++++++++ src/pat/scroll-marker/scroll-marker.js | 231 ++++++++++++++++++++ src/pat/scroll-marker/scroll-marker.test.js | 164 ++++++++++++++ src/patterns.js | 1 + 5 files changed, 605 insertions(+) create mode 100644 src/pat/scroll-marker/documentation.md create mode 100644 src/pat/scroll-marker/index.html create mode 100644 src/pat/scroll-marker/scroll-marker.js create mode 100644 src/pat/scroll-marker/scroll-marker.test.js diff --git a/src/pat/scroll-marker/documentation.md b/src/pat/scroll-marker/documentation.md new file mode 100644 index 000000000..8789ef9a9 --- /dev/null +++ b/src/pat/scroll-marker/documentation.md @@ -0,0 +1,58 @@ +# pat-scroll-marker + +## Description + +Marks navigation items and content with CSS classes if they are scrolled to and in view. + + +## Documentation + +For hash-urls in a navigation structure with a corresponding content item, the +link and the content item are marked with configurable CSS classes when the +content item is in view or the current one. + +For the pattern to work you need a navigation structure with hash-urls. +Only urls starting with a `#` sign are included in pat-scroll-marker. + +There are different calculation strategies to determine the current content +item, explained in the options reference below. + +Here is a complete example: + + + +
+

1

+

text1

+
+ +
+

1

+

text1

+
+ +
+

1

+

text1

+
+ + +### Options reference + +| Property | Default Value | Values | Type | Description | +| -------------- | ------------- | ------ | ----------------- | ----------------------------- | +| in-view-class | in-view | | String | CSS class for a navigation item when it's corresponding content item is in view. | +| current-class | current | | String | CSS class for a navigation item when it's corresponding content item is the current one. | +| current-content-class | current | | String | CSS class for a content item when it is the current one. | +| side | top | top, bottom, middle, auto | String | Side of element that scrolls. This is used to calculate the current item. The defined side of the element will be used to calculate the distance baseline. If this is set to "auto" then one of the "top" or "bottom" positions are used, depending on which one is nearer to the distance baseline. | +| distance | 50% | | String | Distance from side of scroll box. any amount in px, %, vw, vh, vmin or vmax. This is used to calculate the current item. The nearest element to the distance-baseline measured from the top of the container will get the current class. | +| visibility | | null, most-visible | String | Visibility of element in scroll box. This is used to calculate the current item. If "most-visible" is set, the element which is most visible in the scroll container gets the current class. If more elements have the same visibility ratio, the other conditions are used to calculate the current one. | diff --git a/src/pat/scroll-marker/index.html b/src/pat/scroll-marker/index.html new file mode 100644 index 000000000..e2d6f873c --- /dev/null +++ b/src/pat/scroll-marker/index.html @@ -0,0 +1,151 @@ + + + + Scroll marker demo + + + + + + + + +

Text from: DeLorean Ipsum Text Generator

+ +
+

1

+
+          Good. Have a good trip Einstein, watch your head.
+          Marty, will we ever see you again? I know what you're
+          gonna say, son, and you're right, you're right, But
+          Biff just happens to be my supervisor, and I'm afraid
+          I'm not very good at confrontations. Right, and where
+          am I gonna be? Great Scott. Let me see that
+          photograph again of your brother. Just as I thought,
+          this proves my theory, look at your brother.
+        
+ +
+          Where does he come from? There, there, now, just
+          relax. You've been asleep for almost nine hours now.
+          Of course, from a group of Libyan Nationalists. They
+          wanted me to build them a bomb, so I took their
+          plutonium and in turn gave them a shiny bomb case
+          full of used pinball machine parts. Good, there's
+          somebody I'd like you to meet. Lorraine. Marty, you
+          seem so nervous, is something wrong?
+        
+
+ +
+

2

+
+          Now that's a risk you'll have to take you're life
+          depends on it. His head's gone, it's like it's been
+          erased. Something that really cooks. Alright, alright
+          this is an oldie, but uh, it's an oldie where I come
+          from. Alright guys, let's do some blues riff in b,
+          watch me for the changes, and uh, try and keep up,
+          okay. Yeah well look, Marvin, Marvin, you gotta play.
+          See that's where they kiss for the first time on the
+          dance floor. And if there's no music, they can't
+          dance, and if they can't dance, they can't kiss, and
+          if they can't kiss, they can't fall in love and I'm
+          history. Right, and where am I gonna be?
+        
+ +
+          Calvin. They're late. My experiment worked. They're
+          all exactly twenty-five minutes slow. I guarantee it.
+          No no no this sucker's electrical, but I need a
+          nuclear reaction to generate the one point twenty-one
+          gigawatts of electricity that I need. he's an idiot,
+          comes from upbringing, parents were probably idiots
+          too. Lorraine, if you ever have a kid like that, I'll
+          disown you.
+        
+
+ +
+

3

+
+          Oh. What, what? Who do you think, the Libyans. I
+          don't know, but I'm gonna find out. Excuse me.
+        
+ +
+          Keys? He's a very strange young man. What, what is it
+          hot? What, what? Oh, if Paul calls me tell him I'm
+          working at the boutique late tonight.
+        
+
+ +
+

4

+
+          George, buddy. remember that girl I introduced you
+          to, Lorraine. What are you writing? You're George
+          McFly. this has gotta be a dream. Why is she gonna
+          get angry with you? Who, who?
+        
+ +
+          That Biff, what a character. Always trying to get
+          away with something. Been on top of Biff ever since
+          high school. Although, if it wasn't for him- Yes,
+          definitely, god-dammit George, swear. Okay, so now,
+          you come up, you punch me in the stomach, I'm out for
+          the count, right? And you and Lorraine live happily
+          ever after. I have to tell you about the future. That
+          Biff, what a character. Always trying to get away
+          with something. Been on top of Biff ever since high
+          school. Although, if it wasn't for him- What a
+          nightmare.
+        
+ +
+ + diff --git a/src/pat/scroll-marker/scroll-marker.js b/src/pat/scroll-marker/scroll-marker.js new file mode 100644 index 000000000..966b76568 --- /dev/null +++ b/src/pat/scroll-marker/scroll-marker.js @@ -0,0 +1,231 @@ +import { BasePattern } from "../../core/basepattern"; +import dom from "../../core/dom"; +import logging from "../../core/logging"; +import Parser from "../../core/parser"; +import registry from "../../core/registry"; +import utils from "../../core/utils"; + +const log = logging.getLogger("scroll-marker"); +//logging.setLevel(logging.Level.DEBUG); + +export const parser = new Parser("scroll-marker"); +parser.addArgument("in-view-class", "in-view"); +parser.addArgument("current-class", "current"); +parser.addArgument("current-content-class", "scroll-marker-current"); + +// Side of element that scrolls. top/bottom/middle/auto (default 'top') +parser.addArgument("side", "top", ["top", "bottom", "middle", "auto"]); +// Distance from side of scroll box. any amount in px, %, vw, vh, vmin or vmax (default '50%') +parser.addArgument("distance", "50%"); +// Visibility of element in scroll box. most-visible or null (default null) +parser.addArgument("visibility", null, [null, "most-visible"]); + +class Pattern extends BasePattern { + static name = "scroll-marker"; + static trigger = ".pat-scroll-marker"; + static parser = parser; + + parser_group_options = false; + + // Used to disable automatically setting the current class when it's set + // differently, e.g. by clicking in a pat-navigation menu. + set_current_disabled = false; + + async init() { + // Get all elements that are referenced by links in the current page. + this.observeables = new Map( + [...dom.querySelectorAllAndMe(this.el, "a[href^='#']")] + .map( + // Create the structure: + // id: {link, target} + // to create the resulting Map holding all necessary information. + (it) => [ + it.hash.split("#")[1], + { link: it, target: document.querySelector(it.hash) }, + ] + ) + .filter( + // Filter again for any missing targets. + (it) => it[1].target + ) + ); + + this.scroll_container = dom.find_scroll_container(this.el, "y", window); + // window.innerHeight or el.clientHeight + const scroll_container_height = + typeof this.scroll_container.innerHeight !== "undefined" + ? this.scroll_container.innerHeight + : this.scroll_container.clientHeight; + this.scroll_marker_distance = utils.parseLength( + this.options.distance, + scroll_container_height + ); + + log.debug("scroll_container: ", this.scroll_container); + log.debug("scroll_container_height: ", scroll_container_height); + log.debug("distance: ", this.options.distance); + log.debug("distance parsed: ", this.scroll_marker_distance); + log.debug("side: ", this.options.side); + log.debug("visibility: ", this.options.visibility); + + this.debounced_scroll_marker_current_callback = utils.debounce( + this.scroll_marker_current_callback.bind(this), + 200, + { timer: null }, + false + ); + + // For debugging. + this.callback_cnt = 0; + + const observer = new IntersectionObserver( + this.scroll_marker_callback.bind(this), + { + root: this.scroll_container === window ? null : this.scroll_container, + threshold: utils.threshold_list(10), + } + ); + for (const it of this.observeables.values()) { + observer.observe(it.target); + } + } + + scroll_marker_callback(entries) { + log.debug("cnt: ", this.callback_cnt++); + log.debug("entries: ", entries); + + for (const entry of entries) { + // Set the in-view class on the link. + const id = entry.target.getAttribute("id"); + const item = this.observeables.get(id); + if (!item) { + continue; + } + + if (entry.isIntersecting) { + item.link.classList.add(this.options["in-view-class"]); + } else { + item.link.classList.remove(this.options["in-view-class"]); + } + } + + // Return if the scroll marker is disabled. + // E.g. when navigation item was clicked. + if (this.set_current_disabled) { + return; + } + + this.debounced_scroll_marker_current_callback(); + } + + scroll_marker_current_callback() { + // Set the item which is nearest to the scroll container's middle to current. + + // First, set all to non-current. + for (const item of this.observeables.values()) { + item.link.classList.remove(this.options["current-class"]); + item.target.classList.remove(this.options["current-content-class"]); + } + + // Sort by distance to the middle of the scroll container. + let items_by_weight = [...this.observeables.values()].map((it) => it.target); + log.debug("items_by_weight initial: ", items_by_weight); + + if (this.options.visibility === "most-visible") { + // Create a map with all ratios. + const ratio_map = new Map( + items_by_weight.map((it) => [ + it, + dom.get_visible_ratio(it, this.scroll_container), + ]) + ); + + log.debug("ratio_map: ", ratio_map); + + // Sort items by ratio of visible area. + items_by_weight.sort((a, b) => { + const ratio_a = ratio_map.get(a); + const ratio_b = ratio_map.get(b); + if (ratio_a > ratio_b) { + return -1; + } + if (ratio_a < ratio_b) { + return 1; + } + return 0; + }); + + log.debug("items_by_weight per ratio: ", items_by_weight); + + // Filter all items which have a ratio smaller than the first item. + const ratio_0 = ratio_map.get(items_by_weight[0]); + items_by_weight = items_by_weight.filter( + (it) => ratio_map.get(it) >= ratio_0 + ); + + log.debug("items_by_weight filtered: ", items_by_weight); + log.debug("ratio item 0: ", ratio_0); + } + // Always sort by distance. + items_by_weight.sort((a, b) => { + let distance_a; + let distance_b; + + const a_rect = a.getBoundingClientRect(); + const b_rect = b.getBoundingClientRect(); + const scroll_marker_distance = this.scroll_marker_distance; + + switch (this.options.side) { + case "top": + distance_a = Math.abs(a_rect.top - scroll_marker_distance); + distance_b = Math.abs(b_rect.top - scroll_marker_distance); + break; + case "bottom": + distance_a = Math.abs(a_rect.bottom - scroll_marker_distance); + distance_b = Math.abs(b_rect.bottom - scroll_marker_distance); + break; + case "middle": + distance_a = Math.abs( + (a_rect.top - a_rect.bottom) / 2 - scroll_marker_distance + ); + distance_b = Math.abs( + (b_rect.top - b_rect.bottom) / 2 - scroll_marker_distance + ); + break; + default: + distance_a = Math.min( + Math.abs(a_rect.top - scroll_marker_distance), + Math.abs(a_rect.bottom - scroll_marker_distance) + ); + distance_b = Math.min( + Math.abs(b_rect.top - scroll_marker_distance), + Math.abs(b_rect.bottom - scroll_marker_distance) + ); + } + + if (distance_a < distance_b) { + return -1; + } + if (distance_a > distance_b) { + return 1; + } + return 0; + }); + + log.debug("items_by_weight per distance: ", items_by_weight); + + // Finally, set the nearest to current. + const nearest = items_by_weight[0]; + const nearest_link = this.observeables.get(nearest?.getAttribute("id"))?.link; + log.debug("nearest: ", nearest); + log.debug("nearest_link: ", nearest_link); + if (nearest_link) { + nearest_link.classList.add(this.options["current-class"]); + nearest.classList.add(this.options["current-content-class"]); + } + } +} + +registry.register(Pattern); +export default Pattern; +export { Pattern }; diff --git a/src/pat/scroll-marker/scroll-marker.test.js b/src/pat/scroll-marker/scroll-marker.test.js new file mode 100644 index 000000000..cf29e2454 --- /dev/null +++ b/src/pat/scroll-marker/scroll-marker.test.js @@ -0,0 +1,164 @@ +import Pattern from "./scroll-marker"; +import events from "../../core/events"; +import utils from "../../core/utils"; + +async function create_scroll_marker(options = {}) { + document.body.innerHTML = ` + + +
+
+ +
+
+ +
+
+ `; + const el = document.querySelector(".pat-scroll-marker"); + + const nav_id1 = document.querySelector("[href='#id1']"); + const nav_id2 = document.querySelector("[href='#id2']"); + const nav_id3 = document.querySelector("[href='#id3']"); + + const id1 = document.querySelector("#id1"); + const id2 = document.querySelector("#id2"); + const id3 = document.querySelector("#id3"); + + // id1 not, id2 fully, id3 partly visible + + jest.replaceProperty(window, "innerHeight", 100); + jest.spyOn(id1, "getBoundingClientRect").mockImplementation(() => { + return { + top: -100, + bottom: -50, + }; + }); + jest.spyOn(id2, "getBoundingClientRect").mockImplementation(() => { + return { + top: 10, + bottom: 60, + }; + }); + jest.spyOn(id3, "getBoundingClientRect").mockImplementation(() => { + return { + top: 70, + bottom: 120, + }; + }); + + const instance = new Pattern(el, options); + await events.await_pattern_init(instance); + + instance.scroll_marker_callback([ + { target: id1, isIntersecting: false }, + { target: id2, isIntersecting: true }, + { target: id3, isIntersecting: true }, + ]); + + // wait for the debounced_scroll_marker_current_callback + await utils.timeout(200); + + return { + el: el, + nav_id1: nav_id1, + nav_id2: nav_id2, + nav_id3: nav_id3, + id1: id1, + id2: id2, + id3: id3, + }; +} + +describe("pat-scroll-marker", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("1: default values, id3 is current", async () => { + // With the default values the baseline is in the middle and the + // content element side's are calculated from the top. id3 is therefore + // the current one, as it's top is nearer to the middle than the top of + // id2. + + const { nav_id1, nav_id2, nav_id3, id1, id2, id3 } = + await create_scroll_marker(); + + expect(nav_id1.classList.contains("in-view")).toBe(false); + expect(nav_id2.classList.contains("in-view")).toBe(true); + expect(nav_id3.classList.contains("in-view")).toBe(true); + + expect(nav_id1.classList.contains("current")).toBe(false); + expect(nav_id2.classList.contains("current")).toBe(false); + expect(nav_id3.classList.contains("current")).toBe(true); + + expect(id1.classList.contains("scroll-marker-current")).toBe(false); + expect(id2.classList.contains("scroll-marker-current")).toBe(false); + expect(id3.classList.contains("scroll-marker-current")).toBe(true); + }); + + it("2: distance 0, id2 is current", async () => { + const { nav_id1, nav_id2, nav_id3, id1, id2, id3 } = await create_scroll_marker({ + distance: 0, + }); + + expect(nav_id1.classList.contains("in-view")).toBe(false); + expect(nav_id2.classList.contains("in-view")).toBe(true); + expect(nav_id3.classList.contains("in-view")).toBe(true); + + expect(nav_id1.classList.contains("current")).toBe(false); + expect(nav_id2.classList.contains("current")).toBe(true); + expect(nav_id3.classList.contains("current")).toBe(false); + + expect(id1.classList.contains("scroll-marker-current")).toBe(false); + expect(id2.classList.contains("scroll-marker-current")).toBe(true); + expect(id3.classList.contains("scroll-marker-current")).toBe(false); + }); + + it("3: distance 50, side bottom, id2 is current", async () => { + const { nav_id1, nav_id2, nav_id3, id1, id2, id3 } = await create_scroll_marker({ + distance: "50%", + side: "bottom", + }); + + expect(nav_id1.classList.contains("in-view")).toBe(false); + expect(nav_id2.classList.contains("in-view")).toBe(true); + expect(nav_id3.classList.contains("in-view")).toBe(true); + + expect(nav_id1.classList.contains("current")).toBe(false); + expect(nav_id2.classList.contains("current")).toBe(true); + expect(nav_id3.classList.contains("current")).toBe(false); + + expect(id1.classList.contains("scroll-marker-current")).toBe(false); + expect(id2.classList.contains("scroll-marker-current")).toBe(true); + expect(id3.classList.contains("scroll-marker-current")).toBe(false); + }); + + it("4: distance 50, side top, visibility: most-visible, id2 is current", async () => { + // Here we have again the default values, this time explicitly set. + // Only the visibility is set to most-visible, which means that id2 is + // the current one, + // + const { nav_id1, nav_id2, nav_id3, id1, id2, id3 } = await create_scroll_marker({ + distance: "50%", + side: "top", + visibility: "most-visible", + }); + + expect(nav_id1.classList.contains("in-view")).toBe(false); + expect(nav_id2.classList.contains("in-view")).toBe(true); + expect(nav_id3.classList.contains("in-view")).toBe(true); + + expect(nav_id1.classList.contains("current")).toBe(false); + expect(nav_id2.classList.contains("current")).toBe(true); + expect(nav_id3.classList.contains("current")).toBe(false); + + expect(id1.classList.contains("scroll-marker-current")).toBe(false); + expect(id2.classList.contains("scroll-marker-current")).toBe(true); + expect(id3.classList.contains("scroll-marker-current")).toBe(false); + }); +}); diff --git a/src/patterns.js b/src/patterns.js index 3553f9df9..7537e47df 100644 --- a/src/patterns.js +++ b/src/patterns.js @@ -45,6 +45,7 @@ import "./pat/notification/notification"; import "./pat/push/push"; import "./pat/scroll-box/scroll-box"; import "./pat/scroll/scroll"; +import "./pat/scroll-marker/scroll-marker"; import "./pat/selectbox/selectbox"; import "./pat/slides/slides"; import "./pat/sortable/sortable"; From 5b0fc43295bb7eab46cb483e8744f1fafb839269 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Fri, 14 Apr 2023 12:10:01 +0200 Subject: [PATCH 18/20] maint(pat navigation): Switch to class based pattern. --- src/pat/navigation/navigation.js | 38 ++++++++++++++++----------- src/pat/navigation/navigation.test.js | 7 +++-- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/pat/navigation/navigation.js b/src/pat/navigation/navigation.js index bc2af7410..4327f82d2 100644 --- a/src/pat/navigation/navigation.js +++ b/src/pat/navigation/navigation.js @@ -1,7 +1,9 @@ -import Base from "../../core/base"; +import $ from "jquery"; +import { BasePattern } from "../../core/basepattern"; import Parser from "../../core/parser"; import logging from "../../core/logging"; import events from "../../core/events"; +import registry from "../../core/registry"; const log = logging.getLogger("navigation"); @@ -10,16 +12,18 @@ parser.addArgument("item-wrapper", "li"); parser.addArgument("in-path-class", "navigation-in-path"); parser.addArgument("current-class", "current"); -export default Base.extend({ - name: "navigation", - trigger: ".pat-navigation", +class Pattern extends BasePattern { + static name = "navigation"; + static trigger = ".pat-navigation"; + static parser = parser; init() { this.options = parser.parse(this.el, this.options); + this.$el = $(this.el); this.init_listeners(); this.init_markings(); - }, + } /** * Initialize listeners for the navigation. @@ -64,7 +68,7 @@ export default Base.extend({ }); this.el.querySelector(`a.${current}, .${current} a`)?.click(); } - }, + } /** * Initial run to mark the current item and its parents. @@ -77,7 +81,7 @@ export default Base.extend({ log.debug("Mark navigation items based on URL pattern."); this.mark_items_url(); } - }, + } /** * Get a matching parent or stop at stop_el. @@ -99,7 +103,7 @@ export default Base.extend({ } matching_parent = matching_parent.parentNode; } - }, + } /** * Mark an item and it's wrapper as current. @@ -120,7 +124,7 @@ export default Base.extend({ this.mark_in_path(wrapper || item); log.debug("Statically set current item marked as current", item); } - }, + } /** * Mark all parent navigation elements as in path. @@ -140,7 +144,7 @@ export default Base.extend({ } path_el = this.get_parent(path_el, this.options.itemWrapper, this.el); } - }, + } /** * Mark all navigation items that are in the path of the current url. @@ -183,7 +187,7 @@ export default Base.extend({ continue; } } - }, + } /** * Clear all navigation items from the inPath and current classes @@ -196,7 +200,7 @@ export default Base.extend({ item.classList.remove(this.options.inPathClass); item.classList.remove(this.options.currentClass); } - }, + } /** * Prepare a URL for comparison. @@ -208,7 +212,7 @@ export default Base.extend({ */ prepare_url(url) { return url?.replace("/view", "").replaceAll("@@", "").replace(/\/$/, ""); - }, + } /** * Get the URL of the current page. @@ -223,5 +227,9 @@ export default Base.extend({ document.querySelector('head link[rel="canonical"]')?.href || window.location.href ); - }, -}); + } +} + +registry.register(Pattern); +export default Pattern; +export { Pattern }; diff --git a/src/pat/navigation/navigation.test.js b/src/pat/navigation/navigation.test.js index c68cdb9fc..67ed23894 100644 --- a/src/pat/navigation/navigation.test.js +++ b/src/pat/navigation/navigation.test.js @@ -1,6 +1,7 @@ import "../inject/inject"; import Pattern from "./navigation"; import Registry from "../../core/registry"; +import events from "../../core/events"; import utils from "../../core/utils"; describe("1 - Navigation pattern tests", function () { @@ -246,7 +247,7 @@ describe("3 - Navigation pattern tests - Mark items based on URL", () => { document.body.dataset.portalUrl = portal_url; }; - it("navigation roundtrip", () => { + it("navigation roundtrip", async () => { document.body.innerHTML = `