Cách tạo Tooltip Directive trong Angular

Cách tạo Tooltip Directive trong Angular

{tocify} $title={Table of Content}

Tạo tooltip directive với input là text string

tooltip.directive.tsimport { Directive, Input, ElementRef, HostListener, Renderer2 } from '@angular/core';

@Directive({
  selector: '[tooltip]'
})
export class TooltipDirective {
  @Input('tooltip')
  tooltipTitle!: string;
  @Input()
  placement!: string;
  @Input()
  delay: string | any;
  tooltip: HTMLElement | any;
  // Distance between host element and tooltip element
  offset = 10;

  constructor(private el: ElementRef, private renderer: Renderer2) { }

  @HostListener('mouseenter') onMouseEnter() {
    if (!this.tooltip) { this.show(); }
  }

  @HostListener('mouseleave') onMouseLeave() {
    if (this.tooltip) { this.hide(); }
  }

  show() {
    this.create();
    this.setPosition();
    this.renderer.addClass(this.tooltip, 'ng-tooltip-show');
  }

  hide() {
    this.renderer.removeClass(this.tooltip, 'ng-tooltip-show');
    window.setTimeout(() => {
      this.renderer.removeChild(document.body, this.tooltip);
      this.tooltip = null;
    }, this.delay);
  }

  create() {
    this.tooltip = this.renderer.createElement('span');

    this.renderer.appendChild(
      this.tooltip,
      this.renderer.createText(this.tooltipTitle) // textNode
    );

    this.renderer.appendChild(document.body, this.tooltip);
    // this.renderer.appendChild(this.el.nativeElement, this.tooltip);

    this.renderer.addClass(this.tooltip, 'ng-tooltip');
    this.renderer.addClass(this.tooltip, `ng-tooltip-${this.placement}`);

    // delay setting
    this.renderer.setStyle(this.tooltip, '-webkit-transition', `opacity ${this.delay}ms`);
    this.renderer.setStyle(this.tooltip, '-moz-transition', `opacity ${this.delay}ms`);
    this.renderer.setStyle(this.tooltip, '-o-transition', `opacity ${this.delay}ms`);
    this.renderer.setStyle(this.tooltip, 'transition', `opacity ${this.delay}ms`);
  }

  setPosition() {
    // The Element.getBoundingClientRect() method returns a DOMRect object
    // providing information about the size of an element and its position relative to the viewport.

    // Host element size and position information
    const hostPos = this.el.nativeElement.getBoundingClientRect();

    // Tooltip element size and position information
    const tooltipPos = this.tooltip.getBoundingClientRect();

    // window's scroll top
    // The getBoundingClientRect method returns the relative position in the viewport.
    // When scrolling occurs, the vertical scroll coordinate value should be reflected on the top of the tooltip element.
    const scrollPos = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0;

    let top, left;

    if (this.placement === 'top') {
      top = hostPos.top - tooltipPos.height - this.offset;
      left = hostPos.left + (hostPos.width - tooltipPos.width) / 2;
    }

    if (this.placement === 'bottom') {
      top = hostPos.bottom + this.offset;
      left = hostPos.left + (hostPos.width - tooltipPos.width) / 2;
    }

    if (this.placement === 'left') {
      top = hostPos.top + (hostPos.height - tooltipPos.height) / 2;
      left = hostPos.left - tooltipPos.width - this.offset;
    }

    if (this.placement === 'right') {
      top = hostPos.top + (hostPos.height - tooltipPos.height) / 2;
      left = hostPos.right + this.offset;
    }

    // When scrolling occurs, the vertical scroll coordinate value should be reflected on the top of the tooltip element.
    this.renderer.setStyle(this.tooltip, 'top', `${top + scrollPos}px`);
    this.renderer.setStyle(this.tooltip, 'left', `${left}px`);
  }
}
styles.scss.ng-tooltip {
  position: absolute;
  max-width: 150px;
  font-size: 14px;
  text-align: center;
  color: #f8f8f2;
  padding: 3px 8px;
  background: #282a36;
  border-radius: 4px;
  z-index: 1000;
  opacity: 0;
}
.ng-tooltip:after {
  content: "";
  position: absolute;
  border-style: solid;
}
.ng-tooltip-top:after {
  top: 100%;
  left: 50%;
  margin-left: -5px;
  border-width: 5px;
  border-color: black transparent transparent transparent;
}
.ng-tooltip-bottom:after {
  bottom: 100%;
  left: 50%;
  margin-left: -5px;
  border-width: 5px;
  border-color: transparent transparent black transparent;
}
.ng-tooltip-left:after {
  top: 50%;
  left: 100%;
  margin-top: -5px;
  border-width: 5px;
  border-color: transparent transparent transparent black;
}
.ng-tooltip-right:after {
  top: 50%;
  right: 100%;
  margin-top: -5px;
  border-width: 5px;
  border-color: transparent black transparent transparent;
}
.ng-tooltip-show {
  opacity: 1;
}
app.component.tsimport { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  title: string = "Tooltip - Attribute Directive";
}
app.component.html<h1>{{title}}</h1>
<div class="d-flex flex-row justify-content-around mt-5">
    <div class="btn btn-primary" placement="top" [tooltip]="'<em>Tooltip</em> <u>with</u> <b>HTML</b>'">Top</div>
    <div class="btn btn-primary" placement="right" tooltip="Right">Right</div>
    <div class="btn btn-primary" placement="bottom" tooltip="Bottom">Bottom</div>
    <div class="btn btn-primary" placement="left" tooltip="Left">Left</div>
