typescript 60 lines · 8 steps

Building a phone-mask ControlValueAccessor in Angular

A directive that formats phone input on the fly while feeding clean digits back to Angular forms.

Explained by highlit
1import { Directive, ElementRef, HostListener, Renderer2, forwardRef } from '@angular/core';
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
3 
4@Directive({
5 selector: '[appPhoneMask]',
6 standalone: true,
7 providers: [
8 {
9 provide: NG_VALUE_ACCESSOR,
10 useExisting: forwardRef(() => PhoneMaskDirective),
11 multi: true,
12 },
13 ],
14})
15export class PhoneMaskDirective implements ControlValueAccessor {
16 private onChange: (value: string) => void = () => {};
17 private onTouched: () => void = () => {};
18 
19 constructor(private el: ElementRef<HTMLInputElement>, private renderer: Renderer2) {}
20 
21 @HostListener('input', ['$event.target.value'])
22 handleInput(value: string): void {
23 const formatted = this.format(value);
24 this.renderer.setProperty(this.el.nativeElement, 'value', formatted);
25 this.onChange(this.digits(formatted));
26 }
27 
28 @HostListener('blur')
29 handleBlur(): void {
30 this.onTouched();
31 }
32 
33 writeValue(value: string | null): void {
34 this.renderer.setProperty(this.el.nativeElement, 'value', this.format(value ?? ''));
35 }
36 
37 registerOnChange(fn: (value: string) => void): void {
38 this.onChange = fn;
39 }
40 
41 registerOnTouched(fn: () => void): void {
42 this.onTouched = fn;
43 }
44 
45 setDisabledState(isDisabled: boolean): void {
46 this.renderer.setProperty(this.el.nativeElement, 'disabled', isDisabled);
47 }
48 
49 private digits(value: string): string {
50 return value.replace(/\D/g, '').slice(0, 10);
51 }
52 
53 private format(value: string): string {
54 const d = this.digits(value);
55 if (d.length === 0) return '';
56 if (d.length < 4) return `(${d}`;
57 if (d.length < 7) return `(${d.slice(0, 3)}) ${d.slice(3)}`;
58 return `(${d.slice(0, 3)}) ${d.slice(3, 6)}-${d.slice(6)}`;
59 }
60}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Implementing ControlValueAccessor lets a directive act as a first-class form control that Angular can read and write.
  2. 2Keeping the displayed value formatted while emitting raw digits separates presentation from the model's stored value.
  3. 3Renderer2 updates the DOM safely without touching nativeElement directly, keeping the directive platform-agnostic.

Related explainers

typescript
import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';
import localeFrExtra from '@angular/common/locales/extra/fr';
import localeDe from '@angular/common/locales/de';

Locale-aware bootstrapping in Angular

i18n localization dependency-injection
Intermediate 8 steps
typescript
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import * as Joi from 'joi';
 

Validating env config at boot in NestJS

configuration schema-validation environment-variables
Intermediate 8 steps
typescript
import { Inject, Injectable, Logger } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { InjectRepository } from '@nestjs/typeorm';

A cache-aside country lookup in NestJS

cache-aside dependency-injection batch-lookup
Intermediate 8 steps
typescript
import { Injectable, effect, signal, computed } from '@angular/core';
 
interface Preferences {
  theme: 'light' | 'dark';

A signal-based preferences store in Angular

signals state-management persistence
Intermediate 7 steps
typescript
import { useEffect, useState } from "react";
 
interface Section {
  id: string;

Building a scroll-spy hook in React

custom-hooks intersectionobserver dom-observation
Intermediate 8 steps
typescript
import { Injectable, Scope, Inject, NotFoundException } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import { DataSource } from 'typeorm';

Per-tenant database connections in NestJS

multi-tenancy connection-pooling dependency-injection
Advanced 8 steps

Share this explainer

Here's the card — post it anywhere.

Building a phone-mask ControlValueAccessor in Angular — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code