Create Modal with createComponent

4 분 소요

createComponent provides lower-level control over dynamic components than ngComponentOutlet, making it ideal for building complex features like a full-fledged Modal system.

Defining a Modal

Unlike a simple tooltip, a modal system has several requirements:

  • Service-Based Invocation: Modals should be launchable from anywhere in the application, not just tied to a specific host element’s event. A reusable Injectable service is the perfect solution.
  • Layer Management: It should be possible to stack modals on top of each other. (While this post covers a basic single-modal implementation, the architecture should be extensible.)
  • Returning a Result: When a modal closes, it should be able to return a value (e.g., ‘confirm’, ‘cancel’, or custom data). The calling code needs to be able to await this result.

Basic Operation Plan

  • Create a ModalService: This centralized service will handle dynamic component creation (createComponent), return an Observable for the result, and manage closing the modal.
  • Set up a ModalHost: In AppComponent, we’ll define a dedicated location (ViewContainerRef) for rendering modals and register it with the ModalService.
  • Create Dynamic Modal Components: Build standalone modal components that interact with the user and pass results back to the service via a Subject.
  • Create a Container Component: This component will inject the ModalService to open modals and will store the returned result in a Signal to display in the UI.

1. Creating the ModalService

This is the heart of our system. It manages the creation, destruction, and result handling of all modals.

// src/app/modal.service.ts
import { Injectable, ViewContainerRef, ComponentRef, Type } from '@angular/core';
import { Subject, Observable } from 'rxjs';
import { first, tap } from 'rxjs/operators';

// An interface that modal components should implement
export interface ModalComponent<R> {
  result$: Subject<R>;
}

@Injectable({ providedIn: 'root' })
export class ModalService {
  private hostVcr?: ViewContainerRef;
  private componentRef?: ComponentRef<ModalComponent<any>>;

  // Register the ViewContainerRef where modals will be rendered
  registerHost(vcr: ViewContainerRef): void {
    this.hostVcr = vcr;
  }

  // Use generics to open any component and return any type of result
  open<T extends ModalComponent<R>, R>(component: Type<T>): Observable<R> {
    if (!this.hostVcr) {
      throw new Error('Modal host container is not registered!');
    }
    // Clear previous modal (single modal policy)
    this.hostVcr.clear();

    const result$ = new Subject<R>();
    
    // Dynamically create the component
    this.componentRef = this.hostVcr.createComponent(component);
    // Inject the result$ Subject into the component's instance
    this.componentRef.instance.result$ = result$;

    // Return an observable that closes the modal after the first result
    return result$.asObservable().pipe(
      first(),
      tap(() => this.close())
    );
  }

  close(): void {
    if (this.componentRef) {
      this.componentRef.destroy();
      this.componentRef = undefined;
    }
  }
}

2. Creating the Components

AppComponent (Setting up the Modal Host)

At the top level of our application, we tell the ModalService where it should render modals.

// src/app/app.component.ts
import { Component, ViewChild, ViewContainerRef, AfterViewInit, inject } from '@angular/core';
import { ContainerComponent } from './container.component'; // Our example calling component
import { ModalService } from './modal.service';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [ContainerComponent],
  template: `
    <app-container></app-container>
    <!-- All modals will be rendered here -->
    <ng-container #modalHost></ng-container>
  `,
})
export class AppComponent implements AfterViewInit {
  @ViewChild('modalHost', { read: ViewContainerRef })
  modalHostVcr!: ViewContainerRef;

  private modalService = inject(ModalService);
  
  ngAfterViewInit(): void {
    // Register the host ViewContainerRef with the service
    this.modalService.registerHost(this.modalHostVcr);
  }
}

Dynamic Modal Components (AModalComponent, BModalComponent)

These components implement the ModalComponent interface, giving them a result$ property.

// src/app/a-modal.component.ts
import { Component } from '@angular/core';
import { Subject } from 'rxjs';
import { ModalComponent } from './modal.service';

@Component({
  standalone: true,
  template: `
    <div class="modal">
      <p>Modal A: Are you sure?</p>
      <button (click)="onConfirm()">Confirm</button>
      <button (click)="onCancel()">Cancel</button>
    </div>
  `,
  styles: `.modal { border: 1px solid blue; padding: 20px; background: white; }`
})
export class AModalComponent implements ModalComponent<boolean> {
  result$!: Subject<boolean>;

  onConfirm(): void {
    this.result$.next(true);
    this.result$.complete();
  }
  onCancel(): void {
    this.result$.next(false);
    this.result$.complete();
  }
}

(BModalComponent can be created similarly.)

Container Component (The Modal Caller)

Now, any component that needs to open a modal only needs to inject the ModalService.

// src/app/container.component.ts
import { Component, inject, signal } from '@angular/core';
import { ModalService } from './modal.service';
import { AModalComponent } from './a-modal.component';
import { BModalComponent } from './b-modal.component';

@Component({
  selector: 'app-container',
  standalone: true,
  template: `
    <button (click)="openModal('A')">Show Modal A</button>
    <button (click)="openModal('B')">Show Modal B</button>
    <p>Last Modal Result: </p>
  `
})
export class ContainerComponent {
  private modalService = inject(ModalService);
  modalResult = signal<any>('N/A');

  openModal(type: 'A' | 'B'): void {
    const modalToOpen = type === 'A' ? AModalComponent : BModalComponent;
    
    this.modalService.open(modalToOpen).subscribe(result => {
      // Store the returned result in our Signal after the modal closes
      this.modalResult.set(result);
    });
  }
}

Result

This architecture provides a clean and reusable foundation for a modal system by leveraging Angular’s DI.

  • Separation of Concerns: The component opening a modal doesn’t need to know where or how it’s rendered. It only needs to know about the ModalService.
  • Type Safety: Generics ensure that the type of the opened component and its return value are clearly defined.
  • Extensibility: This structure can be easily extended to handle features like modal stacking, animations, or centralized state management.

댓글남기기