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 */