katalyst-tables 2.2.2 → 2.2.4

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: 450377488700ebc511da5b3d515c217811ef60ce631df86edf62778ef8a3c5c9
4
+ data.tar.gz: 90040f0e73ad2858fd6b683b8046c9726d6a9223676edafc555dadf3e416e97b
5
5
  SHA512:
6
- metadata.gz: 5825f4058bc40aa823dbdd0719d8738a5fa4e4738442438c7e0c86ae9942cb5fc163d19b7754eb27b1abd061699dccfd79b249c93ceef8ce3605c101a4262522
7
- data.tar.gz: ed4ccfa22d6342eff7c6f0a84ac6c28472263c249846594d794492c40c2b2a8bd9265f26fe897cb5a331c489b2b2d5caf59da98619ad16763a8a4dd4e8124f05
6
+ metadata.gz: 65cb752a4d5ad8620572f123f960b8378e31b5775b34b84bcc7787cd5d89234a5fee3ef2e5d850d19a4c422278aaed74eab8fddf254a92445c11d1e9f17296af
7
+ data.tar.gz: c72888fbed46322cc3adf8bb4a562079699ccacea0fac36a8b85879ddf4378c252f7090c35206b4ac0e7696b2d67e7baadd455c279be9e813efc81103efe7724
@@ -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,106 @@ export default class OrderableRowController extends Controller {
5
5
  params: Object,
6
6
  };
7
7
 
