Reference Source

coral-spectrum/coral-component-select/src/scripts/Select.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 {BaseFormField} from '../../../coral-base-formfield';
import {SelectableCollection} from '../../../coral-collection';
import '../../../coral-component-button';
import {Tag} from '../../../coral-component-taglist';
import {SelectList} from '../../../coral-component-list';
import {Icon} from '../../../coral-component-icon';
import '../../../coral-component-popover';
import base from '../templates/base';
import {transform, validate, commons, i18n, Keys} from '../../../coral-utils';
import {Decorator} from '../../../coral-decorator';

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

 @typedef {Object} SelectVariantEnum

 @property {String} DEFAULT
 A default, gray Select.
 @property {String} QUIET
 A Select with no border or background.
 */
const variant = {
  DEFAULT: 'default',
  QUIET: 'quiet'
};

const CLASSNAME = '_coral-Dropdown';

// used in 'auto' mode to determine if the client is on mobile.
const IS_MOBILE_DEVICE = navigator.userAgent.match(/iPhone|iPad|iPod|Android/i) !== null;

/**
 Extracts the value from the item in case no explicit value was provided.

 @param {HTMLElement} item
 the item whose value will be extracted.

 @returns {String} the value that will be submitted for this item.

 @private
 */
const itemValueFromDOM = function (item) {
  const attr = item.getAttribute('value');
  // checking explicitely for null allows to differenciate between non set values and empty strings
  return attr !== null ? attr : item.textContent.replace(/\s{2,}/g, ' ').trim();
};

/**
 Calculates the difference between two given arrays. It returns the items that are in a that are not in b.

 @param {Array.<String>} a
 @param {Array.<String>} b

 @returns {Array.<String>}
 the difference between the arrays.
 */
const arrayDiff = function (a, b) {
  return a.filter((item) => !b.some((item2) => item === item2));
};

/**
 @class Coral.Select
 @classdesc A Select component is a form field that allows users to select from a list of options. If this component is
 shown on a mobile device, it will show a native select list, instead of the select list styled via Coral Spectrum.
 @htmltag coral-select
 @extends {HTMLElement}
 @extends {BaseComponent}
 @extends {BaseFormField}
 */
