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 () => { diff --git a/src/core/basepattern.js b/src/core/basepattern.js index a5cb18065..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; @@ -31,6 +36,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 @@ -60,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 ff0d84ea8..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"; @@ -210,20 +261,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 () { diff --git a/src/core/dom.js b/src/core/dom.js index 31d111e19..a0ee50334 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. @@ -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. @@ -336,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, @@ -351,10 +421,13 @@ 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, 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 77c39b777..8801a0b3f 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"); @@ -750,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(); + }); + }); }); diff --git a/src/core/utils.js b/src/core/utils.js index 087ca6c53..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), @@ -516,7 +564,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 +589,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); }; }; @@ -670,6 +737,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, @@ -685,6 +771,7 @@ var utils = { isElementInViewport: isElementInViewport, hasValue: hasValue, parseTime: parseTime, + parseLength: parseLength, findRelatives: findRelatives, get_bounds: get_bounds, checkInputSupport: checkInputSupport, @@ -701,6 +788,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 ed672f134..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"); @@ -630,6 +700,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)(); @@ -761,3 +850,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, + ]); + }); +}); 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 60b92d548..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"; @@ -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. @@ -45,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) { @@ -489,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; @@ -562,9 +563,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, @@ -716,10 +714,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 +748,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, { diff --git a/src/pat/navigation/documentation.md b/src/pat/navigation/documentation.md index 3ed025d16..c64a37e49 100644 --- a/src/pat/navigation/documentation.md +++ b/src/pat/navigation/documentation.md @@ -5,12 +5,21 @@ Marks navigation paths with "in-path" and "current" classes. ## Documentation -The "in-path" and "current" classes and the "item-wrapper" are configurable. +When clicking on a navigation item this one will always get the `current-class`. +When loading a page and the browser's url matches a navigation item's href value, this one will get the `current-class`. +Any navigation items in it's path will get the `in-path-class`. + +### scroll-marker functionality + +When a content items with an id which matches a hash-url in the navigation are scrolled into view then the corresponding navigation items will get the `in-view-class`. +One matching navigation item will get the `current-class` and the corresponding content item will get the `current-content-class`, depending on the algorithm in use as described in the [pat-scroll-marker documentation](../scroll-marker/documentation.md). +The default is that the content item in view, which top position is neares to the middle of the scrolling container will be the current item. + +### Automatic loading of items You can automatically load the navigation item marked with the `current` class by adding the class `navigation-load-current` along with `pat-navigation` on the pattern element. This would invoke a `click` event on the current navigation item and that can be used to load content via `pat-inject`. -For examples see index.html. ### Option reference @@ -19,6 +28,12 @@ The available options are: | Field | Default | Options | Description | | --------------- | -------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `item-wrapper` | `li` | CSS selector | The DOM element which wraps each menu item. This is used to set the "current" and "in-path" classes also on the wrapper element. If empty, no wrapper element is used. | -| `in-path-class` | `navigation-in-path` | CSS class name | Class name, which is set on item-wrapper elements if nested menu items are within the current path. | -| `current-class` | `current` | CSS class name | Class name, which is set on item-wrapper or items if they are the currently selected menu option. | +| item-wrapper | li | CSS selector | The DOM element which wraps each menu item. This is used to set the "current" and "in-path" classes also on the wrapper element. If empty, no wrapper element is used. | +| in-path-class | navigation-in-path | CSS class name | Class name, which is set on item-wrapper elements if nested menu items are within the current path. | +| current-class | current | CSS class name | Class name, which is set on item-wrapper or items if they are the currently selected menu option or - for hash links - when it's corresponding content item is in view. | +| in-view-class | in-view | | String | CSS class for a navigation item when it's corresponding content item is in view. | +| current-content-class | current | | String | CSS class for a content item when it is the current one. | +| scroll-marker-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. | +| scroll-marker-distance | 50% | | String | Distance from side of scroll box. any amount in px or %. 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. | +| scroll-marker-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/navigation/navigation.js b/src/pat/navigation/navigation.js index bc2af7410..e089d83e8 100644 --- a/src/pat/navigation/navigation.js +++ b/src/pat/navigation/navigation.js @@ -1,31 +1,67 @@ -import Base from "../../core/base"; +import $ from "jquery"; +import { BasePattern } from "../../core/basepattern"; import Parser from "../../core/parser"; +import ScrollMarker from "../scroll-marker/scroll-marker"; import logging from "../../core/logging"; import events from "../../core/events"; +import registry from "../../core/registry"; +import utils from "../../core/utils"; const log = logging.getLogger("navigation"); export const parser = new Parser("navigation"); parser.addArgument("item-wrapper", "li"); parser.addArgument("in-path-class", "navigation-in-path"); +parser.addArgument("in-view-class", "in-view"); parser.addArgument("current-class", "current"); +parser.addArgument("current-content-class", "navigation-current"); -export default Base.extend({ - name: "navigation", - trigger: ".pat-navigation", +// Side of element that scrolls. top/bottom/middle/auto (default 'top') +parser.addArgument("scroll-marker-side", "top", ["top", "bottom", "middle", "auto"]); +// Distance from side of scroll box. any amount in px or % (default '50%') +parser.addArgument("scroll-marker-distance", "50%"); +// Visibility of element in scroll box. most-visible or null (default null) +parser.addArgument("scroll-marker-visibility", null, [null, "most-visible"]); + +class Pattern extends BasePattern { + static name = "navigation"; + static trigger = ".pat-navigation"; + static parser = parser; + + parser_group_options = false; init() { - this.options = parser.parse(this.el, this.options); + this.$el = $(this.el); this.init_listeners(); this.init_markings(); - }, + + this.scroll_marker = new ScrollMarker(this.el, { + "current-class": this.options["current-class"], + "current-content-class": this.options["current-content-class"], + "in-view-class": this.options["in-view-class"], + "side": this.options["scroll-marker-side"], + "distance": this.options["scroll-marker-distance"], + "visibility": this.options["scroll-marker-visibility"], + }); + + this.debounced_scroll_marker_enabler = utils.debounce(() => { + log.debug("Enable scroll-marker."); + this.scroll_marker.set_current_disabled = false; + events.remove_event_listener( + this.scroll_marker.scroll_container === window + ? document + : this.scroll_marker.scroll_container, + "pat-navigation__scroll_marker_enable" + ); + }, 200); + } /** * Initialize listeners for the navigation. */ init_listeners() { - const current = this.options.currentClass; + const current = this.options["current-class"]; events.add_event_listener( this.el, @@ -37,6 +73,20 @@ export default Base.extend({ this.clear_items(); // Mark the current item this.mark_current(ev.target); + + // Disable scroll marker to set the current class after + // clicking in the menu and scrolling to the target. + log.debug("Disable scroll-marker."); + this.scroll_marker.set_current_disabled = true; + this.debounced_scroll_marker_enabler(); + events.add_event_listener( + this.scroll_marker.scroll_container === window + ? document + : this.scroll_marker.scroll_container, + "scroll", + "pat-navigation__scroll_marker_enable", + this.debounced_scroll_marker_enabler + ); } } ); @@ -64,20 +114,20 @@ export default Base.extend({ }); this.el.querySelector(`a.${current}, .${current} a`)?.click(); } - }, + } /** * Initial run to mark the current item and its parents. */ init_markings() { - if (this.el.querySelector(`.${this.options.currentClass}`)) { + if (this.el.querySelector(`.${this.options["current-class"]}`)) { log.debug("Mark navigation items based on existing current class"); this.mark_current(); } else { log.debug("Mark navigation items based on URL pattern."); this.mark_items_url(); } - }, + } /** * Get a matching parent or stop at stop_el. @@ -99,7 +149,7 @@ export default Base.extend({ } matching_parent = matching_parent.parentNode; } - }, + } /** * Mark an item and it's wrapper as current. @@ -114,13 +164,29 @@ export default Base.extend({ : this.el.querySelectorAll(`.current > a, a.current`); for (const item of current_els) { - item.classList.add(this.options.currentClass); - const wrapper = item.closest(this.options.itemWrapper); - wrapper?.classList.add(this.options.currentClass); + item.classList.add(this.options["current-class"]); + const wrapper = item.closest(this.options["item-wrapper"]); + wrapper?.classList.add(this.options["current-class"]); this.mark_in_path(wrapper || item); log.debug("Statically set current item marked as current", item); + + // Clear all previous current content items. + for (const it of [ + ...document.querySelectorAll( + `.${this.options["current-content-class"]}` + ), + ]) { + it.classList.remove(this.options["current-content-class"]); + } + // Mark the current content item, if it is a hash link. + if (item.matches("a[href^='#']")) { + const content_item = document.querySelector(item.getAttribute("href")); + if (content_item) { + content_item.classList.add(this.options["current-content-class"]); + } + } } - }, + } /** * Mark all parent navigation elements as in path. @@ -129,18 +195,18 @@ export default Base.extend({ * */ mark_in_path(start_el) { - let path_el = this.get_parent(start_el, this.options.itemWrapper, this.el); + let path_el = this.get_parent(start_el, this.options["item-wrapper"], this.el); while (path_el) { - if (!path_el.matches(`.${this.options.currentClass}`)) { - path_el.classList.add(this.options.inPathClass); + if (!path_el.matches(`.${this.options["current-class"]}`)) { + path_el.classList.add(this.options["in-path-class"]); for (const it of [...path_el.children].filter((it) => it.matches("a"))) { - it.classList.add(this.options.inPathClass); + it.classList.add(this.options["in-path-class"]); } log.debug("Marked item as in-path", path_el); } - path_el = this.get_parent(path_el, this.options.itemWrapper, this.el); + path_el = this.get_parent(path_el, this.options["item-wrapper"], this.el); } - }, + } /** * Mark all navigation items that are in the path of the current url. @@ -162,11 +228,11 @@ export default Base.extend({ new URL(nav_item.getAttribute("href", ""), current_url)?.href ); - const wrapper = nav_item.closest(this.options.itemWrapper); + const wrapper = nav_item.closest(this.options["item-wrapper"]); if (nav_url === current_url_prepared) { - nav_item.classList.add(this.options.currentClass); - wrapper?.classList.add(this.options.currentClass); + nav_item.classList.add(this.options["current-class"]); + wrapper?.classList.add(this.options["current-class"]); this.mark_in_path(nav_item); } else if ( // Compare the current navigation item url with a slash at the @@ -176,27 +242,27 @@ export default Base.extend({ // be in the path. nav_url !== portal_url ) { - nav_item.classList.add(this.options.inPathClass); - wrapper?.classList.add(this.options.inPathClass); + nav_item.classList.add(this.options["in-path-class"]); + wrapper?.classList.add(this.options["in-path-class"]); } else { // Not even in path. continue; } } - }, + } /** * Clear all navigation items from the inPath and current classes */ clear_items() { const items = this.el.querySelectorAll( - `.${this.options.inPathClass}, .${this.options.currentClass}` + `.${this.options["in-path-class"]}, .${this.options["current-class"]}` ); for (const item of items) { - item.classList.remove(this.options.inPathClass); - item.classList.remove(this.options.currentClass); + item.classList.remove(this.options["in-path-class"]); + item.classList.remove(this.options["current-class"]); } - }, + } /** * Prepare a URL for comparison. @@ -208,7 +274,7 @@ export default Base.extend({ */ prepare_url(url) { return url?.replace("/view", "").replaceAll("@@", "").replace(/\/$/, ""); - }, + } /** * Get the URL of the current page. @@ -223,5 +289,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 = `