The great Dialog component
The Angular Material Dialog component is-among other things-a component inserted into the body, then another generic component inserted into it. Let's now try to insert a Peach component, in a Rose component after it is being created. First, say hello to Peach. What we want, is a component that receives input, emits output, and has its own events.
// components/peach.partial
// simple peach component
@Component({
template: `
Hello {{ peachSomething }} <br>
<button class="btn-rev" (click)="ok()">Yes Iniside peech</button>
<button class="btn-rev" (click)="clickPeach()">On peach event</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PeachPartialComponent {
@Input() peachSomething: string = 'default peach';
@Output() onPeach: EventEmitter<string> = new EventEmitter<string>();
constructor() {
console.log('created');
}
ok(): void {
console.log('peach ok');
}
clickPeach(): void {
this.onPeach.emit('peach clicked');
}
}
Host it
In Rose, we need to create a tag that will host Peach. We can append it to the root element, but that would not be very beneficial, we really want to decide ahead of time where it shall go. So in Rose, we update and add an HTML element (we later will investigate another option).
// rose component updated
template: `
// ... add a dom for my child
<div class="box">
<div id="mychild"></div>
</div>
//...
`
So that pretty much looks like appending Peach directly to Rose. Would that work? Let's see.
Append HTML
One way to go about this is gain reference to the mychild
element, then append our new component. Straight forward.
// in app root component
// gain reference to my child after creating Rose
const child = (<EmbeddedViewRef<any>>componentRef.hostView).rootNodes[0].querySelector('#mychild')
// create peach
const peachRef = createComponent(PeachPartialComponent, {
// host peach in mychild reference
hostElement: child,
environmentInjector: this.appRef.injector
});
// attach
this.appRef.attachView(peachRef.hostView);
Let's investigate inputs and outputs and see if it all works the same way,
// listen to outputs of peach
const ss = peachRef.instance.onPeach.subscribe((data: string) => {
console.log('onpeach called');
});
// assign input:
peachRef.instance.peachSomething = 'new peach something';
This works perfectly fine. Let's put that to use.
Rose service
We are going to make our own dialog service that does the following:
- Create Rose and insert in body
- Pass Peach and append to Rose
- Listen and respond
- Detach and clean
Working backwards, we need to be able to do the following in our calling component
// components/dialog.component
// somewhere in our code, open dialog using our service
// Dialog in this case is Rose service
const dialog = this.roseService.open(PeachComponent);
// pass few properties to Rose:
dialog.title = "opening peach";
// pass some properties to Peach
dialog.child.prop1 = 'my new peach';
// listen
dialog.onclose.subscribe((data: any) => {
console.log('closed rose');
});
// we are going to combine these props as we go along
So the Rose service initially needs to create a Rose component when open
is called.
// lib/rosedialog/rose.service
// inject in root
@Injectable({ providedIn: 'root' })
export class RoseService {
constructor(
// bring in the application ref
private appRef: ApplicationRef,
// use platform document
@Inject(DOCUMENT) private doc: Document
) { }
// open method, will implement
public open(c: any, options: any) {
// first create a Rose component
const componentRef = createComponent(RosePartialComponent, {
environmentInjector: this.appRef.injector
});
// append to body, we will use platform document for this
this.doc.body.append((<EmbeddedViewRef<any>>componentRef.hostView).rootNodes[0])
// attach view
this.appRef.attachView(componentRef.hostView);
// when closed destroy (onClose is an Output event of Rose)
const s = componentRef.instance.onClose.subscribe(() => {
this.appRef.detachView(componentRef.hostView);
componentRef.destroy();
s.unsubscribe();
});
// gain reference to my child after creating Rose
const child = (<EmbeddedViewRef<any>>componentRef.hostView).rootNodes[0].querySelector('#rosechild')
// now create c, and append to rose host view
const childRef = createComponent(c, {
environmentInjector: this.appRef.injector,
hostElement: child
});
// attach that as well
this.appRef.attachView(childRef.hostView);
}
}
Looking back at the createComponent documentation, the signature's first parameter component
is of type: Type<C>
. Type is an Angular class that refers to the Component type. So our open
method can be typed better with Type<any>
.
// better typing for c
public open(c: Type<any>, options: any) {
}
Rose herself now should look like a dialog box, I'm removing most of the styling for now for simplicity. Rose has a header with a title, a body, and a close button.
// lib/rosedialog/rose.partial
@Component({
template: `
<div class="rose-overlay" >
<div class="rose">
<div class="rose-header">
<h6 class="rose-title" >{{ title }}</h6>
<button type="button" class="rose-close" (click)="close()">Close</button>
</div>
<div class="rose-body" id="rosechild">
<!-- peach will be inserted here -->
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RosePartialComponent {
// a property, does not have to be an input
@Input() title: string = 'Default tile';
// an event that we will catch to destroy Rose
@Output() onClose: EventEmitter<any> = new EventEmitter<any>();
// some public function we can use from within Peach
close(data: any): void {
this.onClose.emit(data);
}
}
Let's test this first before we add properties to Rose.
// components/dialog.component
// in the template
<button (click)="openRose()" class="btn">Open Rose</button>
// in the code behind
constructor(private roseService: RoseService) {
// it's really is that simple:
openRose(): void {
this.roseService.open(PeachPartialComponent, { something: 'anything' });
}
}
So now we opened a Rose component, with Peach in it. We also closed Rose on a click of a close button. We want to do more, but so far, it really is that simple.
Passing properties to Rose
The first thing we want to do is pass the title property to Rose. Let's pass that in the options param of the open
method:
// lib/rosedialog/rose.service
// redifine open method
public open(c: Type<any>, options?: {title?: string}) {
// ...
// after attaching RoseView, let's assign title
this.appRef.attachView(componentRef.hostView);
// assign title
componentRef.instance.title = options?.title || '';
// ...
}
You might think it needs more. Like an interface called IRoseOptions
. But we both know that's just bells and whistles. What if we want to pass an object to Peach itself?
Passing data to Peach
The easiest way to do that is simply pass the object to Peach. Is that possible? (Remember from our first article, we can also use Input
and setInputs
, but setting a public property to instance
is simpler)
// rose.service
// add data of type: any
public open(c: Type<any>, options?: {title?: string, data?: any}) {
// ...
// after creating the child component, pass properties directly
childRef.instance.data = options?.data;
}
Peach now should have a definition for data
// peach.component
export class PeachPartialComponent implements OnInit, AfterViewInit {
// public property to set
data: any;
constructor() {
// this is undefined
console.log(this.data);
}
ngOnInit(): void {
// this propbably has value
console.log(this.data);
}
ngAfterViewInit(): void {
// this most definitly has value
console.log(this.data);
}
}
We might get access to that data as early as OnInit
, but it's generally safer to wait till AfterViewInit
. Trying out from our root component
// components/dialog.component
openRose(): void {
const ref = this.roseService.open(PeachPartialComponent,
{
title: 'Peach says hello',
data: 'some string'
});
}
Notice, I made a big statement by saying that the data is available on AfterViewInit
, but you and I know that Angular is too unpredictable, I have run into situations where I had to pull off some tricks. I hope with the new Angular 16 we will resolve less often to those tricks.
Asking Peach to close Rose
What if I want to call a method from Peach to close Rose? There are two ways to about that, passing a reference to Rose itself to Peach, or creating an event in Peach, and catching it to close Rose.
1. Passing Rose as a reference
One way to do that is to pass a reference to Rose inside of Peach, then the method this.rose.close()
can be called inside of Peach.
// rose.service
open(...) {
// ...
// we can pass Rose partial to the new component
childRef.instance.rose = componentRef.instance;
}
// peach.component
export class PeachPartialComponent implements OnInit, AfterViewInit {
// optionally define data and rose
data: any;
rose!: RosePartialComponent;
// lets use rose to close
someClick() {
this.rose.close();
}
}
You might think this is too clumsy, it is. But it is also that simple and straightforward.
2. Sharing an event with Rose
Another way is to call a predefined event in Peach. In our example we had onPeach
event. Let's make another one: onChildClose
:
// peach.component
@Output() onChildClose: EventEmitter<any> = new EventEmitter<any>();
// then call it in a method
clickPeach(): void {
this.onChildClose.emit('close rose');
}
Now all we have to do is subscribe to that event in our Rose services
// rose.service
// ...
open(...) {
// subscribe to onChildClose if it exists, and destory Rose
childRef.instance.onChildClose?.subscribe(() => {
this.appRef.detachView(componentRef.hostView);
componentRef.destroy();
});
}
Too clumsy? We'll figure it out later. Let's move on. How do we listen to events from the outside world?
Emitting events from Rose
The most straightforward way to do it is to assign functions to the options
property, then call these functions on events happening in Rose. Here is an example of onclose
// rose.service
// define an extra parameter to options
public open(c: Type<any>, options?: {
title?: string,
data?: any,
// onclose or onemit or onopen ... etc
onclose?: (data: any) => void}) {
// then call these functions when you want
const s = componentRef.instance.onClose.subscribe((data: any) => {
// call onclose if exists
if (options?.onclose) {
options.onclose(data);
}
// then destroy
this.appRef.detachView(componentRef.hostView);
componentRef.destroy();
s.unsubscribe();
});
}
Then in our root component, we use it like this
// components/dialog.component
openRose(): void {
this.roseService.open(PeachPartialComponent,
{
title: 'Peach says hello',
data: 'some string',
onclose: () => {
console.log('closed');
}
});
}
This works because we know that onclose
should be called on Rose closing, and we can add onopen
, or oninit
, etc. But what if we just want Peach to emit data?
Handling Peach events
There are two ways for this as well. We either pass the event handler as an option to Rose service (if you wish to be a control freak), or we can directly catch events from Peach, no need to let Rose know about it.
1. Event handlers as options
For example we can wire the onPeach
event to be a property for Rose service.
// rose.service
// add yet another function
public open(c: Type<any>, options?: {
title?: string,
data?: any,
onPeach?: (data: any) => void,
onclose?: () => void}) {
// at the end, emit subscribe if it exists
childRef.instance.onPeach?.subscribe((data: any) => {
options?.onPeach?.(data);
});
}
Trigger the event in Peach
// peach.partial
// create an event
@Output() onPeach: EventEmitter<string> = new EventEmitter<string>();
// trigger it with some data inside peach
clickPeach(): void {
this.onPeach.emit('peach clicked');
}
Finally, make use of it in app root
// components/dialog.component
// openRose
openRose(): void {
this.roseService.open(PeachPartialComponent,
{
title: 'Peach says hello',
data: 'some string',
onclose: () => {
console.log('closed');
},
// pass custom event handlers
onPeach: (data: string) => {
console.log('do seomthing with', data);
}
});
}
2. Keep Rose out of it
We can return a reference to the new component (Peach) and deal with it directly, No need to map all properties and events. This is definitely more robust solution, and a lot less strict.
// rose.service
@Injectable({ providedIn: 'root' })
export class RoseService {
// open method
public open(...) {
// ...
// return refrence to peach
return childRef.instance;
}
}
Now in our application, we can do the following
// components/dialog.component
openRose(): void {
const peachRef = this.roseService.open(PeachPartialComponent,
{
//...
});
// its easier to deal with Peach directly:
peachRef.onPeach.subscribe((data: any) => {
console.log('do something with', data);
});
}
So we kept onclose
inside Rose service because we want to detach and get rid of Rose before making other calls. But everything else can be done directly on Peach component. There is no need for Rose service to get in between them.
Standalone
Noticed how we did not once add standalone
flag to Rose and Peach? That's because we did not need to import any modules. If we are to use a *ngIf
for example, we need to turn them into standalone, and import the CommonModule
. This is much cleaner than the old solution where we had to house the different components in some module that imports needed modules.
// Peach and Rose should turn into standalone when using common module directives
@Component({
// ...
standalone: true,
imports: [CommonModule]
})
Tidy up
Is it too clumsy? It is. And we can do something about it. But before we go with our housekeeping, there are two other ways besides an HTML node to add content. That, in addition to passing provider services, is coming on next Tuesday. 😴
The Rose service as we stand can be found on StackBlitz
Thank you for reading this far, if you have comments or questions, let me know.
Top comments (0)