ServiceDirectivesCDK Dialog

Dialog

Modal dialogs via a service that wraps @angular/cdk/dialog. Open any component as a dialog and read its close result as a Promise.

Installation

// The component that OPENS a dialog injects the service.
import { WrDialog } from 'ngwr/dialog';

@Component({...})
export class MyComponent {
  private readonly dialog = inject(WrDialog);
}

// The component OPENED as a dialog imports the layout directives it uses.
// Selector -> class: [wrDialogTitle] -> WrDialogTitle, and so on.
import { WrDialogClose, WrDialogContent, WrDialogFooter, WrDialogTitle } from 'ngwr/dialog';

@Component({
  imports: [WrDialogTitle, WrDialogContent, WrDialogFooter, WrDialogClose],
  templateUrl: './confirm.html',
})
export class ConfirmComponent {}

Open a dialog

const ref = dialog.open(ConfirmComponent, {
  data: { title: 'Delete', message: 'Are you sure?' },
  width: '24rem',
});

const ok = await ref.awaitClose(); // result from <wr-btn wrDialogClose value>
// Inside the opened component. Every attribute below is a directive:
// imports: [WrDialogTitle, WrDialogContent, WrDialogFooter, WrDialogClose]
<h2 wrDialogTitle>Delete</h2>
<div wrDialogContent>Are you sure?</div>
<div wrDialogFooter>
  <wr-btn wrDialogClose>Cancel</wr-btn>
  <wr-btn color="danger" [wrDialogClose]="true">Delete</wr-btn>
</div>
Open confirm dialog Last result: —
dialog.open(ConfirmComponent, { data, width: '24rem' })

Closing from the content

[wrDialogClose] covers close-on-click. When the dialog has to dismiss itself — after a save resolves, or when a store signal flips — inject its WrDialogRef and call close(result).

// Inside the opened component — close without a click.
import { WR_DIALOG_DATA, WrDialogRef } from 'ngwr/dialog';

@Component({...})
export class EditUserComponent {
  private readonly ref = inject(WrDialogRef);
  protected readonly data = inject<EditUserData>(WR_DIALOG_DATA);

  save(): void {
    this.store.saveUser(this.form.value);
    this.ref.close(true);            // the caller's awaitClose() resolves
  }
}

// The class token already types the generics — no cast, no WR_DIALOG_REF needed:
private readonly ref = inject<WrDialogRef<EditUserComponent, boolean>>(WR_DIALOG_REF);

Lifetime — the dialog outlives its opener

WrDialog is root-provided, so a dialog is not tied to the component that opened it: destroy that component and the panel, its backdrop and the cdk-global-scrollblock stay on the page. Navigation is the one case handled for you — closeOnNavigation is on by default. Everything else (an @if that removes the opener, a row dropped from a list) is the caller's job.

// A dialog outlives the component that opened it. `WrDialog` is root-provided
// and the panel's injector hangs off the root environment injector, so an
// `@if` that removes the opener leaves the panel, its backdrop and the
// `cdk-global-scrollblock` on the page. (Navigation is the one exception:
// `closeOnNavigation` is on by default.) Close the ref when you go away.
import { DestroyRef, inject } from '@angular/core';

@Component({...})
export class MyComponent {
  private readonly dialog = inject(WrDialog);
  private readonly destroyRef = inject(DestroyRef);

  async confirmDelete(): Promise<void> {
    const ref = this.dialog.open<ConfirmComponent, boolean>(ConfirmComponent);
    const stop = this.destroyRef.onDestroy(() => ref.close());

    // awaitClose() is a Promise, so takeUntilDestroyed() does not apply to it —
    // that operator takes an Observable. Two ways to not act on a stale result:
    const ok = await ref.awaitClose();
    stop();                                  // nothing left to cancel
    if (ok) this.store.remove();
  }
}

// Or subscribe instead of awaiting, and takeUntilDestroyed() does apply —
// `ref.closed` is an Observable, and the ref is the same one either way.
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

ref.closed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(ok => { … });

The close button

Dialogs render a dismiss (×) in the top-right corner by default, on top of closing via backdrop click, Escape, and any element carrying [wrDialogClose]. Opt out per dialog with closable: false. <wr-drawer> and WrDrawerManager take the same closable / closeLabel pair.

// The × comes for free — nothing to add. It sits in the panel's top-right
// corner, is labelled from the `dialog.close` i18n key, and the title row
// reserves the gutter so a long heading wraps instead of running under it.
dialog.open(EditUserComponent);

// Turn it off when the content already owns its dismiss, or when the dialog
// must be resolved through its own actions:
dialog.open(EditUserComponent, { closable: false });

// Override just the accessible name:
dialog.open(EditUserComponent, { closeLabel: 'Discard changes' });

Where focus goes, and where it comes back

On open, focus moves into the panel: to whatever carries cdkFocusInitial if anything does, and otherwise to the first tabbable element in DOM order. The built-in ✕ is appended after your content, so it is the LAST tabbable element rather than the first — a dialog whose content starts with a text field opens with the caret in that field. A panel with nothing tabbable in it takes no focus at all; Escape still closes it, because the key is handled by the overlay rather than by whatever is focused.

