<bpdm/ui />
Overlay

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.

app.tsx
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:

dialog-setup.ts
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.

edit-project.tsx
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>;
}
edit-project.ts
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.' });
  }
}
edit-project.html
<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.

stacked.tsx
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>;
}
stacked.ts
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);
}
stacked.html
<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 no title, so a screen reader still announces the dialog. Default "Dialog". When you provide a title, this is never shown.

Set them once for the whole app — every opened dialog inherits them:

  • React — pass messages to the provider (<DialogProvider messages={{ … }}>). It's a Partial<DialogMessages> merged over the English defaults, so override one key or both.
  • Angular — register provideBpdmDynamicDialogMessages({ … }) in your app providers (it configures the BPDM_DYNAMIC_DIALOG_MESSAGES injection 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' }).

dynamic-dialog-i18n.tsx
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>;
}
main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideBpdmDynamicDialogMessages } from '@bpdm/ng';
import { App } from './app';

bootstrapApplication(App, {
  providers: [
    provideBpdmDynamicDialogMessages({ close: 'Schließen', dialogLabel: 'Dialog' }),
  ],
});
projekt-bearbeiten.ts
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.' });
  }
}
projekt-bearbeiten.html
<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.

OptionTypeDefaultDescription
titleReactNode / stringHeading.
descriptionReactNode / stringSupporting text.
sizesm | md | lg | xlmdPanel width.
footerReactNode / TemplateRefFooter 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 messages prop on DialogProvider (Partial<DialogMessages>), applied to every dialog opened through it.
  • AngularprovideBpdmDynamicDialogMessages(Partial<DialogMessages>) in the app providers, backed by the BPDM_DYNAMIC_DIALOG_MESSAGES injection token.
DialogMessages keyDefaultDescription
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 title names the dialog via aria-labelledby and the description, when set, is linked via aria-describedby. Provide a title for every dialog; when you omit one, a visually-hidden fallback (messages.dialogLabel, default "Dialog") keeps it named.
  • Stacking: only the topmost dialog is dismissable — Esc closes 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; under dir="rtl" it mirrors to the inline-start (top-left) corner.

On this page