@dynamic-field-kit/angular 1.2.10 → 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 (53) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +113 -4
  3. package/dist/README.md +113 -4
  4. package/dist/components/BaseInput.d.ts +23 -11
  5. package/dist/components/DynamicInput.d.ts +19 -8
  6. package/dist/components/FieldInput.d.ts +15 -7
  7. package/dist/components/MultiFieldInput.d.ts +30 -4
  8. package/dist/esm2022/components/BaseInput.mjs +17 -18
  9. package/dist/esm2022/components/DynamicInput.mjs +166 -84
  10. package/dist/esm2022/components/FieldInput.mjs +54 -26
  11. package/dist/esm2022/components/MultiFieldInput.mjs +282 -29
  12. package/dist/esm2022/fieldRegistryToken.mjs +12 -0
  13. package/dist/esm2022/layout/defaultLayouts.mjs +70 -23
  14. package/dist/esm2022/layout/index.mjs +2 -1
  15. package/dist/esm2022/layout/layoutRegistry.mjs +14 -0
  16. package/dist/esm2022/public-api.mjs +6 -2
  17. package/dist/esm2022/types/layout.mjs +1 -1
  18. package/dist/fesm2022/dynamic-field-kit-angular.mjs +604 -173
  19. package/dist/fesm2022/dynamic-field-kit-angular.mjs.map +1 -1
  20. package/dist/fieldRegistryToken.d.ts +3 -0
  21. package/dist/layout/defaultLayouts.d.ts +21 -3
  22. package/dist/layout/index.d.ts +1 -0
  23. package/dist/layout/layoutRegistry.d.ts +12 -0
  24. package/dist/public-api.d.ts +5 -2
  25. package/dist/types/layout.d.ts +4 -1
  26. package/package.json +19 -8
  27. package/src/components/BaseInput.ts +36 -10
  28. package/src/components/DynamicInput.ts +196 -104
  29. package/src/components/FieldInput.ts +43 -18
  30. package/src/components/MultiFieldInput.ts +275 -19
  31. package/src/fieldRegistryToken.ts +15 -0
  32. package/src/layout/defaultLayouts.ts +42 -10
  33. package/src/layout/index.ts +1 -0
  34. package/src/layout/layoutRegistry.ts +25 -0
  35. package/src/public-api.ts +40 -24
  36. package/src/types/layout.ts +14 -1
  37. package/test/DynamicInput.spec.ts +230 -0
  38. package/test/FieldInput.spec.ts +146 -0
  39. package/test/MultiFieldInput.spec.ts +256 -0
  40. package/test/helpers/renderers.ts +89 -0
  41. package/test/layout.spec.ts +119 -0
  42. package/test/publicApi.spec.ts +64 -0
  43. package/test/setup.ts +12 -0
  44. package/test/smoke.spec.ts +27 -0
  45. package/tsconfig.json +13 -13
  46. package/tsconfig.spec.json +9 -0
  47. package/vitest.config.ts +30 -0
  48. package/src/adapters/tailwind.ts +0 -13
  49. package/src/components/imports.ts +0 -3
  50. package/src/examples/index.ts +0 -4
  51. package/src/examples/registerExample.ts +0 -7
  52. package/src/examples/text-field.component.ts +0 -18
  53. package/src/types.ts +0 -1
@@ -1,35 +1,116 @@
1
- import { NgClass, NgFor } from '@angular/common';
1
+ import { NgClass, NgFor, NgIf } from '@angular/common';
2
2
  import {
3
+ ChangeDetectionStrategy,
4
+ ChangeDetectorRef,
3
5
  Component,
4
- Input,
5
- Output,
6
6
  EventEmitter,
7
+ HostListener,
8
+ Input,
7
9
  OnChanges,
8
10
  OnInit,
11
+ Output,
9
12
  SimpleChanges,
10
13
  } from '@angular/core';
