Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage
/.nx/cache
/.nx/cache
20 changes: 20 additions & 0 deletions apps/playground/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,23 @@ <h2>Pass data to the dialog and get a result</h2>

<button (click)="openDialogWithCustomData(component, data.value, config.value)">Open</button>
</div>

<div class="block">
<h2>Close Protection Guards</h2>
<p>Test dialogs with close protection that only trigger on specific actions:</p>

<div style="margin: 10px 0">
<button (click)="openDialogWithEscapeGuard(component, config.value)">Open dialog with ESC protection only</button>
<p>
<small
>This dialog will ask for confirmation only when you press ESC key, not on backdrop click or close
button.</small
>
</p>
</div>

<div style="margin: 10px 0">
<button (click)="openDialogWithAllGuard(component, config.value)">Open dialog with full protection</button>
<p><small>This dialog will ask for confirmation on any close action (ESC, backdrop, close button).</small></p>
</div>
</div>
57 changes: 50 additions & 7 deletions apps/playground/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Component, TemplateRef, Type, ViewChild, ViewContainerRef } from '@angular/core';
import { Component, inject, TemplateRef, Type, ViewChild, ViewContainerRef } from '@angular/core';
import { ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms';
import { interval } from 'rxjs';
import { interval, firstValueFrom } from 'rxjs';
import { shareReplay } from 'rxjs/operators';
import { CommonModule } from '@angular/common';

import { DialogCloseDirective, DialogService, DialogConfig } from '@ngneat/dialog';

import { ResetLocationDialogComponent } from './reset-location-dialog.component';
import { TestDialogComponent } from './test-dialog.component';
import { ConfirmationDialogComponent } from './confirmation-dialog.component';

@Component({
selector: 'app-root',
Expand All @@ -17,6 +18,9 @@ import { TestDialogComponent } from './test-dialog.component';
styleUrls: ['./app.component.scss'],
})
export class AppComponent {
private fb = inject(UntypedFormBuilder);
dialog: DialogService = inject(DialogService);

@ViewChild('template', { static: true })
template: TemplateRef<any>;

Expand Down Expand Up @@ -68,11 +72,6 @@ export class AppComponent {

backDropClicked = false;

constructor(
private fb: UntypedFormBuilder,
public dialog: DialogService,
) {}

openDialog(compOrTemplate: Type<any> | TemplateRef<any>, config: DialogConfig) {
this.backDropClicked = false;
this.cleanConfig = this.normalizeConfig(config);
Expand All @@ -89,6 +88,38 @@ export class AppComponent {
return ref;
}

openDialogWithEscapeGuard(compOrTemplate: Type<any> | TemplateRef<any>, config: DialogConfig) {
const ref = this.openDialog(compOrTemplate, {
...config,
guardTrigger: 'escape',
});

// guard will fire on escape keypress
ref?.beforeClose(async () => {
const confirmRef = this.showConfirmationDialog();
const result = await firstValueFrom(confirmRef.afterClosed$);
return result === true;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return result

});

return ref;
}

openDialogWithAllGuard(compOrTemplate: Type<any> | TemplateRef<any>, config: DialogConfig) {
const ref = this.openDialog(compOrTemplate, {
...config,
guardTrigger: 'all',
});

ref?.beforeClose(async () => {
const confirmRef = this.showConfirmationDialog();
const result = await firstValueFrom(confirmRef.afterClosed$);

return result;
});

return ref;
}

openDialogWithCustomVCR(compOrTemplate: Type<any> | TemplateRef<any>, config: DialogConfig) {
this.templateOfCustomVCRIsAttached = true;

Expand All @@ -115,6 +146,18 @@ export class AppComponent {
view.detectChanges();
}

private showConfirmationDialog() {
return this.dialog.open(ConfirmationDialogComponent, {
width: '400px',
data: {
title: 'Confirm Close',
message: 'Are you sure you want to close this dialog?',
confirmText: 'Yes, Close',
cancelText: 'Cancel',
},
});
}

openResetLocationDialog(config: DialogConfig) {
this.openDialog(ResetLocationDialogComponent, { ...config, draggable: true });
}
Expand Down
30 changes: 30 additions & 0 deletions apps/playground/src/app/confirmation-dialog.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';

import { DialogRef } from '@ngneat/dialog';

@Component({
selector: 'app-confirmation-dialog',
standalone: true,
imports: [CommonModule],
template: `
<div style="padding: 10px">
<p>Are you sure you want to close this dialog ?</p>
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor punctuation/spacing: there is an extra space before the question mark in the dialog text.

Suggested change
<p>Are you sure you want to close this dialog ?</p>
<p>Are you sure you want to close this dialog?</p>

Copilot uses AI. Check for mistakes.
<div style="display: flex; justify-content: flex-end; gap: 8px;">
<button (click)="onCancel()">NO</button>
<button (click)="onConfirm()">YES</button>
</div>
</div>
`,
})
export class ConfirmationDialogComponent {
private dialogRef = inject(DialogRef<boolean>);

Comment on lines +20 to +22
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConfirmationDialogComponent declares a message field but never uses it, and the dialog data passed in showConfirmationDialog() is also unused. Either remove the unused field/data, or render the message (e.g., read from dialogRef.data) so the component reflects the provided config.

Copilot uses AI. Check for mistakes.
onConfirm() {
this.dialogRef.close(true);
}

onCancel() {
this.dialogRef.close(false);
}
}
2 changes: 1 addition & 1 deletion apps/playground/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
</head>
<body>
<app-root />
<app-root></app-root>
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redundant ? check this

</body>
</html>
25 changes: 23 additions & 2 deletions libs/dialog/src/lib/dialog-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ComponentRef, TemplateRef } from '@angular/core';
import { from, merge, Observable, of, Subject } from 'rxjs';
import { defaultIfEmpty, filter, first } from 'rxjs/operators';

import { DialogConfig, GlobalDialogConfig, JustProps } from './types';
import { DialogConfig, GlobalDialogConfig, JustProps, GuardTrigger } from './types';
import { DragOffset } from './draggable.directive';

type GuardFN<R> = (result?: R) => Observable<boolean> | Promise<boolean> | boolean;
Expand Down Expand Up @@ -34,13 +34,16 @@ export class InternalDialogRef extends DialogRef {
beforeCloseGuards: GuardFN<unknown>[] = [];
onClose: (result?: unknown) => void;
onReset: (offset?: DragOffset) => void;
private activeGuardTrigger?: GuardTrigger;

constructor(props: InternalDialogRefProps = {}) {
super();
this.mutate(props);
}

close(result?: unknown): void {
close(result?: unknown, action: GuardTrigger = 'closeButton'): void {
this.activeGuardTrigger = action;

this.canClose(result)
.pipe(filter<boolean>(Boolean))
.subscribe({ next: () => this.onClose(result) });
Expand All @@ -55,6 +58,13 @@ export class InternalDialogRef extends DialogRef {
}

canClose(result: unknown): Observable<boolean> {
const shouldRunGuards = this.shouldRunGuards();

if (!shouldRunGuards) {
// skip checks, close the dialog right away
return of(true);
}

const guards$ = this.beforeCloseGuards
.map((guard) => guard(result))
.filter((value) => value !== undefined && value !== true)
Expand All @@ -65,6 +75,17 @@ export class InternalDialogRef extends DialogRef {
return merge(...guards$).pipe(defaultIfEmpty(true), first());
}

private shouldRunGuards(): boolean {
const { guardTrigger } = this.config;
// we want to make sure that user already using a guard but NOT using the guardTrigger type, that their checks still run
if (!guardTrigger || guardTrigger === 'all') {
return true;
}

//else, if the user has provided a guardTrigger, only run it if it matches the guardTrigger
return guardTrigger === this.activeGuardTrigger;
}

mutate(props: InternalDialogRefProps) {
Object.assign(this, props);
this.data = this.config.data;
Expand Down
49 changes: 26 additions & 23 deletions libs/dialog/src/lib/dialog.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { DialogService } from './dialog.service';
import { coerceCssPixelValue } from './dialog.utils';
import { DialogDraggableDirective, DragOffset } from './draggable.directive';
import { NODES_TO_INSERT } from './providers';
import { GuardTrigger } from './types';

@Component({
selector: 'ngneat-dialog',
Expand All @@ -37,22 +38,23 @@ import { NODES_TO_INSERT } from './providers';
role="dialog"
>
@if (config.draggable) {
<div
class="ngneat-drag-marker"
dialogDraggable
[dialogDragEnabled]="true"
[dialogDragTarget]="dialog"
[dragConstraint]="config.dragConstraint"
></div>
} @if (config.closeButton) {
<div class="ngneat-close-dialog" (click)="closeDialog()">
<svg viewBox="0 0 329.26933 329" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="m194.800781 164.769531 128.210938-128.214843c8.34375-8.339844 8.34375-21.824219 0-30.164063-8.339844-8.339844-21.824219-8.339844-30.164063 0l-128.214844 128.214844-128.210937-128.214844c-8.34375-8.339844-21.824219-8.339844-30.164063 0-8.34375 8.339844-8.34375 21.824219 0 30.164063l128.210938 128.214843-128.210938 128.214844c-8.34375 8.339844-8.34375 21.824219 0 30.164063 4.15625 4.160156 9.621094 6.25 15.082032 6.25 5.460937 0 10.921875-2.089844 15.082031-6.25l128.210937-128.214844 128.214844 128.214844c4.160156 4.160156 9.621094 6.25 15.082032 6.25 5.460937 0 10.921874-2.089844 15.082031-6.25 8.34375-8.339844 8.34375-21.824219 0-30.164063zm0 0"
/>
</svg>
</div>
<div
class="ngneat-drag-marker"
dialogDraggable
[dialogDragEnabled]="true"
[dialogDragTarget]="dialog"
[dragConstraint]="config.dragConstraint"
></div>
}
@if (config.closeButton) {
<div class="ngneat-close-dialog" (click)="closeDialog()">
<svg viewBox="0 0 329.26933 329" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="m194.800781 164.769531 128.210938-128.214843c8.34375-8.339844 8.34375-21.824219 0-30.164063-8.339844-8.339844-21.824219-8.339844-30.164063 0l-128.214844 128.214844-128.210937-128.214844c-8.34375-8.339844-21.824219-8.339844-30.164063 0-8.34375 8.339844-8.34375 21.824219 0 30.164063l128.210938 128.214843-128.210938 128.214844c-8.34375 8.339844-8.34375 21.824219 0 30.164063 4.15625 4.160156 9.621094 6.25 15.082032 6.25 5.460937 0 10.921875-2.089844 15.082031-6.25l128.210937-128.214844 128.214844 128.214844c4.160156 4.160156 9.621094 6.25 15.082032 6.25 5.460937 0 10.921874-2.089844 15.082031-6.25 8.34375-8.339844 8.34375-21.824219 0-30.164063zm0 0"
/>
</svg>
</div>
}
</div>
</div>
Expand Down Expand Up @@ -96,7 +98,7 @@ export class DialogComponent implements OnInit, OnDestroy {
// Append nodes to dialog component, template or component could need
// something from the dialog component
// for example, if `[dialogClose]` is used into a directive,
// DialogRef will be getted from DialogService instead of DI
// DialogRef will be injected from DialogService instead of DI
this.nodes.forEach((node) => this.host.appendChild(node));

if (this.config.windowClass) {
Expand Down Expand Up @@ -143,24 +145,25 @@ export class DialogComponent implements OnInit, OnDestroy {
merge(
fromEvent<KeyboardEvent>(this.document.body, 'keyup').pipe(
filter(({ key }) => key === 'Escape'),
map(() => closeConfig.escape),
// we track what triggers the actual closing, esc or backdrop click so we can act accordingly with the closingGuards
map(() => ({ action: 'escape' as const, strategy: closeConfig.escape })),
),
backdropClick$.pipe(
filter(() => this.document.getSelection()?.toString() === ''),
map(() => closeConfig.backdrop),
map(() => ({ action: 'backdrop' as const, strategy: closeConfig.backdrop })),
),
)
.pipe(
takeUntil(this.destroy$),
filter((strategy) => {
filter(({ strategy }) => {
if (!strategy) return false;
if (strategy === 'onlyLastStrategy') {
return this.dialogService.isLastOpened(this.config.id);
}
return true;
}),
)
.subscribe(() => this.closeDialog());
.subscribe(({ action }) => this.closeDialog(action));

// `dialogElement` is resolved at this point
// And here is where dialog finally will be placed
Expand All @@ -178,8 +181,8 @@ export class DialogComponent implements OnInit, OnDestroy {
}
}

closeDialog() {
this.dialogRef.close();
closeDialog(action: GuardTrigger = 'closeButton') {
this.dialogRef.close(undefined, action);
}

ngOnDestroy() {
Expand Down
16 changes: 6 additions & 10 deletions libs/dialog/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DialogRef } from './dialog-ref';
type Sizes = 'sm' | 'md' | 'lg' | 'fullScreen' | string;
export type DragConstraint = 'none' | 'bounce' | 'constrain';
export type CloseStrategy = boolean | 'onlyLastStrategy';
export type GuardTrigger = 'escape' | 'backdrop' | 'closeButton' | 'all';

export interface GlobalDialogConfig {
sizes: Partial<
Expand Down Expand Up @@ -44,6 +45,7 @@ export interface GlobalDialogConfig {
zIndexGetter?(): number;
onOpen: () => void | undefined;
onClose: () => void | undefined;
guardTrigger?: GuardTrigger;
}

export interface DialogConfig<Data = any> extends Omit<GlobalDialogConfig, 'sizes'> {
Expand All @@ -66,16 +68,10 @@ export type ExtractRefProp<T> = Exclude<
undefined | null
>;

export type ExtractData<T> = ExtractRefProp<T> extends never
? any
: T[ExtractRefProp<T>] extends DialogRef<infer Data>
? Data
: never;
export type ExtractResult<T> = ExtractRefProp<T> extends never
? any
: T[ExtractRefProp<T>] extends DialogRef<any, infer Result>
? Result
: never;
export type ExtractData<T> =
ExtractRefProp<T> extends never ? any : T[ExtractRefProp<T>] extends DialogRef<infer Data> ? Data : never;
export type ExtractResult<T> =
ExtractRefProp<T> extends never ? any : T[ExtractRefProp<T>] extends DialogRef<any, infer Result> ? Result : never;

export interface AttachOptions {
ref: ComponentRef<any> | TemplateRef<any>;
Expand Down
2 changes: 1 addition & 1 deletion libs/dialog/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ export { DialogService } from './lib/dialog.service';
export { DialogRef } from './lib/dialog-ref';
export { provideDialogConfig, provideDialogDocRef } from './lib/providers';
export { DialogCloseDirective } from './lib/dialog-close.directive';
export { DialogConfig } from './lib/types';
export { DialogConfig, GuardTrigger } from './lib/types';
export { CloseAllDialogsDirective } from './lib/close-all-dialogs.directive';
Loading