Skip to content

Commit 39a0ae5

Browse files
spike-rabbitspliffone
authored andcommitted
feat(application-header): show tooltip on icon-only header items
Action and account items now show a tooltip with the title when only the icon is visible. The tooltip reuses the existing title via SiTooltipService and adds no extra aria roles.
1 parent b200ce2 commit 39a0ae5

7 files changed

Lines changed: 149 additions & 7 deletions

File tree

api-goldens/element-ng/application-header/index.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { ActivatedRoute } from '@angular/router';
88
import * as _angular_core from '@angular/core';
9+
import { ElementRef } from '@angular/core';
910
import { HeaderWithDropdowns } from '@siemens/element-ng/header-dropdown';
1011
import { Injector } from '@angular/core';
1112
import { IsActiveMatchOptions } from '@angular/router';
@@ -15,6 +16,7 @@ import { OnDestroy } from '@angular/core';
1516
import { OnInit } from '@angular/core';
1617
import * as _siemens_element_ng_application_header from '@siemens/element-ng/application-header';
1718
import * as _siemens_element_translate_ng_translate from '@siemens/element-translate-ng/translate';
19+
import { Signal } from '@angular/core';
1820
import { SiHeaderDropdownTriggerDirective } from '@siemens/element-ng/header-dropdown';
1921
import { SimpleChanges } from '@angular/core';
2022
import { Subject } from 'rxjs';

projects/element-ng/application-header/si-application-header.component.spec.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import {
1313
SiHeaderDropdownTriggerDirective
1414
} from '@siemens/element-ng/header-dropdown';
1515
import { runOnPushChangeDetection } from '@siemens/element-ng/test-helpers';
16+
import { SiTooltipDirective } from '@siemens/element-ng/tooltip';
1617
import { BehaviorSubject, Observable } from 'rxjs';
18+
import { page } from 'vitest/browser';
1719

1820
import { SiLaunchpadFactoryComponent } from './launchpad/si-launchpad-factory.component';
1921
import { SiApplicationHeaderComponent } from './si-application-header.component';
@@ -331,6 +333,98 @@ describe('SiApplicationHeaderComponent', () => {
331333
});
332334
});
333335

