Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
Directive,
DoCheck,
ElementRef,
HostListener,
inject,
Injector,
input,
Expand Down Expand Up @@ -58,19 +57,15 @@ export class SiFormValidationTooltipDirective implements OnDestroy, DoCheck {
private readonly errors = signal<SiFormError[]>([]);
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();
}
}
Expand All @@ -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(
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,25 @@ describe('SiFormValidationTooltipDirective', () => {
control = new FormControl('');
}

const hoverDelay = 500;
let fixture: ComponentFixture<TestHostComponent>;
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');
Expand All @@ -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();
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,19 @@ export class SiFormValidationTooltipHarness extends ComponentHarness {
static readonly hostSelector = 'input';

async hover(): Promise<void> {
this.host().then(host => host.hover());
await this.host().then(host => host.hover());
}

async mouseAway(): Promise<void> {
this.host().then(host => host.mouseAway());
await this.host().then(host => host.mouseAway());
}

async focus(): Promise<void> {
this.host().then(host => host.focus());
await this.host().then(host => host.focus());
}

async blur(): Promise<void> {
this.host().then(host => host.blur());
await this.host().then(host => host.blur());
}

async sendKeys(...keys: (string | TestKey)[]): Promise<void> {
Expand Down
77 changes: 22 additions & 55 deletions projects/element-ng/tooltip/si-tooltip.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -62,57 +56,30 @@ export class SiTooltipDirective implements OnDestroy {
protected describedBy = `__tooltip_${SiTooltipDirective.idCounter++}`;

private tooltipRef?: TooltipRef;
private showTimeout?: ReturnType<typeof setTimeout>;
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();
}
}
73 changes: 64 additions & 9 deletions projects/element-ng/tooltip/si-tooltip.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,15 +19,64 @@ import { SI_TOOLTIP_CONFIG, SiTooltipContent } from './si-tooltip.model';
* @internal
*/
class TooltipRef {
private readonly destroy$ = new Subject<void>();
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());
}
Comment on lines +30 to +51
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The TooltipRef constructor directly subscribes to DOM events using fromEvent. This will fail during Server-Side Rendering (SSR) as these events and nativeElement are browser-specific. This change removes a previous isPlatformBrowser check, introducing a regression.

This violates a general rule for our repository: 'When using browser-dependent UI features like Angular CDK Overlay, ensure they are only executed in a browser environment. Use a check like isPlatformBrowser to prevent errors during Server-Side Rendering (SSR).'

To fix this, you should ensure this logic only runs in a browser environment. The cleanest way would be to inject PLATFORM_ID in SiTooltipService and pass it down to TooltipRef.

Here is an example of how you could implement this:

  1. Modify SiTooltipService to inject PLATFORM_ID and pass it to TooltipRef:

    // projects/element-ng/tooltip/si-tooltip.service.ts
    @Injectable()
    export class SiTooltipService {
      private overlay = inject(Overlay);
      private platformId = inject(PLATFORM_ID); // Add this
    
      createTooltip(config: { /*...*/ }): TooltipRef {
        // ...
        return new TooltipRef(
          getOverlay(config.element, this.overlay, false, config.placement),
          config.element,
          injector,
          this.platformId // Pass it here
        );
      }
    }
  2. Update TooltipRef to accept platformId and guard the browser-specific code:

    // projects/element-ng/tooltip/si-tooltip.service.ts
    class TooltipRef {
      // ...
      constructor(
        private overlayRef: OverlayRef,
        private element: ElementRef,
        private injector: Injector | undefined,
        private platformId: object // Add this
      ) {
        if (isPlatformBrowser(this.platformId)) {
          const nativeElement = this.element.nativeElement;
          // Move all fromEvent subscriptions inside this block
          fromEvent(nativeElement, 'focus')
            .pipe(takeUntil(this.destroy$))
            .subscribe(event => this.onFocus(event));
          // ... other subscriptions
        }
      }
      // ...
    }

You will also need to add the necessary imports for PLATFORM_ID and isPlatformBrowser.

References
  1. When using browser-dependent UI features like Angular CDK Overlay, ensure they are only executed in a browser environment. Use a check like isPlatformBrowser to prevent errors during Server-Side Rendering (SSR).


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;
}
Expand All @@ -35,20 +85,25 @@ class TooltipRef {
const tooltipRef: ComponentRef<TooltipComponent> = this.overlayRef.attach(toolTipPortal);

const positionStrategy = getPositionStrategy(this.overlayRef);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somehow the auto placement looks different now, any idea why?

Image Image

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();
}
}

Expand Down
Loading