Angular CDK: Cuando el tooltip nativo se queda corto (Nivel Básico)

por | Feb 10, 2020 | Angular, Angular CDK, Angular Material | 6 Comentarios

Custom Tooltip - Basic Level (Thumb)

Conociendo Angular Material CDK

Desde la versión 5 de Angular, se introdujo como parte de Angular Material algo que denominaron CDK, o Component Development Kit. ¿Qué hace de esto un hecho importante? Hasta el momento la librería disponía de una serie de componentes desarrollados y listos para su uso, tales como distintos tipos de boton, controles de formulario, listas, cards… El problema es que eran muy básicos, y a pesar de ser altamente personalizables, en muchas ocasiones se requería el uso de un control que no existía, o modificar el comportamiento/funcionamiento interno de alguno ya existente. Debido a esta necesidad, el equipo de Angular publicó su kit de desarrollo que les permitió crear los componentes de Material. Desde este momento cualquier desarrollador podría crear su propio componente, basándose en el core de Material.

Resumiendo, el core de los componentes Material está diseñado sobre el CDK (que es una abstracción de funcionalidades centrales), y ahora está disponible para el público; así que podemos diseñar componentes completamente personalizados, o extender los ya existentes.

Tooltip nativo

En algún momento, hemos necesitado o vamos a necesitar mostrar información adicional a la que es visible en la pantalla de una forma sencilla; ya sea por razones de cantidad, legibilidad, espacio, lógica de negocio… Entonces aparecen los tooltips, preparados para mostrar datos adicionales cuando se pasa el ratón por encima de los elementos.

Inicialmente, la forma de conseguir esto era mediante el atributo title dentro de la etiqueta HTML que debía mostrar dicha información. Y se ve de la siguiente manera (en mi caso se ve con el fondo negro porque mi navegador está en Modo Oscuro, a vosotros debería saliros con el fondo blanco):

Angular CDK: Cuando el tooltip nativo se queda corto (Nivel Básico)

Tooltip Material

En la sección de Popups & Modals de Angular Material encontramos la documentación sobre los tooltips que integran. Es tan sencillo como usar la directiva matTooltip y del mismo modo que el tooltip nativo, aparecerá al pasar el ratón por encima del elemento.

La diferencia aquí es que Material nos provee un sinfín de opciones para personalizar nuestro tooltip, como elegir en qué posición respecto al elemento saldrá, color de fondo, tamaño de letra, aplicar delays… Tienen una sección dedicada para que podamos trastear con ejemplos de prueba, y cambiemos sus propiedades y comportamiento. Por defecto, el tooltip de Material se ve así:

Angular CDK: Cuando el tooltip nativo se queda corto (Nivel Básico)

Tooltip personalizado

Bueno, ¿y para qué querríamos personalizar un tooltip? Muy sencillo, la principal limitación de éstos reside en que sólo puede mostrar texto plano. ¿Y si quisiéramos mostrar alguna imágen? ¿O darle un formato con header body footer? ¿Siquiera formatear algunas palabras del texto a mostrar? No seríamos capaces de lograrlo, porque no están diseñados para eso.

Objetivo

Nuestro principal objetivo va a ser sobrepasar dicha limitación, y ser capaces de crear un tooltip ‘enriquecido’, mediante el componente Overlay del CDK, ya sea inyectándole un template del HTML, o un componente entero de nuestra aplicación.

Tecnologías

Para ello, el stack de versiones que se utilizarán en el proyecto es el siguiente:

  • Angular: 8.2.9
  • Angular Material: 8.2.3
  • Angular CDK: 8.2.3
  • Angular CLI: 8.3.23
  • NodeJS: 12.14.1
  • Npm: 6.13.4
  • Typescript: 3.5.3

Creando el proyecto

Abrimos un terminal en la carpeta donde vayamos a crear el proyecto y ejecutamos el siguiente comando:

ng new nombre-del-proyecto

Una vez hecho esto, lo más probable es que el prompt nos haga una serie de preguntas para la configuración del proyecto (si no os ocurre, es porque tenéis una versión antigüa del CLI). En nuestro caso no vamos a necesitar el Router de Angular, y preferiblemente usaremos SCSS como preprocesador de estilos. Después, nos situaremos en la carpeta del proyecto desde el terminal. Para instalar Angular Material usaremos el siguiente comando:

ng add @angular/material

En esta ocasión, el CLI también nos preguntará una serie de cosas antes de la instalación. Como tema predefinido yo elegí el primero que sale (Theme Indigo/Pink). Tampoco nos hará falta la librería HammerJS ni las animaciones de Material, por lo que indicamos que no.

Componentes

