From d4ffb47c67436cf198a0d509f2eac4362213f6cf Mon Sep 17 00:00:00 2001 From: Tyler Smith Date: Wed, 6 Mar 2024 11:07:48 -0500 Subject: [PATCH] feat(dialog): open dynamic component in dialog (#180) refactor(dialog): working on new dialog approach refactor(dialog): refactor dialog component to use global dialog service refactor(dialog): remove provider function for BrnDialog feat(dialog): add HlmDialogService for opening components in a dialog dynamically chore: add dynamic dialog component section to docs chore: add note about injectDialogContext refactor(dialog): dialog overlay class names and dialog options as reusable defaults chore: add story for dialog dynamic components refactor(dialog): make HlmDialogService options optional (defaults are provided) refactor(dialog): continue support for injectBrnDialogCtx (deprecated) refactor(dialog): move (click) handlers back into metadata host refactor(dialog): prefer early returns refactor(dialog): injectDialogContext -> injectBrnDialogContext chore: add nested dialog stories and e2e tests refactor(dialog): use input function in brnDialogTrigger refactor(dialog): use optional chaining in dialog component refactor(dialog): _id() renamed to id(), use in inheriting classes refactor(popover): change ariaDescribedBy, ariaLabelledBy to empty string so not assigned defaults refactor(dialog): use correct member names after merge --- .../dialog-dynamic-component.preview.ts | 239 +++++++++++++++ .../components/(dialog)/dialog.page.ts | 17 ++ .../src/integration/dialog/dialog.cy.ts | 107 +++++++ .../lib/brn-alert-dialog-trigger.directive.ts | 3 +- .../src/lib/brn-alert-dialog.component.ts | 4 +- .../src/lib/hlm-alert-dialog.component.ts | 3 +- libs/ui/dialog/brain/src/index.ts | 3 + .../src/lib/brn-dialog-close.directive.ts | 15 +- .../src/lib/brn-dialog-content.directive.ts | 18 +- .../lib/brn-dialog-description.directive.ts | 10 +- .../brain/src/lib/brn-dialog-options.ts | 50 ++++ .../src/lib/brn-dialog-overlay.component.ts | 10 +- .../ui/dialog/brain/src/lib/brn-dialog-ref.ts | 62 ++++ .../dialog/brain/src/lib/brn-dialog-state.ts | 1 + .../src/lib/brn-dialog-title.directive.ts | 10 +- .../src/lib/brn-dialog-trigger.directive.ts | 20 +- .../brain/src/lib/brn-dialog.component.ts | 110 ++++--- .../brain/src/lib/brn-dialog.service.ts | 241 +++++++-------- libs/ui/dialog/dialog.stories.ts | 280 +++++++++++++++++- libs/ui/dialog/helm/src/index.ts | 1 + .../src/lib/hlm-dialog-content.component.ts | 26 +- .../src/lib/hlm-dialog-overlay.directive.ts | 14 +- .../helm/src/lib/hlm-dialog.component.ts | 3 +- .../dialog/helm/src/lib/hlm-dialog.service.ts | 34 +++ .../src/lib/brn-popover-trigger.directive.ts | 6 +- .../brain/src/lib/brn-popover.component.ts | 7 +- .../brain/src/lib/brn-sheet.component.ts | 3 +- .../sheet/helm/src/lib/hlm-sheet.component.ts | 3 +- 28 files changed, 1052 insertions(+), 248 deletions(-) create mode 100644 apps/app/src/app/pages/(components)/components/(dialog)/dialog-dynamic-component.preview.ts create mode 100644 libs/ui/dialog/brain/src/lib/brn-dialog-options.ts create mode 100644 libs/ui/dialog/brain/src/lib/brn-dialog-ref.ts create mode 100644 libs/ui/dialog/brain/src/lib/brn-dialog-state.ts create mode 100644 libs/ui/dialog/helm/src/lib/hlm-dialog.service.ts diff --git a/apps/app/src/app/pages/(components)/components/(dialog)/dialog-dynamic-component.preview.ts b/apps/app/src/app/pages/(components)/components/(dialog)/dialog-dynamic-component.preview.ts new file mode 100644 index 000000000..35ae8d0c4 --- /dev/null +++ b/apps/app/src/app/pages/(components)/components/(dialog)/dialog-dynamic-component.preview.ts @@ -0,0 +1,239 @@ +import { Component, HostBinding, inject } from '@angular/core'; +import { lucideCheck } from '@ng-icons/lucide'; +import { HlmButtonDirective } from '@spartan-ng/ui-button-helm'; +import { BrnDialogRef, injectBrnDialogContext } from '@spartan-ng/ui-dialog-brain'; +import { + HlmDialogDescriptionDirective, + HlmDialogHeaderComponent, + HlmDialogService, + HlmDialogTitleDirective, +} from '@spartan-ng/ui-dialog-helm'; +import { HlmIconComponent, provideIcons } from '@spartan-ng/ui-icon-helm'; +import { HlmTableComponent, HlmTdComponent, HlmThComponent, HlmTrowComponent } from '@spartan-ng/ui-table-helm'; + +type ExampleUser = { + name: string; + email: string; + phone: string; +}; + +@Component({ + selector: 'spartan-dialog-dynamic-component-preview', + standalone: true, + imports: [HlmButtonDirective], + template: ` + + `, +}) +export class DialogDynamicComponentPreviewComponent { + 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) { + + } + + `, +}) +class SelectUserComponent { + @HostBinding('class') private readonly _class: string = 'flex flex-col gap-4'; + + private readonly _dialogRef = inject>(BrnDialogRef); + private readonly _dialogContext = injectBrnDialogContext<{ users: ExampleUser[] }>(); + + protected readonly users = this._dialogContext.users; + + public selectUser(user: ExampleUser) { + this._dialogRef.close(user); + } +} + +export const dynamicComponentCode = ` +import { + HlmDialogDescriptionDirective, + HlmDialogHeaderComponent, + HlmDialogService, + HlmDialogTitleDirective, +} from '@spartan-ng/ui-dialog-helm'; +import { HlmIconComponent, provideIcons } from '@spartan-ng/ui-icon-helm'; +import { HlmTableComponent, HlmTdComponent, HlmThComponent, HlmTrowComponent } from '@spartan-ng/ui-table-helm'; + +type ExampleUser = { + name: string; + email: string; + phone: string; +}; + +@Component({ + selector: 'spartan-dialog-dynamic-component-preview', + standalone: true, + imports: [HlmButtonDirective], + template: \` + + \`, +}) +export class DialogDynamicComponentPreviewComponent { + 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) { + + } + + \`, +}) +class SelectUserComponent { + @HostBinding('class') private readonly _class: string = 'flex flex-col gap-4'; + + private readonly _dialogRef = inject>(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: ` + + + + +

First dialog

+

Click the button below to open a nested dialog.

+
+ + + + + +

Nested dialog

+

I am a 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: ` + + `, +}) +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) { + + } + + `, +}) +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.

+
+ + + `, + 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!

+
+ + + `, + 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: ` + + `, +}) +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 { + + } +