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-button
Thatā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-icon
With 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 a
HostBinding
@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 buttonTheme
Angular 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 _MatTabNavBase
No. We need custom components!
However, when youāre creating a new component, you should ask yourself
Can I augment an existing one instead? ā