Reference Source

coral-spectrum/coral-component-sidenav/src/scripts/SideNav.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 {Decorator} from '../../../coral-decorator';

const CLASSNAME = '_coral-SideNav';

const isLevel = node => node.nodeName === 'CORAL-SIDENAV-LEVEL';
const isHeading = node => node.nodeName === 'CORAL-SIDENAV-HEADING';
const isItem = node => node.nodeName === 'A' && node.getAttribute('is') === 'coral-sidenav-item';

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

 @typedef {Object} SideNavVariantEnum

 @property {String} DEFAULT
 A default sidenav.
 @property {String} MULTI_LEVEL
 A sidenav with multiple levels of indentation.
 */
const variant = {
  DEFAULT: 'default',
  MULTI_LEVEL: 'multilevel',
};

/**
 @class Coral.SideNav
 @classdesc A Side Navigation component to navigate the entire content of a product or a section.
 These can be used for a single level or a multi-level navigation.
 @htmltag coral-sidenav
 @extends {HTMLElement}
 @extends {BaseComponent}
 */
const SideNav = Decorator(class extends BaseComponent(HTMLElement) {
  /** @ignore */
  constructor() {
    super();

    // Attach events
    this._delegateEvents({
      // Interaction
      'click a[is="coral-sidenav-item"]': '_onItemClick',

      // Accessibility
      'capture:focus a[is="coral-sidenav-item"].focus-ring': '_onItemFocusIn',
      'capture:blur a[is="coral-sidenav-item"]': '_onItemFocusOut',

      // Private
      'coral-sidenav-item:_selectedchanged': '_onItemSelectedChanged'
    });

    // Used for eventing
    this._oldSelection = null;

    // Level Collection
    this._levels = this.getElementsByTagName('coral-sidenav-level');

    // Heading Collection
    this._headings = this.getElementsByTagName('coral-sidenav-heading');

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

    // Initialize content MO
    this._observer = new MutationObserver(this._handleMutations.bind(this));
    this._observer.observe(this, {
      childList: true,
      subtree: true
    });
  }

  /**
   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-sidenav-item',
        itemBaseTagName: 'a',
        onItemAdded: this._validateSelection,
        onItemRemoved: this._validateSelection
      });
    }
    return this._items;
  }

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

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

  /**
   The sidenav's variant. See {@link SideNavVariantEnum}.

   @type {String}
   @default SideNavVariantEnum.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.classList.toggle(`${CLASSNAME}--multiLevel`, this._variant === variant.MULTI_LEVEL);

    if (this.variant === variant.MULTI_LEVEL) {
      // Don't hide the selected item level
      const selectedItem = this.selectedItem;
      const ignoreLevel = selectedItem && selectedItem.parentNode;

      // Hide every other level that doesn't contain the selected item
      for (let i = 0 ; i < this._levels.length ; i++) {
        if (this._levels[i] !== ignoreLevel) {
          this._levels[i].setAttribute('_expanded', 'off');
        }
      }
    }
  }

  _onItemClick(event) {
    const item = event.matchedTarget;

    if (!item.selected) {
      item.selected = true;
    }
  }

  _onItemFocusIn(event) {
    const item = event.matchedTarget;

    item._elements.container.classList.add('focus-ring');
  }

  _onItemFocusOut(event) {
    const item = event.matchedTarget;

    item._elements.container.classList.remove('focus-ring');
  }

  _onItemSelectedChanged(event) {
    event.stopImmediatePropagation();

    this._validateSelection(event.target);
  }

  _validateSelection(item) {
    const selectedItems = this.items._getAllSelected();
    // Last selected item wins if multiple selection while not allowed
    item = item || selectedItems[selectedItems.length - 1];

    // Deselect other selected items
    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;
    }

    // Expand multi level
    this._expandLevels();

    // Notify of change
    this._triggerChangeEvent();
  }

  _expandLevels() {
    const selectedItem = this.selectedItem;
    if (selectedItem) {
      let level = selectedItem.closest('coral-sidenav-level');

      // Expand until root
      while (level) {
        level.setAttribute('_expanded', 'on');

        const prev = level.previousElementSibling;
        if (prev && prev.matches('a[is="coral-sidenav-item"]')) {
          prev.setAttribute('aria-expanded', 'true');
        }

        level = level.parentNode && level.parentNode.closest('coral-sidenav-level');
      }

      // Expand corresponding item level
      level = selectedItem.nextElementSibling;
      if (level && level.tagName === 'CORAL-SIDENAV-LEVEL') {
        level.setAttribute('_expanded', 'on');
        selectedItem.setAttribute('aria-expanded', 'true');
      }
    }
  }

  _triggerChangeEvent() {
    const selectedItem = this.selectedItem;
    const oldSelection = this._oldSelection;

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

      this._oldSelection = selectedItem;
    }
  }

  _syncLevel(el, isRemoved) {
    if (isRemoved) {
      if (el.id && isLevel(el)) {
        const item = this.querySelector(`a[is="coral-sidenav-item"][aria-controls="${el.id}"]`);
        item && item.removeAttribute('aria-controls');
      } else if (el.id && (isHeading(el) || isItem(el))) {
        const level = this.querySelector(`coral-sidenav-level[aria-labelledby="${el.id}"]`);
        level && level.removeAttribute('aria-labelledby');
        this._syncLevel(level);
      }
    } else if (isLevel(el)) {
      const prev = el.previousElementSibling;
      if (prev && (isHeading(prev) || isItem(prev))) {
        prev.id = prev.id || commons.getUID();
        el.setAttribute('aria-labelledby', prev.id);

        if (isItem(prev)) {
          el.id = el.id || commons.getUID();
          prev.setAttribute('aria-controls', el.id);
        }
      }
    } else if (isHeading(el) || isItem(el)) {
      const next = el.nextElementSibling;
      if (next && isLevel(next)) {
        el.id = el.id || commons.getUID();
        next.setAttribute('aria-labelledby', el.id);

        if (isItem(el)) {
          next.id = next.id || commons.getUID();
          el.setAttribute('aria-controls', next.id);
        }
      }
    }
  }

  _syncHeading(heading) {
    heading.classList.add(`${CLASSNAME}-heading`);
    heading.setAttribute('role', 'heading');
  }

  _handleMutations(mutations) {
    mutations.forEach((mutation) => {
      // Sync added levels and headings
      for (let i = 0 ; i < mutation.addedNodes.length ; i++) {
        const addedNode = mutation.addedNodes[i];

        // a11y
        this._syncLevel(addedNode);

        // a11y
        if (isHeading(addedNode)) {
          this._syncHeading(addedNode);
        }

        if (isLevel(addedNode)) {
          this._validateSelection(addedNode.querySelector('a[is="coral-sidenav-item"][selected]'));
        }
      }

      // Sync removed levels
      for (let k = 0 ; k < mutation.removedNodes.length ; k++) {
        const removedNode = mutation.removedNodes[k];

        this._syncLevel(removedNode, true);

        if (isLevel(removedNode)) {
          this._validateSelection();
        }
      }
    });
  }

  /**
   Returns {@link SideNav} variants.

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

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

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

    this.classList.add(CLASSNAME);

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

    // a11y
    for (let i = 0 ; i < this._levels.length ; i++) {
      this._syncLevel(this._levels[i]);
    }

    // a11y
    for (let i = 0 ; i < this._headings.length ; i++) {
      this._syncHeading(this._headings[i]);
    }

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

    this._oldSelection = this.selectedItem;
  }

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

   @typedef {CustomEvent} coral-sidenav:change

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

export default SideNav;