We build new components every day in our Angular application. A few core principles, such as augmenting native elements or utilising the directive selector, could help develop fantastic components. In this post, weāll go back to the basics to revisit the power of Angular directive selectors in ways that simplify the componentās implementation and improve accessibility.
Components are the most basic UI building block of an Angular app. An Angular app contains a tree of Angular components.
We will look into a rather simple use case and see how augmenting native elements could help.
This blog post is based on my recent presentation at NGRome 2022.
We will create a custom button component with three variants
<a> tag and open a new URL when clicked.stackblitz.com/edit/angular-directives-use-case ā
Thatās the simple implementation for the first requirement that simple can render a button with specific styling and text content.
export type ButtonType = 'reset' | 'button' | 'submit'
export type ButtonTheme = 'primary' | 'secondary'
@Component({
selector: 'shared-button',
template: `
<button
class="button"
[ngClass]="'btn-' + buttonTheme"
[attr.type]="buttonType"
>
<span class="button-text">{{ buttonText }}</span>
</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ButtonComponent {
@Input() buttonText!: string
@Input() buttonTheme: ButtonTheme = 'secondary'
@Input() buttonType: ButtonType = 'button'
}To create a new component in Angular, we usually need at least three parts:
shared-buttonThatās how we use it to render the first use-case, notice that you only need to give the [buttonText] input and we will have the text Login render inside the button for us.
Below is how it gets rendered into the DOM. As we can see there is a shared-button that wraps the real native button
In the second example, we want to render the text along with an icon. The simplest way to do it is introducing new @Input() icon: string and our shared-button will use this icon to render with our favourite icon library, e.g. material icon. The code could potentially look like this:
export type ButtonType = 'reset' | 'button' | 'submit';
export type ButtonTheme = 'primary' | 'secondary';
@Component({
selector: 'shared-button',
template: `
<button class="button" [ngClass]="'btn-' + buttonTheme" [attr.type]="buttonType">
<span class="button-text">{{ buttonText }}</span>
+ <mat-icon [svgIcon]="buttonIcon"></mat-icon>
</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ButtonComponent {
@Input() buttonText!: string;
+ @Input() buttonIcon!: string;
}However with this implementation, there are always certain constraints:
mat-iconWith that, several questions arise:
mat-icon but used just a font icon with a simple <i> tag? Or sometimes, an image with <img> tagTo support the above requirements, it is so much easier if we can provide both text and icons together as a ātemplateā to the button component. So that the componentās user can decide how he wants to arrange the content, or which icon library he wants to use.
We introduce a new @Input() buttonContent that accepts a TemplateRef instead of just a simple string. We can use something like content projection that does the same thing, however, letās stick with TemplateRef for now.
@Component({
selector: 'shared-button',
template: `
<button class="button" [ngClass]="'btn-' + buttonTheme" [attr.type]="buttonType">
<span class="button-text">
<ng-container> {{ buttonText }} </ng-container>
+ <ng-container *ngTemplateOutlet="buttonContent"> </ng-container>
</span>
</button>
`,
})
export class ButtonComponent {
+ @Input() buttonContent!: TemplateRef<any>;
@Input() buttonText!: string;
@Input() buttonTheme: ButtonTheme = 'secondary';
@Input() buttonType: ButtonType = 'button';
}So with the new implementation, it is very flexible since we can essentially decide what template we want to render inside the button: maybe it is just a single icon, maybe the icon appears in front of the text, and in this example, we use a dedicated component twitter-icon. You know what I am talking about.
<shared-button [buttonContent]="twitterBtnTmpl">
<ng-template #twitterBtnTmpl>
Twitter <twitter-icon class="btn-icon"></twitter-icon>
</ng-template>
</shared-button>Below is how it gets rendered into the DOM. As we can see there is a shared-button that wraps the real native button as the previous #1 example.
In the 3rd use case, things get a little more interesting. We want to have a link, that looks like a button. A link means when click, it opens an URL.
Looks simple enough, however, the real implementation was a bit more complicated than that.
A link could be either opened using routerLink for an internal Angular route or href for an external URL
http or https to differentiate between internal and external URLrouterLink could require some query parametershref, do we want to open it in a new tab using target="_blank"target="_blank" appears, we should set rel="noopener noreferrer" to a tag for safety reasons.type ButtonMode = 'internalURL' | 'externalURL' | 'button'
export type ButtonType = 'reset' | 'button' | 'submit'
export type ButtonTheme = 'primary' | 'secondary'
@Component({
selector: 'shared-button',
template: `
<ng-container [ngSwitch]="buttonMode()">
<button
*ngSwitchCase="'button'"
class="button"
[ngClass]="'btn-' + buttonTheme"
[attr.aria-label]="ariaLabel"
[attr.disabled]="isDisabled ? true : null"
[attr.type]="buttonType"
>
<img *ngIf="iconPath" alt="" class="btn-icon" [src]="iconPath" />
<span class="button-text">
<ng-container *ngIf="!!buttonText"> {{ buttonText }} </ng-container>
<ng-container *ngTemplateOutlet="buttonContent"> </ng-container>
</span>
</button>
<a
*ngSwitchCase="'internalURL'"
class="button"
[ngClass]="'btn-' + buttonTheme"
[routerLink]="redirectURL"
[queryParams]="queryParams"
[attr.aria-label]="ariaLabel"
[attr.role]="roleType"
>
<img *ngIf="iconPath" class="btn-icon" [src]="iconPath" />
<span class="button-text">
<ng-container *ngIf="!!buttonText"> {{ buttonText }} </ng-container>
<ng-container *ngTemplateOutlet="buttonContent"> </ng-container>
</span>
</a>
<a
*ngSwitchCase="'externalURL'"
class="button"
[ngClass]="'btn-' + buttonTheme"
[href]="redirectURL"
[attr.target]="isTargetBlank ? '_blank' : null"
[attr.rel]="isTargetBlank ? 'noopener noreferrer' : null"
[attr.aria-label]="ariaLabel"
[attr.role]="roleType"
>
<img *ngIf="iconPath" class="btn-icon" [src]="iconPath" />
<span class="button-text">
<ng-container *ngIf="!!buttonText"> {{ buttonText }} </ng-container>
<ng-container *ngTemplateOutlet="buttonContent"> </ng-container>
</span>
</a>
</ng-container>
`,
})
export class ButtonComponent implements OnInit {
@HostBinding('class') fullButtonClass!: string
@HostBinding('class.shared-button') componentClass = true
@HostBinding('class.is-disabled') get isButtonDisabled(): boolean {
return this.isDisabled
}
@Input() buttonClass!: string
@Input() buttonContent!: TemplateRef<any>
@Input() buttonText!: string
@Input() buttonTheme: ButtonTheme = 'secondary'
@Input() buttonType: ButtonType = 'button'
@Input() roleType: string | null = null
@Input() redirectURL!: string
@Input() queryParams: { [key: string]: string | number } | null = null
@Input() isDisabled!: boolean
@Input() isTargetBlank!: boolean
@Input() iconPath!: string
@Input() ariaLabel!: string
ngOnInit(): void {
const classNames = [this.buttonClass, this.buttonTheme].filter(
className => className
)
this.fullButtonClass = classNames.join(' ')
}
buttonMode(): ButtonMode {
if (!this.redirectURLExists()) {
return 'button'
}
if (this.isInternalURL()) {
return 'internalURL'
} else {
return 'externalURL'
}
}
private redirectURLExists(): boolean {
return !!this.redirectURL
}
private isInternalURL(): boolean {
return !(
this.redirectURL.toString().startsWith('http://') ||
this.redirectURL.toString().startsWith('https://')
)
}
}The following code could render our desired button
However, the button implementation might have @Input with different attributes that you used to work with e.g. isTargetBlank instead of just target or redirectURL instead normal href
Below is how it gets rendered into DOM, we have our shared-button as usual and inside it renders the <a> as we are expecting.
Also, if you donāt know about the implementation detail at all, it could easily end up in a situation where you render the whole button inside <a> tag to have the same anchor behaviour.
<a href="https://trungvose.com/"
target="_blank">
<shared-button
[buttonContent]="readmoreTmpl"
[buttonTheme]="'primary'"
>
<ng-template #readmoreTmpl>
Readmore ā
</ng-template>
</shared-button>
</a>In this case, the DOM structure will be a > shared-button > button and because both a and button are focusable, pressing Tab will focus into each one of them.
In the screenshot, we press Tab to have a default blue outline because we focus on a, press one more time youāll see our custom outline because now it gets focused on button

This approach wonāt scale very well
In all three examples, our implementation is increasingly its complexity when we need to introduce new functionality. However, what we want at the end is truly simple: apply certain classes to button and a so that it looks the way we want.
For the rest of the code, we are trying to duplicate the functionality of the button and a because they are hidden away within our shared-button component and we couldnāt access it otherwise.
Because we hide our button element from shared-button, if we want to support new button attributes, we will need to introduce one new @Input on our shared-button
However, each HTML element like button will also come with a list of global attributes ā it needs to support.
It included all ARIA attributes for making our web more accessible, which includes about 50+ more attributes.
So if our shared-button component suddenly needs to support more attributes, thatās how it might look.
@Input() ariaHidden: boolean;
@Input() ariaPlaceholder: string;
@Input() ariaPressed: string;
@Input() ariaReadonly: string;
@Input() ariaRequired: string;
@Input() ariaSelected: string;
@Input() ariaValueText: string;
@Input() ariaControls: string;
@Input() ariaDescribedBy: string;
@Input() ariaDescription: string;
@Input() ariaFlowTo: string;
@Input() ariaLabelledBy: string;
// and another 100 more Inputs šIn angular.io accessibility guide, there is one small section that mentions Augmenting native elements approach.
Native HTML elements capture several standard interaction patterns that are important to accessibility. When authoring Angular components, you should re-use these native elements directly when possible, rather than re-implementing well-supported behaviors.
For example, instead of creating a custom element for a new variety of button, create a component that uses an attribute selector with a nativeĀ
<button>Ā element. This most commonly applies toĀ<button>Ā andĀ<a>, but can be used with many other types of element.From https://angular.io/guide/accessibility#augmenting-native-elements
In Angular, augmenting native elements is creating a component that uses an attribute selector to extend the native <button> element.
Thatās how the code with augmenting native elements
button[shared-button] and a[shared-button] because we donāt want to apply this component to a div for example.button or aHostBinding@Component({
+ selector: 'button[shared-button], a[shared-button]',
+ template: ` <ng-content></ng-content> `,
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./button-v2.component.scss'],
encapsulation: ViewEncapsulation.None,
})
export class ButtonV2Component {
+ @HostBinding('class') get rdButtonClass(): string {
const classes = ['button', `btn-${this.buttonTheme}`];
return classes.filter(Boolean).join(' ');
}
@Input() buttonTheme: ButtonTheme = 'secondary';
}I show the code side by side so we can have a better comparison. The top is the old approach using a custom shared-button, and the bottom uses our new approach of attribute selector.
Noticed that in all three examples
button and a without a redundant wrapper.button and a directly thus it eliminates the need to forward additional attributes. The only @Input we accepts so far is buttonThemeAngular directive ā selector accepts:
element-name: Select by element name..class: Select by class name.[attribute]: Select by attribute name.[attribute=value]: Select by attribute name and value.:not(sub_selector): Select only if the element does not match theĀ sub_selector.selector1, selector2: Select if eitherĀ selector1Ā orĀ selector2Ā matches.In our code, we use button[shared-button], a[shared-button] which is the combination of element-name and [attribute] selectors.
Angular Material
material/button/button.ts#L40 āļø
@Component({
selector: `
button[mat-button], button[mat-raised-button], button[mat-flat-button],
button[mat-stroked-button]
`,
templateUrl: 'button.html',
inputs: MAT_BUTTON_INPUTS,
exportAs: 'matButton',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatButton extends MatButtonBase {NG-ZORRO
components/button/button.component.ts#L40
Notice here ng-zorro enhance the template to supply a loading icon as well, not just a simple ng-content anymore.
@Component({
selector: 'button[nz-button], a[nz-button]',
exportAs: 'nzButton',
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
+ <i nz-icon nzType="loading" *ngIf="nzLoading"></i>
<ng-content></ng-content>
`,
})
export class NzButtonComponent implements OnDestroy, OnChanges,Angular Material
@Component({
selector: 'mat-table, table[mat-table]',
exportAs: 'matTable',
template: CDK_TABLE_TEMPLATE,
providers: [
{provide: CdkTable, useExisting: MatTable},
{provide: CDK_TABLE, useExisting: MatTable},
changeDetection: ChangeDetectionStrategy.Default,
})
export class MatTable<T> extends CdkTable<T> implements OnInit {material/tabs/tab-nav-bar/tab-nav-bar.ts#L307
@Component({
selector: '[mat-tab-nav-bar]',
exportAs: 'matTabNavBar, matTabNav',
templateUrl: 'tab-nav-bar.html',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.Default,
})
export class MatTabNav extends _MatTabNavBaseNo. We need custom components!
However, when youāre creating a new component, you should ask yourself
Can I augment an existing one instead? ā