Reference Source

coral-spectrum/coral-component-columnview/src/scripts/ColumnViewItem.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 accessibilityState from '../templates/accessibilityState';
import {BaseComponent} from '../../../coral-base-component';
import {BaseLabellable} from '../../../coral-base-labellable';
import {Icon} from '../../../coral-component-icon';
import {Checkbox} from '../../../coral-component-checkbox';
import {commons, i18n, transform, validate} from '../../../coral-utils';
import {Decorator} from '../../../coral-decorator';

const CLASSNAME = '_coral-AssetList-item';

/**
 Enumeration for {@link ColumnViewItem} variants.

 @typedef {Object} ColumnViewItemVariantEnum

 @property {String} DEFAULT
 Default item variant. Contains no special decorations.
 @property {String} DRILLDOWN
 An item with a right arrow indicating that the navigation will go one level down.
 */
const variant = {
  DEFAULT: 'default',
  DRILLDOWN: 'drilldown'
};

/**
 Utility that identifies Chrome on macOS, which announces drilldown items as "row 1 expanded" or "row 1 collapsed" when navigating between items.
 */
const isChromeMacOS = !!window && !!window.chrome && /Mac/i.test(window.navigator.platform);

/**
 @class Coral.ColumnView.Item
 @classdesc A ColumnView Item component
 @htmltag coral-columnview-item
 @extends {HTMLElement}
 @extends {BaseComponent}
 */
