Master Web Component Forms Integration – with Lit and Angular

When a company has cross-framework teams, it is a good choice to use Web Components to build a unified and framework-independent component library. However, some pitfalls are to consider when integrating these components into web forms. Therefore, for a better understanding, we will look at two possible approaches and try to integrate them into an Angular form as an example.

Notice: All code samples are available on Github!

In this article:

sl_300x300
Sascha Lehmann is Developer at Thinktecture. He focusses on web-based frontends with Angular and User Experience improvement.

This article assumes you to have some basic understanding of Web Components and how they work. If you are new to Web Components, you might look at some intro articles and webinars of my colleagues Manuel Rauber and Patrick Jahr first.

Version information: The data and code in this article are based on Angular 13+

Introduction

As passionate Angular developers, we are tempted to solve every upcoming problem with our favorite framework. But not every company out there has homogeneous frontend developer teams. Many of them have mixed teams that use different frameworks. Accordingly, you want to develop a single component library built upon your corporate design guidelines that can be used and integrated with every framework your developers use or may use in the future. 

Due to their framework independence and lightweight nature, Web Components are the perfect solution to this problem. But just the previously mentioned integration of Web Components into the individual frameworks can prove to be very difficult when designing the components.

We’re now getting our hands dirty and will develop a Web Component ourselves. We will look at two possible approaches and then try to integrate the component into Angular as an example.

So let’s find out which approach will perform better and first look at the component we will build.

Our component

The component will be a form input field. It has a left-aligned label, the input field content, and, as an addition, the possibility to add any suffix to it, which will add a little spice to our development in the last run.

To build Web Components, we have a lot of different possibilities. We may create them with plain JavaScript or use some framework. Each way, in turn, has its pros and cons, but that could be the topic of another article.
For today, we will use the Lit library (formerly known as Lit-Element) because, on the one hand, it hides away most of the boilerplate code to register and add custom elements to the DOM. And on the other hand, we get additional tooling for a much cleaner developer experience.

Now that we know the goal let’s start with our first approach!

Approach 1: Hide everything inside the component

We create a new component, place the label and input elements inside, add some styling and expose the corresponding properties to the outside world.  To make it possible to add any suffix to the input content, we use the slot Element, which acts similar to ng-content and reserves space for any content projected in our component.

				
					/** tt-input.ts **/

import { ... }

@customElement('tt-input')
export class TtInput extends LitElement {
  @property({ type: String }) label = 'Label text:';

  @property({ type: String }) id = 'input';
  
  @property() value: string = '';

  static styles = css`
    ...
  `;

  render() {
    return html` 
      <label for=${this.id}>${this.label}</label>
      <div class="input-box">
	    <input id=${this.id} type="text" />
		<slot></slot>
      </div>
   `;
  }
}

				
			

So far, nothing special to see here. At first glance, the value property may not make sense, but bear with me; we’ll need it soon. Let’s jump right into our example Angular application and use our freshly built web component in a reactive form.

First integration

At first, we go into the app.module.ts and import our web component package by adding an import statement at the top. The import reference is just the name property we defined in the package.json of our web component workspace. In this case, @tt/input.

				
					/** app.module.ts **/

import { ... }
import '@tt/input';
import {CUSTOM_ELEMENTS_SCHEMA, NgModule} from '@angular/core';

