recurly/recurly-js

View on GitHub
lib/recurly/hosted-field.js

Summary

Maintainability
B
5 hrs
Test Coverage
import Emitter from 'component-emitter';
import clone from 'component-clone';
import dom from '../util/dom';
import errors from './errors';

const bowser = require('bowser');
const debug = require('debug')('recurly:hostedField');

/**
 * HostedField
 *
 * @constructor
 * @param {Object} options
 * @param {String} options.selector target selector
 * @private
 */

export class HostedField extends Emitter {
  constructor (options) {
    super();

    this.onReady = this.onReady.bind(this);
    this.onChange = this.onChange.bind(this);
    this.onConfigure = this.onConfigure.bind(this);
    this.onStateChange = this.onStateChange.bind(this);
    this.focus = this.focus.bind(this);
    this.destroy = this.destroy.bind(this);

    this.ready = false;
    this.state = {};
    this.configure(options);
    this.inject();
    this.bindDeferredFocus();

    this.on('bus:added', bus => {
      this.bus = bus;
      this.bus.add(this.window);
    });

    this.on('hostedField:ready', this.onReady);
    this.on('hostedField:change', this.onChange);
    this.on('hostedField:configure', this.onConfigure);
    this.on('hostedField:state:change', this.onStateChange);
    this.once('destroy', this.destroy);
  }

  get type () {
    return this.config.type;
  }

  get url () {
    let config = encodeURIComponent(JSON.stringify(this.config));
    return `${this.config.recurly.api}/field.html#config=${config}`;
  }

  get classList () {
    const prefix = 'recurly-hosted-field';
    let classes = [prefix];

    if (this.ready) {
      classes.push(`${prefix}-${this.config.type}`);
      if (this.state.focus) {
        classes.push(`${prefix}-focus`);
        classes.push(`${prefix}-${this.config.type}-focus`);
      }

      if (this.state.valid) {
        classes.push(`${prefix}-valid`);
        classes.push(`${prefix}-${this.config.type}-valid`);
      } else if (!this.state.focus && !this.state.empty) {
        classes.push(`${prefix}-invalid`);
        classes.push(`${prefix}-${this.config.type}-invalid`);
      }
    }

    return classes.join(' ');
  }

  get tabIndex () {
    let tabIndex = parseInt(this.config.tabIndex, 10);
    return isNaN(tabIndex) ? 0 : tabIndex;
  }

  /**
   * Checks that the elements necessary to display the hostedField are in the document.body
   *
   * @return {Boolean}
   */
  integrityCheck () {
    const els = [this.target, this.container, this.iframe];
    const present = document.body.contains.bind(document.body);
    return !~els.map(present).indexOf(false);
  }

  // Private
  configure (options) {
    options = clone(options);
    if (!this.target) this.target = dom.element(window.document.querySelector(options.selector));
    if (!this.target) {
      const { type, selector } = options;
      throw errors('missing-hosted-field-target', { type, selector });
    }
    this.config = options;
  }

  inject () {
    this.target.innerHTML = `
      <div class="${this.classList}">
        <iframe
          src="${this.url}"
          allowtransparency="true"
          border="0"
          frameborder="0"
          scrolling="no"
          style="background: transparent; width: 100%; height: 100%;">
        </iframe>
      </div>
    `;

    this.container = this.target.children[0];
    this.iframe = this.container.querySelector('iframe');
    this.window = this.iframe.contentWindow;
    if (this.iframeTitle) this.iframe.setAttribute('title', this.iframeTitle);

    // Inject mobile tabbing proxy before the iframe
    if (isMobile()) {
      this.tabbingProxy = dom.createHiddenInput();
      this.tabbingProxy.addEventListener('focus', this.focus);
      this.container.insertBefore(this.tabbingProxy, this.iframe);
    }
  }

  /**
   * Binds focus on the following elements to the `focus!` event on the hosted field
   *  - the container element
   *  - `<label>` elements with a `for` attribute matching the target `id` attribute
   *
   * @private
   */
  bindDeferredFocus () {
    this.container.addEventListener('click', this.focus);
    if (!this.target.id) return;
    const labels = window.document.querySelectorAll(`label[for=${this.target.id}]`);
    [].slice.apply(labels).forEach(label => {
      label.addEventListener('click', this.focus);
    });
  }

  /**
   * Removes listeners, and clears references
   *
   * @private
   */
  destroy () {
    debug(`destroying ${this.type} hosted field`, this);
    this.off();
    if (this.bus) {
      this.bus.remove(this.window);
      this.bus.remove(this);
    }
    if (this.target) this.target.innerHTML = '';
    delete this.target;
    delete this.container;
    delete this.iframe;
    delete this.window;
  }

  get iframeTitle () {
    if (this.config.type === 'card') {
      return 'Billing information';
    } else if (this.config.style?.placeholder?.content) {
      return this.config.style.placeholder.content;
    } else {
      return null;
    }
  }

  update () {
    this.container.className = this.classList;
    this.iframe.setAttribute('tabindex', this.tabIndex);
  }

  onReady (body) {
    if (body.type !== this.type) return;
    this.ready = true;
    this.off('hostedField:ready', this.onReady);
    this.update();
  }

  onStateChange (body) {
    if (body.type !== this.type) return;
    let newState = Object.assign({}, body);
    delete newState.type;
    this.state = newState;
    this.update();
  }

  onChange (body) {
    if (body.type !== this.type) return;
    this.update();
  }

  onConfigure (body) {
    if (body.type !== this.type) return;
    this.configure(body);
    this.update();
  }

  /**
   * Places focus on this hosted field
   */
  focus () {
    if (!this.bus) return;
    this.bus.send(`hostedField:${this.type}:focus!`);
  }
}

/**
 * Determine if user's browser is mobile or non-mobile
 * @return {Boolean} value
 */

function isMobile () {
  return bowser.mobile || bowser.tablet;
}