Componentes UI en Angular: botón, modal, input

Base para crear una librería de componentes UI en Angular

0
289

En este tutorial mostraré ejemplos de como implementar 3 componentes UI en Angular. 3 componentes muy comunes en cualquier aplicación web: un botón, una modal y un input.

Para los 3 utilizaré la encapsulación de estilos que proporciona Angular. De esta forma, los componentes serán autocontenidos y podremos usarlos sin depender de otras características externas (a excepción de algún reset o algunas custom properties globales de CSS que se puedan añadir al proyecto).

Índice

Introducción

En el desarrollo front-end hay muchas librerías de componentes UI para poder utilizar en nuestros proyectos. Algunos ejemplos para Angular pueden ser: Material, PrimeNG, NgBootstrap o NgAntDesign. Pero muchas veces nos encontramos en proyectos en los que el diseño propio es fundamental para la marca y no podemos usar estas librerías, ya que sobrecargar los estilos para conseguir el diseño requerido es complicado. En estos casos podemos crear, nosotros mismos, este tipo de componentes UI y usarlos por toda la aplicación, manteniendo una coherencia y cohesión en el diseño.

Es importante la comunicación con el equipo de diseño para estar alineados en qué componentes crear, como crearlos y qué variantes tendrán. De manera que cuando se requiera algún cambio en el diseño sea fácilmente reproducible en el código. Lo ideal sería tener un Design System para estar alineados en todos los elementos del diseño: colores, tamaños de espaciado, tipografías, componentes, etc.

En este tutorial mostraré la base para que podáis construir vuestra librería de componentes UI en Angular. Entendiendo como implementar un botón, una modal y un input se pueden crear los demás componentes que se requieran en vuestros proyectos.

Botón

Para el botón tenemos 2 opciones. Por un lado, un componente que contenga un elemento button y nosotros definir todos los inputs y outputs que queremos exponer. Y, por otro lado, un componente cuyo template sea un elemento ng-content y usemos el selector button[custom-button]. Con esta segunda opción tendremos un botón con todas las variantes que nosotros definamos, pero además con todos los atributos propios del elemento HTML button. Estas 2 opciones tiene sus pros y contras y en función de lo que necesitemos elegiremos una u otra.

Opción 1:

Pros:

  • Se exponen únicamente los inputs y outputs que se requieran. Evitando aquellos atributos del button que no se van a usar en el proyecto.

Contras:

  • Es difícil saber todos los atributos que se van a necesitar en todos los contextos del proyecto. Si se exponen muchos de los atributos del button, lo que se está haciendo es duplicar su interfaz, dificultando el mantenimiento.
  • Estamos añadiendo un elemento HTML extra en el DOM por cada botón. El cual habrá que tener en cuenta en los estilos y comportamiento del componente. Aparte del button tendremos que estilar el :host.
  • Hay diseños en los que un anchor y un button tienen los mismos estilos. Con esta solución se complica tener un mismo componente para los 2 casos, ya que cada elemento tiene sus propios atributos y características.

Solución:

// button.component.ts
import { Component, input, output } from '@angular/core'

type ButtonVariant = 'primary' | 'secondary'
type ButtonSize = 'small' | 'medium'

@Component({
  selector: 'custom-button',
  standalone: true,
  imports: [],
  template: `
    <button
      [class]="getClasses()"
      (click)="onClick($event)"
      [type]="type()"
      [disabled]="disabled()"
    >
      <ng-content></ng-content>
    </button>
  `,
  styleUrl: './button.component.css',
})
export class Button2Component {
  variant = input<ButtonVariant>('primary')
  size = input<ButtonSize>('medium')

  type = input<HTMLButtonElement['type']>('button')
  disabled = input<HTMLButtonElement['disabled']>()
  click = output<MouseEvent>()
  // other attributes that you want to pass to the button element

  getClasses() {
    return [this.variant(), this.size()].join(' ')
  }

