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>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 });dialog.open(ConfirmComponent, { responsive: true })Service API
| Name | Description | Type | Default |
|---|---|---|---|
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.
| Name | Description | Type | Default |
|---|---|---|---|
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> | — |
closed | The 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> | — |
componentInstance | The 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 | — |
overlayRef | The 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 completesWrDialogOptions
| Name | Description | Type | Default |
|---|---|---|---|
WrDialogOptions | Second argument of open(). Every field is optional. | interface | — |
data | Payload exposed to the content via WR_DIALOG_DATA. | D | — |
width | Panel width — any CSS length. | string | — |
maxWidth | Panel maximum width. | string | — |
closeOnBackdropClick | Close when the backdrop is clicked. | boolean | true |
closeOnEscape | Close on Escape. | boolean | true |
closeOnNavigation | Close as soon as the URL changes — Back and router.navigate() alike. Turn it off only for a dialog that owns the navigation. | boolean | true |
closable | Show the built-in dismiss (×) in the top-right corner. | boolean | true |
closeLabel | Accessible name for the dismiss button. Falls back to the dialog.close catalog key. | string | — |
responsive | Present as a bottom-sheet on small viewports. Undefined follows provideWrResponsiveOverlays(). | boolean | — |
panelClass | Extra class(es) on the panel. | string | readonly string[] | — |
Layout directives
| Name | Description | Type | Default |
|---|---|---|---|
[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 | — |
align | Footer alignment. | 'start' | 'center' | 'end' | 'end' |
[wrDialogClose] | Closes the dialog when clicked. Import WrDialogClose. | directive | — |
wrDialogClose | Value passed to close(). Bare attribute closes with undefined. | R | undefined | undefined |
Available inside the dialog
| Name | Description | Type | Default |
|---|---|---|---|
WR_DIALOG_DATA | The data payload passed to open(). undefined when you didn't pass any. | InjectionToken<D> | — |
WrDialogRef | The open dialog’s own ref — call close(result) to dismiss it from the content. | WrDialogRef<unknown, unknown> | — |
WR_DIALOG_REF | The 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.
| Variable | Default | Declared on |
|---|---|---|
--wr-dialog-bg | var(--wr-color-surface) | .wr-dialog-panel |
--wr-dialog-min-width | 18rem | .wr-dialog-panel |
--wr-dialog-padding-x | 1.25rem | .wr-dialog-panel |
--wr-dialog-padding-y | 1rem | .wr-dialog-panel |
--wr-dialog-radius | var(--wr-border-radius-lg) | .wr-dialog-panel |
--wr-dialog-shadow | var(--wr-shadow-modal) | .wr-dialog-panel |