Skip to content

Commit 284d733

Browse files
committed
feat(cdk/a11y): add high-contrast mode detection
* Add a new `HighContrastModeDetector` service that's can detect whether the browser is in high-contrast mode (and which variety). * Augments `A11yModule` to use this serve to add classes to the document body indicating the high-contrast mode * Adds `A11yModule` to `MatCommonModule` so that this runs for all of the material components * Updates the `cdk-high-contrast` Sass mixin to include the new CSS classes
1 parent 1b94295 commit 284d733

File tree

6 files changed

+196
-4
lines changed

6 files changed

+196
-4
lines changed

src/cdk/a11y/_a11y.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,10 @@
2828
@media (-ms-high-contrast: $target) {
2929
@content;
3030
}
31+
32+
@at-root {
33+
.cdk-high-contrast-#{$target} #{&} {
34+
@content;
35+
}
36+
}
3137
}

src/cdk/a11y/a11y-module.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,17 @@ import {CommonModule} from '@angular/common';
1212
import {NgModule} from '@angular/core';
1313
import {CdkMonitorFocus} from './focus-monitor/focus-monitor';
1414
import {CdkTrapFocus} from './focus-trap/focus-trap';
15+
import {HighContrastModeDetector} from './high-contrast-mode/high-contrast-mode-detector';
1516
import {CdkAriaLive} from './live-announcer/live-announcer';
1617

