coral-spectrum/coral-component-taglist/src/scripts/TagList.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 {BaseComponent} from '../../../coral-base-component';
import {BaseFormField} from '../../../coral-base-formfield';
import Tag from './Tag';
import {Collection} from '../../../coral-collection';
import {transform, commons} from '../../../coral-utils';
import {Decorator} from '../../../coral-decorator';
const CLASSNAME = '_coral-Tags';
// Collection
const ITEM_TAGNAME = 'coral-tag';
/**
Extracts the value from the item in case no explicit value was provided.
@param {HTMLElement} item
the item whose value will be extracted.
@returns {String} the value that will be submitted for this item.
@private
*/
const itemValueFromDOM = function (item) {
const attr = item.getAttribute('value');
// checking explicitly for null allows to differentiate between non set values and empty strings
return attr !== null ? attr : item.textContent.replace(/\s{2,}/g, ' ').trim();
};
/**
@class Coral.TagList
@classdesc A TagList component is a form field container to manipulate tags.
@htmltag coral-taglist
@extends {HTMLElement}
@extends {BaseComponent}
@extends {BaseFormField}
*/
const TagList = Decorator(class extends BaseFormField(BaseComponent(HTMLElement)) {
/** @ignore */
constructor() {
super();
// Attach events
this._delegateEvents(commons.extend(this._events, {
'capture:focus coral-tag': '_onItemFocus',
'capture:blur coral-tag': '_onItemBlur',
'key:right coral-tag': '_onNextItemFocus',
'key:down coral-tag': '_onNextItemFocus',
'key:pagedown coral-tag': '_onNextItemFocus',
'key:left coral-tag': '_onPreviousItemFocus',
'key:up coral-tag': '_onPreviousItemFocus',
'key:pageup coral-tag': '_onPreviousItemFocus',
'key:home coral-tag': '_onFirstItemFocus',
'key:end coral-tag': '_onLastItemFocus',
// Accessibility
'capture:focus coral-tag:not(.is-disabled)': '_onItemFocusIn',
'capture:blur coral-tag:not(.is-disabled)': '_onItemFocusOut',
// Private
'coral-tag:_valuechanged': '_onTagValueChanged',
'coral-tag:_connected': '_onTagConnected'
}));
// Pre-define labellable element
this._labellableElement = this;
this._itemToFocusAfterDelete = null;
}
/**
Changing the values will redefine the component's items.
@type {Array.<String>}
@emits {change}
*/
get values() {
return this.items.getAll().map((item) => item.value);
}
set values(values) {
if (Array.isArray(values)) {
this.items.clear();
values.forEach((value) => {
const item = new Tag().set({
label: {
innerHTML: value
},
value: value
});
this._attachInputToItem(item);
this.items.add(item);
});
}
}
/**
The Collection Interface that allows interacting with the items that the component contains.
@type {Collection}
@readonly
*/
get items() {
// just init on demand
if (!this._items) {
this._items = new Collection({
host: this,
itemTagName: ITEM_TAGNAME
});
}
return this._items;
}
/**
Name used to submit the data in a form.
@type {String}
@default ""
@htmlattribute name
@htmlattributereflected
*/
get name() {
return this._name || '';
}
set name(value) {
this._name = transform.string(value);
this._reflectAttribute('name', value);
this.items.getAll().forEach((item) => {
if (item._input) {
item._input.name = this._name;
}
});
}
/**
This field's current value.
@type {String}
@default ""
@htmlattribute value
*/
get value() {
const all = this.items.getAll();
return all.length ? all[0].value : '';
}
set value(value) {
this.items.clear();
if (value) {
const item = new Tag().set({
label: {
innerHTML: value
},
value: value
});
this._attachInputToItem(item);
this.items.add(item);
}
}
/**
Whether this field is disabled or not.
@type {Boolean}
@default false
@htmlattribute disabled
@htmlattributereflected
*/
get disabled() {
return this._disabled || false;
}
set disabled(value) {
this._disabled = transform.booleanAttr(value);
this._reflectAttribute('disabled', this._disabled);
this.items.getAll().forEach((item) => {
item.classList.toggle('is-disabled', this._disabled);
if (item._input) {
item._input.disabled = this._disabled;
}
});
// a11y
this[this._disabled ? 'setAttribute' : 'removeAttribute']('aria-disabled', this._disabled);
}
// JSDoc inherited
get invalid() {
return super.invalid;
}
set invalid(value) {
super.invalid = value;
this.items.getAll().forEach((item) => {
item.classList.toggle('is-invalid', this._invalid);
});
}
/**
Whether this field is readOnly or not. Indicating that the user cannot modify the value of the control.
@type {Boolean}
@default false
@htmlattribute readonly
@htmlattributereflected
*/
get readOnly() {
return this._readOnly || false;
}
set readOnly(value) {
this._readOnly = transform.booleanAttr(value);
this._reflectAttribute('readonly', this._readOnly);
this.items.getAll().forEach((item) => {
item.closable = !this._readOnly;
});
}
/**
Whether this field is required or not.
@type {Boolean}
@default false
@htmlattribute required
@htmlattributereflected
*/
get required() {
return this._required || false;
}
set required(value) {
this._required = transform.booleanAttr(value);
this._reflectAttribute('required', this._required);
}
/** @private */
_attachInputToItem(item) {
if (!item._input) {
item._input = document.createElement('input');
item._input.type = 'hidden';
// We do this so it is recognized by Coral.Tag and handled if cloned
item._input.setAttribute('handle', 'input');
}
const input = item._input;
input.disabled = this.disabled;
input.name = this.name;
input.value = item.value;
if (!item.contains(input)) {
item.appendChild(input);
}
}
/** @private */
_prepareItem(attachedItem) {
const items = this.items.getAll();
// Prevents to add duplicates based on the tag value
const duplicate = items.some((tag) => {
if (itemValueFromDOM(tag) === itemValueFromDOM(attachedItem) && tag !== attachedItem) {
(items.indexOf(tag) < items.indexOf(attachedItem) ? attachedItem : tag).remove();
return true;
}
return false;
});
if (duplicate) {
return;
}
// create corresponding input field
this._attachInputToItem(attachedItem);
// Set tag defaults
attachedItem.setAttribute('color', Tag.color.DEFAULT);
attachedItem.setAttribute('size', Tag.size.SMALL);
// adds the role to support accessibility
attachedItem.setAttribute('role', 'row');
// adds role to parent to support accessibility, if it doesn't already have it
if (this.getAttribute('role') === null) {
this.setAttribute('role', 'grid');
}
if (!this.disabled) {
attachedItem.setAttribute('tabindex', '-1');
}
attachedItem[this.readOnly ? 'removeAttribute' : 'setAttribute']('closable', '');
// add tabindex to first item if none existing
if (!this.disabled && !this.querySelector(`${ITEM_TAGNAME}[tabindex="0"]`)) {
const first = items[0];
if (first) {
first.setAttribute('tabindex', '0');
}
}
// Keep a reference on the host in case the tag gets removed
attachedItem._host = this;
// triggers the Coral.Collection event
this.trigger('coral-collection:add', {
item: attachedItem
});
}
/** @private */
_onItemDisconnected(detachedItem) {
// Cleans the tag from TagList specific values
detachedItem.removeAttribute('role');
// Removes role from taglist if it has no tag elements
if (this.items.length <= 0) {
this.removeAttribute('role');
}
detachedItem.removeAttribute('tabindex');
detachedItem._host = undefined;
const parentElement = this.parentElement;
if (this.items.length === 0 && parentElement) {
// If all tags are removed, call focus method on parent element
if (typeof parentElement.focus === 'function') {
parentElement.focus();
}
const self = this;
commons.nextFrame(() => {
// if the parentElement did not receive focus or move focus to some other element
if (document.activeElement.tagName === 'BODY') {
if (this.items.length > 0) {
self.items.first().focus();
} else {
// make the TagList focusable
self.tabIndex = -1;
self.classList.add('u-coral-screenReaderOnly');
self.style.outline = '0';
self.innerHTML = ' ';
const onBlurFocusManagement = function () {
self.removeAttribute('tabindex');
self.classList.remove('u-coral-screenReaderOnly');
self.style.outline = '';
self.innerHTML = '';
self._vent.off('blur.focusManagement');
};
self._vent.on('blur.focusManagement', null, onBlurFocusManagement);
if (!parentElement.contains(document.activeElement)) {
self.focus();
} else {
onBlurFocusManagement();
}
}
}
});
} else if (this._itemToFocusAfterDelete) {
this._itemToFocusAfterDelete.focus();
}
// triggers the Coral.Collection event
this.trigger('coral-collection:remove', {
item: detachedItem
});
}
/** @private */
_onItemFocus(event) {
if (!this.disabled) {
this.setAttribute('aria-live', 'polite');
const tag = event.matchedTarget;
// add tabindex to first item and remove from previous focused item
this.items.getAll().forEach((item) => {
if (item !== tag) {
item.setAttribute('tabindex', '-1');
}
});
tag.setAttribute('tabindex', '0');
this._setItemToFocusOnDelete(tag);
}
}
/** @private */
_onItemBlur(event) {
if (!this.disabled) {
this.setAttribute('aria-live', 'off');
const tag = event.matchedTarget;
this._setItemToFocusOnDelete(tag);
}
}
/** @private */
_onSiblingItemFocus(event, sibling) {
if (!this.disabled) {
event.preventDefault();
let item = event.target[sibling];
while (item) {
if (item.tagName.toLowerCase() === ITEM_TAGNAME && !item.hidden) {
item.focus();
break;
} else {
item = item[sibling];
}
}
}
}
/** @private */
_onNextItemFocus(event) {
this._onSiblingItemFocus(event, 'nextElementSibling');
}
/** @private */
_onPreviousItemFocus(event) {
this._onSiblingItemFocus(event, 'previousElementSibling');
}
/** @private */
_onFirstItemFocus(event) {
event.preventDefault();
const length = this.items.length;
if (length > 0) {
this.items.getAll()[0].focus();
}
}
/** @private */
_onLastItemFocus(event) {
event.preventDefault();
const length = this.items.length;
if (length > 0) {
this.items.getAll()[length - 1].focus();
}
}
_onItemFocusIn(event) {
event.matchedTarget.classList.add('focus-ring');
}
_onItemFocusOut(event) {
event.matchedTarget.classList.remove('focus-ring');
}
/** @private */
_onTagButtonClicked(item, event) {
this.trigger('change');
this._trackEvent('remove', 'coral-tag', event, item);
}
/** @private */
_onTagValueChanged(event) {
event.stopImmediatePropagation();
const tag = event.target;
if (tag._input) {
tag._input.value = tag.value;
}
}
/** @private */
_setItemToFocusOnDelete(tag) {
let itemToFocusAfterDelete = tag.nextElementSibling;
// Next item will be focusable if the focused tag is removed
while (itemToFocusAfterDelete) {
if (itemToFocusAfterDelete.tagName.toLowerCase() === ITEM_TAGNAME && !itemToFocusAfterDelete.hidden) {
this._itemToFocusAfterDelete = itemToFocusAfterDelete;
return;
}
itemToFocusAfterDelete = itemToFocusAfterDelete.nextElementSibling;
}
itemToFocusAfterDelete = tag.previousElementSibling;
// Previous item will be focusable if the focused tag is removed
while (itemToFocusAfterDelete) {
if (itemToFocusAfterDelete.tagName.toLowerCase() === ITEM_TAGNAME && !itemToFocusAfterDelete.hidden) {
this._itemToFocusAfterDelete = itemToFocusAfterDelete;
break;
} else {
itemToFocusAfterDelete = itemToFocusAfterDelete.previousElementSibling;
}
}
window.requestAnimationFrame(() => {
if (tag.parentElement !== null && !this.contains(document.activeElement)) {
itemToFocusAfterDelete = undefined;
}
});
}
/** @private */
_onTagConnected(event) {
event.stopImmediatePropagation();
const item = event.target;
this._prepareItem(item);
}
/**
Inherited from {@link BaseFormField#reset}.
*/
reset() {
// reset the values to the initial values
this.values = this._initialValues;
}
/** @ignore */
static get observedAttributes() {
return super.observedAttributes.concat([
'closable',
'value',
'quiet',
'multiline',
'size',
'color'
]);
}
/** @ignore */
render() {
super.render();
this.classList.add(CLASSNAME);
// adds the role to support accessibility
if (this.items.length > 0) {
this.setAttribute('role', 'grid');
} else {
this.removeAttribute('role');
};
this.setAttribute('aria-live', 'off');
this.setAttribute('aria-atomic', 'false');
this.setAttribute('aria-relevant', 'additions');
// Since tagList can have multiple values, we have to store them all to be able to reset them
if (this.hasAttribute('value')) {
this._initialValues = [this.getAttribute('value')];
} else {
this._initialValues = this.items.getAll().map((item) => itemValueFromDOM(item));
}
// Prepare items
this.items.getAll().forEach((item) => {
this._prepareItem(item);
});
}
});
export default TagList;