Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[5.1] Subform rows sorting with buttons, addittionaly to drag and drop #3083

Closed
jgerman-bot opened this issue Jan 13, 2024 · 0 comments · Fixed by #3084
Closed

[5.1] Subform rows sorting with buttons, addittionaly to drag and drop #3083

jgerman-bot opened this issue Jan 13, 2024 · 0 comments · Fixed by #3084

Comments

@jgerman-bot
Copy link

New language relevant PR in upstream repo: joomla/joomla-cms#42334 Here are the upstream changes:

Click to expand the diff!
diff --git a/administrator/language/en-GB/joomla.ini b/administrator/language/en-GB/joomla.ini
index 4adc9b185f4e1..04a50421f9024 100644
--- a/administrator/language/en-GB/joomla.ini
+++ b/administrator/language/en-GB/joomla.ini
@@ -433,6 +433,8 @@ JGLOBAL_FIELD_MODIFIED_BY_DESC="The user who did the last modification."
 JGLOBAL_FIELD_MODIFIED_BY_LABEL="Modified By"
 JGLOBAL_FIELD_MODIFIED_LABEL="Modified Date"
 JGLOBAL_FIELD_MOVE="Move"
+JGLOBAL_FIELD_MOVE_DOWN="Move down"
+JGLOBAL_FIELD_MOVE_UP="Move up"
 JGLOBAL_FIELD_NUM_CATEGORY_ITEMS_DESC="Number of categories to display for each level."
 JGLOBAL_FIELD_NUM_CATEGORY_ITEMS_LABEL="Number of Categories"
 JGLOBAL_FIELD_PUBLISH_DOWN_DESC="An optional date to stop publishing."
diff --git a/api/language/en-GB/joomla.ini b/api/language/en-GB/joomla.ini
index fc0b6dfa82a1f..152a4a8bca4bd 100644
--- a/api/language/en-GB/joomla.ini
+++ b/api/language/en-GB/joomla.ini
@@ -427,6 +427,8 @@ JGLOBAL_FIELD_MODIFIED_BY_DESC="The user who did the last modification."
 JGLOBAL_FIELD_MODIFIED_BY_LABEL="Modified By"
 JGLOBAL_FIELD_MODIFIED_LABEL="Modified Date"
 JGLOBAL_FIELD_MOVE="Move"
+JGLOBAL_FIELD_MOVE_DOWN="Move down"
+JGLOBAL_FIELD_MOVE_UP="Move up"
 JGLOBAL_FIELD_NUM_CATEGORY_ITEMS_DESC="Number of categories to display for each level."
 JGLOBAL_FIELD_NUM_CATEGORY_ITEMS_LABEL="Number of Categories"
 JGLOBAL_FIELD_PUBLISH_DOWN_DESC="An optional date to stop publishing."
diff --git a/build/media_source/system/js/fields/joomla-field-subform.w-c.es6.js b/build/media_source/system/js/fields/joomla-field-subform.w-c.es6.js
index 12de2790a5fd0..eaafaf052aea8 100644
--- a/build/media_source/system/js/fields/joomla-field-subform.w-c.es6.js
+++ b/build/media_source/system/js/fields/joomla-field-subform.w-c.es6.js
@@ -3,587 +3,611 @@
  * @license    GNU General Public License version 2 or later; see LICENSE.txt
  */
 
