/* * 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 { 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'; 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'; @Directive({ selector: '[tooltip]', standalone: true, }) 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'; componentRef: ComponentRef<TooltipComponent> = null; parentElement: HTMLElement = null; tooltipId: string; 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') @HostListener('focusin') showTooltip(): void { if (isNull(this.componentRef)) { return; } const nativeElement: HTMLElement = this.elementRef.nativeElement; this.attachedToFocused = nativeElement.contains(document.activeElement); this.setTooltipProperties(); } @HostListener('mouseleave') @HostListener('focusout') 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; } 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; } 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; return; } 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); } hide(): void { if (isNull(this.componentRef)) { return; } this.componentRef.instance.show = false; } destroy(): void { if (isNull(this.componentRef)) { return; } this.componentRef.destroy(); this.componentRef = null; this.removeAriaAttribute(this.tooltipAriaType); this.parentElement = null; } }