const ColumnViewItem = Decorator(class extends BaseLabellable(BaseComponent(HTMLElement)) {
  /** @ignore */
  constructor() {
    super();

    // Content zone
    this._elements = {
      content: this.querySelector('coral-columnview-item-content') || document.createElement('coral-columnview-item-content'),
      thumbnail: this.querySelector('coral-columnview-item-thumbnail') || document.createElement('coral-columnview-item-thumbnail'),
      accessibilityState: this.querySelector('span[handle="accessibilityState"]')
    };

    if (!this._elements.accessibilityState) {
      // Templates
      accessibilityState.call(this._elements, {commons});
    }
  }

  /**
   The content of the item.

   @type {ColumnViewItemContent}
   @contentzone
   */
  get content() {
    return this._getContentZone(this._elements.content);
  }

  set content(value) {
    this._setContentZone('content', value, {
      handle: 'content',
      tagName: 'coral-columnview-item-content',
      insert: function (content) {
        content.classList.add(`${CLASSNAME}Label`);
        // Insert before chevron
        this.insertBefore(content, this.querySelector('._coral-AssetList-itemChildIndicator'));
      }
    });
  }

  /**
   The thumbnail of the item. It is used to hold an icon or an image.

   @type {ColumnViewItemThumbnail}
   @contentzone
   */
  get thumbnail() {
    return this._getContentZone(this._elements.thumbnail);
  }

  set thumbnail(value) {
    this._setContentZone('thumbnail', value, {
      handle: 'thumbnail',
      tagName: 'coral-columnview-item-thumbnail',
      insert: function (thumbnail) {
        thumbnail.classList.add(`${CLASSNAME}Thumbnail`);
        // Insert before content
        this.insertBefore(thumbnail, this.content || null);
      }
    });
  }

  /**
   The item's variant. See {@link ColumnViewItemVariantEnum}.

   @type {String}
   @default ColumnViewItemVariantEnum.DEFAULT
   @htmlattribute variant
   @htmlattributereflected
   */
  get variant() {
    return this._variant || variant.DEFAULT;
  }

  set variant(value) {
    value = transform.string(value).toLowerCase();
    value = validate.enumeration(variant)(value) && value || variant.DEFAULT;

    this._reflectAttribute('variant', value);

    if(validate.valueMustChange(this._variant, value)) {
      this._variant = value;

      if (value === variant.DRILLDOWN) {
        // Render chevron on demand
        const childIndicator = this.querySelector('._coral-AssetList-itemChildIndicator');
        if (!childIndicator) {
          this.insertAdjacentHTML('beforeend', Icon._renderSVG('spectrum-css-icon-ChevronRightMedium', ['_coral-AssetList-itemChildIndicator', '_coral-UIIcon-ChevronRightMedium']));
        }
  
        this.classList.add('is-branch');
  
        // @a11y Update aria-expanded. Active drilldowns should be expanded.
        // Note: Omit aria-expanded on Chrome for macOS, because with VoiceOver tends
        // to announce drilldown items as "row 1 expanded" or "row 1 collapsed" when
        // navigating between items.
        if (this.selected || (isChromeMacOS && this.getAttribute('aria-level') === '1')) {
          this.removeAttribute('aria-expanded');
        } else {
          this.setAttribute('aria-expanded', this.active);
        }
      } else {
        this.classList.remove('is-branch');
        this.removeAttribute('aria-expanded');
      }
    }
  }

  /**
   Specifies the icon that will be placed inside the thumbnail. The size of the icon is always controlled by the
   component.

   @type {String}
   @default ""
   @htmlattribute icon
   @htmlattributereflected
   */
  get icon() {
    return this._icon || '';
  }

  set icon(value) {
    value = transform.string(value);
    
    this._reflectAttribute('icon', value);

    if(validate.valueMustChange(this._icon, value)) {
      this._icon = value;

      // ignored if it is an empty string
      if (value) {
        // creates a new icon element
        if (!this._elements.icon) {
          this._elements.icon = new Icon();
          // register observer only if there present an icon field.
          super._observeLabel();
        }
  
        this._elements.icon.icon = this.icon;
        this._elements.icon.size = Icon.size.SMALL;
  
        // removes all the items, since the icon attribute has precedence
        this._elements.thumbnail.innerHTML = '';
  
        // adds the newly created icon
        this._elements.thumbnail.appendChild(this._elements.icon);
      }
  
      super._toggleIconAriaHidden();
    }
  }

  /**
   Whether the item is selected.

   @type {Boolean}
   @default false
   @htmlattribute selected
   @htmlattributereflected
   */
  get selected() {
    return this._selected || false;
  }

  set selected(value) {
    value = transform.booleanAttr(value);
    this._reflectAttribute('selected', value);

    if(validate.valueMustChange(this._selected, value)) {
      this._selected = value;
      this.trigger('coral-columnview-item:_selectedchanged');
  
      // wait a frame before updating attributes
      commons.nextFrame(() => {
        this.classList.toggle('is-selected', value);
        this.setAttribute('aria-selected', value);
  
        // @a11y Update aria-expanded. Active drilldowns should be expanded.
        // Note: Omit aria-expanded on Chrome for macOS, because with VoiceOver tends
        // to announce drilldown items as "row 1 expanded" or "row 1 collapsed" when
        // navigating between items.
        if (value === variant.DRILLDOWN) {
          if (this._selected || (isChromeMacOS && this.getAttribute('aria-level') === '1')) {
            this.removeAttribute('aria-expanded');
          } else {
            this.setAttribute('aria-expanded', this.active);
          }
        }
  
        let accessibilityState = this._elements.accessibilityState;
  
        if (value) {
  
          // @a11y Panels to right of selected item are removed, so remove aria-owns and aria-describedby attributes.
          this.removeAttribute('aria-owns');
          this.removeAttribute('aria-describedby');
  
          // @a11y Update content to ensure that checked state is announced by assistive technology when the item receives focus
          accessibilityState.innerHTML = i18n.get(', checked');
  
          // @a11y append live region content element
          if (!this.contains(accessibilityState)) {
            this.appendChild(accessibilityState);
          }
        }
        // @a11y If deselecting from checked state,
        else {
  
          // @a11y remove, but retain reference to accessibilityState state
          if (accessibilityState.parentNode) {
            this._elements.accessibilityState = accessibilityState.parentNode.removeChild(accessibilityState);
          }
  
          // @a11y Update content to remove checked state
          this._elements.accessibilityState.innerHTML = '';
        }
  
        // @a11y Item should be labelled by thumbnail, content, and if appropriate accessibility state.
        let ariaLabelledby = this._elements.thumbnail.id + ' ' + this._elements.content.id;
        this.setAttribute('aria-labelledby', this.selected ? `${ariaLabelledby} ${accessibilityState.id}` : ariaLabelledby);
  
        // Sync checkbox item selector
        const itemSelector = this.querySelector('coral-checkbox[coral-columnview-itemselect]');
        if (itemSelector) {
          itemSelector[value ? 'setAttribute' : 'removeAttribute']('checked', '');
        }
      });
    }
  }

  /**
   Whether the item is active.

   @type {Boolean}
   @default false
   @htmlattribut active
   @htmlattributereflected
   */
  get active() {
    return this._active || false;
  }

  set active(value) {
    value = transform.booleanAttr(value);
    this._reflectAttribute('active', value);

    if(validate.valueMustChange(this._active, value)) {
      this._active = value;

      this.classList.toggle('is-navigated', value);
      this.setAttribute('aria-selected', this.hasAttribute('_selectable') ? this.selected : value);
  
      // @a11y Update aria-expanded. Active drilldowns should be expanded.
      // Note: Omit aria-expanded on Chrome for macOS, because with VoiceOver tends
      // to announce drilldown items as "row 1 expanded" or "row 1 collapsed" when
      // navigating between items.
      if (this.variant === variant.DRILLDOWN) {
        if (this.selected || (isChromeMacOS && this.getAttribute('aria-level') === '1')) {
          this.removeAttribute('aria-expanded');
        } else {
          this.setAttribute('aria-expanded', this.active);
        }
      }
  
      if (!value) {
        // @a11y Inactive items are not expanded, so remove aria-owns and aria-describedby attributes.
        this.removeAttribute('aria-owns');
        this.removeAttribute('aria-describedby');
      }
  
      this.trigger('coral-columnview-item:_activechanged');
    }
  }

  get _contentZones() {
    return {
      'coral-columnview-item-content': 'content',
      'coral-columnview-item-thumbnail': 'thumbnail'
    };
  }

  /** @ignore */
  attributeChangedCallback(name, oldValue, value) {
    if (name === '_selectable') {
      // Disable selection
      if (value === null) {
        this.classList.remove('is-selectable');
      }
      // Enable selection
      else {
        this.classList.add('is-selectable');
        let itemSelector = this.querySelector('[coral-columnview-itemselect]');

        // Render checkbox on demand
        if (!itemSelector) {
          itemSelector = new Checkbox();
          itemSelector.setAttribute('coral-columnview-itemselect', '');
          if (this.classList.contains('is-selected')) {
            itemSelector.setAttribute('checked', '');
          }
          itemSelector._elements.input.tabIndex = -1;
          itemSelector.setAttribute('labelledby', this._elements.content.id);

          // Add the item selector as first child
          this.insertBefore(itemSelector, this.firstChild);
        }
      }
    } else {
      super.attributeChangedCallback(name, oldValue, value);
    }
  }

  /**
   Returns {@link ColumnViewItem} variants.

   @return {ColumnViewItemVariantEnum}
   */
  static get variant() {
    return variant;
  }

  /** @ignore */
  static get observedAttributes() {
    return super.observedAttributes.concat([
      'variant',
      'icon',
      'selected',
      'active',
      '_selectable'
    ]);
  }

  /** @ignore */
  render() {
    super.render();

    this.classList.add(CLASSNAME);

    // @a11y
    this.setAttribute('role', 'treeitem');

    this.id = this.id || commons.getUID();

    // only set tabIndex if it is not already set
    if (!this.hasAttribute('tabindex')) {
      this.tabIndex = this.active || this.selected ? 0 : -1;
    }

    // Default reflected attributes
    if (!this._variant) {
      this.variant = variant.DEFAULT;
    }

    const thumbnail = this._elements.thumbnail;
    const content = this._elements.content;

    const contentZoneProvided = content.parentNode || thumbnail.parentNode;

    if (!contentZoneProvided) {
      // move the contents of the item into the content zone
      while (this.firstChild) {
        content.appendChild(this.firstChild);
      }
    }

    // Assign content zones
    this.content = content;
    this.thumbnail = thumbnail;

    // @a11y thumbnail img element should have alt attribute
    const thumbnailImg = thumbnail.querySelector('img:not([alt])');
    if (thumbnailImg) {
      thumbnailImg.setAttribute('alt', '');
    }

    // @ally add aria-labelledby so that JAWS/IE announces item correctly
    thumbnail.id = thumbnail.id || commons.getUID();

    content.id = content.id || commons.getUID();

    // @a11y Add live region element to ensure announcement of selected state
    const accessibilityState = this._elements.accessibilityState;

    // @a11y accessibility state string should announce in document lang, rather than item lang.
    accessibilityState.setAttribute('lang', i18n.locale);

    // @a11y Item should be labelled by thumbnail, content, and accessibility state.
    this.setAttribute('aria-labelledby', thumbnail.id + ' ' + content.id);

    //adding html title, on hovering over textcontent title will be visible
    this.setAttribute('title', this.content.textContent.trim());
  }
});

export default ColumnViewItem;