Reference Source

coral-spectrum/coral-component-tablist/src/scripts/TabList.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 {SelectableCollection} from '../../../coral-collection';
import {transform, validate, commons} from '../../../coral-utils';
import line from '../templates/line';
import getTarget from './getTarget';

/**
 Enumeration for {@link TabList} sizes.

 @typedef {Object} TabListSizeEnum

 @property {String} SMALL
 A small-sized tablist.
 @property {String} MEDIUM
 A medium-sized tablist. This is the default.
 @property {String} LARGE
 A large-sized tablist.
 */
const size = {
  SMALL: 'S',
  MEDIUM: 'M',
  LARGE: 'L'
};

/**
 Enumeration for {@link TabList} orientations.

 @typedef {Object} TabListOrientationEnum

 @property {String} HORIZONTAL
 Horizontal TabList, this is the default value.
 @property {String} VERTICAL
 Vertical TabList.
 */
const orientation = {
  HORIZONTAL: 'horizontal',
  VERTICAL: 'vertical'
};

// the tablist's base classname
const CLASSNAME = '_coral-Tabs';

/**
 @class Coral.TabList
 @classdesc A TabList component holds a collection of tabs.
 @htmltag coral-tablist
 @extends {HTMLElement}
 @extends {BaseComponent}
 */