<!-- Inside the opened component. Nothing to import: the focus trap looks
     for the attribute by name, so a bare `cdkFocusInitial` is enough. -->
<h2 wrDialogTitle>Rename project</h2>
<div wrDialogContent>
  <wr-form-field label="Name">
    <input wrInput cdkFocusInitial [(value)]="name" />
  </wr-form-field>
</div>
<div wrDialogFooter>
  <wr-btn wrDialogClose>Cancel</wr-btn>
  <wr-btn color="primary" [wrDialogClose]="name()">Save</wr-btn>
</div>

<!-- Without `cdkFocusInitial` the first tabbable element wins — here the same
     input, since the ✕ is appended AFTER your content and comes last. A panel
     of plain text focuses nothing; Escape still closes it. -->

On close, focus returns to the element that was active when open() was called — captured then, restored after the panel is disposed. All four dismissal paths go through the same WrDialogRef.close(), so Escape, the ✕, a [wrDialogClose] button and a backdrop click all restore it. The one case it cannot cover is a trigger that no longer exists: if the button that opened the dialog has been removed from the DOM meanwhile, focus lands on <body> and the next Tab starts from the top of the document. Move focus yourself after awaitClose() when the dialog's own result removes its trigger.

Focus is trapped inside the panel while it is open, and the panel announces itself with role="dialog" plus aria-modal="true". The rest of the page is not marked inert and carries no aria-hidden — the modality is the focus trap and the aria-modal claim, which is what screen readers act on, and the backdrop, which takes every pointer event aimed at the page. What is not covered is everything that ignores both — find-in-page, and a reading mode that walks the DOM rather than the accessibility tree. If your app needs the harder guarantee, set inert on your own root element for as long as the dialog is open; the library does not, because choosing which subtree to freeze is not something it can know.

Overlays are in the top layer, so z-index cannot reach them

CDK 22 promotes every overlay by calling showPopover() on its host, which puts the panel and its backdrop in the browser's top layer. The top layer is ordered by the moment of promotion and sits above the whole page — no z-index, stacking context or DOM order in your application can put anything over it. A sticky header at z-index: 100000 still renders under a dialog backdrop, and a click where it appears goes to the backdrop and closes the dialog.

// The overlay container is isolated, and that is what `provideWrOverlay()`
// promises — not a place in your z-index scale. These do nothing:
//
//   .wr-overlay-container .cdk-overlay-pane { z-index: 1100; }
//   header { z-index: 100000; }
//
// To opt the whole application out of the top layer and back into ordinary
// stacking, configure the CDK itself at bootstrap:
import { OVERLAY_DEFAULT_CONFIG } from '@angular/cdk/overlay';

bootstrapApplication(AppComponent, {
  providers: [
    { provide: OVERLAY_DEFAULT_CONFIG, useValue: { usePopover: false } },
  ],
});

// Opting out is a real trade: overlays go back to being clipped by an
// ancestor's `overflow` and to competing on z-index with everything else.

Two consequences worth knowing before you spend an afternoon on CSS. Chrome that must stay above a dialog — a cookie bar, a support widget — has to be in the top layer itself (its own popover or native <dialog>), or you turn the mechanism off as shown above and go back to ordinary stacking, at the cost of every overlay becoming clippable by an ancestor again.

And the mirror image: an ngwr overlay opened from inside a native <dialog open> shown with showModal() is drawn correctly and is unreachable by the mouse. The native dialog makes the rest of the document inert, and hit-testing stops at it — the panel is visible above it, the keyboard still works, and every click passes through to the native dialog underneath. Nothing throws. Use WrDialog rather than nesting inside a native modal.

A select or popconfirm opened inside a dialog

Panels anchored to a trigger — select, dropdown, popover, popconfirm, date-picker — carry no backdrop of their own; they close on the first click that lands outside them. Inside a dialog that click also lands on the dialog's backdrop, so one click away from an open select closes the select AND the dialog. Escape is different: it goes to the topmost overlay only, so it closes the panel and leaves the dialog up.

// A dialog that owns a form: keep the backdrop, ignore its clicks.
const ref = this.dialog.open(EditUserComponent, {
  closeOnBackdropClick: false,   // a click beside an open select cannot lose the form
  // closeOnEscape stays true — Escape closes the select first, the dialog next.
});

That is why a dialog holding a form is usually opened with closeOnBackdropClick: false — losing unsaved input to a mis-click beside a dropdown is the same accident either way, and the ✕ and Escape both remain.

Responsive (bottom-sheet)

With responsive: true — or app-wide via provideWrResponsiveOverlays() — the dialog slides up as a full-width bottom-sheet on small viewports and stays a centred modal on larger ones. Open this on a phone (or narrow the window) to see it dock to the bottom.

// Per dialog — slides up as a bottom-sheet on small screens.
dialog.open(ConfirmComponent, { responsive: true });

