From f0a5008b237d4bde3e8d759ca9e793c164656d7a Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Tue, 9 May 2023 10:16:13 -0700 Subject: [PATCH] fix(material/badge): insert inline description for non-interactive hosts (#27025) The badge current applies `aria-describedby` to surface the developer-provided description. However, assistive technology generally doesn't read these description on non-interactive elements. So, we can take advantage of our handy `InteractivityChecker` and do something different if the host is not focusable; we add the description inline as the next sibling with `.cdk-visually-hidden`. Fixes #26190 --- src/dev-app/badge/badge-demo.html | 2 +- src/material/badge/badge.md | 14 +- src/material/badge/badge.spec.ts | 370 ++++++++++++++++++------------ src/material/badge/badge.ts | 63 ++++- 4 files changed, 285 insertions(+), 164 deletions(-) diff --git a/src/dev-app/badge/badge-demo.html b/src/dev-app/badge/badge-demo.html index 283dac9b7e51..f25109539acf 100644 --- a/src/dev-app/badge/badge-demo.html +++ b/src/dev-app/badge/badge-demo.html @@ -24,7 +24,7 @@

Buttons

Icons

- + home diff --git a/src/material/badge/badge.md b/src/material/badge/badge.md index a4e63ffea269..9a636d75d053 100644 --- a/src/material/badge/badge.md +++ b/src/material/badge/badge.md @@ -48,10 +48,12 @@ background color to `primary`, `accent`, or `warn`. "region":"mat-badge-color"}) --> ### Accessibility -Badges should be given a meaningful description via `matBadgeDescription`. This description will be -applied, via `aria-describedby` to the element decorated by `matBadge`. - -When applying a badge to a ``, it is important to know that the icon is marked as -`aria-hidden` by default. If the combination of icon and badge communicates some meaningful -information, that information should be surfaced in another way. [See the guidance on indicator +You must provide a meaningful description via `matBadgeDescription`. When attached to an interactive +element, `MatBadge` applies this description to its host via `aria-describedby`. When attached to +a non-interactive element, `MatBadge` appends a visually-hidden, inline description element. The +badge determines interactivity based on whether the host element is focusable. + +When applying a badge to a ``, it is important to know that `` is +`aria-hidden="true"` by default. If the combination of icon and badge communicates meaningful +information, always surface this information in another way. [See the guidance on indicator icons for more information](https://material.angular.io/components/icon/overview#indicator-icons). diff --git a/src/material/badge/badge.spec.ts b/src/material/badge/badge.spec.ts index 55b5b168ed1b..7e28a841ee16 100644 --- a/src/material/badge/badge.spec.ts +++ b/src/material/badge/badge.spec.ts @@ -6,207 +6,267 @@ import {ThemePalette} from '@angular/material/core'; describe('MatBadge', () => { let fixture: ComponentFixture; - let testComponent: BadgeTestApp; let badgeHostNativeElement: HTMLElement; let badgeHostDebugElement: DebugElement; - beforeEach(fakeAsync(() => { - TestBed.configureTestingModule({ - imports: [MatBadgeModule], - declarations: [BadgeTestApp, PreExistingBadge, NestedBadge, BadgeOnTemplate], - }).compileComponents(); + describe('on an interative host', () => { + let testComponent: BadgeOnInteractiveElement; - fixture = TestBed.createComponent(BadgeTestApp); - testComponent = fixture.debugElement.componentInstance; - fixture.detectChanges(); + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [MatBadgeModule], + declarations: [BadgeOnInteractiveElement, PreExistingBadge, NestedBadge, BadgeOnTemplate], + }).compileComponents(); - badgeHostDebugElement = fixture.debugElement.query(By.directive(MatBadge))!; - badgeHostNativeElement = badgeHostDebugElement.nativeElement; - })); + fixture = TestBed.createComponent(BadgeOnInteractiveElement); + testComponent = fixture.debugElement.componentInstance; + fixture.detectChanges(); - it('should update the badge based on attribute', () => { - const badgeElement = badgeHostNativeElement.querySelector('.mat-badge-content')!; - expect(badgeElement.textContent).toContain('1'); + badgeHostDebugElement = fixture.debugElement.query(By.directive(MatBadge))!; + badgeHostNativeElement = badgeHostDebugElement.nativeElement; + })); - testComponent.badgeContent = '22'; - fixture.detectChanges(); - expect(badgeElement.textContent).toContain('22'); - }); + it('should update the badge based on attribute', () => { + const badgeElement = badgeHostNativeElement.querySelector('.mat-badge-content')!; + expect(badgeElement.textContent).toContain('1'); - it('should be able to pass in falsy values to the badge content', () => { - const badgeElement = badgeHostNativeElement.querySelector('.mat-badge-content')!; - expect(badgeElement.textContent).toContain('1'); + testComponent.badgeContent = '22'; + fixture.detectChanges(); + expect(badgeElement.textContent).toContain('22'); + }); - testComponent.badgeContent = 0; - fixture.detectChanges(); - expect(badgeElement.textContent).toContain('0'); - }); + it('should be able to pass in falsy values to the badge content', () => { + const badgeElement = badgeHostNativeElement.querySelector('.mat-badge-content')!; + expect(badgeElement.textContent).toContain('1'); - it('should treat null and undefined as empty strings in the badge content', () => { - const badgeElement = badgeHostNativeElement.querySelector('.mat-badge-content')!; - expect(badgeElement.textContent).toContain('1'); + testComponent.badgeContent = 0; + fixture.detectChanges(); + expect(badgeElement.textContent).toContain('0'); + }); - testComponent.badgeContent = null; - fixture.detectChanges(); - expect(badgeElement.textContent?.trim()).toBe(''); + it('should treat null and undefined as empty strings in the badge content', () => { + const badgeElement = badgeHostNativeElement.querySelector('.mat-badge-content')!; + expect(badgeElement.textContent).toContain('1'); - testComponent.badgeContent = undefined; - fixture.detectChanges(); - expect(badgeElement.textContent?.trim()).toBe(''); - }); + testComponent.badgeContent = null; + fixture.detectChanges(); + expect(badgeElement.textContent?.trim()).toBe(''); - it('should apply class based on color attribute', () => { - testComponent.badgeColor = 'primary'; - fixture.detectChanges(); - expect(badgeHostNativeElement.classList.contains('mat-badge-primary')).toBe(true); + testComponent.badgeContent = undefined; + fixture.detectChanges(); + expect(badgeElement.textContent?.trim()).toBe(''); + }); - testComponent.badgeColor = 'accent'; - fixture.detectChanges(); - expect(badgeHostNativeElement.classList.contains('mat-badge-accent')).toBe(true); + it('should apply class based on color attribute', () => { + testComponent.badgeColor = 'primary'; + fixture.detectChanges(); + expect(badgeHostNativeElement.classList.contains('mat-badge-primary')).toBe(true); - testComponent.badgeColor = 'warn'; - fixture.detectChanges(); - expect(badgeHostNativeElement.classList.contains('mat-badge-warn')).toBe(true); + testComponent.badgeColor = 'accent'; + fixture.detectChanges(); + expect(badgeHostNativeElement.classList.contains('mat-badge-accent')).toBe(true); - testComponent.badgeColor = undefined; - fixture.detectChanges(); + testComponent.badgeColor = 'warn'; + fixture.detectChanges(); + expect(badgeHostNativeElement.classList.contains('mat-badge-warn')).toBe(true); - expect(badgeHostNativeElement.classList).not.toContain('mat-badge-accent'); - }); + testComponent.badgeColor = undefined; + fixture.detectChanges(); - it('should update the badge position on direction change', () => { - expect(badgeHostNativeElement.classList.contains('mat-badge-above')).toBe(true); - expect(badgeHostNativeElement.classList.contains('mat-badge-after')).toBe(true); + expect(badgeHostNativeElement.classList).not.toContain('mat-badge-accent'); + }); - testComponent.badgeDirection = 'below before'; - fixture.detectChanges(); + it('should update the badge position on direction change', () => { + expect(badgeHostNativeElement.classList.contains('mat-badge-above')).toBe(true); + expect(badgeHostNativeElement.classList.contains('mat-badge-after')).toBe(true); - expect(badgeHostNativeElement.classList.contains('mat-badge-below')).toBe(true); - expect(badgeHostNativeElement.classList.contains('mat-badge-before')).toBe(true); - }); + testComponent.badgeDirection = 'below before'; + fixture.detectChanges(); - it('should change visibility to hidden', () => { - expect(badgeHostNativeElement.classList.contains('mat-badge-hidden')).toBe(false); + expect(badgeHostNativeElement.classList.contains('mat-badge-below')).toBe(true); + expect(badgeHostNativeElement.classList.contains('mat-badge-before')).toBe(true); + }); - testComponent.badgeHidden = true; - fixture.detectChanges(); + it('should change visibility to hidden', () => { + expect(badgeHostNativeElement.classList.contains('mat-badge-hidden')).toBe(false); - expect(badgeHostNativeElement.classList.contains('mat-badge-hidden')).toBe(true); - }); + testComponent.badgeHidden = true; + fixture.detectChanges(); - it('should change badge sizes', () => { - expect(badgeHostNativeElement.classList.contains('mat-badge-medium')).toBe(true); + expect(badgeHostNativeElement.classList.contains('mat-badge-hidden')).toBe(true); + }); - testComponent.badgeSize = 'small'; - fixture.detectChanges(); + it('should change badge sizes', () => { + expect(badgeHostNativeElement.classList.contains('mat-badge-medium')).toBe(true); - expect(badgeHostNativeElement.classList.contains('mat-badge-small')).toBe(true); + testComponent.badgeSize = 'small'; + fixture.detectChanges(); - testComponent.badgeSize = 'large'; - fixture.detectChanges(); + expect(badgeHostNativeElement.classList.contains('mat-badge-small')).toBe(true); - expect(badgeHostNativeElement.classList.contains('mat-badge-large')).toBe(true); - }); + testComponent.badgeSize = 'large'; + fixture.detectChanges(); - it('should change badge overlap', () => { - expect(badgeHostNativeElement.classList.contains('mat-badge-overlap')).toBe(false); + expect(badgeHostNativeElement.classList.contains('mat-badge-large')).toBe(true); + }); - testComponent.badgeOverlap = true; - fixture.detectChanges(); + it('should change badge overlap', () => { + expect(badgeHostNativeElement.classList.contains('mat-badge-overlap')).toBe(false); - expect(badgeHostNativeElement.classList.contains('mat-badge-overlap')).toBe(true); - }); + testComponent.badgeOverlap = true; + fixture.detectChanges(); - it('should toggle `aria-describedby` depending on whether the badge has a description', () => { - expect(badgeHostNativeElement.hasAttribute('aria-describedby')).toBeFalse(); + expect(badgeHostNativeElement.classList.contains('mat-badge-overlap')).toBe(true); + }); - testComponent.badgeDescription = 'Describing a badge'; - fixture.detectChanges(); + it('should toggle `aria-describedby` depending on whether the badge has a description', () => { + expect(badgeHostNativeElement.hasAttribute('aria-describedby')).toBeFalse(); - const describedById = badgeHostNativeElement.getAttribute('aria-describedby') || ''; - const description = document.getElementById(describedById)?.textContent; - expect(description).toBe('Describing a badge'); + testComponent.badgeDescription = 'Describing a badge'; + fixture.detectChanges(); - testComponent.badgeDescription = ''; - fixture.detectChanges(); + const describedById = badgeHostNativeElement.getAttribute('aria-describedby') || ''; + const description = document.getElementById(describedById)?.textContent; + expect(description).toBe('Describing a badge'); - expect(badgeHostNativeElement.hasAttribute('aria-describedby')).toBeFalse(); - }); + testComponent.badgeDescription = ''; + fixture.detectChanges(); - it('should toggle visibility based on whether the badge has content', () => { - const classList = badgeHostNativeElement.classList; + expect(badgeHostNativeElement.hasAttribute('aria-describedby')).toBeFalse(); + }); - expect(classList.contains('mat-badge-hidden')).toBe(false); + it('should toggle visibility based on whether the badge has content', () => { + const classList = badgeHostNativeElement.classList; - testComponent.badgeContent = ''; - fixture.detectChanges(); + expect(classList.contains('mat-badge-hidden')).toBe(false); - expect(classList.contains('mat-badge-hidden')).toBe(true); + testComponent.badgeContent = ''; + fixture.detectChanges(); - testComponent.badgeContent = 'hello'; - fixture.detectChanges(); + expect(classList.contains('mat-badge-hidden')).toBe(true); - expect(classList.contains('mat-badge-hidden')).toBe(false); + testComponent.badgeContent = 'hello'; + fixture.detectChanges(); - testComponent.badgeContent = ' '; - fixture.detectChanges(); + expect(classList.contains('mat-badge-hidden')).toBe(false); - expect(classList.contains('mat-badge-hidden')).toBe(true); + testComponent.badgeContent = ' '; + fixture.detectChanges(); - testComponent.badgeContent = 0; - fixture.detectChanges(); + expect(classList.contains('mat-badge-hidden')).toBe(true); - expect(classList.contains('mat-badge-hidden')).toBe(false); - }); + testComponent.badgeContent = 0; + fixture.detectChanges(); + + expect(classList.contains('mat-badge-hidden')).toBe(false); + }); - it('should apply view encapsulation on create badge content', () => { - const badge = badgeHostNativeElement.querySelector('.mat-badge-content')!; - let encapsulationAttr: Attr | undefined; + it('should apply view encapsulation on create badge content', () => { + const badge = badgeHostNativeElement.querySelector('.mat-badge-content')!; + let encapsulationAttr: Attr | undefined; - for (let i = 0; i < badge.attributes.length; i++) { - if (badge.attributes[i].name.startsWith('_ngcontent-')) { - encapsulationAttr = badge.attributes[i]; - break; + for (let i = 0; i < badge.attributes.length; i++) { + if (badge.attributes[i].name.startsWith('_ngcontent-')) { + encapsulationAttr = badge.attributes[i]; + break; + } } - } - expect(encapsulationAttr).toBeTruthy(); - }); + expect(encapsulationAttr).toBeTruthy(); + }); - it('should toggle a class depending on the badge disabled state', () => { - const element: HTMLElement = badgeHostDebugElement.nativeElement; + it('should toggle a class depending on the badge disabled state', () => { + const element: HTMLElement = badgeHostDebugElement.nativeElement; - expect(element.classList).not.toContain('mat-badge-disabled'); + expect(element.classList).not.toContain('mat-badge-disabled'); - testComponent.badgeDisabled = true; - fixture.detectChanges(); + testComponent.badgeDisabled = true; + fixture.detectChanges(); - expect(element.classList).toContain('mat-badge-disabled'); - }); + expect(element.classList).toContain('mat-badge-disabled'); + }); - it('should clear any pre-existing badges', () => { - const preExistingFixture = TestBed.createComponent(PreExistingBadge); - preExistingFixture.detectChanges(); + it('should clear any pre-existing badges', () => { + const preExistingFixture = TestBed.createComponent(PreExistingBadge); + preExistingFixture.detectChanges(); - expect(preExistingFixture.nativeElement.querySelectorAll('.mat-badge-content').length).toBe(1); - }); + expect(preExistingFixture.nativeElement.querySelectorAll('.mat-badge-content').length).toBe( + 1, + ); + }); - it('should not clear badge content from child elements', () => { - const preExistingFixture = TestBed.createComponent(NestedBadge); - preExistingFixture.detectChanges(); + it('should not clear badge content from child elements', () => { + const preExistingFixture = TestBed.createComponent(NestedBadge); + preExistingFixture.detectChanges(); - expect(preExistingFixture.nativeElement.querySelectorAll('.mat-badge-content').length).toBe(2); - }); + expect(preExistingFixture.nativeElement.querySelectorAll('.mat-badge-content').length).toBe( + 2, + ); + }); + + it('should expose the badge element', () => { + const badgeElement = badgeHostNativeElement.querySelector('.mat-badge-content')!; + expect(fixture.componentInstance.badgeInstance.getBadgeElement()).toBe(badgeElement); + }); - it('should expose the badge element', () => { - const badgeElement = badgeHostNativeElement.querySelector('.mat-badge-content')!; - expect(fixture.componentInstance.badgeInstance.getBadgeElement()).toBe(badgeElement); + it('should throw if badge is not attached to an element node', () => { + expect(() => { + TestBed.createComponent(BadgeOnTemplate); + }).toThrowError(/matBadge must be attached to an element node/); + }); + + it('should not insert an inline description', () => { + expect(badgeHostNativeElement.nextSibling) + .withContext('The badge host should not have an inline sibling description') + .toBeNull(); + }); }); - it('should throw if badge is not attached to an element node', () => { - expect(() => { - TestBed.createComponent(BadgeOnTemplate); - }).toThrowError(/matBadge must be attached to an element node/); + describe('on an non-interactive host', () => { + let testComponent: BadgeOnNonInteractiveElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MatBadgeModule], + declarations: [BadgeOnNonInteractiveElement], + }).compileComponents(); + + fixture = TestBed.createComponent(BadgeOnNonInteractiveElement); + testComponent = fixture.debugElement.componentInstance; + fixture.detectChanges(); + + badgeHostDebugElement = fixture.debugElement.query(By.directive(MatBadge))!; + badgeHostNativeElement = badgeHostDebugElement.nativeElement; + }); + + it('should insert the description inline after the host', () => { + testComponent.description = 'Extra info'; + fixture.detectChanges(); + + const inlineDescription = badgeHostNativeElement.querySelector('.cdk-visually-hidden')!; + expect(inlineDescription) + .withContext('A visually hidden description element should exist') + .toBeDefined(); + expect(inlineDescription.textContent) + .withContext('The badge host next sibling should contain its description') + .toBe('Extra info'); + + testComponent.description = 'Different info'; + fixture.detectChanges(); + + expect(inlineDescription.textContent) + .withContext('The inline description should update') + .toBe('Different info'); + }); + + it('should not apply aria-describedby for non-interactive hosts', () => { + testComponent.description = 'Extra info'; + fixture.detectChanges(); + + expect(badgeHostNativeElement.hasAttribute('aria-description')) + .withContext('Non-interactive hosts should not have aria-describedby') + .toBeFalse(); + }); }); }); @@ -214,21 +274,21 @@ describe('MatBadge', () => { @Component({ // Explicitly set the view encapsulation since we have a test that checks for it. encapsulation: ViewEncapsulation.Emulated, - styles: ['span { color: hotpink; }'], + styles: ['button { color: hotpink; }'], template: ` - + `, }) -class BadgeTestApp { +class BadgeOnInteractiveElement { @ViewChild(MatBadge) badgeInstance: MatBadge; badgeColor: ThemePalette; badgeContent: string | number | undefined | null = '1'; @@ -240,6 +300,11 @@ class BadgeTestApp { badgeDisabled = false; } +@Component({template: 'Hello'}) +class BadgeOnNonInteractiveElement { + description = ''; +} + @Component({ template: ` @@ -261,6 +326,7 @@ class PreExistingBadge {} class NestedBadge {} @Component({ - template: `Notifications`, + template: ` + Notifications`, }) class BadgeOnTemplate {} diff --git a/src/material/badge/badge.ts b/src/material/badge/badge.ts index f13b44753530..7fc0f78981f6 100644 --- a/src/material/badge/badge.ts +++ b/src/material/badge/badge.ts @@ -6,11 +6,13 @@ * found in the LICENSE file at https://angular.io/license */ -import {AriaDescriber} from '@angular/cdk/a11y'; +import {AriaDescriber, InteractivityChecker} from '@angular/cdk/a11y'; import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; +import {DOCUMENT} from '@angular/common'; import { Directive, ElementRef, + inject, Inject, Input, NgZone, @@ -106,7 +108,7 @@ export class MatBadge extends _MatBadgeBase implements OnInit, OnDestroy, CanDis return this._description; } set description(newDescription: string) { - this._updateHostAriaDescription(newDescription); + this._updateDescription(newDescription); } private _description: string; @@ -129,9 +131,17 @@ export class MatBadge extends _MatBadgeBase implements OnInit, OnDestroy, CanDis /** Visible badge element. */ private _badgeElement: HTMLElement | undefined; + /** Inline badge description. Used when the badge is applied to non-interactive host elements. */ + private _inlineBadgeDescription: HTMLElement | undefined; + /** Whether the OnInit lifecycle hook has run yet */ private _isInitialized = false; + /** InteractivityChecker to determine if the badge host is focusable. */ + private _interactivityChecker = inject(InteractivityChecker); + + private _document = inject(DOCUMENT); + constructor( private _ngZone: NgZone, private _elementRef: ElementRef, @@ -186,11 +196,20 @@ export class MatBadge extends _MatBadgeBase implements OnInit, OnDestroy, CanDis // We have to destroy it ourselves, otherwise it'll be retained in memory. if (this._renderer.destroyNode) { this._renderer.destroyNode(this._badgeElement); + this._inlineBadgeDescription?.remove(); } this._ariaDescriber.removeDescription(this._elementRef.nativeElement, this.description); } + /** Gets whether the badge's host element is interactive. */ + private _isHostInteractive(): boolean { + // Ignore visibility since it requires an expensive style caluclation. + return this._interactivityChecker.isFocusable(this._elementRef.nativeElement, { + ignoreVisibility: true, + }); + } + /** Creates the badge element */ private _createBadgeElement(): HTMLElement { const badgeElement = this._renderer.createElement('span'); @@ -242,12 +261,46 @@ export class MatBadge extends _MatBadgeBase implements OnInit, OnDestroy, CanDis } /** Updates the host element's aria description via AriaDescriber. */ - private _updateHostAriaDescription(newDescription: string): void { + private _updateDescription(newDescription: string): void { + // Always start by removing the aria-describedby; we will add a new one if necessary. this._ariaDescriber.removeDescription(this._elementRef.nativeElement, this.description); - if (newDescription) { - this._ariaDescriber.describe(this._elementRef.nativeElement, newDescription); + + // NOTE: We only check whether the host is interactive here, which happens during + // when then badge content changes. It is possible that the host changes + // interactivity status separate from one of these. However, watching the interactivity + // status of the host would require a `MutationObserver`, which is likely more code + overhead + // than it's worth; from usages inside Google, we see that the vats majority of badges either + // never change interactivity, or also set `matBadgeHidden` based on the same condition. + + if (!newDescription || this._isHostInteractive()) { + this._removeInlineDescription(); } + this._description = newDescription; + + // We don't add `aria-describedby` for non-interactive hosts elements because we + // instead insert the description inline. + if (this._isHostInteractive()) { + this._ariaDescriber.describe(this._elementRef.nativeElement, newDescription); + } else { + this._updateInlineDescription(); + } + } + + private _updateInlineDescription() { + // Create the inline description element if it doesn't exist + if (!this._inlineBadgeDescription) { + this._inlineBadgeDescription = this._document.createElement('span'); + this._inlineBadgeDescription.classList.add('cdk-visually-hidden'); + } + + this._inlineBadgeDescription.textContent = this.description; + this._badgeElement?.appendChild(this._inlineBadgeDescription); + } + + private _removeInlineDescription() { + this._inlineBadgeDescription?.remove(); + this._inlineBadgeDescription = undefined; } /** Adds css theme class given the color to the component host */