-
Notifications
You must be signed in to change notification settings - Fork 0
ALERTS MODULE
Headless notification and confirmation system based on signals. Four presentation formats (toast, banner, bottom-sheet, dialog), six semantic types, centralized state management. Package:
@fireflyframework/core· CORE Module #17 · Platform + Embedded
- 1. Overview
- 2. Developer Usage Guide
- 3. API Reference
- 4. Internal Technical Architecture
- 5. Advanced Usage Patterns
The alerts module addresses the universal need to notify the user and request confirmations in any application.
- Manages alert state as reactive signals (
activeToasts(),activeBanners(),activeBottomSheets(),activeDialogs()) - Supports four presentation formats:
- Toast — compact, auto-dismissible notification in the upper right corner
- Banner — full-width bar at the top of the viewport
- Bottom Sheet — modal panel anchored at the bottom with actions
- Dialog — centered modal with a result promise
- Applies six semantic types:
success,error,warning,info,destructive,custom - Manages auto-dismiss with configurable timers
- Offers convenience shortcuts (
success(),error(),warning(),info(),confirm()) - Limits the number of alerts visible simultaneously (configurable)
-
Does not render UI — it is a headless state manager. The DS components (
ff-toast,ff-banner,ff-bottom-sheet,ff-dialog) render the alerts -
Does not decide layout — the product places
AlertHostComponentwherever it sees fit (typically in the shell) - Does not persist — alerts live in memory. They are lost on page reload
- Does not intercept HTTP errors — the product decides when and which alerts to fire
- Does not know predefined messages — texts are defined by the product
- Contains no business logic — it works with generic strings (messages, types)
- It is headless — it separates state from presentation
- It is reactive — all state is exposed as signals
- It prevents each product from reimplementing the notification system inconsistently
- Decoupled from the Design System — it works with any UI layer
The alerts module deliberately follows a headless pattern:
AlertService (core) → State + logic + timers
AlertHostComponent (shell) → Reads signals, renders DS components
ff-toast/ff-banner/... (DS) → Pure presentational components
The service never creates or manipulates DOM. The host never manages state. The DS components never know about the service. This separation allows:
- Replacing the DS components without touching the service
- Testing the service without DOM
- Reusing the service with a completely different DS
The module is activated by calling provideAlerts() in the product's app.config.ts:
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideAlerts } from '@fireflyframework/core';
export const appConfig: ApplicationConfig = {
providers: [
provideAlerts(),
],
};With custom configuration:
provideAlerts({
defaultToastDuration: 5000, // 5s instead of 3s
maxVisibleToasts: 3, // maximum 3 simultaneous toasts
defaultToastPosition: 'bottom-left',
})Compact notifications that appear in a corner and auto-dismiss:
import { AlertService } from '@fireflyframework/core';
export class MyComponent {
private readonly alerts = inject(AlertService);
onSave(): void {
this.alerts.success('Changes saved successfully.');
}
onError(): void {
this.alerts.error('Something went wrong. Please try again.');
// Errors last 5000ms by default (vs 3000ms for the rest)
}
onCustom(): void {
this.alerts.toast('Custom notification.', 'info', {
duration: 8000, // 8 seconds
dismissible: false, // no close button
icon: '📋',
});
}
}Full-width bars for important notifications:
// Persistent banner with action
this.alerts.banner(
'System maintenance tonight at 23:00 UTC.',
'warning',
{
icon: '⚠',
action: {
label: 'Learn more',
callback: () => this.router.navigate(['/maintenance']),
},
}
);
// Ephemeral banner (auto-dismiss in 5s)
this.alerts.banner('Your changes have been saved.', 'success', {
duration: 5000,
icon: '✓',
});Panels anchored at the bottom with actions:
this.alerts.bottomSheet('Choose an action for this item.', 'info', {
title: 'Item Actions',
actions: [
{ label: 'Edit', callback: () => this.edit(), type: 'info' },
{ label: 'Duplicate', callback: () => this.duplicate() },
{ label: 'Delete', callback: () => this.delete(), type: 'destructive' },
],
});Modals that return a promise with the user's result:
// Simple confirmation
const confirmed = await this.alerts.confirm('Are you sure you want to proceed?');
if (confirmed) {
this.doAction();
}
// Custom dialog
const result = await this.alerts.dialog({
title: 'Export Data',
message: 'Choose the export format.',
type: 'info',
confirmLabel: 'Export',
cancelLabel: 'Cancel',
});
if (result.confirmed) {
this.export();
}The success(), error(), warning(), info() methods fire toasts by default but accept an alternative format:
this.alerts.success('Done!'); // toast success
this.alerts.success('Done!', 'banner'); // banner success
this.alerts.error('Failed.', 'dialog'); // dialog error// Dismiss a specific toast
this.alerts.dismiss(toastId);
// Dismiss a specific banner
this.alerts.dismissBanner(bannerId);
// Dismiss a specific bottom sheet
this.alerts.dismissBottomSheet(sheetId);
// Clear everything (useful on logout or context change)
this.alerts.dismissAll();The product must place a host component that reads the service's signals and renders the DS components. Typical example:
// alert-host.component.ts
@Component({
selector: 'app-alert-host',
template: `
<div class="alert-host__banners">
@for (b of alerts.activeBanners(); track b.id) {
<ff-banner [message]="b.message" [type]="b.type" ... />
}
</div>
<div class="alert-host__toasts">
@for (t of alerts.activeToasts(); track t.id) {
<ff-toast [message]="t.message" [type]="t.type" ... />
}
</div>
<!-- bottom sheets, dialogs... -->
`,
})
export class AlertHostComponent {
readonly alerts = inject(AlertService);
}<!-- shell.component.html -->
<app-alert-host />
<app-sidebar />
<main><router-outlet /></main>type AlertType = 'success' | 'error' | 'warning' | 'info' | 'destructive' | 'custom';Semantic type shared by the four formats.
type AlertFormat = 'toast' | 'banner' | 'bottom-sheet' | 'dialog';Presentation format for the convenience shortcuts.
interface Toast {
readonly id: string;
readonly message: string;
readonly type: AlertType;
readonly options: ToastOptions;
readonly createdAt: number;
}interface ToastOptions {
duration?: number; // ms, default 3000 (error: 5000)
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
dismissible?: boolean; // default true
icon?: string;
customClass?: string;
component?: unknown; // custom Angular component
componentData?: Record<string, unknown>;
}interface Banner {
readonly id: string;
readonly message: string;
readonly type: AlertType;
readonly options: BannerOptions;
readonly createdAt: number;
}interface BannerOptions {
position?: 'top' | 'bottom';
duration?: number; // if omitted, persists until dismissed
dismissible?: boolean;
icon?: string;
action?: { label: string; callback: () => void };
customClass?: string;
component?: unknown;
componentData?: Record<string, unknown>;
}interface BottomSheet {
readonly id: string;
readonly message: string;
readonly type: AlertType;
readonly options: BottomSheetOptions;
readonly createdAt: number;
}interface BottomSheetOptions {
title?: string;
dismissible?: boolean; // default true
swipeToDismiss?: boolean; // default true
actions?: { label: string; callback: () => void; type?: AlertType }[];
icon?: string;
customClass?: string;
component?: unknown;
componentData?: Record<string, unknown>;
}interface Dialog {
readonly id: string;
readonly options: DialogOptions;
readonly createdAt: number;
}interface DialogOptions {
title?: string;
message?: string;
type: AlertType; // required
confirmLabel?: string; // default "Confirm"
cancelLabel?: string; // if omitted, no Cancel button
destructiveConfirmText?: string; // confirmation text for destructive actions
icon?: string;
customClass?: string;
component?: unknown;
componentData?: Record<string, unknown>;
}interface DialogResult {
confirmed: boolean;
input?: string; // user input if captured
}interface AlertConfig {
defaultToastDuration?: number; // default 3000
defaultToastPosition?: ToastOptions['position'];
defaultBannerPosition?: BannerOptions['position'];
maxVisibleToasts?: number; // default 5
maxVisibleBanners?: number; // default 3
}Registered via provideAlerts() — global singleton.
| Member | Type | Description |
|---|---|---|
activeToasts() |
Signal<Toast[]> |
Active toasts. Read-only. |
activeBanners() |
Signal<Banner[]> |
Active banners. Read-only. |
activeBottomSheets() |
Signal<BottomSheet[]> |
Active bottom sheets. Read-only. |
activeDialogs() |
Signal<Dialog[]> |
Active dialogs. Read-only. |
| Method | Returns | Description |
|---|---|---|
toast(message, type, options?) |
void |
Fires a toast. Auto-dismiss with timer. |
banner(message, type, options?) |
void |
Fires a banner. Persists unless duration is set. |
bottomSheet(message, type, options?) |
void |
Fires a modal bottom sheet. |
dialog(options) |
Promise<DialogResult> |
Opens a modal dialog. Returns a promise with the result. |
resolveDialog(id, result) |
void |
Resolves a pending dialog (called from the UI). |
| Method | Returns | Description |
|---|---|---|
success(message, format?) |
void |
Success toast (or alternative format). |
error(message, format?) |
void |
Error toast with 5000ms (or alternative format). |
warning(message, format?) |
void |
Warning toast (or alternative format). |
info(message, format?) |
void |
Info toast (or alternative format). |
confirm(message, options?) |
Promise<boolean> |
Yes/no confirmation dialog. |
| Method | Returns | Description |
|---|---|---|
dismiss(id) |
void |
Dismisses a toast by ID. |
dismissBanner(id) |
void |
Dismisses a banner by ID. |
dismissBottomSheet(id) |
void |
Dismisses a bottom sheet by ID. |
dismissAll() |
void |
Clears all alerts, timers, and resolves pending dialogs as cancelled. |
function provideAlerts(config?: AlertConfig): EnvironmentProvidersModule entry provider. Registers the configuration via the ALERT_CONFIG token. If config is omitted, defaults are applied.
const ALERT_CONFIG: InjectionToken<AlertConfig>Injection token for the global alert service configuration.
The Design System components are purely presentational. They do not know about the AlertService. They are rendered by the host.
| Input | Type | Default | Description |
|---|---|---|---|
message |
string |
'' |
Toast text |
type |
FfToastVariant |
'info' |
Semantic variant |
icon |
string |
'' |
Optional icon |
dismissible |
boolean |
true |
Shows close button |
| Output | Type | Description |
|---|---|---|
dismissed |
void |
Emitted on close click |
| Input | Type | Default | Description |
|---|---|---|---|
message |
string |
'' |
Banner text |
type |
FfBannerVariant |
'info' |
Semantic variant |
icon |
string |
'' |
Optional icon |
actionLabel |
string |
'' |
Action button text |
dismissible |
boolean |
true |
Shows close button |
| Output | Type | Description |
|---|---|---|
dismissed |
void |
Emitted on close click |
actionClicked |
void |
Emitted on action click |
| Input | Type | Default | Description |
|---|---|---|---|
open |
boolean |
false |
Controls visibility |
title |
string |
'' |
Panel title |
message |
string |
'' |
Body message |
type |
FfBottomSheetVariant |
'info' |
Semantic variant |
dismissible |
boolean |
true |
Close via backdrop/Escape/X |
| Output | Type | Description |
|---|---|---|
dismissed |
void |
Emitted on close |
Content projection: [ff-bottom-sheet-actions] for actions.
| Input | Type | Default | Description |
|---|---|---|---|
open |
boolean |
false |
Controls visibility |
title |
string |
'' |
Dialog title |
type |
FfDialogVariant |
undefined |
Optional semantic variant |
dismissible |
boolean |
true |
Close via backdrop/Escape/X |
| Output | Type | Description |
|---|---|---|
closed |
void |
Emitted on close |
Content projection: [ff-dialog-actions] for actions.
- Headless — The service manages state, never DOM. The DS components render, never manage state. The separation is strict.
- Native signals — All state is exposed as Angular signals. No RxJS. No BehaviorSubjects.
-
Four formats, one service — A single
AlertServicemanages all four formats. Avoids fragmentation intoToastService,BannerService, etc. -
Promise-based dialogs —
dialog()returns a promise that resolves when the user interacts. The service stores internal resolvers. - Limited capacity — Max 5 toasts and 3 banners simultaneously (configurable). Prevents visual overflow.
- Fire-and-forget toasts — Toasts auto-dismiss. The caller does not need to manage their lifecycle.
packages/core/src/lib/alerts/
├── index.ts # Public API barrel
├── alert.types.ts # AlertType, AlertFormat, Toast, Banner, BottomSheet, Dialog, options, config
├── alert.service.ts # AlertService (state + logic + timers)
├── alert.service.spec.ts # Unit tests
└── provide-alerts.ts # provideAlerts(), ALERT_CONFIG token
packages/design-system/src/lib/primitives/
├── ff-toast/ # FfToastComponent (pure presentational)
├── ff-banner/ # FfBannerComponent (pure presentational)
├── ff-bottom-sheet/ # FfBottomSheetComponent (pure presentational)
└── ff-dialog/ # FfDialogComponent (pure presentational)
┌──────────────────────────────────────────────────────────────────┐
│ Feature Component │
│ alerts.success('Saved!') │
│ │ │
│ ▼ │
│ AlertService │
│ _toasts.update(arr => [...arr, newToast]) │
│ setTimeout(() => dismiss(id), duration) │
│ │ │
│ ▼ │
│ activeToasts() ──► Signal<Toast[]> │
│ │ │
│ ▼ │
│ AlertHostComponent (in shell) │
│ @for (t of alerts.activeToasts(); track t.id) { │
│ <ff-toast [message]="t.message" [type]="t.type" │
│ (dismissed)="alerts.dismiss(t.id)" /> │
│ } │
│ │ │
│ ▼ │
│ FfToastComponent (DS) │
│ Renders message + icon + dismiss button │
│ Emits (dismissed) on user action │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Dialog flow (Promise-based) │
│ │
│ const result = await alerts.dialog({ title, message, type }) │
│ │ │
│ ▼ │
│ AlertService │
│ _dialogs.update(arr => [...arr, newDialog]) │
│ _pendingResolvers.set(id, { resolve }) │
│ return new Promise(resolve => ...) │
│ │ │
│ ▼ │
│ AlertHostComponent │
│ @for (d of alerts.activeDialogs(); track d.id) { │
│ <ff-dialog [open]="true" [title]="d.options.title" ...> │
│ <ff-button (clicked)="alerts.resolveDialog(d.id, result)"> │
│ </ff-dialog> │
│ } │
│ │ │
│ ▼ │
│ resolveDialog(id, { confirmed: true }) │
│ → Resolves the original promise │
│ → Removes the dialog from the signal │
│ → The caller receives the result │
└──────────────────────────────────────────────────────────────────┘
alert.service.ts
├── alert.types.ts (local)
└── ALERT_CONFIG (local, optional inject)
provide-alerts.ts
└── alert.types.ts (local)
The module does not depend on other framework modules. It only depends on @angular/core. The DS components also do not depend on the service — they only receive inputs and emit outputs.
| Decision | Rationale |
|---|---|
| Headless (service without UI) | Completely decouples state from presentation. Allows replacing the DS without touching the core. Allows testing without DOM. Follows the "headless UI" pattern popularized by Headless UI and Radix. |
| One service, four formats | A single entry point avoids fragmentation. The caller chooses the format via method (toast(), banner(), bottomSheet(), dialog()). Internally, each format has its own signal. |
| Signals instead of BehaviorSubject | Angular 21 signals are the native reactive primitive. They eliminate RxJS boilerplate. The host reads the signals directly in the template with @for. |
| Promise instead of Observable for dialogs | Dialogs produce a single result. A promise is semantically correct and simpler than an Observable that emits once. async/await makes the caller's code more readable. |
| Auto-dismiss with setTimeout | Toasts are ephemeral by nature. setTimeout is sufficient — they do not need observables or schedulers. Timers are cleared in dismissAll(). |
| Configurable limited capacity | Prevents visual overflow (5 stacked toasts, 3 banners). The limits are configured via provideAlerts(). When the limit is reached, the oldest toast is dismissed. |
| AlertHostComponent in the product, not in the framework | The host is part of the product's shell, not the framework. The framework provides the service and types. The product decides where and how to render. This respects the public/private boundary. |
| DS components without state logic |
ff-toast, ff-banner, etc. are pure presentational. They receive inputs, emit events. They do not inject AlertService. They can be used standalone without the service (e.g. in a catalog playground). |
@Injectable() + provideAlerts() |
AlertService is a global singleton — alerts are global to the application. Requires explicit registration via provideAlerts() in app.config.ts. |
| Errors with longer duration | Toasts of type error last 5000ms instead of 3000ms. Errors are more important and require more reading time. |
| Module | Relationship |
|---|---|
| Design System | No direct dependency. The DS components (ff-toast, ff-banner, ff-bottom-sheet, ff-dialog) are consumed by the product's host. The service does not import or know about the DS components. |
| NavigationService | No dependency. Both modules are independent. |
| PermissionService | No dependency. The product can combine them (e.g. "show an alert if the user lacks permission"), but the framework does not couple them. |
| Auth module (future) | No dependency. The product can call dismissAll() on logout, but that is the product's decision. |
| Router (Angular) | No dependency. Unlike the permissions module, the alerts service does not interact with the router. |
async onSave(): Promise<void> {
try {
await this.http.put('/api/items', this.form.value).toPromise();
this.alerts.success('Item saved successfully.');
} catch {
this.alerts.error('Failed to save item. Please try again.');
}
}async onDelete(item: Item): Promise<void> {
const result = await this.alerts.dialog({
title: 'Delete Item',
message: `Are you sure you want to delete "${item.name}"? This cannot be undone.`,
type: 'destructive',
confirmLabel: 'Delete',
cancelLabel: 'Keep',
});
if (result.confirmed) {
await this.itemService.delete(item.id);
this.alerts.success(`"${item.name}" deleted.`);
}
}this.alerts.banner(
'Scheduled maintenance in 30 minutes. Save your work.',
'warning',
{
icon: '🔧',
action: {
label: 'Details',
callback: () => window.open('/status', '_blank'),
},
}
);onLogout(): void {
this.alerts.dismissAll();
this.permissionService.clear();
this.router.navigate(['/login']);
}this.alerts.bottomSheet('What would you like to do with this document?', 'info', {
title: 'Document Actions',
actions: [
{ label: 'Share', callback: () => this.share(), type: 'info' },
{ label: 'Download', callback: () => this.download() },
{ label: 'Archive', callback: () => this.archive(), type: 'warning' },
{ label: 'Delete', callback: () => this.onDelete(), type: 'destructive' },
],
});// app.config.ts
provideAlerts({
defaultToastDuration: 4000,
maxVisibleToasts: 3,
maxVisibleBanners: 1,
defaultToastPosition: 'bottom-right',
defaultBannerPosition: 'top',
})// Show active-alerts indicator in the sidebar
readonly hasActiveAlerts = computed(() =>
this.alerts.activeToasts().length +
this.alerts.activeBanners().length +
this.alerts.activeBottomSheets().length +
this.alerts.activeDialogs().length > 0
);- PLAN_GLOBAL_FIREFLY_SKILLS.md — CORE Module #17: Alerts
- FIREFLY_DESIGN_SYSTEM_DESACOPLABLE_SKILLS.md — DS Components: ff-toast, ff-banner, ff-bottom-sheet, ff-dialog
- FIREFLY_PERMISSIONS_MODULE.md — Permissions module (reference pattern)
- FIREFLY_NAVIGATION_MODULE.md — Navigation module (independent)
- Alerts Module