katalyst-tables 2.2.2 → 2.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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