@@ -22,27 +22,27 @@ import {
2222 OutputRefSubscription ,
2323 untracked ,
2424} from '@angular/core' ;
25- import { ControlValueAccessor , NG_VALUE_ACCESSOR , NgControl } from '@angular/forms' ;
26- import { FormUiControl } from '../api/control' ;
27- import { Field } from '../api/types' ;
25+ import { ControlValueAccessor , NG_VALUE_ACCESSOR , NgControl } from '@angular/forms' ;
26+ import { FormUiControl } from '../api/control' ;
27+ import { Field } from '../api/types' ;
2828import {
2929 illegallyGetComponentInstance ,
3030 illegallyIsModelInput ,
3131 illegallyIsSignalInput ,
3232 illegallySetComponentInput as illegallySetInputSignal ,
3333} from '../illegal' ;
34- import { InteropNgControl } from './interop_ng_control' ;
34+ import { InteropNgControl } from './interop_ng_control' ;
3535
3636@Directive ( {
3737 selector : '[control]' ,
38- providers : [ { provide : NgControl , useFactory : ( ) => inject ( Control ) . ngControl } ] ,
38+ providers : [ { provide : NgControl , useFactory : ( ) => inject ( Control ) . ngControl } ] ,
3939} )
4040export class Control < T > {
4141 readonly injector = inject ( Injector ) ;
42- readonly field = input . required < Field < T > > ( { alias : 'control' } ) ;
42+ readonly field = input . required < Field < T > > ( { alias : 'control' } ) ;
4343 readonly state = computed ( ( ) => this . field ( ) ( ) ) ;
4444 readonly el : ElementRef < HTMLElement > = inject ( ElementRef ) ;
45- readonly cvaArray = inject < ControlValueAccessor [ ] > ( NG_VALUE_ACCESSOR , { optional : true } ) ;
45+ readonly cvaArray = inject < ControlValueAccessor [ ] > ( NG_VALUE_ACCESSOR , { optional : true } ) ;
4646
4747 private _ngControl : InteropNgControl | undefined ;
4848
@@ -57,86 +57,100 @@ export class Control<T> {
5757 ngOnInit ( ) {
5858 const injector = this . injector ;
5959 const cmp = illegallyGetComponentInstance ( injector ) ;
60- if ( this . el . nativeElement instanceof HTMLInputElement ) {
61- // Bind our field to an <input>
62- const i = this . el . nativeElement ;
63- const isCheckbox = i . type === 'checkbox' ;
64-
65- i . addEventListener ( 'input' , ( ) => {
66- this . state ( ) . value . set ( ( ! isCheckbox ? i . value : i . checked ) as T ) ;
67- this . state ( ) . markAsDirty ( ) ;
68- } ) ;
69- i . addEventListener ( 'blur' , ( ) => this . state ( ) . markAsTouched ( ) ) ;
70-
71- effect (
72- ( ) => {
73- if ( ! isCheckbox ) {
74- i . value = this . state ( ) . value ( ) as string ;
75- } else {
76- i . checked = this . state ( ) . value ( ) as boolean ;
77- }
78- } ,
79- { injector} ,
80- ) ;
60+
61+ if ( cmp && isUiControl < T > ( cmp ) ) {
62+ this . setupCustomUiControl ( cmp ) ;
63+ } else if ( this . el . nativeElement instanceof HTMLInputElement || this . el . nativeElement instanceof HTMLTextAreaElement ) {
64+ this . setupNativeInput ( this . el . nativeElement ) ;
8165 } else if ( this . cva !== undefined ) {
82- const cva = this . cva ;
83- // Binding to a Control Value Accessor
84-
85- cva . registerOnChange ( ( value : T ) => this . state ( ) . value . set ( value ) ) ;
86- cva . registerOnTouched ( ( ) => this . state ( ) . markAsTouched ( ) ) ;
87-
88- effect (
89- ( ) => {
90- const value = this . state ( ) . value ( ) ;
91- untracked ( ( ) => {
92- cva . writeValue ( value ) ;
93- } ) ;
94- } ,
95- { injector} ,
96- ) ;
97- } else if ( isUiControl < T > ( cmp ) ) {
98- // Binding to a custom UI component.
99-
100- // Input bindings:
101- maybeSynchronize ( injector , ( ) => this . state ( ) . value ( ) , cmp . value ) ;
102- maybeSynchronize ( injector , ( ) => this . state ( ) . disabled ( ) , cmp . disabled ) ;
103- maybeSynchronize ( injector , ( ) => this . state ( ) . readonly ( ) , cmp . readonly ) ;
104- maybeSynchronize ( injector , ( ) => this . state ( ) . errors ( ) , cmp . errors ) ;
105- maybeSynchronize ( injector , ( ) => this . state ( ) . touched ( ) , cmp . touched ) ;
106- maybeSynchronize ( injector , ( ) => this . state ( ) . valid ( ) , cmp . valid ) ;
107-
108- // Output bindings:
109- const cleanupValue = cmp . value . subscribe ( ( newValue ) => this . state ( ) . value . set ( newValue ) ) ;
110- let cleanupTouch : OutputRefSubscription | undefined ;
111- let cleanupDefaultTouch : ( ( ) => void ) | undefined ;
112- if ( cmp . touch !== undefined ) {
113- cleanupTouch = cmp . touch . subscribe ( ( ) => this . state ( ) . markAsTouched ( ) ) ;
114- } else {
115- // If the component did not give us a touch event stream, use the standard touch logic,
116- // marking it touched when the focus moves from inside the host element to outside.
117- const listener = ( event : FocusEvent ) => {
118- const newActiveEl = event . relatedTarget ;
119- if ( ! this . el . nativeElement . contains ( newActiveEl as Element | null ) ) {
120- this . state ( ) . markAsTouched ( ) ;
121- }
122- } ;
123- this . el . nativeElement . addEventListener ( 'focusout' , listener ) ;
124- cleanupDefaultTouch = ( ) => this . el . nativeElement . removeEventListener ( 'focusout' , listener ) ;
125- }
126-
127- // Cleanup for output binding subscriptions:
128- injector . get ( DestroyRef ) . onDestroy ( ( ) => {
129- cleanupValue . unsubscribe ( ) ;
130- cleanupTouch ?. unsubscribe ( ) ;
131- cleanupDefaultTouch ?.( ) ;
132- } ) ;
66+ this . setupControlValueAccessor ( this . cva ) ;
13367 } else {
13468 throw new Error ( `Unhandled control?` ) ;
13569 }
70+
13671 if ( this . cva ) {
13772 this . cva . writeValue ( this . state ( ) . value ( ) ) ;
13873 }
13974 }
75+
76+ // Bind our field to an <input> or <textarea>
77+ private setupNativeInput ( input : HTMLInputElement | HTMLTextAreaElement ) : void {
78+ const isCheckbox = input instanceof HTMLInputElement && input . type === 'checkbox' ;
79+
80+ input . addEventListener ( 'input' , ( ) => {
81+ this . state ( ) . value . set ( ( ! isCheckbox ? input . value : input . checked ) as T ) ;
82+ this . state ( ) . markAsDirty ( ) ;
83+ } ) ;
84+ input . addEventListener ( 'blur' , ( ) => this . state ( ) . markAsTouched ( ) ) ;
85+
86+ effect (
87+ ( ) => {
88+ if ( ! isCheckbox ) {
89+ input . value = this . state ( ) . value ( ) as string ;
90+ } else {
91+ input . checked = this . state ( ) . value ( ) as boolean ;
92+ }
93+ } ,
94+ { injector : this . injector } ,
95+ ) ;
96+ }
97+
98+
99+ // Binding to a Control Value Accessor
100+ private setupControlValueAccessor ( cva : ControlValueAccessor ) : void {
101+ cva . registerOnChange ( ( value : T ) => this . state ( ) . value . set ( value ) ) ;
102+ cva . registerOnTouched ( ( ) => this . state ( ) . markAsTouched ( ) ) ;
103+
104+ effect (
105+ ( ) => {
106+ const value = this . state ( ) . value ( ) ;
107+ untracked ( ( ) => {
108+ cva . writeValue ( value ) ;
109+ } ) ;
110+ } ,
111+ { injector : this . injector } ,
112+ ) ;
113+ }
114+
115+ // Binding to a custom UI component.
116+ private setupCustomUiControl (
117+ cmp : FormUiControl < T > ,
118+ ) {
119+
120+ // Input bindings:
121+ maybeSynchronize ( this . injector , ( ) => this . state ( ) . value ( ) , cmp . value ) ;
122+ maybeSynchronize ( this . injector , ( ) => this . state ( ) . disabled ( ) , cmp . disabled ) ;
123+ maybeSynchronize ( this . injector , ( ) => this . state ( ) . readonly ( ) , cmp . readonly ) ;
124+ maybeSynchronize ( this . injector , ( ) => this . state ( ) . errors ( ) , cmp . errors ) ;
125+ maybeSynchronize ( this . injector , ( ) => this . state ( ) . touched ( ) , cmp . touched ) ;
126+ maybeSynchronize ( this . injector , ( ) => this . state ( ) . valid ( ) , cmp . valid ) ;
127+
128+ // Output bindings:
129+ const cleanupValue = cmp . value . subscribe ( ( newValue ) => this . state ( ) . value . set ( newValue ) ) ;
130+ let cleanupTouch : OutputRefSubscription | undefined ;
131+ let cleanupDefaultTouch : ( ( ) => void ) | undefined ;
132+ if ( cmp . touch !== undefined ) {
133+ cleanupTouch = cmp . touch . subscribe ( ( ) => this . state ( ) . markAsTouched ( ) ) ;
134+ } else {
135+ // If the component did not give us a touch event stream, use the standard touch logic,
136+ // marking it touched when the focus moves from inside the host element to outside.
137+ const listener = ( event : FocusEvent ) => {
138+ const newActiveEl = event . relatedTarget ;
139+ if ( ! this . el . nativeElement . contains ( newActiveEl as Element | null ) ) {
140+ this . state ( ) . markAsTouched ( ) ;
141+ }
142+ } ;
143+ this . el . nativeElement . addEventListener ( 'focusout' , listener ) ;
144+ cleanupDefaultTouch = ( ) => this . el . nativeElement . removeEventListener ( 'focusout' , listener ) ;
145+ }
146+
147+ // Cleanup for output binding subscriptions:
148+ this . injector . get ( DestroyRef ) . onDestroy ( ( ) => {
149+ cleanupValue . unsubscribe ( ) ;
150+ cleanupTouch ?. unsubscribe ( ) ;
151+ cleanupDefaultTouch ?.( ) ;
152+ } ) ;
153+ }
140154}
141155
142156function isUiControl < T > ( cmp : unknown ) : cmp is FormUiControl < T > {
@@ -163,5 +177,5 @@ function maybeSynchronize<T>(
163177 if ( target === undefined ) {
164178 return ;
165179 }
166- effect ( ( ) => illegallySetInputSignal ( target , source ( ) ) , { injector} ) ;
180+ effect ( ( ) => illegallySetInputSignal ( target , source ( ) ) , { injector } ) ;
167181}
0 commit comments