diff --git a/src/dev-app/badge/badge-demo.html b/src/dev-app/badge/badge-demo.html
index 283dac9b7e51..0ed1d00a007f 100644
--- a/src/dev-app/badge/badge-demo.html
+++ b/src/dev-app/badge/badge-demo.html
@@ -1,24 +1,71 @@
+
+
Text
+
+ Hello
+
+
+
+ Hello
+
+
+
+ Hello
+
+
+
+ Hello
+
+
+
+ Hello
+
+
+
+ Aria
+
+
+
+ Hidden
+
+
+
+
+
+
Buttons
-
@@ -52,38 +99,4 @@
Size
-
-
Text
-
- Hello
-
-
-
- Hello
-
-
-
- Hello
-
-
-
- Hello
-
-
-
- Hello
-
-
-
- Aria
-
-
-
- Hidden
-
-
-
- Toggle
-
-
diff --git a/src/material/badge/badge.spec.ts b/src/material/badge/badge.spec.ts
index 40370370ad25..08d2dbae2e90 100644
--- a/src/material/badge/badge.spec.ts
+++ b/src/material/badge/badge.spec.ts
@@ -7,8 +7,8 @@ import {ThemePalette} from '@angular/material/core';
describe('MatBadge', () => {
let fixture: ComponentFixture;
let testComponent: BadgeTestApp;
- let badgeHostNativeElement: HTMLElement;
- let badgeHostDebugElement: DebugElement;
+ let badgeNativeElement: HTMLElement;
+ let badgeDebugElement: DebugElement;
beforeEach(fakeAsync(() => {
TestBed
@@ -22,12 +22,12 @@ describe('MatBadge', () => {
testComponent = fixture.debugElement.componentInstance;
fixture.detectChanges();
- badgeHostDebugElement = fixture.debugElement.query(By.directive(MatBadge))!;
- badgeHostNativeElement = badgeHostDebugElement.nativeElement;
+ badgeDebugElement = fixture.debugElement.query(By.directive(MatBadge))!;
+ badgeNativeElement = badgeDebugElement.nativeElement;
}));
it('should update the badge based on attribute', () => {
- const badgeElement = badgeHostNativeElement.querySelector('.mat-badge-content')!;
+ const badgeElement = badgeNativeElement.querySelector('.mat-badge-content')!;
expect(badgeElement.textContent).toContain('1');
testComponent.badgeContent = '22';
@@ -36,7 +36,7 @@ describe('MatBadge', () => {
});
it('should be able to pass in falsy values to the badge content', () => {
- const badgeElement = badgeHostNativeElement.querySelector('.mat-badge-content')!;
+ const badgeElement = badgeNativeElement.querySelector('.mat-badge-content')!;
expect(badgeElement.textContent).toContain('1');
testComponent.badgeContent = 0;
@@ -45,7 +45,7 @@ describe('MatBadge', () => {
});
it('should treat null and undefined as empty strings in the badge content', () => {
- const badgeElement = badgeHostNativeElement.querySelector('.mat-badge-content')!;
+ const badgeElement = badgeNativeElement.querySelector('.mat-badge-content')!;
expect(badgeElement.textContent).toContain('1');
testComponent.badgeContent = null;
@@ -60,83 +60,83 @@ describe('MatBadge', () => {
it('should apply class based on color attribute', () => {
testComponent.badgeColor = 'primary';
fixture.detectChanges();
- expect(badgeHostNativeElement.classList.contains('mat-badge-primary')).toBe(true);
+ expect(badgeNativeElement.classList.contains('mat-badge-primary')).toBe(true);
testComponent.badgeColor = 'accent';
fixture.detectChanges();
- expect(badgeHostNativeElement.classList.contains('mat-badge-accent')).toBe(true);
+ expect(badgeNativeElement.classList.contains('mat-badge-accent')).toBe(true);
testComponent.badgeColor = 'warn';
fixture.detectChanges();
- expect(badgeHostNativeElement.classList.contains('mat-badge-warn')).toBe(true);
+ expect(badgeNativeElement.classList.contains('mat-badge-warn')).toBe(true);
testComponent.badgeColor = undefined;
fixture.detectChanges();
- expect(badgeHostNativeElement.classList).not.toContain('mat-badge-accent');
+ expect(badgeNativeElement.classList).not.toContain('mat-badge-accent');
});
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(badgeNativeElement.classList.contains('mat-badge-above')).toBe(true);
+ expect(badgeNativeElement.classList.contains('mat-badge-after')).toBe(true);
testComponent.badgeDirection = 'below before';
fixture.detectChanges();
- expect(badgeHostNativeElement.classList.contains('mat-badge-below')).toBe(true);
- expect(badgeHostNativeElement.classList.contains('mat-badge-before')).toBe(true);
+ expect(badgeNativeElement.classList.contains('mat-badge-below')).toBe(true);
+ expect(badgeNativeElement.classList.contains('mat-badge-before')).toBe(true);
});
it('should change visibility to hidden', () => {
- expect(badgeHostNativeElement.classList.contains('mat-badge-hidden')).toBe(false);
+ expect(badgeNativeElement.classList.contains('mat-badge-hidden')).toBe(false);
testComponent.badgeHidden = true;
fixture.detectChanges();
- expect(badgeHostNativeElement.classList.contains('mat-badge-hidden')).toBe(true);
+ expect(badgeNativeElement.classList.contains('mat-badge-hidden')).toBe(true);
});
it('should change badge sizes', () => {
- expect(badgeHostNativeElement.classList.contains('mat-badge-medium')).toBe(true);
+ expect(badgeNativeElement.classList.contains('mat-badge-medium')).toBe(true);
testComponent.badgeSize = 'small';
fixture.detectChanges();
- expect(badgeHostNativeElement.classList.contains('mat-badge-small')).toBe(true);
+ expect(badgeNativeElement.classList.contains('mat-badge-small')).toBe(true);
testComponent.badgeSize = 'large';
fixture.detectChanges();
- expect(badgeHostNativeElement.classList.contains('mat-badge-large')).toBe(true);
+ expect(badgeNativeElement.classList.contains('mat-badge-large')).toBe(true);
});
it('should change badge overlap', () => {
- expect(badgeHostNativeElement.classList.contains('mat-badge-overlap')).toBe(false);
+ expect(badgeNativeElement.classList.contains('mat-badge-overlap')).toBe(false);
testComponent.badgeOverlap = true;
fixture.detectChanges();
- expect(badgeHostNativeElement.classList.contains('mat-badge-overlap')).toBe(true);
+ expect(badgeNativeElement.classList.contains('mat-badge-overlap')).toBe(true);
});
it('should toggle `aria-describedby` depending on whether the badge has a description', () => {
- expect(badgeHostNativeElement.hasAttribute('aria-describedby')).toBeFalse();
+ const badgeContent = badgeNativeElement.querySelector('.mat-badge-content')!;
+
+ expect(badgeContent.getAttribute('aria-describedby')).toBeFalsy();
testComponent.badgeDescription = 'Describing a badge';
fixture.detectChanges();
- const describedById = badgeHostNativeElement.getAttribute('aria-describedby') || '';
- const description = document.getElementById(describedById)?.textContent;
- expect(description).toBe('Describing a badge');
+ expect(badgeContent.getAttribute('aria-describedby')).toBeTruthy();
testComponent.badgeDescription = '';
fixture.detectChanges();
- expect(badgeHostNativeElement.hasAttribute('aria-describedby')).toBeFalse();
+ expect(badgeContent.getAttribute('aria-describedby')).toBeFalsy();
});
it('should toggle visibility based on whether the badge has content', () => {
- const classList = badgeHostNativeElement.classList;
+ const classList = badgeNativeElement.classList;
expect(classList.contains('mat-badge-hidden')).toBe(false);
@@ -162,7 +162,7 @@ describe('MatBadge', () => {
});
it('should apply view encapsulation on create badge content', () => {
- const badge = badgeHostNativeElement.querySelector('.mat-badge-content')!;
+ const badge = badgeNativeElement.querySelector('.mat-badge-content')!;
let encapsulationAttr: Attr | undefined;
for (let i = 0; i < badge.attributes.length; i++) {
@@ -176,7 +176,7 @@ describe('MatBadge', () => {
});
it('should toggle a class depending on the badge disabled state', () => {
- const element: HTMLElement = badgeHostDebugElement.nativeElement;
+ const element: HTMLElement = badgeDebugElement.nativeElement;
expect(element.classList).not.toContain('mat-badge-disabled');
@@ -186,6 +186,25 @@ describe('MatBadge', () => {
expect(element.classList).toContain('mat-badge-disabled');
});
+ it('should update the aria-label if the description changes', () => {
+ const badgeContent = badgeNativeElement.querySelector('.mat-badge-content')!;
+
+ fixture.componentInstance.badgeDescription = 'initial content';
+ fixture.detectChanges();
+
+ expect(badgeContent.getAttribute('aria-label')).toBe('initial content');
+
+ fixture.componentInstance.badgeDescription = 'changed content';
+ fixture.detectChanges();
+
+ expect(badgeContent.getAttribute('aria-label')).toBe('changed content');
+
+ fixture.componentInstance.badgeDescription = '';
+ fixture.detectChanges();
+
+ expect(badgeContent.hasAttribute('aria-label')).toBe(false);
+ });
+
it('should clear any pre-existing badges', () => {
const preExistingFixture = TestBed.createComponent(PreExistingBadge);
preExistingFixture.detectChanges();
@@ -201,7 +220,7 @@ describe('MatBadge', () => {
});
it('should expose the badge element', () => {
- const badgeElement = badgeHostNativeElement.querySelector('.mat-badge-content')!;
+ const badgeElement = badgeNativeElement.querySelector('.mat-badge-content')!;
expect(fixture.componentInstance.badgeInstance.getBadgeElement()).toBe(badgeElement);
});
@@ -269,7 +288,9 @@ class NestedBadge {
@Component({
- template: `Notifications`,
+ template: `
+ Notifications
+ `
})
class BadgeOnTemplate {
}
diff --git a/src/material/badge/badge.ts b/src/material/badge/badge.ts
index 019791ebdfa2..1e321f7b3340 100644
--- a/src/material/badge/badge.ts
+++ b/src/material/badge/badge.ts
@@ -14,10 +14,11 @@ import {
Inject,
Input,
NgZone,
+ OnChanges,
OnDestroy,
- OnInit,
Optional,
Renderer2,
+ SimpleChanges,
} from '@angular/core';
import {CanDisable, mixinDisabled, ThemePalette} from '@angular/material/core';
import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations';
@@ -37,8 +38,6 @@ export type MatBadgePosition =
/** Allowed size options for matBadgeSize */
export type MatBadgeSize = 'small' | 'medium' | 'large';
-const BADGE_CONTENT_CLASS = 'mat-badge-content';
-
/** Directive to display a text badge. */
@Directive({
selector: '[matBadge]',
@@ -53,11 +52,14 @@ const BADGE_CONTENT_CLASS = 'mat-badge-content';
'[class.mat-badge-small]': 'size === "small"',
'[class.mat-badge-medium]': 'size === "medium"',
'[class.mat-badge-large]': 'size === "large"',
- '[class.mat-badge-hidden]': 'hidden || !content',
+ '[class.mat-badge-hidden]': 'hidden || !_hasContent',
'[class.mat-badge-disabled]': 'disabled',
},
})
-export class MatBadge extends _MatBadgeBase implements OnInit, OnDestroy, CanDisable {
+export class MatBadge extends _MatBadgeBase implements OnDestroy, OnChanges, CanDisable {
+ /** Whether the badge has any content. */
+ _hasContent = false;
+
/** The color of the badge. Can be `primary`, `accent`, or `warn`. */
@Input('matBadgeColor')
get color(): ThemePalette { return this._color; }
@@ -82,20 +84,22 @@ export class MatBadge extends _MatBadgeBase implements OnInit, OnDestroy, CanDis
@Input('matBadgePosition') position: MatBadgePosition = 'above after';
/** The content for the badge */
- @Input('matBadge')
- get content(): string | number | undefined | null {
- return this._content;
- }
- set content(newContent: string | number | undefined | null) {
- this._updateRenderedContent(newContent);
- }
- private _content: string | number | undefined | null;
+ @Input('matBadge') content: string | number | undefined | null;
/** Message used to describe the decorated element via aria-describedby */
@Input('matBadgeDescription')
get description(): string { return this._description; }
set description(newDescription: string) {
- this._updateHostAriaDescription(newDescription);
+ if (newDescription !== this._description) {
+ const badgeElement = this._badgeElement;
+ this._updateHostAriaDescription(newDescription, this._description);
+ this._description = newDescription;
+
+ if (badgeElement) {
+ newDescription ? badgeElement.setAttribute('aria-label', newDescription) :
+ badgeElement.removeAttribute('aria-label');
+ }
+ }
}
private _description: string;
@@ -113,12 +117,8 @@ export class MatBadge extends _MatBadgeBase implements OnInit, OnDestroy, CanDis
/** Unique id for the badge */
_id: number = nextId++;
- /** Visible badge element. */
private _badgeElement: HTMLElement | undefined;
- /** Whether the OnInit lifecycle hook has run yet */
- private _isInitialized = false;
-
constructor(
private _ngZone: NgZone,
private _elementRef: ElementRef,
@@ -145,54 +145,70 @@ export class MatBadge extends _MatBadgeBase implements OnInit, OnDestroy, CanDis
return this.position.indexOf('before') === -1;
}
- /**
- * Gets the element into which the badge's content is being rendered. Undefined if the element
- * hasn't been created (e.g. if the badge doesn't have content).
- */
- getBadgeElement(): HTMLElement | undefined {
- return this._badgeElement;
+ ngOnChanges(changes: SimpleChanges) {
+ const contentChange = changes['content'];
+
+ if (contentChange) {
+ const value = contentChange.currentValue;
+ this._hasContent = value != null && `${value}`.trim().length > 0;
+ this._updateTextContent();
+ }
}
- ngOnInit() {
- // We may have server-side rendered badge that we need to clear.
- // We need to do this in ngOnInit because the full content of the component
- // on which the badge is attached won't necessarily be in the DOM until this point.
- this._clearExistingBadges();
+ ngOnDestroy() {
+ const badgeElement = this._badgeElement;
- if (this.content && !this._badgeElement) {
- this._badgeElement = this._createBadgeElement();
- this._updateRenderedContent(this.content);
+ if (badgeElement) {
+ if (this.description) {
+ this._ariaDescriber.removeDescription(badgeElement, this.description);
+ }
+
+ // When creating a badge through the Renderer, Angular will keep it in an index.
+ // We have to destroy it ourselves, otherwise it'll be retained in memory.
+ if (this._renderer.destroyNode) {
+ this._renderer.destroyNode(badgeElement);
+ }
}
+ }
- this._isInitialized = true;
+ /**
+ * Gets the element into which the badge's content is being rendered.
+ * Undefined if the element hasn't been created (e.g. if the badge doesn't have content).
+ */
+ getBadgeElement(): HTMLElement | undefined {
+ return this._badgeElement;
}
- ngOnDestroy() {
- // ViewEngine only: when creating a badge through the Renderer, Angular remembers its index.
- // We have to destroy it ourselves, otherwise it'll be retained in memory.
- if (this._renderer.destroyNode) {
- this._renderer.destroyNode(this._badgeElement);
+ /** Injects a span element into the DOM with the content. */
+ private _updateTextContent(): HTMLSpanElement {
+ if (!this._badgeElement) {
+ this._badgeElement = this._createBadgeElement();
+ } else {
+ this._badgeElement.textContent = this._stringifyContent();
}
-
- this._ariaDescriber.removeDescription(this._elementRef.nativeElement, this.description);
+ return this._badgeElement;
}
/** Creates the badge element */
private _createBadgeElement(): HTMLElement {
const badgeElement = this._renderer.createElement('span');
const activeClass = 'mat-badge-active';
+ const contentClass = 'mat-badge-content';
+ // Clear any existing badges which may have persisted from a server-side render.
+ this._clearExistingBadges(contentClass);
badgeElement.setAttribute('id', `mat-badge-content-${this._id}`);
-
- // The badge is aria-hidden because we don't want it to appear in the page's navigation
- // flow. Instead, we use the badge to describe the decorated element with aria-describedby.
- badgeElement.setAttribute('aria-hidden', 'true');
- badgeElement.classList.add(BADGE_CONTENT_CLASS);
+ badgeElement.classList.add(contentClass);
+ badgeElement.textContent = this._stringifyContent();
if (this._animationMode === 'NoopAnimations') {
badgeElement.classList.add('_mat-animation-noopable');
}
+ if (this.description) {
+ badgeElement.setAttribute('aria-label', this.description);
+ }
+
this._elementRef.nativeElement.appendChild(badgeElement);
// animate in after insertion
@@ -209,55 +225,56 @@ export class MatBadge extends _MatBadgeBase implements OnInit, OnDestroy, CanDis
return badgeElement;
}
- /** Update the text content of the badge element in the DOM, creating the element if necessary. */
- private _updateRenderedContent(newContent: string | number | undefined | null): void {
- const newContentNormalized: string = `${newContent ?? ''}`.trim();
+ /** Sets the aria-label property on the element */
+ private _updateHostAriaDescription(newDescription: string, oldDescription: string): void {
+ // ensure content available before setting label
+ const content = this._updateTextContent();
- // Don't create the badge element if the directive isn't initialized because we want to
- // append the badge element to the *end* of the host element's content for backwards
- // compatibility.
- if (this._isInitialized && newContentNormalized && !this._badgeElement) {
- this._badgeElement = this._createBadgeElement();
+ if (oldDescription) {
+ this._ariaDescriber.removeDescription(content, oldDescription);
}
- if (this._badgeElement) {
- this._badgeElement.textContent = newContentNormalized;
- }
-
- this._content = newContentNormalized;
- }
-
- /** Updates the host element's aria description via AriaDescriber. */
- private _updateHostAriaDescription(newDescription: string): void {
- this._ariaDescriber.removeDescription(this._elementRef.nativeElement, this.description);
if (newDescription) {
- this._ariaDescriber.describe(this._elementRef.nativeElement, newDescription);
+ this._ariaDescriber.describe(content, newDescription);
}
- this._description = newDescription;
}
/** Adds css theme class given the color to the component host */
private _setColor(colorPalette: ThemePalette) {
- const classList = this._elementRef.nativeElement.classList;
- classList.remove(`mat-badge-${this._color}`);
- if (colorPalette) {
- classList.add(`mat-badge-${colorPalette}`);
+ if (colorPalette !== this._color) {
+ const classList = this._elementRef.nativeElement.classList;
+ if (this._color) {
+ classList.remove(`mat-badge-${this._color}`);
+ }
+ if (colorPalette) {
+ classList.add(`mat-badge-${colorPalette}`);
+ }
}
}
/** Clears any existing badges that might be left over from server-side rendering. */
- private _clearExistingBadges() {
- // Only check direct children of this host element in order to avoid deleting
- // any badges that might exist in descendant elements.
- const badges =
- this._elementRef.nativeElement.querySelectorAll(`:scope > .${BADGE_CONTENT_CLASS}`);
- for (const badgeElement of Array.from(badges)) {
- if (badgeElement !== this._badgeElement) {
- badgeElement.remove();
+ private _clearExistingBadges(cssClass: string) {
+ const element = this._elementRef.nativeElement;
+ let childCount = element.children.length;
+
+ // Use a reverse while, because we'll be removing elements from the list as we're iterating.
+ while (childCount--) {
+ const currentChild = element.children[childCount];
+
+ if (currentChild.classList.contains(cssClass)) {
+ element.removeChild(currentChild);
}
}
}
+ /** Gets the string representation of the badge content. */
+ private _stringifyContent(): string {
+ // Convert null and undefined to an empty string which is consistent
+ // with how Angular handles them in inside template interpolations.
+ const content = this.content;
+ return content == null ? '' : `${content}`;
+ }
+
static ngAcceptInputType_disabled: BooleanInput;
static ngAcceptInputType_hidden: BooleanInput;
static ngAcceptInputType_overlap: BooleanInput;
diff --git a/tools/public_api_guard/material/badge.md b/tools/public_api_guard/material/badge.md
index 237b86dcc659..dc728879f0ea 100644
--- a/tools/public_api_guard/material/badge.md
+++ b/tools/public_api_guard/material/badge.md
@@ -14,21 +14,22 @@ import * as i0 from '@angular/core';
import * as i2 from '@angular/cdk/a11y';
import * as i3 from '@angular/material/core';
import { NgZone } from '@angular/core';
+import { OnChanges } from '@angular/core';
import { OnDestroy } from '@angular/core';
-import { OnInit } from '@angular/core';
import { Renderer2 } from '@angular/core';
+import { SimpleChanges } from '@angular/core';
import { ThemePalette } from '@angular/material/core';
// @public
-export class MatBadge extends _MatBadgeBase implements OnInit, OnDestroy, CanDisable {
+export class MatBadge extends _MatBadgeBase implements OnDestroy, OnChanges, CanDisable {
constructor(_ngZone: NgZone, _elementRef: ElementRef, _ariaDescriber: AriaDescriber, _renderer: Renderer2, _animationMode?: string | undefined);
get color(): ThemePalette;
set color(value: ThemePalette);
- get content(): string | number | undefined | null;
- set content(newContent: string | number | undefined | null);
+ content: string | number | undefined | null;
get description(): string;
set description(newDescription: string);
getBadgeElement(): HTMLElement | undefined;
+ _hasContent: boolean;
get hidden(): boolean;
set hidden(val: boolean);
_id: number;
@@ -41,9 +42,9 @@ export class MatBadge extends _MatBadgeBase implements OnInit, OnDestroy, CanDis
// (undocumented)
static ngAcceptInputType_overlap: BooleanInput;
// (undocumented)
- ngOnDestroy(): void;
+ ngOnChanges(changes: SimpleChanges): void;
// (undocumented)
- ngOnInit(): void;
+ ngOnDestroy(): void;
get overlap(): boolean;
set overlap(val: boolean);
position: MatBadgePosition;