Dynamic Dialog
Open dialogs with arbitrary content imperatively from anywhere — stackable, with a close callback, in React (@bpdm/ui) and Angular (@bpdm/ng).
The Dynamic Dialog opens a dialog with any content imperatively — no
per-dialog open state or prop drilling. Mount the provider once, then
dialog.open(content, options) from anywhere; the content receives a close
callback, and dialogs stack. It's built on the Dialog.
In Angular it's the injectable BpdmDialogService with an <ng-template>.
Setup — mount DialogProvider once near your app root (React). In Angular
there's nothing to mount: BpdmDialogService is providedIn: 'root', so just inject it.
import { DialogProvider } from '@bpdm/ui/dynamic-dialog';
export function App() {
return (
<DialogProvider>
<YourApp />
</DialogProvider>
);
}Nothing to register — BpdmDialogService is providedIn: 'root'. Just inject it
wherever you need it:
import { Component, inject } from '@angular/core';
import { BpdmDialogService } from '@bpdm/ng';
@Component({ selector: 'example', template: '' })
export class Example {
private readonly dialog = inject(BpdmDialogService);
}Usage
Pass content as a function to receive close, plus options (title,
description, size, footer). open() returns an id (React) / a ref
(Angular) you can close programmatically.
import { useDialog } from '@bpdm/ui/dynamic-dialog';
import { Button } from '@bpdm/ui/button';
import { Input } from '@bpdm/ui/input';
export function EditProjectButton() {
const dialog = useDialog();
function openEditor() {
dialog.open(
({ close }) => (
<div className="flex flex-col gap-3">
<Input defaultValue="Acme website" aria-label="Project name" />
<div className="flex justify-end gap-2">
<Button variant="secondary" appearance="ghost" onClick={close}>Cancel</Button>
<Button onClick={close}>Save</Button>
</div>
</div>
),
{ title: 'Edit project', description: 'Rename your project.' },
);
}
return <Button onClick={openEditor}>Edit project</Button>;
}import { Component, inject } from '@angular/core';
import { BpdmDialogService } from '@bpdm/ng';
import { BpdmButton, BpdmInput } from '@bpdm/ng';
import type { TemplateRef } from '@angular/core';
@Component({
selector: 'edit-project',
imports: [BpdmButton, BpdmInput],
templateUrl: './edit-project.html',
})
export class EditProject {
readonly dialog = inject(BpdmDialogService);
open(tpl: TemplateRef<unknown>) {
this.dialog.open(tpl, { title: 'Edit project', description: 'Rename your project.' });
}
}<button bpdmButton (click)="open(formTpl)">Edit project</button>
<ng-template #formTpl let-d>
<div class="flex flex-col gap-3">
<input bpdmInput value="Acme website" aria-label="Project name" />
<div class="flex justify-end gap-2">
<button bpdmButton variant="secondary" appearance="ghost" (click)="d.close()">Cancel</button>
<button bpdmButton (click)="d.close()">Save</button>
</div>
</div>
</ng-template>Stacked dialogs
Open a dialog from inside another — they stack, each with its own focus trap and close handling.
import { useDialog } from '@bpdm/ui/dynamic-dialog';
import { Button } from '@bpdm/ui/button';
export function StackedButton() {
const dialog = useDialog();
function openFirst() {
dialog.open(
({ close }) => (
<div className="flex justify-end gap-2">
<Button variant="secondary" appearance="ghost" onClick={close}>Close</Button>
<Button
onClick={() =>
dialog.open(
({ close }) => (
<div className="flex flex-col gap-3">
<p>This dialog opened on top of the first — they stack independently.</p>
<div className="flex justify-end">
<Button onClick={close}>Done</Button>
</div>
</div>
),
{ title: 'Second dialog', size: 'sm' },
)
}
>
Open second
</Button>
</div>
),
{ title: 'First dialog' },
);
}
return <Button onClick={openFirst}>Stacked dialogs</Button>;
}import { Component, inject } from '@angular/core';
import { BpdmDialogService, BpdmButton } from '@bpdm/ng';
@Component({
selector: 'stacked',
imports: [BpdmButton],
templateUrl: './stacked.html',
})
export class Stacked {
readonly dialog = inject(BpdmDialogService);
}<button bpdmButton (click)="dialog.open(firstTpl, { title: 'First dialog' })">Stacked dialogs</button>
<ng-template #firstTpl let-d>
<div class="flex justify-end gap-2">
<button bpdmButton variant="secondary" appearance="ghost" (click)="d.close()">Close</button>
<button bpdmButton (click)="dialog.open(secondTpl, { title: 'Second dialog', size: 'sm' })">Open second</button>
</div>
</ng-template>
<ng-template #secondTpl let-d>
<div class="flex flex-col gap-3">
<p>This dialog opened on top of the first — they stack independently.</p>
<div class="flex justify-end">
<button bpdmButton (click)="d.close()">Done</button>
</div>
</div>
</ng-template>Internationalization
Your dialogs carry almost no copy of their own — the title, description,
content and footer are entirely your data, so you translate them at the call
site (open(content, { title })). That leaves just two accessibility strings,
shared with the Dialog it's built on:
close— the close button's accessible name (aria-label). Default"Close".dialogLabel— the visually-hidden fallback title, used only when a call passes notitle, so a screen reader still announces the dialog. Default"Dialog". When you provide atitle, this is never shown.
Set them once for the whole app — every opened dialog inherits them:
- React — pass
messagesto the provider (<DialogProvider messages={{ … }}>). It's aPartial<DialogMessages>merged over the English defaults, so override one key or both. - Angular — register
provideBpdmDynamicDialogMessages({ … })in your app providers (it configures theBPDM_DYNAMIC_DIALOG_MESSAGESinjection token).
Being built on the Dialog, it mirrors under dir="rtl" with no extra prop — the
close button flips to the inline-start (top-left) corner.
The example below is German (de-DE) — localised app-wide messages plus a
translated per-call open(..., { title: 'Projekt bearbeiten' }).
import { DialogProvider, useDialog } from '@bpdm/ui/dynamic-dialog';
import { Button } from '@bpdm/ui/button';
import { Input } from '@bpdm/ui/input';
// Set the a11y strings once, near the root — every opened dialog inherits them.
export function App() {
return (
<DialogProvider messages={{ close: 'Schließen', dialogLabel: 'Dialog' }}>
<ProjektBearbeiten />
</DialogProvider>
);
}
function ProjektBearbeiten() {
const dialog = useDialog();
function openEditor() {
dialog.open(
({ close }) => (
<div className="flex flex-col gap-3">
<Input defaultValue="Acme Website" aria-label="Projektname" />
<div className="flex justify-end gap-2">
<Button variant="secondary" appearance="ghost" onClick={close}>Abbrechen</Button>
<Button onClick={close}>Speichern</Button>
</div>
</div>
),
{ title: 'Projekt bearbeiten', description: 'Benennen Sie Ihr Projekt um.' },
);
}
return <Button onClick={openEditor}>Projekt bearbeiten</Button>;
}import { bootstrapApplication } from '@angular/platform-browser';
import { provideBpdmDynamicDialogMessages } from '@bpdm/ng';
import { App } from './app';
bootstrapApplication(App, {
providers: [
provideBpdmDynamicDialogMessages({ close: 'Schließen', dialogLabel: 'Dialog' }),
],
});import { Component, inject } from '@angular/core';
import { BpdmDialogService, BpdmButton, BpdmInput } from '@bpdm/ng';
import type { TemplateRef } from '@angular/core';
@Component({
selector: 'projekt-bearbeiten',
imports: [BpdmButton, BpdmInput],
templateUrl: './projekt-bearbeiten.html',
})
export class ProjektBearbeiten {
readonly dialog = inject(BpdmDialogService);
open(tpl: TemplateRef<unknown>) {
this.dialog.open(tpl, { title: 'Projekt bearbeiten', description: 'Benennen Sie Ihr Projekt um.' });
}
}<button bpdmButton (click)="open(formTpl)">Projekt bearbeiten</button>
<ng-template #formTpl let-d>
<div class="flex flex-col gap-3">
<input bpdmInput value="Acme Website" aria-label="Projektname" />
<div class="flex justify-end gap-2">
<button bpdmButton variant="secondary" appearance="ghost" (click)="d.close()">Abbrechen</button>
<button bpdmButton (click)="d.close()">Speichern</button>
</div>
</div>
</ng-template>API
open(content, options) → React: returns an id (close(id)); Angular: returns a
BpdmDialogRef (ref.close()). The content gets a { close } callback.
| Option | Type | Default | Description |
|---|---|---|---|
title | ReactNode / string | — | Heading. |
description | ReactNode / string | — | Supporting text. |
size | sm | md | lg | xl | md | Panel width. |
footer | ReactNode / TemplateRef | — | Footer area. |
React: DialogProvider, useDialog() → { open, close }. Angular:
BpdmDialogService.open(templateRef, options) → BpdmDialogRef; the template
receives { close } (<ng-template let-d> → d.close()).
App-wide copy — messages
Two accessibility strings are shared with the Dialog and
set once for the whole app (see Internationalization);
your per-call title / description / content always stays at the call site.
- React — the
messagesprop onDialogProvider(Partial<DialogMessages>), applied to every dialog opened through it. - Angular —
provideBpdmDynamicDialogMessages(Partial<DialogMessages>)in the app providers, backed by theBPDM_DYNAMIC_DIALOG_MESSAGESinjection token.
DialogMessages key | Default | Description |
|---|---|---|
close | "Close" | The close button's accessible name (aria-label). |
dialogLabel | "Dialog" | Visually-hidden fallback title used only when a call omits title. |
Accessibility
- Inherits the Dialog's a11y in full: each opened
dialog renders
role="dialog"+aria-modal="true", traps focus and restores it to the element focused before it opened, and locks background scroll while it's open. - Accessible name and description: the
titlenames the dialog viaaria-labelledbyand thedescription, when set, is linked viaaria-describedby. Provide atitlefor every dialog; when you omit one, a visually-hidden fallback (messages.dialogLabel, default"Dialog") keeps it named. - Stacking: only the topmost dialog is dismissable —
Esccloses it, and focus stays trapped within it; the dialogs beneath require an explicit action (a button or the close control). Outside-click dismissal is blocked for the whole stack, so closing the top never tears down the layers below. Closing the top returns focus to the one beneath. - Close button: the close button carries an accessible name
(
messages.close, default"Close") and a focus-visible ring; underdir="rtl"it mirrors to the inline-start (top-left) corner.