Input Porcentagem - Angular

Trabalhar com <input> é uma das primeiras lições que aprendemos quando trabalhamos no frontend. Neste artigo é apresentado como podemos criar um componente em Angular para o usuário digitar um valor percentual sem necessariamente ter um <input>.

Input Porcentagem - Angular

Construção do Componente

Vamos tomar como base o código abaixo e evoluí-lo aos poucos:

import { Component } from '@angular/core';

@Component({
  selector: 'app-input-porcentagem',
  template: `
            <div class="label">Label</div>
            <div class="input">{{valor}}</div> 
  `,
  styleUrls: ['./input-porcentagem.component.css']
})
export class InputPorcentagemComponent {
  valor: number = 0;
}
Renderização do app-input-porcentagem
Renderização do app-input-porcentagem

Capturando a tecla pressionada com @HostListener

Um <div> ou um <app-input-porcentagem> por padrão não recebe foco, ou seja, se você clicar em um destes elementos na tela nada vai acontecer. Para permitir que um <div> ganhe o foco podemos adicionar um atributo tabindex conforme o exemplo a seguir:

<div tabindex="0"></div>

Agora que o <div> pode receber o foco, podemos utilizar o evento onkeydown para capturar os eventos do teclado quando este <div> tiver foco:

Animação mostrando que um div pode receber o foco e capturar as teclas pressionadas
Exemplo de um div que pode receber foco e capturar as teclas pressionadas

Seguindo essa mesma lógica aplicada em cima de um <div>, podemos aplicá-la em cima do <app-input-porcentagem>. Então o que precisamos é de um tabindex e um onkeydown no <app-input-porcentagem>. Para fazer isto vamos utilizar o @HostBinding e @HostListener respectivamente:

import { Component, HostBinding, HostListener } from '@angular/core';

@Component({
  selector: 'app-input-porcentagem',
  template: `
            <div class="label">Label</div>
            <div class="input">{{valor}}</div> 
  `,
  styleUrls: ['./input-porcentagem.component.css']
})
export class InputPorcentagemComponent {
  valor: number = 0; 

  @HostBinding('attr.tabindex') tabindex = 0;

  @HostListener('keydown', ['$event.key'])
  hostKeydown(key: string) {
    console.log('Tecla pressionada', key);
  }
}

Agora o componente pode receber o foco e também sabemos o que está sendo digitado enquanto o componente tem o foco:

Input recebendo foco e mostrando no console as teclas pressionadas
Ajuste do foco do componente e também o evento keydown

Desenvolvendo a lógica

Agora que temos um componente que pode receber o foco e já sabemos quais teclas são pressionadas enquanto o componente tem o foco, vamos desenvolver um pouco mais a lógica.

A ideia é ir capturando as teclas pressionadas, concatenar em uma string, converter a string para número e dividir pela quantidade de casas decimais.

Veja na tabela abaixo a lógica descrita acima, onde temos uma sequência de  números digitados pelo usuário considerando 2 casas decimais:

Tecla pressionada na sequência Valor String Valor Decimal
1 1 0.01
0 10 0.10
2 102 1.02

Ou se tivermos 4 casas decimais:

Tecla pressionada na sequência Valor String Valor Decimal
1 1 0.0001
2 12 0.0012
3 123 0.0123
4 1234 0.1234
5 12345 1.2345

Ou se não tivermos casas decimais:

Tecla pressionada na sequência Valor String Valor Decimal
1 1 1
2 12 12
3 123 123
4 1234 1234
5 12345 2345

Trazendo a lógica acima para o código temos o seguinte:

import { Component, HostBinding, HostListener } from '@angular/core';

@Component({
  selector: 'app-input-porcentagem',
  template: `
            <div class="label">Label</div>
            <div class="input">{{valor}}</div> 
  `,
  styleUrls: ['./input-porcentagem.component.css']
})
export class InputPorcentagemComponent { 
  numeroCasasDecimais: number = 2;
  valor: number = 0;  
  valorTexto: string = '';
 
  @HostBinding('attr.tabindex') tabindex = 0;  
  
  @HostListener('keydown', ['$event.key'])  
  hostKeydown(key: string) { 

    this.valorTexto += key;  

    this.valor = +(this.valorTexto) /
                 (Math.pow(10, this.numeroCasasDecimais));

    console.log(this.valorTexto, '->', this.valor);
  }
}
Exemplo do componente em funcionamento com sua lógica parcialmente desenvolvida
Exemplo da lógica abordada mais acima

Formatando o valor com PercentPipe

Para mostrar o valor do componente em formato de porcentagem vamos utilizar o PercentPipe do próprio Angular.

<div>{{valor | percent}}</div>
<!-- 0.97 -> 97% -->

<div>{{valor | percent : '1.2-2'}}</div>
<!-- 0.97 -> 97,00% -->
Exemplo de como utilizar o PercentPipe em um template

Observação: para utilizar o valor | percent no template, é necessário importar o módulo CommonModule.

Com isto podemos efetuar o seguinte ajuste no template:

