Skip to content

Commit 7fdfa86

Browse files
authored
Merge pull request #1176 from Patternslib/scrum-1455--scroll
Fix for "unwanted scrolling when inside collapsible"
2 parents a7b23ca + 8479cfa commit 7fdfa86

File tree

9 files changed

+172
-42
lines changed

9 files changed

+172
-42
lines changed

src/core/dom.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,34 @@ const escape_css_id = (id) => {
529529
return `#${CSS.escape(id.split("#")[1])}`;
530530
};
531531

532+
/**
533+
* Get a universally unique id (uuid) for a DOM element.
534+
*
535+
* This method returns a uuid for the given element. On the first call it will
536+
* generate a uuid and store it on the element.
537+
*
538+
* @param {Node} el - The DOM node to get the uuid for.
539+
* @returns {String} - The uuid.
540+
*/
541+
const element_uuid = (el) => {
542+
if (!get_data(el, "uuid", false)) {
543+
let uuid;
544+
if (window.crypto.randomUUID) {
545+
// Create a real UUID
546+
// window.crypto.randomUUID does only exist in browsers with secure
547+
// context.
548+
// See: https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
549+
uuid = window.crypto.randomUUID();
550+
} else {
551+
// Create a sufficiently unique ID
552+
const array = new Uint32Array(4);
553+
uuid = window.crypto.getRandomValues(array).join("");
554+
}
555+
set_data(el, "uuid", uuid);
556+
}
557+
return get_data(el, "uuid");
558+
};
559+
532560
const dom = {
533561
toNodeArray: toNodeArray,
534562
querySelectorAllAndMe: querySelectorAllAndMe,
@@ -556,6 +584,7 @@ const dom = {
556584
template: template,
557585
get_visible_ratio: get_visible_ratio,
558586
escape_css_id: escape_css_id,
587+
element_uuid: element_uuid,
559588
add_event_listener: events.add_event_listener, // BBB export. TODO: Remove in an upcoming version.
560589
remove_event_listener: events.remove_event_listener, // BBB export. TODO: Remove in an upcoming version.
561590
};

src/core/dom.test.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,3 +874,32 @@ describe("escape_css_id", function () {
874874
expect(dom.escape_css_id("#-1-2-3")).toBe("#-\\31 -2-3");
875875
});
876876
});
877+
878+
describe("element_uuid", function () {
879+
it("returns a UUIDv4 for an element", function () {
880+
const el = document.createElement("div");
881+
const uuid = dom.element_uuid(el);
882+
expect(uuid).toMatch(
883+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
884+
);
885+
886+
// The UUID isn't created anew when called again.
887+
expect(dom.element_uuid(el)).toBe(uuid);
888+
});
889+
890+
it("returns a sufficiently unique id for an element", function () {
891+
// Mock window.crypto.randomUUID not existing, like in browser with
892+
// non-secure context.
893+
const orig_randomUUID = window.crypto.randomUUID;
894+
window.crypto.randomUUID = undefined;
895+
896+
const el = document.createElement("div");
897+
const uuid = dom.element_uuid(el);
898+
expect(uuid).toMatch(/^[0-9]*$/);
899+
900+
// The UUID isn't created anew when called again.
901+
expect(dom.element_uuid(el)).toBe(uuid);
902+
903+
window.crypto.randomUUID = orig_randomUUID;
904+
});
905+
});

src/pat/collapsible/documentation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,4 @@ attribute. The available options are:
170170
| `effect-duration` | `fast` | Duration of transition. This is ignored if the transition is `none` or `css`. |
171171
| `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. |
172172
| `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. |
173-
| `scroll-offset` | | `offset` in pixels to stop scrolling before the target position defines by `scroll-selector`. Can also be a negative number. |
173+
| `scroll-offset` | | `offset` in pixels to stop scrolling before the target position defined by `scroll-selector`. Can also be a negative number. |

src/pat/scroll/scroll.js

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import $ from "jquery";
21
import { BasePattern } from "../../core/basepattern";
32
import dom from "../../core/dom";
43
import events from "../../core/events";
@@ -36,15 +35,6 @@ class Pattern extends BasePattern {
3635
if (this.options.trigger === "auto" || this.options.trigger === "click") {
3736
this.el.addEventListener("click", this.scrollTo.bind(this));
3837
}
39-
$(this.el).on("pat-update", this.onPatternsUpdate.bind(this));
40-
}
41-
42-
onPatternsUpdate(ev, data) {
43-
if (data?.pattern === "stacks") {
44-
if (data.originalEvent && data.originalEvent.type === "click") {
45-
this.scrollTo();
46-
}
47-
}
4838
}
4939

