[ Index ] |
PHP Cross Reference of Joomla 4.2.2 documentation |
[Summary view] [Print] [Text view]
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);
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Wed Sep 7 05:41:13 2022 | Chilli.vc Blog - For Webmaster,Blog-Writer,System Admin and Domainer |