Reference Source

coral-spectrum/coral-component-list/src/scripts/SelectList.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 '../../../coral-component-wait';
import loadIndicator from '../templates/loadIndicator';
import {transform, i18n} from '../../../coral-utils';
import {Decorator} from '../../../coral-decorator';

const KEYPRESS_TIMEOUT_DURATION = 1000;

/**
 The distance, in pixels, from the bottom of the SelectList at which we trigger a scrollbottom event. For example, a
 value of 50 would indicate a scrollbottom event should be triggered when the user scrolls to within 50 pixels of the
 bottom.

 @type {Number}
 @ignore
 */
const SCROLL_BOTTOM_THRESHOLD = 50;

/**
 The number of milliseconds for which scroll events should be debounced.

 @type {Number}
 @ignore
 */
const SCROLL_DEBOUNCE = 100;

/** @ignore */
const ITEM_TAG_NAME = 'coral-selectlist-item';

/** @ignore */
const GROUP_TAG_NAME = 'coral-selectlist-group';

const CLASSNAME = '_coral-Menu';

/**
 @class Coral.SelectList
 @classdesc A SelectList component is a selectable list of items.
 @htmltag coral-selectlist
 @extends {HTMLElement}
 @extends {BaseComponent}
 */
const SelectList = Decorator(class extends BaseComponent(HTMLElement) {
  /** @ignore */
  constructor() {
    super();

    // Attach events
    this._delegateEvents({
      'scroll': '_onScroll',
      'mouseenter': '_onMouseEnter',
      'capture:blur': '_onBlur',

      'click coral-selectlist-item': '_onItemClick',

      'key:space coral-selectlist-item': '_onToggleItemKey',
      'key:return coral-selectlist-item': '_onToggleItemKey',
      'key:pageup coral-selectlist-item': '_focusPreviousItem',
      'key:left coral-selectlist-item': '_focusPreviousItem',
      'key:up coral-selectlist-item': '_focusPreviousItem',
      'key:pagedown coral-selectlist-item': '_focusNextItem',
      'key:right coral-selectlist-item': '_focusNextItem',
      'key:down coral-selectlist-item': '_focusNextItem',
      'key:home coral-selectlist-item': '_onHomeKey',
      'key:end coral-selectlist-item': '_onEndKey',
      'keypress coral-selectlist-item': '_onKeyPress',

      // private
      'coral-selectlist-item:_selectedchanged': '_onItemSelectedChanged'
    });

    this._keypressTimeoutDuration = KEYPRESS_TIMEOUT_DURATION;

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

    // Used for eventing
    this._oldSelection = [];

    // Used for interaction
    this._keypressTimeoutID = null;
    this._keypressSearchString = '';

    // we correctly bind the scroll event
    this._onDebouncedScroll = this._onDebouncedScroll.bind(this);

    // Init the collection mutation observer
    this.items._startHandlingItems(true);
  }

  /**
   The Collection Interface that allows interacting with the {@link SelectListGroup} elements that the
   SelectList contains. This includes items nested inside groups. To manage items contained within a specific
   group, see {@link SelectListGroup#items}.

   @type {SelectableCollection}
   @readonly
   */
  get groups() {
    // just init on demand
    if (!this._groups) {
      this._groups = new SelectableCollection({
        host: this,
        itemTagName: GROUP_TAG_NAME,
        onItemAdded: this._validateSelection,
        onItemRemoved: this._validateSelection
      });
    }
    return this._groups;
  }

  /**
   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: ITEM_TAG_NAME,
        onItemAdded: this._validateSelection,
        onItemRemoved: this._validateSelection
      });
    }
    return this._items;
  }

  /**
   The selected item in the SelectList.

   @type {HTMLElement}
   @readonly
   */
  get selectedItem() {
    return this.items._getAllSelected()[0] || null;
  }

  /**
   The selected items of the SelectList.

   @type {Array.<HTMLElement>}
   @readonly
   */
  get selectedItems() {
    return this.items._getAllSelected();
  }

  /**
   Indicates whether the SelectList 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[this._multiple ? 'setAttribute' : 'removeAttribute']('aria-multiselectable', this._multiple);

    this._validateSelection();
  }

  /**
   Whether items are being loaded for the SelectList. Toggling this merely shows or hides a loading indicator.

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

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

    const load = this._elements.loadIndicator;

    if (this.loading) {
      // we decide first if we need to scroll to the bottom since adding the load will change the dimentions
      const scrollToBottom = this.scrollTop >= this.scrollHeight - this.clientHeight;

      // inserts the item at the end
      this.appendChild(load);

      // we make the load indicator visible
      if (scrollToBottom) {
        /** @ignore */
        this.scrollTop = this.scrollHeight;
      }
    } else {
      load.remove();
    }
  }

  /** @private **/
  get _tabTarget() {
    return this.__tabTarget || null;
  }

  set _tabTarget(value) {
    this.__tabTarget = value;

    // Set all but the current set _tabTarget to not be a tab target:
    this.items._getSelectableItems().forEach((item) => {
      item.setAttribute('tabindex', !value || item === value ? 0 : -1);
    });
  }

  /** @private */
  _toggleItemSelection(item) {
    if (item) {
      const beforeChangeEvent = this.trigger('coral-selectlist:beforechange', {item});

      if (!beforeChangeEvent.defaultPrevented) {
        item[item.hasAttribute('selected') ? 'removeAttribute' : 'setAttribute']('selected', '');
      }
    }
  }

  /** @private */
  _onBlur() {
    // required otherwise the latest item that had the focus would get it again instead of the selected item
    this._resetTabTarget();
  }

  /** @private */
  _onItemClick(event) {
    event.preventDefault();
    event.stopPropagation();

    const item = event.matchedTarget;
    this._toggleItemSelection(item);
    this._focusItem(item);
  }

  /** @private */
  _focusItem(item) {
    if (item) {
      item.focus();
    }

    this._tabTarget = item;
  }

  /** @private */
  _onToggleItemKey(event) {
    event.preventDefault();
    event.stopPropagation();

    const item = event.target;

    this._toggleItemSelection(item);
    this._focusItem(item);
  }

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

    this._focusItem(this.items._getPreviousSelectable(event.target));
  }

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

    this._focusItem(this.items._getNextSelectable(event.target));
  }

  _focusFirstItem() {
    this._focusItem(this.items._getFirstSelectable());
  }

  _focusLastItem() {
    this._focusItem(this.items._getLastSelectable());
  }

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

    this._focusFirstItem();
  }

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

    this._focusLastItem();
  }

  /**
   Handles keypress event for alphanumeric search.

   @param {KeyboardEvent} event
   The keyboard event.
   @private
   */
  _onKeyPress(event) {
    // The string entered when the key was pressed
    const newString = String.fromCharCode(event.which);

    // Clear the timeout before the _keypressSearchString is cleared
    window.clearTimeout(this._keypressTimeoutID);

    // If the character entered does not match the last character entered, append it to the _keypressSearchString
    if (newString !== this._keypressSearchString) {
      this._keypressSearchString += newString;
    }

    // Set a timeout so that _keypressSearchString is cleared after 1 second
    this._keypressTimeoutID = window.setTimeout(() => {
      this._keypressSearchString = '';
    }, this._keypressTimeoutDuration);

    // Search within selectable items
    const selectableItems = this.items._getSelectableItems().filter(item => !item.hasAttribute('hidden') && item.offsetParent);

    // Remember the index of the focused item within the array of selectable items
    const currentIndex = selectableItems.indexOf(this._tabTarget);

    this._keypressSearchString = this._keypressSearchString.trim().toLowerCase();

    let start;
    // If the currentIndex is -1, meaning no item has focus, start from the beginning
    if (currentIndex === -1) {
      start = 0;
    } else if (this._keypressSearchString.length === 1) {
      // Otherwise, if there is only one character to compare, start comparing from the next item after the currently
      // focused item. This allows us to iterate through items beginning with the same character
      start = currentIndex + 1;
    } else {
      start = currentIndex;
    }

    let newFocusItem;
    let comparison;
    let item;

    // Compare _keypressSearchString against item text until a match is found
    for (let i = start ; i < selectableItems.length ; i++) {
      item = selectableItems[i];
      comparison = item.textContent.trim().toLowerCase();
      if (comparison.indexOf(this._keypressSearchString) === 0) {
        newFocusItem = item;
        break;
      }
    }

    // If no match is found, continue searching for a match starting from the top
    if (!newFocusItem) {
      for (let j = 0 ; j < start ; j++) {
        item = selectableItems[j];
        comparison = item.textContent.trim().toLowerCase();
        if (comparison.indexOf(this._keypressSearchString) === 0) {
          newFocusItem = item;
          break;
        }
      }
    }

    // If a match has been found, focus the matched item
    if (newFocusItem !== undefined) {
      this._focusItem(newFocusItem);

      // Keyboard is being used so apply focus-ring
      const focusedItem = this.querySelector('.focus-ring');
      if (focusedItem) {
        focusedItem.classList.remove('focus-ring');
      }
      newFocusItem.classList.add('focus-ring');
    }
  }

  /**
   Determine what item should get focus (if any) when the user tries to tab into the SelectList. This should be the
   first selected item, or the first selectable item otherwise. When neither is available, it cannot be tabbed into
   the SelectList.

   @private
   */
  _resetTabTarget(forceFirst = false) {
    let items = this.items._getAllSelected().filter(item => !item.hasAttribute('hidden') && item.offsetParent);

    if (items.length === 0) {
      items = this.items._getSelectableItems().filter(item => !item.hasAttribute('hidden') && item.offsetParent);
    }

    this._tabTarget = forceFirst ? items[0] : (items.find(item => item.tabIndex === 0) || items[0]);
  }

  /** @private */
  _onScroll() {
    window.clearTimeout(this._scrollTimeout);
    this._scrollTimeout = window.setTimeout(this._onDebouncedScroll, SCROLL_DEBOUNCE);
  }

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

  /**
   @emits {coral-selectlist:scrollbottom}

   @private
   */
  _onDebouncedScroll() {
    if (this.scrollTop >= this.scrollHeight - this.clientHeight - SCROLL_BOTTOM_THRESHOLD) {
      this.trigger('coral-selectlist:scrollbottom');
    }
  }

  /** @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._resetTabTarget();

    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-selectlist: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-selectlist: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;
  }

  /** @ignore */
  focus() {
    // Avoids moving the focus once it is already inside the component
    if (!this.contains(document.activeElement)) {
      this._resetTabTarget();
      this._focusItem(this._tabTarget);
    }
  }

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

  /** @ignore */
  render() {
    this.classList.add(CLASSNAME);

    // adds the role to support accessibility
    if (!this.hasAttribute('role')) {
      this.setAttribute('role', 'listbox');
    }

    if (!this.hasAttribute('aria-label')) {
      this.setAttribute('aria-label', i18n.get('List'));
    }

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

    this._oldSelection = this.selectedItems;
  }

  /**
   Triggered when the user scrolls to near the bottom of the {@link SelectList}. This can be useful for when additional items can
   be loaded asynchronously (i.e., infinite scrolling).

   @typedef {CustomEvent} coral-selectlist:scrollbottom
   */

  /**
   Triggered before the {@link SelectList} selected item is changed on user interaction. Can be used to cancel selection.

   @typedef {CustomEvent} coral-selectlist:change

   @property {SelectListItem} event.detail.item
   The selected item.
   */

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

   @typedef {CustomEvent} coral-selectlist:change

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

export default SelectList;