5040
get_target() {

src/pat/scroll/scroll.test.js

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import $ from "jquery";
21
import Pattern from "./scroll";
32
import events from "../../core/events";
43
import utils from "../../core/utils";
@@ -89,25 +88,7 @@ describe("pat-scroll", function () {
8988
expect(this.spy_scrollTo).toHaveBeenCalled();
9089
});
9190

92-
it("5 - will scroll to an anchor on pat-update with originalEvent of click", async function () {
93-
document.body.innerHTML = `
94-
<a href="#p1" class="pat-scroll" data-pat-scroll="trigger: click">p1</a>
95-
<p id="p1"></p>
96-
`;
97-
const $el = $(".pat-scroll");
98-
99-
const instance = new Pattern($el[0]);
100-
await events.await_pattern_init(instance);
101-
$el.trigger("pat-update", {
102-
pattern: "stacks",
103-
originalEvent: {
104-
type: "click",
105-
},
106-
});
107-
expect(this.spy_scrollTo).toHaveBeenCalled();
108-
});
109-
110-
it("6 - will allow for programmatic scrolling with trigger set to 'manual'", async function () {
91+
it("5 - will allow for programmatic scrolling with trigger set to 'manual'", async function () {
11192
document.body.innerHTML = `
11293
<a href="#p1" class="pat-scroll" data-pat-scroll="trigger: manual">p1</a>
11394
<p id="p1"></p>
@@ -124,7 +105,7 @@ describe("pat-scroll", function () {
124105
expect(this.spy_scrollTo).toHaveBeenCalled();
125106
});
126107

127-
it("7 - will scroll to bottom with selector:bottom", async function () {
108+
it("6 - will scroll to bottom with selector:bottom", async function () {
128109
document.body.innerHTML = `
129110
<div id="scroll-container" style="overflow-y: scroll">
130111
<button class="pat-scroll" data-pat-scroll="selector: bottom; trigger: manual">to bottom</button>
@@ -156,7 +137,7 @@ describe("pat-scroll", function () {
156137
expect(container.scrollTop).toBe(1000);
157138
});
158139

159-
it("8 - will add an offset to the scroll position", async function () {
140+
it("7 - will add an offset to the scroll position", async function () {
160141
// Testing with `selector: top`, as this just sets scrollTop to 0
161142

162143
document.body.innerHTML = `
@@ -186,7 +167,7 @@ describe("pat-scroll", function () {
186167
expect(container.scrollTop).toBe(-40);
187168
});
188169

189-
it("9 - will adds a negative offset to scroll position", async function () {
170+
it("8 - will adds a negative offset to scroll position", async function () {
190171
// Testing with `selector: top`, as this just sets scrollTop to 0
191172

192173
document.body.innerHTML = `
@@ -215,7 +196,7 @@ describe("pat-scroll", function () {
215196
expect(container.scrollTop).toBe(40);
216197
});
217198

218-
it("10 - handles different selector options.", async function () {
199+
it("9 - handles different selector options.", async function () {
219200
document.body.innerHTML = `
220201
<a href="#el3" class="pat-scroll">scroll</a>
221202
<div id="el1"></div>

src/pat/stacks/documentation.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,5 @@ The Stacks pattern may be configured through a `data-pat-stacks` attribute. The
4545
| `transition` | `none` | Transition effect to use. Must be one of `none`, `css`, `fade` or `slide`. |
4646
| `effect-duration` | `fast` | Duration of transition. This is ignored if the transition is `none` or `css`. |
4747
| `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. |
48+
| `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. |
49+
| `scroll-offset` | | `offset` in pixels to stop scrolling before the target position defined by `scroll-selector`. Can also be a negative number. |

src/pat/stacks/stacks.js

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import $ from "jquery";
22
import { BasePattern } from "../../core/basepattern";
3-
import Parser from "../../core/parser";
3+
import events from "../../core/events";
44
import logging from "../../core/logging";
5+
import Parser from "../../core/parser";
56
import registry from "../../core/registry";
67
import utils from "../../core/utils";
78

@@ -12,19 +13,47 @@ parser.addArgument("selector", "> *[id]");
1213
parser.addArgument("transition", "none", ["none", "css", "fade", "slide"]);
1314
parser.addArgument("effect-duration", "fast");
1415
parser.addArgument("effect-easing", "swing");
16+
// pat-scroll support
17+
parser.addArgument("scroll-selector");
18+
parser.addArgument("scroll-offset", 0);
19+
20+
const debounce_scroll_timer = { timer: null };
1521

1622
class Pattern extends BasePattern {
1723
static name = "stacks";
1824
static trigger = ".pat-stacks";
1925
static parser = parser;
2026
document = document;
2127

22-
init() {
28+
async init() {
2329
this.$el = $(this.el);
30+
31+
// pat-scroll support
32+
if (this.options.scroll?.selector && this.options.scroll.selector !== "none") {
33+
const Scroll = (await import("../scroll/scroll")).default;
34+
this.scroll = new Scroll(this.el, {
35+
trigger: "manual",
36+
selector: this.options.scroll.selector,
37+
offset: this.options.scroll?.offset,
38+
});
39+
await events.await_pattern_init(this.scroll);
40+
41+
// scroll debouncer for later use.
42+
this.debounce_scroll = utils.debounce(
43+
this.scroll.scrollTo.bind(this.scroll),
44+
10,
45+
debounce_scroll_timer
46+
);
47+
}
48+
2449
this._setupStack();
2550
$(this.document).on("click", "a", this._onClick.bind(this));
2651
}
2752

53+
destroy() {
54+
$(this.document).off("click", "a", this._onClick.bind(this));
55+
}
56+
2857
_setupStack() {
2958
let selected = this._currentFragment();
3059
const $sheets = this.$el.find(this.options.selector);
@@ -72,12 +101,16 @@ class Pattern extends BasePattern {
72101
if (base_url !== href_parts[0] || !href_parts[1]) {
73102
return;
74103
}
75-
if (!this.$el.has("#" + href_parts[1]).length) {
104+
if (!this.$el.has(`#${href_parts[1]}`).length) {
76105
return;
77106
}
78107
e.preventDefault();
79108
this._updateAnchors(href_parts[1]);
80109
this._switch(href_parts[1]);
110+
111+
this.debounce_scroll?.(); // debounce scroll, if available.
112+
113+
// Notify other patterns
81114
$(e.target).trigger("pat-update", {
82115
pattern: "stacks",
83116
action: "attribute-changed",
@@ -89,20 +122,20 @@ class Pattern extends BasePattern {
89122
_updateAnchors(selected) {
90123
const $sheets = this.$el.find(this.options.selector);
91124
const base_url = this._base_URL();
92-
$sheets.each(function (idx, sheet) {
125+
for (const sheet of $sheets) {
93126
// This may appear odd, but: when querying a browser uses the
94127
// original href of an anchor as it appeared in the document
95128
// source, but when you access the href property you always get
96129
// the fully qualified version.
97-
var $anchors = $(
98-
'a[href="' + base_url + "#" + sheet.id + '"],a[href="#' + sheet.id + '"]'
130+
const $anchors = $(
131+
`a[href="${base_url}#${sheet.id}"], a[href="#${sheet.id}"]`
99132
);
100133
if (sheet.id === selected) {
101134
$anchors.addClass("current");
102135
} else {
103136
$anchors.removeClass("current");
104137
}
105-
});
138+
}
106139
}
107140

108141
_switch(sheet_id) {

src/pat/stacks/stacks.test.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import $ from "jquery";
22
import events from "../../core/events";
33
import Stacks from "./stacks";
4+
import utils from "../../core/utils";
45
import { jest } from "@jest/globals";
56

67
describe("pat-stacks", function () {
@@ -164,4 +165,65 @@ describe("pat-stacks", function () {
164165
expect($("#l2").hasClass("current")).toBe(true);
165166
});
166167
});
168+
169+
describe("5 - Scrolling support.", function () {
170+
beforeEach(function () {
171+
document.body.innerHTML = "";
172+
this.spy_scrollTo = jest
173+
.spyOn(window, "scrollTo")
174+
.mockImplementation(() => null);
175+
});
176+
177+
afterEach(function () {
178+
this.spy_scrollTo.mockRestore();
179+
});
180+
181+
it("5.1 - Scrolls to self.", async function () {
182+
document.body.innerHTML = `
183+
<a href='#s51'>1</a>
184+
<div class="pat-stacks" data-pat-stacks="scroll-selector: self">
185+
<section id="s51">
186+
</section>
187+
</div>
188+
`;
189+
const el = document.querySelector(".pat-stacks");
190+
191+
const instance = new Stacks(el);
192+
await events.await_pattern_init(instance);
193+
194+
const s51 = document.querySelector("[href='#s51']");
195+
$(s51).click();
196+
await utils.timeout(10);
197+
198+
expect(this.spy_scrollTo).toHaveBeenCalled();
199+
});
200+
201+
it("5.2 - Does clear scroll setting from parent config.", async function () {
202+
// NOTE: We give the stack section a different id.
203+
// The event handler which is registered on the document in the
204+
// previous test is still attached. Two event handlers are run when
205+
// clicking here and if the anchor-targets would have the same id
206+
// the scrolling would happen as it was set up in the previous
207+
// test.
208+
document.body.innerHTML = `
209+
<div data-pat-stacks="scroll-selector: self">
210+
<a href='#s52'>1</a>
211+
<div class="pat-stacks" data-pat-stacks="scroll-selector: none">
212+
<section id="s52">
213+
</section>
214+
</div>
215+
</div>
216+
`;
217+
const el = document.querySelector(".pat-stacks");
218+
219+
const instance = new Stacks(el);
220+
await events.await_pattern_init(instance);
221+
222+
const s52 = document.querySelector("[href='#s52']");
223+
$(s52).click();
224+
await utils.timeout(10);
225+
226+
expect(this.spy_scrollTo).not.toHaveBeenCalled();
227+
});
228+
});
167229
});

src/setup-tests.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,7 @@ dom.is_visible = (el) => {
2222

2323
// polyfill css.escape for jsdom
2424
import("css.escape");
25+
26+
// NodeJS polyfill for window.crypto.randomUUID
27+
import crypto from "crypto";
28+
window.crypto.randomUUID = () => crypto.randomUUID();

0 commit comments

Comments
 (0)