coral-spectrum/coral-component-tablist/src/scripts/Tab.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 {BaseLabellable} from '../../../coral-base-labellable';
import base from '../templates/base';
import {transform, commons} from '../../../coral-utils';
import {Icon} from '../../../coral-component-icon';
import getTarget from './getTarget';
import {Decorator} from '../../../coral-decorator';
const CLASSNAME = '_coral-Tabs-item';
/**
@class Coral.Tab
@classdesc A Tab component
@htmltag coral-tab
@extends {HTMLElement}
@extends {BaseComponent}
*/
const Tab = Decorator(class extends BaseLabellable(BaseComponent(HTMLElement)) {
/** @ignore */
constructor() {
super();
// Templates
this._elements = {
label: this.querySelector('coral-tab-label') || document.createElement('coral-tab-label'),
invalidIcon: this.querySelector('._coral-Tabs-itemInvalidIcon') || this._createInvalidIcon()
};
base.call(this._elements);
// Listen for mutations
this._observer = new MutationObserver(() => {
// Change icon size if the label is empty
const icon = this._elements.icon;
if (icon) {
icon.size = this._elements.label.textContent.trim().length ? Icon.size.EXTRA_SMALL : Icon.size.SMALL;
}
super._toggleIconAriaHidden();
this.trigger('coral-tab:_sizechanged');
});
// Watch for changes to the label element
this._observer.observe(this._elements.label, {
childList: true,
characterData: true,
subtree: true
});
}
/**
The label of the tab.
@type {TabLabel}
@contentzone
*/
get label() {
return this._getContentZone(this._elements.label);
}
set label(value) {
this._setContentZone('label', value, {
handle: 'label',
tagName: 'coral-tab-label',
insert: function (label) {
label.classList.add(`${CLASSNAME}Label`);
this.appendChild(label);
this._toggleEllipsis();
}
});
}
/**
Specifies the name of the icon used inside the Tab. See {@link Icon} for valid icon names.
@type {String}
@default ""
@htmlattribute icon
*/
get icon() {
const iconElement = this._elements.icon;
return iconElement ? iconElement.icon : '';
}
set icon(value) {
const iconElement = this._elements.icon;
iconElement.icon = value;
// removes the icon element from the DOM.
if (this.icon === '') {
iconElement.remove();
this.trigger('coral-tab:_sizechanged');
}
// adds the icon back since it was blown away by textContent
else if (!iconElement.parentElement) {
// Change icon size if the label is empty
if (!this._elements.label.textContent.trim().length) {
iconElement.size = Icon.size.SMALL;
}
super._toggleIconAriaHidden();
this.insertBefore(iconElement, this.firstChild);
this.trigger('coral-tab:_sizechanged');
}
}
/**
Whether the current Tab is invalid.
@type {Boolean}
@default false
@htmlattribute invalid
@htmlattributereflected
*/
get invalid() {
return this._invalid || false;
}
set invalid(value) {
this._invalid = transform.booleanAttr(value);
this._reflectAttribute('invalid', this._invalid);
this.classList.toggle('is-invalid', this._invalid);
this.setAttribute('aria-invalid', this._invalid);
if (this._invalid) {
this._elements.invalidIcon.removeAttribute('hidden');
} else {
this._elements.invalidIcon.setAttribute('hidden', 'true');
}
}
/**
Whether this Tab is disabled. When set to true, this will prevent every user interacting with the Tab. If
disabled is set to true for a selected Tab it will be deselected.
@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.classList.toggle('is-disabled', this._disabled);
this[this._disabled ? 'setAttribute' : 'removeAttribute']('aria-disabled', this._disabled);
if (this._disabled && this.selected) {
this.selected = false;
}
if (!this._disabled && !this.selected) {
// We inform the parent to verify if this item should be selected because it's the only one left
this.trigger('coral-tab:_validateselection');
}
}
/**
Whether the tab is selected.
@type {Boolean}
@default false
@htmlattribute selected
@htmlattributereflected
*/
get selected() {
return this._selected || false;
}
set selected(value) {
value = transform.booleanAttr(value);
if (!value || value && !this.disabled) {
this._selected = value;
this._reflectAttribute('selected', this.disabled ? false : this._selected);
this.classList.toggle('is-selected', this._selected);
this.setAttribute('tabindex', this._selected ? '0' : '-1');
this.setAttribute('aria-selected', this._selected);
// in case the tab is selected, we need to communicate it to the panels.
if (this._selected) {
this._selectTarget();
}
this.trigger('coral-tab:_selectedchanged');
}
}
/**
The target element that will be selected when this Tab is selected. It accepts a CSS selector or a DOM element.
If a CSS Selector is provided, the first matching element will be used.
@type {?HTMLElement|String}
@default null
@htmlattribute target
*/
get target() {
return typeof this._target === 'string' ? this._target : this._target || null;
}
set target(value) {
if (value === null || typeof value === 'string' || value instanceof Node) {
this._target = value;
const realTarget = getTarget(this.target);
// we add proper accessibility if available
if (realTarget) {
// creates a 2 way binding for accessibility
this.setAttribute('aria-controls', realTarget.id);
realTarget.setAttribute('aria-labelledby', this.id);
// adds role to panel to support accessibility
realTarget.setAttribute('role', 'tabpanel');
}
}
}
/**
Inherited from {@link BaseComponent#trackingElement}.
*/
get trackingElement() {
return typeof this._trackingElement === 'undefined' ?
// keep spaces to only 1 max and trim. this mimics native html behaviors
(this.label || this).textContent.replace(/\s{2,}/g, ' ').trim() :
this._trackingElement;
}
set trackingElement(value) {
super.trackingElement = value;
}
_toggleEllipsis() {
requestAnimationFrame(() => {
this.classList.toggle('is-overflowing', this._elements.label.clientWidth > this.clientWidth);
});
}
/**
Selects the target item
@ignore
*/
_selectTarget() {
let realTarget = getTarget(this.target);
// if the target was define at the tab level, it has precedence over everything
if (realTarget) {
realTarget.setAttribute('selected', '');
}
// otherwise, we use the target defined at the tablist level
else {
const tabList = this.parentNode;
if (tabList && tabList.target) {
realTarget = getTarget(tabList.target);
if (realTarget) {
// we get the position of this tab inside the tablist
const currentIndex = tabList.items.getAll().indexOf(this);
// we select the item with the same index
const targetItem = (realTarget.items ? realTarget.items.getAll() : realTarget.children)[currentIndex];
// we select the item if it exists
if (targetItem) {
targetItem.setAttribute('selected', '');
}
}
}
}
}
_createInvalidIcon() {
const iconElement = document.createElement('coral-icon');
iconElement.icon = 'alert';
iconElement.size = Icon.size.EXTRA_SMALL;
iconElement.setAttribute('aria-hidden', 'true');
iconElement.classList.add('_coral-Tabs-itemInvalidIcon');
if (!this._invalid) {
iconElement.setAttribute('hidden', 'true');
}
return iconElement;
}
get _contentZones() {
return {'coral-tab-label': 'label'};
}
/** @ignore */
static get observedAttributes() {
return super.observedAttributes.concat(['selected', 'disabled', 'icon', 'invalid', 'target']);
}
/** @ignore */
connectedCallback() {
super.connectedCallback();
// Query the tab target once the tab item is inserted in the DOM
if (this.selected) {
this._selectTarget();
}
}
/** @ignore */
render() {
super.render();
this.classList.add(CLASSNAME);
// adds the role to support accessibility
this.setAttribute('role', 'tab');
// Generate a unique ID for the tab panel if one isn't already present
// This will be used for accessibility purposes
this.setAttribute('id', this.id || commons.getUID());
// Create a fragment
const frag = document.createDocumentFragment();
// Render the main template
if (this.icon) {
frag.appendChild(this._elements.icon);
}
if (this._elements.invalidIcon) {
frag.append(this._elements.invalidIcon);
}
const label = this._elements.label;
// Remove it so we can process children
if (label.parentNode) {
label.parentNode.removeChild(label);
}
while (this.firstChild) {
const child = this.firstChild;
if (child.nodeType === Node.TEXT_NODE ||
child.nodeType === Node.ELEMENT_NODE && child.getAttribute('handle') !== 'icon') {
// Add non-template elements to the label
label.appendChild(child);
} else {
this.removeChild(child);
}
}
// Add the frag to the component
this.appendChild(frag);
// Assign the content zones, moving them into place in the process
this.label = label;
}
});
export default Tab;