katalyst-navigation 1.4.1 → 1.5.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +29 -5
  3. data/app/assets/builds/katalyst/navigation.esm.js +920 -0
  4. data/app/assets/builds/katalyst/navigation.js +920 -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/assets/stylesheets/katalyst/navigation/editor/_index.scss +2 -2
  9. data/app/assets/stylesheets/katalyst/navigation/editor/_new-items.scss +32 -10
  10. data/app/components/katalyst/navigation/editor/base_component.rb +48 -0
  11. data/app/components/katalyst/navigation/editor/errors_component.html.erb +12 -0
  12. data/app/components/katalyst/navigation/editor/errors_component.rb +15 -0
  13. data/app/components/katalyst/navigation/editor/item_component.html.erb +27 -0
  14. data/app/components/katalyst/navigation/editor/item_component.rb +30 -0
  15. data/app/components/katalyst/navigation/editor/item_editor_component.rb +51 -0
  16. data/app/components/katalyst/navigation/editor/new_item_component.html.erb +14 -0
  17. data/app/components/katalyst/navigation/editor/new_item_component.rb +49 -0
  18. data/app/components/katalyst/navigation/editor/new_items_component.html.erb +3 -0
  19. data/app/components/katalyst/navigation/editor/new_items_component.rb +20 -0
  20. data/app/{views/katalyst/navigation/menus/_list_item.html.erb → components/katalyst/navigation/editor/row_component.html.erb} +1 -1
  21. data/{lib/katalyst/navigation/version.rb → app/components/katalyst/navigation/editor/row_component.rb} +4 -1
  22. data/app/{helpers/katalyst/navigation/editor/status_bar.rb → components/katalyst/navigation/editor/status_bar_component.rb} +17 -13
  23. data/app/components/katalyst/navigation/editor/table_component.html.erb +11 -0
  24. data/app/components/katalyst/navigation/editor/table_component.rb +36 -0
  25. data/app/components/katalyst/navigation/editor_component.html.erb +9 -0
  26. data/app/components/katalyst/navigation/editor_component.rb +47 -0
  27. data/app/controllers/concerns/katalyst/navigation/has_navigation.rb +5 -14
  28. data/app/controllers/katalyst/navigation/items_controller.rb +28 -12
  29. data/app/controllers/katalyst/navigation/menus_controller.rb +25 -13
  30. data/app/helpers/katalyst/navigation/frontend_helper.rb +7 -7
  31. data/app/javascript/navigation/application.js +30 -0
  32. data/app/{assets/javascripts/utils → javascript}/navigation/editor/item.js +0 -1
  33. data/app/{assets/javascripts/controllers → javascript}/navigation/editor/item_controller.js +1 -1
  34. data/app/{assets/javascripts/controllers → javascript}/navigation/editor/list_controller.js +12 -2
  35. data/app/{assets/javascripts/utils → javascript}/navigation/editor/menu.js +1 -1
  36. data/app/{assets/javascripts/controllers → javascript}/navigation/editor/menu_controller.js +3 -3
  37. data/app/models/katalyst/navigation/menu.rb +2 -0
  38. data/app/models/katalyst/navigation/types/nodes_type.rb +2 -2
  39. data/app/views/katalyst/navigation/items/_button.html.erb +6 -0
  40. data/app/views/katalyst/navigation/items/_form_errors.html.erb +5 -0
  41. data/app/views/katalyst/navigation/items/_heading.html.erb +1 -0
  42. data/app/views/katalyst/navigation/items/_link.html.erb +11 -0
  43. data/app/views/katalyst/navigation/items/edit.html.erb +4 -3
  44. data/app/views/katalyst/navigation/items/edit.turbo_stream.erb +3 -0
  45. data/app/views/katalyst/navigation/items/new.html.erb +1 -1
  46. data/app/views/katalyst/navigation/items/update.turbo_stream.erb +2 -5
  47. data/app/views/katalyst/navigation/menus/edit.html.erb +1 -1
  48. data/app/views/katalyst/navigation/menus/index.html.erb +1 -1
  49. data/app/views/katalyst/navigation/menus/show.html.erb +3 -13
  50. data/config/importmap.rb +1 -4
  51. data/lib/katalyst/navigation/config.rb +4 -0
  52. data/lib/katalyst/navigation/engine.rb +1 -1
  53. data/lib/katalyst/navigation.rb +6 -1
  54. data/spec/factories/katalyst/navigation/menus.rb +1 -1
  55. metadata +93 -27
  56. data/app/controllers/katalyst/navigation/base_controller.rb +0 -12
  57. data/app/helpers/katalyst/navigation/editor/base.rb +0 -41
  58. data/app/helpers/katalyst/navigation/editor/errors.rb +0 -24
  59. data/app/helpers/katalyst/navigation/editor/item.rb +0 -62
  60. data/app/helpers/katalyst/navigation/editor/list.rb +0 -41
  61. data/app/helpers/katalyst/navigation/editor/menu.rb +0 -47
  62. data/app/helpers/katalyst/navigation/editor/new_item.rb +0 -53
  63. data/app/helpers/katalyst/navigation/editor_helper.rb +0 -52
  64. data/app/views/katalyst/navigation/menus/_item.html.erb +0 -15
  65. data/app/views/katalyst/navigation/menus/_new_item.html.erb +0 -3
  66. data/app/views/katalyst/navigation/menus/_new_items.html.erb +0 -5
  67. /data/app/{assets/javascripts/controllers → javascript}/navigation/editor/new_item_controller.js +0 -0
  68. /data/app/{assets/javascripts/utils → javascript}/navigation/editor/rules-engine.js +0 -0
  69. /data/app/{assets/javascripts/controllers → javascript}/navigation/editor/status_bar_controller.js +0 -0