const Select = Decorator(class extends BaseFormField(BaseComponent(HTMLElement)) {
  /** @ignore */
  constructor() {
    super();

    // Templates
    this._elements = {};
    base.call(this._elements, {commons, Icon, i18n});

    const events = {
      'global:click': '_onGlobalClick',
      'global:touchstart': '_onGlobalClick',

      'coral-collection:add coral-taglist': '_onInternalEvent',
      'coral-collection:remove coral-taglist': '_onInternalEvent',

      // item events
      'coral-select-item:_valuechanged coral-select-item': '_onItemValueChange',
      'coral-select-item:_contentchanged coral-select-item': '_onItemContentChange',
      'coral-select-item:_disabledchanged coral-select-item': '_onItemDisabledChange',
      'coral-select-item:_selectedchanged coral-select-item': '_onItemSelectedChange',

      'change coral-taglist': '_onTagListChange',
      'change select': '_onNativeSelectChange',
      'click select': '_onNativeSelectClick',
      'click > ._coral-Dropdown-trigger': '_onButtonClick',

      'key:space > ._coral-Dropdown-trigger': '_onSpaceKey',
      'key:enter > ._coral-Dropdown-trigger': '_onSpaceKey',
      'key:return > ._coral-Dropdown-trigger': '_onSpaceKey',
      'key:down > ._coral-Dropdown-trigger': '_onSpaceKey'
    };

    // Overlay
    const overlayId = this._elements.overlay.id;
    events[`global:capture:coral-collection:add #${overlayId} coral-selectlist`] = '_onSelectListItemAdd';
    events[`global:capture:coral-collection:remove #${overlayId} coral-selectlist`] = '_onInternalEvent';
    events[`global:capture:coral-selectlist:beforechange #${overlayId}`] = '_onSelectListBeforeChange';
    events[`global:capture:coral-selectlist:change #${overlayId}`] = '_onSelectListChange';
    events[`global:capture:coral-selectlist:scrollbottom #${overlayId}`] = '_onSelectListScrollBottom';
    events[`global:capture:coral-overlay:close #${overlayId}`] = '_onOverlayToggle';
    events[`global:capture:coral-overlay:open #${overlayId}`] = '_onOverlayToggle';
    events[`global:capture:coral-overlay:positioned #${overlayId}`] = '_onOverlayPositioned';
    events[`global:capture:coral-overlay:beforeopen #${overlayId}`] = '_onBeforeOpen';
    events[`global:capture:coral-overlay:beforeclose #${overlayId}`] = '_onInternalEvent';
    // Keyboard interaction
    events[`global:keypress #${overlayId}`] = '_onOverlayKeyPress';
    // TODO for some reason this disables tabbing into the select
    // events[`global:key:tab #${overlayId} coral-selectlist-item`] = '_onTabKey';
    // events[`global:key:tab+shift #${overlayId} coral-selectlist-item`] = '_onTabKey';

    // Attach events
    this._delegateEvents(commons.extend(this._events, events));

    // Pre-define labellable element
    this._labellableElement = this._elements.button;

    // default value of inner flag to process events
    this._bulkSelectionChange = false;

    // we only have AUTO mode.
    this._useNativeInput = IS_MOBILE_DEVICE;

    this._elements.taglist.reset = () => {
      // since reseting a form will call the reset on every component, we need to kill the behavior of the taglist
      // otherwise the state will not be accurate
    };

    this._initialValues = [];

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

  /**
   Returns the inner overlay to allow customization.

   @type {Popover}
   @readonly
   */
  get overlay() {
    return this._elements.overlay;
  }

  /**
   The item collection.

   @type {SelectableCollection}
   @readonly
   */
  get items() {
    // we do lazy initialization of the collection
    if (!this._items) {
      this._items = new SelectableCollection({
        host: this,
        itemTagName: 'coral-select-item',
        onItemAdded: this._onItemAdded,
        onItemRemoved: this._onItemRemoved,
        onCollectionChange: this._onCollectionChange
      });
    }
    return this._items;
  }

  /**
   Indicates whether the select accepts multiple selected values.

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

    // taglist should not be in DOM if multiple === false
    if (!this._multiple) {
      this.removeChild(this._elements.taglist);
    } else {
      this.appendChild(this._elements.taglist);
    }

    // we need to remove and re-add the native select to loose the selection
    if (this._nativeInput) {
      this.removeChild(this._elements.nativeSelect);
    }
    this._elements.nativeSelect.multiple = this._multiple;
    this._elements.nativeSelect.selectedIndex = -1;

    if (this._nativeInput) {
      if (this._multiple) {
        // We might not be rendered yet
        if (this._elements.nativeSelect.parentNode) {
          this.insertBefore(this._elements.nativeSelect, this._elements.taglist);
        }
      } else {
        this.appendChild(this._elements.nativeSelect);
      }
    }

    this._elements.list.multiple = this._multiple;

    // sets the correct name for value submission
    this._setName(this.getAttribute('name') || '');

    // we need to make sure the selection is valid
    this._setStateFromDOM();

    // everytime multiple changes, the state of the selectlist and taglist need to be updated
    this.items.getAll().forEach((item) => {
      if (this._multiple && item.hasAttribute('selected')) {
        this._addTagToTagList(item);
      } else {
        // taglist is never used for multiple = false
        this._removeTagFromTagList(item);

        // when multiple = false and the item is selected, the value needs to be updated in the input
        if (item.hasAttribute('selected')) {
          this._elements.input.value = itemValueFromDOM(item);
        }
      }
    });
  }

  /**
   Contains a hint to the user of what can be selected in the component. If no placeholder is provided, the first
   option will be displayed in the component.

   @type {String}
   @default ""
   @htmlattribute placeholder
   @htmlattributereflected
   */
  // p = placeholder, m = multiple, se = selected
  // case 1:  p +  m +  se = p
  // case 2:  p +  m + !se = p
  // case 3: !p + !m +  se = se
  // case 4: !p + !m + !se = firstSelectable (native behavior)
  // case 5:  p + !m +  se = se
  // case 6:  p + !m + !se = p
  // case 7: !p +  m +  se = 'Select'
  // case 8: !p +  m + !se = 'Select'
  get placeholder() {
    return this._placeholder || '';
  }

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

    // case 1:  p +  m +  se = p
    // case 2:  p +  m + !se = p
    // case 6:  p + !m + !se = p
    if (this._placeholder && (this.hasAttribute('multiple') || !this.selectedItem)) {
      this._elements.label.classList.add('is-placeholder');
      this._elements.label.textContent = this._placeholder;
    }
      // case 7: !p +  m +  se = 'Select'
    // case 8: !p +  m + !se = 'Select'
    else if (this.hasAttribute('multiple')) {
      this._elements.label.classList.add('is-placeholder');
      this._elements.label.textContent = i18n.get('Select');
    }
    // case 4: !p + !m + !se = firstSelectable (native behavior)
    else if (!this.selectedItem) {
      // we clean the value because there is no selected item
      this._elements.input.value = '';

      // gets the first candidate for selection
      const placeholderItem = this.items._getFirstSelectable();
      this._elements.label.classList.remove('is-placeholder');

      if (placeholderItem) {
        // selects using the attribute in case the item is not yet initialized
        placeholderItem.setAttribute('selected', '');
        this._elements.label.innerHTML = placeholderItem.innerHTML;
      } else {
        // label must be cleared when there is no placeholder and no item to select
        this._elements.label.textContent = '';
      }
    }
  }

  /**
   Name used to submit the data in a form.
   @type {String}
   @default ""
   @htmlattribute name
   @htmlattributereflected
   */
  get name() {
    return this.multiple ? this._elements.taglist.name : this._elements.input.name;
  }

  set name(value) {
    this._setName(value);
    this._reflectAttribute('name', this.name);
  }

  /**
   This field's current value.
   @type {String}
   @default ""
   @htmlattribute value
   */
  get value() {
    // we leverage the internal elements to know the value, this way we are always sure that the server submission
    // will be correct
    return this.multiple ? this._elements.taglist.value : this._elements.input.value;
  }

  set value(value) {
    // we rely on the the values property to handle this correctly
    this.values = [value];
  }

  /**
   The current selected values, as submitted during form submission. When {@link Coral.Select#multiple} is
   <code>false</code>, this will be an array of length 1.

   @type {Array.<String>}
   */
  get values() {
    if (this.multiple) {
      return this._elements.taglist.values;
    }

    // if there is a selection, we return whatever value it has assigned
    return this.selectedItem ? [this._elements.input.value] : [];
  }

  set values(values) {
    if (Array.isArray(values)) {
      // when multiple = false, we explicitely ignore the other values and just set the first one
      if (!this.multiple && values.length > 1) {
        values = [values[0]];
      }

      // gets all the items
      const items = this.items.getAll();

      let itemValue;
      // if multiple, we need to explicitely set the selection state of every item
      if (this.multiple) {
        items.forEach((item) => {
          // we use DOM API instead of properties in case the item is not yet initialized
          itemValue = itemValueFromDOM(item);
          // if the value is located inside the values array, then we set the item as selected
          item[values.indexOf(itemValue) !== -1 ? 'setAttribute' : 'removeAttribute']('selected', '');
        });
      }
        // if single selection, we find the first item that matches the value and deselect everything else. in case,
      // no item matches the value, we may need to find a selection candidate
      else {
        let targetItem;
        // since multiple = false, there is only 1 value value
        const value = values[0] || '';

        items.forEach((item) => {
          // small optimization to avoid calculating the value from every item
          if (!targetItem) {
            itemValue = itemValueFromDOM(item);

            if (itemValue === value) {
              // selecting the item will cause the taglist or input to be updated
              item.setAttribute('selected', '');
              // we store the first ocurrence, afterwards we deselect all items
              targetItem = item;

              // since we found our target item, we continue to avoid removing the selected attribute
              return;
            }
          }

          // every-non targetItem must be deselected
          item.removeAttribute('selected');
        });

        // if no targetItem was found, _setStateFromDOM will make sure that the state is valid
        if (!targetItem) {
          this._setStateFromDOM();
        }
      }
    }
  }

  /**
   Whether this field is disabled or not.
   @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[this._disabled ? 'setAttribute' : 'removeAttribute']('aria-disabled', this._disabled);
    this.classList.toggle('is-disabled', this._disabled);

    this._elements.button.disabled = this._disabled;
    this._elements.input.disabled = this._disabled;
    this._elements.taglist.disabled = this._disabled;
  }

  /**
   Inherited from {@link BaseFormField#invalid}.
   */
  get invalid() {
    return super.invalid;
  }

  set invalid(value) {
    super.invalid = value;

    this.classList.toggle('is-invalid', this.invalid);
    this._elements.button.classList.toggle('is-invalid', this.invalid);
    this._elements.invalidIcon.hidden = !this.invalid;
  }

  /**
   Whether this field is required or not.
   @type {Boolean}
   @default false
   @htmlattribute required
   @htmlattributereflected
   */
  get required() {
    return this._required || false;
  }

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

    this._elements.input.required = this._required;
    this._elements.taglist.required = this._required;
  }

  /**
   Whether this field is readOnly or not. Indicating that the user cannot modify the value of the control.
   @type {Boolean}
   @default false
   @htmlattribute readonly
   @htmlattributereflected
   */
  get readOnly() {
    return this._readOnly || false;
  }

  set readOnly(value) {
    this._readOnly = transform.booleanAttr(value);
    this._reflectAttribute('readonly', this._readOnly);

    this._elements.input.readOnly = this._readOnly;
    this._elements.taglist.readOnly = this._readOnly;
    this._elements.taglist.disabled = this._readOnly;
  }

  /**
   Inherited from {@link BaseFormField#labelled}.
   */
  get labelled() {
    return super.labelled;
  }

  set labelled(value) {
    super.labelled = value;

    if (this.labelled) {
      if (!this.labelledBy) {
        this._elements.button.setAttribute('aria-labelledby', `${this._elements.button.id} ${this._elements.label.id} ${this.invalid ? this._elements.invalidIcon.id : ''}`);
      }
      this._elements.nativeSelect.setAttribute('aria-label', value);
    } else {
      this._elements.button.removeAttribute('aria-label');
      this._elements.nativeSelect.removeAttribute('aria-label');
      if (!this.labelledBy) {
        this._elements.button.removeAttribute('aria-labelledby');
      }
    }

    this._elements.taglist.labelled = value;
  }

  /**
   Inherited from {@link BaseFormField#labelledBy}.
   */
  get labelledBy() {
    return this._labelledBy;
  }

  set labelledBy(value) {
    super.labelledBy = value;
    this._labelledBy = super.labelledBy;

    if (this._labelledBy) {
      this._elements.button.setAttribute('aria-labelledby', `${this._labelledBy} ${this._elements.label.id} ${this.invalid ? this._elements.invalidIcon.id : ''}`);
      this._elements.nativeSelect.setAttribute('aria-labelledby', this._labelledBy);
    } else {
      this._elements.nativeSelect.removeAttribute('aria-labelledby');

      // if the select is also labelled, make sure that aria-labelledby gets restored
      if (this.labelled) {
        this.labelled = this.labelled;
      }
    }

    this._elements.taglist.labelledBy = this._labelledBy;
  }

  /**
   Returns the first selected item in the Select. The value <code>null</code> is returned if no element is
   selected.

   @type {?HTMLElement}
   @readonly
   */
  get selectedItem() {
    return this.hasAttribute('multiple') ? this.items._getFirstSelected() : this.items._getLastSelected();
  }

  /**
   Returns an Array containing the set selected items.

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

    const item = this.selectedItem;
    return item ? [item] : [];
  }

  /**
   Indicates that the Select is currently loading remote data. This will set the wait indicator inside the list.

   @type {Boolean}
   @default false
   @htmlattribute loading
   */
  get loading() {
    return this._elements.list.loading;
  }

  set loading(value) {
    this._elements.list.loading = value;
  }

  /**
   The Select's variant. See {@link SelectVariantEnum}.

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

  set variant(value) {
    value = transform.string(value).toLowerCase();
    this._variant = validate.enumeration(variant)(value) && value || variant.DEFAULT;
    this._reflectAttribute('variant', this._variant);

    this._elements.button.classList.toggle('_coral-FieldButton--quiet', this._variant === variant.QUIET);
  }

  /** @ignore */
  _setName(value) {
    if (this.multiple) {
      this._elements.input.name = '';
      this._elements.taglist.setAttribute('name', value);
    } else {
      this._elements.taglist.setAttribute('name', '');
      this._elements.input.name = value;
    }
  }

  /**
   @param {Boolean} [checkAvailableSpace=false]
   If <code>true</code>, the event is triggered based on the available space.

   @private
   */
  _showOptions(checkAvailableSpace) {
    if (checkAvailableSpace) {
      // threshold in pixels
      const ITEM_SIZE_THRESHOLD = 30;

      let scrollHeight = this._elements.list.scrollHeight;
      const viewportHeight = this._elements.list.clientHeight;
      const scrollTop = this._elements.list.scrollTop;
      // we should not do this, but it increases performance since we do not need to find the item
      const loadIndicator = this._elements.list._elements.loadIndicator;

      // we remove the size of the load indicator
      if (loadIndicator && loadIndicator.parentNode) {
        const outerHeight = function (el) {
          let height = el.offsetHeight;
          const style = getComputedStyle(el);

          height += parseInt(style.marginTop, 10) + parseInt(style.marginBottom, 10);
          return height;
        };

        scrollHeight -= outerHeight(loadIndicator);
      }

      // if we are not close to the bottom scroll, we cancel triggering the event
      if (scrollTop + viewportHeight < scrollHeight - ITEM_SIZE_THRESHOLD) {
        return;
      }
    }

    // we do not show the list with native
    if (!this._useNativeInput) {
      if (!this._elements.overlay.open) {
        // Show the overlay
        this._elements.overlay.open = true;
      }

      // Force overlay repositioning (remote loading)
      requestAnimationFrame(() => {
        this._elements.overlay._onAnimate();
        this._elements.overlay.reposition();
      });
    }

    // Trigger an event
    // @todo: maybe we should only trigger this event when the button is toggled and we have space for more items
    const event = this.trigger('coral-select:showitems', {
      // amount of items in the select
      start: this.items.length
    });

    // while using native there is no need to show the loading
    if (!this._useNativeInput) {
      // if the default is prevented, we should the loading indicator
      this._elements.list.loading = event.defaultPrevented;
    }

    // communicate expanded state to assistive technology
    this._elements.button.setAttribute('aria-expanded', true);
  }

  /** @private */
  _hideOptions() {
    // Don't close the overlay if selection = multiple
    if (!this.multiple) {
      this._elements.overlay.open = false;

      this.trigger('coral-select:hideitems');
    }

    // communicate collapsed state to assistive technology
    this._elements.button.setAttribute('aria-expanded', false);
  }

  /** @ignore */
  _onGlobalClick(event) {
    if (!this._elements.overlay.open) {
      return;
    }

    const eventTargetWithinOverlayTarget = this._elements.button.contains(event.target);
    const eventTargetWithinItself = this._elements.overlay.contains(event.target);
    if (!eventTargetWithinOverlayTarget && !eventTargetWithinItself) {
      this._hideOptions();
    }
  }

  /** @private */
  _onSelectListItemAdd(event) {
    // stops propagation cause the event is internal to the component
    event.stopImmediatePropagation();

    // When items have been added, we are no longer loading
    this.loading = false;

    // Reset height
    this._elements.list.style.height = '';

    // Measure actual height
    const style = window.getComputedStyle(this._elements.list);
    const height = parseInt(style.height, 10);
    const maxHeight = parseInt(style.maxHeight, 10);

    if (height < maxHeight) {
      // Make it scrollable
      this._elements.list.style.height = `${height - 1}px`;
    }
  }

  _onBeforeOpen(event) {
    event.stopImmediatePropagation();

    // Prevent opening the overlay if select is readonly
    if (this.readOnly) {
      event.preventDefault();
    }

    // focus first selected or tabbable item when the list expands
    this._elements.list._resetTabTarget(true);
  }

  /** @private */
  _onInternalEvent(event) {
    // stops propagation cause the event is internal to the component
    event.stopImmediatePropagation();
  }

  /** @ignore */
  _onItemAdded(item) {
    const selectListItemParent = this._elements.list;

    const selectListItem = item._selectListItem || new SelectList.Item();

    // @todo: Make sure it is added at the right index.
    selectListItemParent.appendChild(selectListItem);

    selectListItem.set({
      value: item.value,
      content: {
        innerHTML: item.innerHTML
      },
      disabled: item.disabled,
      selected: item.selected,
      trackingElement: item.trackingElement
    }, true);

    const nativeOption = item._nativeOption || new Option();

    // @todo: make sure it is added at the right index.
    this._elements.nativeSelect.appendChild(nativeOption);

    // Need to store the initially selected values in the native select so that it can be reset
    if (this._initialValues.indexOf(item.value) !== -1) {
      nativeOption.setAttribute('selected', 'selected');
    }

    nativeOption.selected = item.selected;
    nativeOption.value = item.value;
    nativeOption.disabled = item.disabled;
    nativeOption.innerHTML = item.innerHTML;

    if (this.multiple) {
      // in case it was selected before it was added
      if (item.selected) {
        this._addTagToTagList(item);
      }
    }
    // Make sure the input value is set to the selected item
    else if (item.selected) {
      this._elements.input.value = item.value;
    }

    item._selectListItem = selectListItem;
    item._nativeOption = nativeOption;

    selectListItem._selectItem = item;
    nativeOption._selectItem = item;
  }

  /** @private */
  _onItemRemoved(item) {
    if (item._selectListItem) {
      item._selectListItem.remove();
      item._selectListItem._selectItem = undefined;
      item._selectListItem = undefined;
    }

    if (item._nativeOption) {
      this._elements.nativeSelect.removeChild(item._nativeOption);
      item._nativeOption._selectItem = undefined;
      item._nativeOption = undefined;
    }

    this._removeTagFromTagList(item, true);
  }

  /** @private */
  _onItemSelected(item) {
    // in case the component is not in the DOM or the internals have not been created we force it
    if (!item._selectListItem || !item._selectListItem.parentNode) {
      this._onItemAdded(item);
    }

    item._selectListItem.selected = true;
    item._nativeOption.selected = true;

    if (this.multiple) {
      this._addTagToTagList(item);
      // @todo: what happens when ALL items have been selected
      //  1. a message is disabled (i18n?)
      //  2. we don't try to open the selectlist (native behavior).
    } else {
      this._elements.input.value = item.value;
    }
  }

  /** @private */
  _onItemDeselected(item) {
    // in case the component is not in the DOM or the internals have not been created we force it
    if (!item._selectListItem || !item._selectListItem.parentNode) {
      this._onItemAdded(item);
    }

    item._selectListItem.selected = false;
    item._nativeOption.selected = false;

    if (this.multiple) {
      // we use the internal reference to remove the related tag from the taglist
      this._removeTagFromTagList(item);
    }
  }

  /**
   Detects when something is about to change inside the select.

   @private
   */
  _onSelectListBeforeChange(event) {
    // stops propagation cause the event is internal to the component
    event.stopImmediatePropagation();

    // We prevent the selection to change if we're in single selection and the clicked item is already selected
    if (!this.multiple && event.detail.item.selected) {
      event.preventDefault();
      this._elements.overlay.open = false;
    }
  }

  /**
   Detects when something inside the select list changes.

   @private
   */
  _onSelectListChange(event) {
    // stops propagation cause the event is internal to the component
    event.stopImmediatePropagation();

    // avoids triggering unnecessary changes in the selectist because selecting items programatically will trigger
    // a change event
    if (this._bulkSelectionChange) {
      return;
    }

    let oldSelection = event.detail.oldSelection || [];
    oldSelection = !Array.isArray(oldSelection) ? [oldSelection] : oldSelection;

    let selection = event.detail.selection || [];
    selection = !Array.isArray(selection) ? [selection] : selection;

    // if the arrays are the same, there is no point in calculating the selection changes
    if (event.detail.oldSelection !== event.detail.selection) {
      this._bulkSelectionChange = true;

      // we deselect first the ones that have to go
      const removedSelection = arrayDiff(oldSelection, selection);
      removedSelection.forEach((listItem) => {
        // selectlist will report on removed items
        if (listItem._selectItem) {
          listItem._selectItem.removeAttribute('selected');
        }
      });

      // we only sync the items that changed
      const newSelection = arrayDiff(selection, oldSelection);
      newSelection.forEach((listItem) => {
        if (listItem._selectItem) {
          listItem._selectItem.setAttribute('selected', '');
        }
      });

      this._bulkSelectionChange = false;

      // hides the list since something was selected. if the overlay was open, it means there was user interaction so
      // the necessary events need to be triggered
      if (this._elements.overlay.open) {
        // closes and triggers the hideitems event
        this._hideOptions();

        // if there is a change in the added or removed selection, we trigger a change event
        if (newSelection.length || removedSelection.length) {
          this.trigger('change');
        }
      }
    }
      // in case they are the same, we just need to trigger the hideitems event when appropiate, and that is when the
    // overlay was previously open
    else if (this._elements.overlay.open) {
      // closes and triggers the hideitems event
      this._hideOptions();
    }

    if (!this.multiple) {
      this._trackEvent('change', 'coral-select-item', event, this.selectedItem);
    }
  }

  /** @private */
  _onTagListChange(event) {
    // cancels the change event from the taglist
    event.stopImmediatePropagation();

    // avoids triggering unnecessary changes in the selectist because selecting items programatically will trigger
    // a change event
    if (this._bulkSelectionChange) {
      return;
    }

    this._bulkSelectionChange = true;

    const values = event.target.values;
    // we use the selected items, because they are the only possible items that may change
    let itemValue;
    this.items._getAllSelected().forEach((item) => {
      // we use DOM API instead of properties in case the item is not yet initialized
      itemValue = itemValueFromDOM(item);
      // if the item is inside the values array, then it has to be selected
      item[values.indexOf(itemValue) !== -1 ? 'setAttribute' : 'removeAttribute']('selected', '');
    });

    this._bulkSelectionChange = false;

    // if the taglist is empty, we should return the focus to the button
    if (!values.length) {
      this._elements.button.focus();
    }

    // reparents the change event with the select as the target
    this.trigger('change');
  }

  /** @private */
  _addTagToTagList(item) {
    // we prepare the tag
    item._tag = item._tag || new Tag();
    item._tag.set({
      value: item.value,
      label: {
        innerHTML: item.innerHTML
      }
    }, true);

    // we add the new tag at the end
    this._elements.taglist.items.add(item._tag);
  }

  /** @private */
  _removeTagFromTagList(item, destroy) {
    if (item._tag) {
      item._tag.remove();
      // we only remove the reference if destroy is passed, this allow us to recycle the tags when possible
      item._tag = destroy ? undefined : item._tag;
    }
  }

  /** @private */
  _onSelectListScrollBottom(event) {
    // stops propagation cause the event is internal to the component
    event.stopImmediatePropagation();

    if (this._elements.overlay.open) {
      // Checking if the overlay is open guards against debounced scroll events being handled after an overlay has
      // already been closed (e.g. clicking the last element in a selectlist always reopened the overlay emediately
      // after closing)

      // triggers the corresponding event
      // since we got the the event from select list we need to trigger the event
      this._showOptions();
    }
  }

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

    if (this.disabled || this.readOnly) {
      return;
    }

    // if native is required, we do not need to do anything
    if (!this._useNativeInput) {
      // @todo: this was removed cause otherwise the coral-select:showitems event is never triggered.
      // if this is a multiselect and all items are selected, there should be nothing in the list to focus so do
      // nothing.
      // if (this.multiple && this.selectedItems.length === this.items.length) {
      //   return;
      // }

      // Toggle openness
      if (this._elements.overlay.classList.contains('is-open')) {
        this._hideOptions();
      } else {
        // event should be triggered based on the contents
        this._showOptions(true);
      }
    }
  }

  /** @private */
  _onNativeSelectClick() {
    this._showOptions(false);
  }

  _onOverlayKeyPress(event) {
    // Focus on item which text starts with pressed keys
    this._elements.list._onKeyPress(event);
  }

  /** @private */
  _onSpaceKey(event) {
    if (this.disabled || this.readOnly) {
      return;
    }

    event.preventDefault();

    if (this._useNativeInput) {
      // we try to open the native select
      this._elements.nativeSelect.dispatchEvent(new MouseEvent('mousedown'));
    } else if (!this._elements.overlay.open || event.keyCode === Keys.keyToCode('space')) {
      this._elements.button.click();
    }
  }

  /**
   Prevents tab key default handling on selectList Items.

   @private
   */
  // _onTabKey(event) {
  // event.preventDefault();
  // }

  /** @private */
  _onOverlayToggle(event) {
    // stops propagation cause the event is internal to the component
    event.stopImmediatePropagation();

    // Trigger private event instead
    const type = event.type.split(':').pop();
    this.trigger(`coral-select:_overlay${type}`);

    this._elements.button.classList.toggle('is-selected', event.target.open);

    // communicate expanded state to assistive technology
    this._elements.button.setAttribute('aria-expanded', event.target.open);

    if (!event.target.open) {
      this.classList.remove('is-openAbove', 'is-openBelow');
    }
  }

  /** @private */
  _onOverlayPositioned(event) {
    // stops propagation cause the event is internal to the component
    event.stopImmediatePropagation();

    if (this._elements.overlay.open) {
      this._elements.overlay.style.width = `${this.offsetWidth}px`;
    }
  }

  // @todo: while the select is multiple, if everything is deselected no change event will be triggered.
  _onNativeSelectChange(event) {
    // stops propagation cause the event is internal to the component
    event.stopImmediatePropagation();

    // avoids triggering unnecessary changes in the selectist because selecting items programatically will trigger
    // a change event
    if (this._bulkSelectionChange) {
      return;
    }

    this._bulkSelectionChange = true;
    // extracts the native options for the selected items. We use the selected options, instead of the complete
    // options to make the diff since it will normally be a smaller set
    const oldSelectedOptions = this.selectedItems.map((element) => element._nativeOption);

    // we convert the HTMLCollection to an array
    const selectedOptions = Array.prototype.slice.call(event.target.querySelectorAll(':checked'));

    const diff = arrayDiff(oldSelectedOptions, selectedOptions);
    diff.forEach((item) => {
      item._selectItem.selected = false;
    });

    // we only sync the items that changed
    const newSelection = arrayDiff(selectedOptions, oldSelectedOptions);
    newSelection.forEach((item) => {
      item._selectItem.selected = true;
    });

    this._bulkSelectionChange = false;

    // since multiple keeps the select open, we cannot return the focus to the button otherwise the user cannot
    // continue selecting values
    if (!this.multiple) {
      // returns the focus to the button, otherwise the select will keep it
      this._elements.button.focus();
      // since selecting an item closes the native select, we need to trigger an event
      this.trigger('coral-select:hideitems');
    }

    // if the native change event was triggered, then it means there is some new value
    this.trigger('change');
  }

  /**
   This handles content change of coral-select-item and updates its associatives.

   @private
   */
  _onItemContentChange(event) {
    // stops propagation cause the event is internal to the component
    event.stopImmediatePropagation();

    const item = event.target;
    if (item._selectListItem) {
      const content = new SelectList.Item.Content();
      content.innerHTML = item.innerHTML;
      item._selectListItem.content = content;
    }

    if (item._nativeOption) {
      item._nativeOption.innerHTML = item.innerHTML;
    }

    if (item._tag && item._tag.label) {
      item._tag.label.innerHTML = item.innerHTML;
    }

    // since the content changed, we need to sync the placeholder in case it was the selected item
    this._syncSelectedItemPlaceholder();
  }

  /** @private */
  _syncSelectedItemPlaceholder() {
    this.placeholder = this.getAttribute('placeholder');

    // case 3: !p + !m +  se = se
    // case 5:  p + !m +  se = se
    if (this.selectedItem && !this.multiple) {
      this._elements.label.classList.remove('is-placeholder');
      this._elements.label.innerHTML = this.selectedItem.innerHTML;
    }
  }

  /**
   This handles value change of coral-select-item and updates its associatives.

   @private
   */
  _onItemValueChange(event) {
    // stops propagation cause the event is internal to the component
    event.stopImmediatePropagation();

    const item = event.target;
    if (item._selectListItem) {
      item._selectListItem.value = item.value;
    }

    if (item._nativeOption) {
      item._nativeOption.value = item.value;
    }

    if (item._tag) {
      item._tag.value = item.value;
    }
  }

  /**
   This handles disabled change of coral-select-item and updates its associatives.

   @private
   */
  _onItemDisabledChange(event) {
    // stops propagation cause the event is internal to the component
    event.stopImmediatePropagation();

    const item = event.target;
    if (item._selectListItem) {
      item._selectListItem.disabled = item.disabled;
    }

    if (item._nativeOption) {
      item._nativeOption.disabled = item.disabled;
    }
  }

  /**
   In case an item from the initial selection is removed, we need to remove it from the initial values.

   @private
   */
  _validateInitialState(nodes) {
    let item;
    let index;

    // we iterate over all the nodes, checking if they matched the initial value
    for (let i = 0, nodeCount = nodes.length ; i < nodeCount ; i++) {
      // since we are not sure if the item has been upgraded, we try first the attribute, otherwise we extract the
      // value from the textContent
      item = nodes[i];

      index = this._initialValues.indexOf(item.value);

      if (index !== -1) {
        this._initialValues.splice(index, 1);
      }
    }
  }

  /** @private */
  // eslint-disable-next-line no-unused-vars
  _onCollectionChange(addedNodes, removedNodes) {
    // we make sure that items that were part of the initial selection are removed from the internal representation
    this._validateInitialState(removedNodes);
    // makes sure that the selection state matches the multiple variable
    this._setStateFromDOM();
  }

  /**
   Updates the label to reflect the current state. The label needs to be updated when the placeholder changes and
   when the selection changes.

   @private
   */
  _updateLabel() {
    this._syncSelectedItemPlaceholder();
  }

  /**
   Handles the selection state.

   @ignore
   */
  _setStateFromDOM() {
    // if it is not multiple, we need to be sure only one item is selected
    if (!this.hasAttribute('multiple')) {
      // makes sure that only one is selected
      this.items._deselectAllExceptLast();

      // we execute _getFirstSelected instead of _getSelected because it is faster
      const selectedItem = this.items._getFirstSelected();

      // case 1. there is a selected item, so no further change is required
      // case 2. no selected item and no placeholder. an item will be automatically selected
      // case 3. no selected item and a placehoder. we just make sure the value is really empty
      if (!selectedItem) {
        // we clean the value because there is no selected item
        this._elements.input.value = '';

        // when there is no placeholder, we need to force a selection to behave like the native select
        if (transform.string(this.getAttribute('placeholder')) === '') {
          // gets the first candidate for selection
          const selectable = this.items._getFirstSelectable();

          if (selectable) {
            // selects using the attribute in case the item is not yet initialized
            selectable.setAttribute('selected', '');
            // we set the value explicitely, so we do not need to wait for the MO
            this._elements.input.value = itemValueFromDOM(selectable);
          }
        }
      } else {
        // we set the value explicitely, so we do not need to wait for the MO
        this._elements.input.value = itemValueFromDOM(selectedItem);
      }
    }

    // handles the initial item in the select
    this._updateLabel();
  }

  /**
   Handles selecting multiple items. Selection could result a single or multiple selected items.

   @private
   */
  _onItemSelectedChange(event) {
    // we stop propagation since it is a private event
    event.stopImmediatePropagation();

    // the item that was selected
    const item = event.target;

    // setting this to true will ignore any changes from the selectlist al
    this._bulkSelectionChange = true;

    // when the item is selected, we need to enforce the selection mode
    if (item.selected) {
      this._onItemSelected(item);

      if (this.multiple) {
        this._trackEvent('select', 'coral-select-item', event, item);
      }

      // enforces the selection mode
      if (!this.hasAttribute('multiple')) {
        this.items._deselectAllExcept(item);
      }
    } else {
      this._onItemDeselected(item);

      if (this.multiple) {
        this._trackEvent('deselect', 'coral-select-item', event, item);
      }
    }

    this._bulkSelectionChange = false;

    // since there is a change in selection, we need to update the placeholder
    this._updateLabel();
  }

  /**
   Inherited from {@link BaseFormField#clear}.
   */
  clear() {
    this.value = '';
  }

  /**
   Focuses the component.

   @ignore
   */
  focus() {
    if (!this.contains(document.activeElement)) {
      this._elements.button.focus();
    }
  }

  /**
   Inherited from {@link BaseFormField#reset}.
   */
  reset() {
    // reset the values to the initial values
    this.values = this._initialValues;
  }

  /**
   Returns {@link Select} variants.

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

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

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

    const overlay = this._elements.overlay;
    // Cannot be open by default when rendered
    overlay.removeAttribute('open');
    // Restore in DOM
    if (overlay._parent) {
      overlay._parent.appendChild(overlay);
    }
  }

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

    this.classList.add(CLASSNAME);

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

    this.classList.toggle(`${CLASSNAME}--native`, this._useNativeInput);

    if (!this._useNativeInput && this.contains(this._elements.nativeSelect)) {
      this.removeChild(this._elements.nativeSelect);
    }

    // handles the initial selection
    this._setStateFromDOM();

    // we need to keep a state of the initial items to be able to reset the component. values is not reliable during
    // initialization since items are not yet initialized
    this.selectedItems.forEach((item) => {
      // we use DOM API instead of properties in case the item is not yet initialized
      this._initialValues.push(itemValueFromDOM(item));
    });

    // Cleanup template elements (supporting cloneNode)
    const templateElements = this.querySelectorAll('[handle]');
    for (let i = 0 ; i < templateElements.length ; ++i) {
      const currentElement = templateElements[i];
      if (currentElement.parentNode === this) {
        this.removeChild(currentElement);
      }
    }

    // Render the main template
    const frag = document.createDocumentFragment();

    frag.appendChild(this._elements.button);
    frag.appendChild(this._elements.input);
    frag.appendChild(this._elements.nativeSelect);
    frag.appendChild(this._elements.taglist);
    frag.appendChild(this._elements.overlay);

    // Assign the button as the target for the overlay
    this._elements.overlay.target = this._elements.button;
    // handles the focus allocation every time the overlay closes
    this._elements.overlay.returnFocusTo(this._elements.button);

    this.appendChild(frag);
  }

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

    const overlay = this._elements.overlay;
    // In case it was moved out don't forget to remove it
    if (!this.contains(overlay)) {
      overlay._parent = overlay._repositioned ? document.body : this;
      overlay.remove();
    }
  }

  /**
   Triggered when the {@link Select} could accept external data to be loaded by the user. If <code>preventDefault()</code> is
   called, then a loading indicator will be shown. {@link Select#loading} should be set to false to indicate
   that the data has been successfully loaded.

   @typedef {CustomEvent} coral-select:showitems

   @property {Number} detail.start
   The count of existing items, which is the index where new items should start.
   */

  /**
   Triggered when the {@link Select} hides the UI used to select items. This is typically used to cancel a load request
   because the items will not be shown anymore.

   @typedef {CustomEvent} coral-select:hideitems
   */
});

export default Select;