Reference Source

coral-spectrum/coral-component-accordion/src/scripts/Accordion.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 {Decorator} from '../../../coral-decorator';
import {SelectableCollection} from '../../../coral-collection';
import {transform, validate, Keys} from '../../../coral-utils';

// Key codes
const PAGE_UP = 33;
const PAGE_DOWN = 34;
const LEFT_ARROW = 37;
const UP_ARROW = 38;

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

 @typedef {Object} AccordionVariantEnum

 @property {String} DEFAULT
 Default look and feel.
 @property {String} QUIET
 Not supported. Falls back to DEFAULT.
 @property {String} LARGE
 Not supported. Falls back to DEFAULT.
 */
const variant = {
  DEFAULT: 'default',
  QUIET: 'quiet',
  LARGE: 'large'
};

// the accordions's base classname
const CLASSNAME = '_coral-Accordion';

/**
 @class Coral.Accordion
 @classdesc An Accordion component consisting of multiple collapsible items.
 @htmltag coral-accordion
 @extends {HTMLElement}
 @extends {BaseComponent}
 */
const Accordion = Decorator(class extends BaseComponent(HTMLElement) {
  /** @ignore */
  constructor() {
    super();

    // Attach events
    this._delegateEvents({
      'click coral-accordion-item:not([disabled]) ._coral-Accordion-itemHeader': '_onItemClick',

      'key:space ._coral-Accordion-itemHeader': '_onToggleItemKey',
      'key:return ._coral-Accordion-itemHeader': '_onToggleItemKey',
      'key:pageup ._coral-Accordion-itemHeader': '_focusPreviousItem',
      'key:left ._coral-Accordion-itemHeader': '_focusPreviousItem',
      'key:up ._coral-Accordion-itemHeader': '_focusPreviousItem',
      'key:pagedown ._coral-Accordion-itemHeader': '_focusNextItem',
      'key:right ._coral-Accordion-itemHeader': '_focusNextItem',
      'key:down ._coral-Accordion-itemHeader': '_focusNextItem',
      'key:home ._coral-Accordion-itemHeader': '_onHomeKey',
      'key:end ._coral-Accordion-itemHeader': '_onEndKey',
      'keydown ._coral-Accordion-itemHeader': '_onItemContentKeyDown',

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

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

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

  /**
   The Accordion's variant. See {@link AccordionVariantEnum}.

   @type {String}
   @default AccordionVariantEnum.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);
  }

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

   @type {Collection}
   @readonly
   */
  get items() {
    // just init on demand
    if (!this._items) {
      this._items = new SelectableCollection({
        host: this,
        itemTagName: 'coral-accordion-item',
        // allows accordions to be nested
        itemSelector: ':scope > coral-accordion-item',
        onlyHandleChildren: true,
        onItemAdded: this._validateSelection,
        onItemRemoved: this._validateSelection
      });
    }
    return this._items;
  }

  /**
   Indicates whether the accordion 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._validateSelection();
  }

  /**
   Returns an Array containing the set selected items.
   @type {Array.<AccordionItem>}
   @readonly
   */
  get selectedItems() {
    return this.items._getAllSelected();
  }

  /**
   Returns the first selected item in the Accordion. The value <code>null</code> is returned if no element is
   selected.
   @type {AccordionItem}
   @readonly
   */
  get selectedItem() {
    return this.items._getFirstSelected();
  }

  /**
   The heading level for Accordion items within the Accordion

   @type {Number}
   @default 3
   @htmlattribute level
   @htmlattributereflected
   */
  get level() {
    return this._level || 3;
  }

  set level(value) {
    value = transform.number(value);
    if (validate.valueMustChange(value, this._level) && value > 0 && value < 7) {
      this._level = value;
      this._reflectAttribute('level', this._level);
      this.items.getAll().forEach(item => item.setAttribute('level', this._level));
    }
  }

  /** @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.getAll().forEach((item) => {
      item._isTabTarget = item === value;
    });
  }

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

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

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

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

  /**
   References:
   http://www.w3.org/WAI/PF/aria-practices/#accordion &

   Handlers for when focus is on an element inside of the panel:
   http://test.cita.illinois.edu/aria/tabpanel/tabpanel2.php

   @private
   */
  _onItemContentKeyDown(event) {
    // Required since sometimes the value is a number
    const key = parseFloat(event.keyCode);
    const item = event.matchedTarget.parentNode;

    switch (key) {
      case UP_ARROW:
      case LEFT_ARROW:
        // Set focus on the tab button for the currently displayed tab.
        if ((event.metaKey || event.ctrlKey) && Keys.filterInputs(event)) {
          event.preventDefault();
          event.stopPropagation();

          this._focusItem(item);
        }
        break;
      case PAGE_UP:
        // Show the previous tab and set focus on its corresponding tab button. Shows the last tab in the panel if
        // current tab is the first one.
        if (event.metaKey || event.ctrlKey) {
          event.preventDefault();
          event.stopPropagation();

          const prevItem = this.items._getPreviousSelectable(item);
          this._toggleItemSelection(prevItem);
          this._focusItem(prevItem);
        }
        break;
      case PAGE_DOWN:
        // Show the next tab and set focus on its corresponding tab button. Shows the first tab in the panel if current
        // tab is the last one.
        if (event.metaKey || event.ctrlKey) {
          event.preventDefault();
          event.stopPropagation();

          const nextItem = this.items._getNextSelectable(item);
          this._toggleItemSelection(nextItem);
          this._focusItem(nextItem);
        }
        break;
    }
  }

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

    this._focusItem(this.items._getPreviousSelectable(event.target.closest('coral-accordion-item')));
  }

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

    this._focusItem(this.items._getNextSelectable(event.target.closest('coral-accordion-item')));
  }

  /** @private */
  _onItemClick(event) {
    // Clickable elements included in an item header shouldn't automatically trigger the selection of that item
    if (event.target.hasAttribute('coral-interactive') || event.target.closest('[coral-interactive]')) {
      return;
    }

    // The click was performed on the header so we select the item (parentNode) the selection is toggled
    const item = event.target.closest('coral-accordion-item');
    if (item) {
      event.preventDefault();
      event.stopPropagation();

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

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

    const item = event.target.closest('coral-accordion-item');
    this._toggleItemSelection(item);
    this._focusItem(item);
  }

  /** @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;
      }
    }

    // set items level appropriately
    if (item && item.getAttribute('level') !== this.level) {
      item.setAttribute('level', this.level);
    }

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

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

   @private
   */
  _resetTabTarget() {
    if (!this._resetTabTargetScheduled) {
      this._resetTabTargetScheduled = true;

      window.requestAnimationFrame(() => {
        this._resetTabTargetScheduled = false;

        // since hidden items cannot have focus, we need to make sure the tabTarget is not hidden
        const selectedItems = this.items._getAllSelected();

        this._tabTarget = selectedItems.length ? selectedItems[0] : this.items._getFirstSelectable();
      });
    }
  }

  /** @private */
  _toggleItemSelection(item) {
    if (item) {
      item[item.hasAttribute('selected') ? 'removeAttribute' : 'setAttribute']('selected', '');
    }
  }

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

    this._tabTarget = item;
  }

  /**
   Returns {@link Accordion} variants.

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

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

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

    this.classList.add(CLASSNAME);

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

    // WAI-ARIA 1.1
    this.setAttribute('role', 'region');

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

    this._oldSelection = this.selectedItems;

    // Don't trigger animations on rendering
    window.requestAnimationFrame(() => {
      this.classList.add(`${CLASSNAME}--animated`);
    });
  }

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

   @typedef {CustomEvent} coral-accordion:change

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

export default Accordion;