11
- import { FieldDescription, Properties } from '@dynamic-field-kit/core';
12
- import { LayoutConfig } from '../types/layout';
14
+ import {
15
+ applyComputedValues,
16
+ canAddGroupItem,
17
+ canRemoveGroupItem,
18
+ createGroupItem,
19
+ resolveDisabled,
20
+ resolveOptions,
21
+ resolveReadOnly,
22
+ validateField,
23
+ validateFields,
24
+ FieldDescription,
25
+ Properties,
26
+ } from '@dynamic-field-kit/core';
27
+ import type { ValidationResult } from '@dynamic-field-kit/core';
28
+ import { BaseLayoutConfig, LayoutConfig } from '../types/layout';
13
29
  import { FieldInput } from './FieldInput';
14
30
 
31
+ const DEFAULT_BREAKPOINT = 768;
32
+
15
33
  @Component({
16
34
  selector: 'dfk-multi-field-input',
17
35
  standalone: true,
18
- imports: [NgClass, NgFor, FieldInput],
36
+ // Repeatable field groups render a nested <dfk-multi-field-input> per item
37
+ // (see the #groupTpl below), so this component imports itself. That's the
38
+ // supported way to give a standalone component recursive template usage
39
+ // without a cross-file circular import between FieldInput and a separate
40
+ // group component (whose `imports` arrays are evaluated eagerly at module
41
+ // load time and would deadlock on an actual circular module reference).
42
+ imports: [NgClass, NgFor, NgIf, FieldInput, MultiFieldInput],
43
+ changeDetection: ChangeDetectionStrategy.OnPush,
19
44
  template: `
20
45
  <div
21
- class="flex flex-col gap-3 p-4 border rounded-lg bg-gray-50"
46
+ class="p-4 border rounded-lg bg-gray-50"
22
47
  [ngClass]="{
23
- 'flex-row gap-3': layout === 'row',
24
- 'grid grid-cols-2 gap-3': layout === 'grid'
48
+ 'flex flex-col': resolvedLayoutType === 'column',
49
+ 'flex flex-row': resolvedLayoutType === 'row',
50
+ grid: resolvedLayoutType === 'grid'
25
51
  }"
52
+ [style.gap.px]="gap"
53
+ [style.gridTemplateColumns]="
54
+ resolvedLayoutType === 'grid' ? 'repeat(' + columns + ', 1fr)' : null
55
+ "
26
56
  >
27
- <dfk-field-input
28
- *ngFor="let field of visibleFields; trackBy: trackByFn"
29
- [fieldDescription]="field"
30
- [renderInfos]="data"
31
- (onValueChangeField)="onFieldChange($event)"
32
- ></dfk-field-input>
57
+ <ng-container *ngFor="let field of visibleFields; trackBy: trackByFn">
58
+ <dfk-field-input
59
+ *ngIf="!field.fields"
60
+ [fieldDescription]="field"
61
+ [value]="data[field.name]"
62
+ [options]="getResolvedOptions(field)"
63
+ [disabled]="getDisabled(field)"
64
+ [readOnly]="getReadOnly(field)"
65
+ [error]="getError(field)"
66
+ (onValueChangeField)="onFieldChange($event)"
67
+ ></dfk-field-input>
68
+
69
+ <div *ngIf="field.fields" [class]="field.className">
70
+ <div *ngIf="field.label">{{ field.label }}</div>
71
+ <div
72
+ *ngFor="
73
+ let item of getItems(field);
74
+ let i = index;
75
+ trackBy: groupTrackBy(field)
76
+ "
77
+ style="display: flex; align-items: flex-start; gap: 8px;"
78
+ >
79
+ <div style="flex: 1">
80
+ <dfk-multi-field-input
81
+ [fieldDescriptions]="field.fields"
82
+ [properties]="item"
83
+ [rootData]="rootData ?? data"
84
+ (onChange)="onGroupItemChange(field, i, $event)"
85
+ ></dfk-multi-field-input>
86
+ </div>
87
+ <button
88
+ type="button"
89
+ [attr.aria-label]="
90
+ (field.removeLabel || 'Remove') +
91
+ ' ' +
92
+ (field.label || field.name) +
93
+ ' ' +
94
+ (i + 1)
95
+ "
96
+ (click)="onGroupItemRemove(field, i)"
97
+ [disabled]="!canRemoveItem(field)"
98
+ >
99
+ {{ field.removeLabel || 'Remove' }}
100
+ </button>
101
+ </div>
102
+ <button
103
+ type="button"
104
+ [attr.aria-label]="
105
+ (field.addLabel || 'Add') + ' ' + (field.label || field.name)
106
+ "
107
+ (click)="onGroupItemAdd(field)"
108
+ [disabled]="!canAddItem(field)"
109
+ >
110
+ {{ field.addLabel || 'Add' }}
111
+ </button>
112
+ </div>
113
+ </ng-container>
33
114
  </div>
34
115
  `,
35
116
  })
