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 { isNull, uniqueId } from 'lodash-es'; import { TooltipComponent } from './tooltip.component'; const OUTLINE_INDENT = 4; // Outline offset (2) + outline width (2) @Directive({ selector: '[tooltip]', standalone: true, }) export class TooltipDirective implements OnDestroy { @Input() tooltip: string = ''; componentRef: ComponentRef<TooltipComponent> = null; focusableElement: HTMLElement = null; tooltipId: string; public viewContainerRef: ViewContainerRef = inject(ViewContainerRef); public elementRef: ElementRef<HTMLElement> = inject(ElementRef); public renderer: Renderer2 = inject(Renderer2); public interactivityChecker: InteractivityChecker = inject(InteractivityChecker); ngOnDestroy(): void { this.destroy(); } @HostListener('mouseenter') @HostListener('focusin') createTooltip(): void { if (isNotNull(this.componentRef)) { return; } const nativeElement: HTMLElement = this.elementRef.nativeElement; const attachedToFocused: boolean = nativeElement.contains(document.activeElement); this.componentRef = this.viewContainerRef.createComponent(TooltipComponent); nativeElement.appendChild(this.componentRef.location.nativeElement); this.setAriaDescribedBy(); this.setTooltipProperties(attachedToFocused); } @HostListener('mouseleave') @HostListener('window:scroll') @HostListener('focusout') destroyTooltip(): void { this.destroy(); } @HostListener('keydown', ['$event']) onKeydown(e: KeyboardEvent): void { if (isEscapeKey(e)) { this.destroy(); } } setTooltipProperties(attachedToFocused = false): void { if (isNull(this.componentRef)) { return; } const { left, right, bottom } = this.elementRef.nativeElement.getBoundingClientRect(); this.componentRef.instance.left = (right + left) / 2; this.componentRef.instance.top = attachedToFocused ? bottom + OUTLINE_INDENT : bottom; this.componentRef.instance.text = this.tooltip; this.componentRef.instance.id = this.tooltipId; } setAriaDescribedBy(): void { const nativeElement: HTMLElement = this.elementRef.nativeElement; this.tooltipId = uniqueId('tooltip'); this.focusableElement = this.interactivityChecker.isFocusable(nativeElement) ? nativeElement : this.getFocusableElement(nativeElement); this.renderer.setAttribute(this.focusableElement, 'aria-describedby', this.tooltipId); } removeAriaDescribedBy(): void { this.renderer.removeAttribute(this.focusableElement, 'aria-describedby'); } getFocusableElement(element: HTMLElement): HTMLElement { return element.querySelector('a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'); } destroy(): void { if (isNull(this.componentRef)) { return; } this.componentRef.destroy(); this.componentRef = null; this.removeAriaDescribedBy(); this.focusableElement = null; } }