  onClick(event: MouseEvent) {
    this.click.emit(event)
  }
}
/* button.component.css */
:host {
  /* host styles */
}

button {
  /* common button styles */
}

button.primary {
  /* primary variant styles */
}

button.secondary {
  /* secondary variant styles */
}

button.small {
  /* small size styles */
}

button.medium {
  /* medium size styles */
}
<!-- template del componente donde se quiera usar -->
<custom-button
	variant="primary"
	size="small"
	(click)="onClick($event)"
>My Button<custom-button>

Opción 2:

Pros:

  • Solo definimos el comportamiento extra/propio del botón, no hace falta duplicar la interfaz del elemento button.
  • Podemos tener un componente con los mismos estilos y variantes para usarlo como button y como anchor sin preocuparnos por los atributos concretos de cada uno.

Contras:

  • Exponemos toda la interfaz nativa del button y/o anchor aunque no la necesitemos. Cuando vamos a usar el componente no queda tan claro que hemos definido nosotros y que viene por defecto.
  • Si implementamos un button y un anchor en el mismo componente, tendremos que tener en cuenta los estilos por defecto de los dos elementos si queremos tener un diseño consistente en el componente. El :host será el propio elemento button o anchor según el selector que usemos.

Solución:

// button.component.ts
import { Component, HostBinding, input } from '@angular/core'

type ButtonVariant = 'primary' | 'secondary'
type ButtonSize = 'small' | 'medium'

@Component({
  selector: 'button[custom-button], a[custom-button]',
  standalone: true,
  imports: [],
  template: `<ng-content></ng-content>`,
  styleUrl: './button.component.css',
})
export class ButtonComponent {
  variant = input<ButtonVariant>('primary')
  size = input<ButtonSize>('medium')

  @HostBinding('class.primary') get primary() {
    return this.variant() === 'primary'
  }
  @HostBinding('class.secondary') get secondary() {
    return this.variant() === 'secondary'
  }
  @HostBinding('class.small') get small() {
    return this.size() === 'small'
  }
  @HostBinding('class.medium') get medium() {
    return this.size() === 'medium'
  }
}
/* button.component.css */
:host {
  /* common styles */
}

:host.primary {
  /* primary variant styles */
}

:host.secondary {
  /* secondary variant styles */
}

:host.small {
  /* small size styles */
}

:host.medium {
  /* medium size styles */
}
<!-- template del componente donde se quiera usar -->
<button
	custom-button
	variant="primary"
	size="small"
	(click)="onClick($event)"
>My Button<button>
<a
	custom-button
	variant="primary"
	size="small"
	href="#"
>My Anchor<a>

Hay muchos artículos donde se explica como crear modales en Angular. Cada artículo tiene sus propias soluciones y características. Para este tutorial se muestra una solución lo más sencilla posible, en el que hay 2 cosas a tener en cuenta:

  • Se usa el elemento HTML dialog. Con lo que se deberá vigilar el soporte que tiene en los distintos navegadores (https://caniuse.com/dialog).
  • No se hace uso de Portals. Por lo que este componente se añadirá en el árbol del DOM principal, en el lugar donde se utilice.

Solución:

// modal.component.ts
import { Component, ElementRef, ViewChild, input } from '@angular/core';

@Component({
  selector: 'custom-modal',
  standalone: true,
  imports: [],
  template: `
    <dialog #customModal (click)="closeModalOnClickOutside($event)">
      <header>
        <h1>{{ title() }}</h1>
        <button (click)="closeModal()">X</button>
      </header>
      <ng-content />
    </dialog>
  `,
  styleUrl: './modal.component.css',
})
export class ModalComponent {
  title = input.required<string>()
  @ViewChild('customModal', { static: true })
  dialog!: ElementRef<HTMLDialogElement>

  open() {
    this.dialog.nativeElement.showModal()
  }

  close() {
    this.dialog.nativeElement.close()
  }

  closeModalOnClickOutside(e: MouseEvent) {
    const dialog = this.dialog.nativeElement
    const dialogDimensions = dialog.getBoundingClientRect()
    if (
      e.clientX < dialogDimensions.left ||
      e.clientX > dialogDimensions.right ||
      e.clientY < dialogDimensions.top ||
      e.clientY > dialogDimensions.bottom
    ) {
      dialog.close()
    }
  }
}
/* modal.component.css */
dialog {
  top: 50%;
  left: 50%;
  translate: -50% -50%;
  padding: 1rem;
  border: none;
  box-shadow: 0px 10px 15px -3px rgba(0, 0, 0, 0.1),
    0px 10px 15px -3px rgba(0, 0, 0, 0.1),
    0px 10px 15px -3px rgba(0, 0, 0, 0.1);
  border-radius: 0.25rem;
  width: 400px;
}

header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-bottom: 1.5rem;
}

