PopupHandler.js

'use strict';

import $ from 'jquery';
import _defaults from './options';

/**
 * Handles everything related to the UI of the colorpicker popup: show, hide, position,...
 * @ignore
 */
class PopupHandler {
  /**
   * @param {Colorpicker} colorpicker
   * @param {Window} root
   */
  constructor(colorpicker, root) {
    /**
     * @type {Window}
     */
    this.root = root;
    /**
     * @type {Colorpicker}
     */
    this.colorpicker = colorpicker;
    /**
     * @type {jQuery}
     */
    this.popoverTarget = null;
    /**
     * @type {jQuery}
     */
    this.popoverTip = null;

    /**
     * If true, the latest click was inside the popover
     * @type {boolean}
     */
    this.clicking = false;
    /**
     * @type {boolean}
     */
    this.hidding = false;
    /**
     * @type {boolean}
     */
    this.showing = false;
  }

  /**
   * @private
   * @returns {jQuery|false}
   */
  get input() {
    return this.colorpicker.inputHandler.input;
  }

  /**
   * @private
   * @returns {boolean}
   */
  get hasInput() {
    return this.colorpicker.inputHandler.hasInput();
  }

  /**
   * @private
   * @returns {jQuery|false}
   */
  get addon() {
    return this.colorpicker.addonHandler.addon;
  }

  /**
   * @private
   * @returns {boolean}
   */
  get hasAddon() {
    return this.colorpicker.addonHandler.hasAddon();
  }

  /**
   * @private
   * @returns {boolean}
   */
  get isPopover() {
    return !this.colorpicker.options.inline && !!this.popoverTip;
  }

  /**
   * Binds the different colorpicker elements to the focus/mouse/touch events so it reacts in order to show or
   * hide the colorpicker popup accordingly. It also adds the proper classes.
   */
  bind() {
    let cp = this.colorpicker;

    if (cp.options.inline) {
      cp.picker.addClass('colorpicker-inline colorpicker-visible');
      return; // no need to bind show/hide events for inline elements
    }

    cp.picker.addClass('colorpicker-popup colorpicker-hidden');

    // there is no input or addon
    if (!this.hasInput && !this.hasAddon) {
      return;
    }

    // create Bootstrap 4 popover
    if (cp.options.popover) {
      this.createPopover();
    }

    // bind addon show/hide events
    if (this.hasAddon) {
      // enable focus on addons
      if (!this.addon.attr('tabindex')) {
        this.addon.attr('tabindex', 0);
      }

      this.addon.on({
        'mousedown.colorpicker touchstart.colorpicker': $.proxy(this.toggle, this)
      });

      this.addon.on({
        'focus.colorpicker': $.proxy(this.show, this)
      });

      this.addon.on({
        'focusout.colorpicker': $.proxy(this.hide, this)
      });
    }

    // bind input show/hide events
    if (this.hasInput && !this.hasAddon) {
      this.input.on({
        'mousedown.colorpicker touchstart.colorpicker': $.proxy(this.show, this),
        'focus.colorpicker': $.proxy(this.show, this)
      });

      this.input.on({
        'focusout.colorpicker': $.proxy(this.hide, this)
      });
    }

    // reposition popup on window resize
    $(this.root).on('resize.colorpicker', $.proxy(this.reposition, this));
  }

  /**
   * Unbinds any event bound by this handler
   */
  unbind() {
    if (this.hasInput) {
      this.input.off({
        'mousedown.colorpicker touchstart.colorpicker': $.proxy(this.show, this),
        'focus.colorpicker': $.proxy(this.show, this)
      });
      this.input.off({
        'focusout.colorpicker': $.proxy(this.hide, this)
      });
    }

    if (this.hasAddon) {
      this.addon.off({
        'mousedown.colorpicker touchstart.colorpicker': $.proxy(this.toggle, this)
      });
      this.addon.off({
        'focus.colorpicker': $.proxy(this.show, this)
      });
      this.addon.off({
        'focusout.colorpicker': $.proxy(this.hide, this)
      });
    }

    if (this.popoverTarget) {
      this.popoverTarget.popover('dispose');
    }

    $(this.root).off('resize.colorpicker', $.proxy(this.reposition, this));
    $(this.root.document).off('mousedown.colorpicker touchstart.colorpicker', $.proxy(this.hide, this));
    $(this.root.document).off('mousedown.colorpicker touchstart.colorpicker', $.proxy(this.onClickingInside, this));
  }

  isClickingInside(e) {
    if (!e) {
      return false;
    }

    return (
      this.isOrIsInside(this.popoverTip, e.currentTarget) ||
      this.isOrIsInside(this.popoverTip, e.target) ||
      this.isOrIsInside(this.colorpicker.picker, e.currentTarget) ||
      this.isOrIsInside(this.colorpicker.picker, e.target)
    );
  }

  isOrIsInside(container, element) {
    if (!container || !element) {
      return false;
    }

    element = $(element);

    return (
      element.is(container) ||
      container.find(element).length > 0
    );
  }

  onClickingInside(e) {
    this.clicking = this.isClickingInside(e);
  }

