Angular 15 directive - trim (input) on blur

In this article, I'll present the process of creating and testing Angular 15 directive. The directive trim on blur will be responsible for removing white spaces after firing the blur event on the current element. The supported elements are input and textarea. Everything will be based on Angular Forms mechanics.

Requirements

Installed libraries: NodeJs 16, NPM 8, npx

The whole project was set up in Linux environment.

Project initialization

Install Angular CLI version 15

npm install -g @angular/cli@15

Create new project

ng new app --minimal=true --defaults=true

A detailed description of the options: angular.io/cli/new

Navigate to the project directory

cd app

Install additional dependencies

npm i jest@29 @types/jest@29 @angular/material@15 jest-preset-angular@13

The how to integrate Jest with Angular can be found here: github.com/thymikee/jest-preset-angular

Create directive

The directive will be created and exported as a separate module.

Generate module:

ng generate module directives/trim-on-blur

Generate directive:

ng generate directive --export=true directives/trim-on-blur/trim-on-blur

Implement directive

Open file app/src/app/directives/trim-on-blur/trim-on-blur.directive.ts

Start with the selector's modification. The directive will only be used with textarea and input elements.

@Directive({
  selector: 'textarea[appTrimOnBlur], input[appTrimOnBlur]',
})

Create a constructor and add two dependencies:

  constructor(
    @Optional() private formControlDir: FormControlDirective,
    @Optional() private formControlName: FormControlName
  ) {}
  • formControlDir: FormControlDirective - used in conjunction with formControl,

  • formControlName: FormControlName - used in conjunction with formControlName,

Only one of two dependencies will be injected into our directive.

Create the onBlur method and decorate it with @HostListener('blur') decorator in order to take advantage of the native blur event.

  @HostListener('blur')
  onBlur(): void {
  }

Implement blur event handler.

  @HostListener('blur')
  onBlur(): void {
    const control = this.formControlDir?.control || this.formControlName?.control;
    if (!control) {
      return;
    }

    const value = control.value;
    if (value == null) {
      return;
    }

    const trimmed = value.trim();
    control.patchValue(trimmed);
  }

The method works as follows:

  • get the control from the dependencies and if it's not present then return
    const control = this.formControlDir?.control || this.formControlName?.control;
    if (!control) {
      return;
    }
  • if the value is null or undefined then return
    const value = control.value;
    if (value == null) {
      return;
    }
  • remove whitespaces from the start and the end of the current value and update the control
    const trimmed = value.trim();
    control.patchValue(trimmed);

Use directive

Modify the file app/src/app/app.component.ts by declaring controls.

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

@Component({
  selector: 'app-root',
  templateUrl: 'app.component.html',
  styles: []
})
export class AppComponent {
  textAreaControl = new FormControl();
  inputControl = new FormControl();

  form = new FormGroup({
    input: new FormControl(),
    textarea: new FormControl(),
  });
}

Create view app/src/app/app.component.html and add form fields.

<div style="display: flex; flex-direction: column; width: 400px">
<mat-form-field>
  <mat-label>Textarea formControl</mat-label>
  <textarea
    [formControl]="textAreaControl"
    appTrimOnBlur
    matInput
  ></textarea>
</mat-form-field>

<mat-form-field>
  <mat-label>Input formControl</mat-label>
  <input [formControl]="inputControl" appTrimOnBlur matInput/>
</mat-form-field>

  <form [formGroup]="form">
    <mat-form-field>
      <mat-label>Textarea formControlName</mat-label>
      <textarea
        formControlName='textarea'
        appTrimOnBlur
        matInput
      ></textarea>
    </mat-form-field>

    <mat-form-field>
      <mat-label>Input formControlName</mat-label>
      <input formControlName='input' appTrimOnBlur matInput/>
    </mat-form-field>
  </form>
</div>

Start the application by running:

npm start

Navigate to localhost:4200 and verify manually if the directive works.

Testing

As part of the tests, three groups of controls will be tested:

  • with formControl

  • with formControlName

  • without any of the above

Create file app/src/app/directives/trim-on-blur/trim-on-blur.directive.spec.ts and add the following code:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatInputHarness } from '@angular/material/input/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { CommonModule } from '@angular/common';
import { TrimOnBlurModule } from './trim-on-blur.module';

