Reference Source

coral-spectrum/coral-component-card/src/scripts/Card.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 base from '../templates/base';
import {commons, transform, validate} from '../../../coral-utils';

const COLOR_HINT_REG_EXP = /^#[0-9A-F]{6}$/i;

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

 @typedef {Object} CardVariantEnum

 @property {String} DEFAULT
 Default card variant that shows the asset, overlay and content in their default positions.
 @property {String} QUIET
 Quiet card variant that shows the asset, overlay and content in their default positions.
 @property {String} CONDENSED
 Condensed card variant where the overlay is hidden and the content is shown over the image.
 @property {String} INVERTED
 Condensed card variant where the overlay is hidden and the content is shown over the image with a dark style.
 @property {String} ASSET
 Card variant where only the asset is shown.
 */
const variant = {
  DEFAULT: 'default',
  QUIET: 'quiet',
  CONDENSED: 'condensed',
  INVERTED: 'inverted',
  ASSET: 'asset'
};

// the card's base classname
const CLASSNAME = '_coral-Card';

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

/**
 @class Coral.Card
 @classdesc A Card component to display content in different variations.
 @htmltag coral-card
 @extends {HTMLElement}
 @extends {BaseComponent}
 */
class Card extends BaseComponent(HTMLElement) {
  /** @ignore */
  constructor() {
    super();

    // Prepare templates
    this._elements = {
      // Fetch or create the content zone elements
      asset: this.querySelector('coral-card-asset') || document.createElement('coral-card-asset'),
      content: this.querySelector('coral-card-content') || document.createElement('coral-card-content'),
      info: this.querySelector('coral-card-info') || document.createElement('coral-card-info'),
      overlay: this.querySelector('coral-card-overlay') || document.createElement('coral-card-overlay')
    };
    base.call(this._elements);

    // Events
    this._delegateEvents({
      'capture:load coral-card-asset img': '_onLoad'
    });
  }

  /**
   The Asset of the card.

   @type {CardAsset}
   @contentzone
   */
  get asset() {
    return this._getContentZone(this._elements.asset);
  }

  set asset(value) {
    this._setContentZone('asset', value, {
      handle: 'asset',
      tagName: 'coral-card-asset',
      insert: function (asset) {
        this.insertBefore(asset, this.info || this._elements.wrapper || null);
      }
    });
  }

  /**
   Hints the height of the asset that is going to be loaded. This prepares the size so that when the image is
   loaded no reflow is triggered. Both <code>assetHeight</code> and <code>assetWidth</code> need to be specified
   for this feature to take effect.

   @type {String}
   @default ""
   @htmlattribute assetheight
   */
  get assetHeight() {
    return this._assetHeight || '';
  }

  set assetHeight(value) {
    this._assetHeight = transform.number(value);

    // Avoid a forced reflow by executing following in the next frame
    window.requestAnimationFrame(() => {
      // both hint dimensions need to be set in order to use this feature
      if (!this._loaded && this._elements.asset && this.assetWidth && this._assetHeight) {
        // gets the width without the border of the card
        const clientRect = this.getBoundingClientRect();
        const width = clientRect.right - clientRect.left;
        // calculates the image ratio used to resize the height
        const ratio = width / this.assetWidth;

        // the image is considered "low resolution"
        // @todo: check this after removal of lowResolution
        if (ratio > 1) {
          // 32 = $card-asset-lowResolution-padding * 2
          this._elements.asset.style.height = `${this._assetHeight + 32}px`;
        }
        // for non-low resolution images, condensed and inverted cards do not require the height to be set
        else if (this.variant !== variant.CONDENSED && this.variant !== variant.INVERTED) {
          this._elements.asset.style.height = `${ratio * this._assetHeight}px`;
        }
      }
    });
  }

  /**
   Hints the width of the asset that is going to be loaded. This prepares the size so that when the image is
   loaded no reflow is triggered. Both <code>assetHeight</code> and <code>assetWidth</code> need to be specified
   for this feature to take effect.

   @type {String}
   @default ""
   @htmlattribute assetwidth
   */
  get assetWidth() {
    return this._assetWidth || '';
  }

  set assetWidth(value) {
    this._assetWidth = transform.number(value);
  }

  /**
   @type {String}
   @default ""
   @htmlattribute colorhint
   */
  get colorHint() {
    return this._colorHint || '';
  }

  set colorHint(value) {
    if (COLOR_HINT_REG_EXP.test(value)) {
      this._colorHint = value;

      // if the image is already loaded we do not add the color hint to the asset
      if (!this._loaded) {
        this._elements.asset.style['background-color'] = this._colorHint;
      }
    }
  }

  /**
   The Content of the card.

   @type {CardContent}
   @contentzone
   */
  get content() {
    return this._getContentZone(this._elements.content);
  }

