Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
@twnd/ux / modal / modal.ts
Size: Mime:
/**
 * @license
 * FOURBURNER CONFIDENTIAL
 * Unpublished Copyright (C) 2021 FourBurner Technologies, Inc. All Rights Reserved.
 *
 * NOTICE: All information contained herein is, and remains the property of FOURBURNER TECHNOLOGIES,
 * INC. The intellectual and technical concepts contained herein are proprietary to FOURBURNER
 * TECHNOLOGIES, INC. and may be covered by U.S. and Foreign Patents, patents in process, and are
 * protected by trade secret or copyright law. Dissemination of this information or reproduction of
 * this material is strictly forbidden unless prior written permission is obtained from FOURBURNER
 * TECHNOLOGIES, INC. Access to the source code contained herein is hereby forbidden to anyone
 * except current FOURBURNER TECHNOLOGIES, INC. employees, managers or contractors who have executed
 * Confidentiality and Non-disclosure agreements explicitly covering such access.
 *
 * The copyright notice above does not evidence any actual or intended publication or disclosure of
 * this source code, which includes information that is confidential and/or proprietary, and is a
 * trade secret, of FOURBURNER TECHNOLOGIES, INC. ANY REPRODUCTION, MODIFICATION, DISTRIBUTION,
 * PUBLIC PERFORMANCE, OR PUBLIC DISPLAY OF OR THROUGH USE OF THIS SOURCE CODE WITHOUT THE EXPRESS
 * WRITTEN CONSENT OF FOURBURNER TECHNOLOGIES, INC. IS STRICTLY PROHIBITED, AND IN VIOLATION OF
 * APPLICABLE LAWS AND INTERNATIONAL TREATIES. THE RECEIPT OR POSSESSION OF THIS SOURCE CODE AND/OR
 * RELATED INFORMATION DOES NOT CONVEY OR IMPLY ANY RIGHTS TO REPRODUCE, DISCLOSE OR DISTRIBUTE ITS
 * CONTENTS, OR TO MANUFACTURE, USE, OR SELL ANYTHING THAT IT MAY DESCRIBE, IN WHOLE OR IN PART.
 */

import {Directionality} from '@angular/cdk/bidi';
import {
  Overlay,
  OverlayConfig,
  OverlayContainer,
  OverlayRef,
  ScrollStrategy,
} from '@angular/cdk/overlay';
import {ComponentPortal, ComponentType, TemplatePortal} from '@angular/cdk/portal';
import {Location} from '@angular/common';
import {
  Directive,
  Inject,
  Injectable,
  InjectFlags,
  InjectionToken,
  Injector,
  OnDestroy,
  Optional,
  SkipSelf,
  StaticProvider,
  TemplateRef,
  Type,
} from '@angular/core';
import {defer, Observable, of as observableOf, Subject, Subscription} from 'rxjs';
import {startWith} from 'rxjs/operators';
import {ngDevMode} from '@twnd/ux/core';
import {TWNDModalConfig} from './modal.config';
import {TWNDModalContainer, _TWNDModalContainerBase} from './modal.container';
import {TWNDModalRef} from './modal.ref';
import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations';

/**
 * Injection token that can be used to access the data that was passed in to a modal.
 */
export const TWND_MODAL_DATA = new InjectionToken<any>('TWNDModalData');

/**
 * Injection token that can be used to specify default modal options.
 */
export const TWND_MODAL_DEFAULT_OPTIONS = new InjectionToken<TWNDModalConfig>(
  'twnd-modal-default-options',
);

/**
 * Injection token that determines the scroll handling while the modal is open.
 */
export const TWND_MODAL_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>(
  'twnd-modal-scroll-strategy',
);

/**
 * @docs-private
 */
export function TWND_MODAL_SCROLL_STRATEGY_FACTORY(overlay: Overlay): () => ScrollStrategy
{
  return () => overlay.scrollStrategies.block();
}

/**
 * @docs-private
 */
export function TWND_MODAL_SCROLL_STRATEGY_PROVIDER_FACTORY(
  overlay: Overlay,
  ): () => ScrollStrategy
{
  return () => overlay.scrollStrategies.block();
}

/**
 * @docs-private
 */
export const TWND_MODAL_SCROLL_STRATEGY_PROVIDER = {
  provide : TWND_MODAL_SCROLL_STRATEGY,
  deps : [ Overlay ],
  useFactory : TWND_MODAL_SCROLL_STRATEGY_PROVIDER_FACTORY,
};

/**
 * Base class for modal services. The base modal service allows
 * for arbitrary modal refs and modal container components.
 */
