Create a Stencil Form Input Component for Angular and Vue.js

14 Oct 2017

Lately I have been exploring the idea of Web Components which allows the component-based front end development approach to not rely on any specific framework. This concept can greatly increase the reusability of UI components and reduce the hassle of code migration.

One notable existing tool to build Web Components is Polymer. Recently, Ionic team has released something which could be even better to build Web Components - Stencil. As a compiler, Stencil generates Web Components in the compile-time rather than run-time. So here, in order to test out the function of stencil, I created a stencil form input component and apply it in both Angular and Vue.

The post has three main parts:

Part One - How to create a stencil form input component

Project Setup

Creating a stencil component is fairly straightforward by using the ionic stencil component starter project.

The project structure is as the following:

- src
- www
stencil.config.js
package.json
tsconfig.json

stencil.config.js configures which components are included and grouped in the build bundle. So we just need to add form-input-base here to the bundles property on the config.

exports.config = {
  namespace: 'forminputbase',
  generateDistribution: true,
  bundles: [
    { components: ['form-input-base'] }
  ]
};

exports.devServer = {
  root: 'www',
  watchGlob: '**/**'
}

Basic Stencil Component

In src/components/form-input-base/form-input-base.tsx, we initialise a basic stencil component with the custom element selector tag form-input-base.

import { Component, Element, Event, EventEmitter, Prop, PropDidChange } from '@stencil/core';

@Component({
  tag: 'form-input-base',
  styleUrl: 'form-input-base.scss'
})
export class FormInputBase {

  @Element() el: HTMLElement;
  @Prop() type: string = 'text';
  @Prop() label: string;

  render() {
	  return (
	    <div>
	      <label>{ this.label }:
	        <div>
	          <input></input>
	        </div>
	      </label>
	    </div>
	  )
  }
}

Add Value Property and ValueChange Event Emitter

An customised input component has two main parts:

For this, all we need to do is to add value property with prop decorator and set mutable to true. Because by default for stencil, once a property is set, the component cannot update it internally. However, we will need to update the value property whenever a user types something in the form input.

Moreover, as we use an native input element inside the customized input component, we need to update the value of native input element if the value property changes. So we use propDidChange decorator to fire a method to achieve it.

Everytime the value of native input element is updated, we call a event handler method which propagates the change to value property and then emit the valueChange event to the parent component.

So, the complete data flow of the stencil input component shall be like this:

So now we have completed the essential part of the stencil form input component. Next, we will see how to apply it into other front end frameworks.

import { Component, Element, Event, EventEmitter, Prop, PropDidChange } from '@stencil/core';

@Component({
  tag: 'form-input-base',
  styleUrl: 'form-input-base.scss'
})
export class FormInputBase {

  @Element() el: HTMLElement;
  @Prop() type: string = 'text';
  @Prop() label: string;
  @Prop({ mutable: true }) value: string;
  @Event() valueChange: EventEmitter;

  // propagate value change from model to view
  @PropDidChange('value')
  valueChanged() {
  	const inputEl = this.el.querySelector('input');
  	// only update if model and view differ 
    if (inputEl.value !== this.value) {
      inputEl.value = this.value;
    }
  }

  // propagate value change from view to model
  inputChanged(ev: any) {
    let val = ev.target && ev.target.value;
    this.value = val;
    this.valueChange.emit(this.value);
  }

  render() {
      return (
        <div class={this.theme}>
          <label>{ this.label }:
            <div>
              <input value={this.value} onInput={this.inputChanged.bind(this)}></input>
            </div>
          </label>
        </div>
      )
  }
}

Part Two - How to use customised stencil input component in Vue.js

First, we add vue.js script to the previous project’s src/index.html to use it.

<script src="https://unpkg.com/vue"></script>

Create a vue component to wrap the stencil input component

Corresponding to the data flow chart shown earlier, the form-input-wrapper has a value property which is binded to value prop of form-input-base. Also, when form-input-base emits an valueChange event, it triggers onValueChange method on form-input-wrapper.

Here, inside onValueChange method, we intentionally emit an input. This will allow the component to be able to have two-way data binding from outside by using v-model. See more about this here.

  Vue.component('form-input-wrapper', {
      props: ['field', 'value'],
      template: '<form-input-base :label="field.label" :value="value" @valueChange="onValueChange"></form-input-base>',
      methods: {
          onValueChange: function(ev) {
              this.$emit('input', ev.target.value)
          }
      }
  })

Ignore stencil component tag

Because the stencil form-input-base component is not a vue component, we need vue to ignore its tag in order to not get compile error.

Vue.config.ignoredElements = [
  'form-input-base'
]

Create a form

Next, we are going to use an array of form-input-wrapper to create a form. Before that, we first create some dummy data to define the label, field type and initial value for the fields.

var app7 = new Vue({
  el: '#app-1',
  data: {
	fields: [
	  {label: 'Name', type: 'text', value: 'Whirlwind'},
	  {label: 'Street', type: 'text', value: '123 Main'},
	  {label: 'City', type: 'text', value: 'Anywhere'},
	  {label: 'State', type: 'select', value: 'CA'},
	  {label: 'Zip', type: 'number', value: '94801'}
	]
  }
})

Then we iterate through the fields data and bind each field value to the value property of form-input-wrapper. This creates a form with 5 input fields with initial value.

  <div id="app-1">
     <ol> 
       <form-input-wrapper 
        v-for="item in fields"
        v-bind:field="item"
        v-model="item.value"
        v-bind:key="item.id">
      </form-input-wrapper> 
     </ol> 
    <div></div>
  </div>