@@ -37,16 +118,86 @@ export class MultiFieldInput implements OnInit, OnChanges {
37
118
  @Input() fieldDescriptions: FieldDescription[] = [];
38
119
  @Input() properties?: Properties;
39
120
  @Output() onChange = new EventEmitter<Properties>();
121
+ @Output() validityChange = new EventEmitter<ValidationResult>();
40
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;
41
127
 
42
128
  data: Properties = {};
43
129
  visibleFields: FieldDescription[] = [];
130
+ private isMobile = false;
131
+
132
+ constructor(private cdr: ChangeDetectorRef) {}
133
+
134
+ @HostListener('window:resize')
135
+ onWindowResize(): void {
136
+ const wasMobile = this.isMobile;
137
+ this.updateIsMobile();
138
+ if (wasMobile !== this.isMobile) {
139
+ this.cdr.markForCheck();
140
+ }
141
+ }
142
+
143
+ get resolvedLayout(): BaseLayoutConfig {
144
+ if (typeof this.layout === 'object' && this.layout.type === 'responsive') {
145
+ return this.isMobile ? this.layout.mobile : this.layout.desktop;
146
+ }
147
+ return this.layout as BaseLayoutConfig;
148
+ }
149
+
150
+ get resolvedLayoutType(): string {
151
+ const resolved = this.resolvedLayout;
152
+ return typeof resolved === 'string' ? resolved : resolved.type;
153
+ }
154
+
155
+ get gap(): number {
156
+ const resolved = this.resolvedLayout;
157
+ if (typeof resolved === 'object' && resolved.gap !== undefined) {
158
+ return resolved.gap;
159
+ }
160
+ return 12;
161
+ }
162
+
163
+ get columns(): number {
164
+ const resolved = this.resolvedLayout;
165
+ if (typeof resolved === 'object' && resolved.type === 'grid') {
166
+ return resolved.columns ?? 2;
167
+ }
168
+ return 2;
169
+ }
44
170
 
45
171
  trackByFn(index: number, field: FieldDescription): string | number {
46
172
  return field.name || index;
47
173
  }
48
174
 
175
+ trackByIndex(index: number): number {
176
+ return index;
177
+ }
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
+
49
199
  ngOnInit() {
200
+ this.updateIsMobile();
50
201
  this.init();
51
202
  }
52
203
 
@@ -54,22 +205,127 @@ export class MultiFieldInput implements OnInit, OnChanges {
54
205
  this.init();
55
206
  }
56
207
 
208
+ private updateIsMobile(): void {
209
+ const breakpoint =
210
+ typeof this.layout === 'object' && this.layout.type === 'responsive'
211
+ ? this.layout.breakpoint ?? DEFAULT_BREAKPOINT
212
+ : DEFAULT_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
+ }
224
+ }
225
+
57
226
  private init() {
58
227
  if (this.properties) {
59
- this.data = { ...this.properties };
228
+ this.data = applyComputedValues(
229
+ this.fieldDescriptions,
230
+ { ...this.properties },
231
+ this.rootData
232
+ );
60
233
  }
61
234
  this.updateVisibleFields();
235
+ this.validityChange.emit(
236
+ validateFields(this.fieldDescriptions, this.data, this.rootData)
237
+ );
62
238
  }