class TabList extends BaseComponent(HTMLElement) {
  /** @ignore */
  constructor() {
    super();

    // Templates
    this._elements = {};
    line.call(this._elements);

    // Attach events
    this._delegateEvents({
      'click > coral-tab': '_onTabClick',
      'key:home > coral-tab': '_onHomeKey',
      'key:end > coral-tab': '_onEndKey',
      'key:pagedown > coral-tab': '_selectNextItem',
      'key:right > coral-tab': '_selectNextItem',
      'key:down > coral-tab': '_selectNextItem',
      'key:pageup > coral-tab': '_selectPreviousItem',
      'key:left > coral-tab': '_selectPreviousItem',
      'key:up > coral-tab': '_selectPreviousItem',

      'global:coral-commons:_webfontactive': '_setLine',

      // private
      'coral-tab:_selectedchanged': '_onItemSelectedChanged',
      'coral-tab:_validateselection': '_onValidateSelection',
      'coral-tab:_sizechanged': '_setLine'
    });

    // Used for eventing
    this._oldSelection = null;

    // Debounce timer
    this._timeout = null;
    // Debounce wait in milliseconds
    this._wait = 50;

    this._setLine = this._setLine.bind(this);

    // Init the collection mutation observer
    this.items._startHandlingItems(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-tab',
        onItemAdded: this._onItemAdded,
        onItemRemoved: this._onItemRemoved
      });
    }
    return this._items;
  }

  /**
   The selected item in the TabList.

   @type {HTMLElement}
   @readonly
   */
  get selectedItem() {
    return this.items._getLastSelected();
  }

  /**
   The target component that will be linked to the TabList. It accepts either a CSS selector or a DOM element. If a
   CSS Selector is provided, the first matching element will be used. Items will be selected based on the index. If
   both target and {@link Coral.Tab#target} are set, the second will have higher priority.

   @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;

      // we do in case the target was not yet in the DOM
      !(this._setTargetInQueue === true) && window.requestAnimationFrame(() => {
        delete this._setTargetInQueue;

        const realTarget = getTarget(this._target);
        // we add proper accessibility if available
        if (realTarget) {
          const tabItems = this.items.getAll();
          const panelItems = realTarget.items ? realTarget.items.getAll() : realTarget.children;

          // we need to add a11y to all component, no matter if they can be perfectly paired
          const maxItems = Math.max(tabItems.length, panelItems.length);

          let tab;
          let panel;
          for (let i = 0 ; i < maxItems ; i++) {
            tab = tabItems[i];
            panel = panelItems[i];

            // if the tab has its own target, we assume the target component will handle its own accessibility. if the
            // target is an empty string we simply ignore it
            if (tab && tab.target && tab.target.trim() !== '') {
              continue;
            }

            if (tab && panel) {
              // sets the required ids
              tab.id = tab.id || commons.getUID();
              panel.id = panel.id || commons.getUID();

              // creates a 2 way binding for accessibility
              tab.setAttribute('aria-controls', panel.id);
              panel.setAttribute('aria-labelledby', tab.id);

              // adds role to panel to support accessibility
              panel.setAttribute('role', 'tabpanel');
            } else if (tab) {
              // cleans the aria since there is no matching panel
              tab.removeAttribute('aria-controls');
            } else {
              // cleans the aria since there is no matching tab
              panel.removeAttribute('aria-labelledby');
            }
          }
        }
      });
      this._setTargetInQueue = true;
    }
  }

  /**
   The size of the TabList. It accepts both lower and upper case sizes. Currently only "M" (the default) and "L"
   are available.
   See {@link TabListSizeEnum}.

   @type {String}
   @default TabListSizeEnum.MEDIUM
   @htmlattribute size
   @htmlattributereflected
   */
  get size() {
    return this._size || size.MEDIUM;
  }

  set size(value) {
    value = transform.string(value).toUpperCase();
    this._size = validate.enumeration(size)(value) && value || size.MEDIUM;
    this._reflectAttribute('size', this._size);

    // Remove all variant classes
    this.classList.remove(`${CLASSNAME}--compact`, `${CLASSNAME}--quiet`);

    if (this._size === size.SMALL) {
      this.classList.add(`${CLASSNAME}--compact`);
    } else if (this._size === size.LARGE) {
      this.classList.add(`${CLASSNAME}--quiet`);
    }
  }

  /**
   Orientation of the TabList. See {@link TabListOrientationEnum}.

   @type {String}
   @default TabListOrientationEnum.HORIZONTAL
   @htmlattribute orientation
   @htmlattributereflected
   */
  get orientation() {
    return this._orientation || orientation.HORIZONTAL;
  }

  set orientation(value) {
    value = transform.string(value).toLowerCase();

    const newValue = typeof this._orientation === 'undefined';
    this._orientation = validate.enumeration(orientation)(value) && value || orientation.HORIZONTAL;
    if (newValue) {
      this._previousOrientation = this._orientation;
    }
    this._reflectAttribute('orientation', this._orientation);

    this.classList.toggle(`${CLASSNAME}--vertical`, this._orientation === orientation.VERTICAL);
    this.classList.toggle(`${CLASSNAME}--horizontal`, this._orientation === orientation.HORIZONTAL);

    this._setLine();
  }

  /** @private */
  _onItemAdded(item) {
    if (!this.selectedItem) {
      item.setAttribute('selected', '');
    } else {
      this._validateSelection(item);
    }
  }

  /** @private */
  _onItemRemoved() {
    if (!this.selectedItem) {
      this._selectFirstItem();
    }
  }

  /** @private */
  _onTabClick(event) {
    event.preventDefault();

    const item = event.matchedTarget;
    this._toggleItemSelectionAndFocus(item);

    this._trackEvent('click', 'coral-tab', event, item);
  }

  /** @private */
  _onHomeKey(event) {
    event.preventDefault();

    const item = this.items._getFirstSelectable();
    this._toggleItemSelectionAndFocus(item);
  }

  /** @private */
  _onEndKey(event) {
    event.preventDefault();

    const item = this.items._getLastSelectable();
    this._toggleItemSelectionAndFocus(item);
  }

  /** @private */
  _selectNextItem(event) {
    event.preventDefault();

    const item = this.selectedItem;
    this._toggleItemSelectionAndFocus(this.items._getNextSelectable(item));
  }

  /** @private */
  _selectPreviousItem(event) {
    event.preventDefault();

    const item = this.selectedItem;
    this._toggleItemSelectionAndFocus(this.items._getPreviousSelectable(item));
  }

  /** @private */
  _toggleItemSelectionAndFocus(item) {
    if (item && !item.hasAttribute('selected')) {
      item.setAttribute('selected', '');
      item.focus();
    }
  }

  /** @private */
  _onItemSelectedChanged(event) {
    event.stopImmediatePropagation();

    this._validateSelection(event.target);
  }

  /** @private */
  _onValidateSelection(event) {
    event.stopImmediatePropagation();

    this._validateSelection();
  }

  /** @private */
  _selectFirstItem() {
    const item = this.items._getFirstSelectable();
    if (item) {
      item.setAttribute('selected', '');
    }
  }

  /** @private */
  _validateSelection(item) {
    const selectedItems = this.items._getAllSelected();

    if (item) {
      // Deselected item
      if (!item.hasAttribute('selected') && !selectedItems.length) {
        const siblingItem = this.items._getNextSelectable(item);
        // Next selectable item is forced to be selected if selection is cleared
        if (item !== siblingItem) {
          siblingItem.setAttribute('selected', '');
        }
      }
      // Selected item
      else if (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;
      }
    } else if (selectedItems.length > 1) {
      // If multiple items are selected, the last one wins
      item = selectedItems[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;
    }
    // First selectable item is forced to be selected if no selection at all
    else if (!selectedItems.length) {
      this._selectFirstItem();
    }

    this._setLine();

    this._triggerChangeEvent();
  }

  _setLine() {
    !(this._setLineInQueue === true) && window.requestAnimationFrame(() => {
      delete this._setLineInQueue;

      const selectedItem = this.selectedItem;

      // Position line under the selected item
      if (selectedItem) {
        if (this.orientation === orientation.HORIZONTAL) {
          const padding = window.parseInt(window.getComputedStyle(selectedItem).paddingLeft);
          const left = selectedItem.offsetLeft + padding;
          const width = selectedItem.clientWidth - padding * 2;

          // Orientation changed
          if (this._previousOrientation !== this.orientation) {
            this._elements.line.style.height = '';
          }

          this._elements.line.style.width = `${width}px`;
          this._elements.line.style.transform = `translate(${left}px, 0)`;
        } else if (this.orientation === orientation.VERTICAL) {
          const top = selectedItem.offsetTop;
          const height = selectedItem.clientHeight;

          // Orientation changed
          if (this._previousOrientation !== this.orientation) {
            this._elements.line.style.width = '';
          }

          this._elements.line.style.height = `${height}px`;
          this._elements.line.style.transform = `translate(0, ${top}px)`;
        }
        this._elements.line.hidden = false;
      } else {
        // Hide line if no selected item
        this._elements.line.hidden = true;
      }
      this._previousOrientation = this.orientation;
    });

    this._setLineInQueue = true;
  }

  /** @private */
  _triggerChangeEvent() {
    const selectedItem = this.selectedItem;
    const oldSelection = this._oldSelection;

    if (!this._preventTriggeringEvents && selectedItem !== oldSelection) {
      this.trigger('coral-tablist:change', {
        oldSelection: oldSelection,
        selection: selectedItem
      });

      this._oldSelection = selectedItem;
    }
  }

  /**
   Returns {@link TabList} sizes.

   @return {TabListSizeEnum}
   */
  static get size() {
    return size;
  }

  /**
   Returns {@link TabList} orientation options.

   @return {TabListOrientationEnum}
   */
  static get orientation() {
    return orientation;
  }

  /** @ignore */
  static get observedAttributes() {
    return super.observedAttributes.concat(['target', 'size', 'orientation']);
  }

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

    this.classList.add(CLASSNAME);

    // adds the role to support accessibility
    this.setAttribute('role', 'tablist');
    this.setAttribute('aria-multiselectable', 'false');

    // Default reflected attributes
    if (!this._size) {
      this.size = size.MEDIUM;
    }
    if (!this._orientation) {
      this.orientation = orientation.HORIZONTAL;
    }

    // Support cloneNode
    const template = this.querySelector('._coral-Tabs-selectionIndicator');
    if (template) {
      template.remove();
    }

    // Insert tab line
    this.appendChild(this._elements.line);

    // Don't trigger events once connected
    this._preventTriggeringEvents = true;
    this._validateSelection();
    this._preventTriggeringEvents = false;

    this._oldSelection = this.selectedItem;

    // Display line once tabList is shown
    commons.addResizeListener(this, this._setLine);
  }

  /**
   Triggered when the {@link TabList} selected item has changed.

   @typedef {CustomEvent} coral-tablist:change

   @property {Tab} event.detail.oldSelection
   The prior selected item(s).
   @property {Tab} event.detail.selection
   The newly selected item(s).
   */
}

export default TabList;