-((customElements) => {
-  'use strict';
+const KEYCODE = {
+  SPACE: 'Space',
+  ESC: 'Escape',
+  ENTER: 'Enter',
+};
 
-  const KEYCODE = {
-    SPACE: 32,
-    ESC: 27,
-    ENTER: 13,
-  };
+/**
+ * Helper for testing whether a selection modifier is pressed
+ * @param {Event} event
+ *
+ * @returns {boolean|*}
+ */
+function hasModifier(event) {
+  return (event.ctrlKey || event.metaKey || event.shiftKey);
+}
 
-  /**
-   * 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'); }
 
-  class JoomlaFieldSubform extends HTMLElement {
-    // Attribute getters
-    get buttonAdd() { return this.getAttribute('button-add'); }
+  get buttonRemove() { return this.getAttribute('button-remove'); }
 
-    get buttonRemove() { return this.getAttribute('button-remove'); }
+  get buttonMove() { return this.getAttribute('button-move'); }
 
-    get buttonMove() { return this.getAttribute('button-move'); }
+  get rowsContainer() { return this.getAttribute('rows-container'); }
 
-    get rowsContainer() { return this.getAttribute('rows-container'); }
+  get repeatableElement() { return this.getAttribute('repeatable-element'); }
 
-    get repeatableElement() { return this.getAttribute('repeatable-element'); }
+  get minimum() { return this.getAttribute('minimum'); }
 
-    get minimum() { return this.getAttribute('minimum'); }
+  get maximum() { return this.getAttribute('maximum'); }
 
-    get maximum() { return this.getAttribute('maximum'); }
+  get name() { return this.getAttribute('name'); }
 
-    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}`);
 
-    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);
+  }
 
-      this.setAttribute('name', value);
-    }
+  constructor() {
+    super();
 
-    constructor() {
-      super();
+    const that = this;
 
-      const that = this;
+    // Get the rows container
+    this.containerWithRows = this;
 
-      // Get the rows container
-      this.containerWithRows = this;
+    if (this.rowsContainer) {
+      const allContainers = this.querySelectorAll(this.rowsContainer);
 
-      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;
+        }
+      });
+    }
 
-        // 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;
 
-      // 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 = '';
 
-      // Template for the repeating group
-      this.template = '';
+    // Prepare a row template, and find available field names
+    this.prepareTemplate();
 
-      // 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;
 
-      // 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.buttonAdd) {
-            btnAdd = event.target.matches(that.buttonAdd)
-              ? event.target
-              : event.target.closest(that.buttonAdd);
-          }
+        if (that.buttonRemove) {
+          btnRem = event.target.closest(that.buttonRemove);
+        }
 
-          if (that.buttonRemove) {
-            btnRem = event.target.matches(that.buttonRemove)
-              ? event.target
-              : 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();
+        }
+      });
 
-          // 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.keyCode !== 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();
-          }
-        });
-      }
+      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);
 
-      // Sorting
-      if (this.buttonMove) {
-        this.setUpDragSort();
-      }
-    }
+        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;
 
-    /**
-     * 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);
+          if (isRem && row) {
+            that.removeRow(row);
+          } else if (isAdd) {
+            that.addRow(row);
+          }
+          event.preventDefault();
         }
       });
-
-      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');
-      }
+    // Sorting
+    if (this.buttonMove) {
+      this.setUpDragSort();
     }
+  }
 
-    /**
-     * 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;
+  /**
+   * 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);
       }
+    });
 
-      // 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];
+    return result;
+  }
 
-      // Add to container
-      if (after) {
-        after.parentNode.insertBefore(row, after.nextSibling);
-      } else {
-        this.containerWithRows.append(row);
-      }
+  /**
+   * Prepare a row template
+   */
+  prepareTemplate() {
+    const tmplElement = [].slice.call(this.children).filter((el) => el.classList.contains('subform-repeatable-template-section'));
 
-      // Add draggable attributes
-      if (this.buttonMove) {
-        row.setAttribute('draggable', 'false');
-        row.setAttribute('aria-grabbed', 'false');
-        row.setAttribute('tabindex', '0');
-      }
+    if (tmplElement[0]) {
+      this.template = tmplElement[0].innerHTML;
+    }
 
-      // Marker that it is new
-      row.setAttribute('data-new', '1');
-      // Fix names and ids, and reset values
-      this.fixUniqueAttributes(row, count);
+    if (!this.template) {
+      throw new Error('The row template is required for the subform element to work');
+    }
+  }
 
-      // Tell about the new row
-      this.dispatchEvent(new CustomEvent('subform-row-add', {
-        detail: { row },
-        bubbles: true,
-      }));
+  /**
+   * 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;
+    }
 
-      row.dispatchEvent(new CustomEvent('joomla:updated', {
-        bubbles: true,
-        cancelable: true,
-      }));
+    // 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);
+    }
 
-      return row;
+    // Add draggable attributes
+    if (this.buttonMove) {
+      row.setAttribute('draggable', 'false');
+      row.setAttribute('aria-grabbed', 'false');
+      row.setAttribute('tabindex', '0');
     }
 
-    /**
-     * Remove the row
-     * @param {HTMLElement} row
-     */
-    removeRow(row) {
-      // Count how much we have
-      const count = this.getRows().length;
-      if (count <= this.minimum) {
-        return;
-      }
+    // Marker that it is new
+    row.setAttribute('data-new', '1');
+    // Fix names and ids, and reset values
+    this.fixUniqueAttributes(row, count);
 
-      // Tell about the row will be removed
-      this.dispatchEvent(new CustomEvent('subform-row-remove', {
-        detail: { row },
-        bubbles: true,
-      }));
+    // Tell about the new row
+    this.dispatchEvent(new CustomEvent('subform-row-add', {
+      detail: { row },
+      bubbles: true,
+    }));
 
-      row.dispatchEvent(new CustomEvent('joomla:removed', {
-        bubbles: true,
-        cancelable: true,
-      }));
+    row.dispatchEvent(new CustomEvent('joomla:updated', {
+      bubbles: true,
+      cancelable: true,
+    }));
 
-      row.parentNode.removeChild(row);
+    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;
     }
 
-    /**
-     * 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;
-        }
+    // Tell about the row will be removed
+    this.dispatchEvent(new CustomEvent('subform-row-remove', {
+      detail: { row },
+      bubbles: true,
+    }));
 
-        return el.closest('joomla-field-subform') === this;
-      });
+    row.dispatchEvent(new CustomEvent('joomla:removed', {
+      bubbles: true,
+      cancelable: true,
+    }));
 
-      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
-        let forOldAttr = id; // Fix "for" in the labels
-
-        if ($el.type === 'checkbox' && name.match(/\[\]$/)) { // <input type="checkbox" name="name[]"> fix
-          // Recount id
-          countMulti = ids[id] ? ids[id].length : 0;
-          if (!countMulti) {
-            // Set the id for fieldset and group label
-            const fieldset = $el.closest('fieldset.checkboxes');
-
-            const elLbl = row.querySelector(`label[for="${id}"]`);
-
-            if (fieldset) {
-              fieldset.setAttribute('id', idNew);
-            }
-
-            if (elLbl) {
-              elLbl.setAttribute('for', idNew);
-              elLbl.setAttribute('id', `${idNew}-lbl`);
-            }
-          }
-          forOldAttr += countMulti;
-          idNew += countMulti;
-        } else if ($el.type === 'radio') { // <input type="radio"> fix
-          // Recount id
-          countMulti = ids[id] ? ids[id].length : 0;
-          if (!countMulti) {
-            // Set the id for fieldset and group label
-            const fieldset = $el.closest('fieldset.radio');
-
-            const elLbl = row.querySelector(`label[for="${id}"]`);
-
-            if (fieldset) {
-              fieldset.setAttribute('id', idNew);
-            }
-
-            if (elLbl) {
-              elLbl.setAttribute('for', idNew);
-              elLbl.setAttribute('id', `${idNew}-lbl`);
-            }
+    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
+      let forOldAttr = id; // Fix "for" in the labels
+
+      if ($el.type === 'checkbox' && name.match(/\[\]$/)) { // <input type="checkbox" name="name[]"> fix
+        // Recount id
+        countMulti = ids[id] ? ids[id].length : 0;
+        if (!countMulti) {
+          // Set the id for fieldset and group label
+          const fieldset = $el.closest('fieldset.checkboxes');
+
+          const elLbl = row.querySelector(`label[for="${id}"]`);
+
+          if (fieldset) {
+            fieldset.setAttribute('id', idNew);
           }
-          forOldAttr += countMulti;
-          idNew += countMulti;
-        }
 
-        // Cache already used id
-        if (ids[id]) {
-          ids[id].push(true);
-        } else {
-          ids[id] = [true];
+          if (elLbl) {
+            elLbl.setAttribute('for', idNew);
+            elLbl.setAttribute('id', `${idNew}-lbl`);
+          }
         }
+        forOldAttr += countMulti;
+        idNew += countMulti;
+      } else if ($el.type === 'radio') { // <input type="radio"> fix
+        // Recount id
+        countMulti = ids[id] ? ids[id].length : 0;
+        if (!countMulti) {
+          // Set the id for fieldset and group label
+          const fieldset = $el.closest('fieldset.radio');
+
+          const elLbl = row.querySelector(`label[for="${id}"]`);
+
+          if (fieldset) {
+            fieldset.setAttribute('id', idNew);
+          }
 
-        // Replace the name to new one
-        $el.name = nameNew;
-        if ($el.id) {
-          $el.id = idNew;
+          if (elLbl) {
+            elLbl.setAttribute('for', idNew);
+            elLbl.setAttribute('id', `${idNew}-lbl`);
+          }
         }
+        forOldAttr += countMulti;
+        idNew += countMulti;
+      }
 
-        if (aria) {
-          $el.setAttribute('aria-describedby', `${nameNew}-desc`);
-        }
+      // Cache already used id
+      if (ids[id]) {
+        ids[id].push(true);
+      } else {
+        ids[id] = [true];
+      }
 
-        // Check if there is a label for this input
-        const lbl = row.querySelector(`label[for="${forOldAttr}"]`);
-        if (lbl) {
-          lbl.setAttribute('for', idNew);
-          lbl.setAttribute('id', `${idNew}-lbl`);
-        }
-      });
-    }
+      // Replace the name to new one
+      $el.name = nameNew;
+      if ($el.id) {
+        $el.id = idNew;
+      }
 
-    /**
-     * 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
-      const rows = Array.from(this.getRows());
-
-      rows.forEach((row) => {
-        row.setAttribute('draggable', 'false');
-        row.setAttribute('aria-grabbed', 'false');
-        row.setAttribute('tabindex', '0');
-      });
+      if (aria) {
+        $el.setAttribute('aria-describedby', `${nameNew}-desc`);
+      }
 
-      // 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);
+      // Check if there is a label for this input
+      const lbl = row.querySelector(`label[for="${forOldAttr}"]`);
+      if (lbl) {
+        lbl.setAttribute('for', idNew);
+        lbl.setAttribute('id', `${idNew}-lbl`);
       }
+    });
+  }
 
-      // 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;
-            }
-          }
-        }
+  /**
+   * 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);
+    }
 
-        if (isRowBefore) {
-          dest.parentNode.insertBefore(src, dest);
-        } else {
-          dest.parentNode.insertBefore(src, dest.nextSibling);
+    // 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;
+          }
         }
       }
 
-      /**
-       *  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;
+      if (isRowBefore) {
+        dest.parentNode.insertBefore(src, dest);
+      } else {
+        dest.parentNode.insertBefore(src, dest.nextSibling);
+      }
+    }
 
-        // Check for .move button
-        const handler = getMoveHandler(event.target);
+    /**
+     *  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;
 
-        const row = handler ? handler.closest(that.repeatableElement) : null;
+      // Check for .move button
+      const handler = getMoveHandler(event.target);
 
-        if (!row || row.closest('joomla-field-subform') !== that) {
-          return;
-        }
+      const row = handler ? handler.closest(that.repeatableElement) : null;
 
-        // 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);
-          }
+      if (!row || row.closest('joomla-field-subform') !== that) {
+        return;
+      }
 
-          item.setAttribute('draggable', 'false');
-          item.setAttribute('aria-grabbed', 'false');
-          item = null;
+      // 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);
         }
 
-        event.preventDefault();
-      });
+        item.setAttribute('draggable', 'false');
+        item.setAttribute('aria-grabbed', 'false');
+        item = null;
+      }
 
-      // Mouse interaction
-      // - mouse down, enable "draggable" and allow to drag the row,
-      // - mouse up, disable "draggable"
-      this.addEventListener('mousedown', ({ target }) => {
-        if (touched) return;
+      event.preventDefault();
+    });
 
-        // Check for .move button
-        const handler = getMoveHandler(target);
+    // Mouse interaction
+    // - mouse down, enable "draggable" and allow to drag the row,
+    // - mouse up, disable "draggable"
+    this.addEventListener('mousedown', ({ target }) => {
+      if (touched) return;
 
-        const row = handler ? handler.closest(that.repeatableElement) : null;
+      // Check for .move button
+      const handler = getMoveHandler(target);
 
-        if (!row || row.closest('joomla-field-subform') !== that) {
-          return;
-        }
+      const row = handler ? handler.closest(that.repeatableElement) : null;
 
-        row.setAttribute('draggable', 'true');
-        row.setAttribute('aria-grabbed', 'true');
-        item = row;
-      });
+      if (!row || row.closest('joomla-field-subform') !== that) {
+        return;
+      }
 
-      this.addEventListener('mouseup', () => {
-        if (item && !touched) {
-          item.setAttribute('draggable', 'false');
-          item.setAttribute('aria-grabbed', 'false');
-          item = null;
-        }
-      });
+      row.setAttribute('draggable', 'true');
+      row.setAttribute('aria-grabbed', 'true');
+      item = row;
+    });
 
-      // 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.keyCode !== KEYCODE.ESC
-          && event.keyCode !== KEYCODE.SPACE
-          && event.keyCode !== KEYCODE.ENTER) || event.target.form
-          || !event.target.matches(that.repeatableElement)) {
-          return;
-        }
+    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;
+      const row = event.target;
 
-        // Make sure we handle correct children
-        if (!row || row.closest('joomla-field-subform') !== that) {
-          return;
-        }
+      // Make sure we handle correct children
+      if (!row || row.closest('joomla-field-subform') !== that) {
+        return;
+      }
 
-        // Space is the selection or unselection keystroke
-        if (event.keyCode === KEYCODE.SPACE && hasModifier(event)) {
-          // Unselect previously selected
-          if (row.getAttribute('aria-grabbed') === 'true') {
-            row.setAttribute('draggable', 'false');
-            row.setAttribute('aria-grabbed', 'false');
+      // 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;
-          } 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();
+          // Mark new selection
+          row.setAttribute('draggable', 'true');
+          row.setAttribute('aria-grabbed', 'true');
+          item = row;
         }
 
-        // Escape is the cancel keystroke (for any target element)
-        if (event.keyCode === KEYCODE.ESC && item) {
-          item.setAttribute('draggable', 'false');
-          item.setAttribute('aria-grabbed', 'false');
+        // 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;
         }
 
-        // Enter, to place selected item in selected position
-        if (event.keyCode === KEYCODE.ENTER && item) {
-          item.setAttribute('draggable', 'false');
-          item.setAttribute('aria-grabbed', 'false');
+        // Move the item to selected position
+        switchRowPositions(item, row);
 
-          // Do nothing here
-          if (row === item) {
-            item = null;
-            return;
-          }
+        event.preventDefault();
+        item = null;
+      }
+    });
 
-          // Move the item to selected position
-          switchRowPositions(item, row);
+    // dragstart event to initiate mouse dragging
+    this.addEventListener('dragstart', ({ dataTransfer }) => {
+      if (item) {
+        // We going to move the row
+        dataTransfer.effectAllowed = 'move';
 
-          event.preventDefault();
-          item = null;
-        }
-      });
+        // This need to work in Firefox and IE10+
+        dataTransfer.setData('text', '');
+      }
+    });
 
-      // dragstart event to initiate mouse dragging
-      this.addEventListener('dragstart', ({ dataTransfer }) => {
-        if (item) {
-          // We going to move the row
-          dataTransfer.effectAllowed = 'move';
+    this.addEventListener('dragover', (event) => {
+      if (item) {
+        event.preventDefault();
+      }
+    });
 
-          // This need to work in Firefox and IE10+
-          dataTransfer.setData('text', '');
-        }
-      });
+    // 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;
+      }
 
-      this.addEventListener('dragover', (event) => {
-        if (item) {
-          event.preventDefault();
-        }
-      });
+      // Find a hovered row
+      const row = target.closest(that.repeatableElement);
 
-      // 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;
-        }
+      // One more check for correct parent
+      if (!row || row.closest('joomla-field-subform') !== that) return;
 
-        // Find a hovered row
-        const row = target.closest(that.repeatableElement);
+      switchRowPositions(item, row);
+    });
 
-        // One more check for correct parent
-        if (!row || row.closest('joomla-field-subform') !== that) return;
+    // 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;
+      }
+    });
 
-        switchRowPositions(item, row);
-      });
+    /**
+     * 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;
 
-      // 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;
-        }
-      });
-    }
+      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);
-})(customElements);
+customElements.define('joomla-field-subform', JoomlaFieldSubform);
diff --git a/build/media_source/templates/administrator/atum/scss/blocks/_form.scss b/build/media_source/templates/administrator/atum/scss/blocks/_form.scss
index 562d7757bdcc2..b86634cf04f2a 100644
--- a/build/media_source/templates/administrator/atum/scss/blocks/_form.scss
+++ b/build/media_source/templates/administrator/atum/scss/blocks/_form.scss
@@ -194,13 +194,27 @@ div.subform-repeatable-group {
         top: 50%;
         right: 100%;
         padding: 0;
-        margin-top: -27px;
         border-radius: $border-radius 0 0 $border-radius;
+        transform: translateY(-50%);
 
         span {
           padding: 1.5rem .5rem;
         }
       }
+      &.group-move-up {
+        top: 50%;
+        right: 100%;
+        margin-top: -45px;
+        border-radius: 0;
+        transform: translateY(-50%);
+      }
+      &.group-move-down {
+        top: 50%;
+        right: 100%;
+        margin-top: 45px;
+        border-radius: 0;
+        transform: translateY(-50%);
+      }
     }
   }
 }
diff --git a/build/media_source/templates/site/cassiopeia/scss/blocks/_form.scss b/build/media_source/templates/site/cassiopeia/scss/blocks/_form.scss
index c204209c31423..438cb3f7a2c16 100644
--- a/build/media_source/templates/site/cassiopeia/scss/blocks/_form.scss
+++ b/build/media_source/templates/site/cassiopeia/scss/blocks/_form.scss
@@ -148,3 +148,76 @@ fieldset {
     }
   }
 }
+
+// Subform - non table layout
+div.subform-repeatable-group {
+  position: relative;
+  padding: 32px 32px 16px 28px;
+  margin-top: 20px;
+  margin-left: 32px;
+  border: $input-border-width solid $input-border-color;
+  @include border-radius($border-radius);
+
+  > .control-group {
+    margin-top: 0;
+  }
+
+  > .btn-toolbar {
+
+    .btn-group {
+      position: static;
+      margin: 0;
+    }
+
+    .btn {
+      position: absolute;
+
+      &.group-add {
+        right: -1px;
+        bottom: -1px;
+        border-radius: $border-radius 0 $border-radius 0;
+      }
+      &.group-remove {
+        top: -1px;
+        right: -1px;
+        border-radius: 0 $border-radius 0 $border-radius;
+      }
+      &.group-move {
+        top: 50%;
+        right: 100%;
+        padding: 0;
+        border-radius: $border-radius 0 0 $border-radius;
+        transform: translateY(-50%);
+
+        span {
+          padding: 1.5rem .5rem;
+        }
+      }
+      &.group-move-up {
+        top: 50%;
+        right: 100%;
+        margin-top: -45px;
+        border-radius: 0;
+        transform: translateY(-50%);
+      }
+      &.group-move-down {
+        top: 50%;
+        right: 100%;
+        margin-top: 45px;
+        border-radius: 0;
+        transform: translateY(-50%);
+      }
+    }
+  }
+}
+
+// Highlight draggable section
+.subform-repeatable-group[draggable="true"] {
+  // For non table layout
+  background-color: $teal;
+
+  // For table layout
+  > td {
+    background-color: $teal;
+  }
+}
diff --git a/layouts/joomla/form/field/subform/repeatable/section-byfieldsets.php b/layouts/joomla/form/field/subform/repeatable/section-byfieldsets.php
index 4710dc0b04540..4713233ba9b60 100644
--- a/layouts/joomla/form/field/subform/repeatable/section-byfieldsets.php
+++ b/layouts/joomla/form/field/subform/repeatable/section-byfieldsets.php
@@ -35,9 +35,11 @@
             <?php if (!empty($buttons['remove'])) :
                 ?><button type="button" class="group-remove btn btn-sm btn-danger" aria-label="<?php echo Text::_('JGLOBAL_FIELD_REMOVE'); ?>"><span class="icon-minus icon-white" aria-hidden="true"></span> </button><?php
             endif; ?>
-            <?php if (!empty($buttons['move'])) :
-                ?><button type="button" class="group-move btn btn-sm btn-primary" aria-label="<?php echo Text::_('JGLOBAL_FIELD_MOVE'); ?>"><span class="icon-arrows-alt icon-white" aria-hidden="true"></span> </button><?php
-            endif; ?>
+            <?php if (!empty($buttons['move'])) : ?>
+                <button type="button" class="group-move btn btn-sm btn-primary" aria-label="<?php echo Text::_('JGLOBAL_FIELD_MOVE'); ?>"><span class="icon-arrows-alt icon-white" aria-hidden="true"></span> </button>
+                <button type="button" class="group-move-up btn btn-sm" aria-label="<?php echo Text::_('JGLOBAL_FIELD_MOVE_UP'); ?>"><span class="icon-chevron-up" aria-hidden="true"></span> </button>
+                <button type="button" class="group-move-down btn btn-sm" aria-label="<?php echo Text::_('JGLOBAL_FIELD_MOVE_DOWN'); ?>"><span class="icon-chevron-down" aria-hidden="true"></span> </button>
+            <?php endif; ?>
         </div>
     </div>
     <?php endif; ?>
diff --git a/layouts/joomla/form/field/subform/repeatable/section.php b/layouts/joomla/form/field/subform/repeatable/section.php
index 7eb4826223d2c..72552d4dedbd5 100644
--- a/layouts/joomla/form/field/subform/repeatable/section.php
+++ b/layouts/joomla/form/field/subform/repeatable/section.php
@@ -35,9 +35,11 @@
             <?php if (!empty($buttons['remove'])) :
                 ?><button type="button" class="group-remove btn btn-sm btn-danger" aria-label="<?php echo Text::_('JGLOBAL_FIELD_REMOVE'); ?>"><span class="icon-minus icon-white" aria-hidden="true"></span> </button><?php
             endif; ?>
-            <?php if (!empty($buttons['move'])) :
-                ?><button type="button" class="group-move btn btn-sm btn-primary" aria-label="<?php echo Text::_('JGLOBAL_FIELD_MOVE'); ?>"><span class="icon-arrows-alt icon-white" aria-hidden="true"></span> </button><?php
-            endif; ?>
+            <?php if (!empty($buttons['move'])) : ?>
+                <button type="button" class="group-move btn btn-sm btn-primary" aria-label="<?php echo Text::_('JGLOBAL_FIELD_MOVE'); ?>"><span class="icon-arrows-alt icon-white" aria-hidden="true"></span> </button>
+                <button type="button" class="group-move-up btn btn-sm" aria-label="<?php echo Text::_('JGLOBAL_FIELD_MOVE_UP'); ?>"><span class="icon-chevron-up" aria-hidden="true"></span> </button>
+                <button type="button" class="group-move-down btn btn-sm" aria-label="<?php echo Text::_('JGLOBAL_FIELD_MOVE_DOWN'); ?>"><span class="icon-chevron-down" aria-hidden="true"></span> </button>
+            <?php endif; ?>
         </div>
     </div>
     <?php endif; ?>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Development

Successfully merging a pull request may close this issue.

4 participants