Skip to content

Commit 6fef7ab

Browse files
committed
wip
1 parent 5a40168 commit 6fef7ab

File tree

3 files changed

+220
-7
lines changed

3 files changed

+220
-7
lines changed

packages/forms/experimental/src/controls/control.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,18 @@ import {
1111
DestroyRef,
1212
Directive,
1313
effect,
14+
EffectRef,
1415
ElementRef,
1516
EventEmitter,
1617
inject,
1718
Injector,
19+
Input,
1820
input,
1921
InputSignal,
2022
OutputEmitterRef,
2123
OutputRef,
2224
OutputRefSubscription,
25+
signal,
2326
untracked,
2427
} from '@angular/core';
2528
import {ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl} from '@angular/forms';
@@ -29,6 +32,7 @@ import {
2932
illegallyGetComponentInstance,
3033
illegallyIsModelInput,
3134
illegallyIsSignalInput,
35+
illegallyRunEffect,
3236
illegallySetComponentInput as illegallySetInputSignal,
3337
} from '../illegal';
3438
import {InteropNgControl} from './interop_ng_control';
@@ -45,7 +49,19 @@ import {AggregateProperty, MAX, MAX_LENGTH, MIN, MIN_LENGTH} from '../api/proper
4549
})
4650
export class Control<T> {
4751
readonly injector = inject(Injector);
48-
readonly field = input.required<Field<T>>({alias: 'control'});
52+
readonly field = signal<Field<T>>(undefined as any);
53+
54+
private initialized = false;
55+
56+
@Input({required: true, alias: 'control'})
57+
set _field(value: Field<T>) {
58+
this.field.set(value);
59+
if (!this.initialized) {
60+
this.initialize();
61+
}
62+
}
63+
64+
// readonly field = input.required<Field<T>>({alias: 'control'});
4965
readonly state = computed(() => this.field()());
5066
readonly el: ElementRef<HTMLElement> = inject(ElementRef);
5167
readonly cvaArray = inject<ControlValueAccessor[]>(NG_VALUE_ACCESSOR, {optional: true});
@@ -60,7 +76,8 @@ export class Control<T> {
6076
return this.cvaArray?.[0] ?? this._ngControl?.valueAccessor ?? undefined;
6177
}
6278

63-
ngOnInit() {
79+
private initialize() {
80+
this.initialized = true;
6481
const injector = this.injector;
6582
const cmp = illegallyGetComponentInstance(injector);
6683

@@ -124,14 +141,18 @@ export class Control<T> {
124141
this.maybeSynchronize(
125142
() => this.state().value(),
126143
(value) => {
127-
(input as HTMLInputElement).checked = value === value;
144+
// Although HTML behavior is to clear the input already, we do this just in case.
145+
// It seems like it might be necessary in certain environments (e.g. Domino).
146+
(input as HTMLInputElement).checked = input.value === value;
128147
},
129148
);
130149
break;
131150
default:
132151
this.maybeSynchronize(
133152
() => this.state().value(),
134-
(value) => (input.value = value as string),
153+
(value) => {
154+
input.value = value as string;
155+
},
135156
);
136157
break;
137158
}
@@ -218,15 +239,16 @@ export class Control<T> {
218239

219240
private maybeSynchronize<T>(source: () => T, sink: ((value: T) => void) | undefined): void {
220241
if (!sink) {
221-
return;
242+
return undefined;
222243
}
223-
effect(
244+
const ref = effect(
224245
() => {
225246
const value = source();
226247
untracked(() => sink(value));
227248
},
228249
{injector: this.injector},
229250
);
251+
illegallyRunEffect(ref);
230252
}
231253

232254
private propertySource<T>(key: AggregateProperty<T, any>): () => T | undefined {
@@ -270,6 +292,7 @@ function isUiControl<T>(cmp: unknown): cmp is FormUiControl<T> {
270292
illegallyIsModelInput(castCmp.value) ||
271293
illegallyIsModelInput((castCmp as FormCheckControl<unknown>).checked);
272294
return (
295+
hasModel &&
273296
(!castCmp.readonly || illegallyIsSignalInput(castCmp.readonly)) &&
274297
(!castCmp.disabled || illegallyIsSignalInput(castCmp.disabled)) &&
275298
(!castCmp.errors || illegallyIsSignalInput(castCmp.errors)) &&

packages/forms/experimental/src/illegal.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Injector, InputSignal, ModelSignal, ɵSIGNAL as SIGNAL} from '@angular/core';
9+
import {EffectRef, Injector, InputSignal, ModelSignal, ɵSIGNAL as SIGNAL} from '@angular/core';
1010

1111
export function illegallyGetComponentInstance(injector: Injector): unknown {
1212
assertIsNodeInjector(injector);
@@ -29,6 +29,10 @@ export function illegallyIsModelInput<T>(value: unknown): value is ModelSignal<T
2929
return isInputSignal(value) && isObject(value) && 'subscribe' in value;
3030
}
3131

32+
export function illegallyRunEffect(ref: EffectRef): void {
33+
(ref as EffectRefImpl)[SIGNAL].run();
34+
}
35+
3236
function assertIsNodeInjector(injector: Injector): asserts injector is NgNodeInjector {
3337
if (!('_tNode' in injector)) {
3438
throw new Error('Expected a Node Injector');
@@ -70,3 +74,9 @@ interface NgInputSignal {
7074
interface NgInputSignalNode {
7175
applyValueToInputSignal(node: NgInputSignalNode, value: unknown): void;
7276
}
77+
78+
interface EffectRefImpl extends EffectRef {
79+
readonly [SIGNAL]: {
80+
run(): void;
81+
};
82+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import {Component, model, provideZonelessChangeDetection, signal} from '@angular/core';
2+
import {TestBed} from '@angular/core/testing';
3+
import {form} from '../src/api/structure';
4+
import {Control} from '../src/controls/control';
5+
import {FormValueControl} from '../public_api';
6+
7+
fdescribe('control directive', () => {
8+
beforeEach(() => {
9+
TestBed.configureTestingModule({
10+
providers: [provideZonelessChangeDetection()],
11+
});
12+
});
13+
14+
it('synchronizes a basic form with a custom control', () => {
15+
@Component({
16+
imports: [Control],
17+
template: `
18+
<input [control]="f">
19+
`,
20+
})
21+
class TestCmp {
22+
f = form<string>(signal('test'));
23+
}
24+
25+
const fix = act(() => TestBed.createComponent(TestCmp));
26+
const input = fix.nativeElement.firstChild as HTMLInputElement;
27+
const cmp = fix.componentInstance as TestCmp;
28+
29+
// Initial state
30+
expect(input.value).toBe('test');
31+
32+
// Model -> View
33+
act(() => cmp.f().value.set('testing'));
34+
expect(input.value).toBe('testing');
35+
36+
// View -> Model
37+
act(() => {
38+
input.value = 'typing';
39+
input.dispatchEvent(new Event('input'));
40+
});
41+
expect(cmp.f().value()).toBe('typing');
42+
});
43+
44+
it('synchronizes with a checkbox control', () => {
45+
@Component({
46+
imports: [Control],
47+
template: `<input type="checkbox" [control]="f">`,
48+
})
49+
class TestCmp {
50+
f = form(signal(false));
51+
}
52+
53+
const fix = act(() => TestBed.createComponent(TestCmp));
54+
const input = fix.nativeElement.firstChild as HTMLInputElement;
55+
const cmp = fix.componentInstance as TestCmp;
56+
57+
// Initial state
58+
expect(input.checked).toBe(false);
59+
60+
// Model -> View
61+
act(() => cmp.f().value.set(true));
62+
expect(input.checked).toBe(true);
63+
64+
// View -> Model
65+
act(() => {
66+
input.checked = false;
67+
input.dispatchEvent(new Event('input'));
68+
});
69+
expect(cmp.f().value()).toBe(false);
70+
});
71+
72+
it('synchronizes with a radio group', () => {
73+
const {cmp, expectStates, inputA, inputB, inputC} = setupRadioGroup();
74+
75+
// All the inputs should have the same name.
76+
expect(inputA.name).toBe('test');
77+
expect(inputB.name).toBe('test');
78+
expect(inputC.name).toBe('test');
79+
80+
// Model -> View
81+
act(() => cmp.f().value.set('c'));
82+
expect(inputA.checked).toBeFalse();
83+
expect(inputB.checked).toBeFalse();
84+
expect(inputC.checked).toBeTrue();
85+
86+
// View -> Model
87+
act(() => {
88+
inputB.click();
89+
expect(inputB.checked).toBeTrue();
90+
});
91+
expect(cmp.f().value()).toBe('c');
92+
});
93+
94+
fit('synchronizes with a custom control', () => {
95+
@Component({
96+
selector: 'my-input',
97+
template: '',
98+
})
99+
class CustomInput implements FormValueControl<string> {
100+
value = model.required<string>();
101+
102+
ngOnInit(): void {
103+
// console.log('CustomInput.ngOnInit');
104+
this.value();
105+
}
106+
}
107+
108+
@Component({
109+
imports: [Control, CustomInput],
110+
template: `<my-input [control]="f" />`,
111+
})
112+
class TestCmp {
113+
f = form<string>(signal('test'));
114+
}
115+
116+
const fix = act(() => TestBed.createComponent(TestCmp));
117+
const input = fix.nativeElement.firstChild as HTMLInputElement;
118+
const cmp = fix.componentInstance as TestCmp;
119+
120+
// Initial state
121+
expect(input.value).toBe('test');
122+
123+
// Model -> View
124+
act(() => cmp.f().value.set('testing'));
125+
expect(input.value).toBe('testing');
126+
127+
// View -> Model
128+
act(() => {
129+
input.value = 'typing';
130+
input.dispatchEvent(new Event('input'));
131+
});
132+
expect(cmp.f().value()).toBe('typing');
133+
});
134+
});
135+
136+
function setupRadioGroup() {
137+
@Component({
138+
imports: [Control],
139+
template: `
140+
<form>
141+
<input type="radio" value="a" [control]="f">
142+
<input type="radio" value="b" [control]="f">
143+
<input type="radio" value="c" [control]="f">
144+
</form>
145+
`,
146+
})
147+
class TestCmp {
148+
f = form(signal('a'), {
149+
name: 'test',
150+
});
151+
}
152+
153+
const fix = act(() => TestBed.createComponent(TestCmp));
154+
const formEl = (fix.nativeElement as HTMLElement).firstChild as HTMLFormElement;
155+
const inputs = Array.from(formEl.children) as HTMLInputElement[];
156+
157+
// A fix for Domino issues with <form> around <input>.
158+
for (const input of inputs) {
159+
Object.defineProperty(input, 'form', {get: () => formEl});
160+
}
161+
162+
const [inputA, inputB, inputC] = inputs;
163+
const cmp = fix.componentInstance as TestCmp;
164+
165+
function expectStates(a: boolean, b: boolean, c: boolean): void {
166+
expect(inputA.checked).toBe(a);
167+
expect(inputB.checked).toBe(b);
168+
expect(inputC.checked).toBe(c);
169+
}
170+
171+
return {cmp, expectStates, inputA, inputB, inputC};
172+
}
173+
174+
function act<T>(fn: () => T): T {
175+
try {
176+
return fn();
177+
} finally {
178+
TestBed.tick();
179+
}
180+
}

0 commit comments

Comments
 (0)