@dynamic-field-kit/angular 1.3.3 → 1.4.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +50 -3
  3. package/dist/README.md +50 -3
  4. package/dist/components/BaseInput.d.ts +5 -11
  5. package/dist/components/DynamicInput.d.ts +6 -2
  6. package/dist/components/FieldInput.d.ts +6 -2
  7. package/dist/components/MultiFieldInput.d.ts +10 -1
  8. package/dist/esm2022/components/BaseInput.mjs +8 -17
  9. package/dist/esm2022/components/DynamicInput.mjs +67 -14
  10. package/dist/esm2022/components/FieldInput.mjs +39 -20
  11. package/dist/esm2022/components/MultiFieldInput.mjs +87 -13
  12. package/dist/esm2022/fieldRegistryToken.mjs +12 -0
  13. package/dist/esm2022/public-api.mjs +6 -2
  14. package/dist/esm2022/types/layout.mjs +1 -1
  15. package/dist/fesm2022/dynamic-field-kit-angular.mjs +208 -61
  16. package/dist/fesm2022/dynamic-field-kit-angular.mjs.map +1 -1
  17. package/dist/fieldRegistryToken.d.ts +3 -0
  18. package/dist/public-api.d.ts +4 -1
  19. package/dist/types/layout.d.ts +4 -25
  20. package/package.json +14 -16
  21. package/src/components/BaseInput.ts +7 -10
  22. package/src/components/DynamicInput.ts +67 -11
  23. package/src/components/FieldInput.ts +23 -16
  24. package/src/components/MultiFieldInput.ts +101 -8
  25. package/src/fieldRegistryToken.ts +15 -0
  26. package/src/public-api.ts +40 -24
  27. package/src/types/layout.ts +14 -36
  28. package/test/DynamicInput.spec.ts +217 -17
  29. package/test/FieldInput.spec.ts +131 -29
  30. package/test/MultiFieldInput.spec.ts +256 -0
  31. package/test/helpers/renderers.ts +89 -0
  32. package/test/layout.spec.ts +119 -0
  33. package/test/publicApi.spec.ts +64 -0
  34. package/{test.ts → test/setup.ts} +2 -2
  35. package/test/smoke.spec.ts +27 -0
  36. package/tsconfig.json +13 -13
  37. package/tsconfig.spec.json +4 -16
  38. package/vitest.config.ts +30 -0
  39. package/angular.json +0 -27
  40. package/karma.conf.js +0 -37
  41. package/test/angular.spec.ts +0 -102
  42. package/test/integration.spec.ts +0 -57
  43. package/test/layouts.spec.ts +0 -26
@@ -16,9 +16,15 @@ import {
16
16
  canAddGroupItem,
17
17
  canRemoveGroupItem,
18
18
  createGroupItem,
19
+ resolveDisabled,
20
+ resolveOptions,
21
+ resolveReadOnly,
22
+ validateField,
23
+ validateFields,
19
24
  FieldDescription,
20
25
  Properties,
21
26
  } from '@dynamic-field-kit/core';
27
+ import type { ValidationResult } from '@dynamic-field-kit/core';
22
28
  import { BaseLayoutConfig, LayoutConfig } from '../types/layout';
23
29
  import { FieldInput } from './FieldInput';
24
30
 
@@ -53,6 +59,10 @@ const DEFAULT_BREAKPOINT = 768;
53
59
  *ngIf="!field.fields"
54
60
  [fieldDescription]="field"
55
61
  [value]="data[field.name]"
62
+ [options]="getResolvedOptions(field)"
63
+ [disabled]="getDisabled(field)"
64
+ [readOnly]="getReadOnly(field)"
65
+ [error]="getError(field)"
56
66
  (onValueChangeField)="onFieldChange($event)"
57
67
  ></dfk-field-input>
58
68
 
@@ -62,7 +72,7 @@ const DEFAULT_BREAKPOINT = 768;
62
72
  *ngFor="
63
73
  let item of getItems(field);
64
74
  let i = index;
65
- trackBy: trackByIndex
75
+ trackBy: groupTrackBy(field)
66
76
  "
67
77
  style="display: flex; align-items: flex-start; gap: 8px;"
68
78
  >
@@ -70,11 +80,19 @@ const DEFAULT_BREAKPOINT = 768;
70
80
  <dfk-multi-field-input
71
81
  [fieldDescriptions]="field.fields"
72
82
  [properties]="item"
