Current File : /home/pacjaorg/.trash/media.1/system/js/fields/joomla-field-subform.js |
/**
* @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);