Skip to content

Commit 458e76b

Browse files
committed
maint(pat navigation): Refactor implementation for more stability.
1 parent f5269de commit 458e76b

File tree

2 files changed

+130
-177
lines changed

2 files changed

+130
-177
lines changed

src/pat/navigation/navigation.js

Lines changed: 90 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -15,133 +15,121 @@ export default Base.extend({
1515

1616
init() {
1717
this.options = parser.parse(this.el, this.options);
18+
19+
this.init_listeners();
20+
21+
this.mark_current();
22+
},
23+
24+
/**
25+
* Initialize listeners for the navigation.
26+
*/
27+
init_listeners() {
1828
const current = this.options.currentClass;
1929

20-
// Automatically load the ``.current`` item.
30+
// Mark the navigation items after pat-inject triggered within this navigation menu.
31+
this.$el.on("patterns-inject-triggered", "a", (ev) => {
32+
// Remove all set current classes
33+
this.clear_items();
34+
35+
// Mark the current item
36+
this.mark_current(ev.target);
37+
});
38+
39+
// Automatically and recursively load the ``.current`` item.
2140
if (this.el.classList.contains("navigation-load-current")) {
41+
// Check for current elements injected here.
42+
this.$el.on("patterns-injected-scanned", (ev) => {
43+
const target = ev.target;
44+
if (target.matches(`a.${current}`)) target.click();
45+
else if (target.matches(`.${current}`)) target.querySelector("a")?.click(); // prettier-ignore
46+
});
2247
this.el.querySelector(`a.${current}, .${current} a`)?.click();
23-
// check for current elements injected here
24-
this.$el.on(
25-
"patterns-injected-scanned",
26-
function (ev) {
27-
const target = ev.target;
28-
if (target.matches(`a.${current}`)) target.click();
29-
if (target.matches(`.${current}`))
30-
target.querySelector("a")?.click();
31-
this._updatenavpath();
32-
}.bind(this)
33-
);
3448
}
3549

36-
// Mark the navigation items after pat-inject triggered within this navigation menu.
37-
this.$el.on(
38-
"patterns-inject-triggered",
39-
"a",
40-
function (ev) {
41-
const target = ev.target;
42-
// Remove all set current classes
43-
this.el.querySelectorAll(`.${current}`).forEach((it) => {
44-
it.classList.remove(current);
45-
});
46-
// Set current class on target
47-
target.classList.add(current);
48-
// Also set the current class on the item wrapper.
49-
target.closest(this.options.itemWrapper)?.classList.add(current);
50-
this._updatenavpath();
51-
}.bind(this)
52-
);
53-
5450
// Re-init when navigation changes.
55-
const observer = new MutationObserver(this._initialSet.bind(this));
51+
const observer = new MutationObserver(() => {
52+
this.init_listeners();
53+
this.mark_current();
54+
});
5655
observer.observe(this.el, {
5756
childList: true,
5857
subtree: true,
5958
attributes: false,
6059
characterData: false,
6160
});
62-
63-
// Initialize.
64-
this._initialSet();
6561
},
6662

67-
_initialSet() {
68-
const current = this.options.currentClass;
69-
70-
// Set current class if it is not set
71-
if (this.el.querySelectorAll(`.${current}`).length === 0) {
72-
const a_els = this.el.querySelectorAll("a");
73-
for (const a_el of a_els) {
74-
const li = a_el.closest(this.options.itemWrapper);
75-
const url = a_el.getAttribute("href");
76-
if (typeof url === "undefined") {
77-
return;
78-
}
79-
const path = this._pathfromurl(url);
80-
log.debug(`checking url: ${url}, extracted path: ${path}`);
81-
if (this._match(window.location.pathname, path)) {
82-
log.debug("found match", li);
83-
a_el.classList.add(current);
84-
li.classList.add(current);
85-
}
63+
/**
64+
* Get a matching parent or stop at stop_el.
65+
*
66+
* @param {Node} item - The item to start with.
67+
* @param {String} selector - The CSS selector to search parent elements for.
68+
* @param {Node} stop_el - The element to stop at.
69+
*
70+
* @returns {Node} - The matching parent or null.
71+
*/
72+
get_parent(item, selector, stop_el) {
73+
let matching_parent = item.parentNode;
74+
while (matching_parent) {
75+
if (matching_parent === stop_el || matching_parent === document) {
76+
return null;
8677
}
78+
if (matching_parent.matches(selector)) {
79+
return matching_parent;
80+
}
81+
matching_parent = matching_parent.parentNode;
8782
}
88-
89-
// Set current class on item-wrapper, if not set.
90-
if (
91-
this.options.itemWrapper &&
92-
this.el.querySelectorAll(`.${current}`).length > 0 &&
93-
this.el.querySelectorAll(`${this.options.itemWrapper}.${current}`).length ===
94-
0
95-
) {
96-
this.el
97-
.querySelector(`a.${current}`)
98-
.closest(this.options.itemWrapper)
99-
?.classList.add(current);
100-
}
101-
102-
this._updatenavpath();
10383
},
10484

105-
_updatenavpath() {
106-
const in_path = this.options.inPathClass;
107-
if (!in_path) {
108-
return;
85+
/**
86+
* Mark an item and it's wrapper as current.
87+
*
88+
* @param {Node} [current_el] - The item to mark as current.
89+
* If not given, the element's tree will be searched for an existing current item.
90+
* This is to also mark the wrapper and it's path appropriately.
91+
*/
92+
mark_current(current_el) {
93+
const current_els = current_el
94+
? [current_el]
95+
: document.querySelectorAll(`.current > a, a.current`);
96+
97+
for (const item of current_els) {
98+
item.classList.add(this.options.currentClass);
99+
const wrapper = item.closest(this.options.itemWrapper);
100+
wrapper?.classList.add(this.options.currentClass);
101+
this.mark_in_path(wrapper || item);
102+
log.debug("Statically set current item marked as current", item);
109103
}
110-
this.el.querySelectorAll(`.${in_path}`).forEach((it) => {
111-
it.classList.remove(in_path);
112-
});
113-
this.el
114-
.querySelectorAll(
115-
`${this.options.itemWrapper}:not(.${this.options.currentClass})`
116-
)
117-
.forEach((it) => {
118-
if (it.querySelector(`.${this.options.currentClass}`)) {
119-
it.classList.add(in_path);
120-
}
121-
});
122104
},
123105

124-
_match(curpath, path) {
125-
if (!path) {
126-
log.debug("path empty");
127-
return false;
128-
}
129-
// current path needs to end in the anchor's path
130-
if (path !== curpath.slice(-path.length)) {
131-
log.debug(`Current path ${curpath} does not end in ${path}`);
132-
return false;
106+
/**
107+
* Mark all parent navigation elements as in path.
108+
*
109+
* @param {Node} start_el - The element to start with.
110+
*
111+
*/
112+
mark_in_path(start_el) {
113+
let path_el = this.get_parent(start_el, this.options.itemWrapper, this.el);
114+
while (path_el) {
115+
if (!path_el.matches(`.${this.options.currentClass}`)) {
116+
path_el.classList.add(this.options.inPathClass);
117+
log.debug("Marked item as in-path", path_el);
118+
}
119+
path_el = this.get_parent(path_el, this.options.itemWrapper, this.el);
133120
}
134-
// XXX: we might need more exclusion tests
135-
return true;
136121
},
137122

138-
_pathfromurl(url) {
139-
const path = url.split("#")[0].split("://");
140-
if (path.length > 2) {
141-
log.error("weird url", url);
142-
return "";
123+
/**
124+
* Clear all navigation items from the inPath and current classes
125+
*/
126+
clear_items() {
127+
const items = this.el.querySelectorAll(
128+
`.${this.options.inPathClass}, .${this.options.currentClass}`
129+
);
130+
for (const item of items) {
131+
item.classList.remove(this.options.inPathClass);
132+
item.classList.remove(this.options.currentClass);
143133
}
144-
if (path.length === 1) return path[0];
145-
return path[1].split("/").slice(1).join("/");
146134
},
147135
});

src/pat/navigation/navigation.test.js

Lines changed: 40 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,51 @@
1-
import "./navigation";
21
import "../inject/inject";
2+
import Pattern from "./navigation";
33
import Registry from "../../core/registry";
44
import utils from "../../core/utils";
55

66
describe("Navigation pattern tests", function () {
77
beforeEach(function () {
8-
const page_wrapper = document.createElement("div");
9-
page_wrapper.setAttribute("id", "page_wrapper");
10-
11-
const injection_content = document.createElement("article");
12-
injection_content.setAttribute("id", "injection_content");
13-
injection_content.appendChild(document.createTextNode("test content"));
14-
page_wrapper.appendChild(injection_content);
15-
16-
const injection_area = document.createElement("div");
17-
injection_area.setAttribute("id", "injection_area");
18-
page_wrapper.appendChild(injection_area);
19-
20-
// Nav 1
21-
const nav1 = document.createElement("ul");
22-
nav1.setAttribute("class", "pat-navigation nav1");
23-
24-
const w1 = document.createElement("li");
25-
w1.setAttribute("class", "w1");
26-
nav1.appendChild(w1);
27-
28-
const a1 = document.createElement("a");
29-
a1.setAttribute("href", "#injection_content");
30-
a1.setAttribute("class", "pat-inject a1");
31-
a1.setAttribute("data-pat-inject", "target: #injection_area");
32-
a1.appendChild(document.createTextNode("link a1"));
33-
w1.appendChild(a1);
34-
35-
const w11 = document.createElement("li");
36-
w11.setAttribute("class", "w11");
37-
w1.appendChild(w11);
38-
39-
const a11 = document.createElement("a");
40-
a11.setAttribute("href", "#injection_content");
41-
a11.setAttribute("class", "pat-inject a11");
42-
a11.setAttribute("data-pat-inject", "target: #injection_area");
43-
a11.appendChild(document.createTextNode("link a11"));
44-
w11.appendChild(a11);
45-
46-
page_wrapper.appendChild(nav1);
47-
48-
// Nav 2
49-
const nav2 = document.createElement("nav");
50-
nav2.setAttribute("class", "pat-navigation nav2");
51-
nav2.setAttribute(
52-
"data-pat-navigation",
53-
"item-wrapper: div; in-path-class: in-path; current-class: active"
54-
);
55-
56-
const w2 = document.createElement("div");
57-
w2.setAttribute("class", "w2");
58-
nav2.appendChild(w2);
59-
60-
const a2 = document.createElement("a");
61-
a2.setAttribute("href", "#injection_content");
62-
a2.setAttribute("class", "pat-inject a2");
63-
a2.setAttribute("data-pat-inject", "target: #injection_area");
64-
a2.appendChild(document.createTextNode("link a2"));
65-
w2.appendChild(a2);
66-
67-
const w21 = document.createElement("div");
68-
w21.setAttribute("class", "w21");
69-
w2.appendChild(w21);
70-
71-
const a21 = document.createElement("a");
72-
a21.setAttribute("href", "#injection_content");
73-
a21.setAttribute("class", "pat-inject a21");
74-
a21.setAttribute("data-pat-inject", "target: #injection_area");
75-
a21.appendChild(document.createTextNode("link a21"));
76-
w21.appendChild(a21);
77-
78-
page_wrapper.appendChild(nav2);
79-
80-
document.body.appendChild(page_wrapper);
8+
document.body.innerHTML = `
9+
<div id="page_wrapper">
10+
<article id="injection_content">test content</article>
11+
<div id="injection_area"></div>
12+
<ul class="pat-navigation nav1">
13+
<li class="w1">
14+
<a
15+
href="#injection_content"
16+
class="pat-inject a1"
17+
data-pat-inject="target: #injection_area">link a1</a>
18+
<ul class="nav11">
19+
<li class="w11">
20+
<a
21+
href="#injection_content"
22+
class="pat-inject a11"
23+
data-pat-inject="target: #injection_area">link a11</a>
24+
</li>
25+
</ul>
26+
</li>
27+
</ul>
28+
<nav
29+
class="pat-navigation nav2"
30+
data-pat-navigation="item-wrapper: div; in-path-class: in-path; current-class: active">
31+
<div class="w2">
32+
<a
33+
href="#injection_content"
34+
class="pat-inject a2"
35+
data-pat-inject="target: #injection_area">link a2</a>
36+
<div class="w21">
37+
<a
38+
href="#injection_content"
39+
class="pat-inject a21"
40+
data-pat-inject="target: #injection_area">link a21</a>
41+
</div>
42+
</div>
43+
</nav>
44+
</div>
45+
`;
8146
});
8247
afterEach(function () {
83-
document.body.removeChild(document.querySelector("#page_wrapper"));
48+
document.body.innerHTML = "";
8449
});
8550

8651
it("Test 1: Test roundtrip", async () => {

0 commit comments

Comments
 (0)