coral-spectrum/coral-component-taglist/src/scripts/Tag.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 '../../../coral-component-button';
import {Icon} from '../../../coral-component-icon';
import base from '../templates/base';
import {transform, validate, events, i18n, commons} from '../../../coral-utils';
import {Decorator} from '../../../coral-decorator';
const CLASSNAME = '_coral-Tags-item';
const LABEL_CLASSNAME = '_coral-Label';
/**
Enumeration for {@link Tag} sizes. Only colored tags can have different sizes.
@typedef {Object} TagSizeEnum
@property {String} SMALL
A small sized tag.
@property {String} MEDIUM
A default sized tag.
@property {String} LARGE
A large sized tag.
*/
const size = {
SMALL: 'S',
MEDIUM: 'M',
LARGE: 'L'
};
/**
Enumeration for {@link Tag} colors.
@typedef {Object} TagColorEnum
@property {String} DEFAULT
@property {String} GREY
@property {String} BLUE
@property {String} RED
@property {String} ORANGE
@property {String} GREEN
@property {String} YELLOW
@property {String} SEA_FOAM
@property {String} FUCHSIA
@property {String} LIGHT_BLUE
Not supported. Falls back to BLUE.
@property {String} PERIWINKLE
Not supported. Falls back to BLUE.
@property {String} CYAN
Not supported. Falls back to BLUE.
@property {String} PLUM
Not supported. Falls back to RED.
@property {String} MAGENTA
Not supported. Falls back to RED.
@property {String} TANGERINE
Not supported. Falls back to ORANGE.
@property {String} CHARTREUSE
Not supported. Falls back to GREEN.
@property {String} KELLY_GREEN
Not supported. Falls back to GREEN.
*/
const color = {
DEFAULT: '',
GREY: 'grey',
BLUE: 'blue',
RED: 'red',
ORANGE: 'orange',
GREEN: 'green',
LIGHT_BLUE: 'lightblue',
PERIWINKLE: 'periwinkle',
PLUM: 'plum',
FUCHSIA: 'fuchsia',
MAGENTA: 'magenta',
TANGERINE: 'tangerine',
YELLOW: 'yellow',
CHARTREUSE: 'chartreuse',
KELLY_GREEN: 'kellygreen',
SEA_FOAM: 'seafoam',
CYAN: 'cyan'
};
const colorMap = {
lightblue: 'blue',
periwinkle: 'blue',
cyan: 'blue',
plum: 'red',
magenta: 'red',
tangerine: 'orange',
chartreuse: 'green',
kelly_green: 'green'
};
const swappedSize = commons.swapKeysAndValues(size);
// builds a string containing all possible color classnames. this will be used to remove classnames when the color
// changes
const ALL_COLOR_CLASSES = [];
for (const colorValue in color) {
ALL_COLOR_CLASSES.push(`${LABEL_CLASSNAME}--${color[colorValue]}`);
}
// builds a string containing all possible size classnames. this will be used to remove classnames when the size
// changes
const ALL_SIZE_CLASSES = [];
for (const sizeValue in Object.keys(size)) {
ALL_SIZE_CLASSES.push(`${LABEL_CLASSNAME}--${sizeValue}`);
}
const QUIET_CLASSNAME = `${CLASSNAME}--quiet`;
const MULTILINE_CLASSNAME = `${CLASSNAME}--multiline`;
// Store coordinates of a mouse down event to compare against mouseup coordinates.
let bullsEye = null;
// Utility method to detect center point of an element.
const getOffsetCenter = (element) => {
const rect = element.getBoundingClientRect();
const body = document.body;
const documentElement = document.documentElement;
const scrollTop = window.pageYOffset || documentElement.scrollTop || body.scrollTop;
const scrollLeft = window.pageXOffset || documentElement.scrollLeft || body.scrollLeft;
const clientTop = documentElement.clientTop || body.clientTop || 0;
const clientLeft = documentElement.clientLeft || body.clientLeft || 0;
const x = rect.left + rect.width / 2 + scrollLeft - clientLeft;
const y = rect.top + rect.height / 2 + scrollTop - clientTop;
return {
x: Math.round(x),
y: Math.round(y)
};
};
/**
@class Coral.Tag
@classdesc A Tag component
@htmltag coral-tag
@extends {HTMLElement}
@extends {BaseComponent}
*/
const Tag = Decorator(class extends BaseComponent(HTMLElement) {
/** @ignore */
constructor() {
super();
// Attach events
this._delegateEvents({
'click [handle="button"]': '_onRemoveButtonClick',
'key:backspace': '_onRemoveButtonClick',
'key:delete': '_onRemoveButtonClick'
});
// Prepare templates
this._elements = {
// Create or fetch the label element.
label: this.querySelector('coral-tag-label') || document.createElement('coral-tag-label')
};
base.call(this._elements, {i18n, Icon});
}
/**
The tag's label element.
@type {TagLabel}
@contentzone
*/
get label() {
return this._getContentZone(this._elements.label);
}
set label(value) {
this._setContentZone('label', value, {
handle: 'label',
tagName: 'coral-tag-label',
insert: function (label) {
label.classList.add(`${CLASSNAME}Label`);
this.insertBefore(label, this.firstChild);
this._updateAriaLabel();
}
});
}
/**
Whether this component can be closed.
@type {Boolean}
@default false
@htmlattribute closable
@htmlattributereflected
*/
get closable() {
return this._closable || false;
}
set closable(value) {
this._closable = transform.booleanAttr(value);
this._reflectAttribute('closable', this._closable);
// Only tags are closable
this._toggleTagVariant();
if (this._closable && !this.contains(this._elements.buttonCell)) {
// Insert the buttonCell if it was not added to the DOM
this.appendChild(this._elements.buttonCell);
}
this._elements.button.hidden = !this._closable;
this._elements.button.tabIndex = this._elements.button.hidden ? -1 : 0;
this._elements.buttonCell.hidden = !this._closable;
this._updateAriaLabel();
}
/**
Value of the tag. If not explicitly set, the value of <code>Node.textContent</code> is returned.
@type {String}
@default ""
@htmlattribute value
@htmlattributereflected
*/
get value() {
return typeof this._value === 'string' ? this._value : this.textContent.replace(/\s{2,}/g, ' ').trim();
}
set value(value) {
let _value = transform.string(value);
this._value = _value;
this._reflectAttribute('value', this._value);
this.trigger('coral-tag:_valuechanged');
}
/**
A quiet tag to differentiate it from default tag.
@type {Boolean}
@default false
@htmlattribute quiet
@htmlattributereflected
*/
get quiet() {
return this._quiet || false;
}
set quiet(value) {
this._quiet = transform.booleanAttr(value);
this._reflectAttribute('quiet', this._quiet);
// Only tags are quiet
this._toggleTagVariant();
}
/**
A multiline tag for block-level layout with multiline text.
@type {Boolean}
@default false
@htmlattribute multiline
@htmlattributereflected
*/
get multiline() {
return this._multiline || false;
}
set multiline(value) {
this._multiline = transform.booleanAttr(value);
this._reflectAttribute('multiline', this._multiline);
this.classList.toggle(MULTILINE_CLASSNAME, this._multiline);
}
/**
The tag's size. See {@link {TagSizeEnum}. Only colored tags can have different sizes.
@type {String}
@default TagSizeEnum.MEDIUM
@htmlattribute size
@htmlattributereflected
*/
get size() {
return this._size || size.MEDIUM;
}
set size(value) {
value = this._host ? size.MEDIUM : transform.string(value).toUpperCase();
this._size = validate.enumeration(size)(value) && value || size.MEDIUM;
this._reflectAttribute('size', this._size);
this._toggleTagVariant();
}
/**
The tags's color. See {@link TagColorEnum}.
@type {String}
@default Coral.Tag.color.DEFAULT
@htmlattribute color
@htmlattributereflected
*/
get color() {
return this._color || color.DEFAULT;
}
set color(value) {
value = this._host ? color.DEFAULT : transform.string(value).toLowerCase();
this._color = validate.enumeration(color)(value) && value || color.DEFAULT;
// Map unsupported colors
if (Object.keys(colorMap).indexOf(this._color) !== -1) {
this._color = colorMap[this._color];
}
this._reflectAttribute('color', this._color);
this._toggleTagVariant();
}
/**
Toggle between Tag and Label styles
@private
*/
_toggleTagVariant() {
const isColored = this.color !== color.DEFAULT;
// Base
this.classList.toggle(CLASSNAME, !isColored);
this.classList.toggle(LABEL_CLASSNAME, isColored);
// Closable
this.classList.toggle(`${CLASSNAME}--deletable`, !isColored);
// Quiet
this.classList.toggle(QUIET_CLASSNAME, !isColored && this.quiet);
// Size
this.classList.remove(...ALL_SIZE_CLASSES);
this.classList.toggle(`${LABEL_CLASSNAME}--${swappedSize[this.size].toLowerCase()}`, isColored);
// Color
this.classList.remove(...ALL_COLOR_CLASSES);
this.classList.toggle(`${LABEL_CLASSNAME}--${this.color}`, isColored);
}
/**
Inherited from {@link BaseComponent#trackingElement}.
*/
get trackingElement() {
// it uses the name as the first fallback since it is not localized, otherwise it uses the label
return typeof this._trackingElement === 'undefined' ?
// keep spaces to only 1 max and trim. this mimics native html behaviors
this.value || (this.label || this).textContent.replace(/\s{2,}/g, ' ').trim() :
this._trackingElement;
}
set trackingElement(value) {
super.trackingElement = value;
}
/** @private */
_onRemoveButtonClick(event) {
event.preventDefault();
if (this.closable && !this._elements.button.disabled) {
event.stopPropagation();
this.focus();
const host = this._host;
this.remove();
if (host) {
host._onTagButtonClicked(this, event);
}
}
}
/**
Updates the aria-label property from the button and label elements.
@ignore
*/
_updateAriaLabel() {
const button = this._elements.button;
const buttonCell = this._elements.buttonCell;
const label = this._elements.label;
// In the edge case that this is a Tag without a TagList,
// just treat the Tag as a container element without special labelling.
if (this.getAttribute('role') !== 'row') {
buttonCell.removeAttribute('role');
label.removeAttribute('role');
if (this.getAttribute('aria-labelledby') === label.id) {
this.removeAttribute('aria-labelledby');
}
return;
}
buttonCell.setAttribute('role', 'gridcell');
label.setAttribute('role', this._closable ? 'rowheader' : 'gridcell');
const buttonAriaLabel = button.getAttribute('title');
const labelTextContent = label.textContent;
// button should be labelled, "Remove: labelTextContent".
button.setAttribute('aria-label', `${buttonAriaLabel}: ${labelTextContent}`);
if (!label.id) {
label.id = commons.getUID();
}
this.setAttribute('aria-labelledby', label.id);
}
get _contentZones() {
return {'coral-tag-label': 'label'};
}
/**
Returns {@link Tag} sizes.
@return {TagSizeEnum}
*/
static get size() {
return size;
}
/**
Returns {@link Tag} colors.
@return {TagColorEnum}
*/
static get color() {
return color;
}
/** @ignore */
static get observedAttributes() {
return super.observedAttributes.concat([
'closable',
'value',
'quiet',
'multiline',
'size',
'color',
'disabled',
'role'
]);
}
/** @ignore */
attributeChangedCallback(name, oldValue, value) {
// This is required by TagList but we don't need to expose disabled publicly as API
if (name === 'disabled') {
this._elements.button.disabled = value;
}
// This is required by TagList but we don't need to expose disabled publicly as API
else if (name === 'role') {
this._updateAriaLabel();
} else {
super.attributeChangedCallback(name, oldValue, value);
}
}
/** @ignore */
connectedCallback() {
super.connectedCallback();
// Used to inform the tag list that it's added
this.trigger('coral-tag:_connected');
}
/** @ignore */
render() {
super.render();
// Default reflected attributes
if (!this._size) {
this.size = size.MEDIUM;
}
if (!this._color) {
this.color = color.DEFAULT;
}
const templateHandleNames = ['input', 'button', 'buttonCell'];
const label = this._elements.label;
// Remove it so we can process children
if (label.parentNode) {
this.removeChild(label);
}
// Process remaining elements as necessary
while (this.firstChild) {
const child = this.firstChild;
if (child.nodeType === Node.TEXT_NODE ||
templateHandleNames.indexOf(child.getAttribute('handle')) === -1) {
// Add non-template elements to the label
label.appendChild(child);
} else {
// Remove anything else
this.removeChild(child);
}
}
// Assign the content zones, moving them into place in the process
this.label = label;
}
/** @ignore */
disconnectedCallback() {
super.disconnectedCallback();
// Used to inform the tag list that it's removed synchronously
if (this._host) {
this._host._onItemDisconnected(this);
}
}
/**
Triggered when the {@link Tag} value is changed.
@typedef {CustomEvent} coral-tag:_valuechanged
@private
*/
/**
Triggered when the {@link Tag} is added to the document.
@typedef {CustomEvent} coral-tag:_connected
@private
*/
});
export default Tag;