diff --git a/src/material/tooltip/tooltip.spec.ts b/src/material/tooltip/tooltip.spec.ts index f6553f8a923d..bc4c93464d3b 100644 --- a/src/material/tooltip/tooltip.spec.ts +++ b/src/material/tooltip/tooltip.spec.ts @@ -90,13 +90,14 @@ describe('MDC-based MatTooltip', () => { let buttonElement: HTMLButtonElement; let tooltipDirective: MatTooltip; - beforeEach(() => { + beforeEach(fakeAsync(() => { fixture = TestBed.createComponent(BasicTooltipDemo); fixture.detectChanges(); + tick(); buttonDebugElement = fixture.debugElement.query(By.css('button'))!; - buttonElement = buttonDebugElement.nativeElement; + buttonElement = buttonDebugElement.nativeElement; tooltipDirective = buttonDebugElement.injector.get(MatTooltip); - }); + })); it('should show and hide the tooltip', fakeAsync(() => { assertTooltipInstance(tooltipDirective, false); @@ -616,7 +617,7 @@ describe('MDC-based MatTooltip', () => { expect(overlayContainerElement.textContent).toBe(''); })); - it('should have an aria-described element with the tooltip message', fakeAsync(() => { + it('should have an aria-describedby element with the tooltip message', fakeAsync(() => { const dynamicTooltipsDemoFixture = TestBed.createComponent(DynamicTooltipsDemo); const dynamicTooltipsComponent = dynamicTooltipsDemoFixture.componentInstance; @@ -632,18 +633,30 @@ describe('MDC-based MatTooltip', () => { expect(document.querySelector(`#${secondButtonAria}`)!.textContent).toBe('Tooltip Two'); })); - it( - 'should not add an ARIA description for elements that have the same text as a' + - 'data-bound aria-label', - fakeAsync(() => { - const ariaLabelFixture = TestBed.createComponent(DataBoundAriaLabelTooltip); - ariaLabelFixture.detectChanges(); - tick(); + it('should not add an ARIA description for elements that have the same text as a data-bound aria-label', fakeAsync(() => { + const ariaLabelFixture = TestBed.createComponent(DataBoundAriaLabelTooltip); + ariaLabelFixture.detectChanges(); + tick(); + + const button = ariaLabelFixture.nativeElement.querySelector('button'); + expect(button.getAttribute('aria-describedby')).toBeFalsy(); + })); + + it('should toggle aria-describedby depending on whether the tooltip is disabled', fakeAsync(() => { + expect(buttonElement.getAttribute('aria-describedby')).toBeTruthy(); - const button = ariaLabelFixture.nativeElement.querySelector('button'); - expect(button.getAttribute('aria-describedby')).toBeFalsy(); - }), - ); + fixture.componentInstance.tooltipDisabled = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + tick(); + expect(buttonElement.hasAttribute('aria-describedby')).toBe(false); + + fixture.componentInstance.tooltipDisabled = false; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + tick(); + expect(buttonElement.getAttribute('aria-describedby')).toBeTruthy(); + })); it('should not try to dispose the tooltip when destroyed and done hiding', fakeAsync(() => { tooltipDirective.show(); @@ -1585,17 +1598,19 @@ describe('MDC-based MatTooltip', () => { + [matTooltipClass]="{'custom-one': showTooltipClass, 'custom-two': showTooltipClass}" + [matTooltipTouchGestures]="touchGestures" + [matTooltipDisabled]="tooltipDisabled">Button }`, standalone: true, imports: [MatTooltipModule, OverlayModule], }) class BasicTooltipDemo { - position: string = 'below'; + position = 'below'; message: any = initialTooltipMessage; - showButton: boolean = true; + showButton = true; showTooltipClass = false; + tooltipDisabled = false; touchGestures: TooltipTouchGestures = 'auto'; @ViewChild(MatTooltip) tooltip: MatTooltip; @ViewChild('button') button: ElementRef; diff --git a/src/material/tooltip/tooltip.ts b/src/material/tooltip/tooltip.ts index 428d8a5792fc..71a5409d5339 100644 --- a/src/material/tooltip/tooltip.ts +++ b/src/material/tooltip/tooltip.ts @@ -205,6 +205,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit { private _viewportMargin = 8; private _currentPosition: TooltipPosition; private readonly _cssClassPrefix: string = 'mat-mdc'; + private _ariaDescriptionPending: boolean; /** Allows the user to define the position of the tooltip relative to the parent element */ @Input('matTooltipPosition') @@ -246,13 +247,19 @@ export class MatTooltip implements OnDestroy, AfterViewInit { } set disabled(value: BooleanInput) { - this._disabled = coerceBooleanProperty(value); + const isDisabled = coerceBooleanProperty(value); - // If tooltip is disabled, hide immediately. - if (this._disabled) { - this.hide(0); - } else { - this._setupPointerEnterEventsIfNeeded(); + if (this._disabled !== isDisabled) { + this._disabled = isDisabled; + + // If tooltip is disabled, hide immediately. + if (isDisabled) { + this.hide(0); + } else { + this._setupPointerEnterEventsIfNeeded(); + } + + this._syncAriaDescription(this.message); } } @@ -307,7 +314,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit { } set message(value: string | null | undefined) { - this._ariaDescriber.removeDescription(this._elementRef.nativeElement, this._message, 'tooltip'); + const oldMessage = this._message; // If the message is not a string (e.g. number), convert it to a string and trim it. // Must convert with `String(value)`, not `${value}`, otherwise Closure Compiler optimises @@ -319,16 +326,9 @@ export class MatTooltip implements OnDestroy, AfterViewInit { } else { this._setupPointerEnterEventsIfNeeded(); this._updateTooltipMessage(); - this._ngZone.runOutsideAngular(() => { - // The `AriaDescriber` has some functionality that avoids adding a description if it's the - // same as the `aria-label` of an element, however we can't know whether the tooltip trigger - // has a data-bound `aria-label` or when it'll be set for the first time. We can avoid the - // issue by deferring the description by a tick so Angular has time to set the `aria-label`. - Promise.resolve().then(() => { - this._ariaDescriber.describe(this._elementRef.nativeElement, this.message, 'tooltip'); - }); - }); } + + this._syncAriaDescription(oldMessage); } private _message = ''; @@ -904,6 +904,30 @@ export class MatTooltip implements OnDestroy, AfterViewInit { (style as any).webkitTapHighlightColor = 'transparent'; } } + + /** Updates the tooltip's ARIA description based on it current state. */ + private _syncAriaDescription(oldMessage: string): void { + if (this._ariaDescriptionPending) { + return; + } + + this._ariaDescriptionPending = true; + this._ariaDescriber.removeDescription(this._elementRef.nativeElement, oldMessage, 'tooltip'); + + this._ngZone.runOutsideAngular(() => { + // The `AriaDescriber` has some functionality that avoids adding a description if it's the + // same as the `aria-label` of an element, however we can't know whether the tooltip trigger + // has a data-bound `aria-label` or when it'll be set for the first time. We can avoid the + // issue by deferring the description by a tick so Angular has time to set the `aria-label`. + Promise.resolve().then(() => { + this._ariaDescriptionPending = false; + + if (this.message && !this.disabled) { + this._ariaDescriber.describe(this._elementRef.nativeElement, this.message, 'tooltip'); + } + }); + }); + } } /**