¡Por fin! Ahora viene lo interesante. Vamos a comenzar por crear el contenido que deberá mostrarse en el tooltip. Vamos a cubrir dos modos diferentes de pasarle datos al tooltip: Mediante un template, y mediante un componente normal.

Empezando por el código principal y creando el template, nuestros archivos app.component.htmlapp.component.scss se verán de la siguiente manera:

app.component.html

<mat-chip-list class="chip-list">
  <mat-chip color="primary" selected>
    Tooltip con template
  </mat-chip>
  <mat-chip color="accent" selected>
    Tooltip con componente
  </mat-chip>
</mat-chip-list>

<ng-template #tooltipTemplate>
  <div>
    <h3>Este contenido está siendo pasado desde un template</h3>
    <p>Lorem ipsum dolor sit amet</p>
    <p>consectetur adipiscing elit</p>
    <p>sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
  </div>
</ng-template>

app.component.scss

.chip-list {
  display: flex;
  flex-flow: row;
  place-content: center;
  padding-top: 16px;
}

Como os habréis fijado, he utilizado las etiquetas mat-chip-listmat-chip de Angular Material. Para poder compilar sin errores deberemos incluir el módulo de los chips en el archivo app.module.ts.

Ahora vamos a generar el componente que mostraremos en el tooltip:

ng g c components/tooltip

El resultado de nuestro archivo app.module.ts:

app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatChipsModule } from '@angular/material/chips';
import { TooltipComponent } from './components/tooltip/tooltip.component';