describe('TrimOnBlurModule', () => {
  let fixture: ComponentFixture<TestComponent>;
  let loader: HarnessLoader;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
        CommonModule,
        NoopAnimationsModule,
        MatFormFieldModule,
        ReactiveFormsModule,
        MatInputModule,
        TrimOnBlurModule
      ],
      declarations: [TestComponent],
    });

    fixture = TestBed.createComponent(TestComponent);
    loader = TestbedHarnessEnvironment.loader(fixture);
  });

  describe('when component is initialized', () => {

    describe('test formControl', () => {
      describe('test textarea', () => {
        describe('and user sets "test me" value', () => {
          beforeEach(async () => {
            const input = await getTextareaHarness(formControlWrapperAncestor);
            await input.focus();
            await input.setValue('test me');
            await input.blur();
            fixture.detectChanges();
          });

          it('should not change control value', async () => {
            const input = await getTextareaHarness(formControlWrapperAncestor);
            const value = await input.getValue();
            expect(value).toBe('test me');
          });
        });

        describe('and user sets "  test me  " value', () => {
          beforeEach(async () => {
            const input = await getTextareaHarness(formControlWrapperAncestor);
            await input.focus();
            await input.setValue('  test me  ');
            await input.blur();
            fixture.detectChanges();
          });

          it('should update control value', async () => {
            const input = await getTextareaHarness(formControlWrapperAncestor);
            const value = await input.getValue();
            expect(value).toBe('test me');
          });
        });
      });

      describe('test input', () => {
        describe('and user sets "test me" value', () => {
          beforeEach(async () => {
            const input = await getInputHarness(formControlWrapperAncestor);
            await input.focus();
            await input.setValue('test me');
            await input.blur();
            fixture.detectChanges();
          });

          it('should not change control value', async () => {
            const input = await getInputHarness(formControlWrapperAncestor);
            const value = await input.getValue();
            expect(value).toBe('test me');
          });
        });

        describe('and user sets "  test me  " value', () => {
          beforeEach(async () => {
            const input = await getInputHarness(formControlWrapperAncestor);
            await input.focus();
            await input.setValue('  test me  ');
            await input.blur();
            fixture.detectChanges();
          });

          it('should update control value', async () => {
            const input = await getInputHarness(formControlWrapperAncestor);
            const value = await input.getValue();
            expect(value).toBe('test me');
          });
        });
      });
    })

    describe('test formControlName', () => {
      describe('test textarea', () => {
        describe('and user sets "test me" value', () => {
          beforeEach(async () => {
            const input = await getTextareaHarness(formControlNameWrapperAncestor);
            await input.focus();
            await input.setValue('test me');
            await input.blur();
            fixture.detectChanges();
          });

          it('should not change control value', async () => {
            const input = await getTextareaHarness(formControlNameWrapperAncestor);
            const value = await input.getValue();
            expect(value).toBe('test me');
          });
        });

        describe('and user sets "  test me  " value', () => {
          beforeEach(async () => {
            const input = await getTextareaHarness(formControlNameWrapperAncestor);
            await input.focus();
            await input.setValue('  test me  ');
            await input.blur();
            fixture.detectChanges();
          });

          it('should update control value', async () => {
            const input = await getTextareaHarness(formControlNameWrapperAncestor);
            const value = await input.getValue();
            expect(value).toBe('test me');
          });
        });
      });

      describe('test input', () => {
        describe('and user sets "test me" value', () => {
          beforeEach(async () => {
            const input = await getInputHarness(formControlNameWrapperAncestor);
            await input.focus();
            await input.setValue('test me');
            await input.blur();
            fixture.detectChanges();
          });

          it('should not change control value', async () => {
            const input = await getInputHarness(formControlNameWrapperAncestor);
            const value = await input.getValue();
            expect(value).toBe('test me');
          });
        });

        describe('and user sets "  test me  " value', () => {
          beforeEach(async () => {
            const input = await getInputHarness(formControlNameWrapperAncestor);
            await input.focus();
            await input.setValue('  test me  ');
            await input.blur();
            fixture.detectChanges();
          });

          it('should update control value', async () => {
            const input = await getInputHarness(formControlNameWrapperAncestor);
            const value = await input.getValue();
            expect(value).toBe('test me');
          });
        });
      });
    });

    describe('test elements without form control', () => {
      describe('test textarea', () => {
        describe('and user sets "test me" value', () => {
          beforeEach(async () => {
            const input = await getTextareaHarness(noControlWrapperAncestor);
            await input.focus();
            await input.setValue('test me');
            await input.blur();
            fixture.detectChanges();
          });

          it('should not change control value', async () => {
            const input = await getTextareaHarness(noControlWrapperAncestor);
            const value = await input.getValue();
            expect(value).toBe('test me');
          });
        });

        describe('and user sets "  test me  " value', () => {
          beforeEach(async () => {
            const input = await getTextareaHarness(noControlWrapperAncestor);
            await input.focus();
            await input.setValue('  test me  ');
            await input.blur();
            fixture.detectChanges();
          });

          it('should not change control value', async () => {
            const input = await getTextareaHarness(noControlWrapperAncestor);
            const value = await input.getValue();
            expect(value).toBe('  test me  ');
          });
        });
      });

      describe('test input', () => {
        describe('and user sets "test me" value', () => {
          beforeEach(async () => {
            const input = await getInputHarness(noControlWrapperAncestor);
            await input.focus();
            await input.setValue('test me');
            await input.blur();
            fixture.detectChanges();
          });

          it('should not change control value', async () => {
            const input = await getInputHarness(noControlWrapperAncestor);
            const value = await input.getValue();
            expect(value).toBe('test me');
          });
        });

        describe('and user sets "  test me  " value', () => {
          beforeEach(async () => {
            const input = await getInputHarness(noControlWrapperAncestor);
            await input.focus();
            await input.setValue('  test me  ');
            await input.blur();
            fixture.detectChanges();
          });

          it('should not change control value', async () => {
            const input = await getInputHarness(noControlWrapperAncestor);
            const value = await input.getValue();
            expect(value).toBe('  test me  ');
          });
        });
      });
    });


  });

  const getTextareaHarness = (ancestor: string): Promise<MatInputHarness> => {
    return loader.getHarness(
      MatInputHarness.with({
        ancestor,
      })
    );
  };

  const getInputHarness = (ancestor: string): Promise<MatInputHarness> => {
    return loader.getHarness(
      MatInputHarness.with({
        ancestor,
      })
    );
  };
});

