UNPKG

@adobe/coral-spectrum

Version:

Coral Spectrum is a JavaScript library of Web Components following Spectrum design patterns.

567 lines (475 loc) 16.8 kB
/** * 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 {SelectableCollection} from '../../../coral-collection'; import TreeItem from './TreeItem'; import {transform} from '../../../coral-utils'; import {Decorator} from '../../../coral-decorator'; const CLASSNAME = '_coral-TreeView'; /** @class Coral.Tree @classdesc A Tree component is a container component to display collapsible content. Tree items don't expand by default. It's the developer's responsibility to handle it by listening to the {@link coral-collection:add} and {@link coral-collection:remove} events. @htmltag coral-tree @extends {HTMLElement} @extends {BaseComponent} */ const Tree = Decorator(class extends BaseComponent(HTMLElement) { /** @ignore */ constructor() { super(); // Attach events this._delegateEvents({ 'click ._coral-TreeView-itemLink': '_onItemClick', 'click ._coral-TreeView-indicator': '_onExpandCollapseClick', 'coral-collection:add coral-tree-item': '_onCollectionChange', 'coral-collection:remove coral-tree-item': '_onCollectionChange', // a11y 'key:space ._coral-TreeView-itemLink': '_onItemClick', 'key:space ._coral-TreeView-indicator': '_onExpandCollapseClick', 'key:return ._coral-TreeView-itemLink, ._coral-TreeView-indicator': '_onExpandCollapseClick', 'key:pageup ._coral-TreeView-itemLink, ._coral-TreeView-indicator': '_onFocusPreviousItem', 'key:left ._coral-TreeView-itemLink, ._coral-TreeView-indicator': '_onCollapseItem', 'key:up ._coral-TreeView-itemLink, ._coral-TreeView-indicator': '_onFocusPreviousItem', 'key:pagedown ._coral-TreeView-itemLink, ._coral-TreeView-indicator': '_onFocusNextItem', 'key:right ._coral-TreeView-itemLink, ._coral-TreeView-indicator': '_onExpandItem', 'key:down ._coral-TreeView-itemLink, ._coral-TreeView-indicator': '_onFocusNextItem', 'key:home ._coral-TreeView-itemLink, ._coral-TreeView-indicator': '_onFocusFirstItem', 'key:end ._coral-TreeView-itemLink, ._coral-TreeView-indicator': '_onFocusLastItem', 'capture:blur ._coral-TreeView-itemLink[tabindex="0"]': '_onItemBlur', // private 'coral-tree-item:_selectedchanged': '_onItemSelectedChanged', 'coral-tree-item:_disabledchanged': '_onFocusableChanged', 'coral-tree-item:_expandedchanged': '_onFocusableChanged', 'coral-tree-item:_afterexpandedchanged': '_onExpandedChanged', 'coral-tree-item:_hiddenchanged': '_onFocusableChanged' }); // Used for eventing this._oldSelection = []; // Init the collection mutation observer this.items._startHandlingItems(true); // Listen for mutations for Torq compatibility const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { for (let i = 0 ; i < mutation.addedNodes.length ; i++) { const addedNode = mutation.addedNodes[i]; if (addedNode.tagName === 'CORAL-TREE-ITEM') { // Move tree items to their container if (addedNode.parentNode.tagName === addedNode.tagName) { addedNode.parentNode._elements.subTreeContainer.appendChild(addedNode); } } } }); }); observer.observe(this, { childList: true, subtree: true }); } /** The Collection Interface that allows interacting with the items that the component contains. @type {SelectableCollection} @readonly */ get items() { // just init on demand if (!this._items) { this._items = new SelectableCollection({ host: this, itemTagName: 'coral-tree-item' }); } return this._items; } /** Indicates whether the tree accepts multiple selected items. @type {Boolean} @default false @htmlattribute multiple @htmlattributereflected */ get multiple() { return this._multiple || false; } set multiple(value) { this._multiple = transform.booleanAttr(value); this._reflectAttribute('multiple', this._multiple); this.setAttribute('aria-multiselectable', this._multiple); this._validateSelection(); } /** Returns an Array containing the set selected items. @type {Array.<HTMLElement>} @readonly */ get selectedItems() { return this.items._getAllSelected(); } /** Returns the first selected item in the Tree. The value <code>null</code> is returned if no element is selected. @type {?HTMLElement} @readonly */ get selectedItem() { return this.items._getAllSelected()[0] || null; } /** @private */ _onItemSelectedChanged(event) { event.stopImmediatePropagation(); this._validateSelection(event.target); } /** @private */ _validateSelection(item) { const selectedItems = this.selectedItems; if (!this.multiple) { // Last selected item wins if multiple selection while not allowed item = item || selectedItems[selectedItems.length - 1]; if (item && item.hasAttribute('selected') && selectedItems.length > 1) { selectedItems.forEach((selectedItem) => { if (selectedItem !== item) { // Don't trigger change events this._preventTriggeringEvents = true; selectedItem.removeAttribute('selected'); } }); // We can trigger change events again this._preventTriggeringEvents = false; } } this._triggerChangeEvent(); } /** @private */ _triggerChangeEvent() { const selectedItems = this.selectedItems; const oldSelection = this._oldSelection; if (!this._preventTriggeringEvents && this._arraysAreDifferent(selectedItems, oldSelection)) { // We differentiate whether multiple is on or off and return an array or HTMLElement respectively if (this.multiple) { this.trigger('coral-tree:change', { oldSelection: oldSelection, selection: selectedItems }); } else { // Return all items if we just switched from multiple=true to multiple=false and we had >1 selected items this.trigger('coral-tree:change', { oldSelection: oldSelection.length > 1 ? oldSelection : oldSelection[0] || null, selection: selectedItems[0] || null }); } this._oldSelection = selectedItems; } } /** @private */ _arraysAreDifferent(selection, oldSelection) { let diff = []; if (oldSelection.length === selection.length) { diff = oldSelection.filter((item) => selection.indexOf(item) === -1); } // since we guarantee that they are arrays, we can start by comparing their size return oldSelection.length !== selection.length || diff.length !== 0; } /** @private */ _toggleItemAttribute(item, attributeName) { if (item) { item[item.hasAttribute(attributeName) ? 'removeAttribute' : 'setAttribute'](attributeName, ''); } } /** @private */ _onCollectionChange(event) { // Prevent triggering collection event twice. Only coral-tree collection events are propagated. event.stopImmediatePropagation(); } /** @private */ _onItemClick(event) { // Clickable item inside Tree Item should not trigger selection of item if (event.target.hasAttribute('coral-interactive') || event.target.closest('[coral-interactive]')) { return; } // If the indicator is clicked, expand/collapse the tree item if (event.target.closest('._coral-TreeView-indicator')) { this._onExpandCollapseClick(event); return; } // The click was performed on the header so we select the item (parentNode) the selection is toggled const item = event.target.closest('coral-tree-item'); if (item && !item.hasAttribute('disabled')) { event.preventDefault(); event.stopPropagation(); // We ignore the selection if the item is disabled this._toggleItemAttribute(item, 'selected'); const focusable = this._getFocusable(); if (focusable) { focusable.setAttribute('tabindex', '-1'); } item._elements.header.setAttribute('tabindex', '0'); item._elements.header.focus(); } } /** @private */ _onExpandCollapseClick(event) { event.preventDefault(); event.stopPropagation(); // The click was performed on the icon to expand/collapse the sub tree const item = event.target.closest('coral-tree-item'); if (item) { // We ignore the expand/collapse if the item is disabled if (item.hasAttribute('disabled')) { return; } // Toggle the expanded of the item: this._toggleItemAttribute(item, 'expanded'); } } /** @private */ _onExpandItem(event) { event.preventDefault(); event.stopPropagation(); // The click was performed on the icon to expand the sub tree const item = event.target.closest('coral-tree-item'); if (item) { // We ignore the expand if the item is disabled if (item.hasAttribute('disabled')) { return; } if (!item.expanded && item.variant === TreeItem.variant.DRILLDOWN) { // If the item is not expanded, expand the item item.expanded = !item.expanded; item._elements.header.classList.add('focus-ring'); } else if (item.items.length > 0) { // If the item is expanded, and contains items, focus the next item this._onFocusNextItem(event); } } } /** @private */ _onCollapseItem(event) { event.preventDefault(); event.stopPropagation(); // The click was performed on the icon to collapse the sub tree const item = event.target.closest('coral-tree-item'); if (item) { // We ignore the expand if the item is disabled if (item.hasAttribute('disabled')) { return; } if (item.expanded && item.variant === TreeItem.variant.DRILLDOWN) { // If the item is not expanded, expand the item item.expanded = !item.expanded; item._elements.header.classList.add('focus-ring'); } else if (item.parent) { item._elements.header.setAttribute('tabindex', '-1'); item._elements.header.classList.remove('focus-ring'); item.parent.focus(); item.parent._elements.header.classList.add('focus-ring'); } } } /** @private */ _focusSiblingItem(item, next) { const focusableItems = this._getFocusableItems(); // There's not enough items to change focus if (focusableItems.length < 2) { return; } let index = focusableItems.indexOf(item) + (next ? 1 : -1); let siblingItem = null; // If we reached the edge, target the other edge if (index > focusableItems.length - 1) { siblingItem = focusableItems[0]; } else if (index < 0) { siblingItem = focusableItems[focusableItems.length - 1]; } // Find the sibling item while (!siblingItem) { siblingItem = focusableItems[index]; // The item might be hidden because a parent is collapsed if (siblingItem.parentNode.closest('coral-tree-item.is-collapsed')) { if (next) { index++; siblingItem = index > focusableItems.length - 1 ? item : null; } else { index--; siblingItem = index < 0 ? item : null; } } } // Change focus if (siblingItem !== item) { item._elements.header.setAttribute('tabindex', '-1'); item._elements.header.classList.remove('focus-ring'); siblingItem._elements.header.setAttribute('tabindex', '0'); siblingItem._elements.header.classList.add('focus-ring'); siblingItem._elements.header.focus(); } } /** @private */ _focusEdgeItem(last) { // Query the focusable item const focusable = this._getFocusable(); if (focusable) { const focusableItems = this._getFocusableItems(); const edgeItem = focusableItems[last ? focusableItems.length - 1 : 0]; // Change focus if (edgeItem !== focusable) { focusable.setAttribute('tabindex', '-1'); edgeItem._elements.header.setAttribute('tabindex', '0'); edgeItem._elements.header.focus(); } } } /** @private */ _onFocusNextItem(event) { event.preventDefault(); event.stopPropagation(); const item = event.target.closest('coral-tree-item'); if (item) { this._focusSiblingItem(item, true); } } /** @private */ _onFocusPreviousItem(event) { event.preventDefault(); event.stopPropagation(); const item = event.target.closest('coral-tree-item'); if (item) { this._focusSiblingItem(item, false); } } /** @private */ _onFocusFirstItem(event) { event.preventDefault(); event.stopPropagation(); this._focusEdgeItem(false); } /** @private */ _onFocusLastItem(event) { event.preventDefault(); event.stopPropagation(); this._focusEdgeItem(true); } /** @private */ _onFocusableChanged(event) { event.preventDefault(); event.stopPropagation(); if (event.target.contains(this._getFocusable())) { this._resetFocusableItem(); } } /** @private */ _onExpandedChanged(event) { event.stopImmediatePropagation(); const item = event.target; this.trigger(`coral-tree:${item.expanded ? 'expand' : 'collapse'}`, {item}); } /** @private */ _getFocusable() { return this.querySelector('coral-tree-item > ._coral-TreeView-itemLink[tabindex="0"]'); } /** @private */ _getFocusableItems() { return this.items.getAll().filter((item) => !item.closest('coral-tree-item[disabled]') && !item.closest('coral-tree-item[hidden]')); } /** @private */ _onItemBlur() { const focused = this.querySelector('._coral-TreeView-itemLink.focus-ring'); if (focused) { focused.classList.remove('focus-ring'); } } /** @private */ _resetFocusableItem(item) { // Old focusable becomes unfocusable const focusable = this._getFocusable(); if (focusable) { focusable.setAttribute('tabindex', '-1'); focusable.classList.remove('focus-ring'); } // Defined item or first item by default gets the focus item = item || this._getFocusableItems()[0]; if (item) { item._elements.header.setAttribute('tabindex', '0'); } } /** @private */ _expandCollapseAll(expand) { const coralTreeItems = this.querySelectorAll('coral-tree-item'); if (coralTreeItems) { let item; const length = coralTreeItems.length; if (length > 0) { for (let index = 0 ; index < length ; index++) { item = coralTreeItems[index]; if (item) { item.expanded = expand; } } } } } /** Expand all the Tree Items */ expandAll() { this._expandCollapseAll(true); } /** Collapse all the Tree Items */ collapseAll() { this._expandCollapseAll(false); } /** @ignore */ static get observedAttributes() { return super.observedAttributes.concat(['multiple']); } /** @ignore */ render() { super.render(); this.classList.add(CLASSNAME); // a11y this.setAttribute('role', 'tree'); this.setAttribute('aria-multiselectable', this.multiple); // Enable keyboard interaction requestAnimationFrame(() => { this._resetFocusableItem(); }); // Don't trigger events once connected this._preventTriggeringEvents = true; this._validateSelection(); this._preventTriggeringEvents = false; this._oldSelection = this.selectedItems; } /** Triggered when the {@link Tree} selection changed. @typedef {CustomEvent} coral-tree:change @property {Array.<TreeItem>} detail.oldSelection The old selected item. @property {Array.<TreeItem>} detail.selection The selected items. */ /** Triggered when a {@link Tree} item expanded. @typedef {CustomEvent} coral-tree:expand @property {TreeItem} detail.item The expanded item. */ /** Triggered when a {@link Tree} item collapsed. @typedef {CustomEvent} coral-tree:collapse @property {TreeItem} detail.item The collapsed item. */ }); export default Tree;