@NgModule({
  declarations: [
    AppComponent,
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    ReactiveFormsModule
  ],
  providers: [],
  bootstrap: [AppComponent],
  schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule { }
				
			

Last but not least, we have to tell angular to accept custom elements. By default, Angular only takes custom elements declared within the Angular application in the form of Angular components. To tell Angular to accept external components, we need to add the schemas property to the module and fill it with the CUSTOM_ELEMENTS_SCHEMA. For simplicity reasons, we apply it application-wide at the level of the app.module. Keep in mind that this approach is not recommended for real-world projects!
Instead, import and use external custom elements in a separate module and apply the CUSTOM_ELEMENTS_SCHEMA just for that single module.

Now we set up a reactive angular form, include our web component, start our application and see … nothing.

				
					/** app.component.ts **/

<form [formGroup]="form">
	<tt-input label="Name" formControlName="name"></tt-input>
    <tt-input label="Last Name" formControlName="lastName"></tt-input>
    <tt-input label="Age" formControlName="age"><span>years</span></tt-input>
</form>

<span>
  {{form.value | json}}
</span>
				
			

Although the underlying reactive form contains the correct default values. Nothing gets displayed in our component. And that isn’t surprising at all. Although we have tagged the element with formControlName, Angular doesn’t know how to interact with it. This is just the same problem we have when using other angular components like regular form components. In these cases, we need to implement the ControlValueAccessor to make the component known to Angular.

Adding a ControlValueAccessor

Because we are a Web Component, we can’t directly implement the interface on a component level. In this case, we help ourselves with a directive whose selector exactly matches the HTML tag of our Web Component. This will instantiate the directive whenever our component is used, connecting the Web Component and form control.

				
					/** tt-input.directive.ts **/

const TT_HIDDEN_INPUT_VALUE_ACCESSOR: Provider = {
  provide: NG_VALUE_ACCESSOR,
  useExisting: forwardRef(() => TtInputDirective),
  multi: true,
};

@Directive({
  selector: 'tt-input',
  providers: [TT_HIDDEN_INPUT_VALUE_ACCESSOR]
})
export class TtInputDirective implements ControlValueAccessor
{
  val = "";
  onChange: any = () => {};
  onTouch: any = () => {};

  set value(value: string){
    if(!value) return;
    this.val = value;
    this.elRef.nativeElement.value = this.value;

    this.onChange(value);
    this.onTouch(value);
  }

  constructor(private elRef : ElementRef) {}

  registerOnChange(fn: any): void { ... }

  registerOnTouched(fn: any): void { ... }

  writeValue(value: string): void {
    this.val = value;
    this.elRef.nativeElement.value = value;
  }
}

				
			

Let’s rerun our demo application and see what has changed. Now we can see that Angular detects the input as a form control and sets the initial value. But our web component doesn’t display the value, and neither the form value changes when we type something in our input field.

We need additional tweaking of our web component and the just created directive to make this work.

Now the time has come that we need the value property mentioned initially, which will make the input value accessible to the outside world. Then we need to set the inner input value to our value after the first render and emit a custom event every time our input value changes. In the Angular directive, we need to listen to the event.

				
					/** tt-input.ts **/

@customElement('tt-input')
export class TtInput extends LitElement {
  @property({ type: String }) label = 'Input within Shadow DOM:';

  @property() value: string = '';

  @query('input') input!: HTMLInputElement;

  /* ... */

  render() {
    return html`
      <label for=${this.id}>${this.label}</label>
      <div class="input-box">
        <input type="text" @input="${this.handleInput}" />
        <slot></slot>
      </div>
    `;
  }

  protected firstUpdated(changedProperties: PropertyValues) {
    super.firstUpdated(changedProperties);
    this.input.value = this.value;
  }

  private handleInput() {
    this.value = this.input.value;
    this.dispatchValueChange();
  }

  private dispatchValueChange() {
    const options = {
      detail: this.value,
      bubbles: true,
      composed: true,
    };
    this.dispatchEvent(new CustomEvent('change', options));
  }
}
				
			
				
					/*tt-input.directive.ts*/
@HostListener("valueChange", ['$event.detail'])
onHostChange(value: string){
  this.value = value;
}

				
			

With this code in place, we can see that everything works as smoothly as expected, and we can use our web component in Angular forms. But we must admit that this approach is tedious and needs a lot of effort and  Angular code to make Web Components and Angular work together. Imagine you have to do this for EVERY Web Component you want to use in any form. Yikes!

A first resume

Before moving on, take a step back and look at what we have done. We wrapped our native HTML elements in a Web Component and sent them to the Shadow DOM. Therefore they are unreachable for Angular (or any other framework). Then we had to add a value property to the component to pipe the underlying input value through to the Light DOM. Also, keep in mind that you have to pipe through any other properties you want to apply to the inner inpute.g.,  disablereadonlyetc. This will rapidly grow the Web Components API.

It doesn’t feel right, does it? In addition, we violated some core principles we should have kept in mind on our way. We broke the Single Responsibility Principle by adding duplicate input states and event handling. Furthermore, by hiding the native elements inside our component, we violated the Inversion of Control principle because we restricted the external control of what can be displayed inside to a very minimum.

So is there a better approach? Yes, there is!

Approach 2: Using the power of slotting / content projection

With our second approach, we leave the native form elements in the Light DOM and project them into the Web Component. By letting them live in the Light DOM, we have complete control over what can be displayed inside the component and solve our Inversion of Control problem. We also can get rid of all the ControlValueAccessor shenanigans because we now can directly access and integrate the native elements in reactive forms, as we are used to.

				
					/** app.component.ts **/

<tt-input>
  <label for="age">Age</label>
  <input type="number" id="age" formControlName="age"/>
  <span>years</span>
</tt-input>
				
			

The tt-input.directive.ts also served its purpose so that we can get rid of it. When we look at our Web Component’s code now, we can see that we can remove all the unnecessary properties and event handling functions as well.

Hidden rearrangements behind the scenes

Because we want the suffix inside our input field, we need to style the suffix slot and input together. To make this possible, we need some surrounding containers. But we can’t assume that the developer who uses our component knows about this fact and has already added such a boxing element inside the projected template. So we have to take action and rearrange the projected elements the way we want them to be. In this case, we need to extract the label because we want it to be outside the boxed element.

With @queryAssignedElements we can grab the projected label and assign it to the right slot whenever the slotted content changes by listening to the slotchange event. In the case of styling, we can apply the same styles for the label or input elements before, just by scoping them to the slotted contend with the ::slotted() pseudo-selector.

				
					/** tt-input.ts **/

@customElement('tt-input')
export class TtInput extends LitElement {

  @queryAssignedElements({ selector: 'label' })
  slottedLabel!: ReadonlyArray<HTMLLabelElement | undefined>;

  static styles = css`
 
    ::slotted(label) {
      text-align: right;
    }
    
     ::slotted(span) {
      font-size: 0.8rem;
      order: 3;
    }

    ::slotted(input:focus-visible) {
      border: none;
      background-color: transparent;
      outline: none;
    }
     /* ... */
  `;

  render() {
    return html`
      <slot name="label"></slot>
      <div class="input-box">
        <slot @slotchange=${this.handleSlotChange}></slot>
      </div>
    `;
  }

  private handleSlotChange(): void {
    const [label] = this.slottedLabel;
    if (label) {
      label.setAttribute('slot', 'label');
    }
  }
}

				
			

Summary

The first approach hides the native elements in the Shadow DOM, forcing us to pipe through all the properties we wanted to set from the Light DOM, duplicate the inputs value property, write interoperability code in Angular, and violate our underlying principles of component design. Also, note that we covered just a tiny fraction because we did not look into the additional problems with this approach regarding keyboard navigation or accessibility. Under given circumstances, this could be a valid choice. But for the following reasons, we should favor the second approach as much as we can.

The second approach needs much less code, plays well with the principles of component design, and lets us take advantage of the built-in features of native elements. Allowing the native input elements to live in the Light DOM also directly removed the just mentioned accessibility problems because native elements have built-in keyboard-navigation support. Possible accessibility roles can also be added to the elements.

In conclusion: When it comes to the fact that we want to use our Web Components like native form elements, it is best to use the native elements because we don’t want to reimplement what the browser already gives us. And this also applies when it comes to framework integration because every framework out there knows how to handle and interact with native elements.

Free
Newsletter

Current articles, screencasts and interviews by our experts

Don’t miss any content on Angular, .NET Core, Blazor, Azure, and Kubernetes and sign up for our free monthly dev newsletter.

EN Newsletter Anmeldung (#7)
Related Articles
AI
sg
One of the more pragmatic ways to get going on the current AI hype, and to get some value out of it, is by leveraging semantic search. This is, in itself, a relatively simple concept: You have a bunch of documents and want to find the correct one based on a given query. The semantic part now allows you to find the correct document based on the meaning of its contents, in contrast to simply finding words or parts of words in it like we usually do with lexical search. In our last projects, we gathered some experience with search bots, and with this article, I'd love to share our insights with you.
17.05.2024
Angular
sl_300x300
If you previously wanted to integrate view transitions into your Angular application, this was only possible in a very cumbersome way that needed a lot of detailed knowledge about Angular internals. Now, Angular 17 introduced a feature to integrate the View Transition API with the router. In this two-part series, we will look at how to leverage the feature for route transitions and how we could use it for single-page animations.
15.04.2024
.NET
kp_300x300
.NET 8 brings Native AOT to ASP.NET Core, but many frameworks and libraries rely on unbound reflection internally and thus cannot support this scenario yet. This is true for ORMs, too: EF Core and Dapper will only bring full support for Native AOT in later releases. In this post, we will implement a database access layer with Sessions using the Humble Object pattern to get a similar developer experience. We will use Npgsql as a plain ADO.NET provider targeting PostgreSQL.
15.11.2023