katalyst-navigation 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +25 -0
- data/app/assets/config/katalyst-navigation.js +1 -0
- data/app/assets/javascripts/controllers/navigation/editor/item_controller.js +45 -0
- data/app/assets/javascripts/controllers/navigation/editor/list_controller.js +105 -0
- data/app/assets/javascripts/controllers/navigation/editor/menu_controller.js +110 -0
- data/app/assets/javascripts/controllers/navigation/editor/new_item_controller.js +12 -0
- data/app/assets/javascripts/controllers/navigation/editor/status_bar_controller.js +22 -0
- data/app/assets/javascripts/utils/navigation/editor/item.js +245 -0
- data/app/assets/javascripts/utils/navigation/editor/menu.js +54 -0
- data/app/assets/javascripts/utils/navigation/editor/rules-engine.js +140 -0
- data/app/assets/stylesheets/katalyst/navigation/editor/_icon.scss +17 -0
- data/app/assets/stylesheets/katalyst/navigation/editor/_index.scss +145 -0
- data/app/assets/stylesheets/katalyst/navigation/editor/_item-actions.scss +92 -0
- data/app/assets/stylesheets/katalyst/navigation/editor/_item-rules.scss +24 -0
- data/app/assets/stylesheets/katalyst/navigation/editor/_new-items.scss +22 -0
- data/app/assets/stylesheets/katalyst/navigation/editor/_status-bar.scss +87 -0
- data/app/controllers/katalyst/navigation/base_controller.rb +12 -0
- data/app/controllers/katalyst/navigation/items_controller.rb +57 -0
- data/app/controllers/katalyst/navigation/menus_controller.rb +82 -0
- data/app/helpers/katalyst/navigation/editor/base.rb +41 -0
- data/app/helpers/katalyst/navigation/editor/item.rb +69 -0
- data/app/helpers/katalyst/navigation/editor/list.rb +41 -0
- data/app/helpers/katalyst/navigation/editor/menu.rb +37 -0
- data/app/helpers/katalyst/navigation/editor/new_item.rb +53 -0
- data/app/helpers/katalyst/navigation/editor/status_bar.rb +57 -0
- data/app/helpers/katalyst/navigation/editor_helper.rb +46 -0
- data/app/helpers/katalyst/navigation/frontend/builder.rb +53 -0
- data/app/helpers/katalyst/navigation/frontend_helper.rb +42 -0
- data/app/models/concerns/katalyst/navigation/garbage_collection.rb +31 -0
- data/app/models/concerns/katalyst/navigation/has_tree.rb +63 -0
- data/app/models/katalyst/navigation/button.rb +15 -0
- data/app/models/katalyst/navigation/item.rb +21 -0
- data/app/models/katalyst/navigation/link.rb +10 -0
- data/app/models/katalyst/navigation/menu.rb +123 -0
- data/app/models/katalyst/navigation/node.rb +21 -0
- data/app/models/katalyst/navigation/types/nodes_type.rb +42 -0
- data/app/views/katalyst/navigation/items/_button.html.erb +28 -0
- data/app/views/katalyst/navigation/items/_link.html.erb +21 -0
- data/app/views/katalyst/navigation/items/edit.html.erb +4 -0
- data/app/views/katalyst/navigation/items/new.html.erb +4 -0
- data/app/views/katalyst/navigation/items/update.turbo_stream.erb +7 -0
- data/app/views/katalyst/navigation/menus/_item.html.erb +15 -0
- data/app/views/katalyst/navigation/menus/_list_item.html.erb +14 -0
- data/app/views/katalyst/navigation/menus/_new_item.html.erb +3 -0
- data/app/views/katalyst/navigation/menus/_new_items.html.erb +5 -0
- data/app/views/katalyst/navigation/menus/edit.html.erb +15 -0
- data/app/views/katalyst/navigation/menus/index.html.erb +17 -0
- data/app/views/katalyst/navigation/menus/new.html.erb +15 -0
- data/app/views/katalyst/navigation/menus/show.html.erb +15 -0
- data/config/importmap.rb +5 -0
- data/config/locales/en.yml +12 -0
- data/config/routes.rb +9 -0
- data/db/migrate/20220826034057_create_katalyst_navigation_menus.rb +25 -0
- data/db/migrate/20220826034507_create_katalyst_navigation_items.rb +17 -0
- data/lib/katalyst/navigation/engine.rb +36 -0
- data/lib/katalyst/navigation/version.rb +7 -0
- data/lib/katalyst/navigation.rb +9 -0
- data/lib/tasks/yarn.rake +18 -0
- data/spec/factories/katalyst/navigation/items.rb +14 -0
- data/spec/factories/katalyst/navigation/menus.rb +17 -0
- metadata +109 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: '0149b9c1ba7f7a1ecfcc1e085fbe33b2b0eb11d0d5372b743f05d0261daf88e3'
|
4
|
+
data.tar.gz: 2648b6258ac7a7f2b4800f0b1371687cba043d9d4a647f5753076bc8a1b04327
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: d31db0f384749242de03b5bc04da8a0d3137092c702a3c4eda5769bf53fe1f764bb457bd672af5a60c1f3ef1daa22a7d2830e4f53b78b0f2161bdc2db675b54a
|
7
|
+
data.tar.gz: 615fd376052d6b4e48351ef2d0d144a8bd66bfbe615d01b65eed57eb90a1679f4eae630abddbfe82d3aff9f1a47baa09987f4c3a536625f5ea00849612109805
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2022 Katalyst Interactive
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# Navigation
|
2
|
+
|
3
|
+
Generates and edits navigation menus.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Install the gem and add to the application's Gemfile by executing:
|
8
|
+
|
9
|
+
$ bundle add katalyst-navigation
|
10
|
+
|
11
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
12
|
+
|
13
|
+
$ gem install katalyst-navigation
|
14
|
+
|
15
|
+
## Usage
|
16
|
+
|
17
|
+
This gem is still experimental and is not yet ready for public consumption. See dummy app for example usage.
|
18
|
+
|
19
|
+
## Contributing
|
20
|
+
|
21
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/katalyst/navigation.
|
22
|
+
|
23
|
+
## License
|
24
|
+
|
25
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1 @@
|
|
1
|
+
//= link_tree ../javascripts
|
@@ -0,0 +1,45 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
2
|
+
import Item from "utils/navigation/editor/item";
|
3
|
+
|
4
|
+
export default class ItemController extends Controller {
|
5
|
+
get item() {
|
6
|
+
return new Item(this.li);
|
7
|
+
}
|
8
|
+
|
9
|
+
get ol() {
|
10
|
+
return this.element.closest("ol");
|
11
|
+
}
|
12
|
+
|
13
|
+
get li() {
|
14
|
+
return this.element.closest("li");
|
15
|
+
}
|
16
|
+
|
17
|
+
connect() {
|
18
|
+
if (this.element.dataset.hasOwnProperty("delete")) {
|
19
|
+
this.remove();
|
20
|
+
}
|
21
|
+
// if index is not already set, re-index will set it
|
22
|
+
else if (!(this.item.index >= 0)) {
|
23
|
+
this.reindex();
|
24
|
+
}
|
25
|
+
// if item has been replaced via turbo, re-index will run the rules engine
|
26
|
+
// update our depth and index with values from the li's data attributes
|
27
|
+
else if (this.item.hasItemIdChanged()) {
|
28
|
+
this.item.updateAfterChange();
|
29
|
+
this.reindex();
|
30
|
+
}
|
31
|
+
}
|
32
|
+
|
33
|
+
remove() {
|
34
|
+
// capture ol
|
35
|
+
const ol = this.ol;
|
36
|
+
// remove self from dom
|
37
|
+
this.li.remove();
|
38
|
+
// reindex ol
|
39
|
+
this.reindex();
|
40
|
+
}
|
41
|
+
|
42
|
+
reindex() {
|
43
|
+
this.dispatch("reindex", { bubbles: true, prefix: "navigation" });
|
44
|
+
}
|
45
|
+
}
|
@@ -0,0 +1,105 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
2
|
+
|
3
|
+
export default class ListController extends Controller {
|
4
|
+
dragstart(event) {
|
5
|
+
if (this.element !== event.target.parentElement) return;
|
6
|
+
|
7
|
+
const target = event.target;
|
8
|
+
event.dataTransfer.effectAllowed = "move";
|
9
|
+
|
10
|
+
// update element style after drag has begun
|
11
|
+
setTimeout(() => (target.dataset.dragging = ""));
|
12
|
+
}
|
13
|
+
|
14
|
+
dragover(event) {
|
15
|
+
const item = this.dragItem();
|
16
|
+
if (!item) return;
|
17
|
+
|
18
|
+
swap(this.dropTarget(event.target), item);
|
19
|
+
|
20
|
+
event.preventDefault();
|
21
|
+
return true;
|
22
|
+
}
|
23
|
+
|
24
|
+
dragenter(event) {
|
25
|
+
event.preventDefault();
|
26
|
+
|
27
|
+
if (event.dataTransfer.effectAllowed === "copy" && !this.dragItem()) {
|
28
|
+
const item = document.createElement("li");
|
29
|
+
item.dataset.dragging = "";
|
30
|
+
item.dataset.newItem = "";
|
31
|
+
this.element.prepend(item);
|
32
|
+
}
|
33
|
+
}
|
34
|
+
|
35
|
+
dragleave(event) {
|
36
|
+
const item = this.dragItem();
|
37
|
+
const related = this.dropTarget(event.relatedTarget);
|
38
|
+
|
39
|
+
// ignore if item is not set or we're moving into a valid drop target
|
40
|
+
if (!item || related) return;
|
41
|
+
|
42
|
+
// remove item if it's a new item
|
43
|
+
if (item.dataset.hasOwnProperty("newItem")) {
|
44
|
+
item.remove();
|
45
|
+
}
|
46
|
+
}
|
47
|
+
|
48
|
+
drop(event) {
|
49
|
+
const item = this.dragItem();
|
50
|
+
|
51
|
+
if (!item) return;
|
52
|
+
|
53
|
+
event.preventDefault();
|
54
|
+
delete item.dataset.dragging;
|
55
|
+
swap(this.dropTarget(event.target), item);
|
56
|
+
|
57
|
+
if (item.dataset.hasOwnProperty("newItem")) {
|
58
|
+
const template = document.createElement("template");
|
59
|
+
template.innerHTML = event.dataTransfer.getData("text/html");
|
60
|
+
const newItem = template.content.querySelector("li");
|
61
|
+
|
62
|
+
this.element.replaceChild(newItem, item);
|
63
|
+
setTimeout(() =>
|
64
|
+
newItem.querySelector("[role='button'][value='edit']").click()
|
65
|
+
);
|
66
|
+
}
|
67
|
+
|
68
|
+
this.reindex();
|
69
|
+
}
|
70
|
+
|
71
|
+
dragend() {
|
72
|
+
const item = this.dragItem();
|
73
|
+
if (!item) return;
|
74
|
+
|
75
|
+
delete item.dataset.dragging;
|
76
|
+
this.reset();
|
77
|
+
}
|
78
|
+
|
79
|
+
dragItem() {
|
80
|
+
return this.element.querySelector("[data-dragging]");
|
81
|
+
}
|
82
|
+
|
83
|
+
dropTarget(e) {
|
84
|
+
return e && e.closest("[data-controller='navigation--editor--list'] > *");
|
85
|
+
}
|
86
|
+
|
87
|
+
reindex() {
|
88
|
+
this.dispatch("reindex", { bubbles: true, prefix: "navigation" });
|
89
|
+
}
|
90
|
+
|
91
|
+
reset() {
|
92
|
+
this.dispatch("reset", { bubbles: true, prefix: "navigation" });
|
93
|
+
}
|
94
|
+
}
|
95
|
+
|
96
|
+
function swap(target, item) {
|
97
|
+
if (target && target !== item) {
|
98
|
+
const positionComparison = target.compareDocumentPosition(item);
|
99
|
+
if (positionComparison & Node.DOCUMENT_POSITION_FOLLOWING) {
|
100
|
+
target.insertAdjacentElement("beforebegin", item);
|
101
|
+
} else if (positionComparison & Node.DOCUMENT_POSITION_PRECEDING) {
|
102
|
+
target.insertAdjacentElement("afterend", item);
|
103
|
+
}
|
104
|
+
}
|
105
|
+
}
|
@@ -0,0 +1,110 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
2
|
+
|
3
|
+
import Item from "utils/navigation/editor/item";
|
4
|
+
import Menu from "utils/navigation/editor/menu";
|
5
|
+
import RulesEngine from "utils/navigation/editor/rules-engine";
|
6
|
+
|
7
|
+
export default class MenuController extends Controller {
|
8
|
+
static targets = ["menu"];
|
9
|
+
|
10
|
+
connect() {
|
11
|
+
this.state = this.menu.state;
|
12
|
+
|
13
|
+
this.reindex();
|
14
|
+
}
|
15
|
+
|
16
|
+
get menu() {
|
17
|
+
return new Menu(this.menuTarget);
|
18
|
+
}
|
19
|
+
|
20
|
+
reindex() {
|
21
|
+
this.menu.reindex();
|
22
|
+
this.#update();
|
23
|
+
}
|
24
|
+
|
25
|
+
reset() {
|
26
|
+
this.menu.reset();
|
27
|
+
}
|
28
|
+
|
29
|
+
remove(event) {
|
30
|
+
const item = getEventItem(event);
|
31
|
+
|
32
|
+
item.node.remove();
|
33
|
+
|
34
|
+
this.#update();
|
35
|
+
event.preventDefault();
|
36
|
+
}
|
37
|
+
|
38
|
+
nest(event) {
|
39
|
+
const item = getEventItem(event);
|
40
|
+
|
41
|
+
item.traverse((child) => {
|
42
|
+
child.depth += 1;
|
43
|
+
});
|
44
|
+
|
45
|
+
this.#update();
|
46
|
+
event.preventDefault();
|
47
|
+
}
|
48
|
+
|
49
|
+
deNest(event) {
|
50
|
+
const item = getEventItem(event);
|
51
|
+
|
52
|
+
item.traverse((child) => {
|
53
|
+
child.depth -= 1;
|
54
|
+
});
|
55
|
+
|
56
|
+
this.#update();
|
57
|
+
event.preventDefault();
|
58
|
+
}
|
59
|
+
|
60
|
+
collapse(event) {
|
61
|
+
const item = getEventItem(event);
|
62
|
+
|
63
|
+
item.collapse();
|
64
|
+
|
65
|
+
this.#update();
|
66
|
+
event.preventDefault();
|
67
|
+
}
|
68
|
+
|
69
|
+
expand(event) {
|
70
|
+
const item = getEventItem(event);
|
71
|
+
|
72
|
+
item.expand();
|
73
|
+
|
74
|
+
this.#update();
|
75
|
+
event.preventDefault();
|
76
|
+
}
|
77
|
+
|
78
|
+
/**
|
79
|
+
* Re-apply rules to items to enable/disable appropriate actions.
|
80
|
+
*/
|
81
|
+
#update() {
|
82
|
+
// debounce requests to ensure that we only update once per tick
|
83
|
+
this.updateRequested = true;
|
84
|
+
setTimeout(() => {
|
85
|
+
if (!this.updateRequested) return;
|
86
|
+
|
87
|
+
this.updateRequested = false;
|
88
|
+
const engine = new RulesEngine();
|
89
|
+
this.menu.items.forEach((item) => engine.update(item));
|
90
|
+
|
91
|
+
this.#notifyChange();
|
92
|
+
}, 0);
|
93
|
+
}
|
94
|
+
|
95
|
+
#notifyChange() {
|
96
|
+
this.dispatch("change", {
|
97
|
+
bubbles: true,
|
98
|
+
prefix: "navigation",
|
99
|
+
detail: { dirty: this.#isDirty() },
|
100
|
+
});
|
101
|
+
}
|
102
|
+
|
103
|
+
#isDirty() {
|
104
|
+
return this.menu.state !== this.state;
|
105
|
+
}
|
106
|
+
}
|
107
|
+
|
108
|
+
function getEventItem(event) {
|
109
|
+
return new Item(event.target.closest("[data-navigation-item]"));
|
110
|
+
}
|
@@ -0,0 +1,12 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
2
|
+
|
3
|
+
export default class NewItemController extends Controller {
|
4
|
+
static targets = ["template"];
|
5
|
+
|
6
|
+
dragstart(event) {
|
7
|
+
if (this.element !== event.target) return;
|
8
|
+
|
9
|
+
event.dataTransfer.setData("text/html", this.templateTarget.innerHTML);
|
10
|
+
event.dataTransfer.effectAllowed = "copy";
|
11
|
+
}
|
12
|
+
}
|
@@ -0,0 +1,22 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
2
|
+
|
3
|
+
export default class StatusBarController extends Controller {
|
4
|
+
connect() {
|
5
|
+
// cache the version's state in the controller on connect
|
6
|
+
this.versionState = this.element.dataset.state;
|
7
|
+
}
|
8
|
+
|
9
|
+
change(e) {
|
10
|
+
if (e.detail && e.detail.hasOwnProperty("dirty")) {
|
11
|
+
this.update(e.detail);
|
12
|
+
}
|
13
|
+
}
|
14
|
+
|
15
|
+
update({ dirty }) {
|
16
|
+
if (dirty) {
|
17
|
+
this.element.dataset.state = "dirty";
|
18
|
+
} else {
|
19
|
+
this.element.dataset.state = this.versionState;
|
20
|
+
}
|
21
|
+
}
|
22
|
+
}
|
@@ -0,0 +1,245 @@
|
|
1
|
+
export default class Item {
|
2
|
+
/**
|
3
|
+
* Sort items by their index.
|
4
|
+
*
|
5
|
+
* @param a {Item}
|
6
|
+
* @param b {Item}
|
7
|
+
* @returns {number}
|
8
|
+
*/
|
9
|
+
static comparator(a, b) {
|
10
|
+
return a.index - b.index;
|
11
|
+
}
|
12
|
+
|
13
|
+
/**
|
14
|
+
* @param node {Element} li[data-navigation-index]
|
15
|
+
*/
|
16
|
+
constructor(node) {
|
17
|
+
this.node = node;
|
18
|
+
}
|
19
|
+
|
20
|
+
/**
|
21
|
+
* @returns {String} id of the node's item (from data attributes)
|
22
|
+
*/
|
23
|
+
get itemId() {
|
24
|
+
return this.node.dataset[`navigationItemId`];
|
25
|
+
}
|
26
|
+
|
27
|
+
get #itemIdInput() {
|
28
|
+
return this.node.querySelector(`input[name$="[id]"]`);
|
29
|
+
}
|
30
|
+
|
31
|
+
/**
|
32
|
+
* @param itemId {String} id
|
33
|
+
*/
|
34
|
+
set itemId(id) {
|
35
|
+
if (this.itemId === id) return;
|
36
|
+
|
37
|
+
this.node.dataset[`navigationItemId`] = `${id}`;
|
38
|
+
this.#itemIdInput.value = `${id}`;
|
39
|
+
}
|
40
|
+
|
41
|
+
/**
|
42
|
+
* @returns {number} logical nesting depth of node in menu
|
43
|
+
*/
|
44
|
+
get depth() {
|
45
|
+
return parseInt(this.node.dataset[`navigationDepth`]);
|
46
|
+
}
|
47
|
+
|
48
|
+
get #depthInput() {
|
49
|
+
return this.node.querySelector(`input[name$="[depth]"]`);
|
50
|
+
}
|
51
|
+
|
52
|
+
/**
|
53
|
+
* @param depth {number} depth >= 0
|
54
|
+
*/
|
55
|
+
set depth(depth) {
|
56
|
+
if (this.depth === depth) return;
|
57
|
+
|
58
|
+
this.node.dataset[`navigationDepth`] = `${depth}`;
|
59
|
+
this.#depthInput.value = `${depth}`;
|
60
|
+
}
|
61
|
+
|
62
|
+
/**
|
63
|
+
* @returns {number} logical index of node in menu (pre-order traversal)
|
64
|
+
*/
|
65
|
+
get index() {
|
66
|
+
return parseInt(this.node.dataset[`navigationIndex`]);
|
67
|
+
}
|
68
|
+
|
69
|
+
get #indexInput() {
|
70
|
+
return this.node.querySelector(`input[name$="[index]"]`);
|
71
|
+
}
|
72
|
+
|
73
|
+
/**
|
74
|
+
* @param index {number} index >= 0
|
75
|
+
*/
|
76
|
+
set index(index) {
|
77
|
+
if (this.index === index) return;
|
78
|
+
|
79
|
+
this.node.dataset[`navigationIndex`] = `${index}`;
|
80
|
+
this.#indexInput.value = `${index}`;
|
81
|
+
}
|
82
|
+
|
83
|
+
/**
|
84
|
+
* @returns {Item} nearest neighbour (index - 1)
|
85
|
+
*/
|
86
|
+
get previousItem() {
|
87
|
+
let sibling = this.node.previousElementSibling;
|
88
|
+
if (sibling) return new Item(sibling);
|
89
|
+
}
|
90
|
+
|
91
|
+
/**
|
92
|
+
* @returns {Item} nearest neighbour (index + 1)
|
93
|
+
*/
|
94
|
+
get nextItem() {
|
95
|
+
let sibling = this.node.nextElementSibling;
|
96
|
+
if (sibling) return new Item(sibling);
|
97
|
+
}
|
98
|
+
|
99
|
+
/**
|
100
|
+
* @returns {boolean} true if this item has any collapsed children
|
101
|
+
*/
|
102
|
+
hasCollapsedDescendants() {
|
103
|
+
let childrenList = this.#childrenListElement;
|
104
|
+
return !!childrenList && childrenList.children.length > 0;
|
105
|
+
}
|
106
|
+
|
107
|
+
/**
|
108
|
+
* @returns {boolean} true if this item has any expanded children
|
109
|
+
*/
|
110
|
+
hasExpandedDescendants() {
|
111
|
+
let sibling = this.nextItem;
|
112
|
+
return !!sibling && sibling.depth > this.depth;
|
113
|
+
}
|
114
|
+
|
115
|
+
/**
|
116
|
+
* Recursively traverse the node and its descendants.
|
117
|
+
*
|
118
|
+
* @callback {Item}
|
119
|
+
*/
|
120
|
+
traverse(callback) {
|
121
|
+
// capture descendants before traversal in case of side-effects
|
122
|
+
// specifically, setting depth affects calculation
|
123
|
+
const collapsed = this.#collapsedChildren;
|
124
|
+
const expanded = this.#expandedDescendants;
|
125
|
+
|
126
|
+
callback(this);
|
127
|
+
collapsed.forEach((item) => item.traverse(callback));
|
128
|
+
expanded.forEach((item) => item.traverse(callback));
|
129
|
+
}
|
130
|
+
|
131
|
+
/**
|
132
|
+
* Collapses visible (logical) children into this element's hidden children
|
133
|
+
* list, creating it if it doesn't already exist.
|
134
|
+
*/
|
135
|
+
collapse() {
|
136
|
+
let listElement = this.#childrenListElement;
|
137
|
+
|
138
|
+
if (!listElement) listElement = createChildrenList(this.node);
|
139
|
+
|
140
|
+
this.#expandedDescendants.forEach((child) =>
|
141
|
+
listElement.appendChild(child.node)
|
142
|
+
);
|
143
|
+
}
|
144
|
+
|
145
|
+
/**
|
146
|
+
* Moves any collapsed children back into the parent menu.
|
147
|
+
*/
|
148
|
+
expand() {
|
149
|
+
if (!this.hasCollapsedDescendants()) return;
|
150
|
+
|
151
|
+
Array.from(this.#childrenListElement.children)
|
152
|
+
.reverse()
|
153
|
+
.forEach((node) => {
|
154
|
+
this.node.insertAdjacentElement("afterend", node);
|
155
|
+
});
|
156
|
+
}
|
157
|
+
|
158
|
+
/**
|
159
|
+
* Sets the state of a given rule on the target node.
|
160
|
+
*
|
161
|
+
* @param rule {String}
|
162
|
+
* @param deny {boolean}
|
163
|
+
*/
|
164
|
+
toggleRule(rule, deny = false) {
|
165
|
+
if (this.node.dataset.hasOwnProperty(rule) && !deny)
|
166
|
+
delete this.node.dataset[rule];
|
167
|
+
if (!this.node.dataset.hasOwnProperty(rule) && deny)
|
168
|
+
this.node.dataset[rule] = "";
|
169
|
+
|
170
|
+
if (rule === "denyDrag") {
|
171
|
+
if (!this.node.hasAttribute("draggable") && !deny) {
|
172
|
+
this.node.setAttribute("draggable", "true");
|
173
|
+
}
|
174
|
+
if (this.node.hasAttribute("draggable") && deny) {
|
175
|
+
this.node.removeAttribute("draggable");
|
176
|
+
}
|
177
|
+
}
|
178
|
+
}
|
179
|
+
|
180
|
+
/**
|
181
|
+
* Detects turbo item changes by comparing the dataset id with the input
|
182
|
+
*/
|
183
|
+
hasItemIdChanged() {
|
184
|
+
return !(this.#itemIdInput.value === this.itemId);
|
185
|
+
}
|
186
|
+
|
187
|
+
/**
|
188
|
+
* Updates inputs, in case they don't match the data values, e.g., when the
|
189
|
+
* nested inputs have been hot-swapped by turbo with data from the server.
|
190
|
+
*
|
191
|
+
* Updates itemId from input as that is the canonical source.
|
192
|
+
*/
|
193
|
+
updateAfterChange() {
|
194
|
+
this.itemId = this.#itemIdInput.value;
|
195
|
+
this.#indexInput.value = this.index;
|
196
|
+
this.#depthInput.value = this.depth;
|
197
|
+
}
|
198
|
+
|
199
|
+
/**
|
200
|
+
* Finds the dom container for storing collapsed (hidden) children, if present.
|
201
|
+
*
|
202
|
+
* @returns {Element} ol[data-navigation-children]
|
203
|
+
*/
|
204
|
+
get #childrenListElement() {
|
205
|
+
return this.node.querySelector(`:scope > [data-navigation-children]`);
|
206
|
+
}
|
207
|
+
|
208
|
+
get #expandedDescendants() {
|
209
|
+
const descendants = [];
|
210
|
+
|
211
|
+
let sibling = this.nextItem;
|
212
|
+
while (sibling && sibling.depth > this.depth) {
|
213
|
+
descendants.push(sibling);
|
214
|
+
sibling = sibling.nextItem;
|
215
|
+
}
|
216
|
+
|
217
|
+
return descendants;
|
218
|
+
}
|
219
|
+
|
220
|
+
get #collapsedChildren() {
|
221
|
+
if (!this.hasCollapsedDescendants()) return [];
|
222
|
+
|
223
|
+
return Array.from(this.#childrenListElement.children).map(
|
224
|
+
(node) => new Item(node)
|
225
|
+
);
|
226
|
+
}
|
227
|
+
}
|
228
|
+
|
229
|
+
/**
|
230
|
+
* Finds or creates a dom container for storing collapsed (hidden) children.
|
231
|
+
*
|
232
|
+
* @param node {Element} li[data-navigation-index]
|
233
|
+
* @returns {Element} ol[data-navigation-children]
|
234
|
+
*/
|
235
|
+
function createChildrenList(node) {
|
236
|
+
const childrenList = document.createElement("ol");
|
237
|
+
childrenList.setAttribute("class", "hidden");
|
238
|
+
|
239
|
+
// if objectType is "rich-content" set richContentChildren as a data attribute
|
240
|
+
childrenList.dataset[`navigationChildren`] = "";
|
241
|
+
|
242
|
+
node.appendChild(childrenList);
|
243
|
+
|
244
|
+
return childrenList;
|
245
|
+
}
|
@@ -0,0 +1,54 @@
|
|
1
|
+
import Item from "./item";
|
2
|
+
|
3
|
+
/**
|
4
|
+
* @param nodes {NodeList}
|
5
|
+
* @returns {Item[]}
|
6
|
+
*/
|
7
|
+
function createItemList(nodes) {
|
8
|
+
return Array.from(nodes).map((node) => new Item(node));
|
9
|
+
}
|
10
|
+
|
11
|
+
export default class Menu {
|
12
|
+
/**
|
13
|
+
* @param node {Element} navigation editor list
|
14
|
+
*/
|
15
|
+
constructor(node) {
|
16
|
+
this.node = node;
|
17
|
+
}
|
18
|
+
|
19
|
+
/**
|
20
|
+
* @return {Item[]} an ordered list of all items in the menu
|
21
|
+
*/
|
22
|
+
get items() {
|
23
|
+
return createItemList(
|
24
|
+
this.node.querySelectorAll("[data-navigation-index]")
|
25
|
+
);
|
26
|
+
}
|
27
|
+
|
28
|
+
/**
|
29
|
+
* @return {String} a serialized description of the structure of the menu
|
30
|
+
*/
|
31
|
+
get state() {
|
32
|
+
const inputs = this.node.querySelectorAll("li input[type=hidden]");
|
33
|
+
return Array.from(inputs)
|
34
|
+
.map((e) => e.value)
|
35
|
+
.join("/");
|
36
|
+
}
|
37
|
+
|
38
|
+
/**
|
39
|
+
* Set the index of items based on their current position.
|
40
|
+
*/
|
41
|
+
reindex() {
|
42
|
+
this.items.map((item, index) => (item.index = index));
|
43
|
+
}
|
44
|
+
|
45
|
+
/**
|
46
|
+
* Resets the order of items to their defined index.
|
47
|
+
* Useful after an aborted drag.
|
48
|
+
*/
|
49
|
+
reset() {
|
50
|
+
this.items.sort(Item.comparator).forEach((item) => {
|
51
|
+
this.node.appendChild(item.node);
|
52
|
+
});
|
53
|
+
}
|
54
|
+
}
|