63
239
 
64
240
  private updateVisibleFields() {
241
+ const root = this.rootData ?? this.data;
65
242
  this.visibleFields = this.fieldDescriptions.filter(
66
- (f) => !f.appearCondition || f.appearCondition(this.data)
243
+ (f) => !f.appearCondition || f.appearCondition(this.data, root)
244
+ );
245
+ }
246
+
247
+ onFieldChange(event: { value: unknown; key: string }): void {
248
+ this.commitData({ ...this.data, [event.key]: event.value });
249
+ }
250
+
251
+ getItems(field: FieldDescription): Properties[] {
252
+ const value = this.data[field.name];
253
+ return Array.isArray(value) ? (value as Properties[]) : [];
254
+ }
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
67
277
  );
278
+ return errors.length > 0 ? errors : undefined;
279
+ }
280
+
281
+ canAddItem(field: FieldDescription): boolean {
282
+ return canAddGroupItem(field, this.getItems(field));
283
+ }
284
+
285
+ canRemoveItem(field: FieldDescription): boolean {
286
+ return canRemoveGroupItem(field, this.getItems(field));
287
+ }
288
+
289
+ onGroupItemAdd(field: FieldDescription): void {
290
+ if (!this.canAddItem(field)) {
291
+ return;
292
+ }
293
+ const items = this.getItems(field);
294
+ this.commitData({
295
+ ...this.data,
296
+ [field.name]: [...items, createGroupItem(field)],
297
+ });
68
298
  }
69
299
 
70
- onFieldChange(event: { value: any; key: string }) {
71
- this.data = { ...this.data, [event.key]: event.value };
300
+ onGroupItemRemove(field: FieldDescription, index: number): void {
301
+ if (!this.canRemoveItem(field)) {
302
+ return;
303
+ }
304
+ const items = this.getItems(field).filter((_, i) => i !== index);
305
+ this.commitData({ ...this.data, [field.name]: items });
306
+ }
307
+
308
+ onGroupItemChange(
309
+ field: FieldDescription,
310
+ index: number,
311
+ next: Properties
312
+ ): void {
313
+ const items = this.getItems(field).slice();
314
+ items[index] = next;
315
+ this.commitData({ ...this.data, [field.name]: items });
316
+ }
317
+
318
+ private commitData(nextData: Properties): void {
319
+ this.data = applyComputedValues(
320
+ this.fieldDescriptions,
321
+ nextData,
322
+ this.rootData
323
+ );
72
324
  this.updateVisibleFields();
73
325
  this.onChange.emit(this.data);
326
+ this.validityChange.emit(
327
+ validateFields(this.fieldDescriptions, this.data, this.rootData)
328
+ );
329
+ this.cdr.markForCheck();
74
330
  }
75
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
+ );
@@ -1,38 +1,70 @@
1
1
  import { CommonModule } from '@angular/common';
2
- import { Component } from '@angular/core';
2
+ import { Component, Input, TemplateRef } from '@angular/core';
3
+ import { layoutRegistry } from './layoutRegistry';
3
4
 
4
5
  @Component({
5
6
  selector: 'dfk-column-layout',
6
7
  standalone: true,
7
8
  imports: [CommonModule],
8
9
  template: `<div
9
- style="display:flex;flex-direction:column;gap:var(--dfk-gap,12px)"
10
+ [style.display]="'flex'"
11
+ [style.flexDirection]="'column'"
12
+ [style.gap]="gap + 'px'"
10
13
  >
11
- <ng-content></ng-content>
14
+ <ng-container *ngTemplateOutlet="template"></ng-container>
12
15
  </div>`,
13
16
  })
