coral-spectrum/coral-component-multifield/src/scripts/Multifield.js
/**
* Copyright 2019 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import '../../../coral-component-textfield';
import {BaseComponent} from '../../../coral-base-component';
import MultifieldCollection from './MultifieldCollection';
import {commons, i18n, validate, transform} from '../../../coral-utils';
import {Decorator} from '../../../coral-decorator';
const CLASSNAME = '_coral-Multifield';
const IS_DRAGGING_CLASS = 'is-dragging';
const IS_AFTER_CLASS = 'is-after';
const IS_BEFORE_CLASS = 'is-before';
const TEMPLATE_SUPPORT = 'content' in document.createElement('template');
/**
@class Coral.Multifield
@classdesc A Multifield component that enables adding, reordering, and removing multiple instances of a component.
Multifield partially supports the <code>template</code> element in IE 11. If adding/removing items in the template
is required, <code>template.content</code> should be used.
Child elements can be given a special attribute to enable functionality:
- <code>[coral-multifield-add]</code>. Click to add an item.
@htmltag coral-multifield
@extends {HTMLElement}
@extends {BaseComponent}
*/
const Multifield = Decorator(class extends BaseComponent(HTMLElement) {
/** @ignore */
constructor() {
super();
this.setAttribute('id', this.id || commons.getUID());
// Attach events
const events = {
'coral-dragaction:dragstart coral-multifield-item': '_onDragStart',
'coral-dragaction:drag coral-multifield-item': '_onDrag',
'coral-dragaction:dragend coral-multifield-item': '_onDragEnd',
'click [coral-multifield-add]': '_onAddItemClick',
'click ._coral-Multifield-remove': '_onRemoveItemClick',
'click [coral-multifield-move]': '_onClickDragHandle',
'key:up [coral-multifield-move]': '_onMoveItemUp',
'key:pageup [coral-multifield-move]': '_onMoveItemUp',
'key:down [coral-multifield-move]': '_onMoveItemDown',
'key:pagedown [coral-multifield-move]': '_onMoveItemDown',
'key:home [coral-multifield-move]': '_onMoveItemHome',
'key:end [coral-multifield-move]': '_onMoveItemEnd',
'key:esc [coral-multifield-move]': '_onMoveItemEsc',
'click [coral-multifield-up]': '_onUpClick',
'click [coral-multifield-down]': '_onDownClick',
'capture:blur [coral-multifield-move]': '_onBlurDragHandle',
'change coral-multifield-item-content > input': '_onInputChange'
};
events[`global:key:escape #${this.id} > [coral-multifield-move]`] = '_onMoveItemEsc';
this._delegateEvents(events);
// Templates
this._elements = {
template: this.querySelector(`#${this.id} > template[coral-multifield-template]`) || document.createElement('template')
};
this._elements.template.setAttribute('coral-multifield-template', '');
// In case <template> is not supported
this._handleTemplateSupport(this._elements.template);
// Template support: move nodes added to the <template> to its content fragment
this._observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
for (let i = 0 ; i < mutation.addedNodes.length ; i++) {
const addedNode = mutation.addedNodes[i];
const template = this.template;
if (template.contains(addedNode) && template !== addedNode) {
// Move the node to the template content
template.content.appendChild(addedNode);
// Update all items content with the template content
this.items.getAll().forEach((item) => {
this._renderTemplate(item);
});
this._updatePosInSet();
}
}
});
});
// Watch for changes to the template element
this._observer.observe(this, {
childList: true,
subtree: true
});
// Init the collection mutation observer
this.items._startHandlingItems(true);
}
/**
The Collection Interface that allows interacting with the Coral.Multifield items that the component contains.
@type {MultifieldCollection}
@readonly
*/
get items() {
// just init on demand
if (!this._items) {
this._items = new MultifieldCollection({
host: this,
itemTagName: 'coral-multifield-item',
// allows multifields to be nested
itemSelector: ':scope > coral-multifield-item',
onlyHandleChildren: true,
onItemAdded: this._onItemAdded,
onItemRemoved: this._onItemRemoved
});
}
return this._items;
}
/**
The Multifield template element. It will be used to render a new item once the element with the attribute
<code>coral-multifield-add</code> is clicked. It supports the <code>template</code> tag. While specifying the
template from markup, it should include the <code>coral-multifield-template</code> attribute.
NOTE: On IE11, only <code>template.content</code> is supported to add/remove elements to the template.
@type {HTMLElement}
@contentzone
*/
get template() {
return this._getContentZone(this._elements.template);
}
set template(value) {
this._setContentZone('template', value, {
handle: 'template',
tagName: 'template',
insert: function (template) {
this.appendChild(template);
},
set: function (content) {
// Additionally add support for template
this._handleTemplateSupport(content);
}
});
}
/**
Whether this multifield is readOnly or not. Indicating that the user cannot modify the value of the multifield fields.
@type {Boolean}
@default false
@htmlattribute readonly
@htmlattributereflected
*/
get readOnly() {
return this._readOnly || false;
}
set readOnly(value) {
value = transform.booleanAttr(value);
this._readOnly = value;
this._reflectAttribute('readonly', value);
this.items.getAll().forEach((item) => {
item[value ? 'setAttribute' : 'removeAttribute']('_readonly', '');
});
let addBtn = this.querySelector('[coral-multifield-add]');
if (addBtn) {
addBtn.disabled = value;
}
}
/**
Specifies the minimum number of items multifield should render.
If component contains less items, remaining items will be added.
@type {Number}
@default 0
@htmlattribute min
@htmlattributereflected
*/
get min() {
return this._min || 0;
}
set min(value) {
const self = this;
value = transform.number(value);
if(value && validate.valueMustChange(self._min, value)) {
self._min = value;
self._reflectAttribute('min', value);
self._validateMinItems();
}
}
static get _attributePropertyMap() {
return commons.extend(super._attributePropertyMap, {
reorderupdown: 'reorderUpDown',
readonly: 'readOnly'
});
}
/**
Whether this multifield require up and down buttons.
@type {Boolean}
@default false
@htmlattribute reorderupdown
@htmlattributereflected
*/
get reorderUpDown() {
return this._reorderUpDown || false;
}
set reorderUpDown(value) {
value = transform.booleanAttr(value);
this._reorderUpDown = value;
this._reflectAttribute('reorderupdown', value);
}
/**
* Validates minimum items required. will add items, if validation fails.
* @param schedule schedule validation in next frame
* @ignore
*/
_validateMinItems(schedule) {
// only validate when multifield is connected
if(this._disconnected === false) {
const self = this;
const items = self.items;
let currentLength = items.length;
let currentMin = self.min;
let deletable = true;
if(currentLength <= currentMin) {
let itemsToBeAdded = currentMin - currentLength;
for(let i = 0; i < itemsToBeAdded; i++) {
let item = document.createElement('coral-multifield-item');
items.add(item);
item._readOnly = this.readOnly;
}
deletable = !deletable;
}
if(!schedule) {
window.cancelAnimationFrame(self._updateItemsDeletableId);
delete self._updateItemsDeletableId;
self._updateItemsDeletable(items.getAll(), deletable);
} else if(!self._updateItemsDeletableId) {
self._updateItemsDeletableId = window.requestAnimationFrame(() => {
delete self._updateItemsDeletableId;
self._updateItemsDeletable(items.getAll(), deletable);
});
}
}
}
/**
* Change the deletable property of passed items to the specified deletable value
* @ignore
*/
_updateItemsDeletable(items, deletable) {
deletable = transform.boolean(deletable);
items = !Array.isArray(items) ? [items] : items;
items.forEach(function(item) {
item._deletable = deletable;
});
}
/** @ignore */
_handleTemplateSupport(template) {
// @polyfill IE
if (!TEMPLATE_SUPPORT && !template.content) {
const frag = document.createDocumentFragment();
while (template.firstChild) {
frag.appendChild(template.firstChild);
}
template.content = frag;
}
}
/** @ignore */
_onAddItemClick(event) {
if (event.matchedTarget.closest('coral-multifield') === this) {
this.items.add(document.createElement('coral-multifield-item'));
// Wait for MO to render item template
window.requestAnimationFrame(() => {
this.trigger('change');
this._trackEvent('click', 'add item button', event);
// Focus the newly created input if it can receive focus
var addBtn = event.target;
const items = this.items.getAll();
const setsize = items.length;
const itemToFocus = items[setsize - 1];
const focusableItem = itemToFocus.querySelector(commons.TABBABLE_ELEMENT_SELECTOR);
if (focusableItem.hasAttribute('disabled')) {
addBtn.focus();
} else {
focusableItem.focus();
}
});
}
}
/** @ignore */
_onRemoveItemClick(event) {
if (event.matchedTarget.closest('coral-multifield') === this) {
const item = event.matchedTarget.closest('coral-multifield-item');
if (item) {
// manage focus when item is removed
let itemToFocus;
const items = this.items.getAll();
const setsize = items.length;
if (setsize > 1) {
const itemIndex = items.indexOf(item);
if (itemIndex === setsize - 1) {
itemToFocus = items[itemIndex - 1];
} else {
itemToFocus = items[itemIndex + 1];
}
}
item.remove();
if (itemToFocus) {
itemToFocus._elements.remove.focus();
} else {
itemToFocus = this.querySelector('[coral-multifield-add]');
if (itemToFocus) {
itemToFocus.focus();
}
}
}
this.trigger('change');
this._trackEvent('click', 'remove item button', event);
}
}
/**
* Toggles keyboard accessible dragging of the current multifield item.
* @ignore
*/
_toggleItemDragging(multiFieldItem, dragging = false) {
if (multiFieldItem._dragging === dragging) {
return;
}
multiFieldItem._dragging = dragging;
if (dragging) {
this._oldBefore = multiFieldItem.previousElementSibling;
this._before = multiFieldItem.nextElementSibling;
} else {
this.trigger('coral-multifield:beforeitemorder', {
item: multiFieldItem,
oldBefore: this._oldBefore,
before: this._before
});
this.trigger('coral-multifield:itemorder', {
item: multiFieldItem,
oldBefore: this._oldBefore,
before: multiFieldItem.nextElementSibling
});
this.trigger('change');
this._oldBefore = null;
this._before = null;
}
}
/**
* Clicking dragHandle toggles keyboard accessible dragging of the current multifield item.
* @ignore
*/
_onClickDragHandle(event) {
event.preventDefault();
event.stopPropagation();
const multiFieldItem = event.matchedTarget.closest('coral-multifield-item');
this._toggleItemDragging(multiFieldItem, !multiFieldItem._dragging);
}
/**
* When the drag handle blurs, cancel dragging, leaving item where it is.
* @ignore
*/
_onBlurDragHandle(event) {
const dragHandle = event.matchedTarget;
const multiFieldItem = dragHandle.closest('coral-multifield-item');
commons.nextFrame(() => {
if (document.activeElement !== dragHandle) {
this._toggleItemDragging(multiFieldItem, false);
}
});
}
/**
* Moves multiField item selected for dragging up one index position in the multifield collection.
* @ignore
*/
_onMoveItemUp(event) {
const dragHandle = event.matchedTarget;
const dragElement = dragHandle.closest('coral-multifield-item');
if (!dragElement._dragging) {
return;
}
event.preventDefault();
event.stopPropagation();
const items = this.items.getAll();
const dragElementIndex = items.indexOf(dragElement);
if (dragElementIndex > 0) {
this.insertBefore(dragElement, dragElement.previousElementSibling);
}
dragElement._dragging = true;
dragHandle.focus();
}
/**
* Moves multiField item selected for dragging down one index position in the multifield collection.
* @ignore
*/
_onMoveItemDown(event) {
const dragHandle = event.matchedTarget;
const dragElement = dragHandle.closest('coral-multifield-item');
if (!dragElement._dragging) {
return;
}
event.preventDefault();
event.stopPropagation();
const items = this.items.getAll();
const dragElementIndex = items.indexOf(dragElement);
if (dragElementIndex < items.length - 1) {
const nextElement = dragElement.nextElementSibling;
this.insertBefore(dragElement, nextElement.nextElementSibling);
}
dragElement._dragging = true;
dragHandle.focus();
}
/**
* Moves multiField item selected for dragging to start of multifield collection.
* @ignore
*/
_onMoveItemHome(event) {
const dragHandle = event.matchedTarget;
let dragElement = dragHandle.closest('coral-multifield-item');
if (!dragElement._dragging) {
return;
}
event.preventDefault();
event.stopPropagation();
const items = this.items.getAll();
const dragElementIndex = items.indexOf(dragElement);
if (dragElementIndex > 0) {
this.insertBefore(dragElement, this.items.first());
}
dragElement._dragging = true;
dragHandle.focus();
}
/**
* Moves multiField item selected for dragging to end of multifield collection.
* @ignore
*/
_onMoveItemEnd(event) {
const dragHandle = event.matchedTarget;
let dragElement = dragHandle.closest('coral-multifield-item');
if (!dragElement._dragging) {
return;
}
event.preventDefault();
event.stopPropagation();
const items = this.items.getAll();
const dragElementIndex = items.indexOf(dragElement);
if (dragElementIndex < items.length - 1) {
this.insertBefore(dragElement, this.items.last().nextElementSibling);
}
dragElement._dragging = true;
dragHandle.focus();
}
/**
* Cancels keyboard drag and drop operation, restoring item to its previous location.
* @ignore
*/
_onMoveItemEsc(event) {
const dragHandle = event.matchedTarget;
const multiFieldItem = dragHandle.closest('coral-multifield-item');
if (multiFieldItem._dragging && this._oldBefore && this._before) {
event.stopPropagation();
this.insertBefore(multiFieldItem, this._before);
dragHandle.focus();
}
this._toggleItemDragging(multiFieldItem, false);
}
_onInputChange(event) {
this._trackEvent('change', 'input', event);
}
/** @ignore */
_onDragStart(event) {
if (event.target.closest('coral-multifield') === this) {
document.body.classList.add('u-coral-closedHand');
const dragElement = event.detail.dragElement;
const items = this.items.getAll();
const dragElementIndex = items.indexOf(dragElement);
// Toggle dragging state on multifield item.
dragElement._dragging = true;
dragElement.classList.add(IS_DRAGGING_CLASS);
items.forEach((item, i) => {
if (i < dragElementIndex) {
item.classList.add(IS_BEFORE_CLASS);
} else if (i > dragElementIndex) {
item.classList.add(IS_AFTER_CLASS);
}
});
}
}
/** @ignore */
_onDrag(event) {
if (event.target.closest('coral-multifield') === this) {
const items = this.items.getAll();
let marginBottom = 0;
if (items.length) {
marginBottom = parseFloat(window.getComputedStyle(items[0]).marginBottom);
}
items.forEach((item) => {
if (!item.classList.contains(IS_DRAGGING_CLASS)) {
const dragElement = event.detail.dragElement;
const dragElementBoundingClientRect = dragElement.getBoundingClientRect();
const itemBoundingClientRect = item.getBoundingClientRect();
const dragElementOffsetTop = dragElementBoundingClientRect.top;
const itemOffsetTop = itemBoundingClientRect.top;
const isAfter = dragElementOffsetTop < itemOffsetTop;
const itemReorderedTop = `${dragElementBoundingClientRect.height + marginBottom}px`;
item.classList.toggle(IS_AFTER_CLASS, isAfter);
item.classList.toggle(IS_BEFORE_CLASS, !isAfter);
if (item.classList.contains(IS_AFTER_CLASS)) {
item.style.top = items.indexOf(item) < items.indexOf(dragElement) ? itemReorderedTop : '';
}
if (item.classList.contains(IS_BEFORE_CLASS)) {
const afterDragElement = items.indexOf(item) > items.indexOf(dragElement);
item.style.top = afterDragElement ? `-${itemReorderedTop}` : '';
}
}
});
}
}
/** @ignore */
_onDragEnd(event) {
if (event.target.closest('coral-multifield') === this) {
document.body.classList.remove('u-coral-closedHand');
const dragElement = event.detail.dragElement;
const items = this.items.getAll();
const beforeArr = [];
const afterArr = [];
items.forEach((item) => {
if (item.classList.contains(IS_AFTER_CLASS)) {
afterArr.push(item);
} else if (item.classList.contains(IS_BEFORE_CLASS)) {
beforeArr.push(item);
}
item.classList.remove(IS_DRAGGING_CLASS, IS_AFTER_CLASS, IS_BEFORE_CLASS);
item.style.top = '';
item.style.position = '';
});
const oldBefore = dragElement.previousElementSibling;
const before = afterArr.shift();
const after = beforeArr.pop();
const beforeEvent = this.trigger('coral-multifield:beforeitemorder', {
item: dragElement,
oldBefore: oldBefore,
before: before
});
if (!beforeEvent.defaultPrevented) {
if (before) {
this.insertBefore(dragElement, before);
}
if (after) {
this.insertBefore(dragElement, after.nextElementSibling);
}
// Toggle dragging state on multifield item.
dragElement._dragging = false;
this.trigger('coral-multifield:itemorder', {
item: dragElement,
oldBefore: oldBefore,
before: before
});
this.trigger('change');
dragElement._elements.move.focus();
}
}
}
/** @ignore */
_onUpClick(event) {
const upHandle = event.matchedTarget;
const shiftElement = upHandle.closest('coral-multifield-item');
if(shiftElement.previousElementSibling.tagName === 'CORAL-MULTIFIELD-ITEM') {
this.insertBefore(shiftElement, shiftElement.previousElementSibling);
}
}
/** @ignore */
_onDownClick(event) {
const upHandle = event.matchedTarget;
const shiftElement = upHandle.closest('coral-multifield-item');
if(shiftElement.nextElementSibling.tagName === 'CORAL-MULTIFIELD-ITEM') {
this.insertBefore(shiftElement.nextElementSibling, shiftElement);
}
}
/** @private */
_onItemAdded(item) {
const self = this;
// Update the item content with the template content
if (item.parentNode === self) {
self._renderTemplate(item);
self._updatePosInSet();
}
if(self.items.length === self.min + 1) {
self._validateMinItems();
}
// a11y
self._handleRoleList();
}
/** @private */
_onItemRemoved() {
const self = this;
self._updatePosInSet();
// only validate when required
if(self.items.length <= self.min) {
self._validateMinItems();
}
// a11y
self._handleRoleList();
}
/**
* handle role list of the multifield based on number of items
* @private
*/
_handleRoleList() {
const self = this;
if (self.items.length > 0 && self.getAttribute('role') !== 'list') {
self.setAttribute('role', 'list');
} else if (self.items.length === 0 && self.getAttribute('role') === 'list') {
self.removeAttribute('role');
}
}
/**
* update aria-posinset and aria-setsize for each item in the collection
* @private
*/
_updatePosInSet() {
const items = this.items.getAll();
const setsize = items.length;
items.forEach((item, i) => {
item.setAttribute('aria-posinset', i + 1);
item.setAttribute('aria-setsize', setsize);
item.setAttribute('aria-label', i18n.get('({0} of {1})', i + 1, setsize));
// so long as item content is not another multifield,
// add aria-labelledby so that the item is labelled by its content and itself.
if (!item.querySelector('coral-multifield')) {
item.setAttribute('aria-labelledby', `${item.id}-content ${item.id}`);
}
});
}
/** @private */
_renderTemplate(item) {
const content = item.content || item.querySelector('coral-multifield-item-content') || item;
// Insert the template if item content is empty
if (!content.firstChild) {
// @polyfill IE
if (!TEMPLATE_SUPPORT) {
// Before cloning, put the nested templates content back in the DOM
const nestedTemplates = this.template.content.querySelectorAll('template[coral-multifield-template]');
Array.prototype.forEach.call(nestedTemplates, (template) => {
while (template.content.firstChild) {
template.appendChild(template.content.firstChild);
}
});
}
// Clone the template and append it to the item content
content.appendChild(document.importNode(this.template.content, true));
}
}
get _contentZones() {
return {template: 'template'};
}
/** @ignore */
static get observedAttributes() {
return super.observedAttributes.concat([
'min',
'readonly',
'reorderupdown'
]);
}
/** @ignore */
render() {
super.render();
this.classList.add(CLASSNAME, 'coral-Well');
// a11y
this._handleRoleList();
// a11y Add aria-label to the add button if exists to give context to screen reader users
const coralMultifieldAddBtn = this.querySelector('[coral-multifield-add]');
if (coralMultifieldAddBtn){
coralMultifieldAddBtn.setAttribute("aria-label","Add");
}
// Assign the content zones, moving them into place in the process
this.template = this._elements.template;
// Prepare items content based on the given template
this.items.getAll().forEach((item) => {
this._renderTemplate(item);
});
// update aria-posinset and aria-setsize for each item in the collection
this._updatePosInSet();
this._validateMinItems(true);
}
/**
Triggered when the {@link Multifield} item are reordered.
@typedef {CustomEvent} coral-multifield:beforeitemorder
@property {MultifieldItem} detail.item
The item to be ordered.
@property {MultifieldItem} detail.oldBefore
Ordered item next sibling before the swap. If <code>null</code>, the item was the last item.
@property {MultifieldItem} detail.before
Ordered item will be inserted before this sibling item. If <code>null</code>, the item is inserted at the end.
*/
/**
Triggered when the {@link Multifield} item are reordered.
@typedef {CustomEvent} coral-multifield:itemorder
@property {MultifieldItem} detail.item
The ordered item.
@property {MultifieldItem} detail.oldBefore
Ordered item next sibling before the swap. If <code>null</code>, the item was the last item.
@property {MultifieldItem} detail.before
Ordered item was inserted before this sibling item. If <code>null</code>, the item was inserted at the end.
*/
});
export default Multifield;