>(BrnDialogRef);
+ private readonly _dialogContext = injectBrnDialogContext<{ users: ExampleUser[] }>();
+
+ protected readonly users = this._dialogContext.users;
+
+ public selectUser(user: ExampleUser) {
+ this._dialogRef.close(user);
+ }
+}
+`;
diff --git a/apps/app/src/app/pages/(components)/components/(dialog)/dialog.page.ts b/apps/app/src/app/pages/(components)/components/(dialog)/dialog.page.ts
index c22824571..3d1019d57 100644
--- a/apps/app/src/app/pages/(components)/components/(dialog)/dialog.page.ts
+++ b/apps/app/src/app/pages/(components)/components/(dialog)/dialog.page.ts
@@ -22,6 +22,7 @@ import { TabsCliComponent } from '../../../../shared/layout/tabs-cli.component';
import { TabsComponent } from '../../../../shared/layout/tabs.component';
import { metaWith } from '../../../../shared/meta/meta.util';
import { DialogContextMenuPreviewComponent, contextMenuCode } from './dialog-context-menu.preview';
+import { DialogDynamicComponentPreviewComponent, dynamicComponentCode } from './dialog-dynamic-component.preview';
import { DialogPreviewComponent, defaultCode, defaultImports, defaultSkeleton } from './dialog.preview';
export const routeMeta: RouteMeta = {
@@ -49,6 +50,7 @@ export const routeMeta: RouteMeta = {
DialogPreviewComponent,
DialogPreviewComponent,
DialogContextMenuPreviewComponent,
+ DialogDynamicComponentPreviewComponent,
HlmAlertDirective,
HlmAlertDescriptionDirective,
HlmIconComponent,
@@ -116,6 +118,20 @@ export const routeMeta: RouteMeta = {
+ Dynamic Component
+
+ You can dynamically open a dialog with a component rendered as the content. The dialog context can be injected
+ into the dynamic component using the provided
+ injectBrnDialogContext
+ function.
+
+
+
+
+
+
+
+
@@ -129,4 +145,5 @@ export default class DialogPageComponent {
protected readonly defaultSkeleton = defaultSkeleton;
protected readonly defaultImports = defaultImports;
protected readonly contextMenuCode = contextMenuCode;
+ protected readonly dynamicComponentCode = dynamicComponentCode;
}
diff --git a/apps/ui-storybook-e2e/src/integration/dialog/dialog.cy.ts b/apps/ui-storybook-e2e/src/integration/dialog/dialog.cy.ts
index 979970e71..ff816b4fa 100644
--- a/apps/ui-storybook-e2e/src/integration/dialog/dialog.cy.ts
+++ b/apps/ui-storybook-e2e/src/integration/dialog/dialog.cy.ts
@@ -98,4 +98,111 @@ describe('dialog--default', () => {
cy.findAllByText(/edit profile/i).should('have.focus');
});
});
+
+ describe('nested dialog', () => {
+ beforeEach(() => {
+ cy.visit('/iframe.html?id=dialog--nested-dialog');
+ cy.injectAxe();
+ });
+
+ it('click on trigger should open the first dialog, click on button inside first dialog should open a nested dialog, click on button inside nested dialog closes nested dialog', () => {
+ cy.findByText(/open dialog/i).should('have.attr', 'aria-haspopup', 'dialog');
+ cy.findByText(/open dialog/i).click();
+
+ cy.get('#brn-dialog-0');
+ cy.get('#brn-dialog-0').should('have.attr', 'aria-labelledby', 'brn-dialog-title-0');
+ cy.get('#brn-dialog-0').should('have.attr', 'aria-labelledby', 'brn-dialog-title-0');
+ cy.get('#brn-dialog-0').should('have.attr', 'aria-modal', 'true');
+ cy.get('#brn-dialog-0').should('have.attr', 'tabindex', '-1');
+
+ cy.findByText(/first dialog/i);
+ cy.findByText(/open nested dialog/i).should('have.attr', 'aria-haspopup', 'dialog');
+ cy.findByText(/open nested dialog/i).click();
+
+ cy.get('#brn-dialog-1');
+ cy.get('#brn-dialog-1').should('have.attr', 'aria-labelledby', 'brn-dialog-title-1');
+ cy.get('#brn-dialog-1').should('have.attr', 'aria-labelledby', 'brn-dialog-title-1');
+ cy.get('#brn-dialog-1').should('have.attr', 'aria-modal', 'true');
+ cy.get('#brn-dialog-1').should('have.attr', 'tabindex', '-1');
+
+ cy.get('#brn-dialog-1')
+ .findByText(/close nested dialog/i)
+ .click();
+
+ cy.wait(100);
+
+ cy.get('.cdk-overlay-backdrop').click({ force: true });
+
+ cy.findAllByText(/open dialog/i).should('have.length', 1);
+ cy.findAllByText(/open dialog/i).should('have.focus');
+ });
+ });
+});
+
+describe('dialog--dynamic-component', () => {
+ describe('dynamic-component', () => {
+ beforeEach(() => {
+ cy.visit('/iframe.html?id=dialog--dynamic-component');
+ cy.injectAxe();
+ });
+
+ it('click on button should open dyanmic component, click on close should close, click outside should close', () => {
+ cy.findAllByText(/select user/i).click();
+ cy.findByRole('dialog');
+ cy.findByRole('dialog').should('have.attr', 'aria-labelledby', 'brn-dialog-title-0');
+ cy.findByRole('dialog').should('have.attr', 'aria-labelledby', 'brn-dialog-title-0');
+ cy.findByRole('dialog').should('have.attr', 'aria-modal', 'true');
+ cy.findByRole('dialog').should('have.attr', 'tabindex', '-1');
+ cy.get('dynamic-content');
+
+ // close on click close button
+ cy.findByRole('dialog').get('hlm-icon').click();
+ cy.findAllByText(/select user/i).should('have.length', 1);
+ cy.findAllByText(/select user/i).should('have.focus');
+ cy.findByText(/select user/i).click();
+
+ // close on click backdrop
+ cy.get('dynamic-content');
+ cy.get('.cdk-overlay-backdrop').click({ force: true });
+ cy.findAllByText(/select user/i).should('have.length', 1);
+ cy.findAllByText(/select user/i).should('have.focus');
+ });
+ });
+
+ describe('nested dialog', () => {
+ beforeEach(() => {
+ cy.visit('/iframe.html?id=dialog--nested-dynamic-component');
+ cy.injectAxe();
+ });
+
+ it('click on trigger should open the first dialog, click on button inside first dialog should open a nested dialog, click on button inside nested dialog closes nested dialog', () => {
+ cy.findByText(/open dialog/i).click();
+
+ cy.get('#brn-dialog-0');
+ cy.get('#brn-dialog-0').should('have.attr', 'aria-labelledby', 'brn-dialog-title-0');
+ cy.get('#brn-dialog-0').should('have.attr', 'aria-labelledby', 'brn-dialog-title-0');
+ cy.get('#brn-dialog-0').should('have.attr', 'aria-modal', 'true');
+ cy.get('#brn-dialog-0').should('have.attr', 'tabindex', '-1');
+
+ cy.findByText(/first dialog/i);
+ cy.findByText(/open nested dialog/i).click();
+
+ cy.get('#brn-dialog-1');
+ cy.get('#brn-dialog-1').should('have.attr', 'aria-labelledby', 'brn-dialog-title-1');
+ cy.get('#brn-dialog-1').should('have.attr', 'aria-labelledby', 'brn-dialog-title-1');
+ cy.get('#brn-dialog-1').should('have.attr', 'aria-modal', 'true');
+ cy.get('#brn-dialog-1').should('have.attr', 'tabindex', '-1');
+
+ cy.get('#brn-dialog-1')
+ .findByText(/close nested dialog/i)
+ .click();
+
+ cy.wait(100);
+
+ cy.get('.cdk-overlay-backdrop').click({ force: true });
+
+ cy.findAllByText(/open dialog/i).should('have.length', 1);
+ cy.findAllByText(/open dialog/i).should('have.focus');
+ });
+ });
});
diff --git a/libs/ui/alert-dialog/brain/src/lib/brn-alert-dialog-trigger.directive.ts b/libs/ui/alert-dialog/brain/src/lib/brn-alert-dialog-trigger.directive.ts
index 68278815b..92e6fda62 100644
--- a/libs/ui/alert-dialog/brain/src/lib/brn-alert-dialog-trigger.directive.ts
+++ b/libs/ui/alert-dialog/brain/src/lib/brn-alert-dialog-trigger.directive.ts
@@ -6,8 +6,7 @@ import { BrnAlertDialogComponent } from './brn-alert-dialog.component';
selector: 'button[brnAlertDialogTrigger],button[brnAlertDialogTriggerFor]',
standalone: true,
host: {
- '[id]': '_id()',
- '(click)': 'open()',
+ '[id]': 'id()',
'aria-haspopup': 'dialog',
'[attr.aria-expanded]': "state() === 'open' ? 'true': 'false'",
'[attr.data-state]': 'state()',
diff --git a/libs/ui/alert-dialog/brain/src/lib/brn-alert-dialog.component.ts b/libs/ui/alert-dialog/brain/src/lib/brn-alert-dialog.component.ts
index de1449ba1..49b43b970 100644
--- a/libs/ui/alert-dialog/brain/src/lib/brn-alert-dialog.component.ts
+++ b/libs/ui/alert-dialog/brain/src/lib/brn-alert-dialog.component.ts
@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, forwardRef, ViewEncapsulation } from '@angular/core';
-import { BrnDialogComponent, provideBrnDialog } from '@spartan-ng/ui-dialog-brain';
+import { BrnDialogComponent } from '@spartan-ng/ui-dialog-brain';
@Component({
selector: 'brn-alert-dialog',
@@ -8,7 +8,6 @@ import { BrnDialogComponent, provideBrnDialog } from '@spartan-ng/ui-dialog-brai
`,
providers: [
- provideBrnDialog(),
{
provide: BrnDialogComponent,
useExisting: forwardRef(() => BrnAlertDialogComponent),
@@ -22,6 +21,7 @@ export class BrnAlertDialogComponent extends BrnDialogComponent {
constructor() {
super();
this._options.role = 'alertdialog';
+ this._options.closeOnBackdropClick = false;
this._options.closeOnOutsidePointerEvents = false;
}
}
diff --git a/libs/ui/alert-dialog/helm/src/lib/hlm-alert-dialog.component.ts b/libs/ui/alert-dialog/helm/src/lib/hlm-alert-dialog.component.ts
index 4f8ccbd35..d52143d2c 100644
--- a/libs/ui/alert-dialog/helm/src/lib/hlm-alert-dialog.component.ts
+++ b/libs/ui/alert-dialog/helm/src/lib/hlm-alert-dialog.component.ts
@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, forwardRef, ViewEncapsulation } from '@angular/core';
import { BrnAlertDialogComponent, BrnAlertDialogOverlayComponent } from '@spartan-ng/ui-alertdialog-brain';
-import { BrnDialogComponent, provideBrnDialog } from '@spartan-ng/ui-dialog-brain';
+import { BrnDialogComponent } from '@spartan-ng/ui-dialog-brain';
import { HlmAlertDialogOverlayDirective } from './hlm-alert-dialog-overlay.directive';
@Component({
@@ -11,7 +11,6 @@ import { HlmAlertDialogOverlayDirective } from './hlm-alert-dialog-overlay.direc
`,
providers: [
- provideBrnDialog(),
{
provide: BrnDialogComponent,
useExisting: forwardRef(() => HlmAlertDialogComponent),
diff --git a/libs/ui/dialog/brain/src/index.ts b/libs/ui/dialog/brain/src/index.ts
index 9faf8aa68..444d03806 100644
--- a/libs/ui/dialog/brain/src/index.ts
+++ b/libs/ui/dialog/brain/src/index.ts
@@ -11,7 +11,10 @@ import { BrnDialogComponent } from './lib/brn-dialog.component';
export * from './lib/brn-dialog-close.directive';
export * from './lib/brn-dialog-content.directive';
export * from './lib/brn-dialog-description.directive';
+export * from './lib/brn-dialog-options';
export * from './lib/brn-dialog-overlay.component';
+export * from './lib/brn-dialog-ref';
+export * from './lib/brn-dialog-state';
export * from './lib/brn-dialog-title.directive';
export * from './lib/brn-dialog-trigger.directive';
export * from './lib/brn-dialog.component';
diff --git a/libs/ui/dialog/brain/src/lib/brn-dialog-close.directive.ts b/libs/ui/dialog/brain/src/lib/brn-dialog-close.directive.ts
index f7c2212e5..6322b8b27 100644
--- a/libs/ui/dialog/brain/src/lib/brn-dialog-close.directive.ts
+++ b/libs/ui/dialog/brain/src/lib/brn-dialog-close.directive.ts
@@ -1,6 +1,6 @@
-import { coerceNumberProperty, NumberInput } from '@angular/cdk/coercion';
-import { Directive, inject, Input } from '@angular/core';
-import { BrnDialogComponent } from './brn-dialog.component';
+import { NumberInput, coerceNumberProperty } from '@angular/cdk/coercion';
+import { Directive, Input, inject } from '@angular/core';
+import { BrnDialogRef } from './brn-dialog-ref';
@Directive({
selector: 'button[brnDialogClose]',
@@ -10,13 +10,16 @@ import { BrnDialogComponent } from './brn-dialog.component';
},
})
export class BrnDialogCloseDirective {
- private _brnDialog = inject(BrnDialogComponent);
+ private readonly _brnDialogRef = inject(BrnDialogRef);
+
private _delay: number | undefined;
+
@Input()
set delay(value: NumberInput) {
this._delay = coerceNumberProperty(value);
}
- close() {
- this._brnDialog.close(this._delay);
+
+ public close() {
+ this._brnDialogRef.close(undefined, this._delay);
}
}
diff --git a/libs/ui/dialog/brain/src/lib/brn-dialog-content.directive.ts b/libs/ui/dialog/brain/src/lib/brn-dialog-content.directive.ts
index 2a4af414b..d068d6434 100644
--- a/libs/ui/dialog/brain/src/lib/brn-dialog-content.directive.ts
+++ b/libs/ui/dialog/brain/src/lib/brn-dialog-content.directive.ts
@@ -1,5 +1,6 @@
-import { Directive, inject, Input, TemplateRef } from '@angular/core';
-import { ExposesState, provideExposesStateProviderExisting } from '@spartan-ng/ui-core';
+import { computed, Directive, inject, Input, TemplateRef } from '@angular/core';
+import { provideExposesStateProviderExisting } from '@spartan-ng/ui-core';
+import { BrnDialogRef } from './brn-dialog-ref';
import { BrnDialogComponent } from './brn-dialog.component';
@Directive({
@@ -7,21 +8,26 @@ import { BrnDialogComponent } from './brn-dialog.component';
standalone: true,
providers: [provideExposesStateProviderExisting(() => BrnDialogContentDirective)],
})
-export class BrnDialogContentDirective implements ExposesState {
- private _brnDialog = inject(BrnDialogComponent);
- private _template = inject(TemplateRef);
- public state = this._brnDialog.state;
+export class BrnDialogContentDirective {
+ private readonly _brnDialog = inject(BrnDialogComponent, { optional: true });
+ private readonly _brnDialogRef = inject(BrnDialogRef, { optional: true });
+ private readonly _template = inject(TemplateRef);
+ public readonly state = computed(() => this._brnDialog?.state() ?? this._brnDialogRef?.state() ?? 'closed');
constructor() {
+ if (!this._brnDialog) return;
this._brnDialog.registerTemplate(this._template);
}
+
@Input()
set class(newClass: string | null | undefined) {
+ if (!this._brnDialog) return;
this._brnDialog.setPanelClass(newClass);
}
@Input()
set context(context: T) {
+ if (!this._brnDialog) return;
this._brnDialog.setContext(context);
}
}
diff --git a/libs/ui/dialog/brain/src/lib/brn-dialog-description.directive.ts b/libs/ui/dialog/brain/src/lib/brn-dialog-description.directive.ts
index 8024e4a98..aca607e71 100644
--- a/libs/ui/dialog/brain/src/lib/brn-dialog-description.directive.ts
+++ b/libs/ui/dialog/brain/src/lib/brn-dialog-description.directive.ts
@@ -1,5 +1,5 @@
import { Directive, effect, inject, signal } from '@angular/core';
-import { BrnDialogComponent } from './brn-dialog.component';
+import { BrnDialogRef } from './brn-dialog-ref';
@Directive({
selector: '[brnDialogDescription]',
@@ -9,11 +9,13 @@ import { BrnDialogComponent } from './brn-dialog.component';
},
})
export class BrnDialogDescriptionDirective {
- private _dialog = inject(BrnDialogComponent);
- protected _id = signal('brn-dialog-description-' + this._dialog.dialogId);
+ private readonly _brnDialogRef = inject(BrnDialogRef);
+
+ protected _id = signal(`brn-dialog-description-${this._brnDialogRef?.dialogId}`);
+
constructor() {
effect(() => {
- this._dialog.setAriaDescribedBy(this._id());
+ this._brnDialogRef.setAriaDescribedBy(this._id());
});
}
}
diff --git a/libs/ui/dialog/brain/src/lib/brn-dialog-options.ts b/libs/ui/dialog/brain/src/lib/brn-dialog-options.ts
new file mode 100644
index 000000000..a5805a893
--- /dev/null
+++ b/libs/ui/dialog/brain/src/lib/brn-dialog-options.ts
@@ -0,0 +1,50 @@
+import { AutoFocusTarget } from '@angular/cdk/dialog';
+import {
+ ConnectedPosition,
+ FlexibleConnectedPositionStrategyOrigin,
+ PositionStrategy,
+ ScrollStrategy,
+} from '@angular/cdk/overlay';
+import { ElementRef, StaticProvider } from '@angular/core';
+
+export type BrnDialogOptions = {
+ id: string;
+ role: 'dialog' | 'alertdialog';
+ hasBackdrop: boolean;
+ panelClass: string | string[];
+ backdropClass: string | string[];
+ positionStrategy: PositionStrategy | null | undefined;
+ scrollStrategy: ScrollStrategy | null | undefined;
+ restoreFocus: boolean | string | ElementRef;
+ closeDelay: number;
+ closeOnOutsidePointerEvents: boolean;
+ closeOnBackdropClick: boolean;
+ attachTo: FlexibleConnectedPositionStrategyOrigin | null | undefined;
+ attachPositions: ConnectedPosition[];
+ autoFocus: AutoFocusTarget | string;
+ disableClose: boolean;
+ ariaDescribedBy: string | null | undefined;
+ ariaLabelledBy: string | null | undefined;
+ ariaLabel: string | null | undefined;
+ ariaModal: boolean;
+ providers?: StaticProvider[] | (() => StaticProvider[]);
+};
+
+export const DEFAULT_BRN_DIALOG_OPTIONS: Readonly> = {
+ role: 'dialog',
+ attachPositions: [],
+ attachTo: null,
+ autoFocus: 'first-tabbable',
+ backdropClass: '',
+ closeDelay: 0,
+ closeOnBackdropClick: true,
+ closeOnOutsidePointerEvents: false,
+ hasBackdrop: true,
+ panelClass: '',
+ positionStrategy: null,
+ restoreFocus: true,
+ scrollStrategy: null,
+ disableClose: false,
+ ariaLabel: undefined,
+ ariaModal: true,
+};
diff --git a/libs/ui/dialog/brain/src/lib/brn-dialog-overlay.component.ts b/libs/ui/dialog/brain/src/lib/brn-dialog-overlay.component.ts
index f08c0a9b2..e82f55d07 100644
--- a/libs/ui/dialog/brain/src/lib/brn-dialog-overlay.component.ts
+++ b/libs/ui/dialog/brain/src/lib/brn-dialog-overlay.component.ts
@@ -1,21 +1,23 @@
import { ChangeDetectionStrategy, Component, inject, Input, ViewEncapsulation } from '@angular/core';
-import { CustomElementClassSettable, provideCustomClassSettableExisting } from '@spartan-ng/ui-core';
+import { provideCustomClassSettableExisting } from '@spartan-ng/ui-core';
import { BrnDialogComponent } from './brn-dialog.component';
@Component({
selector: 'brn-dialog-overlay',
standalone: true,
- providers: [provideCustomClassSettableExisting(() => BrnDialogOverlayComponent)],
template: ``,
+ providers: [provideCustomClassSettableExisting(() => BrnDialogOverlayComponent)],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
})
-export class BrnDialogOverlayComponent implements CustomElementClassSettable {
- private _brnDialog = inject(BrnDialogComponent);
+export class BrnDialogOverlayComponent {
+ private readonly _brnDialog = inject(BrnDialogComponent);
+
@Input()
set class(newClass: string | null | undefined) {
this._brnDialog.setOverlayClass(newClass);
}
+
setClassToCustomElement(newClass: string) {
this._brnDialog.setOverlayClass(newClass);
}
diff --git a/libs/ui/dialog/brain/src/lib/brn-dialog-ref.ts b/libs/ui/dialog/brain/src/lib/brn-dialog-ref.ts
new file mode 100644
index 000000000..9dc659bde
--- /dev/null
+++ b/libs/ui/dialog/brain/src/lib/brn-dialog-ref.ts
@@ -0,0 +1,62 @@
+import { DialogRef } from '@angular/cdk/dialog';
+import { Signal, WritableSignal } from '@angular/core';
+import { Subject, take } from 'rxjs';
+import { BrnDialogOptions } from './brn-dialog-options';
+import { BrnDialogState } from './brn-dialog-state';
+import { cssClassesToArray } from './brn-dialog.service';
+
+export class BrnDialogRef {
+ private readonly _closing$ = new Subject();
+ public readonly closing$ = this._closing$.asObservable();
+
+ public readonly closed$ = this._cdkDialogRef.closed.pipe(take(1));
+
+ private _previousTimeout: ReturnType | undefined;
+
+ public get open() {
+ return this.state() === 'open' ? true : false;
+ }
+
+ constructor(
+ private readonly _cdkDialogRef: DialogRef,
+ private readonly _open: WritableSignal,
+ public readonly state: Signal,
+ public readonly dialogId: number,
+ private readonly _options?: BrnDialogOptions,
+ ) {}
+
+ public close(result?: DialogResult, delay: number = this._options?.closeDelay ?? 0) {
+ if (!this.open || this._options?.disableClose) return;
+
+ this._closing$.next();
+ this._open.set(false);
+
+ if (this._previousTimeout) {
+ clearTimeout(this._previousTimeout);
+ }
+
+ this._previousTimeout = setTimeout(() => {
+ this._cdkDialogRef.close(result);
+ }, delay);
+ }
+
+ public setPanelClass(paneClass: string | null | undefined) {
+ this._cdkDialogRef.config.panelClass = cssClassesToArray(paneClass);
+ }
+
+ public setOverlayClass(overlayClass: string | null | undefined) {
+ this._cdkDialogRef.config.backdropClass = cssClassesToArray(overlayClass);
+ }
+
+ public setAriaDescribedBy(ariaDescribedBy: string | null | undefined) {
+ this._cdkDialogRef.config.ariaDescribedBy = ariaDescribedBy;
+ }
+
+ public setAriaLabelledBy(ariaLabelledBy: string | null | undefined) {
+ this._cdkDialogRef.config.ariaLabelledBy = ariaLabelledBy;
+ }
+
+ public setAriaLabel(ariaLabel: string | null | undefined) {
+ this._cdkDialogRef.config.ariaLabel = ariaLabel;
+ }
+}
diff --git a/libs/ui/dialog/brain/src/lib/brn-dialog-state.ts b/libs/ui/dialog/brain/src/lib/brn-dialog-state.ts
new file mode 100644
index 000000000..7b88280c4
--- /dev/null
+++ b/libs/ui/dialog/brain/src/lib/brn-dialog-state.ts
@@ -0,0 +1 @@
+export type BrnDialogState = 'closed' | 'open';
diff --git a/libs/ui/dialog/brain/src/lib/brn-dialog-title.directive.ts b/libs/ui/dialog/brain/src/lib/brn-dialog-title.directive.ts
index 22591e6c8..36f523914 100644
--- a/libs/ui/dialog/brain/src/lib/brn-dialog-title.directive.ts
+++ b/libs/ui/dialog/brain/src/lib/brn-dialog-title.directive.ts
@@ -1,5 +1,5 @@
import { Directive, effect, inject, signal } from '@angular/core';
-import { BrnDialogComponent } from './brn-dialog.component';
+import { BrnDialogRef } from './brn-dialog-ref';
@Directive({
selector: '[brnDialogTitle]',
@@ -9,11 +9,13 @@ import { BrnDialogComponent } from './brn-dialog.component';
},
})
export class BrnDialogTitleDirective {
- private _dialog = inject(BrnDialogComponent);
- protected _id = signal('brn-dialog-title-' + this._dialog.dialogId);
+ private readonly _brnDialogRef = inject(BrnDialogRef);
+
+ protected _id = signal(`brn-dialog-title-${this._brnDialogRef?.dialogId}`);
+
constructor() {
effect(() => {
- this._dialog.setAriaLabelledBy(this._id());
+ this._brnDialogRef.setAriaLabelledBy(this._id());
});
}
}
diff --git a/libs/ui/dialog/brain/src/lib/brn-dialog-trigger.directive.ts b/libs/ui/dialog/brain/src/lib/brn-dialog-trigger.directive.ts
index 3751075bd..d70f8dc1d 100644
--- a/libs/ui/dialog/brain/src/lib/brn-dialog-trigger.directive.ts
+++ b/libs/ui/dialog/brain/src/lib/brn-dialog-trigger.directive.ts
@@ -1,12 +1,15 @@
-import { Directive, inject, Input, signal } from '@angular/core';
+import { Directive, inject, input, Input, Signal, signal } from '@angular/core';
+import { BrnDialogRef } from './brn-dialog-ref';
+import { BrnDialogState } from './brn-dialog-state';
import { BrnDialogComponent } from './brn-dialog.component';
let idSequence = 0;
+
@Directive({
selector: 'button[brnDialogTrigger],button[brnDialogTriggerFor]',
standalone: true,
host: {
- '[id]': '_id()',
+ '[id]': 'id()',
'(click)': 'open()',
'aria-haspopup': 'dialog',
'[attr.aria-expanded]': "state() === 'open' ? 'true': 'false'",
@@ -17,19 +20,18 @@ let idSequence = 0;
})
export class BrnDialogTriggerDirective {
protected _brnDialog = inject(BrnDialogComponent, { optional: true });
- protected _id = signal('brn-dialog-trigger-' + idSequence++);
- state = this._brnDialog?.state ?? signal('closed');
- dialogId = 'brn-dialog-' + (this._brnDialog?.dialogId ?? idSequence++);
+ protected readonly _brnDialogRef = inject(BrnDialogRef, { optional: true });
- @Input()
- set id(newId: string) {
- this._id.set(newId);
- }
+ public readonly id = input(`brn-dialog-trigger-${idSequence++}`);
+
+ public readonly state: Signal = this._brnDialogRef?.state ?? signal('closed');
+ public readonly dialogId = `brn-dialog-${this._brnDialogRef?.dialogId ?? idSequence++}`;
@Input()
set brnDialogTriggerFor(brnDialog: BrnDialogComponent) {
this._brnDialog = brnDialog;
}
+
open() {
this._brnDialog?.open();
}
diff --git a/libs/ui/dialog/brain/src/lib/brn-dialog.component.ts b/libs/ui/dialog/brain/src/lib/brn-dialog.component.ts
index 5859ea6a4..203b47a76 100644
--- a/libs/ui/dialog/brain/src/lib/brn-dialog.component.ts
+++ b/libs/ui/dialog/brain/src/lib/brn-dialog.component.ts
@@ -10,21 +10,28 @@ import {
import {
ChangeDetectionStrategy,
Component,
+ EffectRef,
ElementRef,
+ EventEmitter,
+ Injector,
Input,
Output,
TemplateRef,
ViewContainerRef,
ViewEncapsulation,
booleanAttribute,
+ computed,
+ effect,
inject,
numberAttribute,
+ runInInjectionContext,
+ signal,
} from '@angular/core';
-import { BrnDialogOptions, BrnDialogService, provideBrnDialog } from './brn-dialog.service';
-
-let dialogSequence = 0;
-
-export type BrnDialogState = 'open' | 'closed';
+import { take } from 'rxjs';
+import { BrnDialogOptions, DEFAULT_BRN_DIALOG_OPTIONS } from './brn-dialog-options';
+import { BrnDialogRef } from './brn-dialog-ref';
+import { BrnDialogState } from './brn-dialog-state';
+import { BrnDialogService } from './brn-dialog.service';
@Component({
selector: 'brn-dialog',
@@ -32,7 +39,6 @@ export type BrnDialogState = 'open' | 'closed';
template: `
`,
- providers: [provideBrnDialog()],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
exportAs: 'brnDialog',
@@ -40,39 +46,26 @@ export type BrnDialogState = 'open' | 'closed';
export class BrnDialogComponent {
private readonly _dialogService = inject(BrnDialogService);
private readonly _vcr = inject(ViewContainerRef);
- private _contentTemplate: TemplateRef | undefined;
public readonly positionBuilder = inject(OverlayPositionBuilder);
public readonly ssos = inject(ScrollStrategyOptions);
- public readonly state = this._dialogService.state;
- public readonly dialogId = dialogSequence++;
+ private readonly _injector = inject(Injector);
private _context = {};
- protected _options: BrnDialogOptions = {
- role: 'dialog',
- id: 'brn-dialog-' + this.dialogId,
- attachPositions: [],
- attachTo: null,
- autoFocus: 'first-tabbable',
- backdropClass: '',
- closeDelay: 0,
- closeOnOutsidePointerEvents: true,
- hasBackdrop: true,
- panelClass: '',
- positionStrategy: null,
- restoreFocus: true,
- scrollStrategy: null,
- disableClose: false,
- ariaDescribedBy: 'brn-dialog-description-' + this.dialogId,
- ariaLabelledBy: 'brn-dialog-title-' + this.dialogId,
- ariaLabel: undefined,
- ariaModal: true,
+ protected _options: Partial = {
+ ...DEFAULT_BRN_DIALOG_OPTIONS,
};
+ private _contentTemplate: TemplateRef | undefined;
+ private _dialogRef = signal(undefined);
+ private _dialogStateEffectRef?: EffectRef;
+
+ public readonly state = computed(() => this._dialogRef()?.state() ?? 'closed');
+
@Output()
- public readonly closed = this._dialogService.closed;
+ public readonly closed = new EventEmitter();
@Output()
- public readonly stateChanged = this._dialogService.stateChange;
+ public readonly stateChanged = new EventEmitter();
// eslint-disable-next-line @angular-eslint/no-input-rename
@Input('state')
@@ -121,6 +114,11 @@ export class BrnDialogComponent {
this._options['closeOnOutsidePointerEvents'] = closeOnOutsidePointerEvents;
}
+ @Input({ transform: booleanAttribute })
+ set closeOnBackdropClick(closeOnBackdropClick: boolean) {
+ this._options['closeOnBackdropClick'] = closeOnBackdropClick;
+ }
+
@Input()
set attachTo(attachTo: FlexibleConnectedPositionStrategyOrigin | null | undefined) {
this._options['attachTo'] = attachTo;
@@ -173,54 +171,70 @@ export class BrnDialogComponent {
this.setAriaModal(isModal);
}
- open() {
- if (!this._contentTemplate) return;
- this._dialogService.open(
- this._vcr,
+ public open() {
+ if (!this._contentTemplate || this._dialogRef()) return;
+
+ this._dialogStateEffectRef?.destroy();
+
+ const dialogRef = this._dialogService.open(
this._contentTemplate,
+ this._vcr,
this._context as DialogContext,
this._options,
);
+
+ this._dialogRef.set(dialogRef);
+
+ runInInjectionContext(this._injector, () => {
+ this._dialogStateEffectRef = effect(() => this.stateChanged.emit(dialogRef.state()));
+ });
+
+ dialogRef.closed$.pipe(take(1)).subscribe((result) => {
+ this._dialogRef.set(undefined);
+ this.closed.emit(result);
+ });
}
- close(delay?: number) {
- this._dialogService.close(delay ?? this._options.closeDelay);
+ public close(result: any, delay?: number) {
+ this._dialogRef()?.close(result, delay ?? this._options.closeDelay);
}
- registerTemplate(tpl: TemplateRef) {
- this._contentTemplate = tpl;
+ public registerTemplate(template: TemplateRef) {
+ this._contentTemplate = template;
}
- setOverlayClass(overlayClass: string | null | undefined) {
+ public setOverlayClass(overlayClass: string | null | undefined) {
this._options['backdropClass'] = overlayClass ?? '';
+ this._dialogRef()?.setOverlayClass(overlayClass);
}
- setPanelClass(panelClass: string | null | undefined) {
+ public setPanelClass(panelClass: string | null | undefined) {
this._options['panelClass'] = panelClass ?? '';
+ this._dialogRef()?.setPanelClass(panelClass);
}
- setContext(context: unknown) {
+ public setContext(context: unknown) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
this._context = { ...this._context, ...context };
}
- setAriaDescribedBy(ariaDescribedBy: string | null | undefined) {
+ public setAriaDescribedBy(ariaDescribedBy: string | null | undefined) {
this._options = { ...this._options, ariaDescribedBy };
- this._dialogService.setAriaDescribedBy(ariaDescribedBy);
+ this._dialogRef()?.setAriaDescribedBy(ariaDescribedBy);
}
- setAriaLabelledBy(ariaLabelledBy: string | null | undefined) {
+ public setAriaLabelledBy(ariaLabelledBy: string | null | undefined) {
this._options = { ...this._options, ariaLabelledBy };
- this._dialogService.setAriaLabelledBy(ariaLabelledBy);
+ this._dialogRef()?.setAriaLabelledBy(ariaLabelledBy);
}
- setAriaLabel(ariaLabel: string | null | undefined) {
+ public setAriaLabel(ariaLabel: string | null | undefined) {
this._options = { ...this._options, ariaLabel };
- this._dialogService.setAriaLabel(ariaLabel);
+ this._dialogRef()?.setAriaLabel(ariaLabel);
}
- setAriaModal(ariaModal: boolean) {
+ public setAriaModal(ariaModal: boolean) {
this._options = { ...this._options, ariaModal };
}
}
diff --git a/libs/ui/dialog/brain/src/lib/brn-dialog.service.ts b/libs/ui/dialog/brain/src/lib/brn-dialog.service.ts
index 705aed85e..936313e44 100644
--- a/libs/ui/dialog/brain/src/lib/brn-dialog.service.ts
+++ b/libs/ui/dialog/brain/src/lib/brn-dialog.service.ts
@@ -1,58 +1,28 @@
-import { AutoFocusTarget, Dialog, DIALOG_DATA, DIALOG_SCROLL_STRATEGY_PROVIDER, DialogRef } from '@angular/cdk/dialog';
-import {
- ComponentType,
- ConnectedPosition,
- FlexibleConnectedPositionStrategyOrigin,
- OverlayPositionBuilder,
- PositionStrategy,
- ScrollStrategy,
- ScrollStrategyOptions,
-} from '@angular/cdk/overlay';
+import { Dialog, DIALOG_DATA } from '@angular/cdk/dialog';
+import { ComponentType, OverlayPositionBuilder, ScrollStrategyOptions } from '@angular/cdk/overlay';
import {
computed,
effect,
- ElementRef,
- EventEmitter,
+ EffectRef,
inject,
Injectable,
- OnDestroy,
- Renderer2,
+ InjectOptions,
+ Injector,
+ RendererFactory2,
+ runInInjectionContext,
signal,
+ StaticProvider,
TemplateRef,
ViewContainerRef,
} from '@angular/core';
import { filter, Subject, takeUntil } from 'rxjs';
+import { BrnDialogOptions } from './brn-dialog-options';
+import { BrnDialogRef } from './brn-dialog-ref';
+import { BrnDialogState } from './brn-dialog-state';
-export const provideBrnDialog = () => [Dialog, BrnDialogService, DIALOG_SCROLL_STRATEGY_PROVIDER];
-
-export type BrnDialogOptions = {
- id: string;
- role: 'dialog' | 'alertdialog';
- hasBackdrop: boolean;
- panelClass: string | string[];
- backdropClass: string | string[];
- positionStrategy: PositionStrategy | null | undefined;
- scrollStrategy: ScrollStrategy | null | undefined;
- restoreFocus: boolean | string | ElementRef;
- closeDelay: number;
- closeOnOutsidePointerEvents: boolean;
- attachTo: FlexibleConnectedPositionStrategyOrigin | null | undefined;
- attachPositions: ConnectedPosition[];
- autoFocus: AutoFocusTarget | string;
- disableClose: boolean;
- ariaDescribedBy: string | null | undefined;
- ariaLabelledBy: string | null | undefined;
- ariaLabel: string | null | undefined;
- ariaModal: boolean;
-};
-
-export type BrnDialogContext = T & { close: () => void };
-
-export const injectBrnDialogCtx = (): BrnDialogContext => {
- return inject(DIALOG_DATA);
-};
+let dialogSequence = 0;
-const cssClassesToArray = (classes: string | string[] | undefined | null, defaultClass = ''): string[] => {
+export const cssClassesToArray = (classes: string | string[] | undefined | null, defaultClass = ''): string[] => {
if (typeof classes === 'string') {
const splitClasses = classes.trim().split(' ');
if (splitClasses.length === 0) {
@@ -63,62 +33,64 @@ const cssClassesToArray = (classes: string | string[] | undefined | null, defaul
return classes ?? [];
};
-@Injectable()
-export class BrnDialogService implements OnDestroy {
- private _destroyed$ = new Subject();
- private _previousTimeout: ReturnType | undefined;
-
- private _cdkDialog = inject(Dialog);
- private _renderer = inject(Renderer2);
- private _positionBuilder = inject(OverlayPositionBuilder);
- private _sso = inject(ScrollStrategyOptions);
- private _dialogRef?: DialogRef;
-
- private readonly _open = signal(false);
- public state = computed(() => (this._open() ? 'open' : 'closed'));
- private _overlay: HTMLElement | null = null;
- private _backdrop: HTMLElement | null = null;
-
- public closed = new EventEmitter();
- public stateChange = new EventEmitter<'open' | 'closed'>();
-
- constructor() {
- effect(
- () => {
- this.stateChange.emit(this.state());
- if (this._overlay) {
- this._renderer.setAttribute(this._overlay, 'data-state', this.state());
- }
- if (this._backdrop) {
- this._renderer.setAttribute(this._backdrop, 'data-state', this.state());
- }
- },
- { allowSignalWrites: true },
- );
- }
+export type BrnDialogContext = T & { close: (result?: any) => void };
+
+/** @deprecated `injectBrnDialogCtx` will no longer be supported once components are stable. Use `injectBrnDialogContext` instead. */
+export const injectBrnDialogCtx = (): BrnDialogContext => {
+ return inject(DIALOG_DATA);
+};
+
+export const injectBrnDialogContext = (options: InjectOptions = {}) => {
+ return inject(DIALOG_DATA, options) as DialogContext;
+};
+
+@Injectable({ providedIn: 'root' })
+export class BrnDialogService {
+ private readonly _cdkDialog = inject(Dialog);
+ private readonly _rendererFactory = inject(RendererFactory2);
+ private readonly _renderer = this._rendererFactory.createRenderer(null, null);
+ private readonly _positionBuilder = inject(OverlayPositionBuilder);
+ private readonly _sso = inject(ScrollStrategyOptions);
+ private readonly _injector = inject(Injector);
public open(
- vcr: ViewContainerRef,
content: ComponentType | TemplateRef,
+ vcr?: ViewContainerRef,
context?: DialogContext,
options?: Partial,
- ): void {
- if (this._open() || (options?.id && this._cdkDialog.getDialogById(options.id))) {
- return;
+ ) {
+ if (options?.id && this._cdkDialog.getDialogById(options.id)) {
+ throw new Error(`Dialog with ID: ${options.id} already exists`);
}
+
const positionStrategy =
options?.positionStrategy ??
(options?.attachTo && options?.attachPositions && options?.attachPositions?.length > 0
? this._positionBuilder?.flexibleConnectedTo(options.attachTo).withPositions(options.attachPositions ?? [])
: this._positionBuilder.global().centerHorizontally().centerVertically());
- const contextOrData = { ...context, close: () => this.close(options?.closeDelay) };
+
+ let brnDialogRef!: BrnDialogRef;
+ let effectRef!: EffectRef;
+
+ const contextOrData: BrnDialogContext = {
+ ...context,
+ close: (result: any = undefined) => brnDialogRef.close(result, options?.closeDelay),
+ };
+
+ const destroyed$ = new Subject();
+ const open = signal(true);
+ const state = computed(() => (open() ? 'open' : 'closed'));
+ const dialogId = dialogSequence++;
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
- this._dialogRef = this._cdkDialog.open(content, {
- id: options?.id,
+ const cdkDialogRef = this._cdkDialog.open(content, {
+ id: options?.id ?? `brn-dialog-${dialogId}`,
role: options?.role,
viewContainerRef: vcr,
- templateContext: contextOrData,
+ templateContext: () => ({
+ $implicit: contextOrData,
+ }),
data: contextOrData,
hasBackdrop: options?.hasBackdrop,
panelClass: cssClassesToArray(options?.panelClass),
@@ -128,71 +100,76 @@ export class BrnDialogService implements OnDestroy {
restoreFocus: options?.restoreFocus,
disableClose: true,
autoFocus: options?.autoFocus ?? 'first-tabbable',
- ariaDescribedBy: options?.ariaDescribedBy,
- ariaLabelledBy: options?.ariaLabelledBy,
+ ariaDescribedBy: options?.ariaDescribedBy ?? `brn-dialog-description-${dialogId}`,
+ ariaLabelledBy: options?.ariaLabelledBy ?? `brn-dialog-title-${dialogId}`,
ariaLabel: options?.ariaLabel,
ariaModal: options?.ariaModal,
+ providers: (cdkDialogRef) => {
+ brnDialogRef = new BrnDialogRef(cdkDialogRef, open, state, dialogId, options as BrnDialogOptions);
+
+ runInInjectionContext(this._injector, () => {
+ effectRef = effect(() => {
+ if (overlay) {
+ this._renderer.setAttribute(overlay, 'data-state', state());
+ }
+ if (backdrop) {
+ this._renderer.setAttribute(backdrop, 'data-state', state());
+ }
+ });
+ });
+
+ let providers: StaticProvider[] = [
+ {
+ provide: BrnDialogRef,
+ useValue: brnDialogRef,
+ },
+ ];
+
+ if (options?.providers) {
+ if (typeof options.providers === 'function') {
+ providers.push(...options.providers());
+ }
+
+ if (Array.isArray(options.providers)) {
+ providers.push(...options.providers);
+ }
+ }
+
+ return providers;
+ },
});
- if (!this._dialogRef) return;
- this._overlay = this._dialogRef.overlayRef.overlayElement;
- this._backdrop = this._dialogRef.overlayRef.backdropElement;
+ const overlay = cdkDialogRef.overlayRef.overlayElement;
+ const backdrop = cdkDialogRef.overlayRef.backdropElement;
if (options?.closeOnOutsidePointerEvents) {
- this._dialogRef.outsidePointerEvents.pipe(takeUntil(this._destroyed$)).subscribe(() => {
- this.close(options?.closeDelay);
+ cdkDialogRef.outsidePointerEvents.pipe(takeUntil(destroyed$)).subscribe(() => {
+ brnDialogRef.close(undefined, options?.closeDelay);
+ });
+ }
+
+ if (options?.closeOnBackdropClick) {
+ cdkDialogRef.backdropClick.pipe(takeUntil(destroyed$)).subscribe((e) => {
+ brnDialogRef.close(undefined, options?.closeDelay);
});
}
+
if (!options?.disableClose) {
- this._dialogRef.keydownEvents
+ cdkDialogRef.keydownEvents
.pipe(
filter((e) => e.key === 'Escape'),
- takeUntil(this._destroyed$),
+ takeUntil(destroyed$),
)
.subscribe(() => {
- this.close(options?.closeDelay);
+ brnDialogRef.close(undefined, options?.closeDelay);
});
}
- this._dialogRef.closed.pipe(takeUntil(this._destroyed$)).subscribe(() => {
- this._open.set(false);
- this.closed.emit();
+ cdkDialogRef.closed.pipe(takeUntil(destroyed$)).subscribe(() => {
+ effectRef?.destroy();
+ destroyed$.next();
});
- this._open.set(true);
- }
-
- public close(delay = 0): void {
- if (!this._open()) return;
-
- this._open.set(false);
-
- if (this._previousTimeout) {
- clearTimeout(this._previousTimeout);
- }
-
- this._previousTimeout = setTimeout(() => {
- this._dialogRef?.close();
- }, delay);
- }
-
- public setAriaDescribedBy(ariaDescribedBy: string | null | undefined) {
- if (!this._dialogRef) return;
- this._dialogRef.config.ariaDescribedBy = ariaDescribedBy;
- }
-
- public setAriaLabelledBy(ariaLabelledBy: string | null | undefined) {
- if (!this._dialogRef) return;
- this._dialogRef.config.ariaLabelledBy = ariaLabelledBy;
- }
-
- public setAriaLabel(ariaLabel: string | null | undefined) {
- if (!this._dialogRef) return;
- this._dialogRef.config.ariaLabel = ariaLabel;
- }
-
- public ngOnDestroy(): void {
- this._destroyed$.next();
- this._destroyed$.complete();
+ return brnDialogRef;
}
}
diff --git a/libs/ui/dialog/dialog.stories.ts b/libs/ui/dialog/dialog.stories.ts
index 7fd257ed7..e0bce3a25 100644
--- a/libs/ui/dialog/dialog.stories.ts
+++ b/libs/ui/dialog/dialog.stories.ts
@@ -1,9 +1,28 @@
-import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
+import { Component, HostBinding, inject } from '@angular/core';
+import { provideIcons } from '@ng-icons/core';
+import { lucideCheck } from '@ng-icons/lucide';
+import { Meta, StoryObj, moduleMetadata } from '@storybook/angular';
import { HlmButtonDirective } from '../button/helm/src';
+import { HlmIconComponent } from '../icon/helm/src';
import { HlmInputDirective } from '../input/helm/src';
import { HlmLabelDirective } from '../label/helm/src';
-import { BrnDialogImports } from './brain/src';
-import { HlmDialogComponent, HlmDialogImports } from './helm/src';
+import { HlmTableComponent, HlmTdComponent, HlmThComponent, HlmTrowComponent } from '../table/helm/src';
+import {
+ BrnDialogContentDirective,
+ BrnDialogImports,
+ BrnDialogRef,
+ BrnDialogTriggerDirective,
+ injectBrnDialogContext,
+} from './brain/src';
+import {
+ HlmDialogComponent,
+ HlmDialogContentComponent,
+ HlmDialogDescriptionDirective,
+ HlmDialogHeaderComponent,
+ HlmDialogImports,
+ HlmDialogService,
+ HlmDialogTitleDirective,
+} from './helm/src';
const meta: Meta = {
title: 'Dialog',
@@ -53,3 +72,258 @@ export const Default: Story = {
`,
}),
};
+
+@Component({
+ selector: 'nested-dialog-story',
+ standalone: true,
+ imports: [
+ HlmDialogComponent,
+ HlmButtonDirective,
+ BrnDialogTriggerDirective,
+ BrnDialogContentDirective,
+ HlmDialogContentComponent,
+ HlmDialogHeaderComponent,
+ HlmDialogDescriptionDirective,
+ HlmDialogTitleDirective,
+ ],
+ template: `
+
+ Open Dialog
+
+
+ First dialog
+ Click the button below to open a nested dialog.
+
+
+
+ Open Nested Dialog
+
+
+ Nested dialog
+ I am a nested dialog!
+
+
+ Close Nested Dialog
+
+
+
+
+ `,
+})
+class NestedDialogStory {}
+
+export const NestedDialog: Story = {
+ name: 'Nested Dialog',
+ decorators: [
+ moduleMetadata({
+ imports: [NestedDialogStory],
+ }),
+ ],
+ render: () => ({
+ template: ` `,
+ }),
+};
+
+type ExampleUser = {
+ name: string;
+ email: string;
+ phone: string;
+};
+
+@Component({
+ selector: 'dialog-dynamic-component-story',
+ standalone: true,
+ imports: [HlmButtonDirective],
+ template: `
+ Select User
+ `,
+})
+class DialogDynamicComponentStory {
+ private readonly _hlmDialogService = inject(HlmDialogService);
+
+ private readonly _users: ExampleUser[] = [
+ {
+ name: 'Helena Chambers',
+ email: 'helenachambers@chorizon.com',
+ phone: '+1 (812) 588-3759',
+ },
+ {
+ name: 'Josie Crane',
+ email: 'josiecrane@hinway.com',
+ phone: '+1 (884) 523-3324',
+ },
+ {
+ name: 'Lou Hartman',
+ email: 'louhartman@optyk.com',
+ phone: '+1 (912) 479-3998',
+ },
+ {
+ name: 'Lydia Zimmerman',
+ email: 'lydiazimmerman@ultrasure.com',
+ phone: '+1 (944) 511-2111',
+ },
+ ];
+
+ public openDynamicComponent() {
+ const dialogRef = this._hlmDialogService.open(SelectUserComponent, {
+ context: {
+ users: this._users,
+ },
+ contentClass: 'sm:!max-w-[750px]',
+ });
+
+ dialogRef.closed$.subscribe((user) => {
+ if (user) {
+ console.log('Selected user:', user);
+ }
+ });
+ }
+}
+
+@Component({
+ selector: 'dynamic-content',
+ standalone: true,
+ imports: [
+ HlmDialogHeaderComponent,
+ HlmDialogTitleDirective,
+ HlmDialogDescriptionDirective,
+ HlmTableComponent,
+ HlmThComponent,
+ HlmTrowComponent,
+ HlmTdComponent,
+ HlmButtonDirective,
+ HlmIconComponent,
+ ],
+ providers: [provideIcons({ lucideCheck })],
+ template: `
+
+ Select user
+ Click a row to select a user.
+
+
+
+
+ Name
+ Email
+ Phone
+
+ @for (user of users; track user.name) {
+
+
+ {{ user.name }}
+ {{ user.email }}
+ {{ user.phone }}
+
+
+ }
+
+ `,
+})
+class SelectUserComponent {
+ @HostBinding('class') private readonly _class: string = 'flex flex-col gap-4';
+
+ private readonly _hlmDialogService = inject(HlmDialogService);
+ private readonly _dialogRef = inject>(BrnDialogRef);
+ private readonly _dialogContext = injectBrnDialogContext<{ users: ExampleUser[] }>();
+
+ protected readonly users = this._dialogContext.users;
+
+ public selectUser(user: ExampleUser) {
+ this._hlmDialogService.open(SelectUserComponent, { context: { users: [user] }, contentClass: 'sm:!max-w-[750px]' });
+ // this._dialogRef.close(user);
+ }
+}
+
+export const DynamicComponent: Story = {
+ name: 'Dynamic Component',
+ decorators: [
+ moduleMetadata({
+ imports: [DialogDynamicComponentStory],
+ }),
+ ],
+ render: () => ({
+ template: ` `,
+ }),
+};
+
+@Component({
+ selector: 'nested-dialog-dynamic-first',
+ standalone: true,
+ imports: [
+ HlmButtonDirective,
+ HlmDialogContentComponent,
+ HlmDialogHeaderComponent,
+ HlmDialogTitleDirective,
+ HlmDialogDescriptionDirective,
+ ],
+ template: `
+
+ First dialog
+ Click the button below to open a nested dialog.
+
+
+ Open Nested Dialog
+ `,
+ host: {
+ class: 'flex flex-col gap-4',
+ },
+})
+class NestedDialogDynamicFirstComponent {
+ private readonly _hlmDialogService = inject(HlmDialogService);
+
+ public openNestedDialog() {
+ this._hlmDialogService.open(NestedDialogDynamicNestedComponent);
+ }
+}
+
+@Component({
+ selector: 'nested-dialog-dynamic-nested',
+ standalone: true,
+ imports: [HlmButtonDirective, HlmDialogHeaderComponent, HlmDialogTitleDirective, HlmDialogDescriptionDirective],
+ template: `
+
+ Nested dialog
+ I am a nested dialog!
+
+
+ Close Nested Dialog
+ `,
+ host: {
+ class: 'flex flex-col gap-4',
+ },
+})
+class NestedDialogDynamicNestedComponent {
+ private readonly _brnDialogRef = inject(BrnDialogRef);
+
+ public close() {
+ this._brnDialogRef.close();
+ }
+}
+
+@Component({
+ selector: 'nested-dialog-dynamic-content-story',
+ standalone: true,
+ imports: [HlmButtonDirective],
+ template: `
+ Open Dialog
+ `,
+})
+class NestedDialogDynamicComponentStory {
+ private readonly _hlmDialogService = inject(HlmDialogService);
+
+ public openDialog() {
+ this._hlmDialogService.open(NestedDialogDynamicFirstComponent);
+ }
+}
+
+export const NestedDynamicComponent: Story = {
+ name: 'Nested Dynamic Component',
+ decorators: [
+ moduleMetadata({
+ imports: [NestedDialogDynamicComponentStory],
+ }),
+ ],
+ render: () => ({
+ template: ` `,
+ }),
+};
diff --git a/libs/ui/dialog/helm/src/index.ts b/libs/ui/dialog/helm/src/index.ts
index 0c3a4925a..fb6b7041a 100644
--- a/libs/ui/dialog/helm/src/index.ts
+++ b/libs/ui/dialog/helm/src/index.ts
@@ -17,6 +17,7 @@ export * from './lib/hlm-dialog-header.component';
export * from './lib/hlm-dialog-overlay.directive';
export * from './lib/hlm-dialog-title.directive';
export * from './lib/hlm-dialog.component';
+export * from './lib/hlm-dialog.service';
export const HlmDialogImports = [
HlmDialogComponent,
diff --git a/libs/ui/dialog/helm/src/lib/hlm-dialog-content.component.ts b/libs/ui/dialog/helm/src/lib/hlm-dialog-content.component.ts
index f2b21ab43..3dbfb9cf2 100644
--- a/libs/ui/dialog/helm/src/lib/hlm-dialog-content.component.ts
+++ b/libs/ui/dialog/helm/src/lib/hlm-dialog-content.component.ts
@@ -1,7 +1,8 @@
-import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, input, signal } from '@angular/core';
+import { NgComponentOutlet } from '@angular/common';
+import { ChangeDetectionStrategy, Component, ViewEncapsulation, computed, inject, input } from '@angular/core';
import { lucideX } from '@ng-icons/lucide';
-import { hlm, injectExposesStateProvider } from '@spartan-ng/ui-core';
-import { BrnDialogCloseDirective } from '@spartan-ng/ui-dialog-brain';
+import { hlm } from '@spartan-ng/ui-core';
+import { BrnDialogCloseDirective, BrnDialogRef, injectBrnDialogContext } from '@spartan-ng/ui-dialog-brain';
import { HlmIconComponent, provideIcons } from '@spartan-ng/ui-icon-helm';
import { ClassValue } from 'clsx';
import { HlmDialogCloseDirective } from './hlm-dialog-close.directive';
@@ -9,14 +10,19 @@ import { HlmDialogCloseDirective } from './hlm-dialog-close.directive';
@Component({
selector: 'hlm-dialog-content',
standalone: true,
- imports: [BrnDialogCloseDirective, HlmDialogCloseDirective, HlmIconComponent],
+ imports: [NgComponentOutlet, BrnDialogCloseDirective, HlmDialogCloseDirective, HlmIconComponent],
providers: [provideIcons({ lucideX })],
host: {
'[class]': '_computedClass()',
'[attr.data-state]': 'state()',
},
template: `
-
+ @if (component) {
+
+ } @else {
+
+ }
+
Close
@@ -26,14 +32,20 @@ import { HlmDialogCloseDirective } from './hlm-dialog-close.directive';
encapsulation: ViewEncapsulation.None,
})
export class HlmDialogContentComponent {
- private readonly _statusProvider = injectExposesStateProvider({ host: true });
- public readonly state = this._statusProvider.state ?? signal('closed').asReadonly();
+ private readonly _dialogRef = inject(BrnDialogRef);
+ private readonly _dialogContext = injectBrnDialogContext({ optional: true });
+
+ public readonly state = computed(() => this._dialogRef?.state() ?? 'closed');
+
+ public readonly component = this._dialogContext?.['$component'];
+ private readonly _dynamicComponentClass = this._dialogContext?.['$dynamicComponentClass'];
public readonly userClass = input('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'border-border grid w-full max-w-lg relative gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%] sm:rounded-lg md:w-full',
this.userClass(),
+ this._dynamicComponentClass,
),
);
}
diff --git a/libs/ui/dialog/helm/src/lib/hlm-dialog-overlay.directive.ts b/libs/ui/dialog/helm/src/lib/hlm-dialog-overlay.directive.ts
index 75df90b17..5bb2ea9cd 100644
--- a/libs/ui/dialog/helm/src/lib/hlm-dialog-overlay.directive.ts
+++ b/libs/ui/dialog/helm/src/lib/hlm-dialog-overlay.directive.ts
@@ -2,6 +2,9 @@ import { computed, Directive, effect, input } from '@angular/core';
import { hlm, injectCustomClassSettable } from '@spartan-ng/ui-core';
import { ClassValue } from 'clsx';
+export const hlmDialogOverlayClass =
+ 'bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0';
+
@Directive({
selector: '[hlmDialogOverlay],brn-dialog-overlay[hlm]',
standalone: true,
@@ -10,14 +13,11 @@ export class HlmDialogOverlayDirective {
private readonly _classSettable = injectCustomClassSettable({ optional: true, host: true });
public readonly userClass = input('', { alias: 'class' });
- protected readonly _computedClass = computed(() =>
- hlm(
- 'bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
- this.userClass(),
- ),
- );
+ protected readonly _computedClass = computed(() => hlm(hlmDialogOverlayClass, this.userClass()));
constructor() {
- effect(() => this._classSettable?.setClassToCustomElement(this._computedClass()));
+ effect(() => {
+ this._classSettable?.setClassToCustomElement(this._computedClass());
+ });
}
}
diff --git a/libs/ui/dialog/helm/src/lib/hlm-dialog.component.ts b/libs/ui/dialog/helm/src/lib/hlm-dialog.component.ts
index 9dd983d79..0baa920f5 100644
--- a/libs/ui/dialog/helm/src/lib/hlm-dialog.component.ts
+++ b/libs/ui/dialog/helm/src/lib/hlm-dialog.component.ts
@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, forwardRef, ViewEncapsulation } from '@angular/core';
-import { BrnDialogComponent, BrnDialogOverlayComponent, provideBrnDialog } from '@spartan-ng/ui-dialog-brain';
+import { BrnDialogComponent, BrnDialogOverlayComponent } from '@spartan-ng/ui-dialog-brain';
import { HlmDialogOverlayDirective } from './hlm-dialog-overlay.directive';
@Component({
@@ -7,7 +7,6 @@ import { HlmDialogOverlayDirective } from './hlm-dialog-overlay.directive';
standalone: true,
imports: [BrnDialogComponent, BrnDialogOverlayComponent, HlmDialogOverlayDirective],
providers: [
- provideBrnDialog(),
{
provide: BrnDialogComponent,
useExisting: forwardRef(() => HlmDialogComponent),
diff --git a/libs/ui/dialog/helm/src/lib/hlm-dialog.service.ts b/libs/ui/dialog/helm/src/lib/hlm-dialog.service.ts
new file mode 100644
index 000000000..9e81e3cfb
--- /dev/null
+++ b/libs/ui/dialog/helm/src/lib/hlm-dialog.service.ts
@@ -0,0 +1,34 @@
+import { ComponentType } from '@angular/cdk/portal';
+import { inject, Injectable, TemplateRef } from '@angular/core';
+import {
+ BrnDialogOptions,
+ BrnDialogService,
+ cssClassesToArray,
+ DEFAULT_BRN_DIALOG_OPTIONS,
+} from '@spartan-ng/ui-dialog-brain';
+import { HlmDialogContentComponent } from './hlm-dialog-content.component';
+import { hlmDialogOverlayClass } from './hlm-dialog-overlay.directive';
+
+export type HlmDialogOptions = BrnDialogOptions & {
+ contentClass?: string;
+ context?: DialogContext;
+};
+
+@Injectable({
+ providedIn: 'root',
+})
+export class HlmDialogService {
+ private readonly _brnDialogService = inject(BrnDialogService);
+
+ public open(component: ComponentType | TemplateRef, options?: Partial) {
+ options = {
+ ...DEFAULT_BRN_DIALOG_OPTIONS,
+ closeDelay: 100,
+ ...(options ?? {}),
+ backdropClass: cssClassesToArray(`${hlmDialogOverlayClass} ${options?.backdropClass ?? ''}`),
+ context: { ...options?.context, $component: component, $dynamicComponentClass: options?.contentClass },
+ };
+
+ return this._brnDialogService.open(HlmDialogContentComponent, undefined, options.context, options);
+ }
+}
diff --git a/libs/ui/popover/brain/src/lib/brn-popover-trigger.directive.ts b/libs/ui/popover/brain/src/lib/brn-popover-trigger.directive.ts
index 74e6961ee..208402601 100644
--- a/libs/ui/popover/brain/src/lib/brn-popover-trigger.directive.ts
+++ b/libs/ui/popover/brain/src/lib/brn-popover-trigger.directive.ts
@@ -6,8 +6,7 @@ import { BrnPopoverComponent } from './brn-popover.component';
selector: 'button[brnPopoverTrigger],button[brnPopoverTriggerFor]',
standalone: true,
host: {
- '[id]': '_id()',
- '(click)': 'open()',
+ '[id]': 'id()',
'aria-haspopup': 'dialog',
'[attr.aria-expanded]': "state() === 'open' ? 'true': 'false'",
'[attr.data-state]': 'state()',
@@ -16,15 +15,18 @@ import { BrnPopoverComponent } from './brn-popover.component';
})
export class BrnPopoverTriggerDirective extends BrnDialogTriggerDirective {
private _host = inject(ElementRef, { host: true });
+
constructor() {
super();
if (!this._brnDialog) return;
this._brnDialog.attachTo = this._host.nativeElement;
+ this._brnDialog.closeOnOutsidePointerEvents = true;
}
@Input()
set brnPopoverTriggerFor(brnDialog: BrnPopoverComponent) {
brnDialog.attachTo = this._host.nativeElement;
+ brnDialog.closeOnOutsidePointerEvents = true;
super.brnDialogTriggerFor = brnDialog;
}
}
diff --git a/libs/ui/popover/brain/src/lib/brn-popover.component.ts b/libs/ui/popover/brain/src/lib/brn-popover.component.ts
index bf467993a..9ca6ff1e9 100644
--- a/libs/ui/popover/brain/src/lib/brn-popover.component.ts
+++ b/libs/ui/popover/brain/src/lib/brn-popover.component.ts
@@ -9,7 +9,7 @@ import {
untracked,
ViewEncapsulation,
} from '@angular/core';
-import { BrnDialogComponent, provideBrnDialog } from '@spartan-ng/ui-dialog-brain';
+import { BrnDialogComponent } from '@spartan-ng/ui-dialog-brain';
export type BrnPopoverAlign = 'start' | 'center' | 'end';
@Component({
@@ -19,7 +19,6 @@ export type BrnPopoverAlign = 'start' | 'center' | 'end';
`,
providers: [
- provideBrnDialog(),
{
provide: BrnDialogComponent,
useExisting: forwardRef(() => BrnPopoverComponent),
@@ -45,8 +44,8 @@ export class BrnPopoverComponent extends BrnDialogComponent {
constructor() {
super();
this.hasBackdrop = false;
- this.ariaDescribedBy = undefined;
- this.ariaLabelledBy = undefined;
+ this.ariaDescribedBy = '';
+ this.ariaLabelledBy = '';
this.scrollStrategy = this.ssos.reposition();
effect(() => {
diff --git a/libs/ui/sheet/brain/src/lib/brn-sheet.component.ts b/libs/ui/sheet/brain/src/lib/brn-sheet.component.ts
index 8537caea3..fb7c7e8ca 100644
--- a/libs/ui/sheet/brain/src/lib/brn-sheet.component.ts
+++ b/libs/ui/sheet/brain/src/lib/brn-sheet.component.ts
@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, forwardRef, Input, signal, ViewEncapsulation } from '@angular/core';
-import { BrnDialogComponent, provideBrnDialog } from '@spartan-ng/ui-dialog-brain';
+import { BrnDialogComponent } from '@spartan-ng/ui-dialog-brain';
@Component({
selector: 'brn-sheet',
@@ -8,7 +8,6 @@ import { BrnDialogComponent, provideBrnDialog } from '@spartan-ng/ui-dialog-brai
`,
providers: [
- provideBrnDialog(),
{
provide: BrnDialogComponent,
useExisting: forwardRef(() => BrnSheetComponent),
diff --git a/libs/ui/sheet/helm/src/lib/hlm-sheet.component.ts b/libs/ui/sheet/helm/src/lib/hlm-sheet.component.ts
index feae86b82..09952d128 100644
--- a/libs/ui/sheet/helm/src/lib/hlm-sheet.component.ts
+++ b/libs/ui/sheet/helm/src/lib/hlm-sheet.component.ts
@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, forwardRef, ViewEncapsulation } from '@angular/core';
-import { BrnDialogComponent, provideBrnDialog } from '@spartan-ng/ui-dialog-brain';
+import { BrnDialogComponent } from '@spartan-ng/ui-dialog-brain';
import { BrnSheetComponent, BrnSheetOverlayComponent } from '@spartan-ng/ui-sheet-brain';
import { HlmSheetOverlayDirective } from './hlm-sheet-overlay.directive';
@@ -8,7 +8,6 @@ import { HlmSheetOverlayDirective } from './hlm-sheet-overlay.directive';
standalone: true,
imports: [BrnSheetOverlayComponent, HlmSheetOverlayDirective],
providers: [
- provideBrnDialog(),
{
provide: BrnDialogComponent,
useExisting: forwardRef(() => BrnSheetComponent),