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 - - - - -
- 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;