Reference Source

coral-spectrum/coral-component-shell/src/scripts/ShellMenuBarItem.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 {commons, transform, validate} from '../../../coral-utils';
import '../../../coral-component-icon';
import '../../../coral-component-button';
import '../../../coral-component-anchorbutton';
import menuBarItem from '../templates/menuBarItem';

/**
 Enumeration for {@link ShellMenuBarItem} icon variants.

 @typedef {Object} ShellMenuBarItemIconVariantEnum

 @property {String} DEFAULT
 A default menubar item.
 @property {String} CIRCLE
 A round image as menubar item.
 */
const iconVariant = {
  DEFAULT: 'default',
  CIRCLE: 'circle'
};

/**
 Enumeration for valid aria-haspopup values.

 @typedef {Object} ShellMenuBarItemHasPopupRoleEnum
 @property {String} MENU
 ShellMenuBarItem opens a menu.
 @property {String} LISTBOX
 ShellMenuBarItem opens a list box.
 @property {String} TREE
 ShellMenuBarItem opens a tree.
 @property {String} GRID
 ShellMenuBarItem opens a grid.
 @property {String} DIALOG
 ShellMenuBarItem opens a dialog.
 @property {Null} DEFAULT
 Defaults to null.

 */
const hasPopupRole = {
  MENU: 'menu',
  LISTBOX: 'listbox',
  TREE: 'tree',
  GRID: 'grid',
  DIALOG: 'dialog',
  DEFAULT: null
};

// the Menubar Item's base classname
const CLASSNAME = '_coral-Shell-menubar-item';

// Builds a string containing all possible iconVariant classnames. This will be used to remove classnames when the variant
// changes
const ALL_ICON_VARIANT_CLASSES = [];
for (const variantValue in iconVariant) {
  ALL_ICON_VARIANT_CLASSES.push(`${CLASSNAME}--${iconVariant[variantValue]}`);
}

/**
 @class Coral.Shell.MenuBar.Item
 @classdesc A Shell MenuBar Item component
 @htmltag coral-shell-menubar-item
 @extends {HTMLElement}
 @extends {BaseComponent}
 */
class ShellMenuBarItem extends BaseComponent(HTMLElement) {
  /** @ignore */
  constructor() {
    super();

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

    // Events
    this._delegateEvents({
      'click [handle="shellMenuButton"]': '_handleButtonClick',

      // it has to be global because the menus are not direct children
      'global:coral-overlay:close': '_handleOverlayEvent',
      'global:coral-overlay:beforeclose': '_handleOverlayBeforeEvent',
      'global:coral-overlay:open': '_handleOverlayEvent',
      'global:coral-overlay:beforeopen': '_handleOverlayBeforeEvent'
    });
  }

  /**
   Specifies the icon name used inside the menu item.
   See {@link Icon} for valid icon names.

   @type {String}
   @default ""
   @htmlattribute icon
   */
  get icon() {
    return this._elements.shellMenuButton.icon;
  }

  set icon(value) {
    this._elements.shellMenuButton.icon = value;
  }

  /**
   Size of the icon. It accepts both lower and upper case sizes. See {@link ButtonIconSizeEnum}.

   @type {String}
   @default ButtonIconSizeEnum.SMALL
   @htmlattribute iconsize
   @htmlattributereflected
   */
  get iconSize() {
    return this._elements.shellMenuButton.iconSize;
  }

  set iconSize(value) {
    this._elements.shellMenuButton.iconSize = value;
    // Required for styling
    this._reflectAttribute('iconsize', this.iconSize);
  }

  /**
   The menubar item's iconVariant. See {@link ShellMenuBarItemIconVariantEnum}.

   @type {String}
   @default ShellMenuBarItemIconVariantEnum.DEFAULT
   @htmlattribute iconvariant
   */
  get iconVariant() {
    return this._iconVariant || iconVariant.DEFAULT;
  }

