katalyst-tables 2.2.2 → 2.2.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b8413e971a3164d1ab1fa688a20ce34e1f25768da7650565d9f600e1612a80e
4
- data.tar.gz: d14455c86030073e293aafbaaf86a4da483be27912cca5a8d46cc43cc3a532b8
3
+ metadata.gz: 8224ebb910f428c3f7750ae6e79c92f21440f5546a2baaede473bb286f217a82
4
+ data.tar.gz: 6be02c8a263dda22b7faae734cb20e8cec5879db532adb50f257bd502fa51265
5
5
  SHA512:
6
- metadata.gz: 5825f4058bc40aa823dbdd0719d8738a5fa4e4738442438c7e0c86ae9942cb5fc163d19b7754eb27b1abd061699dccfd79b249c93ceef8ce3605c101a4262522
7
- data.tar.gz: ed4ccfa22d6342eff7c6f0a84ac6c28472263c249846594d794492c40c2b2a8bd9265f26fe897cb5a331c489b2b2d5caf59da98619ad16763a8a4dd4e8124f05
6
+ metadata.gz: 61052d361a93ab2675274f9ed3b3f59e0acd08519261cf016c4db55006097d72f78114ba489e5eed9d066a82aa6bbaa9dd00675d378e24c996a5ccba775bce75
7
+ data.tar.gz: e411c014472cd5d34338ad0a02d869d69352fd4c6abe70c68ead05d75b3d2f0f52bf11c263031cd6606d27d09c7565e76770eee0b9149b58ef5e91ec383d86bf
@@ -1,12 +1,12 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  export default class OrderableFormController extends Controller {
4
- add(item, index) {
4
+ add(item) {
5
5
  const { id_name, id_value, index_name } = item.paramsValue;
6
6
  this.element.insertAdjacentHTML(
7
7
  "beforeend",
8
8
  `<input type="hidden" name="${id_name}" value="${id_value}" data-generated>
9
- <input type="hidden" name="${index_name}" value="${index}" data-generated>`,
9
+ <input type="hidden" name="${index_name}" value="${item.index}" data-generated>`,
10
10
  );
11
11
  }
12
12
 
@@ -5,7 +5,96 @@ export default class OrderableRowController extends Controller {
5
5
  params: Object,
6
6
  };
7
7
 
8
- get index() {
9
- return this.paramsValue.index_value;
8
+ paramsValueChanged(params) {
9
+ this.id = params.id_value;
10
+ this.index = params.index_value;
11
+ }
12
+
13
+ dragUpdate(offset) {
14
+ this.dragOffset = offset;
15
+ this.row.style.position = "relative";
16
+ this.row.style.top = offset + "px";
17
+ this.row.style.zIndex = 1;
18
+ this.row.toggleAttribute("dragging", true);
19
+ }
20
+
21
+ /**
22
+ * Called on items that are not the dragged item during drag. Updates the
23
+ * visual position of the item relative to the dragged item.
24
+ *
25
+ * @param index {number} intended index of the item during drag
26
+ */
27
+ updateVisually(index) {
28
+ this.row.style.position = "relative";
29
+ this.row.style.top = `${
30
+ this.row.offsetHeight * (index - this.dragIndex)
31
+ }px`;
32
+ }
33
+
34
+ /**
35
+ * Set the index value of the item. This is called on all items after a drop
36
+ * event. If the index is different to the params index then this item has
37
+ * changed.
38
+ *
39
+ * @param index {number} the new index value
40
+ */
41
+ updateIndex(index) {
42
+ this.index = index;
43
+ }
44
+
45
+ /**
46
+ * Restore any visual changes made during drag and remove the drag state.
47
+ */
48
+ reset() {
49
+ delete this.dragOffset;
50
+ this.row.removeAttribute("style");
51
+ this.row.removeAttribute("dragging");
52
+ }
53
+
54
+ /**
55
+ * @returns {boolean} true when the item has a change to its index value
56
+ */
57
+ get hasChanges() {
58
+ return this.paramsValue.index_value !== this.index;
59
+ }
60
+
61
+ /**
62
+ * Calculate the relative index of the item during drag. This is used to
63
+ * sort items during drag as it takes into account any uncommitted changes
64
+ * to index caused by the drag offset.
65
+ *
66
+ * @returns {number} index for the purposes of drag and drop ordering
67
+ */
68
+ get dragIndex() {
69
+ if (this.dragOffset && this.dragOffset !== 0) {
70
+ return this.index + Math.round(this.dragOffset / this.row.offsetHeight);
71
+ } else {
72
+ return this.index;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Index value for use in comparisons during drag. This is used to determine
78
+ * whether the dragged item is above or below another item. If this item is
79
+ * being dragged then we offset the index by 0.5 to ensure that it jumps up
80
+ * or down when it reaches the midpoint of the item above or below it.
81
+ *
82
+ * @returns {number}
83
+ */
84
+ get comparisonIndex() {
85
+ if (this.dragOffset) {
86
+ return this.dragIndex + (this.dragOffset > 0 ? 0.5 : -0.5);
87
+ } else {
88
+ return this.index;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * The containing row element.
94
+ *
95
+ * @returns {HTMLElement}
96
+ */
97
+ get row() {
98
+ return this.element.parentElement;
10
99
  }
11
100
  }
@@ -3,71 +3,245 @@ import { Controller } from "@hotwired/stimulus";
3
3
  export default class OrderableListController extends Controller {
4
4
  static outlets = ["tables--orderable--item", "tables--orderable--form"];
5
5
 
6
- dragstart(event) {
7
- if (this.element !== event.target.parentElement) return;
6
+ //region State transitions
8
7
 
9
- const target = event.target;
10
- event.dataTransfer.effectAllowed = "move";
8
+ startDragging(dragState) {
9
+ this.dragState = dragState;
11
10
 
12
- // update element style after drag has begun
13
- setTimeout(() => (target.dataset.dragging = ""));
11
+ document.addEventListener("mousemove", this.mousemove);
12
+ document.addEventListener("mouseup", this.mouseup);
13
+
14
+ this.element.style.position = "relative";
14
15
  }
15
16
 
16
- dragover(event) {
17
- if (!this.dragItem) return;
17
+ stopDragging() {
18
+ const dragState = this.dragState;
19
+ delete this.dragState;
18
20
 
19
- swap(this.dropTarget(event.target), this.dragItem);
21
+ document.removeEventListener("mousemove", this.mousemove);
22
+ document.removeEventListener("mouseup", this.mouseup);
20
23
 
21
- event.preventDefault();
22
- return true;
23
- }
24
+ this.element.removeAttribute("style");
25
+ this.tablesOrderableItemOutlets.forEach((item) => item.reset());
24
26
 
25
- dragenter(event) {
26
- event.preventDefault();
27
+ return dragState;
27
28
  }
28
29
 
29
- drop(event) {
30
- if (!this.dragItem) return;
30
+ drop() {
31
+ // note: early returns guard against turbo updates that prevent us finding
32
+ // the right item to drop on. In this situation it's better to discard the
33
+ // drop than to drop in the wrong place.
34
+
35
+ const dragItem = this.dragItem;
36
+
37
+ if (!dragItem) return;
38
+
39
+ const newIndex = dragItem.dragIndex;
40
+ const targetItem = this.tablesOrderableItemOutlets[newIndex];
41
+
42
+ if (!targetItem) return;
31
43
 
32
- event.preventDefault();
33
- delete this.dragItem.dataset.dragging;
44
+ // swap the dragged item into the correct position for its current offset
45
+ if (newIndex < dragItem.index) {
46
+ targetItem.row.insertAdjacentElement("beforebegin", dragItem.row);
47
+ } else if (newIndex > dragItem.index) {
48
+ targetItem.row.insertAdjacentElement("afterend", dragItem.row);
49
+ }
50
+
51
+ // reindex all items based on their new positions
52
+ this.tablesOrderableItemOutlets.forEach((item, index) =>
53
+ item.updateIndex(index),
54
+ );
34
55
 
35
- this.update();
56
+ // save the changes
57
+ this.commitChanges();
36
58
  }
37
59
 
38
- update() {
60
+ commitChanges() {
39
61
  // clear any existing inputs to prevent duplicates
40
62
  this.tablesOrderableFormOutlet.clear();
41
63
 
42
64
  // insert any items that have changed position
43
- this.tablesOrderableItemOutlets.forEach((item, index) => {
44
- if (item.index !== index) {
45
- this.tablesOrderableFormOutlet.add(item, index);
46
- }
65
+ this.tablesOrderableItemOutlets.forEach((item) => {
66
+ if (item.hasChanges) this.tablesOrderableFormOutlet.add(item);
47
67
  });
48
68
 
49
69
  this.tablesOrderableFormOutlet.submit();
50
70
  }
51
71
 
52
- get dragItem() {
53
- return this.element.querySelector("[data-dragging]");
72
+ //endregion
73
+
74
+ //region Events
75
+
76
+ mousedown(event) {
77
+ if (this.isDragging) return;
78
+
79
+ const target = this.#targetItem(event.target);
80
+
81
+ if (!target) return;
82
+
83
+ event.preventDefault(); // prevent built-in drag
84
+
85
+ this.startDragging(new DragState(this.element, event, target.id));
86
+
87
+ this.#updateDragItem(event);
54
88
  }
55
89
 
56
- dropTarget($e) {
57
- while ($e && $e.parentElement !== this.element) {
58
- $e = $e.parentElement;
90
+ mousemove = (event) => {
91
+ if (!this.isDragging) return;
92
+
93
+ event.preventDefault(); // prevent build-in drag
94
+
95
+ const dragItem = this.#updateDragItem(event);
96
+
97
+ // Visually updates the position of all items in the list relative to the
98
+ // dragged item. No actual changes to orderings at this stage.
99
+ this.#currentItems.forEach((item, index) => {
100
+ if (item === dragItem) return;
101
+ item.updateVisually(index);
102
+ });
103
+ };
104
+
105
+ mouseup = (event) => {
106
+ if (!this.isDragging) return;
107
+
108
+ this.drop();
109
+ this.stopDragging();
110
+ this.tablesOrderableFormOutlets.forEach((form) => delete form.dragState);
111
+ };
112
+
113
+ tablesOrderableFormOutletConnected(form, element) {
114
+ if (form.dragState) {
115
+ // restore the previous controller's state
116
+ this.startDragging(form.dragState);
59
117
  }
60
- return $e;
61
118
  }
119
+
120
+ tablesOrderableFormOutletDisconnected(form, element) {
121
+ if (this.isDragging) {
122
+ // cache drag state in the form
123
+ form.dragState = this.stopDragging();
124
+ }
125
+ }
126
+
127
+ //endregion
128
+
129
+ //region Helpers
130
+
131
+ get isDragging() {
132
+ return !!this.dragState;
133
+ }
134
+
135
+ get dragItem() {
136
+ if (!this.isDragging) return null;
137
+
138
+ return this.tablesOrderableItemOutlets.find(
139
+ (item) => item.id === this.dragState.targetId,
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Returns the current items in the list, sorted by their current index.
145
+ * Current uses the drag index if the item is being dragged, if set.
146
+ *
147
+ * @returns {Array[OrderableRowController]}
148
+ */
149
+ get #currentItems() {
150
+ return this.tablesOrderableItemOutlets.toSorted(
151
+ (a, b) => a.comparisonIndex - b.comparisonIndex,
152
+ );
153
+ }
154
+
155
+ /**
156
+ * Returns the item outlet that was clicked on, if any.
157
+ *
158
+ * @param event {HTMLElement} the clicked ordinal cell
159
+ * @returns {OrderableRowController}
160
+ */
161
+ #targetItem(element) {
162
+ return this.tablesOrderableItemOutlets.find(
163
+ (item) => item.element === element,
164
+ );
165
+ }
166
+
167
+ #updateDragItem(event) {
168
+ const offset = this.dragState.itemOffset(
169
+ this.element,
170
+ this.dragItem.row,
171
+ event,
172
+ );
173
+ const item = this.dragItem;
174
+ item.dragUpdate(offset);
175
+ return item;
176
+ }
177
+
178
+ //endregion
62
179
  }
63
180
 
64
- function swap(target, item) {
65
- if (target && target !== item) {
66
- const positionComparison = target.compareDocumentPosition(item);
67
- if (positionComparison & Node.DOCUMENT_POSITION_FOLLOWING) {
68
- target.insertAdjacentElement("beforebegin", item);
69
- } else if (positionComparison & Node.DOCUMENT_POSITION_PRECEDING) {
70
- target.insertAdjacentElement("afterend", item);
181
+ /**
182
+ * During drag we want to be able to translate a document-relative coordinate
183
+ * into a coordinate relative to the list element. This state object calculates
184
+ * and stores internal state so that we can translate absolute page coordinates
185
+ * from mouse events into relative offsets for the list items within the list
186
+ * element.
187
+ *
188
+ * We also keep track of the drag target so that if the controller is attached
189
+ * to a new element during the drag we can continue after the turbo update.
190
+ */
191
+ class DragState {
192
+ /**
193
+ * @param list {HTMLElement} the list controller's element (tbody)
194
+ * @param event {MouseEvent} the initial event
195
+ * @param id {String} the id of the element being dragged
196
+ */
197
+ constructor(list, event, id) {
198
+ // calculate the offset top of the tbody element relative to offsetParent
199
+ const offsetParent = list.offsetParent;
200
+ let offsetTop = event.offsetY;
201
+ let current = event.target;
202
+ while (current && current !== offsetParent) {
203
+ offsetTop += current.offsetTop;
204
+ current = current.offsetParent;
71
205
  }
206
+
207
+ // page offset is the offset of the tbody element relative to the page
208
+ this.pageOffset = event.pageY - offsetTop + list.offsetTop;
209
+
210
+ // cursor offset is the offset of the cursor relative to the drag item
211
+ this.cursorOffset = event.offsetY;
212
+
213
+ // initial offset is the offset position of the drag item at drag start
214
+ this.initialOffset = event.target.offsetTop - list.offsetTop;
215
+
216
+ // id of the item being dragged
217
+ this.targetId = id;
218
+ }
219
+
220
+ /**
221
+ * Calculates the offset of the drag item relative to its initial position.
222
+ *
223
+ * @param list {HTMLElement} the list controller's element (tbody)
224
+ * @param row {HTMLElement} the row being dragged
225
+ * @param event {MouseEvent} the current event
226
+ * @returns {number} relative offset for the item being dragged
227
+ */
228
+ itemOffset(list, row, event) {
229
+ // cursor position relative to the list
230
+ const cursorPosition = event.pageY - this.pageOffset;
231
+
232
+ // item position relative to the list
233
+ let itemPosition = cursorPosition - this.cursorOffset;
234
+
235
+ // ensure itemPosition is within the bounds of the list (tbody)
236
+ itemPosition = Math.max(itemPosition, 0);
237
+ itemPosition = Math.min(itemPosition, list.offsetHeight - row.offsetHeight);
238
+
239
+ // Item has position: relative, so we want to calculate the amount to move
240
+ // the item relative to it's DOM position to represent how much it has been
241
+ // dragged by.
242
+
243
+ // Convert itemPosition from offset relative to list to offset relative to
244
+ // its position within the DOM (if it hadn't moved).
245
+ return itemPosition - this.initialOffset;
72
246
  }
73
247
  }
@@ -45,10 +45,7 @@ module Katalyst
45
45
  super.merge_html(
46
46
  { data: { controller: LIST_CONTROLLER,
47
47
  action: <<~ACTIONS.squish,
48
- dragstart->#{LIST_CONTROLLER}#dragstart
49
- dragenter->#{LIST_CONTROLLER}#dragenter
50
- dragover->#{LIST_CONTROLLER}#dragover
51
- drop->#{LIST_CONTROLLER}#drop
48
+ mousedown->#{LIST_CONTROLLER}#mousedown
52
49
  ACTIONS
53
50
  "#{LIST_CONTROLLER}-#{FORM_CONTROLLER}-outlet" => "##{orderable.id}",
54
51
  "#{LIST_CONTROLLER}-#{ITEM_CONTROLLER}-outlet" => "td.ordinal" } },
@@ -95,19 +92,11 @@ module Katalyst
95
92
  index_name: @table.orderable.record_scope(id, attribute),
96
93
  index_value: @record.public_send(attribute),
97
94
  }
98
- cell(attribute, class: "ordinal", data: {
95
+ cell(attribute, class: "ordinal", draggable: true, data: {
99
96
  controller: ITEM_CONTROLLER,
100
97
  "#{ITEM_CONTROLLER}-params-value": params.to_json,
101
98
  }) { t("katalyst.tables.orderable.value") }
102
99
  end
103
-
104
- def html_attributes
105
- super.merge_html(
106
- {
107
- draggable: "true",
108
- },
109
- )
110
- end
111
100
  end
112
101
 
113
102
  class FormComponent < ViewComponent::Base # :nodoc:
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Katalyst
4
4
  module Tables
5
- VERSION = "2.2.2"
5
+ VERSION = "2.2.3"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: katalyst-tables
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.2
4
+ version: 2.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katalyst Interactive
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-09-20 00:00:00.000000000 Z
11
+ date: 2023-09-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: html-attributes-utils