Skip to content

Commit 2ede62b

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 * Have `MatCommonModule` apply high-contrast css classes * Replace `ms-` media queries in `cdk-high-contrast` Sass mixin with the new CSS classes
1 parent 5c92c06 commit 2ede62b

File tree

8 files changed

+233
-6
lines changed

8 files changed

+233
-6
lines changed

src/cdk/a11y/_a11y.scss

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,19 @@
2525
// @param target Which kind of high contrast setting to target. Defaults to `active`, can be
2626
// `white-on-black` or `black-on-white`.
2727
@mixin cdk-high-contrast($target: active) {
28-
@media (-ms-high-contrast: $target) {
29-
@content;
28+
@if ($target != 'active' and $target != 'black-on-white' and $target != 'white-on-black') {
29+
@error 'Unknown cdk-high-contrast value "#{$target}" provided. Allowed values are "active", "black-on-white", and "white-on-black"'; // stylelint-disable
30+
}
31+
32+
// If the selector context has multiple parts, such as `.section, .region`, just doing
33+
// `.cdk-high-contrast-xxx #{&}` will only apply the parent selector to the first part of the
34+
// context. We address this by nesting the selector context under .cdk-high-contrast.
35+
@at-root {
36+
$selector-context: #{&};
37+
.cdk-high-contrast-#{$target} {
38+
#{$selector-context} {
39+
@content
40+
}
41+
}
3042
}
3143
}

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: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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 =
41+
new HighContrastModeDetector(fakePlatform, getFakeDocument('rgb(255,255,255)'));
42+
expect(detector.getHighContrastMode())
43+
.toBe(HighContrastMode.BLACK_ON_WHITE, 'Expected high-contrast mode `BLACK_ON_WHITE`');
44+
});
45+
46+
it('should detect NONE when backgrounds are not coerced ', () => {
47+
const detector = new HighContrastModeDetector(fakePlatform, getFakeDocument('rgb(1,2,3)'));
48+
expect(detector.getHighContrastMode())
49+
.toBe(HighContrastMode.NONE, 'Expected high-contrast mode `NONE`');
50+
});
51+
52+
it('should apply css classes for BLACK_ON_WHITE high-contrast mode', () => {
53+
const fakeDocument = getFakeDocument('rgb(255,255,255)');
54+
const detector = new HighContrastModeDetector(fakePlatform, fakeDocument);
55+
detector._applyBodyHighContrastModeCssClasses();
56+
expect(fakeDocument.body.classList).toContain(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS);
57+
expect(fakeDocument.body.classList).toContain(BLACK_ON_WHITE_CSS_CLASS);
58+
});
59+
60+
it('should apply css classes for WHITE_ON_BLACK high-contrast mode', () => {
61+
const fakeDocument = getFakeDocument('rgb(0,0,0)');
62+
const detector = new HighContrastModeDetector(fakePlatform, fakeDocument);
63+
detector._applyBodyHighContrastModeCssClasses();
64+
expect(fakeDocument.body.classList).toContain(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS);
65+
expect(fakeDocument.body.classList).toContain(WHITE_ON_BLACK_CSS_CLASS);
66+
});
67+
68+
it('should not apply any css classes when backgrounds are not coerced', () => {
69+
const fakeDocument = getFakeDocument('');
70+
const detector = new HighContrastModeDetector(fakePlatform, fakeDocument);
71+
detector._applyBodyHighContrastModeCssClasses();
72+
expect(fakeDocument.body.className)
73+
.toBe('', 'Expected body not to have any CSS classes in non-browser platforms');
74+
});
75+
});
76+
77+
78+
/** Gets a fake document that includes a fake `window.getComputedStyle` implementation. */
79+
function getFakeDocument(fakeComputedBackgroundColor: string) {
80+
return {
81+
body: document.createElement('body'),
82+
createElement: (tag: string) => document.createElement(tag),
83+
defaultView: {
84+
getComputedStyle: () => ({backgroundColor: fakeComputedBackgroundColor}),
85+
},
86+
};
87+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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 const 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+
* IE, Edge, and Firefox currently support this mode. Chrome does not support Windows High Contrast
38+
* Mode. This service does not detect high-contrast mode as added by the Chrome "High Contrast"
39+
* browser extension.
40+
*/
41+
@Injectable({providedIn: 'root'})
42+
export class HighContrastModeDetector {
43+
private _document: Document;
44+
45+
constructor(private _platform: Platform, @Inject(DOCUMENT) document: any) {
46+
this._document = document;
47+
}
48+
49+
/** Gets the current high-constrast-mode for the page. */
50+
getHighContrastMode(): HighContrastMode {
51+
if (!this._platform.isBrowser) {
52+
return HighContrastMode.NONE;
53+
}
54+
55+
// Create a test element with an arbitrary background-color that is neither black nor
56+
// white; high-contrast mode will coerce the color to either black or white. Also ensure that
57+
// appending the test element to the DOM does not affect layout by absolutely positioning it
58+
const testElement = this._document.createElement('div');
59+
testElement.style.backgroundColor = 'rgb(1,2,3)';
60+
testElement.style.position = 'absolute';
61+
this._document.body.appendChild(testElement);
62+
63+
// Get the computed style for the background color, collapsing spaces to normalize between
64+
// browsers. Once we get this color, we no longer need the test element. Access the `window`
65+
// via the document so we can fake it in tests.
66+
const documentWindow = this._document.defaultView!;
67+
const computedColor =
68+
(documentWindow.getComputedStyle(testElement).backgroundColor || '').replace(/ /g, '');
69+
this._document.body.removeChild(testElement);
70+
71+
switch (computedColor) {
72+
case 'rgb(0,0,0)': return HighContrastMode.WHITE_ON_BLACK;
73+
case 'rgb(255,255,255)': return HighContrastMode.BLACK_ON_WHITE;
74+
}
75+
return HighContrastMode.NONE;
76+
}
77+
78+
/** Applies CSS classes indicating high-contrast mode to document body (browser-only). */
79+
_applyBodyHighContrastModeCssClasses(): void {
80+
if (this._platform.isBrowser && this._document.body) {
81+
const bodyClasses = this._document.body.classList;
82+
// IE11 doesn't support `classList` operations with multiple arguments
83+
bodyClasses.remove(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS);
84+
bodyClasses.remove(BLACK_ON_WHITE_CSS_CLASS);
85+
bodyClasses.remove(WHITE_ON_BLACK_CSS_CLASS);
86+
87+
const mode = this.getHighContrastMode();
88+
if (mode === HighContrastMode.BLACK_ON_WHITE) {
89+
bodyClasses.add(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS);
90+
bodyClasses.add(BLACK_ON_WHITE_CSS_CLASS);
91+
} else if (mode === HighContrastMode.WHITE_ON_BLACK) {
92+
bodyClasses.add(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS);
93+
bodyClasses.add(WHITE_ON_BLACK_CSS_CLASS);
94+
}
95+
}
96+
}
97+
}

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: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

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

14+
1315
// Private version constant to circumvent test/build issues,
1416
// i.e. avoid core to depend on the @angular/material primary entry-point
1517
// Can be removed once the Material primary entry-point no longer
@@ -69,7 +71,13 @@ export class MatCommonModule {
6971
/** Configured sanity checks. */
7072
private _sanityChecks: SanityChecks;
7173

72-
constructor(@Optional() @Inject(MATERIAL_SANITY_CHECKS) sanityChecks: any) {
74+
constructor(
75+
highContrastModeDetector: HighContrastModeDetector,
76+
@Optional() @Inject(MATERIAL_SANITY_CHECKS) sanityChecks: any) {
77+
// While A11yModule also does this, we repeat it here to avoid importing A11yModule
78+
// in MatCommonModule.
79+
highContrastModeDetector._applyBodyHighContrastModeCssClasses();
80+
7381
// Note that `_sanityChecks` is typed to `any`, because AoT
7482
// throws an error if we use the `SanityChecks` type directly.
7583
this._sanityChecks = sanityChecks;

tools/public_api_guard/cdk/a11y.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export declare class A11yModule {
2+
constructor(highContrastModeDetector: HighContrastModeDetector);
23
static ɵinj: i0.ɵɵInjectorDef<A11yModule>;
34
static ɵmod: i0.ɵɵNgModuleDefWithMeta<A11yModule, [typeof i1.CdkAriaLive, typeof i2.CdkTrapFocus, typeof i3.CdkMonitorFocus], [typeof i4.CommonModule, typeof i5.PlatformModule, typeof i6.ObserversModule], [typeof i1.CdkAriaLive, typeof i2.CdkTrapFocus, typeof i3.CdkMonitorFocus]>;
45
}
@@ -106,6 +107,18 @@ export declare class FocusTrapFactory {
106107
static ɵprov: i0.ɵɵInjectableDef<FocusTrapFactory>;
107108
}
108109

110+
export declare const enum HighContrastMode {
111+
NONE = 0,
112+
BLACK_ON_WHITE = 1,
113+
WHITE_ON_BLACK = 2
114+
}
115+
116+
export declare class HighContrastModeDetector {
117+
constructor(_platfrom: Platform, document: any);
118+
_applyBodyHighContrastModeCssClasses(): void;
119+
getHighContrastMode(): HighContrastMode;
120+
}
121+
109122
export interface Highlightable extends ListKeyManagerOption {
110123
setActiveStyles(): void;
111124
setInactiveStyles(): void;

tools/public_api_guard/material/core.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,9 @@ export declare const MAT_OPTION_PARENT_COMPONENT: InjectionToken<MatOptionParent
201201
export declare const MAT_RIPPLE_GLOBAL_OPTIONS: InjectionToken<RippleGlobalOptions>;
202202

203203
export declare class MatCommonModule {
204-
constructor(sanityChecks: any);
205204
static ɵinj: i0.ɵɵInjectorDef<MatCommonModule>;
206205
static ɵmod: i0.ɵɵNgModuleDefWithMeta<MatCommonModule, never, [typeof i1.BidiModule], [typeof i1.BidiModule]>;
206+
constructor(highContrastModeDetector: HighContrastModeDetector, sanityChecks: any);
207207
}
208208

209209
export declare type MatDateFormats = {

0 commit comments

Comments
 (0)