8
- get index() {
9
- return this.paramsValue.index_value;
8
+ connect() {
9
+ // index from server may be inconsistent with the visual ordering,
10
+ // especially if this is a new node. Use positional indexes instead,
11
+ // as these are the values we will send on save.
12
+ this.index = domIndex(this.row);
10
13
  }
14
+
15
+ paramsValueChanged(params) {
16
+ this.id = params.id_value;
17
+ }
18
+
19
+ dragUpdate(offset) {
20
+ this.dragOffset = offset;
21
+ this.row.style.position = "relative";
22
+ this.row.style.top = offset + "px";
23
+ this.row.style.zIndex = "1";
24
+ this.row.toggleAttribute("dragging", true);
25
+ }
26
+
27
+ /**
28
+ * Called on items that are not the dragged item during drag. Updates the
29
+ * visual position of the item relative to the dragged item.
30
+ *
31
+ * @param index {number} intended index of the item during drag
32
+ */
33
+ updateVisually(index) {
34
+ this.row.style.position = "relative";
35
+ this.row.style.top = `${
36
+ this.row.offsetHeight * (index - this.dragIndex)
37
+ }px`;
38
+ }
39
+
40
+ /**
41
+ * Set the index value of the item. This is called on all items after a drop
42
+ * event. If the index is different to the params index then this item has
43
+ * changed.
44
+ *
45
+ * @param index {number} the new index value
46
+ */
47
+ updateIndex(index) {
48
+ this.index = index;
49
+ }
50
+
51
+ /**
52
+ * Restore any visual changes made during drag and remove the drag state.
53
+ */
54
+ reset() {
55
+ delete this.dragOffset;
56
+ this.row.removeAttribute("style");
57
+ this.row.removeAttribute("dragging");
58
+ }
59
+
60
+ /**
61
+ * @returns {boolean} true when the item has a change to its index value
62
+ */
63
+ get hasChanges() {
64
+ return this.paramsValue.index_value !== this.index;
65
+ }
66
+
67
+ /**
68
+ * Calculate the relative index of the item during drag. This is used to
69
+ * sort items during drag as it takes into account any uncommitted changes
70
+ * to index caused by the drag offset.
71
+ *
72
+ * @returns {number} index for the purposes of drag and drop ordering
73
+ */
74
+ get dragIndex() {
75
+ if (this.dragOffset && this.dragOffset !== 0) {
76
+ return this.index + Math.round(this.dragOffset / this.row.offsetHeight);
77
+ } else {
78
+ return this.index;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Index value for use in comparisons during drag. This is used to determine
84
+ * whether the dragged item is above or below another item. If this item is
85
+ * being dragged then we offset the index by 0.5 to ensure that it jumps up
86
+ * or down when it reaches the midpoint of the item above or below it.
87
+ *
88
+ * @returns {number}
89
+ */
90
+ get comparisonIndex() {
91
+ if (this.dragOffset) {
92
+ return this.dragIndex + (this.dragOffset > 0 ? 0.5 : -0.5);
93
+ } else {
94
+ return this.index;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * The containing row element.
100
+ *
101
+ * @returns {HTMLElement}
102
+ */
103
+ get row() {
104
+ return this.element.parentElement;
105
+ }
106
+ }
107
+
108
+ function domIndex(element) {
109
+ return Array.from(element.parentElement.children).indexOf(element);
11
110
  }
@@ -3,71 +3,302 @@ 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
+ window.addEventListener("scroll", this.scroll, true);
14
+
15
+ this.element.style.position = "relative";
14
16
  }
15
17
 
16
- dragover(event) {
17
- if (!this.dragItem) return;
18
+ stopDragging() {
19
+ const dragState = this.dragState;
20
+ delete this.dragState;
18
21
 
19
- swap(this.dropTarget(event.target), this.dragItem);
22
+ document.removeEventListener("mousemove", this.mousemove);
23
+ document.removeEventListener("mouseup", this.mouseup);
24
+ window.removeEventListener("scroll", this.scroll, true);
20
25
 
21
- event.preventDefault();
22
- return true;
23
- }
26
+ this.element.removeAttribute("style");
27
+ this.tablesOrderableItemOutlets.forEach((item) => item.reset());
24
28
 
25
- dragenter(event) {
26
- event.preventDefault();
29
+ return dragState;
27
30
  }
28
31
 
29
- drop(event) {
30
- if (!this.dragItem) return;
32
+ drop() {
33
+ // note: early returns guard against turbo updates that prevent us finding
34
+ // the right item to drop on. In this situation it's better to discard the
35
+ // drop than to drop in the wrong place.
36
+
37
+ const dragItem = this.dragItem;
38
+
39
+ if (!dragItem) return;
40
+
41
+ const newIndex = dragItem.dragIndex;
42
+ const targetItem = this.tablesOrderableItemOutlets[newIndex];
31
43
 
32
- event.preventDefault();
33
- delete this.dragItem.dataset.dragging;
44
+ if (!targetItem) return;
34
45
 
35
- this.update();
46
+ // swap the dragged item into the correct position for its current offset
47
+ if (newIndex < dragItem.index) {
48
+ targetItem.row.insertAdjacentElement("beforebegin", dragItem.row);
49
+ } else if (newIndex > dragItem.index) {
50
+ targetItem.row.insertAdjacentElement("afterend", dragItem.row);
51
+ }
52
+
53
+ // reindex all items based on their new positions
54
+ this.tablesOrderableItemOutlets.forEach((item, index) =>
55
+ item.updateIndex(index),
56
+ );
57
+
58
+ // save the changes
59
+ this.commitChanges();
36
60
  }
37
61
 
38
- update() {
62
+ commitChanges() {
39
63
  // clear any existing inputs to prevent duplicates
40
64
  this.tablesOrderableFormOutlet.clear();
41
65
 
42
66
  // 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
- }
67
+ this.tablesOrderableItemOutlets.forEach((item) => {
68
+ if (item.hasChanges) this.tablesOrderableFormOutlet.add(item);
47
69
  });
48
70
 
49
71
  this.tablesOrderableFormOutlet.submit();
50
72
  }
51
73
 
52
- get dragItem() {
53
- return this.element.querySelector("[data-dragging]");
74
+ //endregion
75
+
76
+ //region Events
77
+
78
+ mousedown(event) {
79
+ if (this.isDragging) return;
80
+
81
+ const target = this.#targetItem(event.target);
82
+
83
+ if (!target) return;
84
+
85
+ event.preventDefault(); // prevent built-in drag
86
+
87
+ this.startDragging(new DragState(this.element, event, target.id));
88
+
89
+ this.dragState.updateCursor(this.element, target.row, event, this.animate);
54
90
  }
55
91
 
56
- dropTarget($e) {
57
- while ($e && $e.parentElement !== this.element) {
58
- $e = $e.parentElement;
92
+ mousemove = (event) => {
93
+ if (!this.isDragging) return;
94
+
95
+ event.preventDefault(); // prevent build-in drag
96
+
97
+ if (this.ticking) return;
98
+
99
+ this.ticking = true;
100
+
101
+ window.requestAnimationFrame(() => {
102
+ this.ticking = false;
103
+ this.dragState.updateCursor(
104
+ this.element,
105
+ this.dragItem.row,
106
+ event,
107
+ this.animate,
108
+ );
109
+ });
110
+ };
111
+
112
+ scroll = (event) => {
113
+ if (!this.isDragging || this.ticking) return;
114
+
115
+ this.ticking = true;
116
+
117
+ window.requestAnimationFrame(() => {
118
+ this.ticking = false;
119
+ this.dragState.updateScroll(
120
+ this.element,
121
+ this.dragItem.row,
122
+ this.animate,
123
+ );
124
+ });
125
+ };
126
+
127
+ mouseup = (event) => {
128
+ if (!this.isDragging) return;
129
+
130
+ this.drop();
131
+ this.stopDragging();
132
+ this.tablesOrderableFormOutlets.forEach((form) => delete form.dragState);
133
+ };
134
+
135
+ tablesOrderableFormOutletConnected(form, element) {
136
+ if (form.dragState) {
137
+ // restore the previous controller's state
138
+ this.startDragging(form.dragState);
59
139
  }
60
- return $e;
61
140
  }
62
- }
63
141
 
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);
142
+ tablesOrderableFormOutletDisconnected(form, element) {
143
+ if (this.isDragging) {
144
+ // cache drag state in the form
145
+ form.dragState = this.stopDragging();
71
146
  }
72
147
  }
148
+
149
+ //endregion
150
+
151
+ //region Helpers
152
+
153
+ /**
154
+ * Updates the position of the drag item with a relative offset. Updates
155
+ * other items relative to the new position of the drag item, as required.
156
+ *
157
+ * @callback {OrderableListController~animate}
158
+ * @param {number} offset
159
+ */
160
+ animate = (offset) => {
161
+ const dragItem = this.dragItem;
162
+
163
+ // Visually update the dragItem so it follows the cursor
164
+ dragItem.dragUpdate(offset);
165
+
166
+ // Visually updates the position of all items in the list relative to the
167
+ // dragged item. No actual changes to orderings at this stage.
168
+ this.#currentItems.forEach((item, index) => {
169
+ if (item === dragItem) return;
170
+ item.updateVisually(index);
171
+ });
172
+ };
173
+
174
+ get isDragging() {
175
+ return !!this.dragState;
176
+ }
177
+
178
+ get dragItem() {
179
+ if (!this.isDragging) return null;
180
+
181
+ return this.tablesOrderableItemOutlets.find(
182
+ (item) => item.id === this.dragState.targetId,
183
+ );
184
+ }
185
+
186
+ /**
187
+ * Returns the current items in the list, sorted by their current index.
188
+ * Current uses the drag index if the item is being dragged, if set.
189
+ *
190
+ * @returns {Array[OrderableRowController]}
191
+ */
192
+ get #currentItems() {
193
+ return this.tablesOrderableItemOutlets.toSorted(
194
+ (a, b) => a.comparisonIndex - b.comparisonIndex,
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Returns the item outlet that was clicked on, if any.
200
+ *
201
+ * @param element {HTMLElement} the clicked ordinal cell
202
+ * @returns {OrderableRowController}
203
+ */
204
+ #targetItem(element) {
205
+ return this.tablesOrderableItemOutlets.find(
206
+ (item) => item.element === element,
207
+ );
208
+ }
209
+
210
+ //endregion
211
+ }
212
+
213
+ /**
214
+ * During drag we want to be able to translate a document-relative coordinate
215
+ * into a coordinate relative to the list element. This state object calculates
216
+ * and stores internal state so that we can translate absolute page coordinates
217
+ * from mouse events into relative offsets for the list items within the list
218
+ * element.
219
+ *
220
+ * We also keep track of the drag target so that if the controller is attached
221
+ * to a new element during the drag we can continue after the turbo update.
222
+ */
223
+ class DragState {
224
+ /**
225
+ * @param list {HTMLElement} the list controller's element (tbody)
226
+ * @param event {MouseEvent} the initial event
227
+ * @param id {String} the id of the element being dragged
228
+ */
229
+ constructor(list, event, id) {
230
+ // cursor offset is the offset of the cursor relative to the drag item
231
+ this.cursorOffset = event.offsetY;
232
+
233
+ // initial offset is the offset position of the drag item at drag start
234
+ this.initialPosition = event.target.offsetTop - list.offsetTop;
235
+
236
+ // id of the item being dragged
237
+ this.targetId = id;
238
+ }
239
+
240
+ /**
241
+ * Calculates the offset of the drag item relative to its initial position.
242
+ *
243
+ * @param list {HTMLElement} the list controller's element (tbody)
244
+ * @param row {HTMLElement} the row being dragged
245
+ * @param event {MouseEvent} the current event
246
+ * @param callback {OrderableListController~animate} updates the drag item with a relative offset
247
+ */
248
+ updateCursor(list, row, event, callback) {
249
+ // Calculate and store the list offset relative to the viewport
250
+ // This value is cached so we can calculate the outcome of any scroll events
251
+ this.listOffset = list.getBoundingClientRect().top;
252
+
253
+ // Calculate the position of the cursor relative to the list.
254
+ // Accounts for scroll offsets by using the item's bounding client rect.
255
+ const cursorPosition = event.clientY - this.listOffset;
256
+
257
+ // intended item position relative to the list, from cursor position
258
+ let itemPosition = cursorPosition - this.cursorOffset;
259
+
260
+ this.#updateItemPosition(list, row, itemPosition, callback);
261
+ }
262
+
263
+ /**
264
+ * Animates the item's position as the list scrolls. Requires a previous call
265
+ * to set the scroll offset.
266
+ *
267
+ * @param list {HTMLElement} the list controller's element (tbody)
268
+ * @param row {HTMLElement} the row being dragged
269
+ * @param callback {OrderableListController~animate} updates the drag item with a relative offset
270
+ */
271
+ updateScroll(list, row, callback) {
272
+ const previousScrollOffset = this.listOffset;
273
+
274
+ // Calculate and store the list offset relative to the viewport
275
+ // This value is cached so we can calculate the outcome of any scroll events
276
+ this.listOffset = list.getBoundingClientRect().top;
277
+
278
+ // Calculate the change in scroll offset since the last update
279
+ const scrollDelta = previousScrollOffset - this.listOffset;
280
+
281
+ // intended item position relative to the list, from cursor position
282
+ const position = this.position + scrollDelta;
283
+
284
+ this.#updateItemPosition(list, row, position, callback);
285
+ }
286
+
287
+ #updateItemPosition(list, row, position, callback) {
288
+ // ensure itemPosition is within the bounds of the list (tbody)
289
+ position = Math.max(position, 0);
290
+ position = Math.min(position, list.offsetHeight - row.offsetHeight);
291
+
292
+ // cache the item's position relative to the list for use in scroll events
293
+ this.position = position;
294
+
295
+ // Item has position: relative, so we want to calculate the amount to move
296
+ // the item relative to it's DOM position to represent how much it has been
297
+ // dragged by.
298
+ const offset = position - this.initialPosition;
299
+
300
+ // Convert itemPosition from offset relative to list to offset relative to
301
+ // its position within the DOM (if it hadn't moved).
302
+ callback(offset);
303
+ }
73
304
  }
@@ -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.4"
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.4
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