Angular CDK: Cuando el tooltip nativo se queda corto (Nivel Avanzado)

por | Feb 17, 2020 | Angular, Angular CDK, Angular Material | 0 Comentarios

Custom Tooltip - Advanced Level (Thumb)

Potenciando el tooltip

Si en la entrega del tooltip personalizado nivel básico creías que no se podía mejorar nada más… Siento decirte que aún vamos a sacarle más partido. En la mayoría de casos, un tooltip con contenido estático es suficiente, pero en otros, nos puede interesar la posibilidad de que el contenido que muestre sea dinámico.

El objetivo en este post es que nuestro tooltip sea capaz de interactuar con datos que le pasemos y reaccionar a ellos debidamente en tiempo real (como si fuera un componente más de la página). Todos los cambios y modificaciones que realizaremos serán sobre el proyecto del anterior post, puedes enontrar el código fuente aquí.

Tooltip desde template

Vamos primero a hacer el caso «sencillo». Simplemente declararemos en el archivo app.component.ts un objeto con datos de una persona, además de una función que recupere y formatee la hora actual. Tanto los datos del objeto como la hora será lo que bindearemos en el template del html.

app.component.html

@Component({ ... })
export class AppComponent {
  /** Componente a utilizar por la directiva customTooltip */
  tooltipComponent = TooltipComponent;

  /** Encabezado del template */
  tooltipTitle = 'Datos de la persona';

  /** Objeto con datos de una persona */
  person = {
    name: 'Iván Gabriel',
    lastName: 'Pajón Rodríguez',
    hobbies: ['programar', 'leer', 'jugar videojuegos'],
  };

  /** Hora formateada que se irá actualizando cada segundo */
  time: string;

  constructor() {
    // Se actualiza el string con la hora cada segundo
    setInterval(() => {
      const currentDate = new Date();
      const hrs = this.addZero(currentDate.getHours());
      const mins = this.addZero(currentDate.getMinutes());
      const secs = this.addZero(currentDate.getSeconds());
      this.time = `${hrs}:${mins}:${secs}`;
    }, 1000);
  }

  /**
   * Añade un cero a la izquierda si el número es menor a 10
   * @param num Número a comprobar
   * @returns Número formateado
   */
  addZero(num: number): string {
    if (num < 10) {
      return `0${num}`;
    }
    return `${num}`;
  }
}

app.component.html

...
<ng-template #tooltipTemplate>
  <div>
    <h3>{{ tooltipTitle }}</h3>
    <p>Nombre: {{ person.name }}</p>
    <p>Apellidos: {{ person.lastName }}</p>
    <p>Aficiones: {{ person.hobbies.join(', ') }}</p>
    <p style="text-align: end;">
      Hora: {{ time }}
    </p>
  </div>
</ng-template>
Angular CDK: Cuando el tooltip nativo se queda corto (Nivel Avanzado)

¡Eso es! Ahora ya sabemos que nuestro tooltip tiene acceso a todos los datos del componente en el que se encuentra el template, en este caso, AppComponent

Tooltip desde componente

El modo de pasarle información a un componente creado mediante ComponentPortal es un poco más complejo. Debido a que se instancia el componente cada vez que mostramos el tooltip, éste es por definición, un componente dinámico.

Inyección de dependencias (DI)

Ésta es la forma que tiene nuestra aplicación Angular de resolver los componentes/servicios que solicitamos en el constructor de los mismos: Dependency Injection. Básicamente, así es como sabe qué clase estamos solicitando y dónde buscarla.

/* El sistema de dependencias sabe dónde buscar el servicio
SomeService para proveerlo en el componente */
constructor(
  private _randomService: SomeService
) { }

Token de inyección (IT)

El token de inyección (ó InjectionToken), es la manera que internamente usa el DI para resolver las dependencias. Nosotros crearemos uno personalizado, en base a nuestros requisitos, para que Angular sepa qué tipo de información esperar, y cómo puede resolver esa dependencia cuando se utilice dicho token.

Nos dirigimos a nuestro TooltipComponent, y exportamos la constante que definirá nuestro IT, así podremos utilizarlo en el resto de la aplicación. Y de paso, en el constructor, especificamos que dicha constante será una dependencia de nuestro componente.

tooltip.component.ts

/** Token de inyección personalizado (se espera una instancia de FormGroup) */
export const CUSTOM_TOOLTIP_DATA = new InjectionToken<FormGroup>('CUSTOM_TOOLTIP_DATA');

@Component({ ... })
export class TooltipComponent implements OnInit {

  constructor(
    // Nuestro token personalizado es una dependencia del componente
    @Inject(CUSTOM_TOOLTIP_DATA) public componentData: FormGroup
  ) { }

  ngOnInit() { }

}

Preparativos finales

Por si no os habíais dado cuenta, nuestro token personalizado CUSTOM_TOOLTIP_DATA espera una instancia de FormGroup. Así que vamos a crear un formulario sencillito en el componente principal AppComponent para podérselo pasar a nuestro tooltip.

app.component.ts

@Component({ ... })
export class AppComponent {
  /** Formulario que se le pasará al tooltip */
  tooltipForm: FormGroup;

  ...

