Developing a tooltip

杨旭 bio photo By 杨旭

Developing a tooltip directive

First of all, develop a TooltipWindow that will show a tooltip with the support of bootstrap

  it('should display a .tooltip and a .tooltip-inner inside', () => {
    fixture = createGenericTestComponent(`
      <tooltip-window></tooltip-window>
    `, TestComponent);

    const tooltipContainer = fixture.debugElement.query(By.css('.tooltip'));
    const inner = tooltipContainer.query(By.css('.tooltip-inner'));

    expect(tooltipContainer).toBeTruthy();
    expect(inner).toBeTruthy();
  });
<div class="tooltip">
  <div class="tooltip-inner">
    Tooltip
  </div>
</div>

It will display content arround tags.

  it('should display content arround <tooltip-window> tags', () => {
    fixture = createGenericTestComponent(`
      <tooltip-window>Custom Content!</tooltip-window>
    `, TestComponent);

    const tooltip = fixture.debugElement.query(By.css('.tooltip-inner'));
    expect(tooltip.nativeElement.textContent).toContain('Custom Content!');
  });

Just use to display contents inside tags.

<div class="tooltip">
  <div class="tooltip-inner">
    <ng-content></ng-content>
  </div>
</div>

Add placement class like tooltip-tip in the container depends on the placement property.

  it('should add tooltip-top class to the container as default', () => {
    fixture = createGenericTestComponent(`
      <tooltip-window>Custom Content!</tooltip-window>
    `, TestComponent);

    const tooltip = fixture.debugElement.query(By.css('.tooltip'));
    expect(tooltip.nativeElement.classList).toContain('tooltip-top');
  });

  it('should add tooltip-{placement} class to the container', () => {
    fixture = createGenericTestComponent(`
      <tooltip-window placement="bottom">Custom Content!</tooltip-window>
    `, TestComponent);

    const tooltip = fixture.debugElement.query(By.css('.tooltip'));
    expect(tooltip.nativeElement.classList).toContain('tooltip-bottom');
  });

Add a placement property with top as default value and build class with it. And it will be shown as default with show class.

  @Input() placement = 'top';
  @HostBinding('class') hostClass: string;
  ngOnInit() {
    this.hostClass = `tooltip show tooltip-${this.placement}`;
  }
<div class="tooltip-inner">
  <ng-content></ng-content>
</div>

Notice - add tooltip class with @HostBinding

It will be shown as:

Create a simple tooltip directive and only support string content.

We will create a attribute directive with selector [myTooltip]. Get the content with myTooltip input. And show the tips with a dynamically loaded TooltipWindowComponent.

Init the spec enviroment

@NgModule({
  imports: [],
  exports: [],
  declarations: [TooltipWindowComponent],
  providers: [],
  entryComponents: [
    TooltipWindowComponent
  ]
})
export class TestModule { }

@Component({
  selector: 'test-component',
  template: ''
})
export class TestComponent {
}

describe('MyTooltipDirective', () => {
  let component: TestComponent;
  let fixture: ComponentFixture<TestComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [TestModule],
      declarations: [TestComponent, MyTooltipDirective]
    });
  }));


  it('should be created', () => {
    fixture = createGenericTestComponent(`
      <button myTooltip="Tooltip content.">Button</button>
    `, TestComponent);

    component = fixture.componentInstance;
    expect(component).toBeTruthy();
  });
});

When we use myTooltip in an element, a tooltip will be shown when the mosue moves over the element. We will finish this feature step by step.

  it('should show the tooltip when mose moves over the element', () => {
    fixture = createGenericTestComponent(`
      <button myTooltip="Tooltip content.">Button</button>
    `, TestComponent);

    const button: HTMLElement = fixture.debugElement.query(By.css('button')).nativeElement;
    button.dispatchEvent(new Event('mouseover'));

    const tooltip = fixture.debugElement.query(By.css('.tooltip-inner'));
    expect(tooltip).toBeTruthy();
  });

When mouse overs the element, open method will be called.

export class TestComponent {
  @ViewChild(MyTooltipDirective) myTooltipDirective: MyTooltipDirective;
}

  it('should call open method when mouseover', () => {
    fixture = createGenericTestComponent(`
      <button myTooltip="Tooltip content.">Button</button>
    `, TestComponent);
    component = fixture.componentInstance;

    const spy = spyOn(component.myTooltipDirective, 'open');

    const button: HTMLElement = fixture.debugElement.query(By.css('button')).nativeElement;
    button.dispatchEvent(new Event('mouseover'));

    expect(spy).toHaveBeenCalled();
  });

Add a HostListener and call open method when mouse over

  @HostListener('mouseover') open() {
    console.log('open method is called');
  }

We load a new TooltipWindowComponent when the directive has been inited with PopupService

  private popupService: PopupService<TooltipWindowComponent>;
  private windowRef: ComponentRef<TooltipWindowComponent>;

  constructor(
    private injector: Injector,
    private viewContainerRef: ViewContainerRef,
    private renderer: Renderer,
    private componentFactoryResolver: ComponentFactoryResolver
  ) { }

  ngOnInit(): void {
    this.popupService = new PopupService<TooltipWindowComponent>(
      TooltipWindowComponent,
      this.injector,
      this.viewContainerRef,
      this.renderer,
      this.componentFactoryResolver
    );
  }

When open method is called, we open the component

  @HostListener('mouseover') open() {
    this.windowRef = this.popupService.open(this.myTooltip);
  }

When mouse over the button, we will reset the tooltip’s position with default top placement

  it('should reset the position of the tooltip when mouseover with default `top` placement', () => {
    fixture = createGenericTestComponent(`
      <button myTooltip="Tooltip content.">Button</button>
    `, TestComponent);

    const button: HTMLElement = fixture.debugElement.query(By.css('button')).nativeElement;
    button.dispatchEvent(new Event('mouseover'));

    const tooltip = fixture.debugElement.query(By.css('tooltip-window')).nativeElement;

    const expectedLeft = button.getBoundingClientRect().left + button.offsetWidth / 2 - tooltip.offsetWidth / 2;

    expect(tooltip.style.top).toBe((button.getBoundingClientRect().top - tooltip.offsetHeight) + 'px');
    expect(tooltip.style.left).toBe(expectedLeft + 'px');
  });

Reset the position with positioning function.

  @HostListener('mouseover') open() {
    this.windowRef = this.popupService.open(this.myTooltip);
    positionElements(
      this.elementRef.nativeElement,
      this.windowRef.location.nativeElement,
      this.placement,
      false
    );
  }

But we found that the open method will be called twice, when the mouse over. And the tooltip’s position will be set twice. Fix it with an injected ngZone: NgZone

  @HostListener('mouseover') open() {
    this.windowRef = this.popupService.open(this.myTooltip);

    this.ngZone.onStable.subscribe(() => {
      positionElements(
        this.elementRef.nativeElement,
        this.windowRef.location.nativeElement,
        this.placement,
        false
      );
    });
  }

Hide the tooltip when mouseout

  it('should hide the tooltip when mose moves out the element', () => {
    fixture = createGenericTestComponent(`
      <button myTooltip="Tooltip content.">Button</button>
    `, TestComponent);

    component = fixture.componentInstance;
    component.myTooltipDirective.open();

    const button: HTMLElement = fixture.debugElement.query(By.css('button')).nativeElement;
    button.dispatchEvent(new Event('mouseout'));

    const tooltip = fixture.debugElement.query(By.css('.tooltip-inner'));
    expect(tooltip).toBeFalsy();
  });
  @HostListener('mouseout') close() {
    if (this.windowRef) {
      this.popupService.close();
      this.windowRef = null;
    }
  }

The tooltip should supports templateRef as content to be shown.

  it('should support tempalteRef as content', () => {
    fixture = createGenericTestComponent(`
      <button [myTooltip]="tooltipTemplate">Button</button>
      <ng-template #tooltipTemplate>
        Contents in a template!
      </ng-template>
    `, TestComponent);

    component = fixture.componentInstance;
    component.myTooltipDirective.open();

    const tooltip = fixture.debugElement.query(By.css('.tooltip-inner'));
    expect(tooltip.nativeElement.textContent).toContain('Contents in a template!');
  });

PopupService has already support TemplateRef, just change myTooltip's type to be string | TemplateRef<any>

  @Input() myTooltip: string | TemplateRef<any>;

The placement property should support values "top" | "bottom" | "left" | "right"

  it('should support `right` placement', () => {
    fixture = createGenericTestComponent(`
      <button myTooltip="Tooltip content." placement="right">Button</button>
    `, TestComponent);

    const button: HTMLElement = fixture.debugElement.query(By.css('button')).nativeElement;
    button.dispatchEvent(new Event('mouseover'));

    const tooltip = fixture.debugElement.query(By.css('tooltip-window')).nativeElement;

    const expectedTop = Math.round(button.getBoundingClientRect().top + button.offsetHeight / 2 - tooltip.offsetHeight / 2);
    const expectedLeft = Math.round(button.getBoundingClientRect().right);

    expect(tooltip.style.top).toBe(expectedTop + 'px');
    expect(tooltip.style.left).toBe(expectedLeft + 'px');
  });

Set TooltipWindowComponent’s placement.

    this.windowRef.instance.placement = this.placement;

The tooltip can be appended in body

  it('should support to append tooltip window in body', () => {
    fixture = createGenericTestComponent(`
      <button [myTooltip]="Content" container="body">Button</button>
    `, TestComponent);

    component = fixture.componentInstance;
    component.myTooltipDirective.open();

    const tooltipInBody = document.querySelector('tooltip-window');
    expect(tooltipInBody.parentElement.nodeName.toLowerCase()).toBe('body');
  });

Move tooltip window to body and positioning the window relative to body

        positionElements(
          this.elementRef.nativeElement,
          this.windowRef.location.nativeElement,
          this.placement,
          this.container === 'body'
        );

  @HostListener('mouseover') open() {
    this.windowRef = this.popupService.open(this.myTooltip);
    this.windowRef.instance.placement = this.placement;

    if (this.container === 'body') {
      window.document.querySelector(this.container).appendChild(this.windowRef.location.nativeElement);
    }
  }

It should support isOpen method to check whether the tooltip window is opened.

  it('should support `isOpen` method to check whether the window is opened', () => {
    fixture = createGenericTestComponent(`
      <button [myTooltip]="Content">Button</button>
    `, TestComponent);

    component = fixture.componentInstance;

    expect(component.myTooltipDirective.isOpen()).toBeFalsy();

    component.myTooltipDirective.open();

    expect(component.myTooltipDirective.isOpen()).toBeTruthy();
  });

Check open status base on this.windowRef

  isOpen() {
    return this.windowRef != null;
  }

We should get shown and hidden events when status changed.

  it('should catch events when status changed', () => {
    fixture = createGenericTestComponent(`
      <button [myTooltip]="Content" (shown)="onOpen($event)" (hidden)="onClose($event)">Button</button>
    `, TestComponent);
    component = fixture.componentInstance;

    const openSpy = spyOn(component, 'onOpen');
    const closeSpy = spyOn(component, 'onClose');

    component.myTooltipDirective.open();
    expect(openSpy).toHaveBeenCalled();

    component.myTooltipDirective.close();
    expect(closeSpy).toHaveBeenCalled();
  });

Create shown and hidden EventEmitter, emit event when open and close method is called.

  @Output() shown = new EventEmitter();
  @Output() hidden = new EventEmitter();
  @HostListener('mouseover') open() {
    this.windowRef = this.popupService.open(this.myTooltip);
    this.windowRef.instance.placement = this.placement;

    if (this.container === 'body') {
      window.document.querySelector(this.container).appendChild(this.windowRef.location.nativeElement);
    }

    this.shown.emit();
  }

  @HostListener('mouseout') close() {
    if (this.windowRef) {
      this.popupService.close();
      this.windowRef = null;

      this.hidden.emit();
    }
  }

When opening the tooltip window, pass a context and insert into the template

  it('should pass a context when opening the tooltip', () => {
    fixture = createGenericTestComponent(`
      <button [myTooltip]="tooltipTemplate">Button</button>
      <ng-template #tooltipTemplate let-name="name">
        Contents in a template -  !
      </ng-template>
    `, TestComponent);

    component = fixture.componentInstance;

    component.myTooltipDirective.open({name: 'Stone'});

    fixture.detectChanges(); // Important!!!

    const tooltip = fixture.debugElement.query(By.css('.tooltip-inner')).nativeElement;
    expect(tooltip.textContent).toContain('Stone');
  });

Pass a context method to PopupService and show the template content.

  @HostListener('mouseover') open(context?: any) {
    this.windowRef = this.popupService.open(this.myTooltip, context);
    // others
  }

Compare with source of ng-bootstrap

Accessibility with aria-describedby - the host element is described by the tooltip window

TooltipWindowComponent will set id attribute by @HostBinding

export class TooltipWindowComponent implements OnInit {

  @Input() placement = 'top';
  @HostBinding('class') hostClass: string;
  @HostBinding('id') id: string;

  constructor() { }

  ngOnInit() {
    this.hostClass = `tooltip show tooltip-${this.placement}`;
  }
}

When opening, set the component instance’s id.

  private ngbTooltipWindowId = `ngb-tooltip-${nextId++}`;

  open() {
      // others
      this.renderer.setElementAttribute(this.elementRef.nativeElement, 'aria-describedby', this.ngbTooltipWindowId);
      // others
  }

When closing, remove the attribute in close method

      this.renderer.setElementAttribute(this.elementRef.nativeElement, 'aria-describedby', null);

When destroying the directive, unsubscribe ngZone

      this.zoneSubscription.unsubscribe();

toggle supports in ng-bootstrap

Specifies events that should trigger. Supports a space separated list of event names.

It can be used like:

<!-- open when click and close on blur -->
<button type="button" class="btn btn-secondary" ngbTooltip="You see, I show up on click!" triggers="click:blur">
  Click me!
</button>

<!-- when using 'manual', only support open/close by calling methods -->
<button type="button" class="btn btn-secondary" ngbTooltip="What a great tip!" triggers="manual" #t="ngbTooltip" (click)="t.open()">
  Click me to open a tooltip
</button>
<button type="button" class="btn btn-secondary" (click)="t.close()">
  Click me to close a tooltip
</button>

The trigglers is processed in ngOnInit method

  ngOnInit() {
    this._unregisterListenersFn = listenToTriggers(
        this._renderer, this._elementRef.nativeElement, this.triggers, this.open.bind(this), this.close.bind(this),
        this.toggle.bind(this));
  }
export function listenToTriggers(renderer: any, nativeElement: any, triggers: string, openFn, closeFn, toggleFn) { }

The listenToTriggers function is supplied by util/triggers.

  • renderer is used to listen events.
  • nativeElement is the target to be bind events.
  • triggers is event names split with :
  • openFn, closeFn, toggleFn bind to the real functions to be done.