0a69deb2ca78bcf5e079b7a905e0a231310d7b63
[yaffs-website] / web / core / misc / tabledrag.es6.js
1 /**
2  * @file
3  * Provide dragging capabilities to admin uis.
4  */
5
6 /**
7  * Triggers when weights columns are toggled.
8  *
9  * @event columnschange
10  */
11
12 (function ($, Drupal, drupalSettings) {
13   /**
14    * Store the state of weight columns display for all tables.
15    *
16    * Default value is to hide weight columns.
17    */
18   let showWeight = JSON.parse(localStorage.getItem('Drupal.tableDrag.showWeight'));
19
20   /**
21    * Drag and drop table rows with field manipulation.
22    *
23    * Using the drupal_attach_tabledrag() function, any table with weights or
24    * parent relationships may be made into draggable tables. Columns containing
25    * a field may optionally be hidden, providing a better user experience.
26    *
27    * Created tableDrag instances may be modified with custom behaviors by
28    * overriding the .onDrag, .onDrop, .row.onSwap, and .row.onIndent methods.
29    * See blocks.js for an example of adding additional functionality to
30    * tableDrag.
31    *
32    * @type {Drupal~behavior}
33    */
34   Drupal.behaviors.tableDrag = {
35     attach(context, settings) {
36       function initTableDrag(table, base) {
37         if (table.length) {
38           // Create the new tableDrag instance. Save in the Drupal variable
39           // to allow other scripts access to the object.
40           Drupal.tableDrag[base] = new Drupal.tableDrag(table[0], settings.tableDrag[base]);
41         }
42       }
43
44       Object.keys(settings.tableDrag || {}).forEach((base) => {
45         initTableDrag($(context).find(`#${base}`).once('tabledrag'), base);
46       });
47     },
48   };
49
50   /**
51    * Provides table and field manipulation.
52    *
53    * @constructor
54    *
55    * @param {HTMLElement} table
56    *   DOM object for the table to be made draggable.
57    * @param {object} tableSettings
58    *   Settings for the table added via drupal_add_dragtable().
59    */
60   Drupal.tableDrag = function (table, tableSettings) {
61     const self = this;
62     const $table = $(table);
63
64     /**
65      * @type {jQuery}
66      */
67     this.$table = $(table);
68
69     /**
70      *
71      * @type {HTMLElement}
72      */
73     this.table = table;
74
75     /**
76      * @type {object}
77      */
78     this.tableSettings = tableSettings;
79
80     /**
81      * Used to hold information about a current drag operation.
82      *
83      * @type {?HTMLElement}
84      */
85     this.dragObject = null;
86
87     /**
88      * Provides operations for row manipulation.
89      *
90      * @type {?HTMLElement}
91      */
92     this.rowObject = null;
93
94     /**
95      * Remember the previous element.
96      *
97      * @type {?HTMLElement}
98      */
99     this.oldRowElement = null;
100
101     /**
102      * Used to determine up or down direction from last mouse move.
103      *
104      * @type {number}
105      */
106     this.oldY = 0;
107
108     /**
109      * Whether anything in the entire table has changed.
110      *
111      * @type {bool}
112      */
113     this.changed = false;
114
115     /**
116      * Maximum amount of allowed parenting.
117      *
118      * @type {number}
119      */
120     this.maxDepth = 0;
121
122     /**
123      * Direction of the table.
124      *
125      * @type {number}
126      */
127     this.rtl = $(this.table).css('direction') === 'rtl' ? -1 : 1;
128
129     /**
130      *
131      * @type {bool}
132      */
133     this.striping = $(this.table).data('striping') === 1;
134
135     /**
136      * Configure the scroll settings.
137      *
138      * @type {object}
139      *
140      * @prop {number} amount
141      * @prop {number} interval
142      * @prop {number} trigger
143      */
144     this.scrollSettings = { amount: 4, interval: 50, trigger: 70 };
145
146     /**
147      *
148      * @type {?number}
149      */
150     this.scrollInterval = null;
151
152     /**
153      *
154      * @type {number}
155      */
156     this.scrollY = 0;
157
158     /**
159      *
160      * @type {number}
161      */
162     this.windowHeight = 0;
163
164     /**
165      * Check this table's settings for parent relationships.
166      *
167      * For efficiency, large sections of code can be skipped if we don't need to
168      * track horizontal movement and indentations.
169      *
170      * @type {bool}
171      */
172     this.indentEnabled = false;
173     Object.keys(tableSettings || {}).forEach((group) => {
174       Object.keys(tableSettings[group] || {}).forEach((n) => {
175         if (tableSettings[group][n].relationship === 'parent') {
176           this.indentEnabled = true;
177         }
178         if (tableSettings[group][n].limit > 0) {
179           this.maxDepth = tableSettings[group][n].limit;
180         }
181       });
182     });
183     if (this.indentEnabled) {
184       /**
185        * Total width of indents, set in makeDraggable.
186        *
187        * @type {number}
188        */
189       this.indentCount = 1;
190       // Find the width of indentations to measure mouse movements against.
191       // Because the table doesn't need to start with any indentations, we
192       // manually append 2 indentations in the first draggable row, measure
193       // the offset, then remove.
194       const indent = Drupal.theme('tableDragIndentation');
195       const testRow = $('<tr/>').addClass('draggable').appendTo(table);
196       const testCell = $('<td/>').appendTo(testRow).prepend(indent).prepend(indent);
197       const $indentation = testCell.find('.js-indentation');
198
199       /**
200        *
201        * @type {number}
202        */
203       this.indentAmount = $indentation.get(1).offsetLeft - $indentation.get(0).offsetLeft;
204       testRow.remove();
205     }
206
207     // Make each applicable row draggable.
208     // Match immediate children of the parent element to allow nesting.
209     $table.find('> tr.draggable, > tbody > tr.draggable').each(function () {
210       self.makeDraggable(this);
211     });
212
213     // Add a link before the table for users to show or hide weight columns.
214     $table.before($('<button type="button" class="link tabledrag-toggle-weight"></button>')
215       .attr('title', Drupal.t('Re-order rows by numerical weight instead of dragging.'))
216       .on('click', $.proxy(function (e) {
217         e.preventDefault();
218         this.toggleColumns();
219       }, this))
220       .wrap('<div class="tabledrag-toggle-weight-wrapper"></div>')
221       .parent(),
222     );
223
224     // Initialize the specified columns (for example, weight or parent columns)
225     // to show or hide according to user preference. This aids accessibility
226     // so that, e.g., screen reader users can choose to enter weight values and
227     // manipulate form elements directly, rather than using drag-and-drop..
228     self.initColumns();
229
230     // Add event bindings to the document. The self variable is passed along
231     // as event handlers do not have direct access to the tableDrag object.
232     $(document).on('touchmove', event => self.dragRow(event.originalEvent.touches[0], self));
233     $(document).on('touchend', event => self.dropRow(event.originalEvent.touches[0], self));
234     $(document).on('mousemove pointermove', event => self.dragRow(event, self));
235     $(document).on('mouseup pointerup', event => self.dropRow(event, self));
236
237     // React to localStorage event showing or hiding weight columns.
238     $(window).on('storage', $.proxy(function (e) {
239       // Only react to 'Drupal.tableDrag.showWeight' value change.
240       if (e.originalEvent.key === 'Drupal.tableDrag.showWeight') {
241         // This was changed in another window, get the new value for this
242         // window.
243         showWeight = JSON.parse(e.originalEvent.newValue);
244         this.displayColumns(showWeight);
245       }
246     }, this));
247   };
248
249   /**
250    * Initialize columns containing form elements to be hidden by default.
251    *
252    * Identify and mark each cell with a CSS class so we can easily toggle
253    * show/hide it. Finally, hide columns if user does not have a
254    * 'Drupal.tableDrag.showWeight' localStorage value.
255    */
256   Drupal.tableDrag.prototype.initColumns = function () {
257     const $table = this.$table;
258     let hidden;
259     let cell;
260     let columnIndex;
261     Object.keys(this.tableSettings || {}).forEach((group) => {
262       // Find the first field in this group.
263       // eslint-disable-next-line no-restricted-syntax
264       for (const d in this.tableSettings[group]) {
265         if (this.tableSettings[group].hasOwnProperty(d)) {
266           const field = $table.find(`.${this.tableSettings[group][d].target}`).eq(0);
267           if (field.length && this.tableSettings[group][d].hidden) {
268             hidden = this.tableSettings[group][d].hidden;
269             cell = field.closest('td');
270             break;
271           }
272         }
273       }
274
275       // Mark the column containing this field so it can be hidden.
276       if (hidden && cell[0]) {
277         // Add 1 to our indexes. The nth-child selector is 1 based, not 0
278         // based. Match immediate children of the parent element to allow
279         // nesting.
280         columnIndex = cell.parent().find('> td').index(cell.get(0)) + 1;
281         $table.find('> thead > tr, > tbody > tr, > tr').each(this.addColspanClass(columnIndex));
282       }
283     });
284     this.displayColumns(showWeight);
285   };
286
287   /**
288    * Mark cells that have colspan.
289    *
290    * In order to adjust the colspan instead of hiding them altogether.
291    *
292    * @param {number} columnIndex
293    *   The column index to add colspan class to.
294    *
295    * @return {function}
296    *   Function to add colspan class.
297    */
298   Drupal.tableDrag.prototype.addColspanClass = function (columnIndex) {
299     return function () {
300       // Get the columnIndex and adjust for any colspans in this row.
301       const $row = $(this);
302       let index = columnIndex;
303       const cells = $row.children();
304       let cell;
305       cells.each(function (n) {
306         if (n < index && this.colSpan && this.colSpan > 1) {
307           index -= this.colSpan - 1;
308         }
309       });
310       if (index > 0) {
311         cell = cells.filter(`:nth-child(${index})`);
312         if (cell[0].colSpan && cell[0].colSpan > 1) {
313           // If this cell has a colspan, mark it so we can reduce the colspan.
314           cell.addClass('tabledrag-has-colspan');
315         }
316         else {
317           // Mark this cell so we can hide it.
318           cell.addClass('tabledrag-hide');
319         }
320       }
321     };
322   };
323
324   /**
325    * Hide or display weight columns. Triggers an event on change.
326    *
327    * @fires event:columnschange
328    *
329    * @param {bool} displayWeight
330    *   'true' will show weight columns.
331    */
332   Drupal.tableDrag.prototype.displayColumns = function (displayWeight) {
333     if (displayWeight) {
334       this.showColumns();
335     }
336     // Default action is to hide columns.
337     else {
338       this.hideColumns();
339     }
340     // Trigger an event to allow other scripts to react to this display change.
341     // Force the extra parameter as a bool.
342     $('table').findOnce('tabledrag').trigger('columnschange', !!displayWeight);
343   };
344
345   /**
346    * Toggle the weight column depending on 'showWeight' value.
347    *
348    * Store only default override.
349    */
350   Drupal.tableDrag.prototype.toggleColumns = function () {
351     showWeight = !showWeight;
352     this.displayColumns(showWeight);
353     if (showWeight) {
354       // Save default override.
355       localStorage.setItem('Drupal.tableDrag.showWeight', showWeight);
356     }
357     else {
358       // Reset the value to its default.
359       localStorage.removeItem('Drupal.tableDrag.showWeight');
360     }
361   };
362
363   /**
364    * Hide the columns containing weight/parent form elements.
365    *
366    * Undo showColumns().
367    */
368   Drupal.tableDrag.prototype.hideColumns = function () {
369     const $tables = $('table').findOnce('tabledrag');
370     // Hide weight/parent cells and headers.
371     $tables.find('.tabledrag-hide').css('display', 'none');
372     // Show TableDrag handles.
373     $tables.find('.tabledrag-handle').css('display', '');
374     // Reduce the colspan of any effected multi-span columns.
375     $tables.find('.tabledrag-has-colspan').each(function () {
376       this.colSpan = this.colSpan - 1;
377     });
378     // Change link text.
379     $('.tabledrag-toggle-weight').text(Drupal.t('Show row weights'));
380   };
381
382   /**
383    * Show the columns containing weight/parent form elements.
384    *
385    * Undo hideColumns().
386    */
387   Drupal.tableDrag.prototype.showColumns = function () {
388     const $tables = $('table').findOnce('tabledrag');
389     // Show weight/parent cells and headers.
390     $tables.find('.tabledrag-hide').css('display', '');
391     // Hide TableDrag handles.
392     $tables.find('.tabledrag-handle').css('display', 'none');
393     // Increase the colspan for any columns where it was previously reduced.
394     $tables.find('.tabledrag-has-colspan').each(function () {
395       this.colSpan = this.colSpan + 1;
396     });
397     // Change link text.
398     $('.tabledrag-toggle-weight').text(Drupal.t('Hide row weights'));
399   };
400
401   /**
402    * Find the target used within a particular row and group.
403    *
404    * @param {string} group
405    *   Group selector.
406    * @param {HTMLElement} row
407    *   The row HTML element.
408    *
409    * @return {object}
410    *   The table row settings.
411    */
412   Drupal.tableDrag.prototype.rowSettings = function (group, row) {
413     const field = $(row).find(`.${group}`);
414     const tableSettingsGroup = this.tableSettings[group];
415     // eslint-disable-next-line no-restricted-syntax
416     for (const delta in tableSettingsGroup) {
417       if (tableSettingsGroup.hasOwnProperty(delta)) {
418         const targetClass = tableSettingsGroup[delta].target;
419         if (field.is(`.${targetClass}`)) {
420           // Return a copy of the row settings.
421           const rowSettings = {};
422           // eslint-disable-next-line no-restricted-syntax
423           for (const n in tableSettingsGroup[delta]) {
424             if (tableSettingsGroup[delta].hasOwnProperty(n)) {
425               rowSettings[n] = tableSettingsGroup[delta][n];
426             }
427           }
428           return rowSettings;
429         }
430       }
431     }
432   };
433
434   /**
435    * Take an item and add event handlers to make it become draggable.
436    *
437    * @param {HTMLElement} item
438    *   The item to add event handlers to.
439    */
440   Drupal.tableDrag.prototype.makeDraggable = function (item) {
441     const self = this;
442     const $item = $(item);
443     // Add a class to the title link.
444     $item.find('td:first-of-type').find('a').addClass('menu-item__link');
445     // Create the handle.
446     const handle = $('<a href="#" class="tabledrag-handle"><div class="handle">&nbsp;</div></a>').attr('title', Drupal.t('Drag to re-order'));
447     // Insert the handle after indentations (if any).
448     const $indentationLast = $item.find('td:first-of-type').find('.js-indentation').eq(-1);
449     if ($indentationLast.length) {
450       $indentationLast.after(handle);
451       // Update the total width of indentation in this entire table.
452       self.indentCount = Math.max($item.find('.js-indentation').length, self.indentCount);
453     }
454     else {
455       $item.find('td').eq(0).prepend(handle);
456     }
457
458     handle.on('mousedown touchstart pointerdown', (event) => {
459       event.preventDefault();
460       if (event.originalEvent.type === 'touchstart') {
461         event = event.originalEvent.touches[0];
462       }
463       self.dragStart(event, self, item);
464     });
465
466     // Prevent the anchor tag from jumping us to the top of the page.
467     handle.on('click', (e) => {
468       e.preventDefault();
469     });
470
471     // Set blur cleanup when a handle is focused.
472     handle.on('focus', () => {
473       self.safeBlur = true;
474     });
475
476     // On blur, fire the same function as a touchend/mouseup. This is used to
477     // update values after a row has been moved through the keyboard support.
478     handle.on('blur', (event) => {
479       if (self.rowObject && self.safeBlur) {
480         self.dropRow(event, self);
481       }
482     });
483
484     // Add arrow-key support to the handle.
485     handle.on('keydown', (event) => {
486       // If a rowObject doesn't yet exist and this isn't the tab key.
487       if (event.keyCode !== 9 && !self.rowObject) {
488         self.rowObject = new self.row(item, 'keyboard', self.indentEnabled, self.maxDepth, true);
489       }
490
491       let keyChange = false;
492       let groupHeight;
493
494       /* eslint-disable no-fallthrough */
495
496       switch (event.keyCode) {
497         // Left arrow.
498         case 37:
499         // Safari left arrow.
500         case 63234:
501           keyChange = true;
502           self.rowObject.indent(-1 * self.rtl);
503           break;
504
505         // Up arrow.
506         case 38:
507         // Safari up arrow.
508         case 63232: {
509           let $previousRow = $(self.rowObject.element).prev('tr:first-of-type');
510           let previousRow = $previousRow.get(0);
511           while (previousRow && $previousRow.is(':hidden')) {
512             $previousRow = $(previousRow).prev('tr:first-of-type');
513             previousRow = $previousRow.get(0);
514           }
515           if (previousRow) {
516             // Do not allow the onBlur cleanup.
517             self.safeBlur = false;
518             self.rowObject.direction = 'up';
519             keyChange = true;
520
521             if ($(item).is('.tabledrag-root')) {
522               // Swap with the previous top-level row.
523               groupHeight = 0;
524               while (previousRow && $previousRow.find('.js-indentation').length) {
525                 $previousRow = $(previousRow).prev('tr:first-of-type');
526                 previousRow = $previousRow.get(0);
527                 groupHeight += $previousRow.is(':hidden') ? 0 : previousRow.offsetHeight;
528               }
529               if (previousRow) {
530                 self.rowObject.swap('before', previousRow);
531                 // No need to check for indentation, 0 is the only valid one.
532                 window.scrollBy(0, -groupHeight);
533               }
534             }
535             else if (self.table.tBodies[0].rows[0] !== previousRow || $previousRow.is('.draggable')) {
536               // Swap with the previous row (unless previous row is the first
537               // one and undraggable).
538               self.rowObject.swap('before', previousRow);
539               self.rowObject.interval = null;
540               self.rowObject.indent(0);
541               window.scrollBy(0, -parseInt(item.offsetHeight, 10));
542             }
543             // Regain focus after the DOM manipulation.
544             handle.trigger('focus');
545           }
546           break;
547         }
548         // Right arrow.
549         case 39:
550         // Safari right arrow.
551         case 63235:
552           keyChange = true;
553           self.rowObject.indent(self.rtl);
554           break;
555
556         // Down arrow.
557         case 40:
558         // Safari down arrow.
559         case 63233: {
560           let $nextRow = $(self.rowObject.group).eq(-1).next('tr:first-of-type');
561           let nextRow = $nextRow.get(0);
562           while (nextRow && $nextRow.is(':hidden')) {
563             $nextRow = $(nextRow).next('tr:first-of-type');
564             nextRow = $nextRow.get(0);
565           }
566           if (nextRow) {
567             // Do not allow the onBlur cleanup.
568             self.safeBlur = false;
569             self.rowObject.direction = 'down';
570             keyChange = true;
571
572             if ($(item).is('.tabledrag-root')) {
573               // Swap with the next group (necessarily a top-level one).
574               groupHeight = 0;
575               const nextGroup = new self.row(nextRow, 'keyboard', self.indentEnabled, self.maxDepth, false);
576               if (nextGroup) {
577                 $(nextGroup.group).each(function () {
578                   groupHeight += $(this).is(':hidden') ? 0 : this.offsetHeight;
579                 });
580                 const nextGroupRow = $(nextGroup.group).eq(-1).get(0);
581                 self.rowObject.swap('after', nextGroupRow);
582                 // No need to check for indentation, 0 is the only valid one.
583                 window.scrollBy(0, parseInt(groupHeight, 10));
584               }
585             }
586             else {
587               // Swap with the next row.
588               self.rowObject.swap('after', nextRow);
589               self.rowObject.interval = null;
590               self.rowObject.indent(0);
591               window.scrollBy(0, parseInt(item.offsetHeight, 10));
592             }
593             // Regain focus after the DOM manipulation.
594             handle.trigger('focus');
595           }
596           break;
597         }
598       }
599
600       /* eslint-enable no-fallthrough */
601
602       if (self.rowObject && self.rowObject.changed === true) {
603         $(item).addClass('drag');
604         if (self.oldRowElement) {
605           $(self.oldRowElement).removeClass('drag-previous');
606         }
607         self.oldRowElement = item;
608         if (self.striping === true) {
609           self.restripeTable();
610         }
611         self.onDrag();
612       }
613
614       // Returning false if we have an arrow key to prevent scrolling.
615       if (keyChange) {
616         return false;
617       }
618     });
619
620     // Compatibility addition, return false on keypress to prevent unwanted
621     // scrolling. IE and Safari will suppress scrolling on keydown, but all
622     // other browsers need to return false on keypress.
623     // http://www.quirksmode.org/js/keys.html
624     handle.on('keypress', (event) => {
625       /* eslint-disable no-fallthrough */
626
627       switch (event.keyCode) {
628         // Left arrow.
629         case 37:
630         // Up arrow.
631         case 38:
632         // Right arrow.
633         case 39:
634         // Down arrow.
635         case 40:
636           return false;
637       }
638
639       /* eslint-enable no-fallthrough */
640     });
641   };
642
643   /**
644    * Pointer event initiator, creates drag object and information.
645    *
646    * @param {jQuery.Event} event
647    *   The event object that trigger the drag.
648    * @param {Drupal.tableDrag} self
649    *   The drag handle.
650    * @param {HTMLElement} item
651    *   The item that that is being dragged.
652    */
653   Drupal.tableDrag.prototype.dragStart = function (event, self, item) {
654     // Create a new dragObject recording the pointer information.
655     self.dragObject = {};
656     self.dragObject.initOffset = self.getPointerOffset(item, event);
657     self.dragObject.initPointerCoords = self.pointerCoords(event);
658     if (self.indentEnabled) {
659       self.dragObject.indentPointerPos = self.dragObject.initPointerCoords;
660     }
661
662     // If there's a lingering row object from the keyboard, remove its focus.
663     if (self.rowObject) {
664       $(self.rowObject.element).find('a.tabledrag-handle').trigger('blur');
665     }
666
667     // Create a new rowObject for manipulation of this row.
668     self.rowObject = new self.row(item, 'pointer', self.indentEnabled, self.maxDepth, true);
669
670     // Save the position of the table.
671     self.table.topY = $(self.table).offset().top;
672     self.table.bottomY = self.table.topY + self.table.offsetHeight;
673
674     // Add classes to the handle and row.
675     $(item).addClass('drag');
676
677     // Set the document to use the move cursor during drag.
678     $('body').addClass('drag');
679     if (self.oldRowElement) {
680       $(self.oldRowElement).removeClass('drag-previous');
681     }
682   };
683
684   /**
685    * Pointer movement handler, bound to document.
686    *
687    * @param {jQuery.Event} event
688    *   The pointer event.
689    * @param {Drupal.tableDrag} self
690    *   The tableDrag instance.
691    *
692    * @return {bool|undefined}
693    *   Undefined if no dragObject is defined, false otherwise.
694    */
695   Drupal.tableDrag.prototype.dragRow = function (event, self) {
696     if (self.dragObject) {
697       self.currentPointerCoords = self.pointerCoords(event);
698       const y = self.currentPointerCoords.y - self.dragObject.initOffset.y;
699       const x = self.currentPointerCoords.x - self.dragObject.initOffset.x;
700
701       // Check for row swapping and vertical scrolling.
702       if (y !== self.oldY) {
703         self.rowObject.direction = y > self.oldY ? 'down' : 'up';
704         // Update the old value.
705         self.oldY = y;
706         // Check if the window should be scrolled (and how fast).
707         const scrollAmount = self.checkScroll(self.currentPointerCoords.y);
708         // Stop any current scrolling.
709         clearInterval(self.scrollInterval);
710         // Continue scrolling if the mouse has moved in the scroll direction.
711         if ((scrollAmount > 0 && self.rowObject.direction === 'down')
712           || (scrollAmount < 0 && self.rowObject.direction === 'up')) {
713           self.setScroll(scrollAmount);
714         }
715
716         // If we have a valid target, perform the swap and restripe the table.
717         const currentRow = self.findDropTargetRow(x, y);
718         if (currentRow) {
719           if (self.rowObject.direction === 'down') {
720             self.rowObject.swap('after', currentRow, self);
721           }
722           else {
723             self.rowObject.swap('before', currentRow, self);
724           }
725           if (self.striping === true) {
726             self.restripeTable();
727           }
728         }
729       }
730
731       // Similar to row swapping, handle indentations.
732       if (self.indentEnabled) {
733         const xDiff = self.currentPointerCoords.x - self.dragObject.indentPointerPos.x;
734         // Set the number of indentations the pointer has been moved left or
735         // right.
736         const indentDiff = Math.round(xDiff / self.indentAmount);
737         // Indent the row with our estimated diff, which may be further
738         // restricted according to the rows around this row.
739         const indentChange = self.rowObject.indent(indentDiff);
740         // Update table and pointer indentations.
741         self.dragObject.indentPointerPos.x += self.indentAmount * indentChange * self.rtl;
742         self.indentCount = Math.max(self.indentCount, self.rowObject.indents);
743       }
744
745       return false;
746     }
747   };
748
749   /**
750    * Pointerup behavior.
751    *
752    * @param {jQuery.Event} event
753    *   The pointer event.
754    * @param {Drupal.tableDrag} self
755    *   The tableDrag instance.
756    */
757   Drupal.tableDrag.prototype.dropRow = function (event, self) {
758     let droppedRow;
759     let $droppedRow;
760
761     // Drop row functionality.
762     if (self.rowObject !== null) {
763       droppedRow = self.rowObject.element;
764       $droppedRow = $(droppedRow);
765       // The row is already in the right place so we just release it.
766       if (self.rowObject.changed === true) {
767         // Update the fields in the dropped row.
768         self.updateFields(droppedRow);
769
770         // If a setting exists for affecting the entire group, update all the
771         // fields in the entire dragged group.
772         Object.keys(self.tableSettings || {}).forEach((group) => {
773           const rowSettings = self.rowSettings(group, droppedRow);
774           if (rowSettings.relationship === 'group') {
775             Object.keys(self.rowObject.children || {}).forEach((n) => {
776               self.updateField(self.rowObject.children[n], group);
777             });
778           }
779         });
780
781         self.rowObject.markChanged();
782         if (self.changed === false) {
783           $(Drupal.theme('tableDragChangedWarning')).insertBefore(self.table).hide().fadeIn('slow');
784           self.changed = true;
785         }
786       }
787
788       if (self.indentEnabled) {
789         self.rowObject.removeIndentClasses();
790       }
791       if (self.oldRowElement) {
792         $(self.oldRowElement).removeClass('drag-previous');
793       }
794       $droppedRow.removeClass('drag').addClass('drag-previous');
795       self.oldRowElement = droppedRow;
796       self.onDrop();
797       self.rowObject = null;
798     }
799
800     // Functionality specific only to pointerup events.
801     if (self.dragObject !== null) {
802       self.dragObject = null;
803       $('body').removeClass('drag');
804       clearInterval(self.scrollInterval);
805     }
806   };
807
808   /**
809    * Get the coordinates from the event (allowing for browser differences).
810    *
811    * @param {jQuery.Event} event
812    *   The pointer event.
813    *
814    * @return {object}
815    *   An object with `x` and `y` keys indicating the position.
816    */
817   Drupal.tableDrag.prototype.pointerCoords = function (event) {
818     if (event.pageX || event.pageY) {
819       return { x: event.pageX, y: event.pageY };
820     }
821     return {
822       x: (event.clientX + document.body.scrollLeft) - document.body.clientLeft,
823       y: (event.clientY + document.body.scrollTop) - document.body.clientTop,
824     };
825   };
826
827   /**
828    * Get the event offset from the target element.
829    *
830    * Given a target element and a pointer event, get the event offset from that
831    * element. To do this we need the element's position and the target position.
832    *
833    * @param {HTMLElement} target
834    *   The target HTML element.
835    * @param {jQuery.Event} event
836    *   The pointer event.
837    *
838    * @return {object}
839    *   An object with `x` and `y` keys indicating the position.
840    */
841   Drupal.tableDrag.prototype.getPointerOffset = function (target, event) {
842     const docPos = $(target).offset();
843     const pointerPos = this.pointerCoords(event);
844     return { x: pointerPos.x - docPos.left, y: pointerPos.y - docPos.top };
845   };
846
847   /**
848    * Find the row the mouse is currently over.
849    *
850    * This row is then taken and swapped with the one being dragged.
851    *
852    * @param {number} x
853    *   The x coordinate of the mouse on the page (not the screen).
854    * @param {number} y
855    *   The y coordinate of the mouse on the page (not the screen).
856    *
857    * @return {*}
858    *   The drop target row, if found.
859    */
860   Drupal.tableDrag.prototype.findDropTargetRow = function (x, y) {
861     const rows = $(this.table.tBodies[0].rows).not(':hidden');
862     for (let n = 0; n < rows.length; n++) {
863       let row = rows[n];
864       let $row = $(row);
865       const rowY = $row.offset().top;
866       let rowHeight;
867       // Because Safari does not report offsetHeight on table rows, but does on
868       // table cells, grab the firstChild of the row and use that instead.
869       // http://jacob.peargrove.com/blog/2006/technical/table-row-offsettop-bug-in-safari.
870       if (row.offsetHeight === 0) {
871         rowHeight = parseInt(row.firstChild.offsetHeight, 10) / 2;
872       }
873       // Other browsers.
874       else {
875         rowHeight = parseInt(row.offsetHeight, 10) / 2;
876       }
877
878       // Because we always insert before, we need to offset the height a bit.
879       if ((y > (rowY - rowHeight)) && (y < (rowY + rowHeight))) {
880         if (this.indentEnabled) {
881           // Check that this row is not a child of the row being dragged.
882           // eslint-disable-next-line no-restricted-syntax
883           for (n in this.rowObject.group) {
884             if (this.rowObject.group[n] === row) {
885               return null;
886             }
887           }
888         }
889         // Do not allow a row to be swapped with itself.
890         else if (row === this.rowObject.element) {
891           return null;
892         }
893
894         // Check that swapping with this row is allowed.
895         if (!this.rowObject.isValidSwap(row)) {
896           return null;
897         }
898
899         // We may have found the row the mouse just passed over, but it doesn't
900         // take into account hidden rows. Skip backwards until we find a
901         // draggable row.
902         while ($row.is(':hidden') && $row.prev('tr').is(':hidden')) {
903           $row = $row.prev('tr:first-of-type');
904           row = $row.get(0);
905         }
906         return row;
907       }
908     }
909     return null;
910   };
911
912   /**
913    * After the row is dropped, update the table fields.
914    *
915    * @param {HTMLElement} changedRow
916    *   DOM object for the row that was just dropped.
917    */
918   Drupal.tableDrag.prototype.updateFields = function (changedRow) {
919     Object.keys(this.tableSettings || {}).forEach((group) => {
920       // Each group may have a different setting for relationship, so we find
921       // the source rows for each separately.
922       this.updateField(changedRow, group);
923     });
924   };
925
926   /**
927    * After the row is dropped, update a single table field.
928    *
929    * @param {HTMLElement} changedRow
930    *   DOM object for the row that was just dropped.
931    * @param {string} group
932    *   The settings group on which field updates will occur.
933    */
934   Drupal.tableDrag.prototype.updateField = function (changedRow, group) {
935     let rowSettings = this.rowSettings(group, changedRow);
936     const $changedRow = $(changedRow);
937     let sourceRow;
938     let $previousRow;
939     let previousRow;
940     let useSibling;
941     // Set the row as its own target.
942     if (rowSettings.relationship === 'self' || rowSettings.relationship === 'group') {
943       sourceRow = changedRow;
944     }
945     // Siblings are easy, check previous and next rows.
946     else if (rowSettings.relationship === 'sibling') {
947       $previousRow = $changedRow.prev('tr:first-of-type');
948       previousRow = $previousRow.get(0);
949       const $nextRow = $changedRow.next('tr:first-of-type');
950       const nextRow = $nextRow.get(0);
951       sourceRow = changedRow;
952       if ($previousRow.is('.draggable') && $previousRow.find(`.${group}`).length) {
953         if (this.indentEnabled) {
954           if ($previousRow.find('.js-indentations').length === $changedRow.find('.js-indentations').length) {
955             sourceRow = previousRow;
956           }
957         }
958         else {
959           sourceRow = previousRow;
960         }
961       }
962       else if ($nextRow.is('.draggable') && $nextRow.find(`.${group}`).length) {
963         if (this.indentEnabled) {
964           if ($nextRow.find('.js-indentations').length === $changedRow.find('.js-indentations').length) {
965             sourceRow = nextRow;
966           }
967         }
968         else {
969           sourceRow = nextRow;
970         }
971       }
972     }
973     // Parents, look up the tree until we find a field not in this group.
974     // Go up as many parents as indentations in the changed row.
975     else if (rowSettings.relationship === 'parent') {
976       $previousRow = $changedRow.prev('tr');
977       previousRow = $previousRow;
978       while ($previousRow.length && $previousRow.find('.js-indentation').length >= this.rowObject.indents) {
979         $previousRow = $previousRow.prev('tr');
980         previousRow = $previousRow;
981       }
982       // If we found a row.
983       if ($previousRow.length) {
984         sourceRow = $previousRow.get(0);
985       }
986       // Otherwise we went all the way to the left of the table without finding
987       // a parent, meaning this item has been placed at the root level.
988       else {
989         // Use the first row in the table as source, because it's guaranteed to
990         // be at the root level. Find the first item, then compare this row
991         // against it as a sibling.
992         sourceRow = $(this.table).find('tr.draggable:first-of-type').get(0);
993         if (sourceRow === this.rowObject.element) {
994           sourceRow = $(this.rowObject.group[this.rowObject.group.length - 1]).next('tr.draggable').get(0);
995         }
996         useSibling = true;
997       }
998     }
999
1000     // Because we may have moved the row from one category to another,
1001     // take a look at our sibling and borrow its sources and targets.
1002     this.copyDragClasses(sourceRow, changedRow, group);
1003     rowSettings = this.rowSettings(group, changedRow);
1004
1005     // In the case that we're looking for a parent, but the row is at the top
1006     // of the tree, copy our sibling's values.
1007     if (useSibling) {
1008       rowSettings.relationship = 'sibling';
1009       rowSettings.source = rowSettings.target;
1010     }
1011
1012     const targetClass = `.${rowSettings.target}`;
1013     const targetElement = $changedRow.find(targetClass).get(0);
1014
1015     // Check if a target element exists in this row.
1016     if (targetElement) {
1017       const sourceClass = `.${rowSettings.source}`;
1018       const sourceElement = $(sourceClass, sourceRow).get(0);
1019       switch (rowSettings.action) {
1020         case 'depth':
1021           // Get the depth of the target row.
1022           targetElement.value = $(sourceElement).closest('tr').find('.js-indentation').length;
1023           break;
1024
1025         case 'match':
1026           // Update the value.
1027           targetElement.value = sourceElement.value;
1028           break;
1029
1030         case 'order': {
1031           const siblings = this.rowObject.findSiblings(rowSettings);
1032           if ($(targetElement).is('select')) {
1033             // Get a list of acceptable values.
1034             const values = [];
1035             $(targetElement).find('option').each(function () {
1036               values.push(this.value);
1037             });
1038             const maxVal = values[values.length - 1];
1039             // Populate the values in the siblings.
1040             $(siblings).find(targetClass).each(function () {
1041               // If there are more items than possible values, assign the
1042               // maximum value to the row.
1043               if (values.length > 0) {
1044                 this.value = values.shift();
1045               }
1046               else {
1047                 this.value = maxVal;
1048               }
1049             });
1050           }
1051           else {
1052             // Assume a numeric input field.
1053             let weight = parseInt($(siblings[0]).find(targetClass).val(), 10) || 0;
1054             $(siblings).find(targetClass).each(function () {
1055               this.value = weight;
1056               weight++;
1057             });
1058           }
1059           break;
1060         }
1061       }
1062     }
1063   };
1064
1065   /**
1066    * Copy all tableDrag related classes from one row to another.
1067    *
1068    * Copy all special tableDrag classes from one row's form elements to a
1069    * different one, removing any special classes that the destination row
1070    * may have had.
1071    *
1072    * @param {HTMLElement} sourceRow
1073    *   The element for the source row.
1074    * @param {HTMLElement} targetRow
1075    *   The element for the target row.
1076    * @param {string} group
1077    *   The group selector.
1078    */
1079   Drupal.tableDrag.prototype.copyDragClasses = function (sourceRow, targetRow, group) {
1080     const sourceElement = $(sourceRow).find(`.${group}`);
1081     const targetElement = $(targetRow).find(`.${group}`);
1082     if (sourceElement.length && targetElement.length) {
1083       targetElement[0].className = sourceElement[0].className;
1084     }
1085   };
1086
1087   /**
1088    * Check the suggested scroll of the table.
1089    *
1090    * @param {number} cursorY
1091    *   The Y position of the cursor.
1092    *
1093    * @return {number}
1094    *   The suggested scroll.
1095    */
1096   Drupal.tableDrag.prototype.checkScroll = function (cursorY) {
1097     const de = document.documentElement;
1098     const b = document.body;
1099
1100     const windowHeight = window.innerHeight || (de.clientHeight && de.clientWidth !== 0 ? de.clientHeight : b.offsetHeight);
1101     this.windowHeight = windowHeight;
1102     let scrollY;
1103     if (document.all) {
1104       scrollY = !de.scrollTop ? b.scrollTop : de.scrollTop;
1105     }
1106     else {
1107       scrollY = window.pageYOffset ? window.pageYOffset : window.scrollY;
1108     }
1109     this.scrollY = scrollY;
1110     const trigger = this.scrollSettings.trigger;
1111     let delta = 0;
1112
1113     // Return a scroll speed relative to the edge of the screen.
1114     if (cursorY - scrollY > windowHeight - trigger) {
1115       delta = trigger / ((windowHeight + scrollY) - cursorY);
1116       delta = (delta > 0 && delta < trigger) ? delta : trigger;
1117       return delta * this.scrollSettings.amount;
1118     }
1119     else if (cursorY - scrollY < trigger) {
1120       delta = trigger / (cursorY - scrollY);
1121       delta = (delta > 0 && delta < trigger) ? delta : trigger;
1122       return -delta * this.scrollSettings.amount;
1123     }
1124   };
1125
1126   /**
1127    * Set the scroll for the table.
1128    *
1129    * @param {number} scrollAmount
1130    *   The amount of scroll to apply to the window.
1131    */
1132   Drupal.tableDrag.prototype.setScroll = function (scrollAmount) {
1133     const self = this;
1134
1135     this.scrollInterval = setInterval(() => {
1136       // Update the scroll values stored in the object.
1137       self.checkScroll(self.currentPointerCoords.y);
1138       const aboveTable = self.scrollY > self.table.topY;
1139       const belowTable = self.scrollY + self.windowHeight < self.table.bottomY;
1140       if ((scrollAmount > 0 && belowTable)
1141         || (scrollAmount < 0 && aboveTable)) {
1142         window.scrollBy(0, scrollAmount);
1143       }
1144     }, this.scrollSettings.interval);
1145   };
1146
1147   /**
1148    * Command to restripe table properly.
1149    */
1150   Drupal.tableDrag.prototype.restripeTable = function () {
1151     // :even and :odd are reversed because jQuery counts from 0 and
1152     // we count from 1, so we're out of sync.
1153     // Match immediate children of the parent element to allow nesting.
1154     $(this.table)
1155       .find('> tbody > tr.draggable, > tr.draggable')
1156       .filter(':visible')
1157       .filter(':odd')
1158       .removeClass('odd')
1159       .addClass('even')
1160       .end()
1161       .filter(':even')
1162       .removeClass('even')
1163       .addClass('odd');
1164   };
1165
1166   /**
1167    * Stub function. Allows a custom handler when a row begins dragging.
1168    *
1169    * @return {null}
1170    *   Returns null when the stub function is used.
1171    */
1172   Drupal.tableDrag.prototype.onDrag = function () {
1173     return null;
1174   };
1175
1176   /**
1177    * Stub function. Allows a custom handler when a row is dropped.
1178    *
1179    * @return {null}
1180    *   Returns null when the stub function is used.
1181    */
1182   Drupal.tableDrag.prototype.onDrop = function () {
1183     return null;
1184   };
1185
1186   /**
1187    * Constructor to make a new object to manipulate a table row.
1188    *
1189    * @param {HTMLElement} tableRow
1190    *   The DOM element for the table row we will be manipulating.
1191    * @param {string} method
1192    *   The method in which this row is being moved. Either 'keyboard' or
1193    *   'mouse'.
1194    * @param {bool} indentEnabled
1195    *   Whether the containing table uses indentations. Used for optimizations.
1196    * @param {number} maxDepth
1197    *   The maximum amount of indentations this row may contain.
1198    * @param {bool} addClasses
1199    *   Whether we want to add classes to this row to indicate child
1200    *   relationships.
1201    */
1202   Drupal.tableDrag.prototype.row = function (tableRow, method, indentEnabled, maxDepth, addClasses) {
1203     const $tableRow = $(tableRow);
1204
1205     this.element = tableRow;
1206     this.method = method;
1207     this.group = [tableRow];
1208     this.groupDepth = $tableRow.find('.js-indentation').length;
1209     this.changed = false;
1210     this.table = $tableRow.closest('table')[0];
1211     this.indentEnabled = indentEnabled;
1212     this.maxDepth = maxDepth;
1213     // Direction the row is being moved.
1214     this.direction = '';
1215     if (this.indentEnabled) {
1216       this.indents = $tableRow.find('.js-indentation').length;
1217       this.children = this.findChildren(addClasses);
1218       this.group = $.merge(this.group, this.children);
1219       // Find the depth of this entire group.
1220       for (let n = 0; n < this.group.length; n++) {
1221         this.groupDepth = Math.max($(this.group[n]).find('.js-indentation').length, this.groupDepth);
1222       }
1223     }
1224   };
1225
1226   /**
1227    * Find all children of rowObject by indentation.
1228    *
1229    * @param {bool} addClasses
1230    *   Whether we want to add classes to this row to indicate child
1231    *   relationships.
1232    *
1233    * @return {Array}
1234    *   An array of children of the row.
1235    */
1236   Drupal.tableDrag.prototype.row.prototype.findChildren = function (addClasses) {
1237     const parentIndentation = this.indents;
1238     let currentRow = $(this.element, this.table).next('tr.draggable');
1239     const rows = [];
1240     let child = 0;
1241
1242     function rowIndentation(indentNum, el) {
1243       const self = $(el);
1244       if (child === 1 && (indentNum === parentIndentation)) {
1245         self.addClass('tree-child-first');
1246       }
1247       if (indentNum === parentIndentation) {
1248         self.addClass('tree-child');
1249       }
1250       else if (indentNum > parentIndentation) {
1251         self.addClass('tree-child-horizontal');
1252       }
1253     }
1254
1255     while (currentRow.length) {
1256       // A greater indentation indicates this is a child.
1257       if (currentRow.find('.js-indentation').length > parentIndentation) {
1258         child++;
1259         rows.push(currentRow[0]);
1260         if (addClasses) {
1261           currentRow.find('.js-indentation').each(rowIndentation);
1262         }
1263       }
1264       else {
1265         break;
1266       }
1267       currentRow = currentRow.next('tr.draggable');
1268     }
1269     if (addClasses && rows.length) {
1270       $(rows[rows.length - 1]).find(`.js-indentation:nth-child(${parentIndentation + 1})`).addClass('tree-child-last');
1271     }
1272     return rows;
1273   };
1274
1275   /**
1276    * Ensure that two rows are allowed to be swapped.
1277    *
1278    * @param {HTMLElement} row
1279    *   DOM object for the row being considered for swapping.
1280    *
1281    * @return {bool}
1282    *   Whether the swap is a valid swap or not.
1283    */
1284   Drupal.tableDrag.prototype.row.prototype.isValidSwap = function (row) {
1285     const $row = $(row);
1286     if (this.indentEnabled) {
1287       let prevRow;
1288       let nextRow;
1289       if (this.direction === 'down') {
1290         prevRow = row;
1291         nextRow = $row.next('tr').get(0);
1292       }
1293       else {
1294         prevRow = $row.prev('tr').get(0);
1295         nextRow = row;
1296       }
1297       this.interval = this.validIndentInterval(prevRow, nextRow);
1298
1299       // We have an invalid swap if the valid indentations interval is empty.
1300       if (this.interval.min > this.interval.max) {
1301         return false;
1302       }
1303     }
1304
1305     // Do not let an un-draggable first row have anything put before it.
1306     if (this.table.tBodies[0].rows[0] === row && $row.is(':not(.draggable)')) {
1307       return false;
1308     }
1309
1310     return true;
1311   };
1312
1313   /**
1314    * Perform the swap between two rows.
1315    *
1316    * @param {string} position
1317    *   Whether the swap will occur 'before' or 'after' the given row.
1318    * @param {HTMLElement} row
1319    *   DOM element what will be swapped with the row group.
1320    */
1321   Drupal.tableDrag.prototype.row.prototype.swap = function (position, row) {
1322     // Makes sure only DOM object are passed to Drupal.detachBehaviors().
1323     this.group.forEach((row) => {
1324       Drupal.detachBehaviors(row, drupalSettings, 'move');
1325     });
1326     $(row)[position](this.group);
1327     // Makes sure only DOM object are passed to Drupal.attachBehaviors()s.
1328     this.group.forEach((row) => {
1329       Drupal.attachBehaviors(row, drupalSettings);
1330     });
1331     this.changed = true;
1332     this.onSwap(row);
1333   };
1334
1335   /**
1336    * Determine the valid indentations interval for the row at a given position.
1337    *
1338    * @param {?HTMLElement} prevRow
1339    *   DOM object for the row before the tested position
1340    *   (or null for first position in the table).
1341    * @param {?HTMLElement} nextRow
1342    *   DOM object for the row after the tested position
1343    *   (or null for last position in the table).
1344    *
1345    * @return {object}
1346    *   An object with the keys `min` and `max` to indicate the valid indent
1347    *   interval.
1348    */
1349   Drupal.tableDrag.prototype.row.prototype.validIndentInterval = function (prevRow, nextRow) {
1350     const $prevRow = $(prevRow);
1351     let maxIndent;
1352
1353     // Minimum indentation:
1354     // Do not orphan the next row.
1355     const minIndent = nextRow ? $(nextRow).find('.js-indentation').length : 0;
1356
1357     // Maximum indentation:
1358     if (!prevRow || $prevRow.is(':not(.draggable)') || $(this.element).is('.tabledrag-root')) {
1359       // Do not indent:
1360       // - the first row in the table,
1361       // - rows dragged below a non-draggable row,
1362       // - 'root' rows.
1363       maxIndent = 0;
1364     }
1365     else {
1366       // Do not go deeper than as a child of the previous row.
1367       maxIndent = $prevRow.find('.js-indentation').length + ($prevRow.is('.tabledrag-leaf') ? 0 : 1);
1368       // Limit by the maximum allowed depth for the table.
1369       if (this.maxDepth) {
1370         maxIndent = Math.min(maxIndent, this.maxDepth - (this.groupDepth - this.indents));
1371       }
1372     }
1373
1374     return { min: minIndent, max: maxIndent };
1375   };
1376
1377   /**
1378    * Indent a row within the legal bounds of the table.
1379    *
1380    * @param {number} indentDiff
1381    *   The number of additional indentations proposed for the row (can be
1382    *   positive or negative). This number will be adjusted to nearest valid
1383    *   indentation level for the row.
1384    *
1385    * @return {number}
1386    *   The number of indentations applied.
1387    */
1388   Drupal.tableDrag.prototype.row.prototype.indent = function (indentDiff) {
1389     const $group = $(this.group);
1390     // Determine the valid indentations interval if not available yet.
1391     if (!this.interval) {
1392       const prevRow = $(this.element).prev('tr').get(0);
1393       const nextRow = $group.eq(-1).next('tr').get(0);
1394       this.interval = this.validIndentInterval(prevRow, nextRow);
1395     }
1396
1397     // Adjust to the nearest valid indentation.
1398     let indent = this.indents + indentDiff;
1399     indent = Math.max(indent, this.interval.min);
1400     indent = Math.min(indent, this.interval.max);
1401     indentDiff = indent - this.indents;
1402
1403     for (let n = 1; n <= Math.abs(indentDiff); n++) {
1404       // Add or remove indentations.
1405       if (indentDiff < 0) {
1406         $group.find('.js-indentation:first-of-type').remove();
1407         this.indents--;
1408       }
1409       else {
1410         $group.find('td:first-of-type').prepend(Drupal.theme('tableDragIndentation'));
1411         this.indents++;
1412       }
1413     }
1414     if (indentDiff) {
1415       // Update indentation for this row.
1416       this.changed = true;
1417       this.groupDepth += indentDiff;
1418       this.onIndent();
1419     }
1420
1421     return indentDiff;
1422   };
1423
1424   /**
1425    * Find all siblings for a row.
1426    *
1427    * According to its subgroup or indentation. Note that the passed-in row is
1428    * included in the list of siblings.
1429    *
1430    * @param {object} rowSettings
1431    *   The field settings we're using to identify what constitutes a sibling.
1432    *
1433    * @return {Array}
1434    *   An array of siblings.
1435    */
1436   Drupal.tableDrag.prototype.row.prototype.findSiblings = function (rowSettings) {
1437     const siblings = [];
1438     const directions = ['prev', 'next'];
1439     const rowIndentation = this.indents;
1440     let checkRowIndentation;
1441     for (let d = 0; d < directions.length; d++) {
1442       let checkRow = $(this.element)[directions[d]]();
1443       while (checkRow.length) {
1444         // Check that the sibling contains a similar target field.
1445         if (checkRow.find(`.${rowSettings.target}`)) {
1446           // Either add immediately if this is a flat table, or check to ensure
1447           // that this row has the same level of indentation.
1448           if (this.indentEnabled) {
1449             checkRowIndentation = checkRow.find('.js-indentation').length;
1450           }
1451
1452           if (!(this.indentEnabled) || (checkRowIndentation === rowIndentation)) {
1453             siblings.push(checkRow[0]);
1454           }
1455           else if (checkRowIndentation < rowIndentation) {
1456             // No need to keep looking for siblings when we get to a parent.
1457             break;
1458           }
1459         }
1460         else {
1461           break;
1462         }
1463         checkRow = checkRow[directions[d]]();
1464       }
1465       // Since siblings are added in reverse order for previous, reverse the
1466       // completed list of previous siblings. Add the current row and continue.
1467       if (directions[d] === 'prev') {
1468         siblings.reverse();
1469         siblings.push(this.element);
1470       }
1471     }
1472     return siblings;
1473   };
1474
1475   /**
1476    * Remove indentation helper classes from the current row group.
1477    */
1478   Drupal.tableDrag.prototype.row.prototype.removeIndentClasses = function () {
1479     Object.keys(this.children || {}).forEach((n) => {
1480       $(this.children[n]).find('.js-indentation')
1481         .removeClass('tree-child')
1482         .removeClass('tree-child-first')
1483         .removeClass('tree-child-last')
1484         .removeClass('tree-child-horizontal');
1485     });
1486   };
1487
1488   /**
1489    * Add an asterisk or other marker to the changed row.
1490    */
1491   Drupal.tableDrag.prototype.row.prototype.markChanged = function () {
1492     const marker = Drupal.theme('tableDragChangedMarker');
1493     const cell = $(this.element).find('td:first-of-type');
1494     if (cell.find('abbr.tabledrag-changed').length === 0) {
1495       cell.append(marker);
1496     }
1497   };
1498
1499   /**
1500    * Stub function. Allows a custom handler when a row is indented.
1501    *
1502    * @return {null}
1503    *   Returns null when the stub function is used.
1504    */
1505   Drupal.tableDrag.prototype.row.prototype.onIndent = function () {
1506     return null;
1507   };
1508
1509   /**
1510    * Stub function. Allows a custom handler when a row is swapped.
1511    *
1512    * @param {HTMLElement} swappedRow
1513    *   The element for the swapped row.
1514    *
1515    * @return {null}
1516    *   Returns null when the stub function is used.
1517    */
1518   Drupal.tableDrag.prototype.row.prototype.onSwap = function (swappedRow) {
1519     return null;
1520   };
1521
1522   $.extend(Drupal.theme, /** @lends Drupal.theme */{
1523
1524     /**
1525      * @return {string}
1526      *  Markup for the marker.
1527      */
1528     tableDragChangedMarker() {
1529       return `<abbr class="warning tabledrag-changed" title="${Drupal.t('Changed')}">*</abbr>`;
1530     },
1531
1532     /**
1533      * @return {string}
1534      *   Markup for the indentation.
1535      */
1536     tableDragIndentation() {
1537       return '<div class="js-indentation indentation">&nbsp;</div>';
1538     },
1539
1540     /**
1541      * @return {string}
1542      *   Markup for the warning.
1543      */
1544     tableDragChangedWarning() {
1545       return `<div class="tabledrag-changed-warning messages messages--warning" role="alert">${Drupal.theme('tableDragChangedMarker')} ${Drupal.t('You have unsaved changes.')}</div>`;
1546     },
1547   });
1548 }(jQuery, Drupal, drupalSettings));