@Directive()
export abstract class _TWNDModalBase<C extends _TWNDModalContainerBase> implements OnDestroy
{
  private _openModalsAtThisLevel: TWNDModalRef<any>[] = [];
  private readonly _afterAllClosedAtThisLevel = new Subject<void>();
  private readonly _afterOpenedAtThisLevel = new Subject<TWNDModalRef<any>>();
  private _ariaHiddenElements = new Map<Element, string|null>();
  private _scrollStrategy: () => ScrollStrategy;
  private _modalAnimatingOpen = false;
  private _animationStateSubscriptions: Subscription;
  private _lastModalRef: TWNDModalRef<any>;

  /**
   * Keeps track of the currently-open modals.
   */
  get openModals(): TWNDModalRef<any>[]
  {
    return this._parentModal ? this._parentModal.openModals : this._openModalsAtThisLevel;
  }

  /**
   * Stream that emits when a modal has been opened.
   */
  get afterOpened(): Subject<TWNDModalRef<any>>
  {
    return this._parentModal ? this._parentModal.afterOpened : this._afterOpenedAtThisLevel;
  }

  _getAfterAllClosed(): Subject<void>
  {
    const parent = this._parentModal;
    return parent ? parent._getAfterAllClosed() : this._afterAllClosedAtThisLevel;
  }

  /**
   * Stream that emits when all open modal have finished closing.
   * Will emit on subscribe if there are no open modals to begin with.
   */
  readonly afterAllClosed:
    Observable<void> = defer(
                         () => this.openModals.length ?
                                 this._getAfterAllClosed() :
                                 this._getAfterAllClosed().pipe(startWith(undefined)),
                         ) as Observable<any>;

  constructor(
    private _overlay: Overlay,
    private _injector: Injector,
    private _defaultOptions: TWNDModalConfig|undefined,
    private _parentModal: _TWNDModalBase<C>|undefined,
    private _overlayContainer: OverlayContainer,
    scrollStrategy: any,
    private _modalRefConstructor: Type<TWNDModalRef<any>>,
    private _modalContainerType: Type<C>,
    private _modalDataToken: InjectionToken<any>,
    private _animationMode?: 'NoopAnimations'|'BrowserAnimations',
  )
  {
    this._scrollStrategy = scrollStrategy;
  }

  /**
   * Opens a modal modal containing the given component.
   * @param component Type of the component to load into the modal.
   * @param config Extra configuration options.
   * @returns Reference to the newly-opened modal.
   */
  open<T, D = any, R = any>(
    component: ComponentType<T>,
    config?: TWNDModalConfig<D>,
    ): TWNDModalRef<T, R>;

  /**
   * Opens a modal modal containing the given template.
   * @param template TemplateRef to instantiate as the modal content.
   * @param config Extra configuration options.
   * @returns Reference to the newly-opened modal.
   */
  open<T, D = any, R = any>(
    template: TemplateRef<T>,
    config?: TWNDModalConfig<D>,
    ): TWNDModalRef<T, R>;

  open<T, D = any, R = any>(
    template: ComponentType<T>|TemplateRef<T>,
    config?: TWNDModalConfig<D>,
    ): TWNDModalRef<T, R>;

  open<T, D = any, R = any>(
    componentOrTemplateRef: ComponentType<T>|TemplateRef<T>,
    config?: TWNDModalConfig<D>,
    ): TWNDModalRef<T, R>
  {
    config = _applyConfigDefaults(config, this._defaultOptions || new TWNDModalConfig());

    if (config.id && this.getModalById(config.id) &&
        (typeof ngDevMode === 'undefined' || ngDevMode)) {
      throw Error(`Modal with id "${config.id}" exists already. The modal id must be unique.`);
    }

    // If there is a modal that is currently animating open, return the TWNDModalRef of that modal
    if (this._modalAnimatingOpen) {
      return this._lastModalRef;
    }

    const overlayRef = this._createOverlay(config);
    const modalContainer = this._attachModalContainer(overlayRef, config);
    if (this._animationMode !== 'NoopAnimations') {
      const animationStateSubscription = modalContainer._animationStateChanged.subscribe(
        modalAnimationEvent => {
          if (modalAnimationEvent.state === 'opening') {
            this._modalAnimatingOpen = true;
          }
          if (modalAnimationEvent.state === 'opened') {
            this._modalAnimatingOpen = false;
            animationStateSubscription.unsubscribe();
          }
        },
      );
      if (!this._animationStateSubscriptions) {
        this._animationStateSubscriptions = new Subscription();
      }
      this._animationStateSubscriptions.add(animationStateSubscription);
    }

    const modalRef = this._attachModalContent<T, R>(
      componentOrTemplateRef,
      modalContainer,
      overlayRef,
      config,
    );
    this._lastModalRef = modalRef;

    // If this is the first modal that we're opening, hide all the non-overlay content.
    if (!this.openModals.length) {
      this._hideNonModalContentFromAssistiveTechnology();
    }

    this.openModals.push(modalRef);
    modalRef.afterClosed().subscribe(() => this._removeOpenModal(modalRef));
    this.afterOpened.next(modalRef);

    // Notify the modal container that the content has been attached.
    modalContainer._initializeWithAttachedContent();

    return modalRef;
  }