h1 {
  font-size: 1.5rem;
}

button {
  outline: none;
  border: none;
  background: none;
  font-size: 1rem;
  padding: 1rem;
  width: 48px;
  height: 48px;
  display: flex;
  justify-content: center;
  align-items: center;
}
//Añadir este codigo en el componente donde se quiera usar el ModalComponent:
@ViewChild(ModalComponent) modal!: ModalComponent

openModal() {
  this.modal.open()
}

closeModal() {
  this.modal.close()
}
<!-- template del componente donde se quiera usar -->
<custom-dialog title="Título de la Modal">
 Contenido de la Modal
</custom-dialog>

Input

Por último, vamos a ver un componente para usarlo en nuestros formularios: un input que contenga un label, el propio input y un campo para mostrar errores.

En Angular, para crear componentes completamente compatibles con la API de Formularios de Angular, deberemos implementar la interfaz ControlValueAccessor. Internamente Angular implementa esta interfaz para los elementos nativos de formulario, creando las directivas: CheckboxControlValueAccessor, DefaultValueAccessor, NumberValueAccessor, etc. Estas directivas son internas de Angular y no las tenemos que usar en nuestro código. Pero, si queremos tener un componente propio y poderlo usar exactamente de la misma forma que el resto de elementos nativos del formulario, debemos implementar la interfaz ControlValueAccessor.

He visto 3 tipos de componentes de formulario en los que se requiera implementar esta interfaz. Por un lado, un componente completamente personalizado en el que internamente no se use ningún tipo de elemento nativo de formulario (input, select o textarea). Por otro lado, una sección completa de un formulario que se quiera reutilizar, creando formularios anidados. Y por último, el componente que mostraré en este tutorial, un componente de un solo campo que se pueda utilizar en cualquier formulario. (En las referencias hay ejemplos de como crear los otros dos casos).

De la solución propuesta hay 2 cosas a tener en cuenta:

  • La implementación de la interfaz se ha separado en una directiva, de esta forma se podrá reutilizar en otros componentes.
  • Hay métodos y atributos de la directiva que no se usan, pero en un componente completamente personalizado si harían falta.

Solución:

//control-value-accessor.directive.ts
import { Directive, Inject, Injector, OnInit } from '@angular/core'

import {
  ControlValueAccessor,
  FormControl,
  FormControlDirective,
  FormControlName,
  FormGroupDirective,
  NgControl,
  Validators,
} from '@angular/forms'

@Directive({
  standalone: true,
})
export class ControlValueAccessorDirective<T> implements ControlValueAccessor, OnInit {
  control: FormControl = new FormControl()

  isRequired = false
  isDisabled = false
  onChange(value: T) {
    console.log('on change is not registered', value)
  }
  onTouched() {
    console.log('on touched is not registered')
  }

  constructor(@Inject(Injector) private injector: Injector) {}