show form value

Whenever you modify the value in an input field, its value gets updated correspondingly. We can showcase this by creating a computed property formValue which is a object whose key is each field’s label and whose value is each field’ value.

var app7 = new Vue({
  el: '#app-1',
  data: {
	fields: [
	  {label: 'Name', type: 'text', value: 'Whirlwind'},
	  {label: 'Street', type: 'text', value: '123 Main'},
	  {label: 'City', type: 'text', value: 'Anywhere'},
	  {label: 'State', type: 'select', value: 'CA'},
	  {label: 'Zip', type: 'number', value: '94801'}
	]
  },
  computed: {
      formValue: function() {
          return this.fields.reduce((accu, f) => {
              accu[f.label] = f.value
              return accu
          }, {})
      }
  }
})

Now we can see that the form value gets changed whenever the input field value is modified.

Part Three - How to use customised stencil input component in Angular

Project setup

We can use angular-cli to create a new angular project and then add the stencil forminputbase.js script to the src/index.html.

<script src="/build/forminputbase.js"></script>

Also, we will just copy directly the whole build folder of stencil forminputbase component to the angular project dist folder so that we can quickly use it instead of going through the extra process of publishing and installing a stencil component.

Create a custom form control with stencil input component

We can not use the angular formControl or ngModel directive on a stencil input component but we can create an angular custom input to wrap a stencil component and then use the formControl directive on it. Then, the data can flow two-way between stencil input component and angular form.

First, we create the template of app-custom-input. This is fairly straightforward.

<div class="form-group">
  <label class="center-block">: 
      <form-input-base [value]="value" (valueChange)="onValueChange($event)"></form-input-base> 
  </label>
</div>

Then, we need to implement controlValueAccessor in app-custom-input to propagate its value change to the formControl to which it is binded. There are many online resources which cover how to use controlValueAccessor so I will skip this part and put the full code here. The core piece here is once onValueChange is called, it assigns the payload to value and triggers value setter. The value setter not only updates the _value model but also calls _onChangeCallback to update the form control value.

import { Component, forwardRef, Input, ChangeDetectorRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR  } from '@angular/forms';

export const CUSTOM_INPUT_VALUE_ACCESSOR: any = {
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => CustomInputComponent),
    multi: true
};

const noop = () => { };

@Component({
  selector: 'app-custom-input',
  templateUrl: './custom-input.component.html',
  styleUrls: ['./custom-input.component.css'],
  providers: [CUSTOM_INPUT_VALUE_ACCESSOR]
})
export class CustomInputComponent implements ControlValueAccessor {

  @Input() label: string;
  @Input() type: string;

  /** Callback registered via registerOnTouched (ControlValueAccessor) */
  protected _onTouchedCallback: () => void = noop;
  /** Callback registered via registerOnChange (ControlValueAccessor) */
  protected _onChangeCallback: (_: any) => void = noop;

  protected _value: any = '';

  constructor(private cd: ChangeDetectorRef) { }

  get value(): any {
      return this._value;
  }

  /** value setter */
  @Input() set value(v: any) {
    if (v !== this._value) {
        this._value = v;
        /** _OnChangeCallback will register value change into the formControl */
        this._onChangeCallback(v);
        this.cd.markForCheck();
    }
  }

  writeValue(value: any): void {
      this.value = value;
  }

  registerOnChange(fn: (_: any) => void): void {
      this._onChangeCallback = fn;
  }
  registerOnTouched(fn: () => any): void {
      this._onTouchedCallback = fn;
  }

  onValueChange(ev: any) {
    this.value = ev.target.value;
  }
}

Use custom form control to create a form

Finally, we can start using the app-custom-input in our angular app. Like what we did in Vue, we are going to create some dummy fields’ data including label, field type, and field control name.

Then, we will use angular FormBuilder to group several field controls to create a formGroup called heroForm.

import { Component, ChangeDetectorRef } from '@angular/core';
import { FormGroup, FormBuilder, Validators, FormControl } from '@angular/forms';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'app';

  heroForm: FormGroup;

  fieldControls: any = {};

  fields = [
    {label: 'name', type: 'text', controlName: 'name'},
    {label: 'Street', type: 'text', controlName: 'street'},
    {label: 'City', type: 'text', controlName: 'city'},
    {label: 'State', type: 'select', controlName: 'state'},
    {label: 'Zip', type: 'number', controlName: 'zip'}
  ];

  constructor(private fb: FormBuilder, private cd: ChangeDetectorRef) {
    this.createForm();
  }

  createForm() {
    this.fields.forEach((f) => {
      this.fieldControls[f.controlName] = new FormControl();
    });
    this.heroForm = this.fb.group(this.fieldControls);
    this.cd.markForCheck();
  }

  getControl(controlName: string) {
    return this.fieldControls[controlName];
  }
}

In the template, we create a form by using an array of app-custom-input. We simply use formGroup directive on the form and bind it to heroForm we created earlier. Also, for each app-custom-input, it is binded to its corresponding formControl.

<form [formGroup]="heroForm" novalidate>
    <ng-template ngFor let-field [ngForOf]="fields">
      <app-custom-input [label]="field.label" [type]="field.type" [formControl]="getControl(field.controlName)"></app-custom-input>
    </ng-template>
</form>

Summary

So, as shown in the above experimentation, stencil allows us to create web components with extremely simple syntax. It might require a bit different configuration to integrate stencil web components, but overall they are framework agnostic. This could be a fantastic tool to create an UI library of reusable components to be used in any kind of front-end framework.