83
+ [rootData]="rootData ?? data"
73
84
  (onChange)="onGroupItemChange(field, i, $event)"
74
85
  ></dfk-multi-field-input>
75
86
  </div>
76
87
  <button
77
88
  type="button"
89
+ [attr.aria-label]="
90
+ (field.removeLabel || 'Remove') +
91
+ ' ' +
92
+ (field.label || field.name) +
93
+ ' ' +
94
+ (i + 1)
95
+ "
78
96
  (click)="onGroupItemRemove(field, i)"
79
97
  [disabled]="!canRemoveItem(field)"
80
98
  >
@@ -83,6 +101,9 @@ const DEFAULT_BREAKPOINT = 768;
83
101
  </div>
84
102
  <button
85
103
  type="button"
104
+ [attr.aria-label]="
105
+ (field.addLabel || 'Add') + ' ' + (field.label || field.name)
106
+ "
86
107
  (click)="onGroupItemAdd(field)"
87
108
  [disabled]="!canAddItem(field)"
88
109
  >
@@ -97,7 +118,12 @@ export class MultiFieldInput implements OnInit, OnChanges {
97
118
  @Input() fieldDescriptions: FieldDescription[] = [];
98
119
  @Input() properties?: Properties;
99
120
  @Output() onChange = new EventEmitter<Properties>();
121
+ @Output() validityChange = new EventEmitter<ValidationResult>();
100
122
  @Input() layout: LayoutConfig = 'column';
123
+ // Top-level form data, threaded down through repeatable groups so a nested
124
+ // field's appearCondition/computeValue can read the root form. Omitted at
125
+ // the top level, where the form's own data is the root.
126
+ @Input() rootData?: Properties;
101
127
 
102
128
  data: Properties = {};
103
129
  visibleFields: FieldDescription[] = [];
@@ -150,6 +176,26 @@ export class MultiFieldInput implements OnInit, OnChanges {
150
176
  return index;
151
177
  }
152
178
 
179
+ private groupTrackByCache = new WeakMap<
180
+ FieldDescription,
181
+ (index: number, item: Properties) => unknown
182
+ >();
183
+
184
+ // Returns a stable trackBy per group field: item[keyField] when configured,
185
+ // else the index. Cached by field reference so the template hands Angular the
186
+ // same function each change-detection pass.
187
+ groupTrackBy(
188
+ field: FieldDescription
189
+ ): (index: number, item: Properties) => unknown {
190
+ let fn = this.groupTrackByCache.get(field);
191
+ if (!fn) {
192
+ fn = (index: number, item: Properties) =>
193
+ field.keyField ? item[field.keyField] ?? index : index;
194
+ this.groupTrackByCache.set(field, fn);
195
+ }
196
+ return fn;
197
+ }
198
+
153
199
  ngOnInit() {
154
200
  this.updateIsMobile();
155
201
  this.init();
@@ -164,22 +210,37 @@ export class MultiFieldInput implements OnInit, OnChanges {
164
210
  typeof this.layout === 'object' && this.layout.type === 'responsive'
165
211
  ? this.layout.breakpoint ?? DEFAULT_BREAKPOINT
166
212
  : DEFAULT_BREAKPOINT;
167
- this.isMobile =
168
- typeof window !== 'undefined' && window.innerWidth < breakpoint;
213
+ if (
214
+ typeof window !== 'undefined' &&
215
+ typeof window.matchMedia === 'function'
216
+ ) {
217
+ this.isMobile = window.matchMedia(
218
+ `(max-width: ${breakpoint - 1}px)`
219
+ ).matches;
220
+ } else {
221
+ this.isMobile =
222
+ typeof window !== 'undefined' && window.innerWidth < breakpoint;
223
+ }
169
224
  }
170
225
 
171
226
  private init() {
172
227
  if (this.properties) {
173
- this.data = applyComputedValues(this.fieldDescriptions, {
174
- ...this.properties,
175
- });
228
+ this.data = applyComputedValues(
229
+ this.fieldDescriptions,
230
+ { ...this.properties },
231
+ this.rootData
232
+ );
176
233
  }
177
234
  this.updateVisibleFields();
235
+ this.validityChange.emit(
236
+ validateFields(this.fieldDescriptions, this.data, this.rootData)
237
+ );
178
238
  }
179
239
 
180
240
  private updateVisibleFields() {
241
+ const root = this.rootData ?? this.data;
181
242
  this.visibleFields = this.fieldDescriptions.filter(
182
- (f) => !f.appearCondition || f.appearCondition(this.data)
243
+ (f) => !f.appearCondition || f.appearCondition(this.data, root)
183
244
  );
184
245
  }
185
246
 
@@ -192,6 +253,31 @@ export class MultiFieldInput implements OnInit, OnChanges {
192
253
  return Array.isArray(value) ? (value as Properties[]) : [];
193
254
  }
194
255
 
256
+ getResolvedOptions(field: FieldDescription): Properties[] | undefined {
257
+ return resolveOptions(field, this.data, this.rootData);
258
+ }
259
+
260
+ getDisabled(field: FieldDescription): boolean {
261
+ return resolveDisabled(field, this.data, this.rootData);
262
+ }
263
+
264
+ getReadOnly(field: FieldDescription): boolean {
265
+ return resolveReadOnly(field, this.data, this.rootData);
266
+ }
267
+
268
+ getError(field: FieldDescription): string[] | undefined {
269
+ if (this.getDisabled(field)) {
270
+ return undefined;
271
+ }
272
+ const errors = validateField(
273
+ field,
274
+ this.data[field.name],
275
+ this.data,
276
+ this.rootData
277
+ );
278
+ return errors.length > 0 ? errors : undefined;
279
+ }
280
+
195
281
  canAddItem(field: FieldDescription): boolean {
196
282
  return canAddGroupItem(field, this.getItems(field));
197
283
  }
@@ -230,9 +316,16 @@ export class MultiFieldInput implements OnInit, OnChanges {
230
316
  }
231
317
 
232
318
  private commitData(nextData: Properties): void {
233
- this.data = applyComputedValues(this.fieldDescriptions, nextData);
319
+ this.data = applyComputedValues(
320
+ this.fieldDescriptions,
321
+ nextData,
322
+ this.rootData
323
+ );
234
324
  this.updateVisibleFields();
235
325
  this.onChange.emit(this.data);
326
+ this.validityChange.emit(
327
+ validateFields(this.fieldDescriptions, this.data, this.rootData)
328
+ );
236
329
  this.cdr.markForCheck();
237
330
  }
238
331
  }
@@ -0,0 +1,15 @@
1
+ import { InjectionToken } from '@angular/core';
2
+ import { FieldRegistry, fieldRegistry } from '@dynamic-field-kit/core';
3
+
4
+ // Injecting this token yields the process-wide singleton by default, so code
5
+ // that never provides it keeps working. Provide it on a component/route to give
6
+ // that subtree an isolated set of renderers:
7
+ //
8
+ // providers: [{ provide: FIELD_REGISTRY, useValue: myScopedRegistry }]
9
+ export const FIELD_REGISTRY = new InjectionToken<FieldRegistry>(
10
+ 'dfk.FieldRegistry',
11
+ {
12
+ providedIn: 'root',
13
+ factory: () => fieldRegistry,
14
+ }
15
+ );
package/src/public-api.ts CHANGED
@@ -1,24 +1,40 @@
1
- // public-api.ts
2
-
3
- // Components
4
- export * from './components/BaseInput';
5
- export * from './components/DynamicInput';
6
- export * from './components/FieldInput';
7
- export * from './components/MultiFieldInput';
8
-
9
- // Layout
10
- export * from './layout';
11
- export * from './types/layout';
12
-
13
- // Module
14
- export * from './lib/dynamic-field-kit.module';
15
-
16
- // Re-export types from core (only type, not export * )
17
- export type {
18
- FieldDescription,
19
- FieldRendererProps,
20
- FieldTypeKey,
21
- } from '@dynamic-field-kit/core';
22
-
23
- // Optional: expose registry for advanced use cases, but not required for basic usage
24
- export { fieldRegistry } from '@dynamic-field-kit/core';
1
+ // public-api.ts
2
+
3
+ // Components
4
+ export * from './components/BaseInput';
5
+ export * from './components/DynamicInput';
6
+ export * from './components/FieldInput';
7
+ export * from './components/MultiFieldInput';
8
+
9
+ // Layout
10
+ export * from './layout';
11
+ export * from './types/layout';
12
+
13
+ // Module
14
+ export * from './lib/dynamic-field-kit.module';
15
+
16
+ // Re-export types from core (only type, not export * )
17
+ export type {
18
+ FieldDescription,
19
+ FieldRendererProps,
20
+ FieldTypeKey,
21
+ } from '@dynamic-field-kit/core';
22
+
23
+ // Optional: expose registry for advanced use cases, but not required for basic usage
24
+ export { fieldRegistry, FieldRegistry } from '@dynamic-field-kit/core';
25
+
26
+ export {
27
+ validateField,
28
+ validateFieldAsync,
29
+ validateFields,
30
+ validateFieldsAsync,
31
+ resolveDisabled,
32
+ resolveReadOnly,
33
+ resolveOptions,
34
+ validators,
35
+ } from '@dynamic-field-kit/core';
36
+ export type { ValidationResult } from '@dynamic-field-kit/core';
37
+
38
+ // Scoped registry: provide FIELD_REGISTRY on a component/route to give that
39
+ // subtree an isolated set of renderers.
40
+ export { FIELD_REGISTRY } from './fieldRegistryToken';
@@ -1,36 +1,14 @@
1
- export interface GridLayoutConfig {
2
- type: 'grid';
3
- columns?: number;
4
- gap?: number;
5
- width?: string;
6
- }
7
-
8
- export interface RowLayoutConfig {
9
- type: 'row';
10
- gap?: number;
11
- width?: string;
12
- flex?: boolean;
13
- }
14
-
15
- export interface ColumnLayoutConfig {
16
- type: 'column';
17
- gap?: number;
18
- width?: string;
19
- }
20
-
21
- export type BaseLayoutConfig =
22
- | 'column'
23
- | 'row'
24
- | 'grid'
25
- | GridLayoutConfig
26
- | RowLayoutConfig
27
- | ColumnLayoutConfig;
28
-
29
- export interface ResponsiveLayoutConfig {
30
- type: 'responsive';
31
- mobile: BaseLayoutConfig;
32
- desktop: BaseLayoutConfig;
33
- breakpoint?: number;
34
- }
35
-
36
- export type LayoutConfig = BaseLayoutConfig | ResponsiveLayoutConfig;
1
+ // Layout config types live in @dynamic-field-kit/core so the contract stays
2
+ // identical across every framework adapter. Re-exported here (with the
3
+ // historical Angular ...Config aliases) to preserve existing import paths.
4
+ import type { BaseLayout, ResponsiveLayout } from '@dynamic-field-kit/core';
5
+
6
+ export type {
7
+ ColumnLayoutConfig,
8
+ RowLayoutConfig,
9
+ GridLayoutConfig,
10
+ LayoutConfig,
11
+ } from '@dynamic-field-kit/core';
12
+
13
+ export type BaseLayoutConfig = BaseLayout;
14
+ export type ResponsiveLayoutConfig = ResponsiveLayout;
@@ -1,30 +1,230 @@
1
+ import { TestBed } from '@angular/core/testing';
2
+ import { By } from '@angular/platform-browser';
3
+ import { DynamicInput } from '../src/components/DynamicInput';
4
+ import { FIELD_REGISTRY } from '../src/fieldRegistryToken';
5
+ import { beforeEach, describe, expect, it } from 'vitest';
6
+ import {
7
+ DefaultsRendererComponent,
8
+ makeRegistry,
9
+ TextRendererComponent,
10
+ LegacyOutputRendererComponent,
11
+ fallbackRenderer,
12
+ } from './helpers/renderers';
13
+
1
14
  describe('DynamicInput', () => {
2
- it('should create DynamicInput', () => {
3
- expect(true).toBe(true);
15
+ let registry: ReturnType<typeof makeRegistry>;
16
+
17
+ beforeEach(() => {
18
+ registry = makeRegistry();
19
+ TestBed.configureTestingModule({
20
+ imports: [DynamicInput],
21
+ providers: [{ provide: FIELD_REGISTRY, useValue: registry }],
22
+ });
23
+ });
24
+
25
+ it('renders a registered Angular component class', () => {
26
+ registry.register('text', TextRendererComponent as never);
27
+
28
+ const fixture = TestBed.createComponent(DynamicInput);
29
+ fixture.componentRef.setInput('type', 'text');
30
+ fixture.componentRef.setInput('value', 'hello');
31
+ fixture.detectChanges();
32
+
33
+ const input: HTMLInputElement =
34
+ fixture.nativeElement.querySelector('input.txt');
35
+ expect(input).not.toBeNull();
36
+ expect(input.value).toBe('hello');
37
+ });
38
+
39
+ it('does not clobber a renderer input default when DynamicInput was never given that prop', () => {
40
+ registry.register('defaults', DefaultsRendererComponent as never);
41
+
42
+ const fixture = TestBed.createComponent(DynamicInput);
43
+ fixture.componentRef.setInput('type', 'defaults');
44
+ // Deliberately never setInput('label', ...): DynamicInput was never
45
+ // given a value for `label`, so the renderer's own initializer
46
+ // (`label = 'None'`) must survive applyProps untouched.
47
+ fixture.detectChanges();
48
+
49
+ const label: HTMLElement = fixture.nativeElement.querySelector('.label');
50
+ expect(label.textContent).toBe('None');
51
+ });
52
+
53
+ it('forwards KNOWN_PROPS to the rendered instance', () => {
54
+ registry.register('text', TextRendererComponent as never);
55
+
56
+ const fixture = TestBed.createComponent(DynamicInput);
57
+ fixture.componentRef.setInput('type', 'text');
58
+ fixture.componentRef.setInput('placeholder', 'Your name');
59
+ fixture.componentRef.setInput('disabled', true);
60
+ fixture.detectChanges();
61
+
62
+ const input: HTMLInputElement =
63
+ fixture.nativeElement.querySelector('input.txt');
64
+ expect(input.placeholder).toBe('Your name');
65
+ expect(input.disabled).toBe(true);
4
66
  });
5
67
 
6
- it('should accept type input', () => {
7
- const type = 'text';
8
- expect(type).toBe('text');
68
+ it('forwards extraProps verbatim', () => {
69
+ registry.register('text', TextRendererComponent as never);
70
+
71
+ const fixture = TestBed.createComponent(DynamicInput);
72
+ fixture.componentRef.setInput('type', 'text');
73
+ fixture.componentRef.setInput('extraProps', { hint: 'be brief' });
74
+ fixture.detectChanges();
75
+
76
+ expect(fixture.nativeElement.querySelector('.hint').textContent).toBe(
77
+ 'be brief'
78
+ );
9
79
  });
10
80
 
11
- it('should have valueChange output', () => {
12
- const hasOutput = true;
13
- expect(hasOutput).toBe(true);
81
+ it('syncs prop changes to an already-rendered instance', () => {
82
+ registry.register('text', TextRendererComponent as never);
83
+
84
+ const fixture = TestBed.createComponent(DynamicInput);
85
+ fixture.componentRef.setInput('type', 'text');
86
+ fixture.componentRef.setInput('value', 'first');
87
+ fixture.detectChanges();
88
+
89
+ fixture.componentRef.setInput('value', 'second');
90
+ fixture.detectChanges();
91
+
92
+ const input: HTMLInputElement =
93
+ fixture.nativeElement.querySelector('input.txt');
94
+ expect(input.value).toBe('second');
14
95
  });
15
96
 
16
- it('should have onChange output for backward compatibility', () => {
17
- const hasOutput = true;
18
- expect(hasOutput).toBe(true);
97
+ it('emits valueChange and onChange when the renderer emits valueChange', () => {
98
+ registry.register('text', TextRendererComponent as never);
99
+
100
+ const fixture = TestBed.createComponent(DynamicInput);
101
+ fixture.componentRef.setInput('type', 'text');
102
+ fixture.detectChanges();
103
+
104
+ const seen: unknown[] = [];
105
+ const legacy: unknown[] = [];
106
+ fixture.componentInstance.valueChange.subscribe((v) => seen.push(v));
107
+ fixture.componentInstance.onChange.subscribe((v) => legacy.push(v));
108
+
109
+ const input: HTMLInputElement =
110
+ fixture.nativeElement.querySelector('input.txt');
111
+ input.value = 'typed';
112
+ input.dispatchEvent(new Event('input'));
113
+
114
+ expect(seen).toEqual(['typed']);
115
+ expect(legacy).toEqual(['typed']);
19
116
  });
20
117
 
21
- it('should handle unknown field type', () => {
22
- const unknownType = 'unknownType';
23
- expect(typeof unknownType).toBe('string');
118
+ it('binds the legacy onValueChange output name', () => {
119
+ registry.register('text', LegacyOutputRendererComponent as never);
120
+
121
+ const fixture = TestBed.createComponent(DynamicInput);
122
+ fixture.componentRef.setInput('type', 'text');
123
+ fixture.detectChanges();
124
+
125
+ const seen: unknown[] = [];
126
+ fixture.componentInstance.valueChange.subscribe((v) => seen.push(v));
127
+
128
+ fixture.nativeElement.querySelector('button.legacy-btn').click();
129
+
130
+ expect(seen).toEqual(['legacy']);
24
131
  });
25
132
 
26
- it('should emit value on change', () => {
27
- const value = 'test-value';
28
- expect(value).toBe('test-value');
133
+ it('renders a plain function renderer as fallback HTML', () => {
134
+ registry.register('text', fallbackRenderer as never);
135
+
136
+ const fixture = TestBed.createComponent(DynamicInput);
137
+ fixture.componentRef.setInput('type', 'text');
138
+ fixture.componentRef.setInput('label', 'Name');
139
+ fixture.componentRef.setInput('value', 'Ada');
140
+ fixture.detectChanges();
141
+
142
+ expect(fixture.nativeElement.querySelector('.fallback').textContent).toBe(
143
+ 'Name:Ada'
144
+ );
145
+ });
146
+
147
+ it('rejects a subclass without its own @Component decorator instead of inheriting the parent renderer', () => {
148
+ // Sub has no own `ɵcmp`: it only inherits TextRendererComponent's via
149
+ // the prototype chain. isComponentType must use hasOwnProperty (not
150
+ // `in`) so Sub is not mistaken for a renderable component in its own
151
+ // right - the DOM must not show TextRendererComponent's template.
152
+ class Sub extends TextRendererComponent {}
153
+ registry.register('text', Sub as never);
154
+
155
+ const fixture = TestBed.createComponent(DynamicInput);
156
+ fixture.componentRef.setInput('type', 'text');
157
+ fixture.detectChanges();
158
+
159
+ expect(fixture.nativeElement.querySelector('input.txt')).toBeNull();
160
+ expect(fixture.nativeElement.textContent).toContain(
161
+ 'Failed to render field: text'
162
+ );
163
+ });
164
+
165
+ it('renders an error for an unknown field type', () => {
166
+ const fixture = TestBed.createComponent(DynamicInput);
167
+ fixture.componentRef.setInput('type', 'nope');
168
+ fixture.detectChanges();
169
+
170
+ expect(fixture.nativeElement.textContent).toContain(
171
+ 'Unknown field type: nope'
172
+ );
173
+ });
174
+
175
+ it('re-renders when type changes', () => {
176
+ registry.register('text', TextRendererComponent as never);
177
+ registry.register('number', fallbackRenderer as never);
178
+
179
+ const fixture = TestBed.createComponent(DynamicInput);
180
+ fixture.componentRef.setInput('type', 'text');
181
+ fixture.detectChanges();
182
+ expect(fixture.nativeElement.querySelector('input.txt')).not.toBeNull();
183
+
184
+ fixture.componentRef.setInput('type', 'number');
185
+ fixture.detectChanges();
186
+
187
+ expect(fixture.nativeElement.querySelector('input.txt')).toBeNull();
188
+ expect(fixture.nativeElement.querySelector('.fallback')).not.toBeNull();
189
+ });
190
+
191
+ it('unsubscribes from renderer outputs on destroy', () => {
192
+ registry.register('text', TextRendererComponent as never);
193
+
194
+ const fixture = TestBed.createComponent(DynamicInput);
195
+ fixture.componentRef.setInput('type', 'text');
196
+ fixture.detectChanges();
197
+
198
+ const rendererInstance = fixture.debugElement.query(
199
+ By.directive(TextRendererComponent)
200
+ ).componentInstance as TextRendererComponent;
201
+
202
+ const seen: unknown[] = [];
203
+ fixture.componentInstance.valueChange.subscribe((v) => seen.push(v));
204
+
205
+ fixture.destroy();
206
+ rendererInstance.valueChange.emit('after destroy'); // bypasses DOM entirely
207
+
208
+ expect(seen).toEqual([]);
209
+ });
210
+
211
+ it('unsubscribes from the legacy onValueChange output on destroy', () => {
212
+ registry.register('text', LegacyOutputRendererComponent as never);
213
+
214
+ const fixture = TestBed.createComponent(DynamicInput);
215
+ fixture.componentRef.setInput('type', 'text');
216
+ fixture.detectChanges();
217
+
218
+ const rendererInstance = fixture.debugElement.query(
219
+ By.directive(LegacyOutputRendererComponent)
220
+ ).componentInstance as LegacyOutputRendererComponent;
221
+
222
+ const seen: unknown[] = [];
223
+ fixture.componentInstance.valueChange.subscribe((v) => seen.push(v));
224
+
225
+ fixture.destroy();
226
+ rendererInstance.onValueChange.emit('after destroy');
227
+
228
+ expect(seen).toEqual([]);
29
229
  });
30
230
  });