const formControlWrapperAncestor = '.form-control-wrapper';
const formControlNameWrapperAncestor = '.form-control-name-wrapper';
const noControlWrapperAncestor = '.no-control-wrapper';

@Component({
  selector: 'app-test',
  template: `
    <div class="form-control-wrapper">
      <mat-form-field>
        <mat-label>Textarea formControl</mat-label>
        <textarea
          [formControl]="textAreaControl"
          appTrimOnBlur
          matInput
        ></textarea>
      </mat-form-field>

      <mat-form-field>
        <mat-label>Input formControl</mat-label>
        <input [formControl]="inputControl" appTrimOnBlur matInput/>
      </mat-form-field>
    </div>

    <div class="form-control-name-wrapper">
      <form [formGroup]="form">
        <mat-form-field>
          <mat-label>Textarea formControlName</mat-label>
          <textarea
            formControlName='textarea'
            appTrimOnBlur
            matInput
          ></textarea>
        </mat-form-field>

        <mat-form-field>
          <mat-label>Input formControlName</mat-label>
          <input formControlName='input' appTrimOnBlur matInput/>
        </mat-form-field>
      </form>
    </div>

    <div class="no-control-wrapper">
      <textarea appTrimOnBlur matInput></textarea>
      <input appTrimOnBlur matInput/>
    </div>
  `,
})
export class TestComponent {
  textAreaControl = new FormControl();
  inputControl = new FormControl();

  form = new FormGroup({
    input: new FormControl(),
    textarea: new FormControl(),
  });
}

The initial beforeEach is responsible for initialization. Remember to import all required modules.

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
        CommonModule,
        NoopAnimationsModule,
        MatFormFieldModule,
        ReactiveFormsModule,
        MatInputModule,
        TrimOnBlurModule
      ],
      declarations: [TestComponent],
    });

    fixture = TestBed.createComponent(TestComponent);
    loader = TestbedHarnessEnvironment.loader(fixture);
  });

The definition of the test component does not require any extra explanation. It's just a simple component with controls that are being tested.

@Component({
  selector: 'app-test',
  template: `
    <div class="form-control-wrapper">
      <mat-form-field>
        <mat-label>Textarea formControl</mat-label>
        <textarea
          [formControl]="textAreaControl"
          appTrimOnBlur
          matInput
        ></textarea>
      </mat-form-field>

      <mat-form-field>
        <mat-label>Input formControl</mat-label>
        <input [formControl]="inputControl" appTrimOnBlur matInput/>
      </mat-form-field>
    </div>

    <div class="form-control-name-wrapper">
      <form [formGroup]="form">
        <mat-form-field>
          <mat-label>Textarea formControlName</mat-label>
          <textarea
            formControlName='textarea'
            appTrimOnBlur
            matInput
          ></textarea>
        </mat-form-field>

        <mat-form-field>
          <mat-label>Input formControlName</mat-label>
          <input formControlName='input' appTrimOnBlur matInput/>
        </mat-form-field>
      </form>
    </div>

    <div class="no-control-wrapper">
      <textarea appTrimOnBlur matInput></textarea>
      <input appTrimOnBlur matInput/>
    </div>
  `,
})
export class TestComponent {
  textAreaControl = new FormControl();
  inputControl = new FormControl();

  form = new FormGroup({
    input: new FormControl(),
    textarea: new FormControl(),
  });
}

To test angular material elements use MatHarness.

  const getTextareaHarness = (ancestor: string): Promise<MatInputHarness> => {
    return loader.getHarness(
      MatInputHarness.with({
        ancestor,
      })
    );
  };

  const getInputHarness = (ancestor: string): Promise<MatInputHarness> => {
    return loader.getHarness(
      MatInputHarness.with({
        ancestor,
      })
    );
  };

Run the tests:

npx jest

Source code

gitlab.com/barcioch-blog-examples/20230314-..