  /**
   * Closes all of the currently-open modals.
   */
  closeAll(): void
  {
    this._closeModals(this.openModals);
  }

  /**
   * Finds an open modal by its id.
   * @param id ID to use when looking up the modal.
   */
  getModalById(id: string): TWNDModalRef<any>|undefined
  {
    return this.openModals.find(modal => modal.id === id);
  }

  ngOnDestroy()
  {
    // Only close the modals at this level on destroy
    // since the parent service may still be active.
    this._closeModals(this._openModalsAtThisLevel);
    this._afterAllClosedAtThisLevel.complete();
    this._afterOpenedAtThisLevel.complete();
    // Clean up any subscriptions to modals that never finished opening.
    if (this._animationStateSubscriptions) {
      this._animationStateSubscriptions.unsubscribe();
    }
  }

  /**
   * Creates the overlay into which the modal will be loaded.
   * @param config The modal configuration.
   * @returns A promise resolving to the OverlayRef for the created overlay.
   */
  private _createOverlay(config: TWNDModalConfig): OverlayRef
  {
    const overlayConfig = this._getOverlayConfig(config);
    return this._overlay.create(overlayConfig);
  }

  /**
   * Creates an overlay config from a modal config.
   * @param modalConfig The modal configuration.
   * @returns The overlay configuration.
   */
  private _getOverlayConfig(modalConfig: TWNDModalConfig): OverlayConfig
  {
    const state = new OverlayConfig({
      positionStrategy : this._overlay.position().global(),
      scrollStrategy : modalConfig.scrollStrategy || this._scrollStrategy(),
      panelClass : modalConfig.panelClass,
      hasBackdrop : modalConfig.hasBackdrop,
      direction : modalConfig.direction,
      minWidth : modalConfig.minWidth,
      minHeight : modalConfig.minHeight,
      maxWidth : modalConfig.maxWidth,
      maxHeight : modalConfig.maxHeight,
      disposeOnNavigation : modalConfig.closeOnNavigation,
    });

    if (modalConfig.backdropClass) {
      state.backdropClass = modalConfig.backdropClass;
    }

    return state;
  }

  /**
   * Attaches a modal container to a modal's already-created overlay.
   * @param overlay Reference to the modal's underlying overlay.
   * @param config The modal configuration.
   * @returns A promise resolving to a ComponentRef for the attached container.
   */
  private _attachModalContainer(overlay: OverlayRef, config: TWNDModalConfig): C
  {
    const userInjector = config && config.viewContainerRef && config.viewContainerRef.injector;
    const injector = Injector.create({
      parent : userInjector || this._injector,
      providers : [ {provide : TWNDModalConfig, useValue : config} ],
    });

    const containerPortal = new ComponentPortal(
      this._modalContainerType,
      config.viewContainerRef,
      injector,
      config.componentFactoryResolver,
    );
    const containerRef = overlay.attach<C>(containerPortal);

    return containerRef.instance;
  }

  /**
   * Attaches the user-provided component to the already-created modal container.
   * @param componentOrTemplateRef The type of component being loaded into the modal,
   *     or a TemplateRef to instantiate as the content.
   * @param modalContainer Reference to the wrapping modal container.
   * @param overlayRef Reference to the overlay in which the modal resides.
   * @param config The modal configuration.
   * @returns A promise resolving to the TWNDModalRef that should be returned to the user.
   */
  private _attachModalContent<T, R>(
    componentOrTemplateRef: ComponentType<T>|TemplateRef<T>,
    modalContainer: C,
    overlayRef: OverlayRef,
    config: TWNDModalConfig,
    ): TWNDModalRef<T, R>
  {
    // Create a reference to the modal we're creating in order to give the user a handle
    // to modify and close it.
    const modalRef = new this._modalRefConstructor(overlayRef, modalContainer, config.id);

    if (componentOrTemplateRef instanceof TemplateRef) {
      modalContainer.attachTemplatePortal(
        new TemplatePortal<T>(componentOrTemplateRef, null!, <any>{
          $implicit : config.data,
          modalRef,
        }),
      );
    } else {
      const injector = this._createInjector<T>(config, modalRef, modalContainer);
      const contentRef = modalContainer.attachComponentPortal<T>(
        new ComponentPortal(componentOrTemplateRef, config.viewContainerRef, injector),
      );
      modalRef.componentInstance = contentRef.instance;
    }

    modalRef.updateSize(config.width, config.height).updatePosition(config.position);

    return modalRef;
  }