14
- export class ColumnLayout {}
17
+ export class ColumnLayout {
18
+ @Input() config?: { gap?: number };
19
+ @Input() template!: TemplateRef<unknown>;
20
+ get gap() {
21
+ return this.config?.gap ?? 12;
22
+ }
23
+ }
15
24
 
16
25
  @Component({
17
26
  selector: 'dfk-row-layout',
18
27
  standalone: true,
19
28
  imports: [CommonModule],
20
29
  template: `<div
21
- style="display:flex;flex-direction:row;gap:var(--dfk-gap,12px)"
30
+ [style.display]="'flex'"
31
+ [style.flexDirection]="'row'"
32
+ [style.gap]="gap + 'px'"
22
33
  >
23
- <ng-content></ng-content>
34
+ <ng-container *ngTemplateOutlet="template"></ng-container>
24
35
  </div>`,
25
36
  })
26
- export class RowLayout {}
37
+ export class RowLayout {
38
+ @Input() config?: { gap?: number };
39
+ @Input() template!: TemplateRef<unknown>;
40
+ get gap() {
41
+ return this.config?.gap ?? 12;
42
+ }
43
+ }
27
44
 
28
45
  @Component({
29
46
  selector: 'dfk-grid-layout',
30
47
  standalone: true,
31
48
  imports: [CommonModule],
32
49
  template: `<div
33
- style="display:grid;grid-template-columns:repeat(var(--dfk-columns,2),1fr);gap:var(--dfk-gap,12px)"
50
+ [style.display]="'grid'"
51
+ [style.gridTemplateColumns]="'repeat(' + columns + ', 1fr)'"
52
+ [style.gap]="gap + 'px'"
34
53
  >
35
- <ng-content></ng-content>
54
+ <ng-container *ngTemplateOutlet="template"></ng-container>
36
55
  </div>`,
37
56
  })
38
- export class GridLayout {}
57
+ export class GridLayout {
58
+ @Input() config?: { columns?: number; gap?: number };
59
+ @Input() template!: TemplateRef<unknown>;
60
+ get columns() {
61
+ return this.config?.columns ?? 2;
62
+ }
63
+ get gap() {
64
+ return this.config?.gap ?? 12;
65
+ }
66
+ }
67
+
68
+ layoutRegistry.register('column', ColumnLayout);
69
+ layoutRegistry.register('row', RowLayout);
70
+ layoutRegistry.register('grid', GridLayout);
@@ -1 +1,2 @@
1
1
  export * from './defaultLayouts';
2
+ export { layoutRegistry, LayoutRegistry } from './layoutRegistry';
@@ -0,0 +1,25 @@
1
+ import { Type } from '@angular/core';
2
+
3
+ export type LayoutRenderer<C = unknown> = (props: {
4
+ children: unknown[];
5
+ config?: C;
6
+ }) => unknown;
7
+
8
+ export type AngularLayoutComponent = Type<unknown>;
9
+
10
+ export class LayoutRegistry {
11
+ private layouts = new Map<string, AngularLayoutComponent>();
12
+
13
+ register(type: string, component: AngularLayoutComponent): void {
14
+ if (this.layouts.has(type)) {
15
+ console.warn(`[dynamic-field-kit] Layout "${type}" already exists`);
16
+ }
17
+ this.layouts.set(type, component);
18
+ }
19
+
20
+ get(type: string): AngularLayoutComponent | undefined {
21
+ return this.layouts.get(type);
22
+ }
23
+ }
24
+
25
+ export const layoutRegistry = new LayoutRegistry();
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
- FieldTypeKey,
19
- FieldDescription,
20
- FieldRendererProps,
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 +1,14 @@
1
- export type LayoutConfig = 'column' | 'row' | 'grid';
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;