diff --git a/projects/element-ng/form/si-form-validation-tooltip/si-form-validation-tooltip.directive.ts b/projects/element-ng/form/si-form-validation-tooltip/si-form-validation-tooltip.directive.ts index 35a5c6f80..80e8d7b11 100644 --- a/projects/element-ng/form/si-form-validation-tooltip/si-form-validation-tooltip.directive.ts +++ b/projects/element-ng/form/si-form-validation-tooltip/si-form-validation-tooltip.directive.ts @@ -6,7 +6,6 @@ import { Directive, DoCheck, ElementRef, - HostListener, inject, Injector, input, @@ -58,19 +57,15 @@ export class SiFormValidationTooltipDirective implements OnDestroy, DoCheck { private readonly errors = signal([]); private currentErrors: ValidationErrors | null = null; private touched: boolean | null = null; - - // Use a counter to track how many events are matched that keep the tooltip active. - // Active means we are listening to error changes. - private activationCount = 0; protected readonly describedBy = `__si-form-validation-tooltip-${SiFormValidationTooltipDirective.idCounter++}`; ngDoCheck(): void { - if ( - this.activationCount && - (this.currentErrors !== this.ngControl.errors || this.touched !== this.ngControl.touched) - ) { - this.currentErrors = this.ngControl.errors; - this.touched = this.ngControl.touched; + const nextErrors = this.ngControl.errors; + const nextTouched = this.ngControl.touched; + + if (this.currentErrors !== nextErrors || this.touched !== nextTouched) { + this.currentErrors = nextErrors; + this.touched = nextTouched; this.updateErrors(); } } @@ -79,15 +74,6 @@ export class SiFormValidationTooltipDirective implements OnDestroy, DoCheck { this.destroyTooltip(); } - @HostListener('focus') - @HostListener('mouseenter') - protected increaseActivation(): void { - this.activationCount++; - if (this.activationCount === 1) { - this.updateErrors(); - } - } - private updateErrors(): void { const errors = this.formValidationService .resolveErrors( @@ -98,32 +84,28 @@ export class SiFormValidationTooltipDirective implements OnDestroy, DoCheck { ) .filter(error => !!error.message); - if (!this.tooltipRef && errors.length && this.ngControl.touched) { - this.tooltipRef = this.tooltipService.createTooltip({ - placement: 'auto', - element: this.elementRef, - describedBy: this.describedBy, - injector: Injector.create({ - providers: [{ provide: SI_FORM_VALIDATION_TOOLTIP_DATA, useValue: this.errors }] - }), - tooltip: () => SiFormValidationTooltipComponent, - tooltipContext: () => undefined - }); - this.tooltipRef.show(); - } else if (this.tooltipRef && (!errors.length || this.ngControl.pristine)) { + const shouldShow = errors.length > 0 && !!this.ngControl.touched; + + if (shouldShow) { + this.createTooltip(); + } else { this.destroyTooltip(); } this.errors.set(errors); } - @HostListener('blur') - @HostListener('mouseleave') - protected decreaseActivation(): void { - this.activationCount--; - if (!this.activationCount) { - this.destroyTooltip(); - } + private createTooltip(): void { + this.tooltipRef ??= this.tooltipService.createTooltip({ + placement: 'auto', + element: this.elementRef, + describedBy: this.describedBy, + injector: Injector.create({ + providers: [{ provide: SI_FORM_VALIDATION_TOOLTIP_DATA, useValue: this.errors }] + }), + tooltip: () => SiFormValidationTooltipComponent, + tooltipContext: () => undefined + }); } private destroyTooltip(): void { diff --git a/projects/element-ng/form/si-form-validation-tooltip/si-form-validation-tooltip.spec.ts b/projects/element-ng/form/si-form-validation-tooltip/si-form-validation-tooltip.spec.ts index d93ae55b9..9f4dbc19e 100644 --- a/projects/element-ng/form/si-form-validation-tooltip/si-form-validation-tooltip.spec.ts +++ b/projects/element-ng/form/si-form-validation-tooltip/si-form-validation-tooltip.spec.ts @@ -19,18 +19,25 @@ describe('SiFormValidationTooltipDirective', () => { control = new FormControl(''); } + const hoverDelay = 500; let fixture: ComponentFixture; let harness: SiFormValidationTooltipHarness; beforeEach(async () => { + jasmine.clock().install(); fixture = TestBed.createComponent(TestHostComponent); harness = await TestbedHarnessEnvironment.loader(fixture).getHarness( SiFormValidationTooltipHarness ); }); + afterEach(() => { + jasmine.clock().uninstall(); + }); + it('should show tooltip when control is hovered and becomes touched', async () => { await harness.hover(); + jasmine.clock().tick(hoverDelay); await harness.focus(); await harness.blur(); expect(await harness.getTooltip()).toBe('Required'); @@ -50,6 +57,7 @@ describe('SiFormValidationTooltipDirective', () => { it('should hide tooltip when control becomes valid', async () => { fixture.componentInstance.control.markAsTouched(); await harness.hover(); + jasmine.clock().tick(hoverDelay); expect(await harness.getTooltip()).toBe('Required'); await harness.sendKeys('Lorem ipsum'); expect(await harness.getTooltip()).toBeFalsy(); @@ -58,6 +66,7 @@ describe('SiFormValidationTooltipDirective', () => { it('should hide tooltip when control becomes untouched', async () => { fixture.componentInstance.control.markAsTouched(); await harness.hover(); + jasmine.clock().tick(hoverDelay); expect(await harness.getTooltip()).toBe('Required'); fixture.componentInstance.control.markAsUntouched(); expect(await harness.getTooltip()).toBeFalsy(); diff --git a/projects/element-ng/form/testing/si-form-validation-tooltip.harness.ts b/projects/element-ng/form/testing/si-form-validation-tooltip.harness.ts index 458e3bd6a..88f63f917 100644 --- a/projects/element-ng/form/testing/si-form-validation-tooltip.harness.ts +++ b/projects/element-ng/form/testing/si-form-validation-tooltip.harness.ts @@ -8,19 +8,19 @@ export class SiFormValidationTooltipHarness extends ComponentHarness { static readonly hostSelector = 'input'; async hover(): Promise { - this.host().then(host => host.hover()); + await this.host().then(host => host.hover()); } async mouseAway(): Promise { - this.host().then(host => host.mouseAway()); + await this.host().then(host => host.mouseAway()); } async focus(): Promise { - this.host().then(host => host.focus()); + await this.host().then(host => host.focus()); } async blur(): Promise { - this.host().then(host => host.blur()); + await this.host().then(host => host.blur()); } async sendKeys(...keys: (string | TestKey)[]): Promise { diff --git a/projects/element-ng/tooltip/si-tooltip.directive.ts b/projects/element-ng/tooltip/si-tooltip.directive.ts index 7071031a2..1c81a39ad 100644 --- a/projects/element-ng/tooltip/si-tooltip.directive.ts +++ b/projects/element-ng/tooltip/si-tooltip.directive.ts @@ -2,15 +2,14 @@ * Copyright (c) Siemens 2016 - 2026 * SPDX-License-Identifier: MIT */ -import { isPlatformBrowser } from '@angular/common'; import { booleanAttribute, Directive, + effect, ElementRef, inject, input, OnDestroy, - PLATFORM_ID, TemplateRef } from '@angular/core'; import { positions } from '@siemens/element-ng/common'; @@ -22,12 +21,7 @@ import { SiTooltipService, TooltipRef } from './si-tooltip.service'; selector: '[siTooltip]', providers: [SiTooltipService], host: { - '[attr.aria-describedby]': 'describedBy', - '(focus)': 'focusIn($event)', - '(mouseenter)': 'show()', - '(touchstart)': 'hide()', - '(focusout)': 'hide()', - '(mouseleave)': 'hide()' + '[attr.aria-describedby]': 'describedBy' } }) export class SiTooltipDirective implements OnDestroy { @@ -62,57 +56,30 @@ export class SiTooltipDirective implements OnDestroy { protected describedBy = `__tooltip_${SiTooltipDirective.idCounter++}`; private tooltipRef?: TooltipRef; - private showTimeout?: ReturnType; private tooltipService = inject(SiTooltipService); private elementRef = inject(ElementRef); - private isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); - ngOnDestroy(): void { - this.clearShowTimeout(); - this.tooltipRef?.destroy(); - } - - private clearShowTimeout(): void { - if (this.showTimeout) { - clearTimeout(this.showTimeout); - this.showTimeout = undefined; - } + constructor() { + effect(() => { + const tooltip = this.siTooltip(); + const disabled = this.isDisabled(); + + if (tooltip && !disabled) { + this.tooltipRef ??= this.tooltipService.createTooltip({ + describedBy: this.describedBy, + element: this.elementRef, + placement: this.placement(), + tooltip: this.siTooltip, + tooltipContext: this.tooltipContext + }); + } else if (this.tooltipRef) { + this.tooltipRef.destroy(); + this.tooltipRef = undefined; + } + }); } - private showTooltip(immediate = false): void { - const siTooltip = this.siTooltip(); - if (this.isDisabled() || !siTooltip) { - return; - } - - this.clearShowTimeout(); - - const delay = immediate ? 0 : 500; - - this.showTimeout = setTimeout(() => { - this.tooltipRef ??= this.tooltipService.createTooltip({ - describedBy: this.describedBy, - element: this.elementRef, - placement: this.placement(), - tooltip: this.siTooltip, - tooltipContext: this.tooltipContext - }); - this.tooltipRef.show(); - }, delay); - } - - protected focusIn(event: FocusEvent): void { - if (this.isBrowser && (event.target as Element).matches(':focus-visible')) { - this.showTooltip(true); - } - } - - protected show(): void { - this.showTooltip(false); - } - - protected hide(): void { - this.clearShowTimeout(); - this.tooltipRef?.hide(); + ngOnDestroy(): void { + this.tooltipRef?.destroy(); } } diff --git a/projects/element-ng/tooltip/si-tooltip.service.ts b/projects/element-ng/tooltip/si-tooltip.service.ts index ef2b965df..2ed4f2014 100644 --- a/projects/element-ng/tooltip/si-tooltip.service.ts +++ b/projects/element-ng/tooltip/si-tooltip.service.ts @@ -6,7 +6,8 @@ import { Overlay, OverlayRef } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; import { ComponentRef, ElementRef, inject, Injectable, Injector } from '@angular/core'; import { getOverlay, getPositionStrategy, positions } from '@siemens/element-ng/common'; -import { Subscription } from 'rxjs'; +import { fromEvent, Subject, Subscription, timer } from 'rxjs'; +import { delayWhen, takeUntil } from 'rxjs/operators'; import { TooltipComponent } from './si-tooltip.component'; import { SI_TOOLTIP_CONFIG, SiTooltipContent } from './si-tooltip.model'; @@ -18,15 +19,64 @@ import { SI_TOOLTIP_CONFIG, SiTooltipContent } from './si-tooltip.model'; * @internal */ class TooltipRef { + private readonly destroy$ = new Subject(); + private isFocused = false; + private isHovered = false; + constructor( private overlayRef: OverlayRef, private element: ElementRef, private injector?: Injector - ) {} + ) { + const nativeElement = this.element.nativeElement; + + fromEvent(nativeElement, 'focus') + .pipe(takeUntil(this.destroy$)) + .subscribe(event => this.onFocus(event)); + + fromEvent(nativeElement, 'mouseenter') + .pipe( + takeUntil(this.destroy$), + delayWhen(() => timer(500).pipe(takeUntil(fromEvent(nativeElement, 'mouseleave')))) + ) + .subscribe(() => this.onMouseEnter()); - private subscription?: Subscription; + fromEvent(nativeElement, 'focusout') + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.onFocusOut()); + + fromEvent(nativeElement, 'mouseleave') + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.onMouseLeave()); + } + + private positionSubscription?: Subscription; + + private onFocus(event: unknown): void { + if (event instanceof FocusEvent && event.target instanceof Element) { + if ((event.target as Element).matches(':focus-visible')) { + this.isFocused = true; + this.show(); + } + } + } - show(): void { + private onFocusOut(): void { + this.isFocused = false; + this.hide(); + } + + private onMouseEnter(): void { + this.isHovered = true; + this.show(); + } + + private onMouseLeave(): void { + this.isHovered = false; + this.hide(); + } + + private show(): void { if (this.overlayRef.hasAttached()) { return; } @@ -35,20 +85,25 @@ class TooltipRef { const tooltipRef: ComponentRef = this.overlayRef.attach(toolTipPortal); const positionStrategy = getPositionStrategy(this.overlayRef); - this.subscription?.unsubscribe(); - this.subscription = positionStrategy?.positionChanges.subscribe(change => + this.positionSubscription?.unsubscribe(); + this.positionSubscription = positionStrategy?.positionChanges.subscribe(change => tooltipRef.instance.updateTooltipPosition(change, this.element) ); } - hide(): void { + private hide(): void { + if (this.isFocused || this.isHovered) { + return; + } this.overlayRef.detach(); - this.subscription?.unsubscribe(); + this.positionSubscription?.unsubscribe(); } destroy(): void { + this.destroy$.next(); + this.destroy$.complete(); this.overlayRef.dispose(); - this.subscription?.unsubscribe(); + this.positionSubscription?.unsubscribe(); } }