chromium/chrome/browser/resources/bluetooth_internals/snackbar.js

// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assert} from 'chrome://resources/js/assert.js';
import {CustomElement} from 'chrome://resources/js/custom_element.js';
import {listenOnce} from 'chrome://resources/js/util.js';

import {getTemplate} from './snackbar.html.js';

/**
 * Javascript for Snackbar controls, served from chrome://bluetooth-internals/.
 */

/**
 * @typedef {{
 *    message: string,
 *    type: string,
 *    actionText: (string|undefined),
 *    action: (function()|undefined)
 *  }}
 */
let SnackbarOptions;

/** @type {number} */ const SHOW_DURATION = 5000;
/** @type {number} */ const TRANSITION_DURATION = 225;

/**
 * Enum of Snackbar types. Used by Snackbar to determine the styling for the
 * Snackbar.
 * @enum {string}
 */
export const SnackbarType = {
  INFO: 'info',
  SUCCESS: 'success',
  WARNING: 'warning',
  ERROR: 'error',
};

/**
 * Notification bar for displaying a simple message with an action link.
 * This element should not be instantiated directly. Instead, users should
 * use the showSnackbar and dismissSnackbar functions to ensure proper
 * queuing of messages.
 */
class BluetoothSnackbarElement extends CustomElement {
  static get template() {
    return getTemplate();
  }

  static get is() {
    return 'bluetooth-snackbar';
  }

  constructor() {
    super();

    /** @type {?Function} */
    this.boundStartTimeout_ = null;

    /** @type {?Function} */
    this.boundStopTimeout_ = null;

    /** @type {?number} */
    this.timeoutId_ = null;

    /** @type {?SnackbarOptions} */
    this.options_ = null;
  }

  connectedCallback() {
    assert(this.options_);

    this.shadowRoot.querySelector('#message').textContent =
        this.options_.message;
    this.classList.add(this.options_.type);
    const actionLink = this.shadowRoot.querySelector('a');
    actionLink.textContent = this.options_.actionText || 'Dismiss';

    actionLink.addEventListener('click', () => {
      if (this.options_.action) {
        this.options_.action();
      }
      this.dismiss();
    });

    this.boundStartTimeout_ = this.startTimeout_.bind(this);
    this.boundStopTimeout_ = this.stopTimeout_.bind(this);
    this.addEventListener('mouseleave', this.boundStartTimeout_);
    this.addEventListener('mouseenter', this.boundStopTimeout_);

    this.timeoutId_ = null;
  }

  /**
   * Initializes the content of the Snackbar with the given |options|
   * including the message, action link text, and click action of the link.
   * This must be called before the element is added to the DOM.
   * @param {!SnackbarOptions} options
   */
  initialize(options) {
    this.options_ = options;
  }

  /**
   * Shows the Snackbar and dispatches the 'showed' event.
   */
  show() {
    this.classList.add('open');
    if (hasContentFocus) {
      this.startTimeout_();
    } else {
      this.stopTimeout_();
    }

    document.addEventListener('contentfocus', this.boundStartTimeout_);
    document.addEventListener('contentblur', this.boundStopTimeout_);
    this.dispatchEvent(
        new CustomEvent('showed', {bubbles: true, composed: true}));
  }
  /**
   * transitionend does not always fire (e.g. when animation is aborted
   * or when no paint happens during the animation). This function sets up
   * a timer and emulate the event if it is not fired when the timer expires.
   * @private
   */
  ensureTransitionEndEvent_() {
    let fired = false;
    this.addEventListener('transitionend', function f(e) {
      this.removeEventListener('transitionend', f);
      fired = true;
    }.bind(this));
    window.setTimeout(() => {
      if (!fired) {
        this.dispatchEvent(new CustomEvent('transitionend',
              {bubbles: true, composed: true}));
      }
    }, TRANSITION_DURATION);
  }

