611 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			611 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /**
 | |
|  * @copyright  (C) 2019 Open Source Matters, Inc. <https://www.joomla.org>
 | |
|  * @license    GNU General Public License version 2 or later; see LICENSE.txt
 | |
|  */
 | |
| 
 | |
| const KEYCODE = {
 | |
|   SPACE: 'Space',
 | |
|   ESC: 'Escape',
 | |
|   ENTER: 'Enter'
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * Helper for testing whether a selection modifier is pressed
 | |
|  * @param {Event} event
 | |
|  *
 | |
|  * @returns {boolean|*}
 | |
|  */
 | |
| function hasModifier(event) {
 | |
|   return event.ctrlKey || event.metaKey || event.shiftKey;
 | |
| }
 | |
| class JoomlaFieldSubform extends HTMLElement {
 | |
|   // Attribute getters
 | |
|   get buttonAdd() {
 | |
|     return this.getAttribute('button-add');
 | |
|   }
 | |
|   get buttonRemove() {
 | |
|     return this.getAttribute('button-remove');
 | |
|   }
 | |
|   get buttonMove() {
 | |
|     return this.getAttribute('button-move');
 | |
|   }
 | |
|   get rowsContainer() {
 | |
|     return this.getAttribute('rows-container');
 | |
|   }
 | |
|   get repeatableElement() {
 | |
|     return this.getAttribute('repeatable-element');
 | |
|   }
 | |
|   get minimum() {
 | |
|     return this.getAttribute('minimum');
 | |
|   }
 | |
|   get maximum() {
 | |
|     return this.getAttribute('maximum');
 | |
|   }
 | |
|   get name() {
 | |
|     return this.getAttribute('name');
 | |
|   }
 | |
|   set name(value) {
 | |
|     // Update the template
 | |
|     this.template = this.template.replace(new RegExp(` name="${this.name.replace(/[[\]]/g, '\\$&')}`, 'g'), ` name="${value}`);
 | |
|     this.setAttribute('name', value);
 | |
|   }
 | |
|   constructor() {
 | |
|     super();
 | |
|     const that = this;
 | |
| 
 | |
|     // Get the rows container
 | |
|     this.containerWithRows = this;
 | |
|     if (this.rowsContainer) {
 | |
|       const allContainers = this.querySelectorAll(this.rowsContainer);
 | |
| 
 | |
|       // Find closest, and exclude nested
 | |
|       Array.from(allContainers).forEach(container => {
 | |
|         if (container.closest('joomla-field-subform') === this) {
 | |
|           this.containerWithRows = container;
 | |
|         }
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     // Keep track of row index, this is important to avoid a name duplication
 | |
|     // Note: php side should reset the indexes each time, eg: $value = array_values($value);
 | |
|     this.lastRowIndex = this.getRows().length - 1;
 | |
| 
 | |
|     // Template for the repeating group
 | |
|     this.template = '';
 | |
| 
 | |
|     // Prepare a row template, and find available field names
 | |
|     this.prepareTemplate();
 | |
| 
 | |
|     // Bind buttons
 | |
|     if (this.buttonAdd || this.buttonRemove) {
 | |
|       this.addEventListener('click', event => {
 | |
|         let btnAdd = null;
 | |
|         let btnRem = null;
 | |
|         if (that.buttonAdd) {
 | |
|           btnAdd = event.target.closest(that.buttonAdd);
 | |
|         }
 | |
|         if (that.buttonRemove) {
 | |
|           btnRem = event.target.closest(that.buttonRemove);
 | |
|         }
 | |
| 
 | |
|         // Check active, with extra check for nested joomla-field-subform
 | |
|         if (btnAdd && btnAdd.closest('joomla-field-subform') === that) {
 | |
|           let row = btnAdd.closest(that.repeatableElement);
 | |
|           row = row && row.closest('joomla-field-subform') === that ? row : null;
 | |
|           that.addRow(row);
 | |
|           event.preventDefault();
 | |
|         } else if (btnRem && btnRem.closest('joomla-field-subform') === that) {
 | |
|           const row = btnRem.closest(that.repeatableElement);
 | |
|           that.removeRow(row);
 | |
|           event.preventDefault();
 | |
|         }
 | |
|       });
 | |
|       this.addEventListener('keydown', event => {
 | |
|         if (event.code !== KEYCODE.SPACE) return;
 | |
|         const isAdd = that.buttonAdd && event.target.matches(that.buttonAdd);
 | |
|         const isRem = that.buttonRemove && event.target.matches(that.buttonRemove);
 | |
|         if ((isAdd || isRem) && event.target.closest('joomla-field-subform') === that) {
 | |
|           let row = event.target.closest(that.repeatableElement);
 | |
|           row = row && row.closest('joomla-field-subform') === that ? row : null;
 | |
|           if (isRem && row) {
 | |
|             that.removeRow(row);
 | |
|           } else if (isAdd) {
 | |
|             that.addRow(row);
 | |
|           }
 | |
|           event.preventDefault();
 | |
|         }
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     // Sorting
 | |
|     if (this.buttonMove) {
 | |
|       this.setUpDragSort();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Search for existing rows
 | |
|    * @returns {HTMLElement[]}
 | |
|    */
 | |
|   getRows() {
 | |
|     const rows = Array.from(this.containerWithRows.children);
 | |
|     const result = [];
 | |
| 
 | |
|     // Filter out the rows
 | |
|     rows.forEach(row => {
 | |
|       if (row.matches(this.repeatableElement)) {
 | |
|         result.push(row);
 | |
|       }
 | |
|     });
 | |
|     return result;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Prepare a row template
 | |
|    */
 | |
|   prepareTemplate() {
 | |
|     const tmplElement = [].slice.call(this.children).filter(el => el.classList.contains('subform-repeatable-template-section'));
 | |
|     if (tmplElement[0]) {
 | |
|       this.template = tmplElement[0].innerHTML;
 | |
|     }
 | |
|     if (!this.template) {
 | |
|       throw new Error('The row template is required for the subform element to work');
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Add new row
 | |
|    * @param {HTMLElement} after
 | |
|    * @returns {HTMLElement}
 | |
|    */
 | |
|   addRow(after) {
 | |
|     // Count how many we already have
 | |
|     const count = this.getRows().length;
 | |
|     if (count >= this.maximum) {
 | |
|       return null;
 | |
|     }
 | |
| 
 | |
|     // Make a new row from the template
 | |
|     let tmpEl;
 | |
|     if (this.containerWithRows.nodeName === 'TBODY' || this.containerWithRows.nodeName === 'TABLE') {
 | |
|       tmpEl = document.createElement('tbody');
 | |
|     } else {
 | |
|       tmpEl = document.createElement('div');
 | |
|     }
 | |
|     tmpEl.innerHTML = this.template;
 | |
|     const row = tmpEl.children[0];
 | |
| 
 | |
|     // Add to container
 | |
|     if (after) {
 | |
|       after.parentNode.insertBefore(row, after.nextSibling);
 | |
|     } else {
 | |
|       this.containerWithRows.append(row);
 | |
|     }
 | |
| 
 | |
|     // Add draggable attributes
 | |
|     if (this.buttonMove) {
 | |
|       row.setAttribute('draggable', 'false');
 | |
|       row.setAttribute('aria-grabbed', 'false');
 | |
|       row.setAttribute('tabindex', '0');
 | |
|     }
 | |
| 
 | |
|     // Marker that it is new
 | |
|     row.setAttribute('data-new', '1');
 | |
|     // Fix names and ids, and reset values
 | |
|     this.fixUniqueAttributes(row, count);
 | |
| 
 | |
|     // Tell about the new row
 | |
|     this.dispatchEvent(new CustomEvent('subform-row-add', {
 | |
|       detail: {
 | |
|         row
 | |
|       },
 | |
|       bubbles: true
 | |
|     }));
 | |
|     row.dispatchEvent(new CustomEvent('joomla:updated', {
 | |
|       bubbles: true,
 | |
|       cancelable: true
 | |
|     }));
 | |
|     return row;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Remove the row
 | |
|    * @param {HTMLElement} row
 | |
|    */
 | |
|   removeRow(row) {
 | |
|     // Count how much we have
 | |
|     const count = this.getRows().length;
 | |
|     if (count <= this.minimum) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     // Tell about the row will be removed
 | |
|     this.dispatchEvent(new CustomEvent('subform-row-remove', {
 | |
|       detail: {
 | |
|         row
 | |
|       },
 | |
|       bubbles: true
 | |
|     }));
 | |
|     row.dispatchEvent(new CustomEvent('joomla:removed', {
 | |
|       bubbles: true,
 | |
|       cancelable: true
 | |
|     }));
 | |
|     row.parentNode.removeChild(row);
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Fix name and id for fields that are in the row
 | |
|    * @param {HTMLElement} row
 | |
|    * @param {Number} count
 | |
|    */
 | |
|   fixUniqueAttributes(row, count) {
 | |
|     const countTmp = count || 0;
 | |
|     const group = row.getAttribute('data-group'); // current group name
 | |
|     const basename = row.getAttribute('data-base-name');
 | |
|     const countnew = Math.max(this.lastRowIndex, countTmp);
 | |
|     const groupnew = basename + countnew; // new group name
 | |
| 
 | |
|     this.lastRowIndex = countnew + 1;
 | |
|     row.setAttribute('data-group', groupnew);
 | |
| 
 | |
|     // Fix inputs that have a "name" attribute
 | |
|     let haveName = row.querySelectorAll('[name]');
 | |
|     const ids = {}; // Collect id for fix checkboxes and radio
 | |
| 
 | |
|     // Filter out nested
 | |
|     haveName = [].slice.call(haveName).filter(el => {
 | |
|       if (el.nodeName === 'JOOMLA-FIELD-SUBFORM') {
 | |
|         // Skip self in .closest() call
 | |
|         return el.parentElement.closest('joomla-field-subform') === this;
 | |
|       }
 | |
|       return el.closest('joomla-field-subform') === this;
 | |
|     });
 | |
|     haveName.forEach(elem => {
 | |
|       const $el = elem;
 | |
|       const name = $el.getAttribute('name');
 | |
|       const aria = $el.getAttribute('aria-describedby');
 | |
|       const id = name.replace(/(\[\]$)/g, '').replace(/(\]\[)/g, '__').replace(/\[/g, '_').replace(/\]/g, ''); // id from name
 | |
|       const nameNew = name.replace(`[${group}][`, `[${groupnew}][`); // New name
 | |
|       let idNew = id.replace(group, groupnew).replace(/\W/g, '_'); // Count new id
 | |
|       let countMulti = 0; // count for multiple radio/checkboxes
 | |
|       const forOldAttr = $el.id; // Fix "for" in the labels
 | |
| 
 | |
|       if ($el.type === 'checkbox' && name.match(/\[\]$/)) {
 | |
|         // <input type="checkbox" name="name[]"> fix
 | |
|         countMulti = ids[id] ? ids[id].length : 0;
 | |
| 
 | |
|         // Set the id for fieldset and group label
 | |
|         if (!countMulti) {
 | |
|           // Look for <fieldset class="checkboxes"></fieldset> or <fieldset><div class="checkboxes"></div></fieldset>
 | |
|           let fieldset = $el.closest('.checkboxes, fieldset');
 | |
|           // eslint-disable-next-line no-nested-ternary
 | |
|           if (fieldset) {
 | |
|             // eslint-disable-next-line no-nested-ternary
 | |
|             fieldset = fieldset.nodeName === 'FIELDSET' ? fieldset : fieldset.parentElement.nodeName === 'FIELDSET' ? fieldset.parentElement : false;
 | |
|           }
 | |
|           if (fieldset) {
 | |
|             const oldSetId = fieldset.id;
 | |
|             fieldset.id = idNew;
 | |
|             const groupLbl = row.querySelector(`label[for="${oldSetId}"]`);
 | |
|             if (groupLbl) {
 | |
|               groupLbl.setAttribute('for', idNew);
 | |
|               if (groupLbl.id) {
 | |
|                 groupLbl.setAttribute('id', `${idNew}-lbl`);
 | |
|               }
 | |
|             }
 | |
|           }
 | |
|         }
 | |
|         idNew += countMulti;
 | |
|       } else if ($el.type === 'radio') {
 | |
|         // <input type="radio"> fix
 | |
|         countMulti = ids[id] ? ids[id].length : 0;
 | |
| 
 | |
|         // Set the id for fieldset and group label
 | |
|         if (!countMulti) {
 | |
|           /**
 | |
|            * Look for one of:
 | |
|            * - <fieldset class="radio"></fieldset>
 | |
|            * - <fieldset><div class="radio"></div></fieldset>
 | |
|            * - <fieldset><div class="switcher"></div></fieldset>
 | |
|            */
 | |
|           let fieldset = $el.closest('.radio, .switcher, fieldset');
 | |
|           if (fieldset) {
 | |
|             // eslint-disable-next-line no-nested-ternary
 | |
|             fieldset = fieldset.nodeName === 'FIELDSET' ? fieldset : fieldset.parentElement.nodeName === 'FIELDSET' ? fieldset.parentElement : false;
 | |
|           }
 | |
|           if (fieldset) {
 | |
|             const oldSetId = fieldset.id;
 | |
|             fieldset.id = idNew;
 | |
|             const groupLbl = row.querySelector(`label[for="${oldSetId}"]`);
 | |
|             if (groupLbl) {
 | |
|               groupLbl.setAttribute('for', idNew);
 | |
|               if (groupLbl.id) {
 | |
|                 groupLbl.setAttribute('id', `${idNew}-lbl`);
 | |
|               }
 | |
|             }
 | |
|           }
 | |
|         }
 | |
|         idNew += countMulti;
 | |
|       }
 | |
| 
 | |
|       // Cache already used id
 | |
|       if (ids[id]) {
 | |
|         ids[id].push(true);
 | |
|       } else {
 | |
|         ids[id] = [true];
 | |
|       }
 | |
| 
 | |
|       // Replace the name to new one
 | |
|       $el.name = nameNew;
 | |
|       if ($el.id) {
 | |
|         $el.id = idNew;
 | |
|       }
 | |
|       if (aria) {
 | |
|         $el.setAttribute('aria-describedby', `${nameNew}-desc`);
 | |
|       }
 | |
| 
 | |
|       // Check if there is a label for this input
 | |
|       const lbl = row.querySelector(`label[for="${forOldAttr}"]`);
 | |
|       if (lbl) {
 | |
|         lbl.setAttribute('for', idNew);
 | |
|         if (lbl.id) {
 | |
|           lbl.setAttribute('id', `${idNew}-lbl`);
 | |
|         }
 | |
|       }
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Use of HTML Drag and Drop API
 | |
|    * https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API
 | |
|    * https://www.sitepoint.com/accessible-drag-drop/
 | |
|    */
 | |
|   setUpDragSort() {
 | |
|     const that = this; // Self reference
 | |
|     let item = null; // Storing the selected item
 | |
|     let touched = false; // We have a touch events
 | |
| 
 | |
|     // Find all existing rows and add draggable attributes
 | |
|     this.getRows().forEach(row => {
 | |
|       row.setAttribute('draggable', 'false');
 | |
|       row.setAttribute('aria-grabbed', 'false');
 | |
|       row.setAttribute('tabindex', '0');
 | |
|     });
 | |
| 
 | |
|     // Helper method to test whether Handler was clicked
 | |
|     function getMoveHandler(element) {
 | |
|       return !element.form // This need to test whether the element is :input
 | |
|       && element.matches(that.buttonMove) ? element : element.closest(that.buttonMove);
 | |
|     }
 | |
| 
 | |
|     // Helper method to move row to selected position
 | |
|     function switchRowPositions(src, dest) {
 | |
|       let isRowBefore = false;
 | |
|       if (src.parentNode === dest.parentNode) {
 | |
|         for (let cur = src; cur; cur = cur.previousSibling) {
 | |
|           if (cur === dest) {
 | |
|             isRowBefore = true;
 | |
|             break;
 | |
|           }
 | |
|         }
 | |
|       }
 | |
|       if (isRowBefore) {
 | |
|         dest.parentNode.insertBefore(src, dest);
 | |
|       } else {
 | |
|         dest.parentNode.insertBefore(src, dest.nextSibling);
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      *  Touch interaction:
 | |
|      *
 | |
|      *  - a touch of "move button" marks a row draggable / "selected",
 | |
|      *     or deselect previous selected
 | |
|      *
 | |
|      *  - a touch of "move button" in the destination row will move
 | |
|      *     a selected row to a new position
 | |
|      */
 | |
|     this.addEventListener('touchstart', event => {
 | |
|       touched = true;
 | |
| 
 | |
|       // Check for .move button
 | |
|       const handler = getMoveHandler(event.target);
 | |
|       const row = handler ? handler.closest(that.repeatableElement) : null;
 | |
|       if (!row || row.closest('joomla-field-subform') !== that) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       // First selection
 | |
|       if (!item) {
 | |
|         row.setAttribute('draggable', 'true');
 | |
|         row.setAttribute('aria-grabbed', 'true');
 | |
|         item = row;
 | |
|       } else {
 | |
|         // Second selection
 | |
|         // Move to selected position
 | |
|         if (row !== item) {
 | |
|           switchRowPositions(item, row);
 | |
|         }
 | |
|         item.setAttribute('draggable', 'false');
 | |
|         item.setAttribute('aria-grabbed', 'false');
 | |
|         item = null;
 | |
|       }
 | |
|       event.preventDefault();
 | |
|     });
 | |
| 
 | |
|     // Mouse interaction
 | |
|     // - mouse down, enable "draggable" and allow to drag the row,
 | |
|     // - mouse up, disable "draggable"
 | |
|     this.addEventListener('mousedown', ({
 | |
|       target
 | |
|     }) => {
 | |
|       if (touched) return;
 | |
| 
 | |
|       // Check for .move button
 | |
|       const handler = getMoveHandler(target);
 | |
|       const row = handler ? handler.closest(that.repeatableElement) : null;
 | |
|       if (!row || row.closest('joomla-field-subform') !== that) {
 | |
|         return;
 | |
|       }
 | |
|       row.setAttribute('draggable', 'true');
 | |
|       row.setAttribute('aria-grabbed', 'true');
 | |
|       item = row;
 | |
|     });
 | |
|     this.addEventListener('mouseup', () => {
 | |
|       if (item && !touched) {
 | |
|         item.setAttribute('draggable', 'false');
 | |
|         item.setAttribute('aria-grabbed', 'false');
 | |
|         item = null;
 | |
|       }
 | |
|     });
 | |
| 
 | |
|     // Keyboard interaction
 | |
|     // - "tab" to navigate to needed row,
 | |
|     // - modifier (ctr,alt,shift) + "space" select the row,
 | |
|     // - "tab" to select destination,
 | |
|     // - "enter" to place selected row in to destination
 | |
|     // - "esc" to cancel selection
 | |
|     this.addEventListener('keydown', event => {
 | |
|       if (event.code !== KEYCODE.ESC && event.code !== KEYCODE.SPACE && event.code !== KEYCODE.ENTER || event.target.form || !event.target.matches(that.repeatableElement)) {
 | |
|         return;
 | |
|       }
 | |
|       const row = event.target;
 | |
| 
 | |
|       // Make sure we handle correct children
 | |
|       if (!row || row.closest('joomla-field-subform') !== that) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       // Space is the selection or unselection keystroke
 | |
|       if (event.code === KEYCODE.SPACE && hasModifier(event)) {
 | |
|         // Unselect previously selected
 | |
|         if (row.getAttribute('aria-grabbed') === 'true') {
 | |
|           row.setAttribute('draggable', 'false');
 | |
|           row.setAttribute('aria-grabbed', 'false');
 | |
|           item = null;
 | |
|         } else {
 | |
|           // Select new
 | |
|           // If there was previously selected
 | |
|           if (item) {
 | |
|             item.setAttribute('draggable', 'false');
 | |
|             item.setAttribute('aria-grabbed', 'false');
 | |
|             item = null;
 | |
|           }
 | |
| 
 | |
|           // Mark new selection
 | |
|           row.setAttribute('draggable', 'true');
 | |
|           row.setAttribute('aria-grabbed', 'true');
 | |
|           item = row;
 | |
|         }
 | |
| 
 | |
|         // Prevent default to suppress any native actions
 | |
|         event.preventDefault();
 | |
|       }
 | |
| 
 | |
|       // Escape is the cancel keystroke (for any target element)
 | |
|       if (event.code === KEYCODE.ESC && item) {
 | |
|         item.setAttribute('draggable', 'false');
 | |
|         item.setAttribute('aria-grabbed', 'false');
 | |
|         item = null;
 | |
|       }
 | |
| 
 | |
|       // Enter, to place selected item in selected position
 | |
|       if (event.code === KEYCODE.ENTER && item) {
 | |
|         item.setAttribute('draggable', 'false');
 | |
|         item.setAttribute('aria-grabbed', 'false');
 | |
| 
 | |
|         // Do nothing here
 | |
|         if (row === item) {
 | |
|           item = null;
 | |
|           return;
 | |
|         }
 | |
| 
 | |
|         // Move the item to selected position
 | |
|         switchRowPositions(item, row);
 | |
|         event.preventDefault();
 | |
|         item = null;
 | |
|       }
 | |
|     });
 | |
| 
 | |
|     // dragstart event to initiate mouse dragging
 | |
|     this.addEventListener('dragstart', ({
 | |
|       dataTransfer
 | |
|     }) => {
 | |
|       if (item) {
 | |
|         // We going to move the row
 | |
|         dataTransfer.effectAllowed = 'move';
 | |
| 
 | |
|         // This need to work in Firefox and IE10+
 | |
|         dataTransfer.setData('text', '');
 | |
|       }
 | |
|     });
 | |
|     this.addEventListener('dragover', event => {
 | |
|       if (item) {
 | |
|         event.preventDefault();
 | |
|       }
 | |
|     });
 | |
| 
 | |
|     // Handle drag action, move element to hovered position
 | |
|     this.addEventListener('dragenter', ({
 | |
|       target
 | |
|     }) => {
 | |
|       // Make sure the target in the correct container
 | |
|       if (!item || target.parentElement.closest('joomla-field-subform') !== that) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       // Find a hovered row
 | |
|       const row = target.closest(that.repeatableElement);
 | |
| 
 | |
|       // One more check for correct parent
 | |
|       if (!row || row.closest('joomla-field-subform') !== that) return;
 | |
|       switchRowPositions(item, row);
 | |
|     });
 | |
| 
 | |
|     // dragend event to clean-up after drop or cancelation
 | |
|     // which fires whether or not the drop target was valid
 | |
|     this.addEventListener('dragend', () => {
 | |
|       if (item) {
 | |
|         item.setAttribute('draggable', 'false');
 | |
|         item.setAttribute('aria-grabbed', 'false');
 | |
|         item = null;
 | |
|       }
 | |
|     });
 | |
| 
 | |
|     /**
 | |
|      * Move UP, Move Down sorting
 | |
|      */
 | |
|     const btnUp = `${that.buttonMove}-up`;
 | |
|     const btnDown = `${that.buttonMove}-down`;
 | |
|     this.addEventListener('click', ({
 | |
|       target
 | |
|     }) => {
 | |
|       if (target.closest('joomla-field-subform') !== this) {
 | |
|         return;
 | |
|       }
 | |
|       const btnUpEl = target.closest(btnUp);
 | |
|       const btnDownEl = !btnUpEl ? target.closest(btnDown) : null;
 | |
|       if (!btnUpEl && !btnDownEl) {
 | |
|         return;
 | |
|       }
 | |
|       let row = (btnUpEl || btnDownEl).closest(that.repeatableElement);
 | |
|       row = row && row.closest('joomla-field-subform') === this ? row : null;
 | |
|       if (!row) {
 | |
|         return;
 | |
|       }
 | |
|       const rows = this.getRows();
 | |
|       const curIdx = rows.indexOf(row);
 | |
|       let dstIdx = 0;
 | |
|       if (btnUpEl) {
 | |
|         dstIdx = curIdx - 1;
 | |
|         dstIdx = dstIdx < 0 ? rows.length - 1 : dstIdx;
 | |
|       } else {
 | |
|         dstIdx = curIdx + 1;
 | |
|         dstIdx = dstIdx > rows.length - 1 ? 0 : dstIdx;
 | |
|       }
 | |
|       switchRowPositions(row, rows[dstIdx]);
 | |
|     });
 | |
|   }
 | |
| }
 | |
| customElements.define('joomla-field-subform', JoomlaFieldSubform);
 |