Back to the homepage
Angular

Angular augmenting native elements

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.

  1. Source code and slide
  2. Creating a custom button component
    2.1. Custom button #1
    2.2. Custom button #2
    2.3. Create a custom button #3
  3. Global attributes
  4. Augmenting native elements
    4.1. Re-implement three use cases using augmenting native elements approach
    4.2. Custom button #1
    4.3. Custom button #2
    4.4. Custom button #3
    4.5. Why does it work?
    4.6. Benefits
    4.7. Many popular UI libraries use augmenting native elements approach
    4.8. What else can you do with augmenting native elements?
    4.9. Should we never replace native components with custom components?

Source code and slide

This blog post is based on my recent presentation at NGRome 2022.

Creating a custom button component

We will create a custom button component with three variants

  1. A button with simply a text
  2. A button with text and an icon
  3. A button with text and an icon, behave like a <a> tag and open a new URL when clicked.

 

 

 

 

 

 

 

 

 

 

 

stackblitz.com/edit/angular-directives-use-case

Custom button #1

That’s the simple implementation for the first requirement that simple can render a button with specific styling and text content.

To create a new component in Angular, we usually need at least three parts:

  • A selector, in this case, is shared-button
  • The template to render when we use our component
  • A class that defines the behaviour of our component. In the use case of a button, there isn’t any behaviour needed.

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

Custom button #2

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:

However with this implementation, there are always certain constraints:

  • We always use mat-icon
  • The icon always displays after the text

With that, several questions arise:

  • What if I don’t want to use mat-icon but used just a font icon with a simple <i> tag? Or sometimes, an image with <img> tag
  • What if I want to place the icon, then the text?

To 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.

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.

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.

Create a custom button #3

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

  • We need to check whether the URL includes http or https to differentiate between internal and external URL
  • routerLink could require some query parameters
  • If the link is external and open with href, do we want to open it in a new tab using target="_blank"
  • We also know that if target="_blank" appears, we should set rel="noopener noreferrer" to a tag for safety reasons.
  • View the full shared-button implementation that supports <a>

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.

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.

Global attributes

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.

Augmenting native elements

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

  • Selector: we are not using a custom component tag, instead, we combine button[shared-button] and a[shared-button] because we don’t want to apply this component to a div for example.
  • Template: using purely content projection means we want anything which is between a button or a
  • Then we can apply directly the class we want to the native element using HostBinding

Re-implement three use cases using augmenting native elements approach

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.

Custom button #1

Custom button #2

Custom button #3

Noticed that in all three examples

  • What we render into DOM will be the button and a without a redundant wrapper.
  • When we are using the selector, we can access to button and a directly thus it eliminates the need to forward additional attributes. The only @Input we accepts so far is buttonTheme

Why does it work?

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.

Benefits

  • Familiar APIs!
  • Accessibility win!
  • Simpler implementation!

Angular Material

material/button/button.ts#L40 ↗️

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.

What else can you do with augmenting native elements?

Angular Material

material/table/table.ts#L41

material/tabs/tab-nav-bar/tab-nav-bar.ts#L307

Should we never replace native components with custom components?

No. We need custom components!

However, when you’re creating a new component, you should ask yourself

Can I augment an existing one instead?

About the author

Trung Vo

Don’t miss anything! Subscribe to our newsletter. Stay up-to-date with the latest trends, tips, meetups, courses and be a part of a thriving community. The job market appreciates community members.

Leave a Reply

Your email address will not be published. Required fields are marked *