katalyst-navigation 1.4.1 → 1.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +29 -5
  3. data/app/assets/builds/katalyst/navigation.esm.js +911 -0
  4. data/app/assets/builds/katalyst/navigation.js +911 -0
  5. data/app/assets/builds/katalyst/navigation.min.js +2 -0
  6. data/app/assets/builds/katalyst/navigation.min.js.map +1 -0
  7. data/app/assets/config/katalyst-navigation.js +1 -1
  8. data/app/components/katalyst/navigation/editor/base_component.rb +48 -0
  9. data/app/components/katalyst/navigation/editor/errors_component.html.erb +12 -0
  10. data/app/components/katalyst/navigation/editor/errors_component.rb +15 -0
  11. data/app/components/katalyst/navigation/editor/item_component.html.erb +27 -0
  12. data/app/components/katalyst/navigation/editor/item_component.rb +30 -0
  13. data/app/components/katalyst/navigation/editor/item_editor_component.rb +51 -0
  14. data/app/components/katalyst/navigation/editor/new_item_component.html.erb +14 -0
  15. data/app/components/katalyst/navigation/editor/new_item_component.rb +49 -0
  16. data/app/components/katalyst/navigation/editor/new_items_component.html.erb +3 -0
  17. data/app/components/katalyst/navigation/editor/new_items_component.rb +20 -0
  18. data/app/{views/katalyst/navigation/menus/_list_item.html.erb → components/katalyst/navigation/editor/row_component.html.erb} +1 -1
  19. data/{lib/katalyst/navigation/version.rb → app/components/katalyst/navigation/editor/row_component.rb} +4 -1
  20. data/app/{helpers/katalyst/navigation/editor/status_bar.rb → components/katalyst/navigation/editor/status_bar_component.rb} +17 -13
  21. data/app/components/katalyst/navigation/editor/table_component.html.erb +11 -0
  22. data/app/components/katalyst/navigation/editor/table_component.rb +36 -0
  23. data/app/components/katalyst/navigation/editor_component.html.erb +9 -0
  24. data/app/components/katalyst/navigation/editor_component.rb +47 -0
  25. data/app/controllers/concerns/katalyst/navigation/has_navigation.rb +5 -14
  26. data/app/controllers/katalyst/navigation/items_controller.rb +24 -12
  27. data/app/controllers/katalyst/navigation/menus_controller.rb +25 -13
  28. data/app/helpers/katalyst/navigation/frontend_helper.rb +7 -7
  29. data/app/javascript/navigation/application.js +30 -0
  30. data/app/{assets/javascripts/controllers → javascript}/navigation/editor/item_controller.js +1 -1
  31. data/app/{assets/javascripts/utils → javascript}/navigation/editor/menu.js +1 -1
  32. data/app/{assets/javascripts/controllers → javascript}/navigation/editor/menu_controller.js +3 -3
  33. data/app/models/katalyst/navigation/menu.rb +2 -0
  34. data/app/models/katalyst/navigation/types/nodes_type.rb +2 -2
  35. data/app/views/katalyst/navigation/items/_button.html.erb +6 -0
  36. data/app/views/katalyst/navigation/items/_form_errors.html.erb +5 -0
  37. data/app/views/katalyst/navigation/items/_heading.html.erb +1 -0
  38. data/app/views/katalyst/navigation/items/_link.html.erb +11 -0
  39. data/app/views/katalyst/navigation/items/edit.html.erb +4 -3
  40. data/app/views/katalyst/navigation/items/edit.turbo_stream.erb +3 -0
  41. data/app/views/katalyst/navigation/items/new.html.erb +1 -1
  42. data/app/views/katalyst/navigation/items/update.turbo_stream.erb +2 -5
  43. data/app/views/katalyst/navigation/menus/edit.html.erb +1 -1
  44. data/app/views/katalyst/navigation/menus/index.html.erb +1 -1
  45. data/app/views/katalyst/navigation/menus/show.html.erb +3 -13
  46. data/config/importmap.rb +1 -4
  47. data/lib/katalyst/navigation/config.rb +4 -0
  48. data/lib/katalyst/navigation/engine.rb +1 -1
  49. data/lib/katalyst/navigation.rb +6 -1
  50. data/spec/factories/katalyst/navigation/menus.rb +1 -1
  51. metadata +93 -27
  52. data/app/controllers/katalyst/navigation/base_controller.rb +0 -12
  53. data/app/helpers/katalyst/navigation/editor/base.rb +0 -41
  54. data/app/helpers/katalyst/navigation/editor/errors.rb +0 -24
  55. data/app/helpers/katalyst/navigation/editor/item.rb +0 -62
  56. data/app/helpers/katalyst/navigation/editor/list.rb +0 -41
  57. data/app/helpers/katalyst/navigation/editor/menu.rb +0 -47
  58. data/app/helpers/katalyst/navigation/editor/new_item.rb +0 -53
  59. data/app/helpers/katalyst/navigation/editor_helper.rb +0 -52
  60. data/app/views/katalyst/navigation/menus/_item.html.erb +0 -15
  61. data/app/views/katalyst/navigation/menus/_new_item.html.erb +0 -3
  62. data/app/views/katalyst/navigation/menus/_new_items.html.erb +0 -5
  63. /data/app/{assets/javascripts/utils → javascript}/navigation/editor/item.js +0 -0
  64. /data/app/{assets/javascripts/controllers → javascript}/navigation/editor/list_controller.js +0 -0
  65. /data/app/{assets/javascripts/controllers → javascript}/navigation/editor/new_item_controller.js +0 -0
  66. /data/app/{assets/javascripts/utils → javascript}/navigation/editor/rules-engine.js +0 -0
  67. /data/app/{assets/javascripts/controllers → javascript}/navigation/editor/status_bar_controller.js +0 -0
