Reference Source

coral-spectrum/coral-base-button/src/scripts/BaseButton.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 {BaseLabellable} from '../../../coral-base-labellable';
import {Icon} from '../../../coral-component-icon';
import {transform, validate, commons} from '../../../coral-utils';

/**
 Enumeration for {@link Button}, {@link AnchorButton} icon sizes.

 @typedef {Object} ButtonIconSizeEnum

 @property {String} EXTRA_EXTRA_SMALL
 Extra extra small size icon, typically 9px size.
 @property {String} EXTRA_SMALL
 Extra small size icon, typically 12px size.
 @property {String} SMALL
 Small size icon, typically 18px size. This is the default size.
 @property {String} MEDIUM
 Medium size icon, typically 24px size.
 */
const iconSize = {};
const excludedIconSizes = [Icon.size.LARGE, Icon.size.EXTRA_LARGE, Icon.size.EXTRA_EXTRA_LARGE];
for (const key in Icon.size) {
  // Populate button icon sizes by excluding the largest icon sizes
  if (excludedIconSizes.indexOf(Icon.size[key]) === -1) {
    iconSize[key] = Icon.size[key];
  }
}

/**
 Enumeration for {@link Button}, {@link AnchorButton} variants.

 @typedef {Object} ButtonVariantEnum

 @property {String} CTA
 A button that is meant to grab the user's attention.
 @property {String} PRIMARY
 A button that is meant to grab the user's attention.
 @property {String} QUIET
 A quiet button that indicates that the button's action is the primary action.
 @property {String} SECONDARY
 A button that indicates that the button's action is the secondary action.
 @property {String} QUIET_SECONDARY
 A quiet secondary button.
 @property {String} ACTION
 An action button.
 @property {String} QUIET_ACTION
 A quiet action button.
 @property {String} MINIMAL
 A quiet minimalistic button.
 @property {String} WARNING
 A button that indicates that the button's action is dangerous.
 @property {String} QUIET_WARNING
 A quiet warning button,
 @property {String} OVER_BACKGROUND
 A button to be placed on top of colored background.
 @property {String} DEFAULT
 The default button look and feel.
 */
const variant = {
  CTA: 'cta',
  PRIMARY: 'primary',
  SECONDARY: 'secondary',
  QUIET: 'quiet',
  MINIMAL: 'minimal',
  WARNING: 'warning',
  ACTION: 'action',
  QUIET_ACTION: 'quietaction',
  QUIET_SECONDARY: 'quietsecondary',
  QUIET_WARNING: 'quietwarning',
  OVER_BACKGROUND: 'overbackground',
  DEFAULT: 'default',
  // Private to be used for custom Button classes like field buttons
  _CUSTOM: '_custom'
};

// the button's base classname
const CLASSNAME = '_coral-Button';
const ACTION_CLASSNAME = '_coral-ActionButton';

const ALL_VARIANT_CLASSES = [
  `${CLASSNAME}--cta`,
  `${CLASSNAME}--primary`,
  `${CLASSNAME}--secondary`,
  `${CLASSNAME}--warning`,
  `${CLASSNAME}--quiet`,
  `${ACTION_CLASSNAME}--quiet`,
  `${CLASSNAME}--overBackground`,
];

const VARIANT_MAP = {
  cta: [CLASSNAME, ALL_VARIANT_CLASSES[0]],
  primary: [CLASSNAME, ALL_VARIANT_CLASSES[0]],
  secondary: [CLASSNAME, ALL_VARIANT_CLASSES[2]],
  warning: [CLASSNAME, ALL_VARIANT_CLASSES[3]],
  quiet: [CLASSNAME, ALL_VARIANT_CLASSES[1], ALL_VARIANT_CLASSES[4]],
  minimal: [CLASSNAME, ALL_VARIANT_CLASSES[2], ALL_VARIANT_CLASSES[4]],
  default: [CLASSNAME, ALL_VARIANT_CLASSES[1]],
  action: [ACTION_CLASSNAME],
  quietaction: [ACTION_CLASSNAME, ALL_VARIANT_CLASSES[5]],
  quietsecondary: [CLASSNAME, ALL_VARIANT_CLASSES[2], ALL_VARIANT_CLASSES[4]],
  quietwarning: [CLASSNAME, ALL_VARIANT_CLASSES[3], ALL_VARIANT_CLASSES[4]],
  overbackground: [CLASSNAME, ALL_VARIANT_CLASSES[6]]
};

