diff --git a/src/cdk/a11y/_a11y.scss b/src/cdk/a11y/_a11y.scss index faa18eda44f7..35214a6759ed 100644 --- a/src/cdk/a11y/_a11y.scss +++ b/src/cdk/a11y/_a11y.scss @@ -25,7 +25,23 @@ // @param target Which kind of high contrast setting to target. Defaults to `active`, can be // `white-on-black` or `black-on-white`. @mixin cdk-high-contrast($target: active) { - @media (-ms-high-contrast: $target) { - @content; + @if ($target != 'active' and $target != 'black-on-white' and $target != 'white-on-black') { + @error 'Unknown cdk-high-contrast value "#{$target}" provided. Allowed values are "active", "black-on-white", and "white-on-black"'; // stylelint-disable + } + + // If the selector context has multiple parts, such as `.section, .region`, just doing + // `.cdk-high-contrast-xxx #{&}` will only apply the parent selector to the first part of the + // context. We address this by nesting the selector context under .cdk-high-contrast. + @at-root { + $selector-context: #{&}; + .cdk-high-contrast-#{$target} { + @if ($selector-context == '') { + @content + } @else { + #{$selector-context} { + @content + } + } + } } } diff --git a/src/cdk/a11y/a11y-module.ts b/src/cdk/a11y/a11y-module.ts index 5b9cabfb3ca8..cf2b9f45be4f 100644 --- a/src/cdk/a11y/a11y-module.ts +++ b/src/cdk/a11y/a11y-module.ts @@ -12,11 +12,17 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; import {CdkMonitorFocus} from './focus-monitor/focus-monitor'; import {CdkTrapFocus} from './focus-trap/focus-trap'; +import {HighContrastModeDetector} from './high-contrast-mode/high-contrast-mode-detector'; import {CdkAriaLive} from './live-announcer/live-announcer'; + @NgModule({ imports: [CommonModule, PlatformModule, ObserversModule], declarations: [CdkAriaLive, CdkTrapFocus, CdkMonitorFocus], exports: [CdkAriaLive, CdkTrapFocus, CdkMonitorFocus], }) -export class A11yModule {} +export class A11yModule { + constructor(highContrastModeDetector: HighContrastModeDetector) { + highContrastModeDetector._applyBodyHighContrastModeCssClasses(); + } +} diff --git a/src/cdk/a11y/high-contrast-mode/high-contrast-mode-detector.spec.ts b/src/cdk/a11y/high-contrast-mode/high-contrast-mode-detector.spec.ts new file mode 100644 index 000000000000..82bd797f5e49 --- /dev/null +++ b/src/cdk/a11y/high-contrast-mode/high-contrast-mode-detector.spec.ts @@ -0,0 +1,87 @@ +import { + BLACK_ON_WHITE_CSS_CLASS, + HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS, + HighContrastMode, + HighContrastModeDetector, WHITE_ON_BLACK_CSS_CLASS, +} from './high-contrast-mode-detector'; +import {Platform} from '@angular/cdk/platform'; + + +describe('HighContrastModeDetector', () => { + let fakePlatform: Platform; + + beforeEach(() => { + fakePlatform = new Platform(); + }); + + it('should detect NONE for non-browser platforms', () => { + fakePlatform.isBrowser = false; + const detector = new HighContrastModeDetector(fakePlatform, {}); + expect(detector.getHighContrastMode()) + .toBe(HighContrastMode.NONE, 'Expected high-contrast mode `NONE` on non-browser platforms'); + }); + + it('should not apply any css classes for non-browser platforms', () => { + fakePlatform.isBrowser = false; + const fakeDocument = getFakeDocument(''); + const detector = new HighContrastModeDetector(fakePlatform, fakeDocument); + detector._applyBodyHighContrastModeCssClasses(); + expect(fakeDocument.body.className) + .toBe('', 'Expected body not to have any CSS classes in non-browser platforms'); + }); + + it('should detect WHITE_ON_BLACK when backgrounds are coerced to black', () => { + const detector = new HighContrastModeDetector(fakePlatform, getFakeDocument('rgb(0,0,0)')); + expect(detector.getHighContrastMode()) + .toBe(HighContrastMode.WHITE_ON_BLACK, 'Expected high-contrast mode `WHITE_ON_BLACK`'); + }); + + it('should detect BLACK_ON_WHITE when backgrounds are coerced to white ', () => { + const detector = + new HighContrastModeDetector(fakePlatform, getFakeDocument('rgb(255,255,255)')); + expect(detector.getHighContrastMode()) + .toBe(HighContrastMode.BLACK_ON_WHITE, 'Expected high-contrast mode `BLACK_ON_WHITE`'); + }); + + it('should detect NONE when backgrounds are not coerced ', () => { + const detector = new HighContrastModeDetector(fakePlatform, getFakeDocument('rgb(1,2,3)')); + expect(detector.getHighContrastMode()) + .toBe(HighContrastMode.NONE, 'Expected high-contrast mode `NONE`'); + }); + + it('should apply css classes for BLACK_ON_WHITE high-contrast mode', () => { + const fakeDocument = getFakeDocument('rgb(255,255,255)'); + const detector = new HighContrastModeDetector(fakePlatform, fakeDocument); + detector._applyBodyHighContrastModeCssClasses(); + expect(fakeDocument.body.classList).toContain(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS); + expect(fakeDocument.body.classList).toContain(BLACK_ON_WHITE_CSS_CLASS); + }); + + it('should apply css classes for WHITE_ON_BLACK high-contrast mode', () => { + const fakeDocument = getFakeDocument('rgb(0,0,0)'); + const detector = new HighContrastModeDetector(fakePlatform, fakeDocument); + detector._applyBodyHighContrastModeCssClasses(); + expect(fakeDocument.body.classList).toContain(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS); + expect(fakeDocument.body.classList).toContain(WHITE_ON_BLACK_CSS_CLASS); + }); + + it('should not apply any css classes when backgrounds are not coerced', () => { + const fakeDocument = getFakeDocument(''); + const detector = new HighContrastModeDetector(fakePlatform, fakeDocument); + detector._applyBodyHighContrastModeCssClasses(); + expect(fakeDocument.body.className) + .toBe('', 'Expected body not to have any CSS classes in non-browser platforms'); + }); +}); + + +/** Gets a fake document that includes a fake `window.getComputedStyle` implementation. */ +function getFakeDocument(fakeComputedBackgroundColor: string) { + return { + body: document.createElement('body'), + createElement: (tag: string) => document.createElement(tag), + defaultView: { + getComputedStyle: () => ({backgroundColor: fakeComputedBackgroundColor}), + }, + }; +} diff --git a/src/cdk/a11y/high-contrast-mode/high-contrast-mode-detector.ts b/src/cdk/a11y/high-contrast-mode/high-contrast-mode-detector.ts new file mode 100644 index 000000000000..b1a78cf58d6f --- /dev/null +++ b/src/cdk/a11y/high-contrast-mode/high-contrast-mode-detector.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Platform} from '@angular/cdk/platform'; +import {DOCUMENT} from '@angular/common'; +import {Inject, Injectable} from '@angular/core'; + + +/** Set of possible high-contrast mode backgrounds. */ +export const enum HighContrastMode { + NONE, + BLACK_ON_WHITE, + WHITE_ON_BLACK, +} + +/** CSS class applied to the document body when in black-on-white high-contrast mode. */ +export const BLACK_ON_WHITE_CSS_CLASS = 'cdk-high-contrast-black-on-white'; + +/** CSS class applied to the document body when in white-on-black high-contrast mode. */ +export const WHITE_ON_BLACK_CSS_CLASS = 'cdk-high-contrast-white-on-black'; + +/** CSS class applied to the document body when in high-contrast mode. */ +export const HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS = 'cdk-high-contrast-active'; + +/** + * Service to determine whether the browser is currently in a high-constrast-mode environment. + * + * Microsoft Windows supports an accessibility feature called "High Contrast Mode". This mode + * changes the appearance of all applications, including web applications, to dramatically increase + * contrast. + * + * IE, Edge, and Firefox currently support this mode. Chrome does not support Windows High Contrast + * Mode. This service does not detect high-contrast mode as added by the Chrome "High Contrast" + * browser extension. + */ +@Injectable({providedIn: 'root'}) +export class HighContrastModeDetector { + private _document: Document; + + constructor(private _platform: Platform, @Inject(DOCUMENT) document: any) { + this._document = document; + } + + /** Gets the current high-constrast-mode for the page. */ + getHighContrastMode(): HighContrastMode { + if (!this._platform.isBrowser) { + return HighContrastMode.NONE; + } + + // Create a test element with an arbitrary background-color that is neither black nor + // white; high-contrast mode will coerce the color to either black or white. Also ensure that + // appending the test element to the DOM does not affect layout by absolutely positioning it + const testElement = this._document.createElement('div'); + testElement.style.backgroundColor = 'rgb(1,2,3)'; + testElement.style.position = 'absolute'; + this._document.body.appendChild(testElement); + + // Get the computed style for the background color, collapsing spaces to normalize between + // browsers. Once we get this color, we no longer need the test element. Access the `window` + // via the document so we can fake it in tests. + const documentWindow = this._document.defaultView!; + const computedColor = + (documentWindow.getComputedStyle(testElement).backgroundColor || '').replace(/ /g, ''); + this._document.body.removeChild(testElement); + + switch (computedColor) { + case 'rgb(0,0,0)': return HighContrastMode.WHITE_ON_BLACK; + case 'rgb(255,255,255)': return HighContrastMode.BLACK_ON_WHITE; + } + return HighContrastMode.NONE; + } + + /** Applies CSS classes indicating high-contrast mode to document body (browser-only). */ + _applyBodyHighContrastModeCssClasses(): void { + if (this._platform.isBrowser && this._document.body) { + const bodyClasses = this._document.body.classList; + // IE11 doesn't support `classList` operations with multiple arguments + bodyClasses.remove(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS); + bodyClasses.remove(BLACK_ON_WHITE_CSS_CLASS); + bodyClasses.remove(WHITE_ON_BLACK_CSS_CLASS); + + const mode = this.getHighContrastMode(); + if (mode === HighContrastMode.BLACK_ON_WHITE) { + bodyClasses.add(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS); + bodyClasses.add(BLACK_ON_WHITE_CSS_CLASS); + } else if (mode === HighContrastMode.WHITE_ON_BLACK) { + bodyClasses.add(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS); + bodyClasses.add(WHITE_ON_BLACK_CSS_CLASS); + } + } + } +} diff --git a/src/cdk/a11y/public-api.ts b/src/cdk/a11y/public-api.ts index 3416529b9c6d..26df94b5275e 100644 --- a/src/cdk/a11y/public-api.ts +++ b/src/cdk/a11y/public-api.ts @@ -16,3 +16,7 @@ export * from './live-announcer/live-announcer-tokens'; export * from './focus-monitor/focus-monitor'; export * from './fake-mousedown'; export * from './a11y-module'; +export { + HighContrastModeDetector, + HighContrastMode, +} from './high-contrast-mode/high-contrast-mode-detector'; diff --git a/src/material/core/common-behaviors/common-module.ts b/src/material/core/common-behaviors/common-module.ts index 30604af4741c..897f02337ec8 100644 --- a/src/material/core/common-behaviors/common-module.ts +++ b/src/material/core/common-behaviors/common-module.ts @@ -6,10 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ -import {NgModule, InjectionToken, Optional, Inject, isDevMode, Version} from '@angular/core'; +import {HighContrastModeDetector} from '@angular/cdk/a11y'; import {BidiModule} from '@angular/cdk/bidi'; +import {Inject, InjectionToken, isDevMode, NgModule, Optional, Version} from '@angular/core'; import {VERSION as CDK_VERSION} from '@angular/cdk'; + // Private version constant to circumvent test/build issues, // i.e. avoid core to depend on the @angular/material primary entry-point // Can be removed once the Material primary entry-point no longer @@ -69,7 +71,13 @@ export class MatCommonModule { /** Configured sanity checks. */ private _sanityChecks: SanityChecks; - constructor(@Optional() @Inject(MATERIAL_SANITY_CHECKS) sanityChecks: any) { + constructor( + highContrastModeDetector: HighContrastModeDetector, + @Optional() @Inject(MATERIAL_SANITY_CHECKS) sanityChecks: any) { + // While A11yModule also does this, we repeat it here to avoid importing A11yModule + // in MatCommonModule. + highContrastModeDetector._applyBodyHighContrastModeCssClasses(); + // Note that `_sanityChecks` is typed to `any`, because AoT // throws an error if we use the `SanityChecks` type directly. this._sanityChecks = sanityChecks; diff --git a/tools/public_api_guard/cdk/a11y.d.ts b/tools/public_api_guard/cdk/a11y.d.ts index ff657751d907..36ba7e8722f4 100644 --- a/tools/public_api_guard/cdk/a11y.d.ts +++ b/tools/public_api_guard/cdk/a11y.d.ts @@ -1,4 +1,5 @@ export declare class A11yModule { + constructor(highContrastModeDetector: HighContrastModeDetector); static ɵinj: i0.ɵɵInjectorDef; static ɵmod: i0.ɵɵNgModuleDefWithMeta; } @@ -106,6 +107,20 @@ export declare class FocusTrapFactory { static ɵprov: i0.ɵɵInjectableDef; } +export declare const enum HighContrastMode { + NONE = 0, + BLACK_ON_WHITE = 1, + WHITE_ON_BLACK = 2 +} + +export declare class HighContrastModeDetector { + constructor(_platform: Platform, document: any); + _applyBodyHighContrastModeCssClasses(): void; + getHighContrastMode(): HighContrastMode; + static ɵfac: i0.ɵɵFactoryDef; + static ɵprov: i0.ɵɵInjectableDef; +} + export interface Highlightable extends ListKeyManagerOption { setActiveStyles(): void; setInactiveStyles(): void; diff --git a/tools/public_api_guard/material/core.d.ts b/tools/public_api_guard/material/core.d.ts index e07183267024..6cb30c97b588 100644 --- a/tools/public_api_guard/material/core.d.ts +++ b/tools/public_api_guard/material/core.d.ts @@ -201,7 +201,7 @@ export declare const MAT_OPTION_PARENT_COMPONENT: InjectionToken; export declare class MatCommonModule { - constructor(sanityChecks: any); + constructor(highContrastModeDetector: HighContrastModeDetector, sanityChecks: any); static ɵinj: i0.ɵɵInjectorDef; static ɵmod: i0.ɵɵNgModuleDefWithMeta; }