Skip to content
Snippets Groups Projects
tooltip.directive.ts 6.81 KiB
Newer Older
  • Learn to ignore specific revisions
  • /*
     * Copyright (C) 2024 Das Land Schleswig-Holstein vertreten durch den
     * Ministerpräsidenten des Landes Schleswig-Holstein
     * Staatskanzlei
     * Abteilung Digitalisierung und zentrales IT-Management der Landesregierung
     *
     * Lizenziert unter der EUPL, Version 1.2 oder - sobald
     * diese von der Europäischen Kommission genehmigt wurden -
     * Folgeversionen der EUPL ("Lizenz");
     * Sie dürfen dieses Werk ausschließlich gemäß
     * dieser Lizenz nutzen.
     * Eine Kopie der Lizenz finden Sie hier:
     *
     * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
     *
     * Sofern nicht durch anwendbare Rechtsvorschriften
     * gefordert oder in schriftlicher Form vereinbart, wird
     * die unter der Lizenz verbreitete Software "so wie sie
     * ist", OHNE JEGLICHE GEWÄHRLEISTUNG ODER BEDINGUNGEN -
     * ausdrücklich oder stillschweigend - verbreitet.
     * Die sprachspezifischen Genehmigungen und Beschränkungen
     * unter der Lizenz sind dem Lizenztext zu entnehmen.
     */
    
    import { isEscapeKey, isNotNull } from '@alfa-client/tech-shared';
    
    OZGCloud's avatar
    OZGCloud committed
    import { InteractivityChecker } from '@angular/cdk/a11y';
    
    import { ComponentRef, Directive, ElementRef, HostListener, inject, Input, OnDestroy, Renderer2, ViewContainerRef, } from '@angular/core';
    
    import { isEmpty, isNull, uniqueId } from 'lodash-es';
    
    OZGCloud's avatar
    OZGCloud committed
    import { TooltipComponent } from './tooltip.component';
    
    
    export enum TooltipPosition {
      ABOVE = 'above',
      BELOW = 'below',
    }
    
    OZGCloud's avatar
    OZGCloud committed
    const OUTLINE_INDENT = 4; // Outline offset (2) + outline width (2)
    
    type TooltipAriaType = 'aria-describedby' | 'aria-labelledby';
    
    OZGCloud's avatar
    OZGCloud committed
    @Directive({
      selector: '[tooltip]',
    
    OZGCloud's avatar
    OZGCloud committed
      standalone: true,
    
    OZGCloud's avatar
    OZGCloud committed
    })
    
    export class TooltipDirective implements OnDestroy {
      @Input() set tooltip(value: string) {
        if (isNotNull(this.componentRef)) {
          this.destroy();
        }
    
        if (isEmpty(value)) {
          return;
        }
    
        this.createTooltip(value);
      }
    
      @Input() tooltipPosition: TooltipPosition = TooltipPosition.BELOW;
    
      @Input() tooltipAriaType: TooltipAriaType = 'aria-describedby';
    
    OZGCloud's avatar
    OZGCloud committed
    
    
    OZGCloud's avatar
    OZGCloud committed
      componentRef: ComponentRef<TooltipComponent> = null;
    
      parentElement: HTMLElement = null;
    
    OZGCloud's avatar
    OZGCloud committed
      tooltipId: string;
    
      attachedToFocused: boolean = false;
      position: TooltipPosition;
    
      leftOffset: number = 0;
    
    OZGCloud's avatar
    OZGCloud committed
    
    
      public readonly viewContainerRef: ViewContainerRef = inject(ViewContainerRef);
      public readonly elementRef: ElementRef<HTMLElement> = inject(ElementRef);
      public readonly renderer: Renderer2 = inject(Renderer2);
      public readonly interactivityChecker: InteractivityChecker = inject(InteractivityChecker);
    
    OZGCloud's avatar
    OZGCloud committed
      ngOnDestroy(): void {
        this.destroy();
      }
    
      @HostListener('mouseenter')
    
    OZGCloud's avatar
    OZGCloud committed
      @HostListener('focusin')
    
      showTooltip(): void {
    
        if (isNull(this.componentRef)) {
          return;
        }
    
    
    OZGCloud's avatar
    OZGCloud committed
        const nativeElement: HTMLElement = this.elementRef.nativeElement;
    
        this.attachedToFocused = nativeElement.contains(document.activeElement);
        this.setTooltipProperties();
    
    OZGCloud's avatar
    OZGCloud committed
      }
    
      @HostListener('mouseleave')
    
    OZGCloud's avatar
    OZGCloud committed
      @HostListener('focusout')
    
      hideTooltip(): void {
        this.hide();
    
    OZGCloud's avatar
    OZGCloud committed
      }
    
    
    OZGCloud's avatar
    OZGCloud committed
      @HostListener('keydown', ['$event'])
      onKeydown(e: KeyboardEvent): void {
        if (isEscapeKey(e)) {
    
      createTooltip(tooltipText: string): void {
        if (isEmpty(tooltipText)) {
    
        const nativeElement: HTMLElement = this.elementRef.nativeElement;
        this.componentRef = this.viewContainerRef.createComponent(TooltipComponent);
    
        this.parentElement = this.getParentElement(nativeElement);
        this.parentElement.appendChild(this.componentRef.location.nativeElement);
        this.tooltipId = uniqueId('tooltip');
    
        this.setInitialTooltipProperties(tooltipText, this.tooltipId);
    
        this.setAriaAttribute(this.tooltipAriaType);
    
      setInitialTooltipProperties(text: string, id: string) {
        this.componentRef.instance.text = text;
        this.componentRef.instance.id = id;
      }
    
      setTooltipProperties(): void {
    
        const { left, right, top, bottom } = this.elementRef.nativeElement.getBoundingClientRect();
    
        this.setTooltipOffsetAndPosition();
    
        this.componentRef.instance.left = (right + left) / 2 + this.leftOffset;
    
        this.componentRef.instance.top = this.getTopPosition(top, bottom, this.position, this.attachedToFocused);
        this.componentRef.instance.position = this.position;
    
        this.componentRef.instance.leftOffset = this.leftOffset;
    
        this.componentRef.instance.show = true;
    
    OZGCloud's avatar
    OZGCloud committed
      }
    
    
      setAutoPosition(tooltipHeight: number, parentRect: DOMRect, windowHeight: number): void {
        const { top, bottom } = parentRect;
    
        if (tooltipHeight > windowHeight - bottom) {
          this.position = TooltipPosition.ABOVE;
          return;
        }
    
        if (tooltipHeight > top) {
          this.position = TooltipPosition.BELOW;
          return;
        }
    
        this.position = this.tooltipPosition;
      }
    
    
      setLeftOffset(tooltipWidth: number, parentRect: DOMRect, windowWidth: number): void {
    
        const { left, right, width } = parentRect;
    
        const halfTooltipWidth: number = tooltipWidth / 2;
    
        if (tooltipWidth < width) {
          this.leftOffset = 0;
          return;
        }
    
    
        if (halfTooltipWidth > left) {
          this.leftOffset = halfTooltipWidth - left;
    
        if (halfTooltipWidth > windowWidth - right) {
          this.leftOffset = windowWidth - right - halfTooltipWidth;
    
      setTooltipOffsetAndPosition(): void {
    
        const { width, height } = this.componentRef.location.nativeElement.children[0].getBoundingClientRect();
    
        const parentRect: DOMRect = this.elementRef.nativeElement.getBoundingClientRect();
    
        this.setLeftOffset(width, parentRect, window.innerWidth);
        this.setAutoPosition(height, parentRect, window.innerHeight);
    
      setAriaAttribute(ariaType: TooltipAriaType): void {
    
        this.renderer.setAttribute(this.parentElement, ariaType, this.tooltipId);
    
    OZGCloud's avatar
    OZGCloud committed
      }
    
    
      removeAriaAttribute(ariaType: TooltipAriaType): void {
    
        this.renderer.removeAttribute(this.parentElement, ariaType);
    
      }
    
      getParentElement(element: HTMLElement): HTMLElement {
        if (this.interactivityChecker.isFocusable(element)) {
          return element;
        }
        return this.getFocusableElement(element) ?? element;
    
    OZGCloud's avatar
    OZGCloud committed
      }
    
      getFocusableElement(element: HTMLElement): HTMLElement {
        return element.querySelector('a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])');
      }
    
    
      getTopPosition(parentTop: number, parentBottom: number, tooltipPosition: TooltipPosition, attachedToFocused: boolean): number {
        if (tooltipPosition === TooltipPosition.ABOVE) {
          return parentTop - (attachedToFocused ? OUTLINE_INDENT : 0);
        }
        return parentBottom + (attachedToFocused ? OUTLINE_INDENT : 0);
      }
    
    
        if (isNull(this.componentRef)) {
          return;
        }
    
    
        this.componentRef.instance.show = false;
      }
    
    
    OZGCloud's avatar
    OZGCloud committed
      destroy(): void {
    
    OZGCloud's avatar
    OZGCloud committed
        if (isNull(this.componentRef)) {
          return;
        }
    
    
    OZGCloud's avatar
    OZGCloud committed
        this.componentRef.destroy();
        this.componentRef = null;
    
        this.removeAriaAttribute(this.tooltipAriaType);
    
        this.parentElement = null;
    
    OZGCloud's avatar
    OZGCloud committed
      }
    }