Newer
Older
/*
* 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';
import { ComponentRef, Directive, ElementRef, HostListener, inject, Input, OnDestroy, Renderer2, ViewContainerRef, } from '@angular/core';
import { isEmpty, isNull, uniqueId } from 'lodash-es';
import { TooltipComponent } from './tooltip.component';
export enum TooltipPosition {
ABOVE = 'above',
BELOW = 'below',
}
const OUTLINE_INDENT = 4; // Outline offset (2) + outline width (2)
type TooltipAriaType = 'aria-describedby' | 'aria-labelledby';
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';
attachedToFocused: boolean = false;
position: TooltipPosition;
leftOffset: number = 0;
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);
ngOnDestroy(): void {
this.destroy();
}
@HostListener('mouseenter')
if (isNull(this.componentRef)) {
return;
}
const nativeElement: HTMLElement = this.elementRef.nativeElement;
this.attachedToFocused = nativeElement.contains(document.activeElement);
this.setTooltipProperties();
hideTooltip(): void {
this.hide();
@HostListener('keydown', ['$event'])
onKeydown(e: KeyboardEvent): void {
if (isEscapeKey(e)) {
this.hide();
}
}
createTooltip(tooltipText: string): void {
if (isEmpty(tooltipText)) {
return;
}
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;
}
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;
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;
return;
}
this.leftOffset = 0;
}
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);
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;
}
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;
}
if (isNull(this.componentRef)) {
return;
}
this.componentRef.destroy();
this.componentRef = null;
this.removeAriaAttribute(this.tooltipAriaType);