<!-- de -->
<div class="input">{{valor}}</div> 

<!-- para -->
<div class="input">{{valor | percent : '1.2-2'}}</div> 
Ajuste que será efetuado no template

Também devemos lembrar que precisaremos dividir o valor do componente por 100 para obter o valor percentual, lembre-se de que 1 equivale 100%, logo:

import { Component, HostBinding, HostListener } from '@angular/core';

@Component({
  selector: 'app-input-porcentagem',
  template: `
            <div class="label">Label</div>
            <div class="input">{{valor | percent : digitsInfo}}</div> 
  `,
  styleUrls: ['./input-porcentagem.component.css']
})
export class InputPorcentagemComponent {
  numeroCasasDecimais: number = 2;
  digitsInfo: string = `1.${this.numeroCasasDecimais}-${this.numeroCasasDecimais}`;
  valor: number = 0;  
  valorTexto: string = '';
 
  @HostBinding('attr.tabindex') tabindex = 0;  
  
  @HostListener('keydown', ['$event.key'])  
  hostKeydown(key: string) { 

    this.valorTexto += key;  

    this.valor = +(this.valorTexto) /
                 (100 * Math.pow(10, this.numeroCasasDecimais));

    console.log(this.valorTexto, '->', this.valor);
  }
}
Utilização do PercentPipe para formatar o valor do componente em porcentagem
Utilização do PercentPipe para formatar o valor do componente

Melhorando a experiência

Para melhorar a experiência do usuário precisamos tratar os seguintes casos:

  1. Bloquear caracteres que não sejam números
  2. Tratar o evento do backspace
  3. Tratar o caso onde o usuário fica digitando zero várias vezes
  4. Tratar o evento onChanges
import { Component, HostBinding, HostListener, Input, OnChanges, SimpleChanges } from '@angular/core';

@Component({
  selector: 'app-input-porcentagem',
  template: `
            <div class="label">{{label}}</div>
            <div class="input">{{valor | percent : digitsInfo}}</div> 
  `,
  styleUrls: ['./input-porcentagem.component.css']
})
export class InputPorcentagemComponent implements OnChanges {

  @Input() label: string = 'Label';
  @Input() numeroCasasDecimais: number = 2;
  @HostBinding('attr.tabindex') @Input() tabindex: number|string = 0;

  digitsInfo: string = `1.${this.numeroCasasDecimais}-${this.numeroCasasDecimais}`;
  valor: number = 0;  
  valorTexto: string = '';
   
  ngOnChanges(changes: SimpleChanges): void {
    if (changes.numeroCasasDecimais) {
      this.digitsInfo =
        `1.${this.numeroCasasDecimais}-${this.numeroCasasDecimais}`;
    }
    
    if (changes.tabindex) {
      this.tabindex = +this.tabindex;
    }
  }

  @HostListener('keydown', ['$event.key'])  
  hostKeydown(key: string) { 
  
    const teclasAceitas = /^(\d)|(Backspace)$/;
    if (!teclasAceitas.test(key)) {
      return;
    }
    
    if (key === 'Backspace') {
      key = '';
      this.valorTexto = this.valorTexto
        .substr(0, this.valorTexto.length - 1);
    } else if (!/\d/.test(key)) {
      return; 
    }

    this.valorTexto += key; 

    if (+this.valorTexto === 0) {
      this.valorTexto = '';
    }    

    this.valor = +(this.valorTexto) / 
                 (100 * Math.pow(10, this.numeroCasasDecimais));

    console.log(this.valorTexto, '->', this.valor);
  }
}
Exemplo de uso do componente
Exemplo de uso do componente

Considerações

Apesar do artigo parecer ser grande e cheio de detalhes, com o tempo você irá se acostumar com todos os recursos apresentados neste artigo.

Essa mesma ideia de não se utilizar um <input> para ter uma entrada de dados pode se aplicar por exemplo a um slider, organizador drag & drop, etc.

Podemos destacar como principais recursos abordados neste artigo:  @HostListener, @HostBinding e PercentPipe

Em um próximo artigo vamos abordar a integração deste componente com o ReactiveForms do Angular.

Exemplo do código completo:

input-porcentagem-angular - StackBlitz
Exemplo de como construir um componente para receber um valor percentual.

Posts relacionados a este assunto:

Formatar data - DatePipe - Angular
Formatação de data com certeza é uma das tarefas mais comuns para quem trabalha com frontend. Veja como fazer isto de uma forma bem simples com o DatePipe.
Formatar valor (moeda) em Angular 2+ utilizando CurrencyPipe
Como formatar valores (moeda) utilizando o CurrencyPipe do Angular.
Como criar um pipe para formatar CPF no Angular 2+ - CpfPipe
Com a utilização de um pipe podemos transformar e/ou formatar informações direto no template. Tarefas comuns como formatar moeda, data, cpf, entre outros, podem ser efetuadas através do PipeTransform. Veja neste artigo como formatar um CPF utilizando este recurso - CpfPipe