Skip to content

feat(cdk/a11y): add high-contrast mode detection #17378

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Nov 8, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src/cdk/a11y/_a11y.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
}
8 changes: 7 additions & 1 deletion src/cdk/a11y/a11y-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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}),
},
};
}
97 changes: 97 additions & 0 deletions src/cdk/a11y/high-contrast-mode/high-contrast-mode-detector.ts
Original file line number Diff line number Diff line change
@@ -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');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we cache this in a similar way to how we cache the viewport size? It's unlikely for this to change dynamically and people might end up calling it frequently, if they don't know that it can be expensive.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We invalidate the viewport size cache on window resize, though. We don't have a similar way to know to invalidate for this, so I want to avoid caching for now.

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!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW you could also fake it in tests using spyOn(window, 'getComputedStyle').and.returnValue.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know, I'm just not a big fan of using Jasmine spies in place of more full-blown fakes.

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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It appears that this function may be called too early, when body does not yet exist (e.g. if script is in head). It looks like we need some delay to make sure this is applied.

See _checkThemeIsPresent in common-module.ts for a simliar comment about checking for this._document.body

// 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);
}
}
}
}
4 changes: 4 additions & 0 deletions src/cdk/a11y/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
12 changes: 10 additions & 2 deletions src/material/core/common-behaviors/common-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions tools/public_api_guard/cdk/a11y.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export declare class A11yModule {
constructor(highContrastModeDetector: HighContrastModeDetector);
static ɵinj: i0.ɵɵInjectorDef<A11yModule>;
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]>;
}
Expand Down Expand Up @@ -106,6 +107,20 @@ export declare class FocusTrapFactory {
static ɵprov: i0.ɵɵInjectableDef<FocusTrapFactory>;
}

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<HighContrastModeDetector>;
static ɵprov: i0.ɵɵInjectableDef<HighContrastModeDetector>;
}

export interface Highlightable extends ListKeyManagerOption {
setActiveStyles(): void;
setInactiveStyles(): void;
Expand Down
2 changes: 1 addition & 1 deletion tools/public_api_guard/material/core.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export declare const MAT_OPTION_PARENT_COMPONENT: InjectionToken<MatOptionParent
export declare const MAT_RIPPLE_GLOBAL_OPTIONS: InjectionToken<RippleGlobalOptions>;

export declare class MatCommonModule {
constructor(sanityChecks: any);
constructor(highContrastModeDetector: HighContrastModeDetector, sanityChecks: any);
static ɵinj: i0.ɵɵInjectorDef<MatCommonModule>;
static ɵmod: i0.ɵɵNgModuleDefWithMeta<MatCommonModule, never, [typeof i1.BidiModule], [typeof i1.BidiModule]>;
}
Expand Down