cms/build/media_source/system/js/fields/joomla-field-subform.w-c.es...

611 lines
18 KiB
JavaScript

/**
* @copyright Copyright (C) 2005 - 2019 Open Source Matters, Inc. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
(function (customElements) {
'use strict';
const KEYCODE = {
SPACE: 32,
ESC: 27,
ENTER: 13,
};
// Find matchesFn with vendor prefix
let matchesFn = 'matches';
['matches', 'msMatchesSelector'].some((fn) => {
if (typeof document.body[fn] === 'function') {
matchesFn = fn;
return true;
}
return false;
});
/**
* Helper to find a closest parent element
*
* @param {HTMLElement} element
* @param {String} selector
*
* @returns {HTMLElement|null}
*/
function closest(element, selector) {
let parent;
// Traverse parents
while (element) {
parent = element.parentElement;
if (parent && parent[matchesFn](selector)) {
return parent;
}
// eslint-disable-next-line no-param-reassign
element = parent;
}
return null;
}
/**
* 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}`);
return 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
for (let i = 0, l = allContainers.length; i < l; i++) {
if (closest(allContainers[i], 'joomla-field-subform') === this) {
this.containerWithRows = allContainers[i];
break;
}
}
}
// 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[matchesFn](that.buttonAdd) ? event.target : closest(event.target, that.buttonAdd);
}
if (that.buttonRemove) {
btnRem = event.target[matchesFn](that.buttonRemove) ? event.target : closest(event.target, that.buttonRemove);
}
// Check actine, with extra check for nested joomla-field-subform
if (btnAdd && closest(btnAdd, 'joomla-field-subform') === that) {
let row = closest(btnAdd, that.repeatableElement);
row = closest(row, 'joomla-field-subform') === that ? row : null;
that.addRow(row);
event.preventDefault();
} else if (btnRem && closest(btnRem, 'joomla-field-subform') === that) {
const row = closest(btnRem, that.repeatableElement);
that.removeRow(row);
event.preventDefault();
}
});
this.addEventListener('keydown', (event) => {
if (event.keyCode !== KEYCODE.SPACE) return;
const isAdd = that.buttonAdd && event.target[matchesFn](that.buttonAdd);
const isRem = that.buttonRemove && event.target[matchesFn](that.buttonRemove);
if ((isAdd || isRem) && closest(event.target, 'joomla-field-subform') === that) {
let row = closest(event.target, that.repeatableElement);
row = closest(row, '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 = this.containerWithRows.children;
const matchesFn = document.body.msMatchesSelector ? 'msMatchesSelector' : 'matches';
const result = [];
// Filter out the rows
for (let i = 0, l = rows.length; i < l; i++) {
if (rows[i][matchesFn](this.repeatableElement)) {
result.push(rows[i]);
}
}
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 are required to subform element to work');
}
}
/**
* Add new row
* @param {HTMLElement} after
* @returns {HTMLElement}
*/
addRow(after) {
// Count how much 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 dragable 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,
}));
if (window.Joomla) {
Joomla.Event.dispatch(row, 'joomla:updated');
}
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,
}));
if (window.Joomla) {
Joomla.Event.dispatch(row, 'joomla:removed');
}
row.parentNode.removeChild(row);
}
/**
* Fix names ind id`s for field that in the row
* @param {HTMLElement} row
* @param {Number} count
*/
fixUniqueAttributes(row, count) {
count = count || 0;
const group = row.getAttribute('data-group'); // current group name
const basename = row.getAttribute('data-base-name');
const countnew = Math.max(this.lastRowIndex, count);
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 => closest(el, 'joomla-field-subform') === this);
for (let i = 0, l = haveName.length; i < l; i++) {
const $el = haveName[i];
const name = $el.getAttribute('name');
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 = closest($el, '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 = closest($el, '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`);
}
}
forOldAttr += countMulti;
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;
}
// Guess there a label for this input
const lbl = row.querySelector(`label[for="${forOldAttr}"]`);
if (lbl) {
lbl.setAttribute('for', idNew);
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 dragable attributes
const rows = this.getRows();
for (let ir = 0, lr = rows.length; ir < lr; ir++) {
const childRow = rows[ir];
childRow.setAttribute('draggable', 'false');
childRow.setAttribute('aria-grabbed', 'false');
childRow.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[matchesFn](that.buttonMove) ? element : closest(element, that.buttonMove);
}
// Helper method to mover 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" mark a row dragable / "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 ? closest(handler, that.repeatableElement) : null;
if (!row || closest(row, 'joomla-field-subform') !== that) {
return;
}
// First selection
if (!item) {
row.setAttribute('draggable', 'true');
row.setAttribute('aria-grabbed', 'true');
item = row;
}
// Second selection
else {
// 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', (event) => {
if (touched) return;
// Check for .move button
const handler = getMoveHandler(event.target);
const row = handler ? closest(handler, that.repeatableElement) : null;
if (!row || closest(row, 'joomla-field-subform') !== that) {
return;
}
row.setAttribute('draggable', 'true');
row.setAttribute('aria-grabbed', 'true');
item = row;
});
this.addEventListener('mouseup', (event) => {
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.keyCode !== KEYCODE.ESC && event.keyCode !== KEYCODE.SPACE && event.keyCode !== KEYCODE.ENTER)
|| event.target.form || !event.target[matchesFn](that.repeatableElement)) {
return;
}
const row = event.target;
// Make sure we handle correct children
if (!row || closest(row, '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');
item = null;
}
// Select new
else {
// 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 abort keystroke (for any target element)
if (event.keyCode === KEYCODE.ESC && item) {
item.setAttribute('draggable', 'false');
item.setAttribute('aria-grabbed', 'false');
item = null;
}
// Enter, to place selected item in selected position
if (event.keyCode === 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', (event) => {
if (item) {
// We going to move the row
event.dataTransfer.effectAllowed = 'move';
// This need to work in Firefox and IE10+
event.dataTransfer.setData('text', '');
}
});
this.addEventListener('dragover', (event) => {
if (item) {
event.preventDefault();
}
});
// Handle drag action, move element to hovered position
this.addEventListener('dragenter', (event) => {
// Make sure the target in the correct container
if (!item || that.rowsContainer && closest(event.target, that.rowsContainer) !== that.containerWithRows) {
return;
}
// Find a hovered row, and replace it
const row = event.target[matchesFn](that.repeatableElement) ? event.target : closest(event.target, that.repeatableElement);
if (!row) return;
switchRowPositions(item, row);
});
// dragend event to clean-up after drop or abort
// 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;
}
});
}
}
customElements.define('joomla-field-subform', JoomlaFieldSubform);
}(customElements));