@NgModule({
  declarations: [
    AppComponent,
    TooltipComponent
  ],
  imports: [
    BrowserModule,
    NoopAnimationsModule,
    MatChipsModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Si ejecutamos el comando ng serve en el terminal y abrimos en un navegador la dirección localhost:4200  veremos lo siguiente:

Angular CDK: Cuando el tooltip nativo se queda corto (Nivel Básico)

Directiva

El modo de mostrar el tooltip lo enfocaremos en el uso de una directiva personalizada, la cual recibirá como parámetro el template o componente a mostrar. Para crearla ejecutamos:

ng g d directives/custom-tooltip

Le cambiamos el nombre al selector de la directiva y declaramos un par de variables: Una para recibir el contenido a mostrar, y otra encargada de gestionar el overlay que simulará ser un tooltip.

custom-tooltip.directive.ts

@Directive({ selector: '[customTooltip]' })
export class CustomTooltipDirective {
  /** Contenido que se va a renderizar dentro del tooltip */
  @Input('customTooltip') tooltipContent: TemplateRef<any> | ComponentType<any>;

  /** Overlay que simula ser un tooltip */
  private _overlayRef: OverlayRef;
}

Ahora implementaremos el hook ngOnInit. Aquí nos encargaremos de crear el overlay enlazado al elemento portador de la directiva.

custom-tooltip.directive.ts

@Directive({ ... })
export class CustomTooltipDirective implements OnInit {
  ...
  constructor(
    private overlay: Overlay,
    private overlayPositionBuilder: OverlayPositionBuilder,
    private elementRef: ElementRef,
    private viewContainerRef: ViewContainerRef,
  ) { }

  ngOnInit(): void {
    // Si se recibe el contenido a mostrar
    if (this.tooltipContent) {
      // Se crea la configuración de posicionamiento para el tooltip
      const position = this.overlayPositionBuilder
      // Se enlaza la posición del overlay al elemento portador de la directiva
      .flexibleConnectedTo(this.elementRef)
      // Se declaran las posiciones preferidas que usará el overlay al mostrarse
      .withPositions([
        {
          originX: 'center',
          originY: 'bottom',
          overlayX: 'center',
          overlayY: 'top',
          offsetX: 0,
          offsetY: 8,
        },
        {
          originX: 'center',
          originY: 'top',
          overlayX: 'center',
          overlayY: 'bottom',
          offsetX: 0,
          offsetY: -8,
        }
      ]);

      // Se crea el overlay y se guarda su referencia
      this._overlayRef = this.overlay.create({
        // Configuración para la posición del overlay
        positionStrategy: position,
        // Comportamiento del overlay cuando se haga scroll y se esté mostrando
        scrollStrategy: this.overlay.scrollStrategies.close(),
        // Clase para darle estilo al overlay
        panelClass: 'custom-tooltip',
      });
    }
    // Se muestra un error si la directiva no recibe contenido para mostrar
    else {
      console.error('[ERROR] La directiva tiene que recibir el contenido a mostrar...');
    }
  }

}

Agregamos en el archivo styles.scss una clase para darle estilo al overlay, e intentar que se parezca más a un tooltip Material. En el fragmento de código anterior utilizamos dicha clase al crear el overlay, mediante la propiedad panelClass.

styles.scss

html, body { height: 100%; }
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }

.custom-tooltip {
  background-color: #232F34;
  color: white;
  padding: 16px;
  border-radius: 8px;
}

A nuestra directiva únicamente le falta hacer aparecer y desaparecer el overlay con el contenido que le hemos pasado por parámetro; y para conseguirlo, implementaremos un par de listeners: Uno para detectar cuando el ratón está encima del elemento, y otro para saber cuándo deja de estarlo.

custom-tooltip.directive.ts

@Directive({ ... })
export class CustomTooltipDirective implements OnInit {
  ...
  @HostListener('mouseenter')
  private _show(): void {
    // Si existe overlay se enlaza con el contenido
    if (this._overlayRef) {
      let containerPortal: TemplatePortal<any> | ComponentPortal<any>;

      // Creamos un TemplatePortal si lo que recibió la directiva era un Template
      if (this.tooltipContent instanceof TemplateRef) {
        containerPortal = new TemplatePortal(this.tooltipContent, this.viewContainerRef);
      }
      // En caso contrario creamos un ComponentPortal
      else {
        containerPortal = new ComponentPortal(this.tooltipContent, this.viewContainerRef);
      }

      // Enlazamos el portal con el overlay creado al iniciar la directiva
      this._overlayRef.attach(containerPortal);
    }
  }

  @HostListener('mouseout')
  private _hide(): void {
    // Si existe un overlay se desenlaza del contenido
    if (this._overlayRef) {
      this._overlayRef.detach();
    }
  }
}

Pasos finales

Antes que se nos olvide, vamos a completar el componente que creamos anteriormente.

tooltip.component.html

<div class="component">
  <div class="header">
    <!-- Descomentad la siguiente línea para usar la imágen de prueba -->
    <!-- <img class="user-icon"
      src="https://www.stickpng.com/assets/images/585e4beacb11b227491c3399.png"> -->
    <h2>Header del componente</h2>
  </div>
  <div class="body">
    <p class="center-text">Cuerpo del componente</p>
  </div>
  <mat-divider class="footer-divider"></mat-divider>
  <div class="footer">
    <small class="right-text">Footer del componente</small>
  </div>
</div>

tooltip.component.scss

.component {
  width: 400px;

  .header {
    display: grid;
    grid-template-columns: auto auto;
    align-items: center;
    place-content: center;
    column-gap: 16px;
    grid-auto-rows: 32px;
  }

  .body {
    margin: 16px;

    .center-text {
      margin: auto;
      text-align: center;
    }
  }

  .footer {
    .right-text {
      display: block;
      text-align: right;
    }
  }

  .user-icon {
    height: 30px;
    width: 30px;
  }

  .footer-divider {
    margin: 16px 0;
    background-color: white;
    box-sizing: border-box;
  }
}

Ahora sí que sí, ¡recta final! Ya sólo queda añadir la directiva a nuestros chips en el componente principal, y declarar una variable que actúe como referencia a nuestro componente tooltip. (Además de actualizar el archivo app.module.ts para que reconozca todos los módulos/componentes nuevos).

app.component.html

<mat-chip-list class="chip-list">
  <mat-chip color="primary" selected [customTooltip]="tooltipTemplate">
    Tooltip con template
  </mat-chip>
  <mat-chip color="accent" selected [customTooltip]="tooltipComponent">
    Tooltip con componente
  </mat-chip>
</mat-chip-list>

<ng-template #tooltipTemplate>
  <div>
    <h3>Este contenido está siendo pasado desde un template</h3>
    <p>Lorem ipsum dolor sit amet</p>
    <p>consectetur adipiscing elit</p>
    <p>sed do eiusmod tempor incididunt ut labore et dolore magna aliqua</p>
  </div>
</ng-template>

app.component.ts

@Component({ ... })
export class AppComponent {
  tooltipComponent = TooltipComponent;
}

app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { MatDividerModule } from '@angular/material/divider';
import { AppComponent } from './app.component';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatChipsModule } from '@angular/material/chips';
import { TooltipComponent } from './components/tooltip/tooltip.component';
import { CustomTooltipDirective } from './directives/custom-tooltip.directive';
import { OverlayModule } from '@angular/cdk/overlay';

@NgModule({
  declarations: [
    AppComponent,
    TooltipComponent,
    CustomTooltipDirective
  ],
  imports: [
    BrowserModule,
    NoopAnimationsModule,
    MatChipsModule,
    OverlayModule,
    MatDividerModule,
  ],
  entryComponents: [TooltipComponent],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

¡Terminado! Sólo queda ejecutar ng serve y ver el resultado final.

Angular CDK: Cuando el tooltip nativo se queda corto (Nivel Básico)

Live Demo

🚀 Como extra, os dejo el proyecto en StackBlitz para que podáis dar rienda suelta a vuestra imaginación con los tooltips 🚀