@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
@@ -1,44 +1,146 @@
1
+ import { ChangeDetectorRef } from '@angular/core';
2
+ import { TestBed } from '@angular/core/testing';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { BaseInputComponent } from '../src/components/BaseInput';
5
+ import { FieldInput } from '../src/components/FieldInput';
6
+ import { FIELD_REGISTRY } from '../src/fieldRegistryToken';
7
+ import {
8
+ DefaultsRendererComponent,
9
+ makeRegistry,
10
+ TextRendererComponent,
11
+ } from './helpers/renderers';
12
+
1
13
  describe('FieldInput', () => {
2
- it('should create FieldInput', () => {
3
- expect(true).toBe(true);
14
+ let registry: ReturnType<typeof makeRegistry>;
15
+
16
+ beforeEach(() => {
17
+ registry = makeRegistry();
18
+ registry.register('text', TextRendererComponent as never);
19
+ TestBed.configureTestingModule({
20
+ imports: [FieldInput],
21
+ providers: [{ provide: FIELD_REGISTRY, useValue: registry }],
22
+ });
23
+ });
24
+
25
+ it('renders nothing once fieldDescription is cleared', () => {
26
+ // Mounting with no inputs at all passes on the `shouldRender = false`
27
+ // field initializer without ever exercising ngOnChanges's guard. Mount
28
+ // WITH a fieldDescription first, then clear it, so the assertion
29
+ // actually depends on `shouldRender = !!this.fieldDescription`.
30
+ const fixture = TestBed.createComponent(FieldInput);
31
+ fixture.componentRef.setInput('fieldDescription', {
32
+ name: 'first',
33
+ type: 'text',
34
+ });
35
+ fixture.detectChanges();
36
+ expect(fixture.nativeElement.querySelector('input.txt')).not.toBeNull();
37
+
38
+ fixture.componentRef.setInput('fieldDescription', undefined);
39
+ fixture.detectChanges();
40
+
41
+ expect(fixture.nativeElement.querySelector('input.txt')).toBeNull();
4
42
  });
5
43
 
6
- it('should accept fieldDescription prop', () => {
7
- const fieldDesc: any = {
8
- name: 'username',
44
+ it('renders the field described by fieldDescription', () => {
45
+ const fixture = TestBed.createComponent(FieldInput);
46
+ fixture.componentRef.setInput('fieldDescription', {
47
+ name: 'first',
9
48
  type: 'text',
10
- label: 'Username',
11
- placeholder: 'Enter name',
12
- };
13
- expect(fieldDesc.name).toBe('username');
14
- expect(fieldDesc.type).toBe('text');
15
- expect(fieldDesc.label).toBe('Username');
49
+ placeholder: 'First name',
50
+ });
51
+ fixture.componentRef.setInput('value', 'Ada');
52
+ fixture.detectChanges();
53
+
54
+ const input: HTMLInputElement =
55
+ fixture.nativeElement.querySelector('input.txt');
56
+ expect(input.value).toBe('Ada');
57
+ expect(input.placeholder).toBe('First name');
16
58
  });
17
59
 
18
- it('should pass options from fieldDescription', () => {
19
- const fieldDesc: any = {
20
- name: 'country',
60
+ it('emits onValueChangeField with the field name as key', () => {
61
+ const fixture = TestBed.createComponent(FieldInput);
62
+ fixture.componentRef.setInput('fieldDescription', {
63
+ name: 'first',
21
64
  type: 'text',
22
- options: [{ label: 'USA' }, { label: 'VN' }],
23
- };
24
- expect(fieldDesc.options?.length).toBe(2);
65
+ });
66
+ fixture.detectChanges();
67
+
68
+ const seen: unknown[] = [];
69
+ fixture.componentInstance.onValueChangeField.subscribe((e) => seen.push(e));
70
+
71
+ const input: HTMLInputElement =
72
+ fixture.nativeElement.querySelector('input.txt');
73
+ input.value = 'Grace';
74
+ input.dispatchEvent(new Event('input'));
75
+
76
+ expect(seen).toEqual([{ value: 'Grace', key: 'first' }]);
25
77
  });
26
78
 
27
- it('should pass className from fieldDescription', () => {
28
- const fieldDesc: any = {
29
- name: 'test',
79
+ it('forwards error, disabled and readOnly to the renderer', () => {
80
+ const fixture = TestBed.createComponent(FieldInput);
81
+ fixture.componentRef.setInput('fieldDescription', {
82
+ name: 'email',
30
83
  type: 'text',
31
- className: 'my-custom-class',
32
- };
33
- expect(fieldDesc.className).toBe('my-custom-class');
84
+ });
85
+ fixture.componentRef.setInput('error', ['Required', 'Invalid']);
86
+ fixture.componentRef.setInput('disabled', true);
87
+ fixture.componentRef.setInput('readOnly', true);
88
+ fixture.detectChanges();
89
+
90
+ const input: HTMLInputElement =
91
+ fixture.nativeElement.querySelector('input.txt');
92
+ expect(input.disabled).toBe(true);
93
+ expect(input.readOnly).toBe(true);
94
+ expect(fixture.nativeElement.querySelector('.err').textContent).toBe(
95
+ 'Required, Invalid'
96
+ );
34
97
  });
35
98
 
36
- it('should pass description from fieldDescription', () => {
37
- const fieldDesc: any = {
38
- name: 'test',
99
+ it('forwards FieldDescription.props as extraProps', () => {
100
+ const fixture = TestBed.createComponent(FieldInput);
101
+ fixture.componentRef.setInput('fieldDescription', {
102
+ name: 'first',
39
103
  type: 'text',
40
- description: 'This is a help text',
41
- };
42
- expect(fieldDesc.description).toBe('This is a help text');
104
+ props: { hint: 'keep it short' },
105
+ });
106
+ fixture.detectChanges();
107
+
108
+ expect(fixture.nativeElement.querySelector('.hint').textContent).toBe(
109
+ 'keep it short'
110
+ );
111
+ });
112
+
113
+ it('documents current behaviour: a renderer default IS overwritten on the real FieldInput path', () => {
114
+ // Unlike mounting DynamicInput directly, FieldInput's template binds
115
+ // every KNOWN_PROP unconditionally, so DynamicInput sees a firstChange
116
+ // SimpleChange for `label` even though fieldDescription never set one.
117
+ // The `supplied` gate in DynamicInput.applyProps therefore does NOT
118
+ // protect the renderer's own default here - this pins that fact rather
119
+ // than asserting a guarantee that doesn't hold on this path.
120
+ registry.register('defaults', DefaultsRendererComponent as never);
121
+
122
+ const fixture = TestBed.createComponent(FieldInput);
123
+ fixture.componentRef.setInput('fieldDescription', {
124
+ name: 'first',
125
+ type: 'defaults',
126
+ // label intentionally omitted.
127
+ });
128
+ fixture.detectChanges();
129
+
130
+ const label: HTMLElement = fixture.nativeElement.querySelector('.label');
131
+ expect(label.textContent).toBe('');
132
+ });
133
+ });
134
+
135
+ describe('BaseInputComponent', () => {
136
+ class TestInput extends BaseInputComponent {}
137
+
138
+ it('marks for check on input changes', () => {
139
+ const cdr = { markForCheck: vi.fn() } as unknown as ChangeDetectorRef;
140
+ const input = new TestInput(cdr);
141
+
142
+ input.ngOnChanges({});
143
+
144
+ expect(cdr.markForCheck).toHaveBeenCalledTimes(1);
43
145
  });
44
146
  });
@@ -0,0 +1,256 @@
1
+ import { TestBed } from '@angular/core/testing';
2
+ import type {
3
+ FieldDescription,
4
+ ValidationResult,
5
+ } from '@dynamic-field-kit/core';
6
+ import { beforeEach, describe, expect, it } from 'vitest';
7
+ import { MultiFieldInput } from '../src/components/MultiFieldInput';
8
+ import { FIELD_REGISTRY } from '../src/fieldRegistryToken';
9
+ import { makeRegistry, TextRendererComponent } from './helpers/renderers';
10
+
11
+ describe('MultiFieldInput', () => {
12
+ let registry: ReturnType<typeof makeRegistry>;
13
+
14
+ beforeEach(() => {
15
+ registry = makeRegistry();
16
+ registry.register('text', TextRendererComponent as never);
17
+ TestBed.configureTestingModule({
18
+ imports: [MultiFieldInput],
19
+ providers: [{ provide: FIELD_REGISTRY, useValue: registry }],
20
+ });
21
+ });
22
+
23
+ function mount(
24
+ fields: FieldDescription[],
25
+ properties: Record<string, unknown>
26
+ ) {
27
+ const fixture = TestBed.createComponent(MultiFieldInput);
28
+ fixture.componentRef.setInput('fieldDescriptions', fields);
29
+ fixture.componentRef.setInput('properties', properties);
30
+ fixture.detectChanges();
31
+ return fixture;
32
+ }
33
+
34
+ it('renders one input per field', () => {
35
+ const fixture = mount(
36
+ [
37
+ { name: 'first', type: 'text' },
38
+ { name: 'last', type: 'text' },
39
+ ],
40
+ { first: 'Ada', last: 'Lovelace' }
41
+ );
42
+
43
+ const inputs: HTMLInputElement[] = Array.from(
44
+ fixture.nativeElement.querySelectorAll('input.txt')
45
+ );
46
+ expect(inputs.map((i) => i.value)).toEqual(['Ada', 'Lovelace']);
47
+ });
48
+
49
+ it('hides fields whose appearCondition is false', () => {
50
+ const fields: FieldDescription[] = [
51
+ { name: 'kind', type: 'text' },
52
+ {
53
+ name: 'company',
54
+ type: 'text',
55
+ appearCondition: (data) => data['kind'] === 'business',
56
+ },
57
+ ];
58
+
59
+ expect(
60
+ mount(fields, { kind: 'personal' }).nativeElement.querySelectorAll(
61
+ 'input.txt'
62
+ ).length
63
+ ).toBe(1);
64
+ expect(
65
+ mount(fields, { kind: 'business' }).nativeElement.querySelectorAll(
66
+ 'input.txt'
67
+ ).length
68
+ ).toBe(2);
69
+ });
70
+
71
+ it('applies computeValue to the data it emits', () => {
72
+ const fields: FieldDescription[] = [
73
+ { name: 'price', type: 'text' },
74
+ {
75
+ name: 'total',
76
+ type: 'text',
77
+ computeValue: (data) => `${Number(data['price']) * 2}`,
78
+ },
79
+ ];
80
+
81
+ const fixture = mount(fields, { price: '5' });
82
+
83
+ const inputs: HTMLInputElement[] = Array.from(
84
+ fixture.nativeElement.querySelectorAll('input.txt')
85
+ );
86
+ expect(inputs[1].value).toBe('10');
87
+ });
88
+
89
+ it('emits onChange with the updated data when a field changes', () => {
90
+ const fixture = mount([{ name: 'first', type: 'text' }], { first: 'Ada' });
91
+
92
+ const seen: unknown[] = [];
93
+ fixture.componentInstance.onChange.subscribe((d) => seen.push(d));
94
+
95
+ const input: HTMLInputElement =
96
+ fixture.nativeElement.querySelector('input.txt');
97
+ input.value = 'Grace';
98
+ input.dispatchEvent(new Event('input'));
99
+
100
+ expect(seen).toEqual([{ first: 'Grace' }]);
101
+ });
102
+
103
+ it('passes validation errors down to the renderer', () => {
104
+ const fixture = mount(
105
+ [
106
+ {
107
+ name: 'email',
108
+ type: 'text',
109
+ validate: (value) =>
110
+ String(value).includes('@') ? undefined : 'Invalid email',
111
+ },
112
+ ],
113
+ { email: 'nope' }
114
+ );
115
+
116
+ expect(fixture.nativeElement.querySelector('.err').textContent).toBe(
117
+ 'Invalid email'
118
+ );
119
+ });
120
+
121
+ it('resolves disabledCondition and readOnlyCondition', () => {
122
+ const fixture = mount(
123
+ [
124
+ {
125
+ name: 'a',
126
+ type: 'text',
127
+ disabledCondition: (data) => data['frozen'] === true,
128
+ },
129
+ {
130
+ name: 'b',
131
+ type: 'text',
132
+ readOnlyCondition: (data) => data['frozen'] === true,
133
+ },
134
+ ],
135
+ { frozen: true }
136
+ );
137
+
138
+ const inputs: HTMLInputElement[] = Array.from(
139
+ fixture.nativeElement.querySelectorAll('input.txt')
140
+ );
141
+ expect(inputs[0].disabled).toBe(true);
142
+ expect(inputs[1].readOnly).toBe(true);
143
+ });
144
+
145
+ it('does not report errors for disabled fields', () => {
146
+ const fixture = mount(
147
+ [
148
+ {
149
+ name: 'email',
150
+ type: 'text',
151
+ disabled: true,
152
+ validate: () => 'Invalid email',
153
+ },
154
+ ],
155
+ { email: 'nope' }
156
+ );
157
+
158
+ expect(fixture.nativeElement.querySelector('.err')).toBeNull();
159
+ });
160
+
161
+ it('emits validityChange on init and on every change', () => {
162
+ const seen: ValidationResult[] = [];
163
+ const fixture = TestBed.createComponent(MultiFieldInput);
164
+ fixture.componentInstance.validityChange.subscribe((r) => seen.push(r));
165
+ fixture.componentRef.setInput('fieldDescriptions', [
166
+ {
167
+ name: 'email',
168
+ type: 'text',
169
+ validate: (value: unknown) =>
170
+ String(value).includes('@') ? undefined : 'Invalid email',
171
+ },
172
+ ]);
173
+ fixture.componentRef.setInput('properties', { email: 'nope' });
174
+ fixture.detectChanges();
175
+
176
+ expect(seen[seen.length - 1]).toEqual({
177
+ valid: false,
178
+ errors: { email: ['Invalid email'] },
179
+ });
180
+
181
+ const input: HTMLInputElement =
182
+ fixture.nativeElement.querySelector('input.txt');
183
+ input.value = 'ada@example.com';
184
+ input.dispatchEvent(new Event('input'));
185
+
186
+ expect(seen[seen.length - 1]).toEqual({ valid: true, errors: {} });
187
+ });
188
+
189
+ it('renders a repeatable group item per entry and adds one on Add', () => {
190
+ const fields: FieldDescription[] = [
191
+ {
192
+ name: 'contacts',
193
+ type: 'text',
194
+ label: 'Contacts',
195
+ fields: [{ name: 'email', type: 'text' }],
196
+ },
197
+ ];
198
+
199
+ const fixture = mount(fields, {
200
+ contacts: [{ email: 'a@x.com' }, { email: 'b@x.com' }],
201
+ });
202
+
203
+ expect(fixture.nativeElement.querySelectorAll('input.txt').length).toBe(2);
204
+
205
+ const seen: unknown[] = [];
206
+ fixture.componentInstance.onChange.subscribe((d) => seen.push(d));
207
+
208
+ const addBtn: HTMLButtonElement = Array.from<HTMLButtonElement>(
209
+ fixture.nativeElement.querySelectorAll('button')
210
+ ).find((b) => b.textContent?.trim() === 'Add')!;
211
+ addBtn.click();
212
+ fixture.detectChanges();
213
+
214
+ expect((seen[0] as Record<string, unknown[]>)['contacts'].length).toBe(3);
215
+ });
216
+
217
+ it('removes a group item on Remove', () => {
218
+ const fields: FieldDescription[] = [
219
+ {
220
+ name: 'contacts',
221
+ type: 'text',
222
+ fields: [{ name: 'email', type: 'text' }],
223
+ },
224
+ ];
225
+
226
+ const fixture = mount(fields, {
227
+ contacts: [{ email: 'a@x.com' }, { email: 'b@x.com' }],
228
+ });
229
+
230
+ const seen: unknown[] = [];
231
+ fixture.componentInstance.onChange.subscribe((d) => seen.push(d));
232
+
233
+ const removeBtn: HTMLButtonElement = Array.from<HTMLButtonElement>(
234
+ fixture.nativeElement.querySelectorAll('button')
235
+ ).find((b) => b.textContent?.trim() === 'Remove')!;
236
+ removeBtn.click();
237
+ fixture.detectChanges();
238
+
239
+ expect((seen[0] as Record<string, unknown[]>)['contacts']).toEqual([
240
+ { email: 'b@x.com' },
241
+ ]);
242
+ });
243
+
244
+ it('applies the grid layout', () => {
245
+ const fixture = TestBed.createComponent(MultiFieldInput);
246
+ fixture.componentRef.setInput('fieldDescriptions', [
247
+ { name: 'a', type: 'text' },
248
+ ]);
249
+ fixture.componentRef.setInput('properties', { a: '1' });
250
+ fixture.componentRef.setInput('layout', { type: 'grid', columns: 3 });
251
+ fixture.detectChanges();
252
+
253
+ const container: HTMLElement = fixture.nativeElement.firstElementChild;
254
+ expect(container.style.gridTemplateColumns).toBe('repeat(3, 1fr)');
255
+ });
256
+ });
@@ -0,0 +1,89 @@
1
+ import { NgIf } from '@angular/common';
2
+ import { Component, EventEmitter, Input, Output } from '@angular/core';
3
+ import { FieldRegistry } from '@dynamic-field-kit/core';
4
+
5
+ // The field types the specs register. Without this augmentation `keyof
6
+ // FieldTypeMap` is `never`, so `registry.register('text', …)` fails to compile
7
+ // once vitest type-checking is enabled. Every spec imports this helper, so the
8
+ // augmentation applies across the whole test compilation.
9
+ declare module '@dynamic-field-kit/core' {
10
+ interface FieldTypeMap {
11
+ text: string;
12
+ number: number;
13
+ defaults: string;
14
+ }
15
+ }
16
+
17
+ @Component({
18
+ selector: 'dfk-test-text',
19
+ standalone: true,
20
+ imports: [NgIf],
21
+ template: `
22
+ <input
23
+ class="txt"
24
+ [value]="value ?? ''"
25
+ [disabled]="!!disabled"
26
+ [readOnly]="!!readOnly"
27
+ [placeholder]="placeholder ?? ''"
28
+ (input)="valueChange.emit($any($event.target).value)"
29
+ />
30
+ <span class="err" *ngIf="error">{{ errorText }}</span>
31
+ <span class="hint" *ngIf="hint">{{ hint }}</span>
32
+ `,
33
+ })
34
+ export class TextRendererComponent {
35
+ @Input() value?: unknown;
36
+ @Input() label?: string;
37
+ @Input() placeholder?: string;
38
+ @Input() required?: boolean;
39
+ @Input() disabled?: boolean;
40
+ @Input() readOnly?: boolean;
41
+ @Input() error?: string | string[];
42
+ @Input() options?: unknown[];
43
+ @Input() className?: string;
44
+ @Input() description?: string;
45
+ // Not a FieldRendererProps key: proves extraProps reach the instance.
46
+ @Input() hint?: string;
47
+
48
+ @Output() valueChange = new EventEmitter<unknown>();
49
+
50
+ get errorText(): string {
51
+ return ([] as string[]).concat(this.error ?? []).join(', ');
52
+ }
53
+ }
54
+
55
+ @Component({
56
+ selector: 'dfk-test-legacy',
57
+ standalone: true,
58
+ template: `<button class="legacy-btn" (click)="onValueChange.emit('legacy')">
59
+ go
60
+ </button>`,
61
+ })
62
+ export class LegacyOutputRendererComponent {
63
+ @Input() value?: unknown;
64
+ // Deliberately the legacy output name, to cover DynamicInput.bindOutputs.
65
+ @Output() onValueChange = new EventEmitter<unknown>();
66
+ }
67
+
68
+ @Component({
69
+ selector: 'dfk-test-defaults',
70
+ standalone: true,
71
+ template: `<span class="label">{{ label }}</span>`,
72
+ })
73
+ export class DefaultsRendererComponent {
74
+ // Initialized input: proves DynamicInput does not overwrite a renderer's
75
+ // own default with undefined for a prop it was never given.
76
+ @Input() label = 'None';
77
+ @Input() value?: unknown;
78
+ @Output() valueChange = new EventEmitter<unknown>();
79
+ }
80
+
81
+ export function fallbackRenderer(props: Record<string, unknown>): string {
82
+ return `<span class="fallback">${String(props['label'] ?? '')}:${String(
83
+ props['value'] ?? ''
84
+ )}</span>`;
85
+ }
86
+
87
+ export function makeRegistry(): FieldRegistry {
88
+ return new FieldRegistry();
89
+ }
@@ -0,0 +1,119 @@
1
+ import { Component, TemplateRef, ViewChild } from '@angular/core';
2
+ import { TestBed } from '@angular/core/testing';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import {
5
+ ColumnLayout,
6
+ GridLayout,
7
+ RowLayout,
8
+ } from '../src/layout/defaultLayouts';
9
+ import { LayoutRegistry, layoutRegistry } from '../src/layout/layoutRegistry';
10
+
11
+ @Component({
12
+ standalone: true,
13
+ imports: [ColumnLayout, RowLayout, GridLayout],
14
+ template: `
15
+ <ng-template #tpl><span class="child">x</span></ng-template>
16
+ <dfk-column-layout
17
+ [template]="tpl"
18
+ [config]="{ gap: 20 }"
19
+ ></dfk-column-layout>
20
+ <dfk-row-layout [template]="tpl"></dfk-row-layout>
21
+ <dfk-grid-layout
22
+ [template]="tpl"
23
+ [config]="{ columns: 3 }"
24
+ ></dfk-grid-layout>
25
+ `,
26
+ })
27
+ class LayoutHost {
28
+ @ViewChild('tpl', { static: true }) tpl!: TemplateRef<unknown>;
29
+ }
30
+
31
+ @Component({
32
+ standalone: true,
33
+ imports: [GridLayout],
34
+ template: `
35
+ <ng-template #tpl><span class="child">x</span></ng-template>
36
+ <dfk-grid-layout [template]="tpl"></dfk-grid-layout>
37
+ `,
38
+ })
39
+ class GridDefaultsHost {
40
+ @ViewChild('tpl', { static: true }) tpl!: TemplateRef<unknown>;
41
+ }
42
+
43
+ describe('LayoutRegistry', () => {
44
+ afterEach(() => {
45
+ vi.restoreAllMocks();
46
+ });
47
+
48
+ it('registers and retrieves a layout', () => {
49
+ const registry = new LayoutRegistry();
50
+ registry.register('custom', ColumnLayout);
51
+
52
+ expect(registry.get('custom')).toBe(ColumnLayout);
53
+ });
54
+
55
+ it('returns undefined for an unknown layout', () => {
56
+ expect(new LayoutRegistry().get('nope')).toBeUndefined();
57
+ });
58
+
59
+ it('warns when a layout type is registered twice', () => {
60
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
61
+ const registry = new LayoutRegistry();
62
+ registry.register('custom', ColumnLayout);
63
+ registry.register('custom', RowLayout);
64
+
65
+ expect(warn).toHaveBeenCalledTimes(1);
66
+ expect(registry.get('custom')).toBe(RowLayout);
67
+ });
68
+
69
+ it('exports a shared registry instance', () => {
70
+ expect(layoutRegistry).toBeInstanceOf(LayoutRegistry);
71
+ });
72
+ });
73
+
74
+ describe('default layouts', () => {
75
+ it('renders the projected template with the configured gap', () => {
76
+ const fixture = TestBed.createComponent(LayoutHost);
77
+ fixture.detectChanges();
78
+
79
+ const column: HTMLElement = fixture.nativeElement.querySelector(
80
+ 'dfk-column-layout > div'
81
+ );
82
+ expect(column.style.flexDirection).toBe('column');
83
+ expect(column.style.gap).toBe('20px');
84
+ expect(column.querySelector('.child')).not.toBeNull();
85
+ });
86
+
87
+ it('defaults the gap to 12px', () => {
88
+ const fixture = TestBed.createComponent(LayoutHost);
89
+ fixture.detectChanges();
90
+
91
+ const row: HTMLElement = fixture.nativeElement.querySelector(
92
+ 'dfk-row-layout > div'
93
+ );
94
+ expect(row.style.flexDirection).toBe('row');
95
+ expect(row.style.gap).toBe('12px');
96
+ });
97
+
98
+ it('renders the grid layout with the configured column count', () => {
99
+ const fixture = TestBed.createComponent(LayoutHost);
100
+ fixture.detectChanges();
101
+
102
+ const grid: HTMLElement = fixture.nativeElement.querySelector(
103
+ 'dfk-grid-layout > div'
104
+ );
105
+ expect(grid.style.display).toBe('grid');
106
+ expect(grid.style.gridTemplateColumns).toBe('repeat(3, 1fr)');
107
+ expect(grid.style.gap).toBe('12px');
108
+ });
109
+
110
+ it('defaults the grid columns to 2', () => {
111
+ const fixture = TestBed.createComponent(GridDefaultsHost);
112
+ fixture.detectChanges();
113
+
114
+ const grid: HTMLElement = fixture.nativeElement.querySelector(
115
+ 'dfk-grid-layout > div'
116
+ );
117
+ expect(grid.style.gridTemplateColumns).toBe('repeat(2, 1fr)');
118
+ });
119
+ });