refreshless-8.x-1.x-dev/components/progress-bar/progress-bar.js

components/progress-bar/progress-bar.js
/**
 * @file
 * RefreshLess progress bar.
 *
 * @see https://www.drupal.org/project/refreshless/issues/3488464
 *   Issue for adapting this out of Turbo.
 *
 * @see https://github.com/hotwired/turbo/blob/ea54ae5ad4b6b28cb62ccd62951352641ed08293/src/core/drive/progress_bar.js
 *   Originally adapted from the Turbo progress bar.
 */
(function(Drupal, $) {

  'use strict';

  // @todo Remove when SDC additive aggregation is fixed.
  //
  // @see https://www.drupal.org/project/refreshless/issues/3543409
  if (Drupal?.RefreshLess?.classes?.ProgressBar) {
    return;
  }

  Drupal.theme.refreshlessProgressBar = (
    elementType   = 'div',
    elementClass  = 'refreshless-document-progress-bar',
  ) => {

    const id = 'refreshless-document-progress-bar';

    const container = document.createElement(elementType);

    container.classList.add(elementClass);

    // This provides the ProgressBar JavaScript class the configured class name
    // to build modifier classes from.
    container.refreshlessProgressBarClass = elementClass;

    const progress = document.createElement('progress');

    // There are still (as of 2024 (!!!)) various issues trying to style and
    // transition the <progress> element's pseudo-elements across all browsers,
    // so the <progress> exists primarily for accessibility reasons. The
    // container is used as the visual progress bar, while the <progress>
    // element is set as visually hidden.
    progress.classList.add(`${elementClass}__progress`, 'visually-hidden');
    // The ID is intentionally hard-coded since it's only intended to be used
    // for the aria-describedby we set on the <html> element when the progress
    // bar is installed.
    progress.setAttribute('id', 'refreshless-document-progress-bar');
    progress.setAttribute('max', '100');
    progress.setAttribute('aria-label', Drupal.t('Page is loading...'));

    container.appendChild(progress);

    return container;

  }

  /**
   * CSS custom property base name.
   *
   * @type {String}
   */
  const customPropertyBase = '--refreshless-progress-bar';

  /**
   * Name of the CSS custom property containing the transition duration.
   *
   * The value will be a time in ms.
   *
   * @type {String}
   */
  const durationCustomProperty = `${customPropertyBase}-transition-duration`;

  /**
   * Name of the CSS custom property containing the current progress bar value.
   *
   * The value will be float between 0 and 1, inclusive.
   *
   * @type {String}
   */
  const valueCustomProperty = `${customPropertyBase}-value`;

  /**
   * The <html> element.
   *
   * @type {HTMLHtmlElement}
   */
  const html = document.documentElement;

  /**
   * Represents a progress bar.
   */
  class ProgressBar {

    /**
     * The progress bar container HTML element.
     *
     * @type {HTMLElement}
     */
    #containerElement;

    /**
     * The progress bar <progress> HTML element.
     *
     * @type {HTMLProgressElement}
     */
    #progressElement;

    /**
     * Whether the progress bar is currently in the processing of hiding.
     *
     * @type {Boolean}
     */
    #hiding = false;

    /**
     * The current value of the progress bar, from 0 to 1, inclusive.
     *
     * @type {Number}
     */
    #value = 0;

    /**
     * Whether the progress bar is currently visible.
     *
     * @type {Boolean}
     */
    #visible = false;

    /**
     * The trickle interval ID, or null if one is not active.
     *
     * @type {Number|null}
     */
    #trickleInterval = null;

    /**
     * The progress bar transition duration, in milliseconds.
     *
     * @type {Number}
     */
    #transitionDuration = 300;

    constructor() {

      this.#containerElement = Drupal.theme('refreshlessProgressBar');

      this.#progressElement = this.#containerElement.querySelector('progress');

      this.setValue(0, false);

    }

    /**
     * Destroy this instance.
     *
     * @return {Promise}
     *   A Promise that resolves when the progress bar has been destroyed.
     */
    destroy() {

      // This removes everything from the DOM, including custom properties.
      return this.hide();

    }

    /**
     * Show the progress bar if not already visible.
     *
     * @return {Promise}
     *   A Promise that resolves if the progress bar was show, or if the
     *   progress bar is already visible. The Promise is rejected if the
     *   progress bar was not shown, usually due to the
     *   'refreshless:progress-bar-before-show' event being cancelled by a
     *   handler.
     *
     * @todo Only resolve 'shown' when progress bar has finished transitioning
     *   in for consistency with hide().
     */
    show() {

      if (this.#visible === true) {
        return Promise.resolve('already-visible');
      }

      const beforeShowEvent = new CustomEvent(
        'refreshless:progress-bar-before-show',
      );

      html.dispatchEvent(beforeShowEvent);

      if (beforeShowEvent.defaultPrevented === true) {
        return Promise.reject('cancelled');
      }

      this.#visible = true;
      this.install();
      this.startTrickling();

      html.dispatchEvent(new CustomEvent('refreshless:progress-bar-shown'));

      return Promise.resolve('shown');

    }

    /**
     * Hide the progress bar if visible and not already hiding.
     *
     * @return {Promise}
     *   A Promise that resolves when the progress bar has finished
     *   transitioning out, or if the progress bar is already hidden or in the
     *   process of hiding. The Promise is rejected if the progress bar was not
     *   hidden, usually due to the 'refreshless:progress-bar-before-hide' event
     *   being cancelled by a handler.
     */
    hide() {

      if (this.#visible === false) {
        return Promise.resolve('already-hidden');
      }

      if (this.#hiding === true) {
        return Promise.resolve('already-hiding');
      }

      const beforeHideEvent = new CustomEvent(
        'refreshless:progress-bar-before-hide',
      );

      html.dispatchEvent(beforeHideEvent);

      if (beforeHideEvent.defaultPrevented === true) {
        return Promise.reject('cancelled');
      }

      this.#hiding = true;

      this.stopTrickling();

      return new Promise(async (resolve, reject) => {

        this.#setInactive();

        await new Promise((resolve, reject) => {

          window.setTimeout(resolve, this.#transitionDuration * 1.5);

        });

        this.uninstall();
        this.#visible = false;
        this.#hiding = false;

        html.dispatchEvent(new CustomEvent('refreshless:progress-bar-hidden'));

        resolve('hidden');

      });

    }

    /**
     * Set the progress bar as active, causing CSS to transition it in.
     *
     * @param {Boolean} dispatch
     */
    #setActive(dispatch = true) {

      this.#containerElement.classList.add(
        `${this.#containerElement.refreshlessProgressBarClass}--active`,
      );

      if (dispatch === false) {
        return;
      }

      html.dispatchEvent(new CustomEvent('refreshless:progress-bar-active'));

    }

    /**
     * Set the progress bar as inactive, causing CSS to transition it out.
     *
     * @param {Boolean} dispatch
     *   Whether to dispatch an event. Defaults to true.
     */
    #setInactive(dispatch = true) {

      this.#containerElement.classList.remove(
        `${this.#containerElement.refreshlessProgressBarClass}--active`,
      );

      if (dispatch === false) {
        return;
      }

      html.dispatchEvent(new CustomEvent('refreshless:progress-bar-inactive'));

    }

    /**
     * Explicitly set the progress bar to a value.
     *
     * @param {Number} value
     *   A number between 0 and 1, inclusive.
     *
     * @param {Boolean} dispatch
     *   Whether to dispatch an event. Defaults to true.
     *
     * @throws If value is NaN, or if the value is less than 0 or greater than
     *   1.
     */
    setValue(value, dispatch = true) {

      if (Number.isNaN(value)) {
        throw `Progress bar value must be a number! Got: "${typeof value}"`;
      }

      if (value < 0 || value > 1) {
        throw `Progress bar value must be an integer or float between 0 and 1! Got: "${value}"`;
      }

      const valueChanged = (this.#value !== value);

      const oldValue = this.#value;

      this.#value = value;

      return this.refresh().then(() => {

        if (dispatch === false || valueChanged === false) {
          return;
        }

        html.dispatchEvent(new CustomEvent(
          'refreshless:progress-bar-value-changed', {detail: {
            value:    this.#value,
            oldValue: oldValue,
          }}
        ));

      });

    }

    /**
     * Install the progress bar in the document and set various properties.
     */
    install() {

      html.style.setProperty(
        durationCustomProperty, `${this.#transitionDuration}ms`,
      );

      html.style.setProperty(valueCustomProperty, 0);

      this.#setInactive(false);

      document.body.insertAdjacentElement(
        'beforebegin', this.#containerElement,
      );

      // The progress bar is technically describing the current state of the
      // document so mark it as such. This may be helpful to assistive software
      // such as screen readers.
      html.setAttribute(
        'aria-describedby', this.#progressElement.getAttribute('id'),
      );

      this.refresh().then(async () => {

        await new Promise(requestAnimationFrame);

        this.#setActive();

      });

    }

    /**
     * Uninstall the progress bar from the document and remove properties.
     */
    uninstall() {

      if (this.#containerElement.parentNode) {
        this.#containerElement.remove();
      }

      html.style.removeProperty(durationCustomProperty);
      html.style.removeProperty(valueCustomProperty);

      if (html.getAttribute(
        'aria-describedby',
      ) === this.#progressElement.getAttribute('id')) {
        html.removeAttribute('aria-describedby');
      }

    }

    /**
     * Start the trickling animation.
     */
    startTrickling() {

      if (this.#trickleInterval !== null) {
        return;
      }

      this.#trickleInterval = window.setInterval(
        this.trickle, this.#transitionDuration,
      );

    }

    /**
     * Stop the trickling animation.
     */
    stopTrickling() {

      window.clearInterval(this.#trickleInterval);

      this.#trickleInterval = null;

    }

    /**
     * Trickle animation interval callback.
     *
     * This generates a random value to give the trickle the irregular movement.
     */
    trickle = () => {
      this.setValue(Math.min(1, this.#value + Math.random() / 100));
    }

    /**
     * Update the progress bar element's value with the current value.
     */
    async refresh() {

      // Don't write the custom property if not visible or if currently hiding.
      if (!(this.#visible === true && this.#hiding === false)) {
        return;
      }

      if (this.#value === 1) {
        this.stopTrickling();
      }

      await new Promise(requestAnimationFrame);

      html.style.setProperty(valueCustomProperty, this.#value);

      this.#progressElement.setAttribute('value', this.#value * 100);

      this.#progressElement.innerText = `${Math.round(this.#value * 100)}%`;

    }

    /**
     * Finish/complete the progress bar by setting to 100% and start hiding it.
     *
     * @return {Promise}
     */
    finish() {

      html.dispatchEvent(new CustomEvent('refreshless:progress-bar-finish'));

      return this.setValue(1).then(() => this.hide());

    }

    /**
     * Get the current value of the progress bar.
     *
     * @return {Number}
     */
    get value() {
      return this.#value;
    }

    /**
     * Get the progress bar container HTML element.
     *
     * @return {HTMLElement}
     */
    get element() {
      return this.#containerElement;
    }

    /**
     * Get the progress bar transition value.
     *
     * @return {Number}
     */
    get transitionDuration() {
      return this.#transitionDuration;
    }

  }

  // Merge Drupal.RefreshLess.classes into the existing Drupal global.
  $.extend(true, Drupal, {RefreshLess: {classes: {
    ProgressBar: ProgressBar,
  }}});

})(Drupal, jQuery);

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc