@ng-zen/cli 21.1.1 β†’ 21.2.0-next.1

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## [21.2.0-next.1](https://github.com/kstepien3/ng-zen/compare/v21.1.1...v21.2.0-next.1) (2026-03-09)
2
+
3
+ ### πŸš€ New Features
4
+
5
+ * **dialog:** add reusable dialog component with service and tests ([#387](https://github.com/kstepien3/ng-zen/issues/387)) ([1cb90f6](https://github.com/kstepien3/ng-zen/commit/1cb90f6c5b89b63a461abe6d45da74869275ff12))
6
+
7
+ ### πŸ“š Documentation
8
+
9
+ * **README:** update storybook tag ([#395](https://github.com/kstepien3/ng-zen/issues/395)) ([114dfdc](https://github.com/kstepien3/ng-zen/commit/114dfdc87614345784b264a6bde6be73b5e93aa7))
10
+
1
11
  ## [21.1.1](https://github.com/kstepien3/ng-zen/compare/v21.1.0...v21.1.1) (2026-02-25)
2
12
 
3
13
  ### πŸ› Bug Fixes
package/README.md CHANGED
@@ -22,7 +22,7 @@
22
22
  <img src="https://img.shields.io/badge/-Repository-181818?style=flat&logo=github&logoColor=white" alt="Repository" />
23
23
  </a>
24
24
  <a href="https://kstepien3.github.io/ng-zen/">
25
- <img src="https://img.shields.io/badge/-Storybook%20Demo-FF4785?style=flat&logo=storybook&logoColor=white" alt="Storybook Demo" />
25
+ <img src="https://img.shields.io/badge/-Storybook%20Demo-FF4785?style=flat&logo=storybook&logoColor=white" alt="Storybook" />
26
26
  </a>
27
27
  </p>
28
28
 
@@ -39,7 +39,7 @@ Unlike traditional UI libraries that give you `<library-button>` black boxes, @n
39
39
 
40
40
  ### 🏎️ **Instant Productivity**
41
41
 
42
- - **Production-Ready UI Elements:** Alert, Avatar, Button, Checkbox, Divider, Form Control, Icon, Input, Popover, Radio, Skeleton, Switch, Textarea
42
+ - **Production-Ready UI Elements:** Alert, Avatar, Button, Checkbox, Dialog, Divider, Form Control, Icon, Input, Popover, Radio, Skeleton, Switch, Textarea
43
43
  - **Zero Configuration:** Works with Angular 20+ out of the box
44
44
  - **Complete Setup:** Each component includes unit tests, Storybook stories, and documentation
45
45
 
@@ -146,6 +146,7 @@ export class MyComponent {}
146
146
  | **Alert** | Informational messages | Customizable styles, dismissible options |
147
147
  | **Button** | Interactive buttons | Primary/secondary variants, loading states, icons |
148
148
  | **Checkbox** | Form checkboxes | Indeterminate state, custom styling, validation |
149
+ | **Dialog** | Native modal dialogs | Native dialog element, service API, size variants, backdrop config |
149
150
  | **Divider** | Visual separators | Horizontal/vertical, with labels, custom thickness |
150
151
  | **Form Control** | Form field wrapper | Labels, validation messages, required indicators |
151
152
  | **Icon** | SVG icon system | Huge Icons integration, size variants, colors |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ng-zen/cli",
3
- "version": "21.1.1",
3
+ "version": "21.2.0-next.1",
4
4
  "description": "Angular UI components generator – Zen UI Kit CLI for schematics-based creation of customizable components like avatar, button, checkbox, divider, form-control, icon, input, skeleton, switch, textarea with Storybook demos.",
5
5
  "license": "BSD-2-Clause",
6
6
  "private": false,
@@ -49,6 +49,7 @@
49
49
  "avatar",
50
50
  "button",
51
51
  "checkbox",
52
+ "dialog",
52
53
  "divider",
53
54
  "form-control",
54
55
  "icon",
@@ -0,0 +1,144 @@
1
+ $transition-duration: var(--zen-transition-duration, 0.3s);
2
+ $content-max-height-offset: 100px;
3
+ $sizes: (
4
+ sm: 300px,
5
+ md: 500px,
6
+ lg: 800px,
7
+ xl: 1100px,
8
+ );
9
+
10
+ :host {
11
+ --zen-dialog-padding: 1rem;
12
+ --zen-dialog-bg: white;
13
+ --zen-dialog-border-radius: 8px;
14
+ --zen-dialog-shadow: 0 4px 24px rgb(0 0 0 / 20%);
15
+ --zen-dialog-max-height: 90vh;
16
+ --zen-dialog-max-width: 90vw;
17
+ --zen-dialog-header-gap: 1rem;
18
+ --zen-dialog-header-padding-bottom: 0.75rem;
19
+ --zen-dialog-close-btn-size: 2rem;
20
+ --zen-dialog-close-btn-color: hsl(0deg 0% 40%);
21
+ --zen-dialog-close-btn-hover-color: hsl(0deg 0% 20%);
22
+ --zen-dialog-backdrop-bg: rgb(0 0 0 / 50%);
23
+ }
24
+
25
+ /* stylelint-disable-next-line no-duplicate-selectors -- separate variables and styles */
26
+ :host {
27
+ padding: var(--zen-dialog-padding);
28
+ background: var(--zen-dialog-bg);
29
+ border: none;
30
+ border-radius: var(--zen-dialog-border-radius);
31
+ box-shadow: var(--zen-dialog-shadow);
32
+ max-height: var(--zen-dialog-max-height);
33
+ max-width: var(--zen-dialog-max-width);
34
+ overflow: auto;
35
+ transition:
36
+ opacity $transition-duration ease,
37
+ transform $transition-duration ease,
38
+ overlay $transition-duration allow-discrete,
39
+ display $transition-duration allow-discrete;
40
+ opacity: 0;
41
+ transform: translateY(-10px);
42
+
43
+ &[open] {
44
+ opacity: 1;
45
+ transform: translateY(0);
46
+
47
+ @starting-style {
48
+ opacity: 0;
49
+ transform: translateY(-10px);
50
+ }
51
+ }
52
+
53
+ @each $name, $width in $sizes {
54
+ &[data-size='#{$name}'] {
55
+ width: $width;
56
+ }
57
+ }
58
+
59
+ &[data-size='full'] {
60
+ width: 100%;
61
+ height: 100%;
62
+ max-width: 100%;
63
+ max-height: 100%;
64
+ border-radius: 0;
65
+ transform: none;
66
+
67
+ &[open] {
68
+ @starting-style {
69
+ transform: none;
70
+ }
71
+ }
72
+ }
73
+
74
+ &::backdrop {
75
+ background: var(--zen-dialog-backdrop-bg);
76
+ transition:
77
+ opacity $transition-duration ease,
78
+ overlay $transition-duration allow-discrete,
79
+ display $transition-duration allow-discrete;
80
+ opacity: 0;
81
+ }
82
+
83
+ &[open]::backdrop {
84
+ opacity: 1;
85
+
86
+ @starting-style {
87
+ opacity: 0;
88
+ }
89
+ }
90
+ }
91
+
92
+ .zen-dialog-header {
93
+ display: flex;
94
+ align-items: center;
95
+ justify-content: space-between;
96
+ gap: var(--zen-dialog-header-gap);
97
+ padding-bottom: var(--zen-dialog-header-padding-bottom);
98
+ margin-bottom: var(--zen-dialog-padding);
99
+ border-bottom: 1px solid hsl(0deg 0% 90%);
100
+
101
+ h2 {
102
+ margin: 0;
103
+ font-size: 1.125rem;
104
+ font-weight: 600;
105
+ line-height: 1.4;
106
+ }
107
+ }
108
+
109
+ .zen-dialog-close {
110
+ display: flex;
111
+ align-items: center;
112
+ justify-content: center;
113
+ width: var(--zen-dialog-close-btn-size);
114
+ height: var(--zen-dialog-close-btn-size);
115
+ padding: 0;
116
+ background: transparent;
117
+ border: none;
118
+ border-radius: 4px;
119
+ color: var(--zen-dialog-close-btn-color);
120
+ cursor: pointer;
121
+ transition:
122
+ background-color $transition-duration ease,
123
+ color $transition-duration ease;
124
+
125
+ &:hover {
126
+ color: var(--zen-dialog-close-btn-hover-color);
127
+ background-color: hsl(0deg 0% 96%);
128
+ }
129
+
130
+ &:focus-visible {
131
+ outline: 2px solid hsl(200deg 100% 50% / 50%);
132
+ outline-offset: 2px;
133
+ }
134
+
135
+ zen-icon {
136
+ display: flex;
137
+ line-height: 0;
138
+ }
139
+ }
140
+
141
+ .zen-dialog-content {
142
+ overflow: auto;
143
+ max-height: calc(var(--zen-dialog-max-height) - #{$content-max-height-offset});
144
+ }
@@ -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
+ });
@@ -0,0 +1,105 @@
1
+ import { signal } from '@angular/core';
2
+ import { argsToTemplate, Meta, moduleMetadata, StoryObj } from '@storybook/angular';
3
+
4
+ import { ZenButton } from '../button';
5
+ import { ZenDialog } from './dialog';
6
+
7
+ type DialogSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
8
+
9
+ type Story = StoryObj<ZenDialog>;
10
+
11
+ const meta = {
12
+ title: 'UI/Dialog/Dialog',
13
+ component: ZenDialog,
14
+ tags: ['autodocs'],
15
+ decorators: [
16
+ moduleMetadata({
17
+ imports: [ZenButton, ZenDialog],
18
+ }),
19
+ ],
20
+ argTypes: {
21
+ size: {
22
+ name: 'size',
23
+ control: 'select',
24
+ options: ['sm', 'md', 'lg', 'xl', 'full'] satisfies DialogSize[],
25
+ table: {
26
+ category: 'inputs',
27
+ type: { summary: 'string' },
28
+ defaultValue: { summary: 'md' satisfies DialogSize },
29
+ },
30
+ },
31
+ header: { control: 'text', table: { category: 'inputs' } },
32
+ closable: { control: 'boolean', table: { category: 'inputs', defaultValue: { summary: 'true' } } },
33
+ backdrop: { control: 'boolean', table: { category: 'inputs', defaultValue: { summary: 'true' } } },
34
+ closeOnEscape: { control: 'boolean', table: { category: 'inputs', defaultValue: { summary: 'true' } } },
35
+ open: { control: 'boolean', table: { readonly: false, category: 'models' } },
36
+ },
37
+ args: {
38
+ size: 'md',
39
+ header: 'Dialog Title',
40
+ closable: true,
41
+ backdrop: true,
42
+ closeOnEscape: true,
43
+ open: false,
44
+ },
45
+ } satisfies Meta<ZenDialog>;
46
+
47
+ export default meta;
48
+
49
+ export const Default: Story = {
50
+ render: ({ open, ...args }) => {
51
+ const mutatedArgs = { ...args, open: signal(open) };
52
+ return {
53
+ props: { ...mutatedArgs },
54
+ template: `
55
+ <button zen-btn (click)="open.set(true)">Open Dialog</button>
56
+
57
+ <dialog zen-dialog [(open)]="open" ${argsToTemplate(args)}>
58
+ <p>This is the dialog content. You can put any content here.</p>
59
+ <button zen-btn (click)="open.set(false)">Close</button>
60
+ </dialog>
61
+ `,
62
+ };
63
+ },
64
+ };
65
+
66
+ export const Sizes: Story = {
67
+ render: () => ({
68
+ props: {
69
+ openSm: signal(false),
70
+ openMd: signal(false),
71
+ openLg: signal(false),
72
+ openXl: signal(false),
73
+ openFull: signal(false),
74
+ },
75
+ template: `
76
+ <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
77
+ <button zen-btn (click)="openSm.set(true)">Small</button>
78
+ <button zen-btn (click)="openMd.set(true)">Medium</button>
79
+ <button zen-btn (click)="openLg.set(true)">Large</button>
80
+ <button zen-btn (click)="openXl.set(true)">Extra Large</button>
81
+ <button zen-btn (click)="openFull.set(true)">Full Screen</button>
82
+ </div>
83
+
84
+ <dialog zen-dialog [(open)]="openSm" header="Small Dialog" size="sm">
85
+ <p>Small dialog content</p>
86
+ </dialog>
87
+
88
+ <dialog zen-dialog [(open)]="openMd" header="Medium Dialog" size="md">
89
+ <p>Medium dialog content</p>
90
+ </dialog>
91
+
92
+ <dialog zen-dialog [(open)]="openLg" header="Large Dialog" size="lg">
93
+ <p>Large dialog content with more space for complex layouts.</p>
94
+ </dialog>
95
+
96
+ <dialog zen-dialog [(open)]="openXl" header="Extra Large Dialog" size="xl">
97
+ <p>Extra large dialog content for data tables, forms, etc.</p>
98
+ </dialog>
99
+
100
+ <dialog zen-dialog [(open)]="openFull" header="Full Screen Dialog" size="full">
101
+ <p>Full screen dialog content</p>
102
+ </dialog>
103
+ `,
104
+ }),
105
+ };
@@ -0,0 +1,143 @@
1
+ import { ChangeDetectionStrategy, Component, effect, ElementRef, inject, input, model, untracked } from '@angular/core';
2
+ import { Cancel01Icon } from '@hugeicons/core-free-icons';
3
+
4
+ import { ZenIcon } from '../icon';
5
+
6
+ type DialogSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
7
+
8
+ /**
9
+ * ZenDialog is a reusable dialog component built on the native HTML `<dialog>` element.
10
+ * It provides a modal dialog with customizable header, size, and content.
11
+ *
12
+ * @example
13
+ * ```html
14
+ * <dialog zen-dialog [(open)]="isOpen" header="Dialog Title" size="md">
15
+ * <p>Dialog content</p>
16
+ * </dialog>
17
+ * ```
18
+ *
19
+ * ### CSS Custom Properties
20
+ *
21
+ * You can customize the component using CSS custom properties:
22
+ * ```css
23
+ * :root {
24
+ * --zen-dialog-padding: 1rem;
25
+ * --zen-dialog-bg: white;
26
+ * --zen-dialog-border-radius: 8px;
27
+ * --zen-dialog-shadow: 0 4px 24px rgb(0 0 0 / 20%);
28
+ * --zen-dialog-max-height: 90vh;
29
+ * --zen-dialog-max-width: 90vw;
30
+ * --zen-dialog-backdrop-bg: rgba(0, 0, 0, 0.5);
31
+ * }
32
+ * ```
33
+ *
34
+ * @author Konrad StΔ™pieΕ„
35
+ * @license {@link https://github.com/kstepien3/ng-zen/blob/master/LICENSE|BSD-2-Clause}
36
+ * @see [GitHub](https://github.com/kstepien3/ng-zen)
37
+ * @see [MDN Dialog Element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog)
38
+ */
39
+ @Component({
40
+ // eslint-disable-next-line @angular-eslint/component-selector
41
+ selector: 'dialog[zen-dialog]',
42
+ template: `
43
+ @if (header()) {
44
+ <header class="zen-dialog-header">
45
+ <h2>{{ header() }}</h2>
46
+ @if (closable()) {
47
+ <button (click)="onClose()" aria-label="Close dialog" class="zen-dialog-close" type="button">
48
+ <zen-icon [icon]="closeIcon" [size]="20" />
49
+ </button>
50
+ }
51
+ </header>
52
+ }
53
+ <div class="zen-dialog-content">
54
+ <ng-content />
55
+ </div>
56
+ `,
57
+ styleUrl: './dialog.scss',
58
+ changeDetection: ChangeDetectionStrategy.OnPush,
59
+ imports: [ZenIcon],
60
+ host: {
61
+ '[attr.aria-label]': 'header()',
62
+ '[attr.data-size]': 'size()',
63
+ '(close)': 'onClose()',
64
+ '(cancel)': 'onCancel($event)',
65
+ '(click)': 'onDialogClick($event)',
66
+ },
67
+ })
68
+ export class ZenDialog {
69
+ /**
70
+ * Controls the open state of the dialog.
71
+ * Supports two-way binding via `[(open)]` syntax.
72
+ * When set to `true`, calls `showModal()` on the native dialog element.
73
+ * When set to `false`, calls `close()` on the native dialog element.
74
+ */
75
+ readonly open = model<boolean>(false);
76
+
77
+ /**
78
+ * Header title displayed at the top of the dialog.
79
+ * Also used as the `aria-label` for accessibility.
80
+ */
81
+ readonly header = input<string>('');
82
+
83
+ /**
84
+ * Size variant of the dialog.
85
+ * Affects the width of the dialog via the `data-size` attribute.
86
+ */
87
+ readonly size = input<DialogSize>('md');
88
+
89
+ /**
90
+ * Whether to show the close button (X) in the header.
91
+ */
92
+ readonly closable = input(true);
93
+
94
+ /**
95
+ * Whether clicking the backdrop closes the dialog.
96
+ * This is a native dialog feature.
97
+ */
98
+ readonly backdrop = input(true);
99
+
100
+ /**
101
+ * Whether pressing the Escape key closes the dialog.
102
+ * This is a native dialog feature.
103
+ */
104
+ readonly closeOnEscape = input(true);
105
+
106
+ protected readonly closeIcon = Cancel01Icon;
107
+
108
+ private readonly elementRef = inject(ElementRef<HTMLDialogElement>);
109
+
110
+ constructor() {
111
+ effect(() => {
112
+ const isOpen = this.open();
113
+ const element = this.elementRef.nativeElement;
114
+
115
+ untracked(() => {
116
+ if (isOpen) {
117
+ element.showModal();
118
+ } else if (element.open) {
119
+ element.close();
120
+ }
121
+ });
122
+ });
123
+ }
124
+
125
+ /** Close on backdrop click */
126
+ protected onDialogClick(event: MouseEvent): void {
127
+ if (!this.backdrop()) return;
128
+
129
+ if ((event.target as HTMLElement).tagName === 'DIALOG') {
130
+ this.onClose();
131
+ }
132
+ }
133
+
134
+ protected onCancel(event: Event): void {
135
+ if (!this.closeOnEscape()) {
136
+ event.preventDefault();
137
+ }
138
+ }
139
+
140
+ protected onClose(): void {
141
+ this.open.set(false);
142
+ }
143
+ }
@@ -0,0 +1,3 @@
1
+ export { ZenDialog } from './dialog';
2
+ export type { DialogConfig, DialogRef } from './dialog.service';
3
+ export { DIALOG_REF, ZenDialogService } from './dialog.service';
@@ -1 +1 @@
1
- {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../../../../src/schematics/ui/schema.ts"],"names":[],"mappings":"","sourcesContent":["import { GeneratorSchemaBase } from '../../types';\n\nexport type UiType =\n | 'alert'\n | 'avatar'\n | 'button'\n | 'checkbox'\n | 'divider'\n | 'form-control'\n | 'icon'\n | 'input'\n | 'popover'\n | 'radio'\n | 'skeleton'\n | 'switch'\n | 'textarea';\n\nexport interface Schema extends GeneratorSchemaBase {\n ui: UiType[];\n}\n"]}
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../../../../src/schematics/ui/schema.ts"],"names":[],"mappings":"","sourcesContent":["import { GeneratorSchemaBase } from '../../types';\n\nexport type UiType =\n | 'alert'\n | 'avatar'\n | 'button'\n | 'checkbox'\n | 'dialog'\n | 'divider'\n | 'form-control'\n | 'icon'\n | 'input'\n | 'popover'\n | 'radio'\n | 'skeleton'\n | 'switch'\n | 'textarea';\n\nexport interface Schema extends GeneratorSchemaBase {\n ui: UiType[];\n}\n"]}
@@ -41,6 +41,7 @@
41
41
  "avatar",
42
42
  "button",
43
43
  "checkbox",
44
+ "dialog",
44
45
  "divider",
45
46
  "form-control",
46
47
  "icon",
@@ -5,6 +5,7 @@ export type UiType =
5
5
  | 'avatar'
6
6
  | 'button'
7
7
  | 'checkbox'
8
+ | 'dialog'
8
9
  | 'divider'
9
10
  | 'form-control'
10
11
  | 'icon'