/**
 Enumeration for {@link BaseButton} sizes.

 @typedef {Object} ButtonSizeEnum

 @property {String} MEDIUM
 A medium button is the default, normal sized button.
 @property {String} LARGE
 Not supported. Falls back to MEDIUM.
 */
const size = {
  MEDIUM: 'M',
  LARGE: 'L'
};

/**
 Enumeration for {@link BaseButton} icon position options.

 @typedef {Object} ButtonIconPositionEnum

 @property {String} RIGHT
 Position should be right of the button label.
 @property {String} LEFT
 Position should be left of the button label.
 */
const iconPosition = {
  RIGHT: 'right',
  LEFT: 'left'
};

/**
 @base BaseButton
 @classdesc The base element for Button components
 */
const BaseButton = (superClass) => class extends BaseLabellable(superClass) {
  /** @ignore */
  constructor() {
    super();

    // Templates
    this._elements = {
      // Create or fetch the label element
      label: this.querySelector(this._contentZoneTagName) || document.createElement(this._contentZoneTagName),
      icon: this.querySelector('coral-icon')
    };

    // Events
    this._events = {
      mousedown: '_onMouseDown',
      click: '_onClick'
    };

    super._observeLabel();
  }

  /**
   The label of the button.
   @type {HTMLElement}
   @contentzone
   */
  get label() {
    return this._getContentZone(this._elements.label);
  }

  set label(value) {
    this._setContentZone('label', value, {
      handle: 'label',
      tagName: this._contentZoneTagName,
      insert: function (label) {
        // Update label styles
        this._updateLabel(label);

        // Ensure there's no extra space left for icon only buttons
        if (label.innerHTML.trim() === '') {
          label.textContent = '';
        }

        if (this.iconPosition === iconPosition.LEFT) {
          this.appendChild(label);
        } else {
          this.insertBefore(label, this.firstChild);
        }
      }
    });
  }

  /**
   Position of the icon relative to the label. If no <code>iconPosition</code> is provided, it will be set on the
   left side by default.
   See {@link ButtonIconPositionEnum}.

   @type {String}
   @default ButtonIconPositionEnum.LEFT
   @htmlattribute iconposition
   @htmlattributereflected
   */
  get iconPosition() {
    return this._iconPosition || iconPosition.LEFT;
  }

  set iconPosition(value) {
    value = transform.string(value).toLowerCase();
    value = validate.enumeration(iconPosition)(value) && value || iconPosition.LEFT;
    
    this._reflectAttribute('iconposition', value);

    if(validate.valueMustChange(this._iconPosition, value)) {
      this._iconPosition = value;
      this._updateIcon(this.icon);
    }
  }

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

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

    return this._icon || '';
  }

  set icon(value) {
    value = transform.string(value);
    if(validate.valueMustChange(this._icon, value)) {
      this._icon = value;
      this._updateIcon(value);
    }
  }

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

   @type {String}
   @default ButtonIconSizeEnum.SMALL
   @htmlattribute iconsize
   */
  get iconSize() {
    if (this._elements.icon) {
      return this._elements.icon.getAttribute('size') || Icon.size.SMALL;
    }

    return this._iconSize || Icon.size.SMALL;
  }

  set iconSize(value) {
    value = transform.string(value).toUpperCase();
    value = validate.enumeration(Icon.size)(value) && value || Icon.size.SMALL;

    if(validate.valueMustChange(this._iconSize, value)) {
      this._iconSize = value;
      this._updatedIcon && this._getIconElement().setAttribute('size', value);
    }
  }

  /**
   Whether aria-label is set automatically. See {@link IconAutoAriaLabelEnum}.

   @type {String}
   @default IconAutoAriaLabelEnum.OFF
   @htmlattribute autoarialabel
   */
  get iconAutoAriaLabel() {
    if (this._elements.icon) {
      return this._elements.icon.getAttribute('autoarialabel') || Icon.autoAriaLabel.OFF;
    }

    return this._iconAutoAriaLabel || Icon.autoAriaLabel.OFF;
  }

  set iconAutoAriaLabel(value) {
    value = transform.string(value).toLowerCase();
    value = validate.enumeration(Icon.autoAriaLabel)(value) && value || Icon.autoAriaLabel.OFF;

    if(validate.valueMustChange(this._iconAutoAriaLabel, value)) {
      this._iconAutoAriaLabel = value;
      this._updatedIcon && this._getIconElement().setAttribute('autoarialabel', value);
    }
  }

  /**
   The size of the button. It accepts both lower and upper case sizes. See {@link ButtonSizeEnum}.
   Currently only "MEDIUM" is supported.

   @type {String}
   @default ButtonSizeEnum.MEDIUM
   @htmlattribute size
   @htmlattributereflected
   */
  get size() {
    return this._size || size.MEDIUM;
  }

  set size(value) {
    value = transform.string(value).toUpperCase();
    this._size = validate.enumeration(size)(value) && value || size.MEDIUM;
    
    this._reflectAttribute('size', this._size);
  }

  /**
   Whether the button is selected.

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

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

    if(validate.valueMustChange(this._selected, value)) {
      this._selected = value;

      this.classList.toggle('is-selected', value);
  
      this.trigger('coral-button:_selectedchanged');
    }
  }

  // We just reflect it but we also trigger an event to be used by button group
  /** @ignore */
  get value() {
    return this.getAttribute('value');
  }

  set value(value) {
    this._reflectAttribute('value', value);

    this.trigger('coral-button:_valuechanged');
  }

  /**
   Expands the button to the full width of the parent.

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

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

    if(validate.valueMustChange(this._block, value)) {
      this._block = value;

      this.classList.toggle(`${CLASSNAME}--block`, value);
    }
  }

  /**
   The button's variant. See {@link ButtonVariantEnum}.

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

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

    this._reflectAttribute('variant', value);

    if(validate.valueMustChange(this._variant , value)) {
      this._variant = value;

      // removes every existing variant
      this.classList.remove(CLASSNAME, ACTION_CLASSNAME);
      this.classList.remove(...ALL_VARIANT_CLASSES);
  
      if (value === variant._CUSTOM) {
        this.classList.remove(CLASSNAME);
      } else {
        this.classList.add(...VARIANT_MAP[value]);
  
        if (value === variant.ACTION || value === variant.QUIET_ACTION) {
          this.classList.remove(CLASSNAME);
        }
      }
  
      // Update label styles
      this._updateLabel();
    }
  }

  /**
   Inherited from {@link BaseComponent#trackingElement}.
   */
  get trackingElement() {
    return typeof this._trackingElement === 'undefined' ?
      // keep spaces to only 1 max and trim. this mimics native html behaviors
      (this.label || this).textContent.replace(/\s{2,}/g, ' ').trim() || this.icon :
      this._trackingElement;
  }

  set trackingElement(value) {
    super.trackingElement = value;
  }

  _onClick(event) {
    if (!this.disabled) {
      this._trackEvent('click', this.getAttribute('is'), event);
    }
  }

  /** @ignore */
  _updateIcon(value) {
    if (!this._updatedIcon && this._elements.icon) {
      return;
    }

    this._updatedIcon = true;

    const iconSizeValue = this.iconSize;
    const iconAutoAriaLabelValue = this.iconAutoAriaLabel;
    const iconElement = this._getIconElement();
    iconElement.icon = value;
    // Update size as well
    iconElement.size = iconSizeValue;
    // Update autoAriaLabel as well
    iconElement.autoAriaLabel = iconAutoAriaLabelValue;

    // removes the icon element from the DOM.
    if (this.icon === '') {
      iconElement.remove();
    }
    // add or adjust the icon. Add it back since it was blown away by textContent
    else if (!iconElement.parentNode || this._iconPosition) {
      if (this.contains(this.label)) {
        // insertBefore with <code>null</code> appends
        this.insertBefore(iconElement, this.iconPosition === iconPosition.LEFT ? this.label : this.label.nextElementSibling);
      }
    }

    super._toggleIconAriaHidden();
  }

  /** @ignore */
  _getIconElement() {
    if (!this._elements.icon) {
      this._elements.icon = new Icon();
      this._elements.icon.size = this.iconSize;
    }
    return this._elements.icon;
  }

  /**
   Forces button to receive focus on mousedown
   @param {MouseEvent} event mousedown event
   @ignore
   */
  _onMouseDown(event) {
    const target = event.matchedTarget;

    // Wait a frame or button won't receive focus in Safari.
    window.requestAnimationFrame(() => {
      if (target !== document.activeElement) {
        target.focus();
      }
    });
  }

  _updateLabel(label) {
    label = label || this._elements.label;

    label.classList.remove(`${CLASSNAME}-label`, `${ACTION_CLASSNAME}-label`);

    if (this._variant !== variant._CUSTOM) {
      if (this._variant === variant.ACTION || this._variant === variant.QUIET_ACTION) {
        label.classList.add(`${ACTION_CLASSNAME}-label`);
      } else {
        label.classList.add(`${CLASSNAME}-label`);
      }
    }
  }

  /** @private */
  get _contentZoneTagName() {
    return Object.keys(this._contentZones)[0];
  }

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

  /**
   Returns {@link BaseButton} sizes.

   @return {ButtonSizeEnum}
   */
  static get size() {
    return size;
  }

  /**
   Returns {@link BaseButton} variants.

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

  /**
   Returns {@link BaseButton} icon positions.

   @return {ButtonIconPositionEnum}
   */
  static get iconPosition() {
    return iconPosition;
  }

  /**
   Returns {@link BaseButton} icon sizes.

   @return {ButtonIconSizeEnum}
   */
  static get iconSize() {
    return iconSize;
  }

  static get _attributePropertyMap() {
    return commons.extend(super._attributePropertyMap, {
      iconposition: 'iconPosition',
      iconsize: 'iconSize',
      iconautoarialabel: 'iconAutoAriaLabel'
    });
  }

  /** @ignore */
  static get observedAttributes() {
    return super.observedAttributes.concat([
      'iconposition',
      'iconsize',
      'icon',
      'iconautoarialabel',
      'size',
      'selected',
      'block',
      'variant',
      'value'
    ]);
  }

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

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

    // Create a fragment
    const fragment = document.createDocumentFragment();

    const label = this._elements.label;

    const contentZoneProvided = label.parentNode;

    // Remove it so we can process children
    if (contentZoneProvided) {
      this.removeChild(label);
    }

    let iconAdded = false;
    // Process remaining elements as necessary
    while (this.firstChild) {
      const child = this.firstChild;

      if (child.nodeName === 'CORAL-ICON') {
        // Don't add duplicated icons
        if (iconAdded) {
          this.removeChild(child);
        } else {
          // Conserve existing icon element to content
          this._elements.icon = child;
          fragment.appendChild(child);
          iconAdded = true;
        }
      }
      // Avoid content zone to be voracious
      else if (contentZoneProvided) {
        fragment.appendChild(child);
      } else {
        // Move anything else into the label
        label.appendChild(child);
      }
    }

    // Add the frag to the component
    this.appendChild(fragment);

    // Assign the content zones, moving them into place in the process
    this.label = label;

    // Make sure the icon is well positioned
    this._updatedIcon = true;
    this._updateIcon(this.icon);
  }

  /**
   Triggered when {@link BaseButton#selected} changed.

   @typedef {CustomEvent} coral-button:_selectedchanged

   @private
   */

  /**
   Triggered when {@link BaseButton#value} changed.

   @typedef {CustomEvent} coral-button:_valuechanged

   @private
   */
};

export default BaseButton;