[ Index ]

PHP Cross Reference of Joomla 4.2.2 documentation

title

Body

[close]

/media/system/js/fields/ -> joomla-field-subform.js (source)

   1  /**
   2   * @copyright  (C) 2019 Open Source Matters, Inc. <https://www.joomla.org>
   3   * @license    GNU General Public License version 2 or later; see LICENSE.txt
   4   */
   5  (customElements => {
   6  
   7    const KEYCODE = {
   8      SPACE: 32,
   9      ESC: 27,
  10      ENTER: 13
  11    };
  12    /**
  13     * Helper for testing whether a selection modifier is pressed
  14     * @param {Event} event
  15     *
  16     * @returns {boolean|*}
  17     */
  18  
  19    function hasModifier(event) {
  20      return event.ctrlKey || event.metaKey || event.shiftKey;
  21    }
  22  
  23    class JoomlaFieldSubform extends HTMLElement {
  24      // Attribute getters
  25      get buttonAdd() {
  26        return this.getAttribute('button-add');
  27      }
  28  
  29      get buttonRemove() {
  30        return this.getAttribute('button-remove');
  31      }
  32  
  33      get buttonMove() {
  34        return this.getAttribute('button-move');
  35      }
  36  
  37      get rowsContainer() {
  38        return this.getAttribute('rows-container');
  39      }
  40  
  41      get repeatableElement() {
  42        return this.getAttribute('repeatable-element');
  43      }
  44  
  45      get minimum() {
  46        return this.getAttribute('minimum');
  47      }
  48  
  49      get maximum() {
  50        return this.getAttribute('maximum');
  51      }
  52  
  53      get name() {
  54        return this.getAttribute('name');
  55      }
  56  
  57      set name(value) {
  58        // Update the template
  59        this.template = this.template.replace(new RegExp(` name="$this.name.replace(/[[\]]/g, '\\$&')}`, 'g'), ` name="$value}`);
  60        this.setAttribute('name', value);
  61      }
  62  
  63      constructor() {
  64        super();
  65        const that = this; // Get the rows container
  66  
  67        this.containerWithRows = this;
  68  
  69        if (this.rowsContainer) {
  70          const allContainers = this.querySelectorAll(this.rowsContainer); // Find closest, and exclude nested
  71  
  72          Array.from(allContainers).forEach(container => {
  73            if (container.closest('joomla-field-subform') === this) {
  74              this.containerWithRows = container;
  75            }
  76          });
  77        } // Keep track of row index, this is important to avoid a name duplication
  78        // Note: php side should reset the indexes each time, eg: $value = array_values($value);
  79  
  80  
  81        this.lastRowIndex = this.getRows().length - 1; // Template for the repeating group
  82  
  83        this.template = ''; // Prepare a row template, and find available field names
  84  
  85        this.prepareTemplate(); // Bind buttons
  86  
  87        if (this.buttonAdd || this.buttonRemove) {
  88          this.addEventListener('click', event => {
  89            let btnAdd = null;
  90            let btnRem = null;
  91  
  92            if (that.buttonAdd) {
  93              btnAdd = event.target.matches(that.buttonAdd) ? event.target : event.target.closest(that.buttonAdd);
  94            }
  95  
  96            if (that.buttonRemove) {
  97              btnRem = event.target.matches(that.buttonRemove) ? event.target : event.target.closest(that.buttonRemove);
  98            } // Check active, with extra check for nested joomla-field-subform
  99  
 100  
 101            if (btnAdd && btnAdd.closest('joomla-field-subform') === that) {
 102              let row = btnAdd.closest(that.repeatableElement);
 103              row = row && row.closest('joomla-field-subform') === that ? row : null;
 104              that.addRow(row);
 105              event.preventDefault();
 106            } else if (btnRem && btnRem.closest('joomla-field-subform') === that) {
 107              const row = btnRem.closest(that.repeatableElement);
 108              that.removeRow(row);
 109              event.preventDefault();
 110            }
 111          });
 112          this.addEventListener('keydown', event => {
 113            if (event.keyCode !== KEYCODE.SPACE) return;
 114            const isAdd = that.buttonAdd && event.target.matches(that.buttonAdd);
 115            const isRem = that.buttonRemove && event.target.matches(that.buttonRemove);
 116  
 117            if ((isAdd || isRem) && event.target.closest('joomla-field-subform') === that) {
 118              let row = event.target.closest(that.repeatableElement);
 119              row = row && row.closest('joomla-field-subform') === that ? row : null;
 120  
 121              if (isRem && row) {
 122                that.removeRow(row);
 123              } else if (isAdd) {
 124                that.addRow(row);
 125              }
 126  
 127              event.preventDefault();
 128            }
 129          });
 130        } // Sorting
 131  
 132  
 133        if (this.buttonMove) {
 134          this.setUpDragSort();
 135        }
 136      }
 137      /**
 138       * Search for existing rows
 139       * @returns {HTMLElement[]}
 140       */
 141  
 142  
 143      getRows() {
 144        const rows = Array.from(this.containerWithRows.children);
 145        const result = []; // Filter out the rows
 146  
 147        rows.forEach(row => {
 148          if (row.matches(this.repeatableElement)) {
 149            result.push(row);
 150          }
 151        });
 152        return result;
 153      }
 154      /**
 155       * Prepare a row template
 156       */
 157  
 158  
 159      prepareTemplate() {
 160        const tmplElement = [].slice.call(this.children).filter(el => el.classList.contains('subform-repeatable-template-section'));
 161  
 162        if (tmplElement[0]) {
 163          this.template = tmplElement[0].innerHTML;
 164        }
 165  
 166        if (!this.template) {
 167          throw new Error('The row template is required for the subform element to work');
 168        }
 169      }
 170      /**
 171       * Add new row
 172       * @param {HTMLElement} after
 173       * @returns {HTMLElement}
 174       */
 175  
 176  
 177      addRow(after) {
 178        // Count how many we already have
 179        const count = this.getRows().length;
 180  
 181        if (count >= this.maximum) {
 182          return null;
 183        } // Make a new row from the template
 184  
 185  
 186        let tmpEl;
 187  
 188        if (this.containerWithRows.nodeName === 'TBODY' || this.containerWithRows.nodeName === 'TABLE') {
 189          tmpEl = document.createElement('tbody');
 190        } else {
 191          tmpEl = document.createElement('div');
 192        }
 193  
 194        tmpEl.innerHTML = this.template;
 195        const row = tmpEl.children[0]; // Add to container
 196  
 197        if (after) {
 198          after.parentNode.insertBefore(row, after.nextSibling);
 199        } else {
 200          this.containerWithRows.append(row);
 201        } // Add draggable attributes
 202  
 203  
 204        if (this.buttonMove) {
 205          row.setAttribute('draggable', 'false');
 206          row.setAttribute('aria-grabbed', 'false');
 207          row.setAttribute('tabindex', '0');
 208        } // Marker that it is new
 209  
 210  
 211        row.setAttribute('data-new', '1'); // Fix names and ids, and reset values
 212  
 213        this.fixUniqueAttributes(row, count); // Tell about the new row
 214  
 215        this.dispatchEvent(new CustomEvent('subform-row-add', {
 216          detail: {
 217            row
 218          },
 219          bubbles: true
 220        }));
 221        row.dispatchEvent(new CustomEvent('joomla:updated', {
 222          bubbles: true,
 223          cancelable: true
 224        }));
 225        return row;
 226      }
 227      /**
 228       * Remove the row
 229       * @param {HTMLElement} row
 230       */
 231  
 232  
 233      removeRow(row) {
 234        // Count how much we have
 235        const count = this.getRows().length;
 236  
 237        if (count <= this.minimum) {
 238          return;
 239        } // Tell about the row will be removed
 240  
 241  
 242        this.dispatchEvent(new CustomEvent('subform-row-remove', {
 243          detail: {
 244            row
 245          },
 246          bubbles: true
 247        }));
 248        row.dispatchEvent(new CustomEvent('joomla:removed', {
 249          bubbles: true,
 250          cancelable: true
 251        }));
 252        row.parentNode.removeChild(row);
 253      }
 254      /**
 255       * Fix name and id for fields that are in the row
 256       * @param {HTMLElement} row
 257       * @param {Number} count
 258       */
 259  
 260  
 261      fixUniqueAttributes(row, count) {
 262        const countTmp = count || 0;
 263        const group = row.getAttribute('data-group'); // current group name
 264  
 265        const basename = row.getAttribute('data-base-name');
 266        const countnew = Math.max(this.lastRowIndex, countTmp);
 267        const groupnew = basename + countnew; // new group name
 268  
 269        this.lastRowIndex = countnew + 1;
 270        row.setAttribute('data-group', groupnew); // Fix inputs that have a "name" attribute
 271  
 272        let haveName = row.querySelectorAll('[name]');
 273        const ids = {}; // Collect id for fix checkboxes and radio
 274        // Filter out nested
 275  
 276        haveName = [].slice.call(haveName).filter(el => {
 277          if (el.nodeName === 'JOOMLA-FIELD-SUBFORM') {
 278            // Skip self in .closest() call
 279            return el.parentElement.closest('joomla-field-subform') === this;
 280          }
 281  
 282          return el.closest('joomla-field-subform') === this;
 283        });
 284        haveName.forEach(elem => {
 285          const $el = elem;
 286          const name = $el.getAttribute('name');
 287          const aria = $el.getAttribute('aria-describedby');
 288          const id = name.replace(/(\[\]$)/g, '').replace(/(\]\[)/g, '__').replace(/\[/g, '_').replace(/\]/g, ''); // id from name
 289  
 290          const nameNew = name.replace(`[$group}][`, `[$groupnew}][`); // New name
 291  
 292          let idNew = id.replace(group, groupnew).replace(/\W/g, '_'); // Count new id
 293  
 294          let countMulti = 0; // count for multiple radio/checkboxes
 295  
 296          let forOldAttr = id; // Fix "for" in the labels
 297  
 298          if ($el.type === 'checkbox' && name.match(/\[\]$/)) {
 299            // <input type="checkbox" name="name[]"> fix
 300            // Recount id
 301            countMulti = ids[id] ? ids[id].length : 0;
 302  
 303            if (!countMulti) {
 304              // Set the id for fieldset and group label
 305              const fieldset = $el.closest('fieldset.checkboxes');
 306              const elLbl = row.querySelector(`label[for="$id}"]`);
 307  
 308              if (fieldset) {
 309                fieldset.setAttribute('id', idNew);
 310              }
 311  
 312              if (elLbl) {
 313                elLbl.setAttribute('for', idNew);
 314                elLbl.setAttribute('id', `$idNew}-lbl`);
 315              }
 316            }
 317  
 318            forOldAttr += countMulti;
 319            idNew += countMulti;
 320          } else if ($el.type === 'radio') {
 321            // <input type="radio"> fix
 322            // Recount id
 323            countMulti = ids[id] ? ids[id].length : 0;
 324  
 325            if (!countMulti) {
 326              // Set the id for fieldset and group label
 327              const fieldset = $el.closest('fieldset.radio');
 328              const elLbl = row.querySelector(`label[for="$id}"]`);
 329  
 330              if (fieldset) {
 331                fieldset.setAttribute('id', idNew);
 332              }
 333  
 334              if (elLbl) {
 335                elLbl.setAttribute('for', idNew);
 336                elLbl.setAttribute('id', `$idNew}-lbl`);
 337              }
 338            }
 339  
 340            forOldAttr += countMulti;
 341            idNew += countMulti;
 342          } // Cache already used id
 343  
 344  
 345          if (ids[id]) {
 346            ids[id].push(true);
 347          } else {
 348            ids[id] = [true];
 349          } // Replace the name to new one
 350  
 351  
 352          $el.name = nameNew;
 353  
 354          if ($el.id) {
 355            $el.id = idNew;
 356          }
 357  
 358          if (aria) {
 359            $el.setAttribute('aria-describedby', `$nameNew}-desc`);
 360          } // Check if there is a label for this input
 361  
 362  
 363          const lbl = row.querySelector(`label[for="$forOldAttr}"]`);
 364  
 365          if (lbl) {
 366            lbl.setAttribute('for', idNew);
 367            lbl.setAttribute('id', `$idNew}-lbl`);
 368          }
 369        });
 370      }
 371      /**
 372       * Use of HTML Drag and Drop API
 373       * https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API
 374       * https://www.sitepoint.com/accessible-drag-drop/
 375       */
 376  
 377  
 378      setUpDragSort() {
 379        const that = this; // Self reference
 380  
 381        let item = null; // Storing the selected item
 382  
 383        let touched = false; // We have a touch events
 384        // Find all existing rows and add draggable attributes
 385  
 386        const rows = Array.from(this.getRows());
 387        rows.forEach(row => {
 388          row.setAttribute('draggable', 'false');
 389          row.setAttribute('aria-grabbed', 'false');
 390          row.setAttribute('tabindex', '0');
 391        }); // Helper method to test whether Handler was clicked
 392  
 393        function getMoveHandler(element) {
 394          return !element.form // This need to test whether the element is :input
 395          && element.matches(that.buttonMove) ? element : element.closest(that.buttonMove);
 396        } // Helper method to move row to selected position
 397  
 398  
 399        function switchRowPositions(src, dest) {
 400          let isRowBefore = false;
 401  
 402          if (src.parentNode === dest.parentNode) {
 403            for (let cur = src; cur; cur = cur.previousSibling) {
 404              if (cur === dest) {
 405                isRowBefore = true;
 406                break;
 407              }
 408            }
 409          }
 410  
 411          if (isRowBefore) {
 412            dest.parentNode.insertBefore(src, dest);
 413          } else {
 414            dest.parentNode.insertBefore(src, dest.nextSibling);
 415          }
 416        }
 417        /**
 418         *  Touch interaction:
 419         *
 420         *  - a touch of "move button" marks a row draggable / "selected",
 421         *     or deselect previous selected
 422         *
 423         *  - a touch of "move button" in the destination row will move
 424         *     a selected row to a new position
 425         */
 426  
 427  
 428        this.addEventListener('touchstart', event => {
 429          touched = true; // Check for .move button
 430  
 431          const handler = getMoveHandler(event.target);
 432          const row = handler ? handler.closest(that.repeatableElement) : null;
 433  
 434          if (!row || row.closest('joomla-field-subform') !== that) {
 435            return;
 436          } // First selection
 437  
 438  
 439          if (!item) {
 440            row.setAttribute('draggable', 'true');
 441            row.setAttribute('aria-grabbed', 'true');
 442            item = row;
 443          } else {
 444            // Second selection
 445            // Move to selected position
 446            if (row !== item) {
 447              switchRowPositions(item, row);
 448            }
 449  
 450            item.setAttribute('draggable', 'false');
 451            item.setAttribute('aria-grabbed', 'false');
 452            item = null;
 453          }
 454  
 455          event.preventDefault();
 456        }); // Mouse interaction
 457        // - mouse down, enable "draggable" and allow to drag the row,
 458        // - mouse up, disable "draggable"
 459  
 460        this.addEventListener('mousedown', ({
 461          target
 462        }) => {
 463          if (touched) return; // Check for .move button
 464  
 465          const handler = getMoveHandler(target);
 466          const row = handler ? handler.closest(that.repeatableElement) : null;
 467  
 468          if (!row || row.closest('joomla-field-subform') !== that) {
 469            return;
 470          }
 471  
 472          row.setAttribute('draggable', 'true');
 473          row.setAttribute('aria-grabbed', 'true');
 474          item = row;
 475        });
 476        this.addEventListener('mouseup', () => {
 477          if (item && !touched) {
 478            item.setAttribute('draggable', 'false');
 479            item.setAttribute('aria-grabbed', 'false');
 480            item = null;
 481          }
 482        }); // Keyboard interaction
 483        // - "tab" to navigate to needed row,
 484        // - modifier (ctr,alt,shift) + "space" select the row,
 485        // - "tab" to select destination,
 486        // - "enter" to place selected row in to destination
 487        // - "esc" to cancel selection
 488  
 489        this.addEventListener('keydown', event => {
 490          if (event.keyCode !== KEYCODE.ESC && event.keyCode !== KEYCODE.SPACE && event.keyCode !== KEYCODE.ENTER || event.target.form || !event.target.matches(that.repeatableElement)) {
 491            return;
 492          }
 493  
 494          const row = event.target; // Make sure we handle correct children
 495  
 496          if (!row || row.closest('joomla-field-subform') !== that) {
 497            return;
 498          } // Space is the selection or unselection keystroke
 499  
 500  
 501          if (event.keyCode === KEYCODE.SPACE && hasModifier(event)) {
 502            // Unselect previously selected
 503            if (row.getAttribute('aria-grabbed') === 'true') {
 504              row.setAttribute('draggable', 'false');
 505              row.setAttribute('aria-grabbed', 'false');
 506              item = null;
 507            } else {
 508              // Select new
 509              // If there was previously selected
 510              if (item) {
 511                item.setAttribute('draggable', 'false');
 512                item.setAttribute('aria-grabbed', 'false');
 513                item = null;
 514              } // Mark new selection
 515  
 516  
 517              row.setAttribute('draggable', 'true');
 518              row.setAttribute('aria-grabbed', 'true');
 519              item = row;
 520            } // Prevent default to suppress any native actions
 521  
 522  
 523            event.preventDefault();
 524          } // Escape is the abort keystroke (for any target element)
 525  
 526  
 527          if (event.keyCode === KEYCODE.ESC && item) {
 528            item.setAttribute('draggable', 'false');
 529            item.setAttribute('aria-grabbed', 'false');
 530            item = null;
 531          } // Enter, to place selected item in selected position
 532  
 533  
 534          if (event.keyCode === KEYCODE.ENTER && item) {
 535            item.setAttribute('draggable', 'false');
 536            item.setAttribute('aria-grabbed', 'false'); // Do nothing here
 537  
 538            if (row === item) {
 539              item = null;
 540              return;
 541            } // Move the item to selected position
 542  
 543  
 544            switchRowPositions(item, row);
 545            event.preventDefault();
 546            item = null;
 547          }
 548        }); // dragstart event to initiate mouse dragging
 549  
 550        this.addEventListener('dragstart', ({
 551          dataTransfer
 552        }) => {
 553          if (item) {
 554            // We going to move the row
 555            dataTransfer.effectAllowed = 'move'; // This need to work in Firefox and IE10+
 556  
 557            dataTransfer.setData('text', '');
 558          }
 559        });
 560        this.addEventListener('dragover', event => {
 561          if (item) {
 562            event.preventDefault();
 563          }
 564        }); // Handle drag action, move element to hovered position
 565  
 566        this.addEventListener('dragenter', ({
 567          target
 568        }) => {
 569          // Make sure the target in the correct container
 570          if (!item || target.parentElement.closest('joomla-field-subform') !== that) {
 571            return;
 572          } // Find a hovered row
 573  
 574  
 575          const row = target.closest(that.repeatableElement); // One more check for correct parent
 576  
 577          if (!row || row.closest('joomla-field-subform') !== that) return;
 578          switchRowPositions(item, row);
 579        }); // dragend event to clean-up after drop or abort
 580        // which fires whether or not the drop target was valid
 581  
 582        this.addEventListener('dragend', () => {
 583          if (item) {
 584            item.setAttribute('draggable', 'false');
 585            item.setAttribute('aria-grabbed', 'false');
 586            item = null;
 587          }
 588        });
 589      }
 590  
 591    }
 592  
 593    customElements.define('joomla-field-subform', JoomlaFieldSubform);
 594  })(customElements);


Generated: Wed Sep 7 05:41:13 2022 Chilli.vc Blog - For Webmaster,Blog-Writer,System Admin and Domainer