  createPopover() {
    let cp = this.colorpicker;

    this.popoverTarget = this.hasAddon ? this.addon : this.input;

    cp.picker.addClass('colorpicker-bs-popover-content');

    this.popoverTarget.popover(
      $.extend(
        true,
        {},
        _defaults.popover,
        cp.options.popover,
        {trigger: 'manual', content: cp.picker, html: true}
      )
    );

    /* Bootstrap 5 added an official method to get the popover instance */
    /* global bootstrap */
    const useGetInstance = window.bootstrap &&
      window.bootstrap.Popover &&
      window.bootstrap.Popover.getInstance;

    this.popoverTip = useGetInstance ?
      $(bootstrap.Popover.getInstance(this.popoverTarget[0]).getTipElement()) :
      $(this.popoverTarget.popover('getTipElement').data('bs.popover').tip);

    this.popoverTip.addClass('colorpicker-bs-popover');

    this.popoverTarget.on('shown.bs.popover', $.proxy(this.fireShow, this));
    this.popoverTarget.on('hidden.bs.popover', $.proxy(this.fireHide, this));
  }

  /**
   * If the widget is not inside a container or inline, rearranges its position relative to its element offset.
   *
   * @param {Event} [e]
   * @private
   */
  reposition(e) {
    if (this.popoverTarget && this.isVisible()) {
      this.popoverTarget.popover('update');
    }
  }

  /**
   * Toggles the colorpicker between visible or hidden
   *
   * @fires Colorpicker#colorpickerShow
   * @fires Colorpicker#colorpickerHide
   * @param {Event} [e]
   */
  toggle(e) {
    if (this.isVisible()) {
      this.hide(e);
    } else {
      this.show(e);
    }
  }

  /**
   * Shows the colorpicker widget if hidden.
   *
   * @fires Colorpicker#colorpickerShow
   * @param {Event} [e]
   */
  show(e) {
    if (this.isVisible() || this.showing || this.hidding) {
      return;
    }

    this.showing = true;
    this.hidding = false;
    this.clicking = false;

    let cp = this.colorpicker;

    cp.lastEvent.alias = 'show';
    cp.lastEvent.e = e;

    // Prevent showing browser native HTML5 colorpicker
    if (
      (e && (!this.hasInput || this.input.attr('type') === 'color')) &&
      (e && e.preventDefault)
    ) {
      e.stopPropagation();
      e.preventDefault();
    }

    // If it's a popover, add event to the document to hide the picker when clicking outside of it
    if (this.isPopover) {
      $(this.root).on('resize.colorpicker', $.proxy(this.reposition, this));
    }

    // add visible class before popover is shown
    cp.picker.addClass('colorpicker-visible').removeClass('colorpicker-hidden');

    if (this.popoverTarget) {
      this.popoverTarget.popover('show');
    } else {
      this.fireShow();
    }
  }

  fireShow() {
    this.hidding = false;
    this.showing = false;

    if (this.isPopover) {
      // Add event to hide on outside click
      $(this.root.document).on('mousedown.colorpicker touchstart.colorpicker', $.proxy(this.hide, this));
      $(this.root.document).on('mousedown.colorpicker touchstart.colorpicker', $.proxy(this.onClickingInside, this));
    }

    /**
     * (Colorpicker) When show() is called and the widget can be shown.
     *
     * @event Colorpicker#colorpickerShow
     */
    this.colorpicker.trigger('colorpickerShow');
  }

  /**
   * Hides the colorpicker widget.
   * Hide is prevented when it is triggered by an event whose target element has been clicked/touched.
   *
   * @fires Colorpicker#colorpickerHide
   * @param {Event} [e]
   */
  hide(e) {
    if (this.isHidden() || this.showing || this.hidding) {
      return;
    }

    let cp = this.colorpicker, clicking = (this.clicking || this.isClickingInside(e));

    this.hidding = true;
    this.showing = false;
    this.clicking = false;

    cp.lastEvent.alias = 'hide';
    cp.lastEvent.e = e;

    // TODO: fix having to click twice outside when losing focus and last 2 clicks where inside the colorpicker

    // Prevent hide if triggered by an event and an element inside the colorpicker has been clicked/touched
    if (clicking) {
      this.hidding = false;
      return;
    }

    if (this.popoverTarget) {
      this.popoverTarget.popover('hide');
    } else {
      this.fireHide();
    }
  }

  fireHide() {
    this.hidding = false;
    this.showing = false;

    let cp = this.colorpicker;

    // add hidden class after popover is hidden
    cp.picker.addClass('colorpicker-hidden').removeClass('colorpicker-visible');

    // Unbind window and document events, since there is no need to keep them while the popup is hidden
    $(this.root).off('resize.colorpicker', $.proxy(this.reposition, this));
    $(this.root.document).off('mousedown.colorpicker touchstart.colorpicker', $.proxy(this.hide, this));
    $(this.root.document).off('mousedown.colorpicker touchstart.colorpicker', $.proxy(this.onClickingInside, this));

    /**
     * (Colorpicker) When hide() is called and the widget can be hidden.
     *
     * @event Colorpicker#colorpickerHide
     */
    cp.trigger('colorpickerHide');
  }

  focus() {
    if (this.hasAddon) {
      return this.addon.focus();
    }
    if (this.hasInput) {
      return this.input.focus();
    }
    return false;
  }

  /**
   * Returns true if the colorpicker element has the colorpicker-visible class and not the colorpicker-hidden one.
   * False otherwise.
   *
   * @returns {boolean}
   */
  isVisible() {
    return this.colorpicker.picker.hasClass('colorpicker-visible') &&
      !this.colorpicker.picker.hasClass('colorpicker-hidden');
  }

  /**
   * Returns true if the colorpicker element has the colorpicker-hidden class and not the colorpicker-visible one.
   * False otherwise.
   *
   * @returns {boolean}
   */
  isHidden() {
    return this.colorpicker.picker.hasClass('colorpicker-hidden') &&
      !this.colorpicker.picker.hasClass('colorpicker-visible');
  }
}

export default PopupHandler;