18+
1719
@NgModule({
1820
imports: [CommonModule, PlatformModule, ObserversModule],
1921
declarations: [CdkAriaLive, CdkTrapFocus, CdkMonitorFocus],
2022
exports: [CdkAriaLive, CdkTrapFocus, CdkMonitorFocus],
2123
})
22-
export class A11yModule {}
24+
export class A11yModule {
25+
constructor(highContrastModeDetector: HighContrastModeDetector) {
26+
highContrastModeDetector._applyBodyHighContrastModeCssClasses();
27+
}
28+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import {
2+
BLACK_ON_WHITE_CSS_CLASS,
3+
HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS,
4+
HighContrastMode,
5+
HighContrastModeDetector, WHITE_ON_BLACK_CSS_CLASS,
6+
} from './high-contrast-mode-detector';
7+
import {Platform} from '@angular/cdk/platform';
8+
9+
10+
describe('HighContrastModeDetector', () => {
11+
let fakePlatform: Platform;
12+
13+
beforeEach(() => {
14+
fakePlatform = new Platform();
15+
});
16+
17+
it('should detect NONE for non-browser platforms', () => {
18+
fakePlatform.isBrowser = false;
19+
const detector = new HighContrastModeDetector(fakePlatform, {});
20+
expect(detector.getHighContrastMode())
21+
.toBe(HighContrastMode.NONE, 'Expected high-contrast mode `NONE` on non-browser platforms');
22+
});
23+
24+
it('should not apply any css classes for non-browser platforms', () => {
25+
fakePlatform.isBrowser = false;
26+
const fakeDocument = getFakeDocument('');
27+
const detector = new HighContrastModeDetector(fakePlatform, fakeDocument);
28+
detector._applyBodyHighContrastModeCssClasses();
29+
expect(fakeDocument.body.className)
30+
.toBe('', 'Expected body not to have any CSS classes in non-browser platforms');
31+
});
32+
33+
it('should detect WHITE_ON_BLACK when backgrounds are coerced to black', () => {
34+
const detector = new HighContrastModeDetector(fakePlatform, getFakeDocument('rgb(0,0,0)'));
35+
expect(detector.getHighContrastMode())
36+
.toBe(HighContrastMode.WHITE_ON_BLACK, 'Expected high-contrast mode `WHITE_ON_BLACK`');
37+
});
38+
39+
it('should detect BLACK_ON_WHITE when backgrounds are coerced to white ', () => {
40+
const detector = new HighContrastModeDetector(fakePlatform, getFakeDocument('rgb(1,1,1)'));
41+
expect(detector.getHighContrastMode())
42+
.toBe(HighContrastMode.BLACK_ON_WHITE, 'Expected high-contrast mode `BLACK_ON_WHITE`');
43+
});
44+
45+
it('should detect NONE when backgrounds are not coerced ', () => {
46+
const detector = new HighContrastModeDetector(fakePlatform, getFakeDocument('rgb(1,2,3)'));
47+
expect(detector.getHighContrastMode())
48+
.toBe(HighContrastMode.NONE, 'Expected high-contrast mode `NONE`');
49+
});
50+
51+
it('should apply css classes for BLACK_ON_WHITE high-contrast mode', () => {
52+
const fakeDocument = getFakeDocument('rgb(1,1,1)');
53+
const detector = new HighContrastModeDetector(fakePlatform, fakeDocument);
54+
detector._applyBodyHighContrastModeCssClasses();
55+
expect(fakeDocument.body.classList).toContain(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS);
56+
expect(fakeDocument.body.classList).toContain(BLACK_ON_WHITE_CSS_CLASS);
57+
});
58+
59+
it('should apply css classes for WHITE_ON_BLACK high-contrast mode', () => {
60+
const fakeDocument = getFakeDocument('rgb(0,0,0)');
61+
const detector = new HighContrastModeDetector(fakePlatform, fakeDocument);
62+
detector._applyBodyHighContrastModeCssClasses();
63+
expect(fakeDocument.body.classList).toContain(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS);
64+
expect(fakeDocument.body.classList).toContain(WHITE_ON_BLACK_CSS_CLASS);
65+
});
66+
67+
it('should not apply any css classes when backgrounds are not coerced', () => {
68+
const fakeDocument = getFakeDocument('');
69+
const detector = new HighContrastModeDetector(fakePlatform, fakeDocument);
70+
detector._applyBodyHighContrastModeCssClasses();
71+
expect(fakeDocument.body.className)
72+
.toBe('', 'Expected body not to have any CSS classes in non-browser platforms');
73+
});
74+
});
75+
76+
77+
/** Gets a fake document that includes a fake `window.getComputedStyle` implementation. */
78+
function getFakeDocument(fakeComputedBackgroundColor: string) {
79+
return {
80+
body: document.createElement('body'),
81+
createElement: (tag: string) => document.createElement(tag),
82+
defaultView: {
83+
getComputedStyle: () => ({backgroundColor: fakeComputedBackgroundColor}),
84+
},
85+
};
86+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Platform} from '@angular/cdk/platform';
10+
import {DOCUMENT} from '@angular/common';
11+
import {Inject, Injectable} from '@angular/core';
12+
13+
14+
/** Set of possible high-contrast mode backgrounds. */
15+
export enum HighContrastMode {
16+
NONE,
17+
BLACK_ON_WHITE,
18+
WHITE_ON_BLACK,
19+
}
20+
21+
/** CSS class applied to the document body when in black-on-white high-contrast mode. */
22+
export const BLACK_ON_WHITE_CSS_CLASS = 'cdk-high-contrast-black-on-white';
23+
24+
/** CSS class applied to the document body when in white-on-black high-contrast mode. */
25+
export const WHITE_ON_BLACK_CSS_CLASS = 'cdk-high-contrast-white-on-black';
26+
27+
/** CSS class applied to the document body when in high-contrast mode. */
28+
export const HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS = 'cdk-high-contrast-active';
29+
30+
/**
31+
* Service to determine whether the browser is currently in a high-constrast-mode environment.
32+
*
33+
* Microsoft Windows supports an accessibility feature called "High Contrast Mode". This mode
34+
* changes the appearance of all applications, including web applications, to dramatically increase
35+
* contrast.
36+
*/
37+
@Injectable({providedIn: 'root'})
38+
export class HighContrastModeDetector {
39+
private _document: Document;
40+
41+
constructor(private _platfrom: Platform, @Inject(DOCUMENT) document: any) {
42+
this._document = document;
43+
}
44+
45+
/** Gets the current high-constrast-mode for the page. */
46+
getHighContrastMode(): HighContrastMode {
47+
if (!this._platfrom.isBrowser) {
48+
return HighContrastMode.NONE;
49+
}
50+
51+
// Create a test element with an arbitrary background-color that is neither black nor
52+
// white; high-contrast mode will coerce the color to either black or white. Also ensure that
53+
// appending the test element to the DOM does not affect layout by absolutely positioning it
54+
const testElement = this._document.createElement('div');
55+
testElement.style.backgroundColor = 'rgb(1,2,3)';
56+
testElement.style.position = 'absolute';
57+
this._document.body.appendChild(testElement);
58+
59+
// Get the computed style ofor the background color, collapsing spaces to normalize between
60+
// browsers. Once we get this color, we no longer need the test element. Access the `window`
61+
// via the document so we can fake it in tests.
62+
const documentWindow = this._document.defaultView!;
63+
const computedColor =
64+
(documentWindow.getComputedStyle(testElement).backgroundColor || '').replace(/ /g, '');
65+
this._document.body.removeChild(testElement);
66+
67+
switch (computedColor) {
68+
case 'rgb(0,0,0)': return HighContrastMode.WHITE_ON_BLACK;
69+
case 'rgb(1,1,1)': return HighContrastMode.BLACK_ON_WHITE;
70+
}
71+
return HighContrastMode.NONE;
72+
}
73+
74+
/** Applies CSS classes indicating high-contrast mode to document body (browser-only). */
75+
_applyBodyHighContrastModeCssClasses(): void {
76+
if (this._platfrom.isBrowser) {
77+
const body = this._document.body;
78+
body.classList.remove(
79+
HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS, BLACK_ON_WHITE_CSS_CLASS, WHITE_ON_BLACK_CSS_CLASS);
80+
81+
const mode = this.getHighContrastMode();
82+
if (mode === HighContrastMode.BLACK_ON_WHITE) {
83+
body.classList.add(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS, BLACK_ON_WHITE_CSS_CLASS);
84+
} else if (mode === HighContrastMode.WHITE_ON_BLACK) {
85+
body.classList.add(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS, WHITE_ON_BLACK_CSS_CLASS);
86+
}
87+
}
88+
}
89+
}

src/cdk/a11y/public-api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,7 @@ export * from './live-announcer/live-announcer-tokens';
1616
export * from './focus-monitor/focus-monitor';
1717
export * from './fake-mousedown';
1818
export * from './a11y-module';
19+
export {
20+
HighContrastModeDetector,
21+
HighContrastMode,
22+
}from './high-contrast-mode/high-contrast-mode-detector';

src/material/core/common-behaviors/common-module.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {NgModule, InjectionToken, Optional, Inject, isDevMode, Version} from '@angular/core';
10-
import {BidiModule} from '@angular/cdk/bidi';
119
import {VERSION as CDK_VERSION} from '@angular/cdk';
10+
import {A11yModule} from '@angular/cdk/a11y';
11+
import {BidiModule} from '@angular/cdk/bidi';
12+
import {Inject, InjectionToken, isDevMode, NgModule, Optional, Version} from '@angular/core';
1213

1314
// Private version constant to circumvent test/build issues,
1415
// i.e. avoid core to depend on the @angular/material primary entry-point
@@ -53,7 +54,7 @@ export interface GranularSanityChecks {
5354
* This module should be imported to each top-level component module (e.g., MatTabsModule).
5455
*/
5556
@NgModule({
56-
imports: [BidiModule],
57+
imports: [A11yModule, BidiModule],
5758
exports: [BidiModule],
5859
})
5960
export class MatCommonModule {

0 commit comments

Comments
 (0)