  /**
   * Creates a custom injector to be used inside the modal. This allows a component loaded inside
   * of a modal to close itself and, optionally, to return a value.
   * @param config Config object that is used to construct the modal.
   * @param modalRef Reference to the modal.
   * @param modalContainer Modal container element that wraps all of the contents.
   * @returns The custom injector that can be used inside the modal.
   */
  private _createInjector<T>(
    config: TWNDModalConfig,
    modalRef: TWNDModalRef<T>,
    modalContainer: C,
    ): Injector
  {
    const userInjector = config && config.viewContainerRef && config.viewContainerRef.injector;

    // The modal container should be provided as the modal container and the modal's
    // content are created out of the same `ViewContainerRef` and as such, are siblings
    // for injector purposes. To allow the hierarchy that is expected, the modal
    // container is explicitly provided in the injector.
    const providers: StaticProvider[] = [
      {provide : this._modalContainerType, useValue : modalContainer},
      {provide : this._modalDataToken, useValue : config.data},
      {provide : this._modalRefConstructor, useValue : modalRef},
    ];

    if (config.direction &&
        (!userInjector ||
         !userInjector.get<Directionality|null>(Directionality, null, InjectFlags.Optional))) {
      providers.push({
        provide : Directionality,
        useValue : {value : config.direction, change : observableOf()},
      });
    }

    return Injector.create({parent : userInjector || this._injector, providers});
  }

  /**
   * Removes a modal from the array of open modals.
   * @param modalRef Modal to be removed.
   */
  private _removeOpenModal(modalRef: TWNDModalRef<any>)
  {
    const index = this.openModals.indexOf(modalRef);

    if (index > -1) {
      this.openModals.splice(index, 1);

      // If all the modals were closed, remove/restore the `aria-hidden`
      // to a the siblings and emit to the `afterAllClosed` stream.
      if (!this.openModals.length) {
        this._ariaHiddenElements.forEach((previousValue, element) => {
          if (previousValue) {
            element.setAttribute('aria-hidden', previousValue);
          } else {
            element.removeAttribute('aria-hidden');
          }
        });

        this._ariaHiddenElements.clear();
        this._getAfterAllClosed().next();
      }
    }
  }

  /**
   * Hides all of the content that isn't an overlay from assistive technology.
   */
  private _hideNonModalContentFromAssistiveTechnology()
  {
    const overlayContainer = this._overlayContainer.getContainerElement();

    // Ensure that the overlay container is attached to the DOM.
    if (overlayContainer.parentElement) {
      const siblings = overlayContainer.parentElement.children;

      for (let i = siblings.length - 1; i > -1; i--) {
        let sibling = siblings[i];

        if (sibling !== overlayContainer && sibling.nodeName !== 'SCRIPT' &&
            sibling.nodeName !== 'STYLE' && !sibling.hasAttribute('aria-live')) {
          this._ariaHiddenElements.set(sibling, sibling.getAttribute('aria-hidden'));
          sibling.setAttribute('aria-hidden', 'true');
        }
      }
    }
  }

  /**
   * Closes all of the modals in an array.
   */
  private _closeModals(modals: TWNDModalRef<any>[])
  {
    let i = modals.length;

    while (i--) {
      // The `_openModals` property isn't updated after close until the rxjs subscription
      // runs on the next microtask, in addition to modifying the array as we're going
      // through it. We loop through all of them and call close without assuming that
      // they'll be removed from the list instantaneously.
      modals[i].close();
    }
  }
}

/**
 * Service to open Tailwind UX modals.
 */
@Injectable() export class TWNDModal extends _TWNDModalBase<TWNDModalContainer>
{
  constructor(
    overlay: Overlay,
    injector: Injector,
    /**
     * @deprecated `_location` parameter to be removed.
     * @breaking-change 10.0.0
     */
    @Optional() location: Location,
    @Optional() @Inject(TWND_MODAL_DEFAULT_OPTIONS) defaultOptions: TWNDModalConfig,
    @Inject(TWND_MODAL_SCROLL_STRATEGY) scrollStrategy: any,
    @Optional() @SkipSelf() parentModal: TWNDModal,
    overlayContainer: OverlayContainer,
    @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: 'NoopAnimations'|'BrowserAnimations',
  )
  {
    super(
      overlay,
      injector,
      defaultOptions,
      parentModal,
      overlayContainer,
      scrollStrategy,
      TWNDModalRef,
      TWNDModalContainer,
      TWND_MODAL_DATA,
      animationMode,
    );
  }
}

/**
 * Applies default options to the modal config.
 * @param config Config to be modified.
 * @param defaultOptions Default options provided.
 * @returns The new configuration object.
 */
function _applyConfigDefaults(
  config?: TWNDModalConfig,
  defaultOptions?: TWNDModalConfig,
  ): TWNDModalConfig
{
  return {...defaultOptions, ...config};
}