@ng-zen/cli 21.1.1 → 21.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,174 @@
1
+ import { Component, inject, input, output } from '@angular/core';
2
+ import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
3
+
4
+ import { ZenButton } from '../button';
5
+ import { ZenDialog } from './dialog';
6
+ import { DIALOG_REF, DialogConfig, ZenDialogService } from './dialog.service';
7
+
8
+ type Story = StoryObj<ZenDialogService>;
9
+
10
+ const component = `
11
+
12
+ ZenDialogService stories demonstrate dynamic dialog usage via service.
13
+
14
+ ### Usage
15
+
16
+ \`\`\`typescript
17
+ // Dialog content component
18
+ @Component({
19
+ template: \`
20
+ <p>{{ message() }}</p>
21
+ <button (click)="confirm.emit()">Confirm</button>
22
+ \`
23
+ })
24
+ class MyDialogContent {
25
+ readonly message = input<string>();
26
+ readonly confirm = output<void>();
27
+ }
28
+
29
+ // Open dialog
30
+ @Component({
31
+ template: \`<button (click)="open()">Open</button>\`,
32
+ providers: [ZenDialogService],
33
+ })
34
+ export class OpenComponent {
35
+ private readonly dialogService = inject(ZenDialogService);
36
+
37
+ open(): void {
38
+ const ref = this.dialogService.open(MyDialogContent, {
39
+ header: 'My Dialog',
40
+ size: 'md',
41
+ inputs: { message: 'Hello!' },
42
+ outputs: { confirm: () => ref.close() },
43
+ });
44
+ }
45
+ }
46
+ \`\`\`
47
+
48
+ ### DIALOG_REF
49
+
50
+ Close dialog from within the content component:
51
+
52
+ \`\`\`typescript
53
+ @Component({...})
54
+ class MyDialogContent {
55
+ private readonly dialogRef = inject(DIALOG_REF);
56
+
57
+ close() {
58
+ this.dialogRef.close();
59
+ }
60
+ }
61
+ \`\`\`
62
+
63
+ See [GitHub](https://github.com/kstepien3/ng-zen), [MDN Dialog Element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog)
64
+ `;
65
+
66
+ @Component({
67
+ template: `
68
+ <p>{{ message() }}</p>
69
+ <div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
70
+ <button (click)="confirmClick.emit('confirmed!')" zen-btn>Confirm</button>
71
+ <button (click)="cancel()" zen-btn>Cancel</button>
72
+ </div>
73
+ `,
74
+ standalone: true,
75
+ imports: [ZenButton],
76
+ })
77
+ class DemoDialogContent {
78
+ readonly message = input.required<string>();
79
+ readonly confirmClick = output<string>();
80
+ readonly cancelClick = output<void>();
81
+ readonly dialogRef = inject(DIALOG_REF);
82
+
83
+ cancel(): void {
84
+ this.cancelClick.emit();
85
+ this.dialogRef.close();
86
+ }
87
+ }
88
+
89
+ @Component({
90
+ // eslint-disable-next-line @angular-eslint/component-selector
91
+ selector: 'app-service-demo',
92
+ template: `
93
+ <button (click)="openDialog()" zen-btn>Open via Service</button>
94
+ `,
95
+ standalone: true,
96
+ imports: [ZenButton],
97
+ providers: [ZenDialogService],
98
+ })
99
+ class ServiceDemoComponent {
100
+ readonly args = input<DialogConfig<DemoDialogContent>>();
101
+
102
+ private readonly dialogService = inject(ZenDialogService);
103
+
104
+ openDialog(): void {
105
+ const ref = this.dialogService.open(DemoDialogContent, {
106
+ ...this.args(),
107
+ inputs: { message: 'This dialog was opened via service!' },
108
+ outputs: {
109
+ confirmClick: (value: string) => {
110
+ alert(`Confirmed: ${value}`);
111
+ ref.close();
112
+ },
113
+ cancelClick: () => {
114
+ console.info('Actually canceled via DIALOG_REF');
115
+ },
116
+ },
117
+ });
118
+ }
119
+ }
120
+
121
+ const meta = {
122
+ title: 'UI/Dialog/Dynamic',
123
+ component: ZenDialog,
124
+ tags: [],
125
+ parameters: {
126
+ docs: {
127
+ description: {
128
+ component,
129
+ },
130
+ canvas: {
131
+ sourceState: 'none',
132
+ },
133
+ },
134
+ },
135
+ decorators: [
136
+ moduleMetadata({
137
+ imports: [ServiceDemoComponent],
138
+ providers: [ZenDialogService],
139
+ }),
140
+ ],
141
+ argTypes: {
142
+ size: {
143
+ name: 'size',
144
+ control: 'select',
145
+ options: ['sm', 'md', 'lg', 'xl', 'full'],
146
+ table: {
147
+ category: 'inputs',
148
+ type: { summary: 'string' },
149
+ defaultValue: { summary: 'md' },
150
+ },
151
+ },
152
+ header: { control: 'text', table: { category: 'inputs' } },
153
+ closable: { control: 'boolean', table: { category: 'inputs', defaultValue: { summary: 'true' } } },
154
+ backdrop: { control: 'boolean', table: { category: 'inputs', defaultValue: { summary: 'true' } } },
155
+ closeOnEscape: { control: 'boolean', table: { category: 'inputs', defaultValue: { summary: 'true' } } },
156
+ open: { control: 'boolean', table: { disable: true } },
157
+ },
158
+ args: {
159
+ size: 'md',
160
+ header: 'Dialog Title',
161
+ closable: true,
162
+ backdrop: true,
163
+ closeOnEscape: true,
164
+ },
165
+ } satisfies Meta<ZenDialog>;
166
+
167
+ export default meta;
168
+
169
+ export const Default: Story = {
170
+ render: args => ({
171
+ props: { args },
172
+ template: `<app-service-demo [args]="args" />`,
173
+ }),
174
+ };
@@ -0,0 +1,211 @@
1
+ import {
2
+ ApplicationRef,
3
+ ComponentRef,
4
+ createComponent,
5
+ DestroyRef,
6
+ EnvironmentInjector,
7
+ inject,
8
+ Injectable,
9
+ InjectionToken,
10
+ Injector,
11
+ OutputRef,
12
+ Type,
13
+ ViewContainerRef,
14
+ } from '@angular/core';
15
+
16
+ import { ZenDialog } from './dialog';
17
+
18
+ type SignalValue<T> = T extends (...args: []) => infer V ? V : never;
19
+
20
+ type ComponentInputs<T> = Partial<{
21
+ [K in keyof T as T[K] extends OutputRef<unknown> ? never : K]: SignalValue<T[K]>;
22
+ }>;
23
+
24
+ type ExtractOutputValue<T> = T extends OutputRef<infer V> ? V : never;
25
+
26
+ type ComponentOutputs<T> = Partial<{
27
+ [K in keyof T as T[K] extends OutputRef<unknown> ? K : never]: (value: ExtractOutputValue<T[K]>) => void;
28
+ }>;
29
+
30
+ type ZenDialogInputs = Partial<ComponentInputs<Omit<ZenDialog, 'open'>>>;
31
+
32
+ /**
33
+ * Configuration options for opening a dialog via `ZenDialogService.open()`.
34
+ *
35
+ * @typeParam T - The dynamic component type that will be rendered inside the dialog.
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * interface MyComponent {
40
+ * readonly message: InputSignal<string>;
41
+ * readonly confirm: OutputRef<string>;
42
+ * }
43
+ *
44
+ * const config: DialogConfig<MyComponent> = {
45
+ * header: 'Confirm Action',
46
+ * size: 'md',
47
+ * inputs: { message: 'Are you sure?' },
48
+ * outputs: {
49
+ * confirm: (value) => console.log(value),
50
+ * },
51
+ * };
52
+ * ```
53
+ */
54
+
55
+ interface DialogConfig<T, TInputs = ComponentInputs<T>, TOutputs = ComponentOutputs<T>> extends ZenDialogInputs {
56
+ /** Input values for the dynamic component. */
57
+ inputs?: TInputs;
58
+
59
+ /** Output handlers for the dynamic component's outputs. */
60
+ outputs?: TOutputs;
61
+ }
62
+
63
+ /** Reference to an opened dialog. Provides `close()` and `componentInstance`. */
64
+ class DialogRef<T> {
65
+ public componentInstance!: T;
66
+
67
+ constructor(private readonly closeFn: () => void) {}
68
+
69
+ close(): void {
70
+ this.closeFn();
71
+ }
72
+ }
73
+
74
+ /** Injection token to access DialogRef from within the dynamic component. */
75
+ const DIALOG_REF = new InjectionToken<DialogRef<unknown>>('DIALOG_REF');
76
+
77
+ /**
78
+ * ZenDialogService provides methods to dynamically open dialogs with custom components.
79
+ *
80
+ * @example
81
+ * ```typescript
82
+ * const ref = this.dialogService.open(MyComponent, {
83
+ * header: 'My Dialog',
84
+ * size: 'md',
85
+ * inputs: { message: 'Hello!' },
86
+ * outputs: { confirm: () => ref.close() },
87
+ * });
88
+ * ```
89
+ *
90
+ * @author Konrad Stępień
91
+ * @license {@link https://github.com/kstepien3/ng-zen/blob/master/LICENSE|BSD-2-Clause}
92
+ * @see [GitHub](https://github.com/kstepien3/ng-zen)
93
+ * @see [MDN Dialog Element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog)
94
+ */
95
+ @Injectable()
96
+ class ZenDialogService {
97
+ private readonly appRef = inject(ApplicationRef);
98
+ private readonly injector = inject(EnvironmentInjector);
99
+
100
+ private dialogRef: ComponentRef<ZenDialog> | null = null;
101
+ private contentRef: ComponentRef<unknown> | null = null;
102
+
103
+ private readonly destroyRef = inject(DestroyRef);
104
+
105
+ constructor() {
106
+ this.destroyRef.onDestroy(() => this.closeInternal());
107
+ }
108
+
109
+ /**
110
+ * Opens a dialog with the specified component as content.
111
+ *
112
+ * @example
113
+ * ```typescript
114
+ * const ref = this.dialog.open(MyComponent, {
115
+ * header: 'My Dialog',
116
+ * size: 'md',
117
+ * inputs: { message: 'Hello!' },
118
+ * outputs: { confirm: () => ref.close() },
119
+ * });
120
+ * ```
121
+ */
122
+ open<T>(component: Type<T>, config?: DialogConfig<T>): DialogRef<T> {
123
+ if (this.dialogRef) {
124
+ this.closeInternal();
125
+ }
126
+
127
+ this.dialogRef = createComponent(ZenDialog, {
128
+ environmentInjector: this.injector,
129
+ });
130
+
131
+ this.dialogRef.instance.open.set(true);
132
+
133
+ const { inputs, outputs, ...inputMappings } = config || {};
134
+
135
+ for (const [key, value] of Object.entries(inputMappings)) {
136
+ if (value !== undefined) {
137
+ this.dialogRef.setInput(key, value);
138
+ }
139
+ }
140
+
141
+ this.appRef.attachView(this.dialogRef.hostView);
142
+
143
+ const dialogElement = this.dialogRef.location.nativeElement as HTMLDialogElement;
144
+ document.body.appendChild(dialogElement);
145
+
146
+ const dialogRefInstance = new DialogRef<T>(() => this.closeInternal());
147
+
148
+ const elementInjector = Injector.create({
149
+ parent: this.dialogRef.injector,
150
+ providers: [{ provide: DIALOG_REF, useValue: dialogRefInstance }],
151
+ });
152
+
153
+ this.contentRef = this.dialogRef.injector.get(ViewContainerRef).createComponent(component, {
154
+ environmentInjector: this.injector,
155
+ injector: elementInjector,
156
+ });
157
+
158
+ dialogRefInstance.componentInstance = this.contentRef.instance as T;
159
+
160
+ const contentElement = this.contentRef.location.nativeElement;
161
+ const contentContainer = dialogElement.querySelector('.zen-dialog-content');
162
+ contentContainer?.appendChild(contentElement);
163
+
164
+ if (inputs) {
165
+ for (const [key, value] of Object.entries(inputs)) {
166
+ this.contentRef.setInput(key, value);
167
+ }
168
+ }
169
+
170
+ if (outputs) {
171
+ const destroyRef = this.contentRef.injector.get(DestroyRef);
172
+
173
+ for (const [key, handler] of Object.entries(outputs as Record<string, (value: unknown) => void>)) {
174
+ const output = (this.contentRef.instance as Record<string, unknown>)[key];
175
+ if (this.isOutputRef(output)) {
176
+ const subscription = output.subscribe(handler);
177
+
178
+ destroyRef.onDestroy(() => subscription.unsubscribe());
179
+ }
180
+ }
181
+ }
182
+
183
+ dialogElement.addEventListener('close', () => this.closeInternal(), { once: true });
184
+
185
+ return dialogRefInstance;
186
+ }
187
+
188
+ private isOutputRef(value: unknown): value is OutputRef<unknown> {
189
+ return value !== null && typeof value === 'object' && 'subscribe' in value;
190
+ }
191
+
192
+ private closeInternal(): void {
193
+ if (!this.dialogRef) return;
194
+
195
+ const dialogElement = this.dialogRef.location.nativeElement as HTMLDialogElement;
196
+ if (dialogElement.open) {
197
+ dialogElement.close();
198
+ }
199
+
200
+ this.contentRef?.destroy();
201
+ this.contentRef = null;
202
+
203
+ dialogElement.remove();
204
+
205
+ this.dialogRef.destroy();
206
+ this.dialogRef = null;
207
+ }
208
+ }
209
+
210
+ export { DIALOG_REF, ZenDialogService };
211
+ export type { DialogConfig, DialogRef };
@@ -0,0 +1,249 @@
1
+ import { Component, input, output, provideZonelessChangeDetection, signal } from '@angular/core';
2
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { ZenDialog } from './dialog';
6
+ import { ZenDialogService } from './dialog.service';
7
+
8
+ @Component({
9
+ template: `
10
+ <dialog
11
+ [backdrop]="backdrop()"
12
+ [closeOnEscape]="closeOnEscape()"
13
+ [header]="header()"
14
+ [id]="id()"
15
+ [size]="size()"
16
+ [(open)]="isOpen"
17
+ zen-dialog
18
+ >
19
+ <p>Dialog content</p>
20
+ </dialog>
21
+ `,
22
+ standalone: true,
23
+ imports: [ZenDialog],
24
+ })
25
+ class DialogTestComponent {
26
+ readonly isOpen = signal(false);
27
+ readonly header = signal('Test Dialog');
28
+ readonly size = signal<'sm' | 'md' | 'lg' | 'xl' | 'full'>('md');
29
+ readonly id = signal('test-dialog');
30
+ readonly backdrop = signal(true);
31
+ readonly closeOnEscape = signal(true);
32
+ }
33
+
34
+ @Component({
35
+ template: `
36
+ <p>{{ message() }}</p>
37
+ <button (click)="onConfirm()">Confirm</button>
38
+ <button (click)="onCancel()">Cancel</button>
39
+ `,
40
+ standalone: true,
41
+ })
42
+ class TestDialogContent {
43
+ readonly message = input.required<string>();
44
+ readonly confirmClick = output<string>();
45
+ readonly cancelClick = output<void>();
46
+
47
+ onConfirm(): void {
48
+ this.confirmClick.emit('confirmed');
49
+ }
50
+
51
+ onCancel(): void {
52
+ this.cancelClick.emit();
53
+ }
54
+ }
55
+
56
+ function getDialogEl(fixture: ComponentFixture<unknown>): HTMLDialogElement | null {
57
+ return fixture.nativeElement.querySelector('dialog[zen-dialog]') as HTMLDialogElement | null;
58
+ }
59
+
60
+ function getDialogElFromBody(): HTMLDialogElement | null {
61
+ return document.body.querySelector('dialog[zen-dialog]') as HTMLDialogElement | null;
62
+ }
63
+
64
+ describe('ZenDialog', () => {
65
+ beforeEach(async () => {
66
+ HTMLDialogElement.prototype.showModal = vi.fn();
67
+ HTMLDialogElement.prototype.close = vi.fn();
68
+ });
69
+
70
+ describe('Component usage', () => {
71
+ beforeEach(async () => {
72
+ await TestBed.configureTestingModule({
73
+ imports: [DialogTestComponent],
74
+ providers: [provideZonelessChangeDetection()],
75
+ }).compileComponents();
76
+ });
77
+
78
+ it('should create', () => {
79
+ const fixture = TestBed.createComponent(DialogTestComponent);
80
+ fixture.detectChanges();
81
+ expect(getDialogEl(fixture)).toBeTruthy();
82
+ });
83
+
84
+ it('should call showModal when open is set to true', async () => {
85
+ const fixture = TestBed.createComponent(DialogTestComponent);
86
+ fixture.detectChanges();
87
+
88
+ const dialogEl = getDialogEl(fixture)!;
89
+ expect(dialogEl.showModal).not.toHaveBeenCalled();
90
+
91
+ fixture.componentInstance.isOpen.set(true);
92
+ fixture.detectChanges();
93
+ await fixture.whenStable();
94
+
95
+ expect(dialogEl.showModal).toHaveBeenCalledTimes(1);
96
+ });
97
+
98
+ it('should call close when open is set to false', async () => {
99
+ const fixture = TestBed.createComponent(DialogTestComponent);
100
+ fixture.componentInstance.isOpen.set(true);
101
+ fixture.detectChanges();
102
+ await fixture.whenStable();
103
+
104
+ const dialogEl = getDialogEl(fixture)!;
105
+ Object.defineProperty(dialogEl, 'open', { value: true, writable: true });
106
+
107
+ fixture.componentInstance.isOpen.set(false);
108
+ fixture.detectChanges();
109
+ await fixture.whenStable();
110
+
111
+ expect(dialogEl.close).toHaveBeenCalled();
112
+ });
113
+
114
+ it('should render header when provided', () => {
115
+ const fixture = TestBed.createComponent(DialogTestComponent);
116
+ fixture.detectChanges();
117
+
118
+ const header = fixture.nativeElement.querySelector('.zen-dialog-header h2');
119
+ expect(header?.textContent).toBe('Test Dialog');
120
+ });
121
+
122
+ it('should apply size attribute', () => {
123
+ const fixture = TestBed.createComponent(DialogTestComponent);
124
+ fixture.componentInstance.size.set('lg');
125
+ fixture.detectChanges();
126
+
127
+ const dialogEl = getDialogEl(fixture)!;
128
+ expect(dialogEl.getAttribute('data-size')).toBe('lg');
129
+ });
130
+
131
+ it('should close when close button is clicked', async () => {
132
+ const fixture = TestBed.createComponent(DialogTestComponent);
133
+ fixture.componentInstance.isOpen.set(true);
134
+ fixture.detectChanges();
135
+ await fixture.whenStable();
136
+
137
+ const closeBtn = fixture.nativeElement.querySelector('.zen-dialog-close') as HTMLButtonElement;
138
+ closeBtn?.click();
139
+ fixture.detectChanges();
140
+
141
+ expect(fixture.componentInstance.isOpen()).toBe(false);
142
+ });
143
+
144
+ it('should not close when backdrop is clicked and backdrop is false', async () => {
145
+ const fixture = TestBed.createComponent(DialogTestComponent);
146
+ fixture.componentInstance.isOpen.set(true);
147
+ fixture.componentInstance.backdrop.set(false);
148
+ fixture.detectChanges();
149
+ await fixture.whenStable();
150
+
151
+ const dialogEl = getDialogEl(fixture)!;
152
+ Object.defineProperty(dialogEl, 'tagName', { value: 'DIALOG' });
153
+ dialogEl.dispatchEvent(new MouseEvent('click', { bubbles: true }));
154
+ fixture.detectChanges();
155
+
156
+ expect(fixture.componentInstance.isOpen()).toBe(true);
157
+ });
158
+
159
+ it('should not close on escape when closeOnEscape is false', async () => {
160
+ const fixture = TestBed.createComponent(DialogTestComponent);
161
+ fixture.componentInstance.isOpen.set(true);
162
+ fixture.componentInstance.closeOnEscape.set(false);
163
+ fixture.detectChanges();
164
+ await fixture.whenStable();
165
+
166
+ const dialogEl = getDialogEl(fixture)!;
167
+ const cancelEvent = new Event('cancel', { bubbles: true, cancelable: true });
168
+ dialogEl.dispatchEvent(cancelEvent);
169
+ fixture.detectChanges();
170
+
171
+ expect(fixture.componentInstance.isOpen()).toBe(true);
172
+ });
173
+
174
+ it('should apply id to dialog element', () => {
175
+ const fixture = TestBed.createComponent(DialogTestComponent);
176
+ fixture.componentInstance.id.set('custom-dialog-id');
177
+ fixture.detectChanges();
178
+
179
+ const dialogEl = getDialogEl(fixture)!;
180
+ expect(dialogEl.id).toBe('custom-dialog-id');
181
+ });
182
+ });
183
+ });
184
+
185
+ describe('ZenDialogService', () => {
186
+ beforeEach(async () => {
187
+ HTMLDialogElement.prototype.showModal = vi.fn();
188
+ HTMLDialogElement.prototype.close = vi.fn();
189
+
190
+ await TestBed.configureTestingModule({
191
+ providers: [provideZonelessChangeDetection(), ZenDialogService],
192
+ }).compileComponents();
193
+ });
194
+
195
+ afterEach(() => {
196
+ const dialogEl = getDialogElFromBody();
197
+ if (dialogEl) {
198
+ dialogEl.remove();
199
+ }
200
+ });
201
+
202
+ it('should open dialog with component', () => {
203
+ const service = TestBed.inject(ZenDialogService);
204
+
205
+ const ref = service.open(TestDialogContent, { header: 'Service Dialog' });
206
+
207
+ expect(ref).toBeDefined();
208
+ expect(ref.componentInstance).toBeInstanceOf(TestDialogContent);
209
+ });
210
+
211
+ it('should pass inputs to component', () => {
212
+ const service = TestBed.inject(ZenDialogService);
213
+
214
+ const ref = service.open(TestDialogContent, {
215
+ inputs: { message: 'Hello from service' } as Record<string, unknown>,
216
+ });
217
+
218
+ expect(ref.componentInstance.message()).toBe('Hello from service');
219
+ });
220
+
221
+ it('should subscribe to outputs', () => {
222
+ const service = TestBed.inject(ZenDialogService);
223
+ const confirmHandler = vi.fn();
224
+ const cancelHandler = vi.fn();
225
+
226
+ service.open(TestDialogContent, {
227
+ outputs: {
228
+ confirmClick: confirmHandler,
229
+ cancelClick: cancelHandler,
230
+ },
231
+ });
232
+
233
+ const confirmBtn = document.body.querySelector('button') as HTMLButtonElement;
234
+ confirmBtn?.click();
235
+
236
+ expect(confirmHandler).toHaveBeenCalledWith('confirmed');
237
+ });
238
+
239
+ it('should close dialog via DialogRef', async () => {
240
+ const service = TestBed.inject(ZenDialogService);
241
+
242
+ const ref = service.open(TestDialogContent);
243
+
244
+ ref.close();
245
+
246
+ const dialogEl = getDialogElFromBody();
247
+ expect(dialogEl).toBeNull();
248
+ });
249
+ });