From 64d1303681a813162eff70f692317f674a86751a Mon Sep 17 00:00:00 2001 From: "rene.schakmann" Date: Sun, 26 Jul 2026 20:45:13 +0200 Subject: [PATCH] fix(breadcrumb): give the icon-only home link an accessible name The home item always renders an icon, but `homeAriaLabel` had no default, so `[attr.aria-label]` resolved to `undefined` and was never rendered. When `home` carries no `label`, the link ended up with no accessible name: axe reported a `link-name` violation (WCAG 2.4.4 / 4.1.2) and screen readers announced only "link". The home link now falls back to the new `aria.home` translation key ("Home" by default) instead of hardcoded English, so the label can be localized through the locale configuration. An explicit `homeAriaLabel` still wins, and the fallback is skipped when `home.label` is set so a visible label is never overridden by an aria-label. Fixes #1403 --- apps/docs/doc/breadcrumb/accessibility-doc.ts | 7 ++- apps/docs/doc/configuration/locale/apidoc.ts | 4 ++ packages/optimus-ui/src/api/translation.ts | 1 + .../src/breadcrumb/breadcrumb.spec.ts | 54 +++++++++++++++++++ .../optimus-ui/src/breadcrumb/breadcrumb.ts | 17 ++++-- packages/optimus-ui/src/config/optimus.ts | 1 + 6 files changed, 79 insertions(+), 5 deletions(-) diff --git a/apps/docs/doc/breadcrumb/accessibility-doc.ts b/apps/docs/doc/breadcrumb/accessibility-doc.ts index 044fdd127a..ac49892385 100644 --- a/apps/docs/doc/breadcrumb/accessibility-doc.ts +++ b/apps/docs/doc/breadcrumb/accessibility-doc.ts @@ -1,16 +1,21 @@ import { Component } from '@angular/core'; +import { RouterModule } from '@angular/router'; import { AppDocSectionText } from '@/components/doc/app.docsectiontext'; @Component({ selector: 'accessibility-doc', standalone: true, - imports: [AppDocSectionText], + imports: [RouterModule, AppDocSectionText], template: `

Screen Reader

Breadcrumb uses the nav element and since any attribute is passed to the root implicitly aria-labelledby or aria-label can be used to describe the component. Inside an ordered list is used where the list item separators have aria-hidden to be able to ignored by the screen readers. If the last link represents the current route, aria-current is added with "page" as the value.

+

+ The home item renders an icon, so when it has no label the link is named with the homeAriaLabel property. When homeAriaLabel is not defined either, the aria.home key of the + locale configuration is used as the default. +

Keyboard Support

No special keyboard interaction is needed, all menuitems are focusable based on the page tab sequence.