@@ -0,0 +1,920 @@
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
+ childrenList.dataset[`navigationChildren`] = "";
270
+
271
+ node.appendChild(childrenList);
272
+
273
+ return childrenList;
274
+ }
275
+
276
+ /**
277
+ * @param nodes {NodeList}
278
+ * @returns {Item[]}
279
+ */
280
+ function createItemList(nodes) {
281
+ return Array.from(nodes).map((node) => new Item(node));
282
+ }
283
+
284
+ class Menu {
285
+ /**
286
+ * @param node {Element} navigation editor list
287
+ */
288
+ constructor(node) {
289
+ this.node = node;
290
+ }
291
+
292
+ /**
293
+ * @return {Item[]} an ordered list of all items in the menu
294
+ */
295
+ get items() {
296
+ return createItemList(
297
+ this.node.querySelectorAll("[data-navigation-index]")
298
+ );
299
+ }
300
+
301
+ /**
302
+ * @return {String} a serialized description of the structure of the menu
303
+ */
304
+ get state() {
305
+ const inputs = this.node.querySelectorAll("li input[type=hidden]");
306
+ return Array.from(inputs)
307
+ .map((e) => e.value)
308
+ .join("/");
309
+ }
310
+
311
+ /**
312
+ * Set the index of items based on their current position.
313
+ */
314
+ reindex() {
315
+ this.items.map((item, index) => (item.index = index));
316
+ }
317
+
318
+ /**
319
+ * Resets the order of items to their defined index.
320
+ * Useful after an aborted drag.
321
+ */
322
+ reset() {
323
+ this.items.sort(Item.comparator).forEach((item) => {
324
+ this.node.appendChild(item.node);
325
+ });
326
+ }
327
+ }
328
+
329
+ class RulesEngine {
330
+ static rules = [
331
+ "denyDeNest",
332
+ "denyNest",
333
+ "denyCollapse",
334
+ "denyExpand",
335
+ "denyRemove",
336
+ "denyDrag",
337
+ "denyEdit",
338
+ ];
339
+
340
+ constructor(maxDepth = null, debug = false) {
341
+ this.maxDepth = maxDepth;
342
+ if (debug) {
343
+ this.debug = (...args) => console.log(...args);
344
+ } else {
345
+ this.debug = () => {};
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Enforce structural rules to ensure that the given item is currently in a
351
+ * valid state.
352
+ *
353
+ * @param {Item} item
354
+ */
355
+ normalize(item) {
356
+ // structural rules enforce a valid tree structure
357
+ this.firstItemDepthZero(item);
358
+ this.depthMustBeSet(item);
359
+ this.itemCannotHaveInvalidDepth(item);
360
+ this.itemCannotExceedDepthLimit(item);
361
+ this.parentMustBeLayout(item);
362
+ this.parentCannotHaveExpandedAndCollapsedChildren(item);
363
+ }
364
+
365
+ /**
366
+ * Apply rules to the given item to determine what operations are permitted.
367
+ *
368
+ * @param {Item} item
369
+ */
370
+ update(item) {
371
+ this.rules = {};
372
+
373
+ // behavioural rules define what the user is allowed to do
374
+ this.parentsCannotDeNest(item);
375
+ this.rootsCannotDeNest(item);
376
+ this.nestingNeedsParent(item);
377
+ this.nestingCannotExceedMaxDepth(item);
378
+ this.leavesCannotCollapse(item);
379
+ this.needHiddenItemsToExpand(item);
380
+ this.parentsCannotBeDeleted(item);
381
+ this.parentsCannotBeDragged(item);
382
+
383
+ RulesEngine.rules.forEach((rule) => {
384
+ item.toggleRule(rule, !!this.rules[rule]);
385
+ });
386
+ }
387
+
388
+ /**
389
+ * First item can't have a parent, so its depth should always be 0
390
+ */
391
+ firstItemDepthZero(item) {
392
+ if (item.index === 0 && item.depth !== 0) {
393
+ this.debug(`enforce depth on item ${item.index}: ${item.depth} => 0`);
394
+
395
+ item.depth = 0;
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Every item should have a non-negative depth set.
401
+ *
402
+ * @param {Item} item
403
+ */
404
+ depthMustBeSet(item) {
405
+ if (isNaN(item.depth) || item.depth < 0) {
406
+ this.debug(`unset depth on item ${item.index}: => 0`);
407
+
408
+ item.depth = 0;
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Depth must increase stepwise.
414
+ *
415
+ * @param {Item} item
416
+ */
417
+ itemCannotHaveInvalidDepth(item) {
418
+ const previous = item.previousItem;
419
+ if (previous && previous.depth < item.depth - 1) {
420
+ this.debug(
421
+ `invalid depth on item ${item.index}: ${item.depth} => ${
422
+ previous.depth + 1
423
+ }`
424
+ );
425
+
426
+ item.depth = previous.depth + 1;
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Depth must not exceed menu's depth limit.
432
+ *
433
+ * @param {Item} item
434
+ */
435
+ itemCannotExceedDepthLimit(item) {
436
+ if (this.maxDepth > 0 && this.maxDepth <= item.depth) {
437
+ // Note: this change can cause an issue where the previous item is treated
438
+ // like a parent even though it no longer has children. This is because
439
+ // items are processed in order. This issue does not seem worth solving
440
+ // as it only occurs if the max depth is altered. The issue can be worked
441
+ // around by saving the menu.
442
+ item.depth = this.maxDepth - 1;
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Parent item, if any, must be a layout.
448
+ *
449
+ * @param {Item} item
450
+ */
451
+ parentMustBeLayout(item) {
452
+ // if we're the first child, make sure our parent is a layout
453
+ // if we're a sibling, we know the previous item is valid so we must be too
454
+ const previous = item.previousItem;
455
+ if (previous && previous.depth < item.depth && !previous.isLayout) {
456
+ this.debug(
457
+ `invalid parent for item ${item.index}: ${item.depth} => ${previous.depth}`
458
+ );
459
+
460
+ item.depth = previous.depth;
461
+ }
462
+ }
463
+
464
+ /**
465
+ * If a parent has expanded and collapsed children, expand.
466
+ *
467
+ * @param {Item} item
468
+ */
469
+ parentCannotHaveExpandedAndCollapsedChildren(item) {
470
+ if (item.hasCollapsedDescendants() && item.hasExpandedDescendants()) {
471
+ this.debug(`expanding collapsed children of item ${item.index}`);
472
+
473
+ item.expand();
474
+ }
475
+ }
476
+
477
+ /**
478
+ * De-nesting an item would create a gap of 2 between itself and its children
479
+ *
480
+ * @param {Item} item
481
+ */
482
+ parentsCannotDeNest(item) {
483
+ if (item.hasExpandedDescendants()) this.#deny("denyDeNest");
484
+ }
485
+
486
+ /**
487
+ * Item depth can't go below 0.
488
+ *
489
+ * @param {Item} item
490
+ */
491
+ rootsCannotDeNest(item) {
492
+ if (item.depth === 0) this.#deny("denyDeNest");
493
+ }
494
+
495
+ /**
496
+ * If an item doesn't have children it can't be collapsed.
497
+ *
498
+ * @param {Item} item
499
+ */
500
+ leavesCannotCollapse(item) {
501
+ if (!item.hasExpandedDescendants()) this.#deny("denyCollapse");
502
+ }
503
+
504
+ /**
505
+ * If an item doesn't have any hidden descendants then it can't be expanded.
506
+ *
507
+ * @param {Item} item
508
+ */
509
+ needHiddenItemsToExpand(item) {
510
+ if (!item.hasCollapsedDescendants()) this.#deny("denyExpand");
511
+ }
512
+
513
+ /**
514
+ * An item can't be nested (indented) if it doesn't have a valid parent.
515
+ *
516
+ * @param {Item} item
517
+ */
518
+ nestingNeedsParent(item) {
519
+ const previous = item.previousItem;
520
+ // no previous, so cannot nest
521
+ if (!previous) this.#deny("denyNest");
522
+ // previous is too shallow, nesting would increase depth too much
523
+ else if (previous.depth < item.depth) this.#deny("denyNest");
524
+ // new parent is not a layout
525
+ else if (previous.depth === item.depth && !previous.isLayout)
526
+ this.#deny("denyNest");
527
+ }
528
+
529
+ /**
530
+ * An item can't be nested (indented) if doing so would exceed the max depth.
531
+ *
532
+ * @param {Item} item
533
+ */
534
+ nestingCannotExceedMaxDepth(item) {
535
+ if (this.maxDepth > 0 && this.maxDepth <= item.depth + 1) {
536
+ this.#deny("denyNest");
537
+ }
538
+ }
539
+
540
+ /**
541
+ * An item can't be deleted if it has visible children.
542
+ *
543
+ * @param {Item} item
544
+ */
545
+ parentsCannotBeDeleted(item) {
546
+ if (!item.itemId || item.hasExpandedDescendants()) this.#deny("denyRemove");
547
+ }
548
+
549
+ /**
550
+ * Items cannot be dragged if they have visible children.
551
+ *
552
+ * @param {Item} item
553
+ */
554
+ parentsCannotBeDragged(item) {
555
+ if (item.hasExpandedDescendants()) this.#deny("denyDrag");
556
+ }
557
+
558
+ /**
559
+ * Record a deny.
560
+ *
561
+ * @param rule {String}
562
+ */
563
+ #deny(rule) {
564
+ this.rules[rule] = true;
565
+ }
566
+ }
567
+
568
+ class MenuController extends Controller {
569
+ static targets = ["menu"];
570
+ static values = {
571
+ maxDepth: Number,
572
+ };
573
+
574
+ connect() {
575
+ this.state = this.menu.state;
576
+
577
+ this.reindex();
578
+ }
579
+
580
+ get menu() {
581
+ return new Menu(this.menuTarget);
582
+ }
583
+
584
+ reindex() {
585
+ this.menu.reindex();
586
+ this.#update();
587
+ }
588
+
589
+ reset() {
590
+ this.menu.reset();
591
+ }
592
+
593
+ drop(event) {
594
+ this.menu.reindex(); // set indexes before calculating previous
595
+
596
+ const item = getEventItem(event);
597
+ const previous = item.previousItem;
598
+
599
+ let delta = 0;
600
+ if (previous === undefined) {
601
+ // if previous does not exist, set depth to 0
602
+ delta = -item.depth;
603
+ } else if (item.nextItem && item.nextItem.depth > previous.depth) {
604
+ // if next is a child of previous, make item a child of previous
605
+ delta = previous.depth - item.depth + 1;
606
+ } else {
607
+ // otherwise, make item a sibling of previous
608
+ delta = previous.depth - item.depth;
609
+ }
610
+
611
+ item.traverse((child) => {
612
+ child.depth += delta;
613
+ });
614
+
615
+ this.#update();
616
+ event.preventDefault();
617
+ }
618
+
619
+ remove(event) {
620
+ const item = getEventItem(event);
621
+
622
+ item.node.remove();
623
+
624
+ this.#update();
625
+ event.preventDefault();
626
+ }
627
+
628
+ nest(event) {
629
+ const item = getEventItem(event);
630
+
631
+ item.traverse((child) => {
632
+ child.depth += 1;
633
+ });
634
+
635
+ this.#update();
636
+ event.preventDefault();
637
+ }
638
+
639
+ deNest(event) {
640
+ const item = getEventItem(event);
641
+
642
+ item.traverse((child) => {
643
+ child.depth -= 1;
644
+ });
645
+
646
+ this.#update();
647
+ event.preventDefault();
648
+ }
649
+
650
+ collapse(event) {
651
+ const item = getEventItem(event);
652
+
653
+ item.collapse();
654
+
655
+ this.#update();
656
+ event.preventDefault();
657
+ }
658
+
659
+ expand(event) {
660
+ const item = getEventItem(event);
661
+
662
+ item.expand();
663
+
664
+ this.#update();
665
+ event.preventDefault();
666
+ }
667
+
668
+ /**
669
+ * Re-apply rules to items to enable/disable appropriate actions.
670
+ */
671
+ #update() {
672
+ // debounce requests to ensure that we only update once per tick
673
+ this.updateRequested = true;
674
+ setTimeout(() => {
675
+ if (!this.updateRequested) return;
676
+
677
+ this.updateRequested = false;
678
+ const engine = new RulesEngine(this.maxDepthValue);
679
+ this.menu.items.forEach((item) => engine.normalize(item));
680
+ this.menu.items.forEach((item) => engine.update(item));
681
+
682
+ this.#notifyChange();
683
+ }, 0);
684
+ }
685
+
686
+ #notifyChange() {
687
+ this.dispatch("change", {
688
+ bubbles: true,
689
+ prefix: "navigation",
690
+ detail: { dirty: this.#isDirty() },
691
+ });
692
+ }
693
+
694
+ #isDirty() {
695
+ return this.menu.state !== this.state;
696
+ }
697
+ }
698
+
699
+ function getEventItem(event) {
700
+ return new Item(event.target.closest("[data-navigation-item]"));
701
+ }
702
+
703
+ class ItemController extends Controller {
704
+ get item() {
705
+ return new Item(this.li);
706
+ }
707
+
708
+ get ol() {
709
+ return this.element.closest("ol");
710
+ }
711
+
712
+ get li() {
713
+ return this.element.closest("li");
714
+ }
715
+
716
+ connect() {
717
+ if (this.element.dataset.hasOwnProperty("delete")) {
718
+ this.remove();
719
+ }
720
+ // if index is not already set, re-index will set it
721
+ else if (!(this.item.index >= 0)) {
722
+ this.reindex();
723
+ }
724
+ // if item has been replaced via turbo, re-index will run the rules engine
725
+ // update our depth and index with values from the li's data attributes
726
+ else if (this.item.hasItemIdChanged()) {
727
+ this.item.updateAfterChange();
728
+ this.reindex();
729
+ }
730
+ }
731
+
732
+ remove() {
733
+ // capture ol
734
+ this.ol;
735
+ // remove self from dom
736
+ this.li.remove();
737
+ // reindex ol
738
+ this.reindex();
739
+ }
740
+
741
+ reindex() {
742
+ this.dispatch("reindex", { bubbles: true, prefix: "navigation" });
743
+ }
744
+ }
745
+
746
+ class ListController extends Controller {
747
+ dragstart(event) {
748
+ if (this.element !== event.target.parentElement) return;
749
+
750
+ const target = event.target;
751
+ event.dataTransfer.effectAllowed = "move";
752
+
753
+ // update element style after drag has begun
754
+ setTimeout(() => (target.dataset.dragging = ""));
755
+ }
756
+
757
+ dragover(event) {
758
+ const item = this.dragItem();
759
+ if (!item) return;
760
+
761
+ swap(this.dropTarget(event.target), item);
762
+
763
+ event.preventDefault();
764
+ return true;
765
+ }
766
+
767
+ dragenter(event) {
768
+ event.preventDefault();
769
+
770
+ if (event.dataTransfer.effectAllowed === "copy" && !this.dragItem()) {
771
+ const item = document.createElement("li");
772
+ item.dataset.dragging = "";
773
+ item.dataset.newItem = "";
774
+ this.element.prepend(item);
775
+ }
776
+ }
777
+
778
+ dragleave(event) {
779
+ const item = this.dragItem();
780
+ const related = this.dropTarget(event.relatedTarget);
781
+
782
+ // ignore if item is not set or we're moving into a valid drop target
783
+ if (!item || related) return;
784
+
785
+ // remove item if it's a new item
786
+ if (item.dataset.hasOwnProperty("newItem")) {
787
+ item.remove();
788
+ }
789
+ }
790
+
791
+ drop(event) {
792
+ let item = this.dragItem();
793
+
794
+ if (!item) return;
795
+
796
+ event.preventDefault();
797
+ delete item.dataset.dragging;
798
+ swap(this.dropTarget(event.target), item);
799
+
800
+ if (item.dataset.hasOwnProperty("newItem")) {
801
+ const placeholder = item;
802
+ const template = document.createElement("template");
803
+ template.innerHTML = event.dataTransfer.getData("text/html");
804
+ item = template.content.querySelector("li");
805
+
806
+ this.element.replaceChild(item, placeholder);
807
+ setTimeout(() =>
808
+ item.querySelector("[role='button'][value='edit']").click()
809
+ );
810
+ }
811
+
812
+ this.dispatch("drop", {
813
+ target: item,
814
+ bubbles: true,
815
+ prefix: "navigation",
816
+ });
817
+ }
818
+
819
+ dragend() {
820
+ const item = this.dragItem();
821
+ if (!item) return;
822
+
823
+ delete item.dataset.dragging;
824
+ this.reset();
825
+ }
826
+
827
+ dragItem() {
828
+ return this.element.querySelector("[data-dragging]");
829
+ }
830
+
831
+ dropTarget(e) {
832
+ return (
833
+ e.closest("[data-controller='navigation--editor--list'] > *") ||
834
+ e.closest("[data-controller='navigation--editor--list']")
835
+ );
836
+ }
837
+
838
+ reindex() {
839
+ this.dispatch("reindex", { bubbles: true, prefix: "navigation" });
840
+ }
841
+
842
+ reset() {
843
+ this.dispatch("reset", { bubbles: true, prefix: "navigation" });
844
+ }
845
+ }
846
+
847
+ function swap(target, item) {
848
+ if (!target) return;
849
+ if (target === item) return;
850
+
851
+ if (target.nodeName === "LI") {
852
+ const positionComparison = target.compareDocumentPosition(item);
853
+ if (positionComparison & Node.DOCUMENT_POSITION_FOLLOWING) {
854
+ target.insertAdjacentElement("beforebegin", item);
855
+ } else if (positionComparison & Node.DOCUMENT_POSITION_PRECEDING) {
856
+ target.insertAdjacentElement("afterend", item);
857
+ }
858
+ }
859
+
860
+ if (target.nodeName === "OL") {
861
+ target.appendChild(item);
862
+ }
863
+ }
864
+
865
+ class NewItemController extends Controller {
866
+ static targets = ["template"];
867
+
868
+ dragstart(event) {
869
+ if (this.element !== event.target) return;
870
+
871
+ event.dataTransfer.setData("text/html", this.templateTarget.innerHTML);
872
+ event.dataTransfer.effectAllowed = "copy";
873
+ }
874
+ }
875
+
876
+ class StatusBarController extends Controller {
877
+ connect() {
878
+ // cache the version's state in the controller on connect
879
+ this.versionState = this.element.dataset.state;
880
+ }
881
+
882
+ change(e) {
883
+ if (e.detail && e.detail.hasOwnProperty("dirty")) {
884
+ this.update(e.detail);
885
+ }
886
+ }
887
+
888
+ update({ dirty }) {
889
+ if (dirty) {
890
+ this.element.dataset.state = "dirty";
891
+ } else {
892
+ this.element.dataset.state = this.versionState;
893
+ }
894
+ }
895
+ }
896
+
897
+ const Definitions = [
898
+ {
899
+ identifier: "navigation--editor--menu",
900
+ controllerConstructor: MenuController,
901
+ },
902
+ {
903
+ identifier: "navigation--editor--item",
904
+ controllerConstructor: ItemController,
905
+ },
906
+ {
907
+ identifier: "navigation--editor--list",
908
+ controllerConstructor: ListController,
909
+ },
910
+ {
911
+ identifier: "navigation--editor--new-item",
912
+ controllerConstructor: NewItemController,
913
+ },
914
+ {
915
+ identifier: "navigation--editor--status-bar",
916
+ controllerConstructor: StatusBarController,
917
+ },
918
+ ];
919
+
920
+ export { Definitions as default };