@@ -0,0 +1,911 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ class Item {
4
+ /**
5
+ * Sort items by their index.
6
+ *
7
+ * @param a {Item}
8
+ * @param b {Item}
9
+ * @returns {number}
10
+ */
11
+ static comparator(a, b) {
12
+ return a.index - b.index;
13
+ }
14
+
15
+ /**
16
+ * @param node {Element} li[data-navigation-index]
17
+ */
18
+ constructor(node) {
19
+ this.node = node;
20
+ }
21
+
22
+ /**
23
+ * @returns {String} id of the node's item (from data attributes)
24
+ */
25
+ get itemId() {
26
+ return this.node.dataset[`navigationItemId`];
27
+ }
28
+
29
+ get #itemIdInput() {
30
+ return this.node.querySelector(`input[name$="[id]"]`);
31
+ }
32
+
33
+ /**
34
+ * @param itemId {String} id
35
+ */
36
+ set itemId(id) {
37
+ if (this.itemId === id) return;
38
+
39
+ this.node.dataset[`navigationItemId`] = `${id}`;
40
+ this.#itemIdInput.value = `${id}`;
41
+ }
42
+
43
+ /**
44
+ * @returns {number} logical nesting depth of node in menu
45
+ */
46
+ get depth() {
47
+ return parseInt(this.node.dataset[`navigationDepth`]) || 0;
48
+ }
49
+
50
+ get #depthInput() {
51
+ return this.node.querySelector(`input[name$="[depth]"]`);
52
+ }
53
+
54
+ /**
55
+ * @param depth {number} depth >= 0
56
+ */
57
+ set depth(depth) {
58
+ if (this.depth === depth) return;
59
+
60
+ this.node.dataset[`navigationDepth`] = `${depth}`;
61
+ this.#depthInput.value = `${depth}`;
62
+ }
63
+
64
+ /**
65
+ * @returns {number} logical index of node in menu (pre-order traversal)
66
+ */
67
+ get index() {
68
+ return parseInt(this.node.dataset[`navigationIndex`]);
69
+ }
70
+
71
+ get #indexInput() {
72
+ return this.node.querySelector(`input[name$="[index]"]`);
73
+ }
74
+
75
+ /**
76
+ * @param index {number} index >= 0
77
+ */
78
+ set index(index) {
79
+ if (this.index === index) return;
80
+
81
+ this.node.dataset[`navigationIndex`] = `${index}`;
82
+ this.#indexInput.value = `${index}`;
83
+ }
84
+
85
+ /**
86
+ * @returns {boolean} true if this item can have children
87
+ */
88
+ get isLayout() {
89
+ return this.node.hasAttribute("data-content-layout");
90
+ }
91
+
92
+ /**
93
+ * @returns {Item} nearest neighbour (index - 1)
94
+ */
95
+ get previousItem() {
96
+ let sibling = this.node.previousElementSibling;
97
+ if (sibling) return new Item(sibling);
98
+ }
99
+
100
+ /**
101
+ * @returns {Item} nearest neighbour (index + 1)
102
+ */
103
+ get nextItem() {
104
+ let sibling = this.node.nextElementSibling;
105
+ if (sibling) return new Item(sibling);
106
+ }
107
+
108
+ /**
109
+ * @returns {boolean} true if this item has any collapsed children
110
+ */
111
+ hasCollapsedDescendants() {
112
+ let childrenList = this.#childrenListElement;
113
+ return !!childrenList && childrenList.children.length > 0;
114
+ }
115
+
116
+ /**
117
+ * @returns {boolean} true if this item has any expanded children
118
+ */
119
+ hasExpandedDescendants() {
120
+ let sibling = this.nextItem;
121
+ return !!sibling && sibling.depth > this.depth;
122
+ }
123
+
124
+ /**
125
+ * Recursively traverse the node and its descendants.
126
+ *
127
+ * @callback {Item}
128
+ */
129
+ traverse(callback) {
130
+ // capture descendants before traversal in case of side-effects
131
+ // specifically, setting depth affects calculation
132
+ const expanded = this.#expandedDescendants;
133
+
134
+ callback(this);
135
+ this.#traverseCollapsed(callback);
136
+ expanded.forEach((item) => item.#traverseCollapsed(callback));
137
+ }
138
+
139
+ /**
140
+ * Recursively traverse the node's collapsed descendants, if any.
141
+ *
142
+ * @callback {Item}
143
+ */
144
+ #traverseCollapsed(callback) {
145
+ if (!this.hasCollapsedDescendants()) return;
146
+
147
+ this.#collapsedDescendants.forEach((item) => {
148
+ callback(item);
149
+ item.#traverseCollapsed(callback);
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Collapses visible (logical) children into this element's hidden children
155
+ * list, creating it if it doesn't already exist.
156
+ */
157
+ collapse() {
158
+ let listElement = this.#childrenListElement;
159
+
160
+ if (!listElement) listElement = createChildrenList(this.node);
161
+
162
+ this.#expandedDescendants.forEach((child) =>
163
+ listElement.appendChild(child.node)
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Moves any collapsed children back into the parent menu.
169
+ */
170
+ expand() {
171
+ if (!this.hasCollapsedDescendants()) return;
172
+
173
+ Array.from(this.#childrenListElement.children)
174
+ .reverse()
175
+ .forEach((node) => {
176
+ this.node.insertAdjacentElement("afterend", node);
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Sets the state of a given rule on the target node.
182
+ *
183
+ * @param rule {String}
184
+ * @param deny {boolean}
185
+ */
186
+ toggleRule(rule, deny = false) {
187
+ if (this.node.dataset.hasOwnProperty(rule) && !deny) {
188
+ delete this.node.dataset[rule];
189
+ }
190
+ if (!this.node.dataset.hasOwnProperty(rule) && deny) {
191
+ this.node.dataset[rule] = "";
192
+ }
193
+
194
+ if (rule === "denyDrag") {
195
+ if (!this.node.hasAttribute("draggable") && !deny) {
196
+ this.node.setAttribute("draggable", "true");
197
+ }
198
+ if (this.node.hasAttribute("draggable") && deny) {
199
+ this.node.removeAttribute("draggable");
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Detects turbo item changes by comparing the dataset id with the input
206
+ */
207
+ hasItemIdChanged() {
208
+ return !(this.#itemIdInput.value === this.itemId);
209
+ }
210
+
211
+ /**
212
+ * Updates inputs, in case they don't match the data values, e.g., when the
213
+ * nested inputs have been hot-swapped by turbo with data from the server.
214
+ *
215
+ * Updates itemId from input as that is the canonical source.
216
+ */
217
+ updateAfterChange() {
218
+ this.itemId = this.#itemIdInput.value;
219
+ this.#indexInput.value = this.index;
220
+ this.#depthInput.value = this.depth;
221
+ }
222
+
223
+ /**
224
+ * Finds the dom container for storing collapsed (hidden) children, if present.
225
+ *
226
+ * @returns {Element} ol[data-navigation-children]
227
+ */
228
+ get #childrenListElement() {
229
+ return this.node.querySelector(`:scope > [data-navigation-children]`);
230
+ }
231
+
232
+ /**
233
+ * @returns {Item[]} all items that follow this element that have a greater depth.
234
+ */
235
+ get #expandedDescendants() {
236
+ const descendants = [];
237
+
238
+ let sibling = this.nextItem;
239
+ while (sibling && sibling.depth > this.depth) {
240
+ descendants.push(sibling);
241
+ sibling = sibling.nextItem;
242
+ }
243
+
244
+ return descendants;
245
+ }
246
+
247
+ /**
248
+ * @returns {Item[]} all items directly contained inside this element's hidden children element.
249
+ */
250
+ get #collapsedDescendants() {
251
+ if (!this.hasCollapsedDescendants()) return [];
252
+
253
+ return Array.from(this.#childrenListElement.children).map(
254
+ (node) => new Item(node)
255
+ );
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Finds or creates a dom container for storing collapsed (hidden) children.
261
+ *
262
+ * @param node {Element} li[data-navigation-index]
263
+ * @returns {Element} ol[data-navigation-children]
264
+ */
265
+ function createChildrenList(node) {
266
+ const childrenList = document.createElement("ol");
267
+ childrenList.setAttribute("class", "hidden");
268
+
269
+ // if objectType is "rich-content" set richContentChildren as a data attribute
270
+ childrenList.dataset[`navigationChildren`] = "";
271
+
272
+ node.appendChild(childrenList);
273
+
274
+ return childrenList;
275
+ }
276
+
277
+ /**
278
+ * @param nodes {NodeList}
279
+ * @returns {Item[]}
280
+ */
281
+ function createItemList(nodes) {
282
+ return Array.from(nodes).map((node) => new Item(node));
283
+ }
284
+
285
+ class Menu {
286
+ /**
287
+ * @param node {Element} navigation editor list
288
+ */
289
+ constructor(node) {
290
+ this.node = node;
291
+ }
292
+
293
+ /**
294
+ * @return {Item[]} an ordered list of all items in the menu
295
+ */
296
+ get items() {
297
+ return createItemList(
298
+ this.node.querySelectorAll("[data-navigation-index]")
299
+ );
300
+ }
301
+
302
+ /**
303
+ * @return {String} a serialized description of the structure of the menu
304
+ */
305
+ get state() {
306
+ const inputs = this.node.querySelectorAll("li input[type=hidden]");
307
+ return Array.from(inputs)
308
+ .map((e) => e.value)
309
+ .join("/");
310
+ }
311
+
312
+ /**
313
+ * Set the index of items based on their current position.
314
+ */
315
+ reindex() {
316
+ this.items.map((item, index) => (item.index = index));
317
+ }
318
+
319
+ /**
320
+ * Resets the order of items to their defined index.
321
+ * Useful after an aborted drag.
322
+ */
323
+ reset() {
324
+ this.items.sort(Item.comparator).forEach((item) => {
325
+ this.node.appendChild(item.node);
326
+ });
327
+ }
328
+ }
329
+
330
+ class RulesEngine {
331
+ static rules = [
332
+ "denyDeNest",
333
+ "denyNest",
334
+ "denyCollapse",
335
+ "denyExpand",
336
+ "denyRemove",
337
+ "denyDrag",
338
+ "denyEdit",
339
+ ];
340
+
341
+ constructor(maxDepth = null, debug = false) {
342
+ this.maxDepth = maxDepth;
343
+ if (debug) {
344
+ this.debug = (...args) => console.log(...args);
345
+ } else {
346
+ this.debug = () => {};
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Enforce structural rules to ensure that the given item is currently in a
352
+ * valid state.
353
+ *
354
+ * @param {Item} item
355
+ */
356
+ normalize(item) {
357
+ // structural rules enforce a valid tree structure
358
+ this.firstItemDepthZero(item);
359
+ this.depthMustBeSet(item);
360
+ this.itemCannotHaveInvalidDepth(item);
361
+ this.itemCannotExceedDepthLimit(item);
362
+ this.parentMustBeLayout(item);
363
+ this.parentCannotHaveExpandedAndCollapsedChildren(item);
364
+ }
365
+
366
+ /**
367
+ * Apply rules to the given item to determine what operations are permitted.
368
+ *
369
+ * @param {Item} item
370
+ */
371
+ update(item) {
372
+ this.rules = {};
373
+
374
+ // behavioural rules define what the user is allowed to do
375
+ this.parentsCannotDeNest(item);
376
+ this.rootsCannotDeNest(item);
377
+ this.nestingNeedsParent(item);
378
+ this.nestingCannotExceedMaxDepth(item);
379
+ this.leavesCannotCollapse(item);
380
+ this.needHiddenItemsToExpand(item);
381
+ this.parentsCannotBeDeleted(item);
382
+ this.parentsCannotBeDragged(item);
383
+
384
+ RulesEngine.rules.forEach((rule) => {
385
+ item.toggleRule(rule, !!this.rules[rule]);
386
+ });
387
+ }
388
+
389
+ /**
390
+ * First item can't have a parent, so its depth should always be 0
391
+ */
392
+ firstItemDepthZero(item) {
393
+ if (item.index === 0 && item.depth !== 0) {
394
+ this.debug(`enforce depth on item ${item.index}: ${item.depth} => 0`);
395
+
396
+ item.depth = 0;
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Every item should have a non-negative depth set.
402
+ *
403
+ * @param {Item} item
404
+ */
405
+ depthMustBeSet(item) {
406
+ if (isNaN(item.depth) || item.depth < 0) {
407
+ this.debug(`unset depth on item ${item.index}: => 0`);
408
+
409
+ item.depth = 0;
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Depth must increase stepwise.
415
+ *
416
+ * @param {Item} item
417
+ */
418
+ itemCannotHaveInvalidDepth(item) {
419
+ const previous = item.previousItem;
420
+ if (previous && previous.depth < item.depth - 1) {
421
+ this.debug(
422
+ `invalid depth on item ${item.index}: ${item.depth} => ${
423
+ previous.depth + 1
424
+ }`
425
+ );
426
+
427
+ item.depth = previous.depth + 1;
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Depth must not exceed menu's depth limit.
433
+ *
434
+ * @param {Item} item
435
+ */
436
+ itemCannotExceedDepthLimit(item) {
437
+ if (this.maxDepth > 0 && this.maxDepth <= item.depth) {
438
+ // Note: this change can cause an issue where the previous item is treated
439
+ // like a parent even though it no longer has children. This is because
440
+ // items are processed in order. This issue does not seem worth solving
441
+ // as it only occurs if the max depth is altered. The issue can be worked
442
+ // around by saving the menu.
443
+ item.depth = this.maxDepth - 1;
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Parent item, if any, must be a layout.
449
+ *
450
+ * @param {Item} item
451
+ */
452
+ parentMustBeLayout(item) {
453
+ // if we're the first child, make sure our parent is a layout
454
+ // if we're a sibling, we know the previous item is valid so we must be too
455
+ const previous = item.previousItem;
456
+ if (previous && previous.depth < item.depth && !previous.isLayout) {
457
+ this.debug(
458
+ `invalid parent for item ${item.index}: ${item.depth} => ${previous.depth}`
459
+ );
460
+
461
+ item.depth = previous.depth;
462
+ }
463
+ }
464
+
465
+ /**
466
+ * If a parent has expanded and collapsed children, expand.
467
+ *
468
+ * @param {Item} item
469
+ */
470
+ parentCannotHaveExpandedAndCollapsedChildren(item) {
471
+ if (item.hasCollapsedDescendants() && item.hasExpandedDescendants()) {
472
+ this.debug(`expanding collapsed children of item ${item.index}`);
473
+
474
+ item.expand();
475
+ }
476
+ }
477
+
478
+ /**
479
+ * De-nesting an item would create a gap of 2 between itself and its children
480
+ *
481
+ * @param {Item} item
482
+ */
483
+ parentsCannotDeNest(item) {
484
+ if (item.hasExpandedDescendants()) this.#deny("denyDeNest");
485
+ }
486
+
487
+ /**
488
+ * Item depth can't go below 0.
489
+ *
490
+ * @param {Item} item
491
+ */
492
+ rootsCannotDeNest(item) {
493
+ if (item.depth === 0) this.#deny("denyDeNest");
494
+ }
495
+
496
+ /**
497
+ * If an item doesn't have children it can't be collapsed.
498
+ *
499
+ * @param {Item} item
500
+ */
501
+ leavesCannotCollapse(item) {
502
+ if (!item.hasExpandedDescendants()) this.#deny("denyCollapse");
503
+ }
504
+
505
+ /**
506
+ * If an item doesn't have any hidden descendants then it can't be expanded.
507
+ *
508
+ * @param {Item} item
509
+ */
510
+ needHiddenItemsToExpand(item) {
511
+ if (!item.hasCollapsedDescendants()) this.#deny("denyExpand");
512
+ }
513
+
514
+ /**
515
+ * An item can't be nested (indented) if it doesn't have a valid parent.
516
+ *
517
+ * @param {Item} item
518
+ */
519
+ nestingNeedsParent(item) {
520
+ const previous = item.previousItem;
521
+ // no previous, so cannot nest
522
+ if (!previous) this.#deny("denyNest");
523
+ // previous is too shallow, nesting would increase depth too much
524
+ else if (previous.depth < item.depth) this.#deny("denyNest");
525
+ // new parent is not a layout
526
+ else if (previous.depth === item.depth && !previous.isLayout)
527
+ this.#deny("denyNest");
528
+ }
529
+
530
+ /**
531
+ * An item can't be nested (indented) if doing so would exceed the max depth.
532
+ *
533
+ * @param {Item} item
534
+ */
535
+ nestingCannotExceedMaxDepth(item) {
536
+ if (this.maxDepth > 0 && this.maxDepth <= item.depth + 1) {
537
+ this.#deny("denyNest");
538
+ }
539
+ }
540
+
541
+ /**
542
+ * An item can't be deleted if it has visible children.
543
+ *
544
+ * @param {Item} item
545
+ */
546
+ parentsCannotBeDeleted(item) {
547
+ if (!item.itemId || item.hasExpandedDescendants()) this.#deny("denyRemove");
548
+ }
549
+
550
+ /**
551
+ * Items cannot be dragged if they have visible children.
552
+ *
553
+ * @param {Item} item
554
+ */
555
+ parentsCannotBeDragged(item) {
556
+ if (item.hasExpandedDescendants()) this.#deny("denyDrag");
557
+ }
558
+
559
+ /**
560
+ * Record a deny.
561
+ *
562
+ * @param rule {String}
563
+ */
564
+ #deny(rule) {
565
+ this.rules[rule] = true;
566
+ }
567
+ }
568
+
569
+ class MenuController extends Controller {
570
+ static targets = ["menu"];
571
+ static values = {
572
+ maxDepth: Number,
573
+ };
574
+
575
+ connect() {
576
+ this.state = this.menu.state;
577
+
578
+ this.reindex();
579
+ }
580
+
581
+ get menu() {
582
+ return new Menu(this.menuTarget);
583
+ }
584
+
585
+ reindex() {
586
+ this.menu.reindex();
587
+ this.#update();
588
+ }
589
+
590
+ reset() {
591
+ this.menu.reset();
592
+ }
593
+
594
+ drop(event) {
595
+ this.menu.reindex(); // set indexes before calculating previous
596
+
597
+ const item = getEventItem(event);
598
+ const previous = item.previousItem;
599
+
600
+ let delta = 0;
601
+ if (previous === undefined) {
602
+ // if previous does not exist, set depth to 0
603
+ delta = -item.depth;
604
+ } else if (item.nextItem && item.nextItem.depth > previous.depth) {
605
+ // if next is a child of previous, make item a child of previous
606
+ delta = previous.depth - item.depth + 1;
607
+ } else {
608
+ // otherwise, make item a sibling of previous
609
+ delta = previous.depth - item.depth;
610
+ }
611
+
612
+ item.traverse((child) => {
613
+ child.depth += delta;
614
+ });
615
+
616
+ this.#update();
617
+ event.preventDefault();
618
+ }
619
+
620
+ remove(event) {
621
+ const item = getEventItem(event);
622
+
623
+ item.node.remove();
624
+
625
+ this.#update();
626
+ event.preventDefault();
627
+ }
628
+
629
+ nest(event) {
630
+ const item = getEventItem(event);
631
+
632
+ item.traverse((child) => {
633
+ child.depth += 1;
634
+ });
635
+
636
+ this.#update();
637
+ event.preventDefault();
638
+ }
639
+
640
+ deNest(event) {
641
+ const item = getEventItem(event);
642
+
643
+ item.traverse((child) => {
644
+ child.depth -= 1;
645
+ });
646
+
647
+ this.#update();
648
+ event.preventDefault();
649
+ }
650
+
651
+ collapse(event) {
652
+ const item = getEventItem(event);
653
+
654
+ item.collapse();
655
+
656
+ this.#update();
657
+ event.preventDefault();
658
+ }
659
+
660
+ expand(event) {
661
+ const item = getEventItem(event);
662
+
663
+ item.expand();
664
+
665
+ this.#update();
666
+ event.preventDefault();
667
+ }
668
+
669
+ /**
670
+ * Re-apply rules to items to enable/disable appropriate actions.
671
+ */
672
+ #update() {
673
+ // debounce requests to ensure that we only update once per tick
674
+ this.updateRequested = true;
675
+ setTimeout(() => {
676
+ if (!this.updateRequested) return;
677
+
678
+ this.updateRequested = false;
679
+ const engine = new RulesEngine(this.maxDepthValue);
680
+ this.menu.items.forEach((item) => engine.normalize(item));
681
+ this.menu.items.forEach((item) => engine.update(item));
682
+
683
+ this.#notifyChange();
684
+ }, 0);
685
+ }
686
+
687
+ #notifyChange() {
688
+ this.dispatch("change", {
689
+ bubbles: true,
690
+ prefix: "navigation",
691
+ detail: { dirty: this.#isDirty() },
692
+ });
693
+ }
694
+
695
+ #isDirty() {
696
+ return this.menu.state !== this.state;
697
+ }
698
+ }
699
+
700
+ function getEventItem(event) {
701
+ return new Item(event.target.closest("[data-navigation-item]"));
702
+ }
703
+
704
+ class ItemController extends Controller {
705
+ get item() {
706
+ return new Item(this.li);
707
+ }
708
+
709
+ get ol() {
710
+ return this.element.closest("ol");
711
+ }
712
+
713
+ get li() {
714
+ return this.element.closest("li");
715
+ }
716
+
717
+ connect() {
718
+ if (this.element.dataset.hasOwnProperty("delete")) {
719
+ this.remove();
720
+ }
721
+ // if index is not already set, re-index will set it
722
+ else if (!(this.item.index >= 0)) {
723
+ this.reindex();
724
+ }
725
+ // if item has been replaced via turbo, re-index will run the rules engine
726
+ // update our depth and index with values from the li's data attributes
727
+ else if (this.item.hasItemIdChanged()) {
728
+ this.item.updateAfterChange();
729
+ this.reindex();
730
+ }
731
+ }
732
+
733
+ remove() {
734
+ // capture ol
735
+ this.ol;
736
+ // remove self from dom
737
+ this.li.remove();
738
+ // reindex ol
739
+ this.reindex();
740
+ }
741
+
742
+ reindex() {
743
+ this.dispatch("reindex", { bubbles: true, prefix: "navigation" });
744
+ }
745
+ }
746
+
747
+ class ListController extends Controller {
748
+ dragstart(event) {
749
+ if (this.element !== event.target.parentElement) return;
750
+
751
+ const target = event.target;
752
+ event.dataTransfer.effectAllowed = "move";
753
+
754
+ // update element style after drag has begun
755
+ setTimeout(() => (target.dataset.dragging = ""));
756
+ }
757
+
758
+ dragover(event) {
759
+ const item = this.dragItem();
760
+ if (!item) return;
761
+
762
+ swap(this.dropTarget(event.target), item);
763
+
764
+ event.preventDefault();
765
+ return true;
766
+ }
767
+
768
+ dragenter(event) {
769
+ event.preventDefault();
770
+
771
+ if (event.dataTransfer.effectAllowed === "copy" && !this.dragItem()) {
772
+ const item = document.createElement("li");
773
+ item.dataset.dragging = "";
774
+ item.dataset.newItem = "";
775
+ this.element.prepend(item);
776
+ }
777
+ }
778
+
779
+ dragleave(event) {
780
+ const item = this.dragItem();
781
+ const related = this.dropTarget(event.relatedTarget);
782
+
783
+ // ignore if item is not set or we're moving into a valid drop target
784
+ if (!item || related) return;
785
+
786
+ // remove item if it's a new item
787
+ if (item.dataset.hasOwnProperty("newItem")) {
788
+ item.remove();
789
+ }
790
+ }
791
+
792
+ drop(event) {
793
+ let item = this.dragItem();
794
+
795
+ if (!item) return;
796
+
797
+ event.preventDefault();
798
+ delete item.dataset.dragging;
799
+ swap(this.dropTarget(event.target), item);
800
+
801
+ if (item.dataset.hasOwnProperty("newItem")) {
802
+ const placeholder = item;
803
+ const template = document.createElement("template");
804
+ template.innerHTML = event.dataTransfer.getData("text/html");
805
+ item = template.content.querySelector("li");
806
+
807
+ this.element.replaceChild(item, placeholder);
808
+ setTimeout(() =>
809
+ item.querySelector("[role='button'][value='edit']").click()
810
+ );
811
+ }
812
+
813
+ this.dispatch("drop", {
814
+ target: item,
815
+ bubbles: true,
816
+ prefix: "navigation",
817
+ });
818
+ }
819
+
820
+ dragend() {
821
+ const item = this.dragItem();
822
+ if (!item) return;
823
+
824
+ delete item.dataset.dragging;
825
+ this.reset();
826
+ }
827
+
828
+ dragItem() {
829
+ return this.element.querySelector("[data-dragging]");
830
+ }
831
+
832
+ dropTarget(e) {
833
+ return e && e.closest("[data-controller='navigation--editor--list'] > *");
834
+ }
835
+
836
+ reindex() {
837
+ this.dispatch("reindex", { bubbles: true, prefix: "navigation" });
838
+ }
839
+
840
+ reset() {
841
+ this.dispatch("reset", { bubbles: true, prefix: "navigation" });
842
+ }
843
+ }
844
+
845
+ function swap(target, item) {
846
+ if (target && target !== item) {
847
+ const positionComparison = target.compareDocumentPosition(item);
848
+ if (positionComparison & Node.DOCUMENT_POSITION_FOLLOWING) {
849
+ target.insertAdjacentElement("beforebegin", item);
850
+ } else if (positionComparison & Node.DOCUMENT_POSITION_PRECEDING) {
851
+ target.insertAdjacentElement("afterend", item);
852
+ }
853
+ }
854
+ }
855
+
856
+ class NewItemController extends Controller {
857
+ static targets = ["template"];
858
+
859
+ dragstart(event) {
860
+ if (this.element !== event.target) return;
861
+
862
+ event.dataTransfer.setData("text/html", this.templateTarget.innerHTML);
863
+ event.dataTransfer.effectAllowed = "copy";
864
+ }
865
+ }
866
+
867
+ class StatusBarController extends Controller {
868
+ connect() {
869
+ // cache the version's state in the controller on connect
870
+ this.versionState = this.element.dataset.state;
871
+ }
872
+
873
+ change(e) {
874
+ if (e.detail && e.detail.hasOwnProperty("dirty")) {
875
+ this.update(e.detail);
876
+ }
877
+ }
878
+
879
+ update({ dirty }) {
880
+ if (dirty) {
881
+ this.element.dataset.state = "dirty";
882
+ } else {
883
+ this.element.dataset.state = this.versionState;
884
+ }
885
+ }
886
+ }
887
+
888
+ const Definitions = [
889
+ {
890
+ identifier: "navigation--editor--menu",
891
+ controllerConstructor: MenuController,
892
+ },
893
+ {
894
+ identifier: "navigation--editor--item",
895
+ controllerConstructor: ItemController,
896
+ },
897
+ {
898
+ identifier: "navigation--editor--list",
899
+ controllerConstructor: ListController,
900
+ },
901
+ {
902
+ identifier: "navigation--editor--new-item",
903
+ controllerConstructor: NewItemController,
904
+ },
905
+ {
906
+ identifier: "navigation--editor--status-bar",
907
+ controllerConstructor: StatusBarController,
908
+ },
909
+ ];
910
+
911
+ export { Definitions as default };