  ngOnInit(): void {
    this.setFormControl()
    this.isRequired = this.control?.hasValidator(Validators.required) ?? false
  }

  setFormControl() {
    try {
      const formControl = this.injector.get(NgControl)
      switch (formControl.constructor) {
        case FormControlName:
          this.control = this.injector.get(FormGroupDirective).getControl(formControl as FormControlName)
          break
        default:
          this.control = (formControl as FormControlDirective).form as FormControl
          break
      }
    } catch (err) {
      this.control = new FormControl()
    }
  }

  writeValue(value: T): void {
    if (this.control) {
      this.control.setValue(value)
    } else {
      this.control = new FormControl(value)
    }
  }

  registerOnChange(fn: (value: T) => void): void {
    this.onChange = fn
  }

  registerOnTouched(fn: () => void): void {
    this.onTouched = fn
  }

  setDisabledState(isDisabled: boolean): void {
    this.isDisabled = isDisabled
  }
}
//input.component.ts
import { ChangeDetectionStrategy, Component, forwardRef, input } from '@angular/core'
import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'
import { ControlValueAccessorDirective } from '../../directives/control-value-accessor.directive'

type InputType = 'text' | 'number' | 'email' | 'password'
@Component({
  selector: 'app-input',
  standalone: true,
  imports: [ReactiveFormsModule],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => InputComponent),
      multi: true,
    },
  ],
  templateUrl: './input.component.html',
  styleUrl: './input.component.css',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InputComponent<T> extends ControlValueAccessorDirective<T> {
  label = input.required<string>()
  type = input<InputType>('text')
  autocomplete = input<HTMLInputElement['autocomplete']>()
  error = input<string | undefined>()
}
<!-- input.component.html -->
<label>
  {{ label() }}
  <input
    [type]="type()"
    [autocomplete]="autocomplete()"
    [required]="isRequired"
    [formControl]="control"
    [attr.data-touched]="control.touched || control.dirty"
  />
</label>
@if (error()) {
  <p class="form-field-error">{{ error() }}</p>
}
/* input.component.css */
:host {
  --form-field-focus-color: #005cbb;
  --form-field-error-color: #ba1a1a;
  --input-background-color: #e0e2ec;

  display: block;
  margin-bottom: 20px;
}

label {
  width: 100%;
}

label:focus-within {
  color: var(--form-field-focus-color);
}

label:has(input[data-touched='true']:invalid) {
  color: var(--form-field-error-color);
}

input {
  margin: 4px 0;
  padding: 0 12px;
  height: 40px;
  background-color: var(--input-background-color);
  border-top-left-radius: 4px;
  border-top-right-radius: 4px;
  font-size: 16px;
}

input:focus-visible {
  outline: none;
  border-bottom: var(--form-field-focus-color) 1px solid;
}

input[data-touched='true']:focus-visible:invalid {
  border-bottom: var(--form-field-error-color) 1px solid;
}

.form-field-error {
  font-size: 12px;
  color: var(--form-field-error-color);
}
<!-- template del componente donde se quiera usar -->
<app-input
  label="Email"
  type="email"
  formControlName="email"
  autocomplete="username"
  [error]="emailErrorMessage"
></app-input>

Conclusión

Hemos visto como implementar 3 componentes UI en Angular para poder usarlos en cualquier lugar de nuestra aplicación. Pero simplemente son ejemplos, cada proyecto tendrá sus necesidades y características que requerirán soluciones distintas. En las referencias y en otros artículos, hay otras soluciones para crear estos componentes. Además, deberéis decidir si crear vuestros propios componentes o si usar alguna librería.

Por último, hay muchos otros componentes que se pueden necesitar en una aplicación, solo hace falta ir a la documentación de las librerías existentes para ver ejemplos. Pero espero que, al menos, estos ejemplos sirvan como base para empezar a implementar vuestros propios componentes UI, sean estos o cualquier otro que necesitéis.

Referencias

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad