diff --git a/src/core/dom.js b/src/core/dom.js
index 3f2c1ad83..e1a89ab10 100644
--- a/src/core/dom.js
+++ b/src/core/dom.js
@@ -529,6 +529,34 @@ const escape_css_id = (id) => {
return `#${CSS.escape(id.split("#")[1])}`;
};
+/**
+ * Get a universally unique id (uuid) for a DOM element.
+ *
+ * This method returns a uuid for the given element. On the first call it will
+ * generate a uuid and store it on the element.
+ *
+ * @param {Node} el - The DOM node to get the uuid for.
+ * @returns {String} - The uuid.
+ */
+const element_uuid = (el) => {
+ if (!get_data(el, "uuid", false)) {
+ let uuid;
+ if (window.crypto.randomUUID) {
+ // Create a real UUID
+ // window.crypto.randomUUID does only exist in browsers with secure
+ // context.
+ // See: https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
+ uuid = window.crypto.randomUUID();
+ } else {
+ // Create a sufficiently unique ID
+ const array = new Uint32Array(4);
+ uuid = window.crypto.getRandomValues(array).join("");
+ }
+ set_data(el, "uuid", uuid);
+ }
+ return get_data(el, "uuid");
+};
+
const dom = {
toNodeArray: toNodeArray,
querySelectorAllAndMe: querySelectorAllAndMe,
@@ -556,6 +584,7 @@ const dom = {
template: template,
get_visible_ratio: get_visible_ratio,
escape_css_id: escape_css_id,
+ element_uuid: element_uuid,
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 1835912f9..c44e539d7 100644
--- a/src/core/dom.test.js
+++ b/src/core/dom.test.js
@@ -874,3 +874,32 @@ describe("escape_css_id", function () {
expect(dom.escape_css_id("#-1-2-3")).toBe("#-\\31 -2-3");
});
});
+
+describe("element_uuid", function () {
+ it("returns a UUIDv4 for an element", function () {
+ const el = document.createElement("div");
+ const uuid = dom.element_uuid(el);
+ expect(uuid).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
+ );
+
+ // The UUID isn't created anew when called again.
+ expect(dom.element_uuid(el)).toBe(uuid);
+ });
+
+ it("returns a sufficiently unique id for an element", function () {
+ // Mock window.crypto.randomUUID not existing, like in browser with
+ // non-secure context.
+ const orig_randomUUID = window.crypto.randomUUID;
+ window.crypto.randomUUID = undefined;
+
+ const el = document.createElement("div");
+ const uuid = dom.element_uuid(el);
+ expect(uuid).toMatch(/^[0-9]*$/);
+
+ // The UUID isn't created anew when called again.
+ expect(dom.element_uuid(el)).toBe(uuid);
+
+ window.crypto.randomUUID = orig_randomUUID;
+ });
+});
diff --git a/src/pat/collapsible/documentation.md b/src/pat/collapsible/documentation.md
index 7870318c1..3ecc3aa51 100644
--- a/src/pat/collapsible/documentation.md
+++ b/src/pat/collapsible/documentation.md
@@ -170,4 +170,4 @@ attribute. The available options are:
| `effect-duration` | `fast` | Duration of transition. This is ignored if the transition is `none` or `css`. |
| `effect-easing` | `swing` | Easing to use for the open/close animation. This must be a known jQuery easing method. jQuery includes `swing` and `linear`, but more can be included via jQuery UI. |
| `scroll-selector` | | CSS selector, `self` or `none`. Defines which element will be scrolled into view. `self` if it is the collapsible element itself. `none` to disable scrolling if a scrolling selector is inherited from a parent pat-collapsible element. |
-| `scroll-offset` | | `offset` in pixels to stop scrolling before the target position defines by `scroll-selector`. Can also be a negative number. |
+| `scroll-offset` | | `offset` in pixels to stop scrolling before the target position defined by `scroll-selector`. Can also be a negative number. |
diff --git a/src/pat/scroll/scroll.js b/src/pat/scroll/scroll.js
index 9d8b29807..7e8c248ab 100644
--- a/src/pat/scroll/scroll.js
+++ b/src/pat/scroll/scroll.js
@@ -1,4 +1,3 @@
-import $ from "jquery";
import { BasePattern } from "../../core/basepattern";
import dom from "../../core/dom";
import events from "../../core/events";
@@ -36,15 +35,6 @@ class Pattern extends BasePattern {
if (this.options.trigger === "auto" || this.options.trigger === "click") {
this.el.addEventListener("click", this.scrollTo.bind(this));
}
- $(this.el).on("pat-update", this.onPatternsUpdate.bind(this));
- }
-
- onPatternsUpdate(ev, data) {
- if (data?.pattern === "stacks") {
- if (data.originalEvent && data.originalEvent.type === "click") {
- this.scrollTo();
- }
- }
}
get_target() {
diff --git a/src/pat/scroll/scroll.test.js b/src/pat/scroll/scroll.test.js
index e19d59ea4..9916f9549 100644
--- a/src/pat/scroll/scroll.test.js
+++ b/src/pat/scroll/scroll.test.js
@@ -1,4 +1,3 @@
-import $ from "jquery";
import Pattern from "./scroll";
import events from "../../core/events";
import utils from "../../core/utils";
@@ -89,25 +88,7 @@ describe("pat-scroll", function () {
expect(this.spy_scrollTo).toHaveBeenCalled();
});
- it("5 - will scroll to an anchor on pat-update with originalEvent of click", async function () {
- document.body.innerHTML = `
- p1
-
- `;
- const $el = $(".pat-scroll");
-
- const instance = new Pattern($el[0]);
- await events.await_pattern_init(instance);
- $el.trigger("pat-update", {
- pattern: "stacks",
- originalEvent: {
- type: "click",
- },
- });
- expect(this.spy_scrollTo).toHaveBeenCalled();
- });
-
- it("6 - will allow for programmatic scrolling with trigger set to 'manual'", async function () {
+ it("5 - will allow for programmatic scrolling with trigger set to 'manual'", async function () {
document.body.innerHTML = `
p1
@@ -124,7 +105,7 @@ describe("pat-scroll", function () {
expect(this.spy_scrollTo).toHaveBeenCalled();
});
- it("7 - will scroll to bottom with selector:bottom", async function () {
+ it("6 - will scroll to bottom with selector:bottom", async function () {
document.body.innerHTML = `
@@ -156,7 +137,7 @@ describe("pat-scroll", function () {
expect(container.scrollTop).toBe(1000);
});
- it("8 - will add an offset to the scroll position", async function () {
+ it("7 - will add an offset to the scroll position", async function () {
// Testing with `selector: top`, as this just sets scrollTop to 0
document.body.innerHTML = `
@@ -186,7 +167,7 @@ describe("pat-scroll", function () {
expect(container.scrollTop).toBe(-40);
});
- it("9 - will adds a negative offset to scroll position", async function () {
+ it("8 - will adds a negative offset to scroll position", async function () {
// Testing with `selector: top`, as this just sets scrollTop to 0
document.body.innerHTML = `
@@ -215,7 +196,7 @@ describe("pat-scroll", function () {
expect(container.scrollTop).toBe(40);
});
- it("10 - handles different selector options.", async function () {
+ it("9 - handles different selector options.", async function () {
document.body.innerHTML = `
scroll
diff --git a/src/pat/stacks/documentation.md b/src/pat/stacks/documentation.md
index 8068f73cd..01da4f47d 100644
--- a/src/pat/stacks/documentation.md
+++ b/src/pat/stacks/documentation.md
@@ -45,3 +45,5 @@ The Stacks pattern may be configured through a `data-pat-stacks` attribute. The
| `transition` | `none` | Transition effect to use. Must be one of `none`, `css`, `fade` or `slide`. |
| `effect-duration` | `fast` | Duration of transition. This is ignored if the transition is `none` or `css`. |
| `effect-easing` | `swing` | Easing to use for the transition. This must be a known jQuery easing method. jQuery includes `swing` and `linear`, but more can be included via jQuery UI. |
+| `scroll-selector` | | CSS selector, `self` or `none`. Defines which element will be scrolled into view. `self` if it is the stacks content element itself. `none` to disable scrolling if a scrolling selector is inherited from a parent pat-stacks element. |
+| `scroll-offset` | | `offset` in pixels to stop scrolling before the target position defined by `scroll-selector`. Can also be a negative number. |
diff --git a/src/pat/stacks/stacks.js b/src/pat/stacks/stacks.js
index b6f213c21..29c197b22 100644
--- a/src/pat/stacks/stacks.js
+++ b/src/pat/stacks/stacks.js
@@ -1,7 +1,8 @@
import $ from "jquery";
import { BasePattern } from "../../core/basepattern";
-import Parser from "../../core/parser";
+import events from "../../core/events";
import logging from "../../core/logging";
+import Parser from "../../core/parser";
import registry from "../../core/registry";
import utils from "../../core/utils";
@@ -12,6 +13,11 @@ parser.addArgument("selector", "> *[id]");
parser.addArgument("transition", "none", ["none", "css", "fade", "slide"]);
parser.addArgument("effect-duration", "fast");
parser.addArgument("effect-easing", "swing");
+// pat-scroll support
+parser.addArgument("scroll-selector");
+parser.addArgument("scroll-offset", 0);
+
+const debounce_scroll_timer = { timer: null };
class Pattern extends BasePattern {
static name = "stacks";
@@ -19,12 +25,35 @@ class Pattern extends BasePattern {
static parser = parser;
document = document;
- init() {
+ async init() {
this.$el = $(this.el);
+
+ // pat-scroll support
+ if (this.options.scroll?.selector && this.options.scroll.selector !== "none") {
+ const Scroll = (await import("../scroll/scroll")).default;
+ this.scroll = new Scroll(this.el, {
+ trigger: "manual",
+ selector: this.options.scroll.selector,
+ offset: this.options.scroll?.offset,
+ });
+ await events.await_pattern_init(this.scroll);
+
+ // scroll debouncer for later use.
+ this.debounce_scroll = utils.debounce(
+ this.scroll.scrollTo.bind(this.scroll),
+ 10,
+ debounce_scroll_timer
+ );
+ }
+
this._setupStack();
$(this.document).on("click", "a", this._onClick.bind(this));
}
+ destroy() {
+ $(this.document).off("click", "a", this._onClick.bind(this));
+ }
+
_setupStack() {
let selected = this._currentFragment();
const $sheets = this.$el.find(this.options.selector);
@@ -72,12 +101,16 @@ class Pattern extends BasePattern {
if (base_url !== href_parts[0] || !href_parts[1]) {
return;
}
- if (!this.$el.has("#" + href_parts[1]).length) {
+ if (!this.$el.has(`#${href_parts[1]}`).length) {
return;
}
e.preventDefault();
this._updateAnchors(href_parts[1]);
this._switch(href_parts[1]);
+
+ this.debounce_scroll?.(); // debounce scroll, if available.
+
+ // Notify other patterns
$(e.target).trigger("pat-update", {
pattern: "stacks",
action: "attribute-changed",
@@ -89,20 +122,20 @@ class Pattern extends BasePattern {
_updateAnchors(selected) {
const $sheets = this.$el.find(this.options.selector);
const base_url = this._base_URL();
- $sheets.each(function (idx, sheet) {
+ for (const sheet of $sheets) {
// This may appear odd, but: when querying a browser uses the
// original href of an anchor as it appeared in the document
// source, but when you access the href property you always get
// the fully qualified version.
- var $anchors = $(
- 'a[href="' + base_url + "#" + sheet.id + '"],a[href="#' + sheet.id + '"]'
+ const $anchors = $(
+ `a[href="${base_url}#${sheet.id}"], a[href="#${sheet.id}"]`
);
if (sheet.id === selected) {
$anchors.addClass("current");
} else {
$anchors.removeClass("current");
}
- });
+ }
}
_switch(sheet_id) {
diff --git a/src/pat/stacks/stacks.test.js b/src/pat/stacks/stacks.test.js
index 9db1cfc43..6d9ce8c91 100644
--- a/src/pat/stacks/stacks.test.js
+++ b/src/pat/stacks/stacks.test.js
@@ -1,6 +1,7 @@
import $ from "jquery";
import events from "../../core/events";
import Stacks from "./stacks";
+import utils from "../../core/utils";
import { jest } from "@jest/globals";
describe("pat-stacks", function () {
@@ -164,4 +165,65 @@ describe("pat-stacks", function () {
expect($("#l2").hasClass("current")).toBe(true);
});
});
+
+ describe("5 - Scrolling support.", function () {
+ beforeEach(function () {
+ document.body.innerHTML = "";
+ this.spy_scrollTo = jest
+ .spyOn(window, "scrollTo")
+ .mockImplementation(() => null);
+ });
+
+ afterEach(function () {
+ this.spy_scrollTo.mockRestore();
+ });
+
+ it("5.1 - Scrolls to self.", async function () {
+ document.body.innerHTML = `
+ 1
+
+
+
+
+ `;
+ const el = document.querySelector(".pat-stacks");
+
+ const instance = new Stacks(el);
+ await events.await_pattern_init(instance);
+
+ const s51 = document.querySelector("[href='#s51']");
+ $(s51).click();
+ await utils.timeout(10);
+
+ expect(this.spy_scrollTo).toHaveBeenCalled();
+ });
+
+ it("5.2 - Does clear scroll setting from parent config.", async function () {
+ // NOTE: We give the stack section a different id.
+ // The event handler which is registered on the document in the
+ // previous test is still attached. Two event handlers are run when
+ // clicking here and if the anchor-targets would have the same id
+ // the scrolling would happen as it was set up in the previous
+ // test.
+ document.body.innerHTML = `
+