  set iconVariant(value) {
    value = transform.string(value).toLowerCase();
    this._iconVariant = validate.enumeration(iconVariant)(value) && value || iconVariant.DEFAULT;

    // removes all the existing variants
    this.classList.remove(...ALL_ICON_VARIANT_CLASSES);
    // adds the new variant
    if (this.variant !== iconVariant.DEFAULT) {
      this.classList.add(`${CLASSNAME}--${this._iconVariant}`);
    }
  }

  /**
   The notification badge content.

   @type {String}
   @default ""
   @htmlattribute badge
   */
  get badge() {
    return this._elements.shellMenuButton.getAttribute('badge') || '';
  }

  set badge(value) {
    // Non-truthy values shouldn't show
    // null, empty string, 0, etc
    this._elements.shellMenuButton[!value || value === '0' ? 'removeAttribute' : 'setAttribute']('badge', value);
  }

  /**
   Whether the menu is open or not.

   @type {Boolean}
   @default false
   @htmlattribute open
   @htmlattributereflected

   @emits {coral-shell-menubar-item:open}
   @emits {coral-shell-menubar-item:close}
   */
  get open() {
    return this._open || false;
  }

  set open(value) {
    const menu = this._getMenu();

    // if we want to open the dialog we need to make sure there is a valid menu or hasPopup
    if (menu === null && this.hasPopup === hasPopupRole.DEFAULT) {
      return;
    }

    this._open = transform.booleanAttr(value);
    this._reflectAttribute('open', this._open);

    // if the menu is valid, toggle the menu and trigger the appropriate event
    if (menu !== null) {
      // Toggle the target menu
      if (menu.open !== this._open) {
        menu.open = this._open;
      }

      this.trigger(`coral-shell-menubar-item:${this._open ? 'open' : 'close'}`);
    }

    this._elements.shellMenuButton.setAttribute('aria-expanded', this._open);
  }

  /**
   The menubar item's label content zone.

   @type {ButtonLabel}
   @contentzone
   */
  get label() {
    return this._getContentZone(this._elements.shellMenuButtonLabel);
  }

  set label(value) {
    this._setContentZone('label', value, {
      handle: 'shellMenuButtonLabel',
      tagName: 'coral-button-label',
      insert: function (label) {
        this._elements.shellMenuButton.label = label;
      }
    });
  }

  /**
   The menu that this menu item should show. If a CSS selector is provided, the first matching element will be
   used.

   @type {?HTMLElement|String}
   @default null
   @htmlattribute menu
   */
  get menu() {
    return this._menu || null;
  }

  set menu(value) {
    let menu;
    if (value instanceof HTMLElement) {
      this._menu = value;
      menu = this._menu;
    } else {
      this._menu = String(value);
      menu = document.querySelector(this._menu);
    }

    // Link menu with item
    if (menu !== null) {
      this.id = this.id || commons.getUID();
      menu.setAttribute('target', `#${this.id}`);
      if (this.hasPopup === hasPopupRole.DEFAULT) {
        this.hasPopup = menu.getAttribute('role') || hasPopupRole.DIALOG;
      }
    } else if (this._menu && this.hasPopup !== hasPopupRole.DEFAULT) {
      this.hasPopup = hasPopupRole.DEFAULT;
    }
  }

  /**
   Whether the item opens a popup dialog or menu. Accepts either "menu", "listbox", "tree", "grid", or "dialog".
   @type {?String}
   @default ShellMenuBarItemHasPopupRoleEnum.DEFAULT
   @htmlattribute haspopup
   */
  get hasPopup() {
    return this._hasPopup || null;
  }

  set hasPopup(value) {
    value = transform.string(value).toLowerCase();
    this._hasPopup = validate.enumeration(hasPopupRole)(value) && value || hasPopupRole.DEFAULT;

    const shellMenuButton = this._elements.shellMenuButton;
    let ariaHaspopup = this._hasPopup;

    if (ariaHaspopup) {
      shellMenuButton.setAttribute('aria-haspopup', ariaHaspopup);
      shellMenuButton.setAttribute('aria-expanded', this.open);
    } else {
      shellMenuButton.removeAttribute('aria-haspopup');
      shellMenuButton.removeAttribute('aria-expanded');
    }
  }