336+
describe('with icon-only action items', () => {
337+
@Component({
338+
imports: [
339+
SiApplicationHeaderComponent,
340+
SiHeaderActionItemComponent,
341+
SiHeaderActionsDirective,
342+
SiHeaderCollapsibleActionsComponent
343+
],
344+
template: `
345+
<si-application-header expandBreakpoint="never">
346+
<si-header-actions>
347+
<si-header-collapsible-actions>
348+
<button type="button" si-header-action-item icon="fake-icon">Action 1</button>
349+
</si-header-collapsible-actions>
350+
</si-header-actions>
351+
</si-application-header>
352+
`,
353+
changeDetection: ChangeDetectionStrategy.OnPush
354+
})
355+
class TestHostComponent {}
356+
357+
let fixture: ComponentFixture<TestHostComponent>;
358+
let button: HTMLButtonElement;
359+
360+
beforeEach(async () => {
361+
fixture = TestBed.createComponent(TestHostComponent);
362+
loader = TestbedHarnessEnvironment.loader(fixture);
363+
headerHarness = await loader.getHarness(SiApplicationHeaderHarness);
364+
button = fixture.nativeElement.querySelector('button[si-header-action-item]');
365+
vi.spyOn(button, 'matches').mockImplementation(selector => selector === ':focus-visible');
366+
fixture.detectChanges();
367+
});
368+
369+
it('should show a tooltip with the title when only the icon is visible', async () => {
370+
button.dispatchEvent(new FocusEvent('focus'));
371+
await fixture.whenStable();
372+
await expect.element(page.getByRole('tooltip', { name: 'Action 1' })).toBeInTheDocument();
373+
});
374+
375+
it('should not show a tooltip when the title is visible', async () => {
376+
await headerHarness.openCollapsibleActions();
377+
await fixture.whenStable();
378+
button.dispatchEvent(new FocusEvent('focus'));
379+
await fixture.whenStable();
380+
await expect.element(page.getByRole('tooltip')).not.toBeInTheDocument();
381+
});
382+
});
383+
384+
describe('with icon-only action item and custom tooltip', () => {
385+
@Component({
386+
imports: [
387+
SiApplicationHeaderComponent,
388+
SiHeaderActionItemComponent,
389+
SiHeaderActionsDirective,
390+
SiHeaderCollapsibleActionsComponent,
391+
SiTooltipDirective
392+
],
393+
template: `
394+
<si-application-header expandBreakpoint="never">
395+
<si-header-actions>
396+
<si-header-collapsible-actions>
397+
<button type="button" si-header-action-item icon="fake-icon" siTooltip="Custom">
398+
Action 1
399+
</button>
400+
</si-header-collapsible-actions>
401+
</si-header-actions>
402+
</si-application-header>
403+
`,
404+
changeDetection: ChangeDetectionStrategy.OnPush
405+
})
406+
class TestHostComponent {}
407+
408+
let fixture: ComponentFixture<TestHostComponent>;
409+
let button: HTMLButtonElement;
410+
411+
beforeEach(async () => {
412+
fixture = TestBed.createComponent(TestHostComponent);
413+
loader = TestbedHarnessEnvironment.loader(fixture);
414+
headerHarness = await loader.getHarness(SiApplicationHeaderHarness);
415+
button = fixture.nativeElement.querySelector('button[si-header-action-item]');
416+
vi.spyOn(button, 'matches').mockImplementation(selector => selector === ':focus-visible');
417+
fixture.detectChanges();
418+
});
419+
420+
it('should keep the custom tooltip instead of the title', async () => {
421+
button.dispatchEvent(new FocusEvent('focus'));
422+
await fixture.whenStable();
423+
await expect.element(page.getByRole('tooltip', { name: 'Custom' })).toBeInTheDocument();
424+
await expect.element(page.getByRole('tooltip', { name: 'Action 1' })).not.toBeInTheDocument();
425+
});
426+
});
427+
334428
describe('with launchpad', () => {
335429
@Component({
336430
imports: [

projects/element-ng/application-header/si-header-account-item.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
[initials]="initials()"
66
[imageUrl]="imageUrl()"
77
/>
8-
<div class="item-title" aria-hidden="true" [class.d-none]="visuallyHideTitle">
8+
<div class="item-title" aria-hidden="true" [class.d-none]="visuallyHideTitle()">
99
{{ name() }}
1010
</div>
1111
@if (badgeValue()) {

projects/element-ng/application-header/si-header-account-item.component.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
66
import { SiAvatarComponent } from '@siemens/element-ng/avatar';
77
import { SiIconComponent } from '@siemens/element-ng/icon';
8+
import { SiTooltipService } from '@siemens/element-ng/tooltip';
89

910
import { SiHeaderActionIconItemBase } from './si-header-action-item-icon-base.directive';
1011

@@ -15,6 +16,7 @@ import { SiHeaderActionIconItemBase } from './si-header-action-item-icon-base.di
1516
imports: [SiAvatarComponent, SiIconComponent],
1617
templateUrl: './si-header-account-item.component.html',
1718
styleUrl: './si-header-account-item.component.scss',
19+
providers: [SiTooltipService],
1820
changeDetection: ChangeDetectionStrategy.OnPush,
1921
host: {
2022
class: 'header-item focus-inside px-4 py-0',
@@ -28,4 +30,6 @@ export class SiHeaderAccountItemComponent extends SiHeaderActionIconItemBase {
2830
readonly initials = input<string>();
2931
/** URL to an image which should be shown instead of the initials. */
3032
readonly imageUrl = input<string>();
33+
34+
protected readonly itemTitle = this.name;
3135
}

projects/element-ng/application-header/si-header-action-item-icon-base.directive.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,20 @@
22
* Copyright (c) Siemens 2016 - 2026
33
* SPDX-License-Identifier: MIT
44
*/
5-
import { computed, Directive, input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
5+
import {
6+
computed,
7+
Directive,
8+
effect,
9+
EffectCleanupRegisterFn,
10+
ElementRef,
11+
inject,
12+
input,
13+
OnChanges,
14+
OnInit,
15+
Signal,
16+
SimpleChanges
17+
} from '@angular/core';
18+
import { SiTooltipDirective, SiTooltipService } from '@siemens/element-ng/tooltip';
619

720
import { SiHeaderActionItemBase } from './si-header-action-item.base';
821

@@ -23,6 +36,12 @@ export abstract class SiHeaderActionIconItemBase
2336
*/
2437
readonly badge = input<number | boolean | undefined | null>();
2538

39+
protected abstract readonly itemTitle: Signal<string | ElementRef<Element>>;
40+
41+
private readonly tooltipService = inject(SiTooltipService);
42+
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
43+
private readonly existingTooltip = inject(SiTooltipDirective, { optional: true, host: true });
44+
2645
protected readonly badgeDot = computed(() =>
2746
typeof this.badge() === 'boolean' ? (this.badge() as boolean) : false
2847
);
@@ -33,6 +52,27 @@ export abstract class SiHeaderActionIconItemBase
3352
: undefined;
3453
});
3554

55+
constructor() {
56+
super();
57+
effect(onCleanup => this.setupTooltip(onCleanup));
58+
}
59+
60+
private setupTooltip(onCleanup: EffectCleanupRegisterFn): void {
61+
const hasActiveTooltip =
62+
!!this.existingTooltip &&
63+
!this.existingTooltip.isDisabled() &&
64+
!!this.existingTooltip.siTooltip();
65+
if (this.visuallyHideTitle() && !hasActiveTooltip) {
66+
const tooltipRef = this.tooltipService.createTooltip({
67+
element: this.elementRef,
68+
placement: 'auto',
69+
tooltip: this.itemTitle,
70+
tooltipContext: () => undefined
71+
});
72+
onCleanup(() => tooltipRef.destroy());
73+
}
74+
}
75+
3676
ngOnChanges(changes: SimpleChanges<this>): void {
3777
if (changes.badge) {
3878
if (changes.badge.currentValue && !changes.badge.previousValue) {
@@ -43,7 +83,5 @@ export abstract class SiHeaderActionIconItemBase
4383
}
4484
}
4585

46-
protected get visuallyHideTitle(): boolean {
47-
return !this.collapsibleActions?.mobileExpanded();
48-
}
86+
protected readonly visuallyHideTitle = computed(() => !this.collapsibleActions?.mobileExpanded());
4987
}

projects/element-ng/application-header/si-header-action-item.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<span class="badge-text">{{ badgeValue() }}</span>
44
}
55
<ng-content select="si-avatar" />
6-
<div class="item-title" [class.visually-hidden]="visuallyHideTitle">
6+
<div #itemTitle class="item-title" [class.visually-hidden]="visuallyHideTitle()">
77
<ng-content />
88
</div>
99
@if (dropdownTrigger) {

projects/element-ng/application-header/si-header-action-item.component.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
* Copyright (c) Siemens 2016 - 2026
33
* SPDX-License-Identifier: MIT
44
*/
5-
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
5+
import { ChangeDetectionStrategy, Component, ElementRef, input, viewChild } from '@angular/core';
66
import { SiIconComponent } from '@siemens/element-ng/icon';
7+
import { SiTooltipService } from '@siemens/element-ng/tooltip';
78

89
import { SiHeaderActionIconItemBase } from './si-header-action-item-icon-base.directive';
910

@@ -13,6 +14,7 @@ import { SiHeaderActionIconItemBase } from './si-header-action-item-icon-base.di
1314
selector: 'button[si-header-action-item], a[si-header-action-item]',
1415
imports: [SiIconComponent],
1516
templateUrl: './si-header-action-item.component.html',
17+
providers: [SiTooltipService],
1618
changeDetection: ChangeDetectionStrategy.OnPush,
1719
host: {
1820
class: 'header-item focus-inside',
@@ -22,4 +24,6 @@ import { SiHeaderActionIconItemBase } from './si-header-action-item-icon-base.di
2224
export class SiHeaderActionItemComponent extends SiHeaderActionIconItemBase {
2325
/** The icon to be shown. */
2426
readonly icon = input.required<string>();
27+
28+
protected readonly itemTitle = viewChild.required<ElementRef<HTMLElement>>('itemTitle');
2529
}

0 commit comments

Comments
 (0)