// Or app-wide, for every overlay:
provideWrResponsiveOverlays();          // default breakpoint 640px
provideWrResponsiveOverlays({ breakpoint: 768 });
Open responsive dialog
dialog.open(ConfirmComponent, { responsive: true })

Service API

NameDescriptionTypeDefault
open(component, options?)Opens a dialog. Returns a WrDialogRef.(component, WrDialogOptions) => WrDialogRef

WrDialogRef

What open() hands back — and what the content gets by injecting WrDialogRef. One thing it cannot tell you: a dismissal and a close(undefined) are the same event. closed emits undefined for the ✕, Escape, the backdrop, a navigation and a bare [wrDialogClose] alike, so a dialog that has to distinguish “cancelled” from “saved nothing” must close with a value of its own.

NameDescriptionTypeDefault
WrDialogRef<C, R>Returned by open(), and provided inside the dialog’s own injector. C is the opened component, R the close result.class
close(result?)Dismiss the dialog, optionally with a result. Idempotent — a second call is a no-op, so a save handler racing the ✕ cannot emit twice.(result?: R) => void
awaitClose()Resolves with the close result once the dialog is dismissed. A Promise, so takeUntilDestroyed() does not apply to it — see “Lifetime” above.() => Promise<R | undefined>
closedThe same result as an Observable — emits once, then completes. A ReplaySubject, so subscribing after the dialog has already closed still gets the value rather than a bare completion.ReplaySubject<R | undefined>
componentInstanceThe instantiated dialog component, for reading a signal on it or calling one of its methods. Throws while the dialog is still attaching — which is only reachable from the content’s own constructor.C
overlayRefThe underlying CDK OverlayRef — an escape hatch for the cases the options do not cover. Do not dispose it directly: that bypasses closed, leaves awaitClose() pending and never destroys the focus trap. Call close().OverlayRef
const ref = this.dialog.open<ConfirmComponent, 'saved' | 'discarded'>(ConfirmComponent);

// `undefined` is every dismissal: ✕, Escape, backdrop, navigation, and a bare
// [wrDialogClose]. Give the outcomes you care about their own values.
const result = await ref.awaitClose();   // 'saved' | 'discarded' | undefined
if (result === undefined) return;        // dismissed — leave the page as it was

// Or subscribe, when the caller is not an async method:
ref.closed.subscribe(result => { … });   // emits once, then completes

WrDialogOptions

NameDescriptionTypeDefault
WrDialogOptionsSecond argument of open(). Every field is optional.interface
dataPayload exposed to the content via WR_DIALOG_DATA.D
widthPanel width — any CSS length.string
maxWidthPanel maximum width.string
closeOnBackdropClickClose when the backdrop is clicked.booleantrue
closeOnEscapeClose on Escape.booleantrue
closeOnNavigationClose as soon as the URL changes — Back and router.navigate() alike. Turn it off only for a dialog that owns the navigation.booleantrue
closableShow the built-in dismiss (×) in the top-right corner.booleantrue
closeLabelAccessible name for the dismiss button. Falls back to the dialog.close catalog key.string
responsivePresent as a bottom-sheet on small viewports. Undefined follows provideWrResponsiveOverlays().boolean
panelClassExtra class(es) on the panel.string | readonly string[]

Layout directives

NameDescriptionTypeDefault
[wrDialogTitle]Styles the title row, and supplies the panel’s aria-labelledby. Import WrDialogTitle.directive
[wrDialogContent]Styles the scrollable body. Import WrDialogContent.directive
[wrDialogFooter]Styles the footer. Import WrDialogFooter.directive
alignFooter alignment.'start' | 'center' | 'end''end'
[wrDialogClose]Closes the dialog when clicked. Import WrDialogClose.directive
wrDialogCloseValue passed to close(). Bare attribute closes with undefined.R | undefinedundefined

Available inside the dialog

NameDescriptionTypeDefault
WR_DIALOG_DATAThe data payload passed to open(). undefined when you didn't pass any.InjectionToken<D>
WrDialogRefThe open dialog’s own ref — call close(result) to dismiss it from the content.WrDialogRef<unknown, unknown>
WR_DIALOG_REFThe same ref under a second key, used by [wrDialogClose]. Prefer inject(WrDialogRef) — it already supports { optional: true } and typed generics.InjectionToken<WrDialogRef<C, R>>

CSS variables

Custom properties ngwr/dialog publishes. Each default below is declared on the component's own selector, so a :root override is shadowed by it — set them on that selector, on a wrapper you scope yourself, or inline on the element. Unlike the BEM class names, these are the supported way to restyle the component.

VariableDefaultDeclared on
--wr-dialog-bgvar(--wr-color-surface).wr-dialog-panel
--wr-dialog-min-width18rem.wr-dialog-panel
--wr-dialog-padding-x1.25rem.wr-dialog-panel
--wr-dialog-padding-y1rem.wr-dialog-panel
--wr-dialog-radiusvar(--wr-border-radius-lg).wr-dialog-panel
--wr-dialog-shadowvar(--wr-shadow-modal).wr-dialog-panel