  _handleOverlayBeforeEvent(event) {
    const target = event.target;

    if (target === this._getMenu()) {
      // Mark button as selected
      this._elements.shellMenuButton.classList.toggle('is-selected', !target.open);
    }
  }

  /** @private */
  _handleOverlayEvent(event) {
    const target = event.target;

    // matches the open state of the target in case it was open separately
    if (target === this._getMenu()) {
      const shellMenuButton = this._elements.shellMenuButton;
      if (this.open !== target.open) {
        this.open = target.open;
      } else if (shellMenuButton.getAttribute('aria-expanded') !== target.open) {
        shellMenuButton.setAttribute('aria-expanded', target.open);
      }
    }
  }

  /** @ignore */
  _handleButtonClick() {
    this.open = !this.open;
  }

  /** @ignore */
  _getMenu(targetValue) {
    // Use passed target
    targetValue = targetValue || this.menu;

    if (targetValue instanceof Node) {
      // Just return the provided Node
      return targetValue;
    }

    // Dynamically get the target node based on target
    let newTarget = null;
    if (typeof targetValue === 'string') {
      newTarget = document.querySelector(targetValue);
    }

    return newTarget;
  }

  get _contentZones() {
    return {'coral-button-label': 'label'};
  }

  /** @ignore */
  focus() {
    this._elements.shellMenuButton.focus();
  }

  /**
   Returns {@link ShellMenuBarItem} icon variants.

   @return {ShellMenuBarItemIconVariantEnum}
   */
  static get iconVariant() {
    return iconVariant;
  }

  static get _attributePropertyMap() {
    return commons.extend(super._attributePropertyMap, {
      haspopup: 'hasPopup',
      iconsize: 'iconSize',
      iconvariant: 'iconVariant'
    });
  }

  /** @ignore */
  static get observedAttributes() {
    return super.observedAttributes.concat([
      'haspopup',
      'icon',
      'iconsize',
      'iconvariant',
      'badge',
      'open',
      'menu',
      'aria-label'
    ]);
  }

  /** @ignore */
  attributeChangedCallback(name, oldValue, value) {
    // a11y When user doesn't supply a button label (for an icon-only button),
    // providing aria-label will correctly pass it on to the shell menu button child element.
    if (name === 'aria-label') {
      if (value && this._elements.shellMenuButton.textContent.trim() === '') {
        this._elements.shellMenuButton.setAttribute('aria-label', value);
      }
    } else {
      super.attributeChangedCallback(name, oldValue, value);
    }
  }

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

    this.setAttribute('role', 'listitem');

    this.classList.add(CLASSNAME);

    const button = this.querySelector('._coral-Shell-menu-button');

    if (button) {
      this._elements.shellMenuButton = button;
      this._elements.shellMenuButtonLabel = this.querySelector('coral-button-label');
    } else {
      while (this.firstChild) {
        this._elements.shellMenuButtonLabel.appendChild(this.firstChild);
      }

      this.appendChild(this._elements.shellMenuButton);
    }

    this.label = this._elements.shellMenuButtonLabel;

    // Sync menu
    if (this.menu !== null) {
      this.menu = this.menu;
    }
  }

  /**
   Triggered after the {@link ShellMenuBarItem} is opened with <code>show()</code> or <code>instance.open = true</code>

   @typedef {CustomEvent} coral-shell-menubar-item:open
   */

  /**
   Triggered after the {@link ShellMenuBarItem} is closed with <code>hide()</code> or <code>instance.open = false</code>

   @typedef {CustomEvent} coral-shell-menubar-item:close
   */
}

export default ShellMenuBarItem;