Using the Angular CDK Overlay with native Dialogs

I recently worked on a project where it was necessary to create some widgets due to the UI requirements. Among those were calendar and select widgets for which we used the Angular CDK overlay due to features like following the element when scolling the page. The project also required a variety of dialogs, where we decided to use the native browser dialog, because it’s easy to style and easy to use.

The Problem

What we didn’t know at the time of making those decisions was the fact, that the CDK overlay doens’t work well with the native Dialog. The problem, for which there has been a github issue since 2022, is that Material popups shown from native HTML dialog appear behind dialog. We ran into this problem when we tried to use our calendar widget in a dialog. The root of the issue is the way that the two of them work.

The following picture shows what is required.

 

What initially happens is shown next.

Let’s start with the overlay. When using a CDK overlay in a web app, the CDK overlay requests a CDK overlay container from the OverlayContainer class. The container is an HTML div element with the class cdk-overlay-container at the end of the HTML body. The OverlayContainer class creates this element the first time it is required. The container element has a default z-index of 1000, which causes the overlay to appear usually over all other elements. The dialog is one exception to this.

The reason why the overlay is not diplayed over the dialog, even though it is part of it, is that the dialog is displayed in a rather special layer, the top layer. This layer and it’s content is really displayed on top of everything else on the page.

Because the overlay puts its container in the body and not in the dialog, it is displayed under the top layer and, therefore under the dialog.

The solution

At this point, I’d like to give thanks to the qupaya team because analysis and solution finding were a team effort, as well as the GitHub community for their work and ideas on this issue.

Our guiding idea was that we wanted to be able to make overlays open inside the dialog element if a dialog is open. To achieve this, we needed to make the overlays use a different overlay container.

Therefore our first step was to override CDKs OverlayContainer class. We created an extension that would manage different overlay container HTML elements. It adds a container when a dialog opens and removes it again when the dialog closes. It is also necessary to track the containers that are paused, so to say. An array is used as a stack for this purpose. Thankfully, OverlayContainer has the overlay container as a protected writable property, so it can be changed. The following code shows the implementation.

@Injectable({
  providedIn: 'root',
})
export class QupayaOverlayContainer extends OverlayContainer {
  private readonly containerStack: HTMLElement[] = [];

  addContainer(container: HTMLElement): void {
    if (this._containerElement) {
      this.containerStack.push(this._containerElement);
    }
    this._containerElement = container;
  }

  removeContainer(): void {
    this._containerElement = this.containerStack.pop() as HTMLElement;
  }
};

To replace the original container, it is necessary to override OverlayContainer with QupayaOverlayContainer. We created the following NgModule to handle this. It needs to be added to the ApplicationConfig providers using importProvidersFrom(QupayaModule).

@NgModule({
  providers: [
    {
      provide: OverlayContainer,
      useExisting: QupayaOverlayContainer,
    },
  ],
})
export class QupayaModule {}

Next, we needed to adjust the dialog. We already had a wrapper component for the dialog element that handles opening and closing as well as some styling and uses ng-content to project content into the dialog. We added another overlay container to the end of the dialog element. The following code shows the simplified template. It’s important to use the same container class here as CDK uses because it has styling for it.

<dialog #dialogElement (close)="handleClose()">
  <ng-content></ng-content>
  <div #dialogOverlayContainer class="cdk-overlay-container"></div>
</dialog>

Finally, we needed to handle the adding and removing of containers. We attached that to the model property for opening and closing the dialog (open). When it switches to true, the dialog is shown and its overlay container is added and made the current ‘active’ container. When it switches to false, the dialog is closed and the top container element is removed from the stack. This is shown in the following code.

@Component({
  selector: "qupaya-dialog",
  standalone: true,
  templateUrl: "./dialog.component.html",
  styleUrl: "./dialog.component.css",
})
export class DialogComponent {
  private readonly overlayContainer = inject(QupayaOverlayContainer);

  private readonly dialogOverlayContainerRef = viewChild.required<
    ElementRef<HTMLDivElement>
  >("dialogOverlayContainer");

  private readonly dialogElement =
    viewChild.required<ElementRef<HTMLDialogElement>>("dialogElement");

  readonly open = model(false);

  readonly close = output<void>();

  protected readonly openEvents = effect(() => {
    const dialog = untracked(this.dialogElement);
    const containerRef = untracked(this.dialogOverlayContainerRef);

    if (this.open()) {
      dialog.nativeElement.showModal();
      this.overlayContainer.addContainer(containerRef.nativeElement);
    } else {
      dialog.nativeElement.close();
      this.overlayContainer.removeContainer();
    }
  });

  handleClose(): void {
    this.close.emit();
    this.open.set(false);
  }
}

Potential issues

The shown solution covers a good amount of use cases. Things it doesn’t cover include

  • What if there is something like a notification that pops up and should be shown under the dialog. All overlays that are added while the dialog is open will be shown in the dialog and will disappear when the dialog closes.
  • We have an implementation for notifications that always uses the same overlay. It starts with the first notification and keeps the overlay open. When a dialog opens that overlay stays in the initial overlay container, so it is displayed under the dialog and it’s backdrop. Things like that could become an issue as well.

Issues like the ones mentioned here can be handled by different solutions, p.ex. moving the existing overlay container into the dialog, as mentioned in the GitHub issue comments. We used our solution to ensure that overlays opened on the page would stay under the dialog.

Summary

We learned a solution for how to use Angular CDK overlays in combination with the native dialog element. It works on the principle of overriding the CDKs OverlayContainer class and creating a temporary overlay container in each dialog. You may encounter potential issues, as described in the last section.

We hope this article will help you if you want or need to use Angular CDK overlays with native dialogs.