  set content(value) {
    this._setContentZone('content', value, {
      handle: 'content',
      tagName: 'coral-card-content',
      insert: function (content) {
        // Ensure title comes first
        const title = content.querySelector('coral-card-title');
        if (title) {
          content.insertBefore(title, content.firstChild);
        }

        this._elements.wrapper.insertBefore(content, this.overlay || null);
      }
    });
  }

  /**
   The information area of the card, which is placed over all the content. It is typically used for alerts.

   @type {CardInfo}
   @contentzone
   */
  get info() {
    return this._getContentZone(this._elements.info);
  }

  set info(value) {
    this._setContentZone('info', value, {
      handle: 'info',
      tagName: 'coral-card-info',
      insert: function (info) {
        this.appendChild(info);
      }
    });
  }

  /**
   Fixes the width of the card. By default cards will take the width of their containers allowing them to interact
   nicely with grids. Whenever they are used standalone fixing the width might be desired.

   @type {Boolean}
   @default false
   @htmlattribute fixedwidth
   @htmlattributereflected
   */
  get fixedWidth() {
    return this._fixedWidth || false;
  }

  set fixedWidth(value) {
    this._fixedWidth = transform.booleanAttr(value);
    this._reflectAttribute('fixedwidth', this._fixedWidth);

    this.classList.toggle(`${CLASSNAME}--fixedWidth`, this._fixedWidth);
  }

  /**
   The Overlay of the card.

   @type {CardOverlay}
   @contentzone
   */
  get overlay() {
    return this._getContentZone(this._elements.overlay);
  }

  set overlay(value) {
    this._setContentZone('overlay', value, {
      handle: 'overlay',
      tagName: 'coral-card-overlay',
      insert: function (overlay) {
        this._elements.wrapper.appendChild(overlay);
      }
    });
  }

  /**
   Whether the card is stacked or not. This is used to represent several assets grouped together.

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

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

    this.classList.toggle(`${CLASSNAME}--stacked`, this._stacked);
  }

  /**
   The card's variant. It determines which sections of the Card and in which position they are shown.
   See {@link CardVariantEnum}.

   @type {String}
   @default CardVariantEnum.DEFAULT
   @htmlattribute variant
   */
  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.remove(...ALL_VARIANT_CLASSES);

    if (this._variant !== variant.DEFAULT) {
      this.classList.add(`${CLASSNAME}--${this._variant}`);
    }

    this.assetHeight = this.assetHeight;
  }

  /** @ignore */
  _onLoad(event) {
    // @todo fix me for multiple images
    // sets the image as loaded
    this._loaded = true;

    // removes the height style since the asset has been completely loaded
    this._elements.asset.style.height = '';

    // enables the transition
    event.target.classList.remove('is-loading');
  }

  get _contentZones() {
    return {
      'coral-card-asset': 'asset',
      'coral-card-content': 'content',
      'coral-card-info': 'info',
      'coral-card-overlay': 'overlay'
    };
  }

  /**
   Returns {@link Card} variants.

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

  static get _attributePropertyMap() {
    return commons.extend(super._attributePropertyMap, {
      assetwidth: 'assetWidth',
      assetheight: 'assetHeight',
      colorhint: 'colorHint',
      fixedwidth: 'fixedWidth'
    });
  }

  /** @ignore */
  static get observedAttributes() {
    return super.observedAttributes.concat([
      'assetwidth',
      'assetheight',
      'colorhint',
      'fixedwidth',
      'variant',
      'stacked'
    ]);
  }

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

    this.classList.add(CLASSNAME);

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

    const content = this._elements.content;
    const asset = this._elements.asset;

    // Prepares images to be loaded nicely
    const images = asset.querySelectorAll('img');
    const imagesCount = images.length;
    for (let i = 0 ; i < imagesCount ; i++) {
      const image = images[i];
      if (!image.complete) {
        image.classList.add('is-loading');
      }
    }

    for (const contentZone in this._contentZones) {
      const element = this._elements[this._contentZones[contentZone]];
      // Remove it so we can process children
      if (element.parentNode) {
        element.parentNode.removeChild(element);
      }
    }

    // Moves everything into the main content zone
    while (this.firstChild) {
      const child = this.firstChild;
      // Removes the empty spaces
      if (child.nodeType === Node.TEXT_NODE && child.textContent.trim() !== '' ||
        child.nodeType === Node.ELEMENT_NODE && child.getAttribute('handle') !== 'wrapper') {
        // Add non-template elements to the content
        content.appendChild(child);
      }
      // Remove anything else element
      else {
        this.removeChild(child);
      }
    }

    // Assign the content zones so the insert functions will be called
    this.overlay = this._elements.overlay;
    this.content = content;
    this.info = this._elements.info;

    this.appendChild(this._elements.wrapper);

    // The 'asset' setter knows to insert the element just before the wrapper node.
    this.asset = asset;

    // In case a lot of alerts are added, they will not overflow the card
    // Also check whether any alerts are available
    this.classList.toggle(`${CLASSNAME}--overflow`, this.info.childNodes.length && this.info.scrollHeight > this.clientHeight);
  }
}

export default Card;