  constructor() {
    // Se inicializa el formulario para el tooltip
    this.tooltipForm = new FormGroup({
      title: new FormControl(),
      body: new FormControl(),
      footer: new FormControl()
    });

    setInterval(() => { ... }, 1000);
  }

  addZero(num: number): string { ... }
}

app.component.html

<mat-chip-list class="chip-list">
  ...
</mat-chip-list>

<div class="form-container" [formGroup]="tooltipForm">
  <mat-form-field appearance="standard">
    <mat-label>Título del tooltip</mat-label>
    <input matInput formControlName="title">
  </mat-form-field>
  <mat-form-field appearance="standard">
    <mat-label>Cuerpo del tooltip</mat-label>
    <textarea matInput formControlName="body"></textarea>
  </mat-form-field>
  <mat-form-field appearance="standard">
    <mat-label>Footer del tooltip</mat-label>
    <mat-select formControlName="footer">
      <mat-option value="Codemain ©2020 | Todos los derechos reservados">Footer Largo</mat-option>
      <mat-option value="Codemain ©2020">Footer Corto</mat-option>
      <mat-option value="🤪 Footer loco 🤪">Footer Random</mat-option>
    </mat-select>
  </mat-form-field>
</div>

<ng-template #tooltipTemplate>
  ...
</ng-template>

app.component.scss

.chip-list { ... }

.form-container {
  display: flex;
  flex-flow: column;
  width: 25%;
  margin-left: 32px;
}

Adaptando la directiva

Después de realizar estos cambios, lo único que nos queda es adaptar nuestra directiva para que pueda recibir los datos y pasárselos al tooltip, así que manos a la obra.

custom-tooltip.directive.ts

@Directive({ ... })
export class CustomTooltipDirective implements OnInit {
  ...

  /** Objeto que se le quiere pasar como datos al tooltip */
  @Input('customTooltipData') data: FormGroup = new FormGroup({});

  constructor(
    ...
    private injector: Injector,
  ) { }

  ngOnInit(): void { ... }

  @HostListener('mouseenter')
  private _show(): void {
    // Si existe overly se enlaza con el contenido
    if (this._overlayRef) {
      let containerPortal: TemplatePortal<any> | ComponentPortal<any>;

      // Creamos un TemplatePortal si es lo que recibió la directiva
      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,
          this._createInjector(this.data)  // Creamos y pasamos el inyector con los datos
        );
      }

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

  @HostListener('mouseout')
  private _hide(): void { ... }

  /**
   * Crea un inyector de tokens con los datos que se le van a pasar al tooltip
   * @param data Formulario que se le pasará al tooltip
   * @returns Inyector del token
   */
  private _createInjector(data: FormGroup): PortalInjector {
    const injectorTokens = new WeakMap();

    injectorTokens.set(CUSTOM_TOOLTIP_DATA, data);

    return new PortalInjector(this.injector, injectorTokens);
  }

Pasos finales

Nos queda poquito ya. En nuestro archivo principal app.component.html, deberemos añadir al mat-chip con la directiva la nueva propiedad de entrada (customTooltipData). Y, en el archivo del tooltip tooltip.component.html, sustituiremos los textos fijos por los valores del formulario que recibe por parámetro.

app.component.html

<mat-chip-list class="chip-list">
  <mat-chip color="primary" selected [customTooltip]="tooltipTemplate">
    Tooltip con template
  </mat-chip>
  <!-- MatChip con la nueva propiedad de entrada -->
  <mat-chip color="accent" selected [customTooltip]="tooltipComponent" [customTooltipData]="tooltipForm">
    Tooltip con componente
  </mat-chip>
</mat-chip-list>

<div class="form-container" [formGroup]="tooltipForm">
  ...
</div>

<ng-template #tooltipTemplate>
  ...
</ng-template>

tooltip.component.html

<div class="component">
  <div class="header">
    <!-- Descomentad esta línea para usar la imágen de prueba -->
    <!-- <img class="user-icon"
      src="https://www.stickpng.com/assets/images/585e4beacb11b227491c3399.png"> -->
    <h2>{{ componentData.controls.title.value }}</h2>
  </div>
  <div class="body">
    <p class="center-text">{{ componentData.controls.body.value }}</p>
  </div>
  <mat-divider class="footer-divider"></mat-divider>
  <div class="footer">
    <small class="right-text">{{ componentData.controls.footer.value }}</small>
  </div>
</div>

tooltip.component.scss

.component {
  ...

  .body {
    margin: 16px;
    // Para que mantenga los saltos de línea del textarea
    white-space: pre-wrap;

    ...
  }

  ...
}

Resultado

Llegados hasta aquí, qué menos que os muestre el resultado de estos cambios que hemos realizado. 

Angular CDK: Cuando el tooltip nativo se queda corto (Nivel Avanzado)

Como os habréis dado cuenta, al cambiar el texto del título a la vez que se muestra el tooltip, éste cambia en tiempo real. Esto es porque el componente del tooltip realmente está recibiendo la referencia al objeto FormGroup (el formulario en app.component.ts); y por ello, cualquier cambio en el mismo se propaga a todos los sitios en los que se esté utilizando.

Live demo

🛸 ¡Hay más! También tenéis el ejemplo en Stackblitz para modificarlo y ver los cambios en el momento, exprimid vuestra imaginación 🛸