</div>
app.module.tsimport { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { TooltipDirective } from './tooltip.directive';

@NgModule({
  declarations: [
    AppComponent,
    TooltipDirective,
  ],
  imports: [
    BrowserModule,
    AppRoutingModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Demo


Lưu ý: Mình sử dụng class của Bootstrap, để có giao diện giống mình thì bạn hãy cài đặt Bootstrap nhé.
Khi hover lên button, một thẻ <span> cho tooltip sẽ được tạo ra như sau:

Một số khái niệm:

  1. ElementRef là một trình bao bọc xung quanh đối tượng native DOM element (HTML element - là phần tử mà chúng ta đính kèm Directive). Nó chứa thuộc tính nativeElement tham chiếu đến DOM object. Chúng ta có thể sử dụng nó để thao tác trực tiếp DOM.
  2. HostListener lắng nghe các host event (click, mouseenter, mouseleave,...) trên host element. Host element là một phần tử mà chúng ta đính kèm component hoặc directive của mình. Tính năng này cho phép chúng ta thay đổi styles CSS hoặc thực hiện một số hành động nào đó bất cứ khi nào người dùng thực hiện một số hành động trên host element.
  3. Renderer2 cho phép chúng ta thao tác các phần tử DOM mà không cần truy cập trực tiếp vào DOM. Nó cung cấp một lớp trừu tượng abstract class giữa phần tử DOM và component code. Sử dụng Renderer2, chúng ta có thể tạo element, thêm text node vào element, thêm child element bằng phương thức appendchild,... Chúng ta cũng có thể thêm hoặc xóa các styles, thuộc tính HTML, CSS Classes & properties,... Chúng ta cũng có thể đính kèm và lắng nghe đến các sự kiện trên element,...

Read more

Tạo tooltip directive với input là HTML

Chúng ta sẽ custom lại TooltipDirective ở trên để sử dụng cho cả input là string và HTML. Đầu tiên chúng ta sẽ thêm một input để xác định giá trị tooltip là string hay HTML element:

tooltip.directive.ts  // Allow HTML in the tooltip.
  @Input() htmlTooltip: boolean = false;

Sau đó, chúng ta sẽ dựa vào biến này để render tooltip. Để chèn một HTML element vào một element chúng ta sẽ thiết lập property innerHTML cho nó bằng cách sử dụng method setProperty của Renderer2

Tuy nhiên, chúng ta có thể vấp phải vấn đề liên quan đến XSS attacks, nếu bạn đặt thuộc tính innerHTML trực tiếp như thế này:

tooltip.directive.ts// DANGER - Vulnerable to XSS
this.renderer.setProperty(
  this.tooltip,
  'innerHTML',
  this.tooltipTitle
);

Bạn có thể cảm thấy an toàn khi nghĩ rằng Angular sẽ tự động làm sạch (sanitize) các giá trị được chuyển đến innerHTML. Tuy nhiên, việc sanitize tự động này chỉ xảy ra khi giá trị được ràng buộc trong template như thế này:

<div #userContent id="user-content" [innerHTML]="maliciousString"></div>

Vì vậy, Angular sẽ không tự động loại bỏ các HTML nguy hiểm khỏi các giá trị khi được binding thủ công với innerHTML bằng cách sử dụng Renderer2.

Do đó, chúng ta cần sanitize giá trị Tooltip HTML trước khi gán cho innerHTML. Chúng ta sẽ sử dụng sanitize method của DomSanitizer có sẵn trong @angular/platform-browserSecurityContext từ @angular/core. Sử dụng SecurityContext sẽ cho phép chúng ta chỉ ra chuỗi mà chúng ta muốn làm sạch (sanitize) là gì (ví dụ: HTML, Resource URL, Style, Script, URL):

tooltip.directive.tsimport {
  Directive,
  Input,
  ElementRef,
  HostListener,
  Renderer2,
  SecurityContext,
} from '@angular/core';
import { DomSanitizer } from "@angular/platform-browser";
tooltip.directive.ts  constructor(
    private el: ElementRef,
    private renderer: Renderer2,
    private sanitizer: DomSanitizer
  ) { }
tooltip.directive.ts    this.renderer.setProperty(
      this.tooltip,
      'innerHTML',
      this.sanitizer.sanitize(SecurityContext.HTML, this.tooltipTitle)
    );

Code đầy đủ cho tooltip directive

tooltip.directive.tsimport {
  Directive,
  Input,
  ElementRef,
  HostListener,
  Renderer2,
  SecurityContext,
} from '@angular/core';
import { DomSanitizer } from "@angular/platform-browser";

@Directive({
  selector: '[tooltip]'
})
export class TooltipDirective {
  @Input('tooltip')
  tooltipTitle!: string; // tooltipTitle may be string or HTML element
  @Input()
  placement!: string;
  @Input()
  delay: string | any;
  tooltip: HTMLElement | any;
  // Distance between host element and tooltip element
  offset = 10;
  // Allow HTML in the tooltip.
  @Input() htmlTooltip: boolean = false;

  constructor(
    private el: ElementRef,
    private renderer: Renderer2,
    private sanitizer: DomSanitizer
  ) { }

  @HostListener('mouseenter') onMouseEnter() {
    if (!this.tooltip) { this.show(); }
  }

  @HostListener('mouseleave') onMouseLeave() {
    if (this.tooltip) { this.hide(); }
  }

  show() {
    this.create();
    this.setPosition();
    this.renderer.addClass(this.tooltip, 'ng-tooltip-show');
  }

  hide() {
    this.renderer.removeClass(this.tooltip, 'ng-tooltip-show');
    window.setTimeout(() => {
      this.renderer.removeChild(document.body, this.tooltip);
      this.tooltip = null;
    }, this.delay);
  }

  create() {
    this.tooltip = this.renderer.createElement('span');

    if (this.htmlTooltip) {
      this.renderer.setProperty(
        this.tooltip,
        'innerHTML',
        this.sanitizer.sanitize(SecurityContext.HTML, this.tooltipTitle)
      );
    } else {
      this.renderer.appendChild(
        this.tooltip,
        this.renderer.createText(this.tooltipTitle) // textNode
      );
    }

    this.renderer.appendChild(document.body, this.tooltip);
    // this.renderer.appendChild(this.el.nativeElement, this.tooltip);

    this.renderer.addClass(this.tooltip, 'ng-tooltip');
    this.renderer.addClass(this.tooltip, `ng-tooltip-${this.placement}`);

    // delay setting
    this.renderer.setStyle(this.tooltip, '-webkit-transition', `opacity ${this.delay}ms`);
    this.renderer.setStyle(this.tooltip, '-moz-transition', `opacity ${this.delay}ms`);
    this.renderer.setStyle(this.tooltip, '-o-transition', `opacity ${this.delay}ms`);
    this.renderer.setStyle(this.tooltip, 'transition', `opacity ${this.delay}ms`);
  }

  setPosition() {
    // The Element.getBoundingClientRect() method returns a DOMRect object
    // providing information about the size of an element and its position relative to the viewport.

    // Host element size and position information
    const hostPos = this.el.nativeElement.getBoundingClientRect();

    // Tooltip element size and position information
    const tooltipPos = this.tooltip.getBoundingClientRect();

    // window's scroll top
    // The getBoundingClientRect method returns the relative position in the viewport.
    // When scrolling occurs, the vertical scroll coordinate value should be reflected on the top of the tooltip element.
    const scrollPos = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0;

    let top, left;

    if (this.placement === 'top') {
      top = hostPos.top - tooltipPos.height - this.offset;
      left = hostPos.left + (hostPos.width - tooltipPos.width) / 2;
    }

    if (this.placement === 'bottom') {
      top = hostPos.bottom + this.offset;
      left = hostPos.left + (hostPos.width - tooltipPos.width) / 2;
    }

    if (this.placement === 'left') {
      top = hostPos.top + (hostPos.height - tooltipPos.height) / 2;
      left = hostPos.left - tooltipPos.width - this.offset;
    }

    if (this.placement === 'right') {
      top = hostPos.top + (hostPos.height - tooltipPos.height) / 2;
      left = hostPos.right + this.offset;
    }

    // When scrolling occurs, the vertical scroll coordinate value should be reflected on the top of the tooltip element.
    // If we don't plus scrollPos, when the scroll happens, the position of the tooltip will be incorrect.
    // Because Element.getBoundingClientRect() returns its position according to the viewport
    this.renderer.setStyle(this.tooltip, 'top', `${top + scrollPos}px`);
    this.renderer.setStyle(this.tooltip, 'left', `${left}px`);
  }
}

Bây giờ thì chúng ta không cần phải lo lắng về XSS attacks nữa. Bất kỳ lúc nào Angular loại bỏ bất kỳ thứ gì do quá trình sanitization, nó sẽ hiển thị cảnh báo trong console log browser của chúng ta trong quá trình phát triển (chỉ hiện trong mội trường develop lúc chúng ta implement code, không hiện khi chúng ta đã deploy lên môi trường production đâu nhé).

Để test các lỗ hổng XSS (XSS Vulnerability), bạn có thể làm như sau:

app.component.tsimport { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  title: string = "Tooltip - Attribute Directive";
  test = `<img src=nonexistentimage onerror="alert('Hello there')" />`;
}
app.componenthtml<h1>{{title}}</h1>
<div class="d-flex flex-row justify-content-around mt-5">
    <div class="btn btn-primary" [htmlTooltip]="true" placement="top" [tooltip]="test">Top</div>
    <div class="btn btn-primary" placement="right" tooltip="Right">Right</div>
    <div class="btn btn-primary" placement="bottom" tooltip="Bottom">Bottom</div>
    <div class="btn btn-primary" placement="left" tooltip="Left">Left</div>
</div>

Demo

Sau khi chạy code, bạn sẽ thấy kết quả như sau:

Nếu bạn bỏ đi phần xử lý sanitize cho HTML và gán trực tiếp giá trị cho innerHTML:
tooltip.directive.ts    if (this.htmlTooltip) {
      this.renderer.setProperty(
        this.tooltip,
        'innerHTML',
        this.tooltipTitle
      );
    } else {
      this.renderer.appendChild(
        this.tooltip,
        this.renderer.createText(this.tooltipTitle) // textNode
      );
    }

Kết quả script inject sẽ thực thi:

Read more

Đăng nhận xét

Mới hơn Cũ hơn