diff --git a/apps/docs/doc/configuration/locale/apidoc.ts b/apps/docs/doc/configuration/locale/apidoc.ts index f38ed56a21..c88fc16583 100644 --- a/apps/docs/doc/configuration/locale/apidoc.ts +++ b/apps/docs/doc/configuration/locale/apidoc.ts @@ -322,6 +322,10 @@ import { AppDocSectionText } from '@/components/doc/app.docsectiontext'; aria.navigation Navigation + + aria.home + Home + aria.scrollTop Scroll Top diff --git a/packages/optimus-ui/src/api/translation.ts b/packages/optimus-ui/src/api/translation.ts index 6ed29eaec3..df0a95b5a0 100644 --- a/packages/optimus-ui/src/api/translation.ts +++ b/packages/optimus-ui/src/api/translation.ts @@ -92,6 +92,7 @@ export interface Aria { previous?: string; next?: string; navigation?: string; + home?: string; scrollTop?: string; moveTop?: string; moveUp?: string; diff --git a/packages/optimus-ui/src/breadcrumb/breadcrumb.spec.ts b/packages/optimus-ui/src/breadcrumb/breadcrumb.spec.ts index edd77bc56f..59191d7c74 100644 --- a/packages/optimus-ui/src/breadcrumb/breadcrumb.spec.ts +++ b/packages/optimus-ui/src/breadcrumb/breadcrumb.spec.ts @@ -5,6 +5,7 @@ import { By } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { MenuItem } from '@openng/optimus-ui/api'; +import { Optimus } from '@openng/optimus-ui/config'; import { BreadcrumbItemClickEvent } from '@openng/optimus-ui/types/breadcrumb'; import { Breadcrumb } from './breadcrumb'; @@ -828,6 +829,59 @@ describe('Breadcrumb', () => { } }); + it('should label the icon only home link with the default aria.home translation', async () => { + component.home = { icon: 'pi pi-home' }; + component.homeAriaLabel = undefined; + fixture.changeDetectorRef.markForCheck(); + + await fixture.whenStable(); + + fixture.detectChanges(); + + const homeLink = fixture.debugElement.query(By.css('[data-pc-section="homeitem"] a')); + expect(homeLink.nativeElement.getAttribute('aria-label')).toBe('Home'); + }); + + it('should label the icon only home link with the configured aria.home translation', async () => { + TestBed.inject(Optimus).setTranslation({ aria: { home: 'Startseite' } }); + component.home = { icon: 'pi pi-home' }; + component.homeAriaLabel = undefined; + fixture.changeDetectorRef.markForCheck(); + + await fixture.whenStable(); + + fixture.detectChanges(); + + const homeLink = fixture.debugElement.query(By.css('[data-pc-section="homeitem"] a')); + expect(homeLink.nativeElement.getAttribute('aria-label')).toBe('Startseite'); + }); + + it('should prefer homeAriaLabel over the aria.home translation', async () => { + component.home = { icon: 'pi pi-home' }; + component.homeAriaLabel = 'Go to homepage'; + fixture.changeDetectorRef.markForCheck(); + + await fixture.whenStable(); + + fixture.detectChanges(); + + const homeLink = fixture.debugElement.query(By.css('[data-pc-section="homeitem"] a')); + expect(homeLink.nativeElement.getAttribute('aria-label')).toBe('Go to homepage'); + }); + + it('should not override a visible home label with the aria.home translation', async () => { + component.home = { icon: 'pi pi-home', label: 'Dashboard' }; + component.homeAriaLabel = undefined; + fixture.changeDetectorRef.markForCheck(); + + await fixture.whenStable(); + + fixture.detectChanges(); + + const homeLink = fixture.debugElement.query(By.css('[data-pc-section="homeitem"] a')); + expect(homeLink.nativeElement.hasAttribute('aria-label')).toBe(false); + }); + it('should handle tabindex for disabled items', async () => { component.model = [{ label: 'Disabled Item', disabled: true }]; fixture.changeDetectorRef.markForCheck(); diff --git a/packages/optimus-ui/src/breadcrumb/breadcrumb.ts b/packages/optimus-ui/src/breadcrumb/breadcrumb.ts index 8db1d2c71e..019645ed10 100755 --- a/packages/optimus-ui/src/breadcrumb/breadcrumb.ts +++ b/packages/optimus-ui/src/breadcrumb/breadcrumb.ts @@ -1,7 +1,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, ContentChild, ContentChildren, EventEmitter, inject, InjectionToken, Input, NgModule, Output, QueryList, TemplateRef, ViewEncapsulation } from '@angular/core'; import { Router, RouterLink, RouterLinkActive, RouterModule } from '@angular/router'; -import { MenuItem, PrimeTemplate, SharedModule } from '@openng/optimus-ui/api'; +import { MenuItem, PrimeTemplate, SharedModule, TranslationKeys } from '@openng/optimus-ui/api'; import { Badge } from '@openng/optimus-ui/badge'; import { BaseComponent, PARENT_INSTANCE } from '@openng/optimus-ui/basecomponent'; import { Bind } from '@openng/optimus-ui/bind'; @@ -30,7 +30,7 @@ const BREADCRUMB_INSTANCE = new InjectionToken('BREADCRUMB_INSTANCE' ('BREADCRUMB_INSTANCE' *ngIf="home.routerLink" [routerLink]="home.routerLink" routerLinkActive="p-menuitem-link-active" - [attr.aria-label]="homeAriaLabel" + [attr.aria-label]="homeLinkAriaLabel" [queryParams]="home.queryParams" [routerLinkActiveOptions]="home.routerLinkActiveOptions || { exact: false }" [class]="cn(cx('itemLink'), home.linkClass)" @@ -195,7 +195,7 @@ export class Breadcrumb extends BaseComponent { */ @Input() home: MenuItem | undefined; /** - * Defines a string that labels the home icon for accessibility. + * Defines a string that labels the home icon for accessibility. Defaults to the `aria.home` translation when the home item has no visible label. * @group Props */ @Input() homeAriaLabel: string | undefined; @@ -210,6 +210,15 @@ export class Breadcrumb extends BaseComponent { router = inject(Router); + get homeLinkAriaLabel(): string | undefined { + if (this.homeAriaLabel) { + return this.homeAriaLabel; + } + + // A visible label already names the link, so an aria-label would only override it. + return this.home?.label ? undefined : this.config.getTranslation(TranslationKeys.ARIA)?.home; + } + onClick(event: MouseEvent, item: MenuItem) { if (item.disabled) { event.preventDefault(); diff --git a/packages/optimus-ui/src/config/optimus.ts b/packages/optimus-ui/src/config/optimus.ts index 63385904c2..dc798aa0a1 100644 --- a/packages/optimus-ui/src/config/optimus.ts +++ b/packages/optimus-ui/src/config/optimus.ts @@ -118,6 +118,7 @@ export class Optimus extends ThemeProvider { previous: 'Previous', next: 'Next', navigation: 'Navigation', + home: 'Home', scrollTop: 'Scroll Top', moveTop: 'Move Top', moveUp: 'Move Up',