Sekrab Garage

Angular programmatically created components

Homemade dialog service in Angular

Angular June 4, 23
Subscribe to Sekrab Parts newsletter.
Series:

Programmatic components

In Angular 13, the ComponentFactoryResolver became deprecated. Some libraries that depended on it had to be rewritten using the new introduced function createComponent, that is more aligned with the standalone mantra. Let's dig into it and put it to good use.
  1. two years ago
    Creating a component dynamically, and programmatically in Angular
  2. two years ago
    Inserting Peach component into Rose at runtime
  3. two years ago
    Inserting programmatically created components with templates and content projection in Angular
  4. two years ago
    Providing services to programmatically created components in Angular
  5. two years ago
    Homemade dialog service in Angular

Let's put all our knowledge in creating components programmatically in Angular 16 to good use. Let's create a Dialog service, that allows us to host any component. We already covered the major features in Rose Dialog in our StackBlitz project. Today we dig into the following issues to get closure:

  • Style it to properly look like a dialog, and pass extra css
  • Add extra event handlers to close
  • Keep references of opened dialogs
  • Housekeeping
Find the final Dialog service in StackBlitz project, under lib/Dialog folder.

anchorStyling

The sweetest part. We shall add a css file to the service, and style just enough to make it look right. In the wild, we should rely on a global stylesheet for coloring and measurements. We use Less or Sass for that, but I am leaving that part out to you. The idea is simple, make an overlay of semi transparent black, and host the dialog in the middle of it.

<!-- lib/Dialog/partial.html -->
<div class="dialog-overlay">
   <div class="dialog">
      <div class="dialog-header">
         <h6 class="dialog-title" id="dialogtitle">{{ title }}</h6>
         <button type="button" class="dialog-close" (click)="close()">❌</button>
      </div>
      <div class="dialog-body">
         <ng-content></ng-content>
      </div>
   </div>
</div>

The bare minimum styles; which I promise will need more work, and may be done better within a couple of months as CSS quickly advances.

/* lib/Dialog/styles.css */
.dialog-overlay {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 1030;
  background-color: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
}

.dialog {
  background-color: #fff;
  width: clamp(300px, 75vw, 90vw);
  z-index: 1040;
  overflow: hidden;
  outline: 0;
  display: flex;
  flex-direction: column;
  max-height: 90vh;
}

.dialog-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1.2rem;
}

.dialog-body {
  position: relative;
  flex: 1 1 auto;
  padding: 1.2rem;
  overflow-y: auto;
}

anchorCatch click and escape events

A nice feature to add is to hide the dialog when user clicks on the overlay or presses escape. We can add that directly to the dialog component.

// lib/Dialog/partial.ts

export class DialogPartialComponent {
  // ...
  
  // close on overlay click
  @HostListener('click', ['$event.target'])
  onClick(target: HTMLElement): void {
    // find d-overlay (add it to the dialog-overlay)
    if (target.matches('.d-overlay')) {
      this.close(null);
    }
  }
  
  // close on escape as well
  @HostListener('window:keydown', ['$event'])
  onEscape(event: KeyboardEvent): void {
    // hide on escape
    if (event.code === 'Escape') {
      this.close(null);
    }
  }
}

I added a new class to the .dialog-overlay named .d-overlay, because I formed a habit long ago:

Never mix classes used in styles, with those used in scripts. It always bites back.

anchorAdd extra css

We can create a new option property to pass extra css to our dialog component, for maximum control.

// lib/Dialog/service

// add extra option in open method
public open(c: Type<any>, options?: {
      // ...
      css?: string,
) {
  // ... 
	
  // get dialog element first
  const dialogElement = (<EmbeddedViewRef<any>>componentRef.hostView)
    .rootNodes[0];
  
  // add css to the element root
  if (options?.css) {
    dialogElement.classList.add(...options.css.split(' '));
  }
  
  // then append
  this.doc.body.append(dialogElement);
  
  // ...
}

For example we can define our window to be a full screen dialog, then pass it like this

// pass a different cs
this.dialogService.open(MyPeachComponent, {
  title: 'Peach',
  css: 'dialog-full-screen'
});

I have included in StackBlitz some different styles, have a look at the Final component. We can do something like this:

css: 'dialog-half-screen reverse animate fromright',

This would open a half screen dialog, and animates its position from the right.

anchorKeep references of opened dialogs

It would be nice to be able to get a reference of an open dialog from anywhere in the app, by simply targeting its ID. Like this

// find the dialog by id and close it
this.dialogService.get('uniquePeach')?.close();

To implement that, we first need the ID to be a passable option. then we can collect them to a new property: dialogs

// lib/Dialog/service

// keep references of opened dialogs
dialogs: { [key: string]: DialogPartialComponent | null } = {};

public open(c: Type<any>, options?: {
  //...
  // add id
  id?: string,
}) {

  // get referecne to root element
  const dialogElement = (<EmbeddedViewRef<any>>componentRef.hostView)
    .rootNodes[0];
    
  // and assign it the id 
  if (options?.id) {
    dialogElement.id = options.id;
    // add to collection
    this.dialogs[options.id] = componentRef.instance;
  }
  // ...
  
  // when closed destroy
  const s = componentRef.instance.onClose.subscribe((res) => {
    // get rid of reference
    if (options?.id) {
      delete this.dialogs[options.id];
    }
    // ..
  });
  // ..
}

// then get it
public get(id: string) {
  // find the dialog ref component in collection
  return this.dialogs[id] || null;
}

So next time we want to track a dialog, we simply pass it a unique ID. This probably needs a bit more work to guarantee uniqueness of ID, I'll let you work that out as you pleased.

anchorHousekeeping

A little bit of housekeeping for the code includes the following

  • Destroying the child: upon closing and destroying the componentRef, I forgot to destroy the childRef!
  • Removed the onChildClose event. We can simply call the close method directly on the dialog reference passed.
  • Options need an interface: IDialogOptions. It now looks like this
// lib/Dialog/service

// the dialog options interface
export interface IDialogOptions {
  title?: string;
  data?: any;
  css?: string;
  id?: string;
  onclose?: (res: any) => void;
  providers?: StaticProvider[];
}

I could think of other enhancements to make, but that should do for now.

Note on the side, once you start using it, ExpressionChangedAfterItHasBeenCheckedError error will almost always fire, I do not bother much because I do not believe there is a solid solution for that. It is a development environment warning only.

Let's use it? Find the example in components FinalComponent.

Thank you for reading this far, where you able to make use of this Dialog?

  1. two years ago
    Creating a component dynamically, and programmatically in Angular
  2. two years ago
    Inserting Peach component into Rose at runtime
  3. two years ago
    Inserting programmatically created components with templates and content projection in Angular
  4. two years ago
    Providing services to programmatically created components in Angular
  5. two years ago
    Homemade dialog service in Angular