  /**
   * Dismisses the Snackbar. Once the Snackbar is completely hidden, the
   * 'dismissed' event is fired and the returned Promise is resolved. If the
   * snackbar is already hidden, a resolved Promise is returned.
   * @return {!Promise}
   */
  dismiss() {
    this.stopTimeout_();

    if (!this.classList.contains('open')) {
      return Promise.resolve();
    }

    return new Promise(function(resolve) {
      listenOnce(this, 'transitionend', function() {
        this.dispatchEvent(new CustomEvent('dismissed'));
        resolve();
      }.bind(this));

      this.ensureTransitionEndEvent_();
      this.classList.remove('open');

      document.removeEventListener('contentfocus', this.boundStartTimeout_);
      document.removeEventListener('contentblur', this.boundStopTimeout_);
    }.bind(this));
  }

  /**
   * Starts the timeout for dismissing the Snackbar.
   * @private
   */
  startTimeout_() {
    this.timeoutId_ = setTimeout(function() {
      this.dismiss();
    }.bind(this), SHOW_DURATION);
  }

  /**
   * Stops the timeout for dismissing the Snackbar. Only clears the timeout
   * when the Snackbar is open.
   * @private
   */
  stopTimeout_() {
    if (this.classList.contains('open')) {
      clearTimeout(this.timeoutId_);
      this.timeoutId_ = null;
    }
  }
}

customElements.define('bluetooth-snackbar', BluetoothSnackbarElement);

/** @type {?BluetoothSnackbarElement} */
let current = null;

/** @type {!Array<!BluetoothSnackbarElement>} */
let queue = [];

/** @type {boolean} */
let hasContentFocus = true;

// There is a chance where the snackbar is shown but the content doesn't have
// focus. In this case, the current focus state must be tracked so the
// snackbar can pause the dismiss timeout.
document.addEventListener('contentfocus', function() {
  hasContentFocus = true;
});
document.addEventListener('contentblur', function() {
  hasContentFocus = false;
});

/**
 * @return {{current: ?BluetoothSnackbarElement, numPending: number}}
 */
export function getSnackbarStateForTest() {
  return {
    current: current,
    numPending: queue.length,
  };
}

/**
 * TODO(crbug.com/40498702): Add ability to specify parent element to Snackbar.
 * Creates a Snackbar and shows it if one is not showing already. If a
 * Snackbar is already active, the next Snackbar is queued.
 * @param {string} message The message to display in the Snackbar.
 * @param {string=} opt_type A string determining the Snackbar type: info,
 *     success, warning, error. If not provided, info type is used.
 * @param {string=} opt_actionText The text to display for the action link.
 * @param {function()=} opt_action A function to be called when the user
 *     presses the action link.
 * @return {!BluetoothSnackbarElement}
 */
export function showSnackbar(message, opt_type, opt_actionText, opt_action) {
  const options = {
    message: message,
    type: opt_type || SnackbarType.INFO,
    actionText: opt_actionText,
    action: opt_action,
  };
  const newSnackbar = document.createElement('bluetooth-snackbar');
  newSnackbar.initialize(options);

  if (current) {
    queue.push(newSnackbar);
  } else {
    show(newSnackbar);
  }

  return newSnackbar;
}

window.showSnackbar = showSnackbar;

/**
 * TODO(crbug.com/40498702): Add ability to specify parent element to Snackbar.
 * Creates a Snackbar and sets events for queuing the next Snackbar to show.
 * @param {!BluetoothSnackbarElement} snackbar
 */
function show(snackbar) {
  document.body.querySelector('#snackbar-container').appendChild(snackbar);

  snackbar.addEventListener('dismissed', function() {
    document.body.querySelector('#snackbar-container').removeChild(current);

    const newSnackbar = queue.shift();
    if (newSnackbar) {
      show(newSnackbar);
      return;
    }

    current = null;
  });

  current = snackbar;

  // Show the Snackbar after a slight delay to allow for a layout reflow.
  setTimeout(function() {
    snackbar.show();
  }, 10);
}

/**
 * Dismisses the Snackbar currently showing.
 * @param {boolean} clearQueue If true, clears the Snackbar queue before
 *     dismissing.
 * @return {!Promise}
 */
export function dismissSnackbar(clearQueue) {
  if (clearQueue) {
    queue = [];
  }
  if (current) {
    return current.dismiss();
  }
  return Promise.resolve();
}