Reference Source

coral-spectrum/coral-base-list/src/scripts/BaseList.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 {SelectableCollection} from '../../../coral-collection';
import {transform, validate} from '../../../coral-utils';

const CLASSNAME = '_coral-Menu';

/**
 Enumeration for {@link BaseList} interactions.

 @typedef {Object} ListInteractionEnum

 @property {String} ON
 Keyboard interaction is enabled.
 @property {String} OFF
 Keyboard interaction is disabled.
 */
const interaction = {
  ON: 'on',
  OFF: 'off'
};

/**
 @base BaseList
 @classdesc The base element for List components
 */
class BaseList extends superClass {
  /** @ignore */
  constructor() {
    super();

    this._events = {
      'mouseenter': '_onMouseEnter',
      // Keyboard interaction
      'key:down [coral-list-item]': '_focusNextItem',
      'key:right [coral-list-item]': '_focusNextItem',
      'key:left [coral-list-item]': '_focusPreviousItem',
      'key:up [coral-list-item]': '_focusPreviousItem',
      'key:pageup [coral-list-item]': '_focusPreviousItem',
      'key:pagedown [coral-list-item]': '_focusNextItem',
      'key:home [coral-list-item]': '_focusFirstItem',
      'key:end [coral-list-item]': '_focusLastItem'
    };
  }

  /**
   The Collection Interface that allows interacting with the items that the component contains.

   @type {SelectableCollection}
   @readonly
   */
  get items() {
    // Construct the collection on first request:
    if (!this._items) {
      this._items = new SelectableCollection({
        itemTagName: this._itemTagName,
        itemBaseTagName: this._itemBaseTagName,
        itemSelector: 'coral-list-item, button[is="coral-buttonlist-item"], a[is="coral-anchorlist-item"]',
        host: this
      });
    }

    return this._items;
  }

  /** @private */
  get _itemTagName() {
    // Used for Collection
    return 'coral-list-item';
  }

  /**
   Whether interaction with the component is enabled. See {@link ListInteractionEnum}.

   @type {String}
   @default ListInteractionEnum.ON
   @htmlattribute interaction
   @htmlattributereflected
   */
  get interaction() {
    return this._interaction || interaction.ON;
  }

  set interaction(value) {
    value = transform.string(value).toLowerCase();
    this._interaction = validate.enumeration(interaction)(value) && value || interaction.ON;
    
    this._reflectAttribute('interaction', this._interaction);
  }

  /**
   Returns true if the event is at the matched target.

   @private
   */
  _eventIsAtTarget(event) {
    const target = event.target;
    const listItem = event.matchedTarget;

    const isAtTarget = target === listItem;

    if (isAtTarget) {
      // Don't let arrow keys etc scroll the page
      event.preventDefault();
    }

    return isAtTarget;
  }

  _onMouseEnter() {
    if (this.contains(document.activeElement)) {
      document.activeElement.blur();
    }
  }

  /** @private */
  _focusFirstItem(event) {
    if (this.interaction === interaction.OFF || !this._eventIsAtTarget(event)) {
      return;
    }

    const items = this._getSelectableItems();
    items[0].focus();
  }

  /** @private */
  _focusLastItem(event) {
    if (this.interaction === interaction.OFF || !this._eventIsAtTarget(event)) {
      return;
    }

    const items = this._getSelectableItems();
    items[items.length - 1].focus();
  }

  /** @private */
  _focusNextItem(event) {
    if (this.interaction === interaction.OFF || !this._eventIsAtTarget(event)) {
      return;
    }

    const target = event.matchedTarget;
    const items = this._getSelectableItems();
    const index = items.indexOf(target);

    if (index === -1) {
      // Invalid state
      return;
    }

    if (index < items.length - 1) {
      items[index + 1].focus();
    } else {
      items[0].focus();
    }
  }

  /** @private */
  _focusPreviousItem(event) {
    if (this.interaction === interaction.OFF || !this._eventIsAtTarget(event)) {
      return;
    }

    const target = event.matchedTarget;
    const items = this._getSelectableItems();
    const index = items.indexOf(target);

    if (index === -1) {
      // Invalid state
      return;
    }

    if (index > 0) {
      items[index - 1].focus();
    } else {
      items[items.length - 1].focus();
    }
  }

  /** @private */
  _getSelectableItems() {
    // Also checks if item is visible
    return this.items._getSelectableItems().filter(item => !item.hasAttribute('hidden') && item.offsetParent);
  }

  /** @ignore */
  focus() {
    if (!this.contains(document.activeElement)) {
      const items = this._getSelectableItems();
      if (items.length > 0) {
        items[0].focus();
      }
    }
  }

  /**
   Returns {@link BaseList} interaction options.

   @return {ListInteractionEnum}
   */
  static get interaction() {
    return interaction;
  }

  /** @ignore */
  static get observedAttributes() {
    return super.observedAttributes.concat(['interaction']);
  }

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

    this.classList.add(CLASSNAME);

    // Default reflected attributes
    if (!this._interaction) {
      this.interaction = interaction.ON;
    }
  }
};

export default BaseList;