Reference Source

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;