@ng-zen/cli 22.0.0 → 22.1.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.
@@ -1,5 +1,9 @@
1
+ import { NgComponentOutlet } from '@angular/common';
2
+ import { Component, signal } from '@angular/core';
3
+ import { form, FormField, FormRoot, required } from '@angular/forms/signals';
1
4
  import { Meta, StoryObj } from '@storybook/angular';
2
5
 
6
+ import FormControlStories from '../form-control/form-control.stories';
3
7
  import { ZenInput } from './input';
4
8
 
5
9
  type Options = ZenInput;
@@ -8,54 +12,27 @@ export default {
8
12
  title: 'Ui/Input',
9
13
  component: ZenInput,
10
14
  argTypes: {
15
+ ...FormControlStories.argTypes,
11
16
  value: {
12
- control: 'text',
17
+ control: 'text' as const,
13
18
  table: {
14
19
  category: 'models',
15
- type: {
16
- summary: 'string',
17
- },
18
- defaultValue: {
19
- summary: '',
20
- },
21
- },
22
- },
23
- placeholder: { control: 'text', table: { type: { summary: 'string' } } },
24
- disabled: {
25
- control: 'boolean',
26
- table: {
27
- category: 'models',
28
- type: {
29
- summary: 'boolean',
30
- },
31
- },
32
- },
33
- required: {
34
- control: 'boolean',
35
- table: {
36
- category: 'inputs',
37
- type: {
38
- summary: 'boolean',
39
- },
40
- defaultValue: {
41
- summary: 'false',
42
- },
20
+ type: { summary: 'string' },
21
+ defaultValue: { summary: "''" },
43
22
  },
44
23
  },
24
+ placeholder: { control: 'text' as const, table: { type: { summary: 'string' } } },
45
25
  onInput: {
46
26
  table: {
47
27
  readonly: true,
48
- type: {
49
- summary: '(value: string) => void',
50
- },
28
+ type: { summary: '(value: string) => void' },
51
29
  },
52
30
  },
53
31
  },
54
32
  args: {
33
+ ...FormControlStories.args,
55
34
  value: '',
56
35
  placeholder: '',
57
- disabled: false,
58
- required: false,
59
36
  },
60
37
  } satisfies Meta<Options>;
61
38
 
