An open API service indexing awesome lists of open source software.

https://github.com/kazepis/ngx-modalieur

Bootstrap-styled modals over Angular CDK Dialog. Open a modal, subscribe to the result — no modal IDs or global result buses.
https://github.com/kazepis/ngx-modalieur

Last synced: 7 days ago
JSON representation

Bootstrap-styled modals over Angular CDK Dialog. Open a modal, subscribe to the result — no modal IDs or global result buses.

Awesome Lists containing this project

README

          

# ngx-modalieur

**Reactive Bootstrap modals for Angular — a thin, typed layer on [CDK Dialog](https://material.angular.dev/cdk/dialog/overview).**

[![npm version](https://img.shields.io/npm/v/ngx-modalieur)](https://www.npmjs.com/package/ngx-modalieur)
[![npm downloads](https://img.shields.io/npm/dw/ngx-modalieur)](https://www.npmjs.com/package/ngx-modalieur)
![Angular](https://img.shields.io/badge/Angular-22-red)
![License](https://img.shields.io/badge/License-MIT-blue)
[![Live demo](https://img.shields.io/badge/demo-live-brightgreen)](https://kazepis.github.io/ngx-modalieur/)

```bash
npm install ngx-modalieur @angular/cdk bootstrap
```

> **Open a modal, `subscribe` to the result.** No modal ids, no global result bus, no boilerplate.

**[Try the live demo](https://kazepis.github.io/ngx-modalieur/)** — interactive examples and a config playground in your browser.

## Table of contents

- [What is this?](#what-is-this)
- [Why use it?](#why-use-it)
- [When to use something else](#when-to-use-something-else)
- [Requirements](#requirements)
- [Setup](#setup)
- [Quick start (60 seconds)](#quick-start-60-seconds)
- [Core concepts](#core-concepts)
- [Two layers](#two-layers)
- [Result flow](#result-flow)
- [Config layering](#config-layering)
- [Usage guide](#usage-guide)
- [Message boxes](#message-boxes)
- [Custom modal components](#custom-modal-components)
- [Typing modals](#typing-modals)
- [Passing data in and out](#passing-data-in-and-out)
- [Configuration](#configuration)
- [Reactive patterns](#reactive-patterns)
- [Auto-close with observables](#auto-close-with-observables)
- [Programmatic control](#programmatic-control)
- [Custom layouts](#custom-layouts)
- [Lazy loading](#lazy-loading)
- [API reference](#api-reference)
- [Testing](#testing)
- [License](#license)

## What is this?

ngx-modalieur wraps **Angular CDK `Dialog`** with a **Bootstrap 5.3** shell, a standardized **`ModalOutcome`** result model, and convenience APIs for confirm / alert dialogs.

It is **not** a replacement for CDK Dialog. Focus trapping, overlay positioning, backdrop, and Escape handling still come from CDK. This library adds the Bootstrap markup, bridging CSS, reactive close semantics, and message-box shortcuts so you do not rebuild that glue in every app.

New to Angular modals? Start with [Quick start](#quick-start-60-seconds) — you will have a working confirm dialog in under a minute.

## Why use it?

| | Raw CDK Dialog | ngx-modalieur |
| --------------------- | ----------------------------------------- | ------------------------------------------------- |
| Styling | Bring your own container and CSS | Bootstrap `.modal-dialog` shell + bridging styles |
| Close result | `dialogRef.close(value)` — shape is yours | Standardized `{ result: ModalResult; data? }` |
| Message boxes | Build yourself | `confirm()`, `alert()`, `messageBox()` |
| App-wide defaults | DIY injection token | `provideModalieur({ … })` |
| Auto-close on streams | Wire `takeUntil` + `close()` yourself | `showUntil()` / `showUntilCondition()` |

**Subscribe, don't wire.** `show()` returns `Observable`. Open a modal, react in one `subscribe` or `pipe` — no modal ids, no global result bus, no `setResult(id, …)` from inside the component.

**Bootstrap without glue.** `ModalieurService` automatically applies `BootstrapDialogContainer`, which wraps your component in `.modal > .modal-dialog > .modal-content`. You only render header, body, and footer.

**Built-in confirm / alert.** One-liners for the dialogs every app reimplements.

**Observable-driven auto-close.** Keep a modal open until a timer fires, a hub event arrives, or an async job completes — with a dedicated `ModalResult.AutoClose`.

## When to use something else

- **Angular Material apps** — use [`MatDialog`](https://material.angular.dev/components/dialog/overview); it is integrated with Material theming.
- **Non-Bootstrap design systems** — use CDK Dialog directly with your own container.
- **A single one-off overlay** — CDK alone is enough; this library shines when modals are a recurring pattern.
- **Angular < 22** — not supported. Peer dependencies require `@angular/core`, `@angular/common`, and `@angular/cdk` `^22.0.0`.

## Requirements

| Package | Version | Required |
| ---------------------------------- | --------- | ------------------------------------------------ |
| `@angular/core`, `@angular/common` | `^22.0.0` | Yes |
| `@angular/cdk` | `^22.0.0` | Yes |
| `bootstrap` | `^5.3.0` | Optional (needed for the default Bootstrap look) |

## Setup

**1. Install**

```bash
npm install ngx-modalieur @angular/cdk bootstrap
```

**2. Add global styles** (e.g. in `angular.json` → `projects.[app].architect.build.options.styles`):

```json
"node_modules/bootstrap/dist/css/bootstrap.min.css",
"node_modules/@angular/cdk/overlay-prebuilt.css",
"node_modules/ngx-modalieur/styles/ngx-modalieur.css"
```

The library stylesheet bridges CDK overlay behavior with Bootstrap modal appearance (backdrop darkness, scrollable body layout, enter animation).

**3. Register app-wide defaults** (optional but recommended):

```ts
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideModalieur } from 'ngx-modalieur';

export const appConfig: ApplicationConfig = {
providers: [
// Override only what you need; unset fields keep MODALIEUR_DEFAULTS.
provideModalieur({ dismissible: false, size: 'lg' })
]
};
```

Calling `provideModalieur()` with no arguments registers built-in defaults. Omitting `provideModalieur()` entirely also works — the service falls back to `MODALIEUR_DEFAULTS` internally.

## Quick start (60 seconds)

The fastest possible modal — no custom component needed:

```ts
import { inject } from '@angular/core';
import { ModalieurService, ModalResult } from 'ngx-modalieur';

export class MyComponent {
private readonly modalieur = inject(ModalieurService);

deleteItem(): void {
this.modalieur.confirm('Delete item?', 'This cannot be undone.').subscribe(result => {
if (result === ModalResult.Yes) {
// user confirmed — do the work
}
});
}
}
```

Need your own content? Define a modal component by extending `ModalContent`, render the Bootstrap inner sections, and close via the built-in helpers:

```ts
import { Component } from '@angular/core';
import { ModalContent } from 'ngx-modalieur';

@Component({
template: `




`
})
export class ConfirmModalComponent extends ModalContent<{ title: string; message: string }, never> {}
```

Then open it and react to the outcome:

```ts
this.modalieur
.show(ConfirmModalComponent, {
data: { title: 'Confirm', message: 'Are you sure?' }
})
.subscribe(outcome => {
if (outcome.result === ModalResult.Yes) {
// user clicked Yes
}
});
```

Input is available inside the modal as `this.data` (typed from the first generic). `config.data` is **required** at the call site when the modal declares input.

## Core concepts

### Two layers

```mermaid
flowchart LR
Caller[Caller subscribes to show]
Service[ModalieurService]
Shell[BootstrapDialogContainer]
Content[Your ModalContent component]
Caller -->|show| Service
Service --> Shell
Shell --> Content
Content -->|"yes / cancel / respondWithData"| Outcome[ModalOutcome emitted once]
Outcome --> Caller
```

1. **Your component** (`extends ModalContent`) — renders `.modal-header`, `.modal-body`, `.modal-footer` and closes via `yes()`, `cancel()`, `respondWithData()`, etc.
2. **Dialog shell** (`BootstrapDialogContainer`) — applied automatically unless `unstyled: true`. Wraps your component in Bootstrap's outer modal markup.

You do **not** extend `BootstrapDialogContainer` for normal modals. It is exported for advanced CDK container customization only. For fully custom layouts (viewport-filling overlays with your own CSS), pass `unstyled: true` and style the component yourself.

### Result flow

Every close produces a **`ModalOutcome`**:

```ts
interface ModalOutcome {
result: ModalResult;
data?: TData;
}
```

The observable emits **once**, then completes. Backdrop click and Escape map to `ModalResult.Cancel` when `dismissible` is `true`.

When you call `show(MyModal, …)`, `T` is inferred from the component's `ModalContent` declaration (`TDataOut`). `confirm()`, `alert()`, and `messageBox()` unwrap this to `Observable` for convenience.

### Config layering

Per-call config is merged in this order (later wins):

```
MODALIEUR_DEFAULTS → provideModalieur(...) → per-call config
```

Built-in defaults (`MODALIEUR_DEFAULTS`):

| Option | Default |
| ------------- | ------- |
| `backdrop` | `true` |
| `centered` | `true` |
| `dismissible` | `true` |
| `scrollable` | `false` |
| `unstyled` | `false` |

## Usage guide

### Message boxes

All message-box APIs open the same built-in component (`MessageBoxDialog`). Pick the API by what you need back and how much wiring you want the library to do:

| API | Prefer when | `subscribe` receives | A11y (`aria-labelledby` / `aria-describedby`) |
| --------------------------- | ------------------------------------------------------------------ | -------------------- | --------------------------------------------- |
| `confirm()` / `alert()` | Yes/No or OK only | `ModalResult` | Auto-wired |
| `messageBox({ … })` | Custom button set | `ModalResult` | Auto-wired |
| `show(MessageBoxDialog, …)` | You want `ModalOutcome`, or full control over `ModalConfig` / aria | `ModalOutcome` | **You** must set aria (see below) |

#### Shorthand (recommended for most cases)

`confirm()`, `alert()`, and `messageBox()` are thin wrappers around `show(MessageBoxDialog, …)`. They unwrap the result to `ModalResult` and point the dialog at the title and body element ids for screen readers.

```ts
this.modalieur.confirm('Delete item?', 'This cannot be undone.').subscribe(result => {
if (result === ModalResult.Yes) {
this.deleteItem();
}
});

this.modalieur.alert('Saved', 'Your changes were saved.', { size: 'sm' }).subscribe();

this.modalieur
.messageBox({
title: 'Retry?',
message: 'Could not reach the server.',
buttons: MessageBoxButtons.RetryCancel
})
.subscribe(result => {
// ModalResult.Retry | ModalResult.Cancel
});
```

`confirm()` and `alert()` do not force a size — pass `{ size: 'sm' }` (or any `ModalConfig` field) when you want a compact dialog.

Available button sets (`MessageBoxButtons` enum): `OK`, `OKCancel`, `YesNo`, `YesNoCancel`, `AbortRetryIgnore`, `RetryCancel`.

#### Low-level: `show(MessageBoxDialog, …)`

Use this when you need the full `ModalOutcome` shape (`{ result, data? }`) for consistency with other `show()` calls, or when you want to pass `ModalConfig` without going through `messageBox()`.

**Accessibility:** `messageBox()` / `confirm()` / `alert()` automatically set `ariaLabelledBy` and `ariaDescribedBy` to match the ids on the message-box title and body (`mdlr-message-box-title`, `mdlr-message-box-body`). If you call `show(MessageBoxDialog, …)` directly, pass those ids (or import the constants) so CDK Dialog can label the overlay correctly:

```ts
import { MESSAGE_BOX_BODY_ID, MESSAGE_BOX_TITLE_ID, MessageBoxDialog, MessageBoxButtons } from 'ngx-modalieur';

this.modalieur
.show(MessageBoxDialog, {
ariaLabelledBy: MESSAGE_BOX_TITLE_ID,
ariaDescribedBy: MESSAGE_BOX_BODY_ID,
data: { title: 'Delete?', message: 'Cannot be undone.', buttons: MessageBoxButtons.YesNo }
})
.subscribe(outcome => {
// outcome.result === ModalResult.Yes | ModalResult.No | ModalResult.Cancel
});
```

#### Custom markup (content projection)

```ts
@Component({
imports: [MessageBoxDialog],
template: `

Custom header

Custom body


Dismiss
Got it


`
})
export class MyMessageBox extends ModalContent {}
```

### Custom modal components

Extend `ModalContent` and use the protected close helpers:

| Method | `ModalResult` |
| ----------------------- | ------------- |
| `yes(data?)` | `Yes` |
| `no(data?)` | `No` |
| `ok(data?)` | `Ok` |
| `cancel(data?)` | `Cancel` |
| `abort(data?)` | `Abort` |
| `retry(data?)` | `Retry` |
| `ignore(data?)` | `Ignore` |
| `respondWithData(data)` | `Data` |
| `close(result?, data?)` | any |

The modal component does not inject a global modal service. Closing the dialog **is** emitting the result.

### Typing modals

Every modal extends `ModalContent`. The component is the single source of truth — `show()` infers input and output types from it.

| Shape | Declaration | `config.data` | `outcome.data` |
| ------------------- | ------------------------- | ------------- | -------------------------- |
| No input, no output | `ModalContent` (defaults) | optional | never (result only) |
| Input only | `ModalContent` | **required** | never |
| Output only | `ModalContent` | optional | typed (optional to return) |
| Both | `ModalContent` | **required** | typed (optional to return) |

- **`void`** — no meaningful input; `this.data` exists but is unusable.
- **`never`** — cannot return output data; `respondWithData` is uncallable.
- **A concrete `TDataOut`** — returning data is **optional**: `close()` / `yes()` accept `data?`, so the same modal can close with or without a payload. `respondWithData` is the explicit always-with-data path.

Inside the modal, input is available as **`this.data`** (injected by the base class). Do not inject `MODAL_DATA` manually. `ModalDataIn` and `ModalDataOut` extract types from a component class for advanced/generic callers.

### Passing data in and out

- **Input** — declare `TDataIn` on `ModalContent`; pass via `config.data` (required when `TDataIn` is not `void`); read as `this.data` inside the modal.
- **Output** — declare `TDataOut` on `ModalContent`; returned as `outcome.data` when a close helper includes a payload.

```ts
class EditModal extends ModalContent<{ id: number }, { saved: boolean }> {
protected save = () => this.respondWithData({ saved: true });
}

this.modalieur.show(EditModal, { data: { id: 7 } }).subscribe(outcome => {
if (outcome.result === ModalResult.Data && outcome.data) {
console.log(outcome.data.saved); // true after save()
}
});
```

### Configuration

All options live on `ModalConfig` and can be set app-wide (`provideModalieur`) or per call:

| Option | Description | Default |
| ----------------- | --------------------------------------------------------------------------- | ------------------------------------------- |
| `data` | Injected as `this.data`; required at call site when `TDataIn` is not `void` | — |
| `size` | `'sm' \| 'md' \| 'lg' \| 'xl' \| 'fullscreen'` | Bootstrap medium (`md` adds no extra class) |
| `centered` | `.modal-dialog-centered` | `true` |
| `scrollable` | `.modal-dialog-scrollable` | `false` |
| `dismissible` | Backdrop click / Escape closes → `Cancel` | `true` |
| `backdrop` | Render CDK backdrop | `true` |
| `unstyled` | Skip Bootstrap shell; component owns layout | `false` |
| `ariaLabel` | CDK `ariaLabel` | — |
| `ariaLabelledBy` | CDK `ariaLabelledBy` | — |
| `ariaDescribedBy` | CDK `ariaDescribedBy` | — |

Try every combination live in the [playground](https://kazepis.github.io/ngx-modalieur/#playground).

Non-dismissible modals with no backdrop (common in kiosk / operator UIs):

```ts
provideModalieur({ dismissible: false, backdrop: false, centered: true });
```

Scrollable body with long content:

```ts
this.modalieur.show(ConfirmModalComponent, {
scrollable: true,
data: { title: 'Terms', message: longText }
});
```

### Reactive patterns

Opening a modal returns an `Observable` — compose with the rest of your RxJS pipelines:

```ts
import { filter, switchMap } from 'rxjs/operators';

this.modalieur
.confirm('Delete item?', 'This cannot be undone.')
.pipe(
filter(result => result === ModalResult.Yes),
switchMap(() => this.api.deleteItem(id))
)
.subscribe();
```

**Compared to imperative modal stacks** many codebases inherit:

```ts
// Before: ref + global bus + setResult inside the component
const ref = modalService.showAndReturnRef(MyModal);
modalService.getModalResult(ref).subscribe(/* ... */);
// inside modal: modalService.setResult(ref.id, ResultType.Yes);

// After: one subscribe, close helpers inside the component
this.modalieur.show(MyModal, { data }).subscribe(outcome => {
if (outcome.result === ModalResult.Yes) this.save();
});
```

| API | Emits | When |
| ---------------------------------------- | ------------------------------- | ---------------------------------------- |
| `show(…)` | `ModalOutcome` | User closes or dismisses |
| `confirm()` / `alert()` / `messageBox()` | `ModalResult` | Button click or dismiss |
| `showUntil(…)` / `showUntilCondition(…)` | `ModalOutcome` with `AutoClose` | User action **or** observable fires |
| `showAndReturnRef(…).closed$` | `ModalOutcome` | Same as `show`, plus you hold `ModalRef` |

### Auto-close with observables

Keep a modal open until an external signal fires, then close with `ModalResult.AutoClose`.

| | `showUntil` | `showUntilCondition` |
| ------------------ | -------------------------- | ---------------------------------------------- |
| Closes when | First emission (any value) | First **truthy** emission |
| `false`, `0`, `''` | **Closes** | Ignored — modal stays open |
| Typical use | Timers, one-shot events | Readiness signals (`loaded$`, `saveComplete$`) |

```ts
import { timer } from 'rxjs';
import { filter, map, take } from 'rxjs/operators';

// Close after 4 seconds regardless of emission value
this.modalieur
.showUntil(WaitingModalComponent, timer(4000).pipe(map(() => false)), {
data: { title: 'Loading…', message: 'Please wait.' }
})
.subscribe(outcome => {
// outcome.result === ModalResult.AutoClose
});

// Close when status becomes 'done'
const ready$ = this.pollStatus().pipe(
map(s => s === 'done'),
filter(Boolean),
take(1)
);
this.modalieur
.showUntilCondition(WaitingModalComponent, ready$, {
data: { title: 'Loading…', message: 'Please wait.' }
})
.subscribe();
```

The user can still close early via buttons or dismissal — `AutoClose` only applies when the observable triggers the close.

### Programmatic control

When you need to close from outside the component (e.g. after an async save), use `showAndReturnRef`. The modal must declare an output type if you pass a payload to `close()`; returning data is optional — omit the second argument when you only need the result.

```ts
// SpinnerModal extends ModalContent
const ref = this.modalieur.showAndReturnRef(SpinnerModal, { dismissible: false });

ref.closed$.subscribe(outcome => this.onSaveComplete(outcome));

await this.save();
ref.close(ModalResult.Ok, { savedId: 42 }); // payload optional when TDataOut is concrete
```

`ModalRef` is also injectable inside the modal component (via `ModalContent`'s internal wiring).

### Custom layouts

**Bootstrap fullscreen** — uses the built-in shell:

```ts
this.modalieur.show(ConfirmModalComponent, { size: 'fullscreen', data });
```

**Fully custom overlay** — skip the Bootstrap shell:

```ts
this.modalieur.show(MyOverlayComponent, { unstyled: true, data });
```

Your component owns the entire layout (positioning, z-index, animations). CDK still provides overlay, focus trap, and backdrop.

### Lazy loading

`show()` accepts a component **class** (`Type`), not a route-style `loadComponent` loader. To lazy-load a modal, dynamically import it first, then pass the resolved class to `show()`.

**Async/await:**

```ts
async openLazyModal(): Promise {
const { LazyLoadModalComponent } = await import('./modals/lazy-load-modal.component');

this.modalieur
.show(LazyLoadModalComponent)
.subscribe((outcome) => {
// outcome.result: ModalResult
});
}
```

**RxJS:**

```ts
import { from, switchMap } from 'rxjs';

from(import('./modals/lazy-load-modal.component'))
.pipe(switchMap(({ LazyLoadModalComponent }) => this.modalieur.show(LazyLoadModalComponent)))
.subscribe(outcome => {
// outcome.result: ModalResult
});
```

**Avoid eager imports.** A top-level `import { LazyLoadModalComponent }` in code loaded at startup pulls the modal into the initial bundle. Use dynamic `import()` only where you open the modal.

For simple yes/no dialogs, `confirm()` and `messageBox()` avoid a custom component entirely — no separate chunk needed.

## API reference

### Public exports

Everything in [`public-api.ts`](https://github.com/kazepis/ngx-modalieur/blob/HEAD/projects/ngx-modalieur/src/public-api.ts) is part of the stable API: `ModalieurService`, `ModalContent`, `ModalRef`, `ModalConfig`, `ModalOutcome`, `ModalResult`, `ModalSize`, `ModalDataIn`, `ModalDataOut`, `MODAL_DATA`, `MODALIEUR_CONFIG`, `MODALIEUR_DEFAULTS`, `provideModalieur`, `MessageBoxDialog`, `MessageBoxButtons`, `MessageBoxOptions`, `MESSAGE_BOX_TITLE_ID`, `MESSAGE_BOX_BODY_ID`, `BootstrapDialogContainer`.

### `ModalieurService`

| Method | Returns | Description |
| ---------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------- |
| `show(component, config?)` | `Observable>` | Opens a modal; emits when it closes. `config` required (with `data`) when component has input. |
| `showUntil(component, until$, config?)` | `Observable>` | Auto-closes on first `until$` emission → `AutoClose`. |
| `showUntilCondition(component, condition$, config?)` | `Observable>` | Auto-closes on first truthy emission → `AutoClose`. |
| `showAndReturnRef(component, config?)` | `ModalRef` | Opens a modal; returns a ref for programmatic control. |
| `messageBox(options, config?)` | `Observable` | Config-driven `MessageBoxDialog`. |
| `confirm(title, message?, config?)` | `Observable` | Yes / No message box. |
| `alert(title, message?, config?)` | `Observable` | Single OK message box. |

### `ModalRef`

| Member | Description |
| ----------------------- | ----------------------------------------------------------- |
| `id` | CDK dialog id |
| `closed$` | `Observable>` — emits when the modal closes |
| `close(result?, data?)` | Programmatic close; default result is `Undefined` |

### `ModalResult`

| Value | When |
| --------------------------- | --------------------------------------------------- |
| `Undefined` | Programmatic `close()` with no result |
| `Data` | `respondWithData()` |
| `Yes`, `No`, `Ok`, `Cancel` | Button helpers |
| `Abort`, `Retry`, `Ignore` | Message-box helpers |
| `AutoClose` | `showUntil` / `showUntilCondition` auto-close |
| `Cancel` | User dismissal (backdrop / Escape) when dismissible |

## Testing

Provide a fake CDK `Dialog` and assert on `closed` emissions. See [`modalieur.service.spec.ts`](https://github.com/kazepis/ngx-modalieur/blob/HEAD/projects/ngx-modalieur/src/lib/modalieur.service.spec.ts) for the full pattern:

```ts
import { Dialog } from '@angular/cdk/dialog';
import { TestBed } from '@angular/core/testing';
import { ModalieurService } from 'ngx-modalieur';

TestBed.configureTestingModule({
providers: [ModalieurService, { provide: Dialog, useValue: fakeDialog }]
});
```

## License

MIT