coral-spectrum/coral-component-tree/src/scripts/TreeItem.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 {Collection} from '../../../coral-collection';
import {Icon} from '../../../coral-component-icon';
import treeItem from '../templates/treeItem';
import {transform, commons, i18n, validate} from '../../../coral-utils';
import {Decorator} from '../../../coral-decorator';
const CLASSNAME = '_coral-TreeView-item';
/**
Enumeration for {@link TreeItem} variants.
@typedef {Object} TreeItemVariantEnum
@property {String} DRILLDOWN
Default variant with icon to expand/collapse subtree.
@property {String} LEAF
Variant for leaf items. Icon to expand/collapse subtree is hidden.
*/
const variant = {
/* Default variant with icon to expand/collapse subtree. */
DRILLDOWN: 'drilldown',
/* Variant for leaf items. Icon to expand/collapse subtree is hidden. */
LEAF: 'leaf'
};
const ALL_VARIANT_CLASSES = [];
for (const variantValue in variant) {
ALL_VARIANT_CLASSES.push(`${CLASSNAME}--${variant[variantValue]}`);
}
const IS_TOUCH_DEVICE = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
/**
@class Coral.Tree.Item
@classdesc A Tree item component
@htmltag coral-tree-item
@extends {HTMLElement}
@extends {BaseComponent}
*/
const TreeItem = Decorator(class extends BaseComponent(HTMLElement) {
/** @ignore */
constructor() {
super();
// Prepare templates
this._elements = {
// Create or fetch the content zones
content: this.querySelector('coral-tree-item-content') || document.createElement('coral-tree-item-content')
};
treeItem.call(this._elements, {Icon, commons});
if (!this._elements.icon) {
this._elements.icon = this._elements.header.querySelector('._coral-TreeView-indicator');
}
// Tells the collection to automatically detect the items and handle the events
this.items._startHandlingItems();
}
/**
The parent tree. Returns <code>null</code> if item is the root.
@type {HTMLElement}
@readonly
*/
get parent() {
return this._parent || null;
}
/**
The content of this tree item.
@type {TreeItemContent}
@contentzone
*/
get content() {
return this._getContentZone(this._elements.content);
}
set content(value) {
this._setContentZone('content', value, {
handle: 'content',
tagName: 'coral-tree-item-content',
insert: function (content) {
this._elements.header.appendChild(content);
}
});
}
/**
The Collection Interface that allows interacting with the items that the component contains.
@type {Collection}
@readonly
*/
get items() {
// Construct the collection on first request
if (!this._items) {
this._items = new Collection({
host: this,
itemTagName: 'coral-tree-item',
itemSelector: ':scope > coral-tree-item',
onlyHandleChildren: true,
container: this._elements.subTreeContainer,
filter: this._filterItem.bind(this),
onItemAdded: this._onItemAdded,
onItemRemoved: this._onItemRemoved
});
}
return this._items;
}
/**
Whether the item is expanded. Expanded cannot be set to <code>true</code> if the item is disabled.
@type {Boolean}
@default false
@htmlattribute expanded
@htmlattributereflected
*/
get expanded() {
return this._expanded || false;
}
set expanded(value) {
value = transform.booleanAttr(value);
const triggerEvent = this.expanded !== value;
this._expanded = value;
this._reflectAttribute('expanded', this._expanded);
const header = this._elements.header;
const subTreeContainer = this._elements.subTreeContainer;
this.classList.toggle('is-open', this._expanded);
this.classList.toggle('is-collapsed', !this._expanded);
if (this.variant !== variant.DRILLDOWN) {
header.removeAttribute('aria-expanded');
header.removeAttribute('aria-owns');
} else if (this.items.length > 0) {
header.setAttribute('aria-expanded', this._expanded);
header.setAttribute('aria-owns', subTreeContainer.id);
}
if (this._expanded) {
subTreeContainer.removeAttribute('aria-hidden');
} else {
subTreeContainer.setAttribute('aria-hidden', !this._expanded);
}
if (IS_TOUCH_DEVICE) {
const icon = header.querySelector('._coral-TreeView-indicator');
icon.setAttribute('aria-label', i18n.get(this._expanded ? 'Collapse' : 'Expand'));
}
this.trigger('coral-tree-item:_expandedchanged');
// Do animation in next frame to avoid a forced reflow
window.requestAnimationFrame(() => {
// Don't animate on initialization
if (this._animate) {
// Remove height as we want the drawer to naturally grow if content is added later
commons.transitionEnd(subTreeContainer, () => {
if (this.expanded) {
subTreeContainer.style.height = '';
} else {
subTreeContainer.hidden = true;
}
// Trigger once the animation is over to inform coral-tree
if (triggerEvent) {
this.trigger('coral-tree-item:_afterexpandedchanged');
}
});
// Force height to enable transition
if (!this.expanded) {
subTreeContainer.style.height = `${subTreeContainer.scrollHeight}px`;
} else {
subTreeContainer.hidden = false;
}
// We read the offset height to force a reflow, this is needed to start the transition between absolute values
// https://blog.alexmaccaw.com/css-transitions under Redrawing
// eslint-disable-next-line no-unused-vars
const offsetHeight = subTreeContainer.offsetHeight;
subTreeContainer.style.height = this.expanded ? `${subTreeContainer.scrollHeight}px` : 0;
} else {
// Make sure it's animated next time
this._animate = true;
// Hide it on initialization if closed
if (!this.expanded) {
subTreeContainer.style.height = 0;
subTreeContainer.hidden = true;
}
}
});
}
/**
The item's variant. See {@link TreeItemVariantEnum}.
@type {String}
@default TreeItemVariant.DRILLDOWN
@htmlattribute variant
@htmlattributereflected
*/
get variant() {
return this._variant || variant.DRILLDOWN;
}
set variant(value) {
value = transform.string(value).toLowerCase();
this._variant = validate.enumeration(variant, value) && value || variant.DRILLDOWN;
// removes every existing variant
this.classList.remove(...ALL_VARIANT_CLASSES);
this.classList.add(`${CLASSNAME}--${this._variant}`);
}
/**
Whether the item is selected.
@type {Boolean}
@default false
@htmlattribute selected
@htmlattributereflected
*/
get selected() {
return this._selected || false;
}
set selected(value) {
this._selected = transform.booleanAttr(value);
this._reflectAttribute('selected', this._selected);
this._elements.header.classList.toggle('is-selected', this._selected);
this._elements.header.setAttribute('aria-selected', this._selected);
const selectedState = this._elements.selectedState;
selectedState.textContent = i18n.get(this._selected ? 'selected' : 'not selected');
if (IS_TOUCH_DEVICE) {
selectedState.setAttribute('aria-pressed', this._selected);
}
this.trigger('coral-tree-item:_selectedchanged');
}
/**
Whether this item is disabled.
@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._elements.header.classList.toggle('is-disabled', this._disabled);
this._elements.header[this._disabled ? 'setAttribute' : 'removeAttribute']('aria-disabled', this._disabled);
this.trigger('coral-tree-item:_disabledchanged');
}
/**
@ignore
*/
get hidden() {
return this.hasAttribute('hidden');
}
set hidden(value) {
this._reflectAttribute('hidden', transform.booleanAttr(value));
// We redefine hidden to trigger an event
this.trigger('coral-tree-item:_hiddenchanged');
}
/** @private */
_filterItem(item) {
// Handle nesting check for parent tree item
// Use parentNode for added items
// Use _parent for removed items
return item.parentNode && item.parentNode.parentNode === this || item._parent === this;
}
/** @private */
_onItemAdded(item) {
item._parent = this;
const header = this._elements.header;
const subTreeContainer = this._elements.subTreeContainer;
if (!header.hasAttribute('aria-owns')) {
header.setAttribute('aria-owns', subTreeContainer.id);
}
}
/** @private */
_onItemRemoved(item) {
item._parent = undefined;
// If there are no items the subTreeContainer
if (!this.items.length) {
this._elements.header.removeAttribute('aria-owns');
}
}
/**
Handles the focus of the item.
@ignore
*/
focus() {
this._elements.header.setAttribute('tabindex', '0');
this._elements.header.focus();
}
/**
Returns {@link TreeItem} variants.
@return {TreeItemVariantEnum}
*/
static get variant() {
return variant;
}
get _contentZones() {
return {'coral-tree-item-content': 'content'};
}
/** @ignore */
static get observedAttributes() {
return super.observedAttributes.concat(['selected', 'disabled', 'variant', 'expanded', 'hidden']);
}
/** @ignore */
render() {
super.render();
this.classList.add(CLASSNAME);
const header = this._elements.header;
const subTreeContainer = this._elements.subTreeContainer;
const content = this._elements.content;
const selectedState = this._elements.selectedState;
// a11ys
content.id = content.id || commons.getUID();
this.setAttribute('role', 'presentation');
header.setAttribute('aria-labelledby', `${content.id} ${selectedState.id}`);
header.setAttribute('aria-selected', this.selected);
subTreeContainer.setAttribute('aria-labelledby', content.id);
selectedState.textContent = i18n.get(this.selected ? 'selected' : 'not selected');
if (IS_TOUCH_DEVICE) {
const icon = this._elements.icon || header.querySelector('._coral-TreeView-indicator');
if (icon && !icon.id) {
icon.id = commons.getUID();
}
icon.setAttribute('role', 'button');
icon.setAttribute('tabindex', '-1');
icon.setAttribute('aria-labelledby', icon.id + ' ' + content.id);
icon.setAttribute('aria-label', i18n.get(this.expanded ? 'Collapse' : 'Expand'));
icon.setAttribute('style', 'outline: none !important');
icon.removeAttribute('aria-hidden');
selectedState.setAttribute('role', 'button');
selectedState.setAttribute('tabindex', '-1');
selectedState.setAttribute('aria-labelledby', content.id + ' ' + selectedState.id);
selectedState.setAttribute('aria-pressed', this.selected);
selectedState.setAttribute('style', 'outline: none !important');
}
// Default reflected attributes
if (!this._variant) {
this.variant = variant.DRILLDOWN;
}
this.expanded = this.expanded;
// Render the template and set element references
const frag = document.createDocumentFragment();
const templateHandleNames = ['header', 'icon', 'subTreeContainer'];
const subTree = this.querySelector('._coral-TreeView');
if (subTree) {
const items = subTree.querySelectorAll('coral-tree-item');
for (let i = 0 ; i < items.length ; i++) {
subTreeContainer.appendChild(items[i]);
}
}
// Add templates into the frag
frag.appendChild(header);
frag.appendChild(subTreeContainer);
// Assign the content zones, moving them into place in the process
this.content = content;
// Move any remaining elements into the content sub-component
while (this.firstChild) {
const child = this.firstChild;
if (child.nodeName === 'CORAL-TREE-ITEM') {
// Adding parent attribute to access the parent directly
child._parent = this;
// Add tree items to the sub tree container
subTreeContainer.appendChild(child);
} else if (child.nodeType === Node.TEXT_NODE ||
child.nodeType === Node.ELEMENT_NODE && templateHandleNames.indexOf(child.getAttribute('handle')) === -1) {
// Add non-template elements to the content
content.appendChild(child);
} else {
// Remove anything else element
this.removeChild(child);
}
}
if (this.variant === variant.DRILLDOWN && this.items.length && !header.hasAttribute('aria-owns')) {
header.setAttribute('aria-owns', subTreeContainer.id);
}
// Lastly, add the fragment into the container
this.appendChild(frag);
}
/**
Triggered when {@link TreeItem#selected} changed.
@typedef {CustomEvent} coral-tree-item:_selectedchanged
@private
*/
/**
Triggered when {@link TreeItem#expanded} changed.
@typedef {CustomEvent} coral-tree-item:_expandedchanged
@private
*/
/**
Triggered when {@link TreeItem#hidden} changed.
@typedef {CustomEvent} coral-tree-item:_hiddenchanged
@private
*/
/**
Triggered when {@link TreeItem#disabled} changed.
@typedef {CustomEvent} coral-tree-item:_disabledchanged
@private
*/
});
export default TreeItem;