@@ -73,3 +50,76 @@ export const WithLabel: Story = {
73
50
  `,
74
51
  }),
75
52
  };
53
+
54
+ export const WithSignalForm: Story = {
55
+ render: () => ({
56
+ moduleMetadata: { imports: [NgComponentOutlet] },
57
+ props: { component: InputSignalFormComponent },
58
+ template: '<ng-container *ngComponentOutlet="component" />',
59
+ }),
60
+ parameters: {
61
+ docs: {
62
+ source: {
63
+ code: `<zen-input [formField]="form.name" />`,
64
+ },
65
+ },
66
+ },
67
+ };
68
+
69
+ export const WithFormRoot: Story = {
70
+ render: () => ({
71
+ moduleMetadata: { imports: [NgComponentOutlet] },
72
+ props: { component: InputFormRootComponent },
73
+ template: '<ng-container *ngComponentOutlet="component" />',
74
+ }),
75
+ parameters: {
76
+ docs: {
77
+ source: {
78
+ code: `<form [formRoot]="loginForm">
79
+ <zen-input [formField]="loginForm.email" placeholder="Email" />
80
+ <zen-input [formField]="loginForm.password" placeholder="Password" type="password" />
81
+ <button type="submit">Sign in</button>
82
+ </form>`,
83
+ },
84
+ },
85
+ },
86
+ };
87
+
88
+ @Component({
89
+ standalone: true,
90
+ template: `
91
+ <div style="display: flex; flex-direction: column; gap: 0.5rem; max-width: 300px;">
92
+ <zen-input [formField]="form.name" placeholder="Type something" />
93
+ @if (form.name().invalid()) {
94
+ <p style="color: red; margin: 0; font-size: 0.875rem;">
95
+ {{ form.name().errors()![0].message }}
96
+ </p>
97
+ }
98
+ </div>
99
+ `,
100
+ imports: [FormField, ZenInput],
101
+ })
102
+ class InputSignalFormComponent {
103
+ readonly form = form(signal({ name: '' }), s => {
104
+ required(s.name, { message: 'Name is required' });
105
+ });
106
+ }
107
+
108
+ @Component({
109
+ standalone: true,
110
+ template: `
111
+ <form [formRoot]="loginForm" style="display: flex; flex-direction: column; gap: 0.5rem; max-width: 300px;">
112
+ <zen-input [formField]="loginForm.email" placeholder="Email" />
113
+ <zen-input [formField]="loginForm.password" placeholder="Password" type="password" />
114
+ <button type="submit">Sign in</button>
115
+ </form>
116
+ `,
117
+ imports: [FormRoot, FormField, ZenInput],
118
+ })
119
+ class InputFormRootComponent {
120
+ readonly loginModel = signal({ email: '', password: '' });
121
+ readonly loginForm = form(this.loginModel, s => {
122
+ required(s.email, { message: 'Email is required' });
123
+ required(s.password, { message: 'Password is required' });
124
+ });
125
+ }
@@ -1,30 +1,33 @@
1
- import { ChangeDetectionStrategy, Component, input, model } from '@angular/core';
2
- import { FormsModule } from '@angular/forms';
1
+ import { Component, input, model } from '@angular/core';
3
2
 
4
- import { ZenFormControl, ZenFormControlProvider } from '../form-control';
3
+ import { ZenFormControl } from '../form-control';
5
4
 
6
5
  /**
7
- * ZenInput is a reusable text input component designed to provide
8
- * a consistent and customizable input style across the application.
9
- * It supports Angular forms integration and provides two-way data binding.
6
+ * ZenInput is a single-line text input component backed by Signal Forms.
10
7
  *
11
- * @example
12
- * <zen-input value="string" />
8
+ * Connect it to a Signal Forms field with `[formField]`:
9
+ *
10
+ * ```html
11
+ * <zen-input [formField]="myForm.name" placeholder="Enter name" />
12
+ * ```
13
+ *
14
+ * The `[formField]` directive automatically synchronises value, disabled
15
+ * state, validation errors, and touched/dirty status.
13
16
  *
14
17
  * ### CSS Custom Properties
15
- * You can customize the component using CSS custom properties:
18
+ *
16
19
  *
17
20
  * ```css
18
21
  * :root {
19
- * --zen-input-border: 1px solid hsl(0deg 0% 80%);
20
- * --zen-input-border-radius: 8px;
21
- * --zen-input-padding: 0.5rem 1rem;
22
- * --zen-input-focus-shadow: 0 1px 4px hsl(0deg 0% 60% / 20%) inset;
23
- * --zen-input-placeholder-color: hsl(0deg 0% 60%);
22
+ * --zen-input-border: 1px solid hsl(0deg 0% 80%);
23
+ * --zen-input-border-radius: 8px;
24
+ * --zen-input-padding: 0.5rem 1rem;
25
+ * --zen-input-focus-shadow: 0 1px 4px hsl(0deg 0% 60% / 20%) inset;
26
+ * --zen-input-placeholder-color: hsl(0deg 0% 60%);
24
27
  * }
25
28
  * ```
26
29
  *
27
- * @implements {ZenFormControl<string>}
30
+ * @extends {ZenFormControl<string>}
28
31
  *
29
32
  * @author Konrad Stępień
30
33
  * @license {@link https://github.com/kstepien3/ng-zen/blob/master/LICENSE|BSD-2-Clause}
@@ -34,24 +37,24 @@ import { ZenFormControl, ZenFormControlProvider } from '../form-control';
34
37
  selector: 'zen-input',
35
38
  template: `
36
39
  <input
37
- [attr.placeholder]="placeholder()"
38
- [attr.required]="required()"
40
+ [aria-invalid]="invalid() || null"
39
41
  [disabled]="disabled()"
40
- [ngModel]="value()"
42
+ [placeholder]="placeholder()"
43
+ [required]="required()"
44
+ [type]="type()"
41
45
  [value]="value()"
42
- (blur)="onTouched()"
43
- (ngModelChange)="onInput($event)"
46
+ (input)="onInput(inputRef.value)"
47
+ #inputRef
44
48
  />
45
49
  `,
46
50
  styleUrls: ['./input.scss'],
47
- changeDetection: ChangeDetectionStrategy.OnPush,
48
- providers: [ZenFormControlProvider(ZenInput)],
49
- imports: [FormsModule],
50
51
  })
51
52
  export class ZenInput extends ZenFormControl<string> {
52
53
  /** The current input value with two-way binding support. */
53
54
  readonly value = model('');
54
55
 
55
56
  /** The placeholder text for the form control. */
56
- readonly placeholder = input<string>();
57
+ readonly placeholder = input<string>('');
58
+
59
+ readonly type = input<'text' | 'email' | 'url' | 'password'>('text');
57
60
  }
@@ -1,4 +1,4 @@
1
- import { ChangeDetectionStrategy, Component, input } from '@angular/core';
1
+ import { Component, input } from '@angular/core';
2
2
 
3
3
  import { PopoverPlacement } from '../popover-positions.type';
4
4
 
@@ -25,7 +25,6 @@ import { PopoverPlacement } from '../popover-positions.type';
25
25
  <ng-content />
26
26
  `,
27
27
  styleUrl: './popover-host.scss',
28
- changeDetection: ChangeDetectionStrategy.OnPush,
29
28
  host: {
30
29
  popover: 'auto',
31
30
  '[attr.id]': 'id()',
@@ -1,8 +1,8 @@
1
- import { Injectable } from '@angular/core';
1
+ import { Service } from '@angular/core';
2
2
 
3
3
  import { ZenRadio } from './radio';
4
4
 
5
- @Injectable({ providedIn: 'root' })
5
+ @Service()
6
6
  export class ZenRadioRegistry {
7
7
  private readonly radios = new Map<string, ZenRadio[]>();
8
8
 
@@ -1,6 +1,5 @@
1
1
  import { provideZonelessChangeDetection } from '@angular/core';
2
2
  import { ComponentFixture, TestBed } from '@angular/core/testing';
3
- import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
4
3
  import { By } from '@angular/platform-browser';
5
4
  import { beforeEach, describe, expect, it, vi } from 'vitest';
6
5
 
@@ -14,7 +13,7 @@ describe('ZenRadio', () => {
14
13
 
15
14
  beforeEach(async () => {
16
15
  await TestBed.configureTestingModule({
17
- imports: [ZenRadio, FormsModule, ReactiveFormsModule],
16
+ imports: [ZenRadio],
18
17
  providers: [ZenRadioRegistry, provideZonelessChangeDetection()],
19
18
  }).compileComponents();
20
19
 
@@ -73,7 +72,7 @@ describe('ZenRadio', () => {
73
72
  });
74
73
 
75
74
  it('should not call onInput when disabled', () => {
76
- component.disabled.set(true);
75
+ fixture.componentRef.setInput('disabled', true);
77
76
  fixture.detectChanges();
78
77
 
79
78
  const spy = vi.spyOn(component, 'onInput');
@@ -85,63 +84,8 @@ describe('ZenRadio', () => {
85
84
  expect(spy).not.toHaveBeenCalled();
86
85
  });
87
86
 
88
- it('should integrate with ngModel', () => {
89
- let selectedValue: string | null = null;
90
-
91
- const testFixture = TestBed.createComponent(ZenRadio);
92
- testFixture.componentRef.setInput('name', 'ng-model-group');
93
- testFixture.componentRef.setInput('option', 'option1');
94
- testFixture.detectChanges();
95
-
96
- // Simulate ngModel binding
97
- testFixture.componentInstance.registerOnChange((value: string | null) => {
98
- selectedValue = value;
99
- });
100
-
101
- const inputElement = testFixture.debugElement.query(By.css('input')).nativeElement;
102
- inputElement.checked = true;
103
- inputElement.dispatchEvent(new Event('change'));
104
-
105
- expect(selectedValue).toBe('option1');
106
- });
107
-
108
- it('should integrate with reactive forms', () => {
109
- const formControl = new FormControl<string | null>(null);
110
- const testFixture = TestBed.createComponent(ZenRadio);
111
- testFixture.componentRef.setInput('name', 'reactive-group');
112
- testFixture.componentRef.setInput('option', 'option1');
113
-
114
- // Simulate form control binding
115
- testFixture.componentInstance.writeValue(formControl.value);
116
-
117
- testFixture.componentInstance.registerOnChange((value: string | null) => {
118
- formControl.setValue(value);
119
- });
120
-
121
- testFixture.detectChanges();
122
-
123
- const inputElement = testFixture.debugElement.query(By.css('input')).nativeElement;
124
- inputElement.checked = true;
125
- inputElement.dispatchEvent(new Event('change'));
126
-
127
- // The component's onInput method should call the onChange callback
128
- // which should update form control value
129
- expect(formControl.value).toBe('option1');
130
- });
131
-
132
- it('should handle disabled state from form control', () => {
133
- component.setDisabledState(true);
134
- fixture.detectChanges();
135
-
136
- expect(component.disabled()).toBe(true);
137
-
138
- const inputElement = fixture.debugElement.query(By.css('input')).nativeElement;
139
- expect(inputElement.disabled).toBe(true);
140
- });
141
-
142
87
  it('should have correct ARIA attributes', () => {
143
88
  component.value.set('test-value');
144
- component.disabled.set(false);
145
89
  fixture.detectChanges();
146
90
 
147
91
  const inputElement = fixture.debugElement.query(By.css('input')).nativeElement;
@@ -151,7 +95,7 @@ describe('ZenRadio', () => {
151
95
 
152
96
  it('should update ARIA attributes when state changes', () => {
153
97
  component.value.set('different-value');
154
- component.disabled.set(true);
98
+ fixture.componentRef.setInput('disabled', true);
155
99
  fixture.detectChanges();
156
100
 
157
101
  const inputElement = fixture.debugElement.query(By.css('input')).nativeElement;
@@ -159,24 +103,12 @@ describe('ZenRadio', () => {
159
103
  expect(inputElement.getAttribute('aria-disabled')).toBe('true');
160
104
  });
161
105
 
162
- it('should call onTouched when host element loses focus', () => {
163
- const onTouchedSpy = vi.fn();
164
- component.registerOnTouched(onTouchedSpy);
165
-
166
- const hostElement = fixture.debugElement.nativeElement;
167
- hostElement.dispatchEvent(new Event('blur'));
168
-
169
- expect(onTouchedSpy).toHaveBeenCalled();
170
- });
171
-
172
106
  it('should register with radio registry on initialization', () => {
173
- // Create a new radio to test registry registration
174
107
  const newFixture = TestBed.createComponent(ZenRadio);
175
108
  newFixture.componentRef.setInput('name', 'registry-test');
176
109
  newFixture.componentRef.setInput('option', 'registry-option');
177
110
  newFixture.detectChanges();
178
111
 
179
- // The radio should be registered after next render
180
112
  expect(newFixture.componentInstance).toBeTruthy();
181
113
  });
182
114
 
@@ -190,7 +122,6 @@ describe('ZenRadio', () => {
190
122
 
191
123
  const radio2 = radio2Fixture.componentInstance;
192
124
 
193
- // Test registry selection
194
125
  registry.select('test-group', 'option2');
195
126
 
196
127
  expect(radio1.value()).toBe('option2');
@@ -1,87 +1,53 @@
1
- import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
1
+ import { NgComponentOutlet } from '@angular/common';
2
+ import { Component, signal } from '@angular/core';
3
+ import { form, FormField } from '@angular/forms/signals';
2
4
  import { Meta, StoryObj } from '@storybook/angular';
3
5
 
6
+ import FormControlStories from '../form-control/form-control.stories';
4
7
  import { ZenRadio } from './radio';
5
8
 
6
- interface StoryParams {
7
- selectedColor?: string;
8
- }
9
-
10
- type Options = ZenRadio & StoryParams;
9
+ type Options = ZenRadio;
11
10
 
12
11
  export default {
13
12
  title: 'Ui/Radio',
14
13
  component: ZenRadio,
15
14
  argTypes: {
15
+ ...FormControlStories.argTypes,
16
16
  value: {
17
17
  table: {
18
18
  category: 'models',
19
- type: {
20
- summary: 'string | null',
21
- },
22
- defaultValue: {
23
- summary: 'null',
24
- },
19
+ type: { summary: 'string | null' },
20
+ defaultValue: { summary: 'null' },
25
21
  },
26
- control: 'text',
22
+ control: 'text' as const,
27
23
  },
28
24
  name: {
29
25
  table: {
30
26
  category: 'models',
31
- type: {
32
- summary: 'string',
33
- },
34
- defaultValue: {
35
- summary: 'radio',
36
- },
27
+ type: { summary: 'string' },
28
+ defaultValue: { summary: 'radio' },
37
29
  },
38
- control: 'text',
30
+ control: 'text' as const,
39
31
  },
40
32
  option: {
41
33
  table: {
42
34
  category: 'inputs',
43
- type: {
44
- summary: 'string',
45
- },
46
- },
47
- control: 'text',
48
- },
49
- disabled: {
50
- control: 'boolean',
51
- table: {
52
- category: 'models',
53
- type: {
54
- summary: 'boolean',
55
- },
56
- },
57
- },
58
- required: {
59
- control: 'boolean',
60
- table: {
61
- category: 'inputs',
62
- type: {
63
- summary: 'boolean',
64
- },
65
- defaultValue: {
66
- summary: 'false',
67
- },
35
+ type: { summary: 'string' },
68
36
  },
37
+ control: 'text' as const,
69
38
  },
70
39
  onInput: {
71
40
  table: {
72
41
  readonly: true,
73
- type: {
74
- summary: '(value: string | null) => void',
75
- },
42
+ type: { summary: '(value: string | null) => void' },
76
43
  },
77
44
  },
78
45
  },
79
46
  args: {
47
+ ...FormControlStories.args,
80
48
  value: null,
81
49
  name: 'radio-group',
82
50
  option: 'option1',
83
- disabled: false,
84
- required: false,
85
51
  },
86
52
  } satisfies Meta<Options>;
87
53
 
@@ -100,70 +66,52 @@ export const WithLabel: Story = {
100
66
  }),
101
67
  };
102
68
 
103
- export const NgModel: Story = {
69
+ export const WithSignalForm: Story = {
104
70
  render: () => ({
105
- moduleMetadata: {
106
- imports: [FormsModule],
107
- },
108
- props: {
109
- selectedColor: 'blue',
110
- },
111
- template: `
112
- <div style="display: flex; flex-direction: column; gap: 1rem">
113
- <div>
114
- <strong>Selected value:</strong>
115
- <p> {{ selectedColor || 'None' }}</p>
116
- </div>
117
- <div style="display: flex; flex-direction: column; gap: 0.5rem">
118
- <div style="display: flex; align-items: center; gap: 0.25rem">
119
- <zen-radio name="color-group" option="red" [(ngModel)]="selectedColor" />
120
- <label>Red</label>
121
- </div>
122
- <div style="display: flex; align-items: center; gap: 0.25rem">
123
- <zen-radio name="color-group" option="green" [(ngModel)]="selectedColor" />
124
- <label>Green</label>
125
- </div>
126
- <div style="display: flex; align-items: center; gap: 0.25rem">
127
- <zen-radio name="color-group" option="blue" [(ngModel)]="selectedColor" />
128
- <label>Blue</label>
129
- </div>
130
- </div>
131
- </div>
132
- `,
71
+ moduleMetadata: { imports: [NgComponentOutlet] },
72
+ props: { component: RadioSignalFormComponent },
73
+ template: '<ng-container *ngComponentOutlet="component" />',
133
74
  }),
134
- };
135
-
136
- export const AsFromControl: Story = {
137
- render: () => {
138
- return {
139
- moduleMetadata: {
140
- imports: [ReactiveFormsModule],
141
- },
142
- props: {
143
- colorControl: new FormControl('green'),
75
+ parameters: {
76
+ docs: {
77
+ source: {
78
+ code: `<zen-radio [formField]="form.color" option="red" />
79
+ <zen-radio [formField]="form.color" option="green" />
80
+ <zen-radio [formField]="form.color" option="blue" />`,
144
81
  },
145
- template: `
146
- <div style="display: flex; flex-direction: column; gap: 1rem">
147
- <div>
148
- <strong>Selected value:</strong>
149
- <p> {{ colorControl.value || 'None' }}</p>
150
- </div>
151
- <div style="display: flex; flex-direction: column; gap: 0.5rem">
152
- <div style="display: flex; align-items: center; gap: 0.25rem">
153
- <zen-radio name="working-group" option="red" [formControl]="colorControl" />
154
- <label>Red</label>
155
- </div>
156
- <div style="display: flex; align-items: center; gap: 0.25rem">
157
- <zen-radio name="working-group" option="green" [formControl]="colorControl" />
158
- <label>Green</label>
159
- </div>
160
- <div style="display: flex; align-items: center; gap: 0.25rem">
161
- <zen-radio name="working-group" option="blue" [formControl]="colorControl" />
162
- <label>Blue</label>
163
- </div>
164
- </div>
165
- </div>
166
- `,
167
- };
82
+ },
168
83
  },
169
84
  };
85
+
86
+ @Component({
87
+ standalone: true,
88
+ imports: [FormField, ZenRadio],
89
+ template: `
90
+ <div style="display: flex; flex-direction: column; gap: 1rem">
91
+ <div>
92
+ <strong>Selected:</strong>
93
+ {{ form.color().value() }}
94
+ </div>
95
+ <div style="display: flex; flex-direction: column; gap: 0.5rem">
96
+ <!-- eslint-disable-next-line @angular-eslint/template/label-has-associated-control -->
97
+ <label style="display: flex; align-items: center; gap: 0.25rem">
98
+ <zen-radio [formField]="form.color" option="red" />
99
+ Red
100
+ </label>
101
+ <!-- eslint-disable-next-line @angular-eslint/template/label-has-associated-control -->
102
+ <label style="display: flex; align-items: center; gap: 0.25rem">
103
+ <zen-radio [formField]="form.color" option="green" />
104
+ Green
105
+ </label>
106
+ <!-- eslint-disable-next-line @angular-eslint/template/label-has-associated-control -->
107
+ <label style="display: flex; align-items: center; gap: 0.25rem">
108
+ <zen-radio [formField]="form.color" option="blue" />
109
+ Blue
110
+ </label>
111
+ </div>
112
+ </div>
113
+ `,
114
+ })
115
+ class RadioSignalFormComponent {
116
+ readonly form = form(signal({ color: 'blue' }));
117
+ }