I'm building a generic Angular list component that integrates with forms using value accessors. I need to dynamically render form controls with custom theming inside this list. The following is a sand box example.
<a-component>
<ng-template let-name="name">
<div *provideControlContainer>
<div>{{ name }}</div>
<div>
<input [formControlName]="name">
</div>
</div>
</ng-template>
</a-component>
And
<div [formGroup]="form">
<input formControlName="name">
<b-component name='alias' />
<hr/>
<ng-container
*provideControlContainer
[ngTemplateOutlet]="template"
[ngTemplateOutletContext]="{ name: 'surname'}"
/>
</div>
The problem is that FormControlName
only searches for parent ControlContainer
instances within the host component, due to the @Host()
decorator:
TypeScript
constructor(
@Optional() @Host() @SkipSelf() parent: ControlContainer,
)
This prevents dynamically generated form controls within my list component's template (using ng-template
) from correctly registering with the parent form.
I've tried injecting and proxying the parent ControlContainer
, but this only works with component transclusion, not ng-template
.
I'm also considering these solutions:
- Overriding the
FormControlName
directive to remove the@Host()
limitation. - Dynamically creating components using a builder function (which seems less ideal).
Are there existing solutions or best practices for this scenario? I'd prefer to use a template or projection approach if possible. Any suggestions or alternative approaches would be greatly appreciated. There is stackblitz to play with.
P.S. It was built with AI tools.
I'm building a generic Angular list component that integrates with forms using value accessors. I need to dynamically render form controls with custom theming inside this list. The following is a sand box example.
<a-component>
<ng-template let-name="name">
<div *provideControlContainer>
<div>{{ name }}</div>
<div>
<input [formControlName]="name">
</div>
</div>
</ng-template>
</a-component>
And
<div [formGroup]="form">
<input formControlName="name">
<b-component name='alias' />
<hr/>
<ng-container
*provideControlContainer
[ngTemplateOutlet]="template"
[ngTemplateOutletContext]="{ name: 'surname'}"
/>
</div>
The problem is that FormControlName
only searches for parent ControlContainer
instances within the host component, due to the @Host()
decorator:
TypeScript
constructor(
@Optional() @Host() @SkipSelf() parent: ControlContainer,
)
This prevents dynamically generated form controls within my list component's template (using ng-template
) from correctly registering with the parent form.
I've tried injecting and proxying the parent ControlContainer
, but this only works with component transclusion, not ng-template
.
I'm also considering these solutions:
- Overriding the
FormControlName
directive to remove the@Host()
limitation. - Dynamically creating components using a builder function (which seems less ideal).
Are there existing solutions or best practices for this scenario? I'd prefer to use a template or projection approach if possible. Any suggestions or alternative approaches would be greatly appreciated. There is stackblitz to play with.
P.S. It was built with AI tools.
Share Improve this question asked Mar 14 at 19:27 Victor ShelepenVictor Shelepen 2,2963 gold badges22 silver badges51 bronze badges2 Answers
Reset to default 1Without a formGroup
there is no control container, so the directive is useless without it.
So just pass the form as an input to the template. Then initialize the form again inside the template using [formGroup]="form"
, it will behave as usual eventhough we have multiple [formGroup]
.
<a-component>
<ng-template let-form="form" let-name="name">
<div [formGroup]="form">
<div>{{ name }}</div>
<div>
<input [formControlName]="name">
</div>
</div>
</ng-template>
</a-component>
Then we can pass in the form as an input through the outlet.
<div [formGroup]="form">
<input formControlName="name">
<b-component name='alias' />
<hr/>
<ng-container
[ngTemplateOutlet]="template"
[ngTemplateOutletContext]="{ name: 'surname', form: form}"
/>
</div>
<button (click)="click()">Click</button>
{{ form.value | json }}
Full Code:
import {
Component,
ContentChild,
Directive,
inject,
InjectFlags,
Input,
TemplateRef,
ViewContainerRef,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { bootstrapApplication } from '@angular/platform-browser';
import {
FormControl,
FormGroup,
ReactiveFormsModule,
FormsModule,
ControlContainer,
} from '@angular/forms';
@Component({
imports: [ReactiveFormsModule, CommonModule, FormsModule],
selector: 'd-component',
viewProviders: [
{
provide: ControlContainer,
useFactory: () => inject(ControlContainer, InjectFlags.SkipSelf),
},
],
template: `
[D]
<ng-content></ng-content>
`,
})
class DComponent {
constructor(private controlContainer: ControlContainer) {
console.log(this.controlContainer);
}
}
@Component({
imports: [ReactiveFormsModule, CommonModule, FormsModule],
selector: 'b-component',
viewProviders: [
{
provide: ControlContainer,
useFactory: () => inject(ControlContainer, InjectFlags.SkipSelf),
},
],
template: `
<div>Control - {{ name }}</div>
<div>
<input [formControlName]="name">
</div>
`,
})
class BComponent {
@Input() name: string = '';
}
@Component({
imports: [ReactiveFormsModule, CommonModule, FormsModule, BComponent],
selector: 'a-component',
template: `
<div [formGroup]="form">
<input formControlName="name">
<b-component name='alias' />
<hr/>
<ng-container
[ngTemplateOutlet]="template"
[ngTemplateOutletContext]="{ name: 'surname', form: form}"
/>
</div>
<button (click)="click()">Click</button>
{{ form.value | json }}
`,
})
class AComponent {
@ContentChild(TemplateRef) template!: TemplateRef<any>;
form = new FormGroup({
name: new FormControl(''),
alias: new FormControl(''),
surname: new FormControl(''),
});
click() {
console.log(this.form.value);
}
}
@Component({
imports: [AComponent, DComponent, ReactiveFormsModule],
selector: 'app-root',
template: `
<a-component>
<ng-template let-form="form" let-name="name">
<div [formGroup]="form">
<div>{{ name }}</div>
<div>
<input [formControlName]="name">
</div>
</div>
</ng-template>
</a-component>
`,
})
export class App {}
bootstrapApplication(App);
Stackblitz Demo
Collecting workspace informationSure, here is a summary of the four variants of attaching control to the control group in Angular, based on the files imported in main.ts.
Summary of Attaching Control to Control Group in Angular
In this project, there are four different approaches to attaching a control to a control group. Each approach is demonstrated in a separate file. Below is a summary of each approach:
1. Using pass-container-instance.ts
In this approach, the control is passed as an input to a child component, and the child component uses ngTemplateOutlet
to render the control.
File: pass-container-instance.ts
Key Components:
ComponentA
receives a control as an input and usesngTemplateOutlet
to render it.ComponentB
passes a control toComponentA
and provides a template for the control.FrameComponent
creates a form group and passes a control toComponentB
.
@Component({
selector: 'component-a',
template: `
<div>component-a</div>
<div>
<ng-container *ngTemplateOutlet="template; context: { control: control }"></ng-container>
</div>
`,
})
class ComponentA {
@Input() control: FormControl | null = null;
@ContentChild(TemplateRef) template!: TemplateRef<any>;
}
@Component({
selector: 'component-b',
template: `
<div>component-b</div>
<div>
<component-a [control]=control>
<ng-template let-control="control">
<input [formControl]="control" ngDefaultControl>
</ng-template>
</component-a>
</div>
`,
})
class ComponentB {
@Input() control: FormControl | null = null;
}
@Component({
selector: 'app-frame',
template: `
<div>
<h1>FrameComponent</h1>
<form [formGroup]="form">
<input formControlName="name" placeholder="Name">
<component-b [control]="surnameControl"/>
</form>
{{ form.value | json }}
</div>
`,
})
class FrameComponent {
surnameControl = new FormControl('Surname');
form = new FormGroup({
name: new FormControl('Name'),
surname: this.surnameControl,
});
}
2. Using pass-control-instance.ts
In this approach, the entire form group is passed to a child component, and the child component uses ngTemplateOutlet
to render the control within the form group.
File: pass-control-instance.ts
Key Components:
ComponentA
receives a form group as an input and usesngTemplateOutlet
to render the control.ComponentB
passes the form group toComponentA
and provides a template for the control.FrameComponent
creates a form group and passes it toComponentB
.
@Component({
selector: 'component-a',
template: `
<div>component-a</div>
<div>
<ng-container *ngTemplateOutlet="template; context: { control: control }"></ng-container>
</div>
`,
})
class ComponentA {
@Input() control: FormControl | null = null;
@ContentChild(TemplateRef) template!: TemplateRef<any>;
}
@Component({
selector: 'component-b',
template: `
<div>component-b</div>
<div>
<component-a [form]=form>
<ng-template let-control="control">
<div [formGroup]="form">
<input formControlName="surname" ngDefaultControl>
</div>
</ng-template>
</component-a>
</div>
`,
})
class ComponentB {
@Input() form: FormGroup | null = null;
}
@Component({
selector: 'app-frame',
template: `
<div>
<h1>FrameComponent</h1>
<form [formGroup]="form">
<input formControlName="name" placeholder="Name">
<component-b [form]="form"/>
</form>
{{ form.value | json }}
</div>
`,
})
class FrameComponent {
form = new FormGroup({
name: new FormControl('Name'),
surname: new FormControl('Surname'),
});
}
3. Using cc-injecting-directive.ts
In this approach, a custom directive is used to inject the parent control container into the child component.
File: cc-injecting-directive.ts
Key Components:
ParentControlContainer
directive injects the parent control container.ComponentA
andComponentB
use theParentControlContainer
directive.FrameComponent
creates a form group and usesComponentB
.
@Directive({
selector: '[parentControlContainer]',
providers: [
{
provide: ControlContainer,
useFactory: () => inject(ControlContainer, { skipSelf: true }),
}
]
})
export class ParentControlContainer {
constructor(
@Optional()
@SkipSelf()
parent: ControlContainer,
private injector: Injector
) {
this.logParentChain();
}
private logParentChain() {
let currentInjector: Injector | null = this.injector;
while (currentInjector) {
const parentControlContainer = currentInjector.get(ControlContainer, null);
console.log('Parent ControlContainer:', parentControlContainer);
currentInjector = (currentInjector as unknown as {parent: Injector}).parent;
}
}
}
@Component({
selector: 'component-b',
template: `
<div>component-b</div>
<div>
<component-a>
<ng-template>
<ng-container parentControlContainer>
<input formControlName="surname" ngDefaultControl>
</ng-container>
</ng-template>
</component-a>
</div>
`,
})
class ComponentB {
@Input() name!: string;
}
@Component({
selector: 'component-a',
template: `
<div>component-a</div>
<div>
<ng-container *ngTemplateOutlet="template"></ng-container>
</div>
`,
})
class ComponentA {
@Input() name!: string;
@ContentChild(TemplateRef) template!: TemplateRef<any>;
}
@Component({
selector: 'app-frame',
template: `
<div>
<h1>FrameComponent</h1>
<form [formGroup]="form">
<input formControlName="name" placeholder="Name">
<component-b />
</form>
{{ form.value | json }}
</div>
`,
})
class FrameComponent {
form = new FormGroup({
name: new FormControl('Name'),
surname: new FormControl('Surname'),
});
}
4. Using main-dirrective-override.ts
In this approach, a custom directive is used to override the default FormControlName
directive.
File: main-dirrective-override.ts
Key Components:
OutControlForm
directive overrides the defaultFormControlName
directive.ComponentA
andComponentB
use theOutControlForm
directive.FrameComponent
creates a form group and usesComponentB
.
const controlNameBinding: Provider = {
provide: NgControl,
useExisting: forwardRef(() => OutControlForm),
};
@Directive({
selector: '[outFormControlName]',
providers: [controlNameBinding],
})
export class OutControlForm extends FormControlName {
@Input('outFormControlName') override name: string | number | null = null;
constructor(
@Optional()
@SkipSelf()
parent: ControlContainer,
@Optional() @Self() @Inject(NG_VALIDATORS) validators: (Validator | ValidatorFn)[],
@Optional()
@Self()
@Inject(NG_ASYNC_VALIDATORS)
asyncValidators: (AsyncValidator | AsyncValidatorFn)[],
@Optional() @Self() @Inject(NG_VALUE_ACCESSOR) valueAccessors: ControlValueAccessor[],
private injector: Injector
) {
super(parent, validators, asyncValidators, valueAccessors, null);
this.logParentChain();
}
private logParentChain() {
let currentInjector: Injector | null = this.injector;
while (currentInjector) {
const parentControlContainer = currentInjector.get(ControlContainer, null);
console.log('Parent ControlContainer:', parentControlContainer);
currentInjector = (currentInjector as unknown as {parent: Injector}).parent;
}
}
}
@Component({
selector: 'component-a',
template: `
<div>component-a</div>
<div>
<ng-container *ngTemplateOutlet="template"></ng-container>
</div>
`,
})
class ComponentA {
@Input() name!: string;
@ContentChild(TemplateRef) template!: TemplateRef<any>;
}
@Component({
selector: 'component-b',
template: `
<div>component-b</div>
<div>
<component-a>
<ng-template>
<input outFormControlName="surname" ngDefaultControl>
</ng-template>
</component-a>
</div>
`,
})
class ComponentB {
@Input() name!: string;
}
@Component({
selector: 'app-frame',
template: `
<div>
<h1>FrameComponent</h1>
<form [formGroup]="form">
<input formControlName="name" placeholder="Name">
<component-b />
</form>
{{ form.value | json }}
</div>
`,
})
class FrameComponent {
form = new FormGroup({
name: new FormControl('Name'),
surname: new FormControl('Surname'),
});
}
Each of these approaches demonstrates a different way to attach a control to a control group in Angular. Depending on your specific requirements, you can choose the approach that best fits your needs.
This summary provides an overview of the four different approaches used in the project. You can refer to the respective files for more details on each implementation.
A.I. helped me to assemble it. I am not so smart.