In this article we're going to explore how you can programmatically access the Angular Material Select panel, without doing strange global DOM queries 😃. Let's dive in!
TL;DR
In your directive, inject the reference to MatSelect
, subscribe to the openedChange
Observable and if it is open, access the panel
property on the MatSelect
.
If you’re in search for some high quality component library, Angular Material might be a good point where to start. As a side-note, if you're not searching for a Material Design, there are lots of other interesting options:
- the CDK package within the Angular Material package which is design agnostic
- Bootstrap based
- VMWare Clarity Design System
- Nebular
- Prime Faces
- (...and a lot more...)
But to come back to our topic. Angular Material puts some major effort around creating accessible components. Sometimes you might want to add some custom logic, like custom attributes. Take for instance the MatSelect
(here are the corresponding docs)
The Angular template for this looks as follows:
<mat-select [(ngModel)]="selectedValue" name="food">
<mat-option *ngFor="let food of foods" [value]="food.value">
{{food.viewValue}}
</mat-option>
</mat-select>
A client of mine had the requirement to access the rendered options and add custom attributes to those <options>
to enhance support for screenreaders. So the first idea is to place some directive - say myDirective
(plz use a proper name 😉) - onto the <mat-select>
and then use some DOM selectors to get hold of the options.
The Material Options Panel is not a child of the MatSelect
It might look easy, right? In your directive myDirective
you can get the ElementRef
injected and simply access the <mat-options>
. The ElementRef
would be the one of the <mat-select>
which would allow to select it’s child option items. Something like
@Directive({})
export class MyDirective implements OnInit {
constructor(private elementRef: ElementRef) {}
ngOnInit() {
this.elementRef.nativeElement.querySelector(...)
}
}
That won’t work! The <mat-options>
- although it might seem from how you write the <mat-select>
- are not child objects of the <mat-select>
in the DOM.
When you open the select, Material renders them in a dedicated,z-index and absolute positioned panel at the document.body
level. Why is that? It’s to make sure it stays over all other elements and to not expand or shift any other element within the body.
You're doing it wrong
The next immediate step would be to change this.elementRef.nativeElement.querySelector(...)
to document.body.querySelector(...)
, right? Do not! We only keep that as a very last resort. You want to keep your querySelector
as focused as possible, for performance reasons but also not to run into other elements rendered on the page.
Reference the Options panel via the panel
property
The biggest advantage of using open source libraries is that we can take a look at the source and see how Material creates the hosting overlay and whether it keeps and in particular exposes its reference to the outside. And indeed, if we have a quick look at the API docs, there's a property panel
which is an ElementRef
to the container of the <options>
.
On that panel property, we can perform our panel.nativeElement.querySelect(...)
to have a nicely scoped DOM query that only runs on the container with our option list.
Accessing the Host component with Dependency Injection
We add our directive to the <mat-select>
as follows
<mat-select myDirective>
...
</mat-select>
We only need a way to get access to the MatSelect
instance from within our directive, s.t. we can grab the panel
reference and perform our query. The by far easiest way (and surprisingly many devs don't know about this) is to use Angular's dependency injection. By requiring the instance in the constructor, Angular will take care to inject the host/parent component.
@Directive({
selector: '[myDirective]'
})
export class MyDirective implements OnInit {
/**
* MatSelect instance injected into the directive
*/
constructor(private select:MatSelect) { }
}
Now the only thing left is to actually use the panel
property. We need to subscribe to the openedChange
Observable since the options are only rendered and visible on the page if the <mat-select>
is active.
@Directive({
selector: '[myDirective]'
})
export class MyDirective implements OnInit {
constructor(private select:MatSelect) { }
ngOnInit() {
this.select.openedChange.subscribe(isOpen => {
if(isOpen) {
console.log('open', this.select.panel);
}
})
}
}
Full example
Here's a Stackblitz example to play around with
https://stackblitz.com/edit/blog-angular-mat-select-panel-options