Skip to content
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

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

Merged
merged 1 commit into from
Nov 8, 2019

Conversation

jelbourn
Copy link
Member

  • 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

@jelbourn jelbourn added Accessibility This issue is related to accessibility (a11y) target: major This PR is targeted for the next major release labels Oct 11, 2019
@googlebot googlebot added the cla: yes PR author has agreed to Google's Contributor License Agreement label Oct 11, 2019
@@ -28,4 +28,10 @@
@media (-ms-high-contrast: $target) {
Copy link
Contributor

Choose a reason for hiding this comment

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

can we detect this in JS and apply the appropriate class instead so that we don't need to duplicate all of the styles?

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 potentially don't need the @media query at all if the class works for IE11 and Edge, but I don't have a Windows machine available to confirm at the moment (our test workstation is busted). I'll chat with Kristiyan about it

Copy link
Contributor

Choose a reason for hiding this comment

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

Sounds good. If what you have currently doesn't work we can probably add some extra logic to detect on IE/Edge which would likely wind up smaller than duplicating all the CSS

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 wasn't super concerned because the high-contrast styles tend to be pretty small and relatively infrequent, but agreed that it's better to avoid if possible.

Copy link
Member

@crisbeto crisbeto left a comment

Choose a reason for hiding this comment

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

I tested this out against IE, Edge, Firefox and Chrome on Windows. These are the results:

  • IE and Edge work after fixing the issue with classList that I commented on below and I tweaked the color for detecting black-on-white mode.
  • Firefox also seems to work since it follows a similar implementation to the Microsoft browsers.
  • It doesn't detect high contrast mode in Chrome that is added through the extension. It looks like Chrome returns the original colors from getComputedStyle. We could work around it by checking the attribute that the extension sets on the body, but that might be a little fragile.

I like the approach since it allows us to at least cover Firefox users which we couldn't before, however I'm not sure whether we can apply the same styles to all browsers and expect it to work consistently. E.g. the places where we add an outline are working correctly, but some of the more advanced cases, like using a border-image to remove the border from form fields, don't work on Firefox.

Angular_Material_-_Mozilla_Firefox_2019-10-13_09-39-11

// 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.

// Get the computed style ofor 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.

_applyBodyHighContrastModeCssClasses(): void {
if (this._platfrom.isBrowser) {
const body = this._document.body;
body.classList.remove(
Copy link
Member

Choose a reason for hiding this comment

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

The signature of classlist.remove with multiple classes isn't supported on IE. You need to do 3 separate remove calls.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done



/** Set of possible high-contrast mode backgrounds. */
export enum HighContrastMode {
Copy link
Member

Choose a reason for hiding this comment

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

Should it be a const enum so that TS inlines the values during compilation?

Copy link
Member Author

Choose a reason for hiding this comment

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

Done, meant to do that

@@ -28,4 +28,10 @@
@media (-ms-high-contrast: $target) {
@content;
}

@at-root {
.cdk-high-contrast-#{$target} #{&} {
Copy link
Member

Choose a reason for hiding this comment

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

While we're at it, should we have some compile-time validation that the target is valid?

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

Copy link
Contributor

@andrewseguin andrewseguin Nov 6, 2019

Choose a reason for hiding this comment

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

This produces an unintended effect for cases where #{&} includes multiple comma-delimited selectors.

For example, consider the following Sass:

.element-a, .element-b {
  @include cdk-high-contrast() {
    background: blue;
  }
}

This will actually produce the following:

.cdk-high-contrast .element-a, .element-b {
  background: blue;
}

https://www.sassmeister.com/gist/30c179f33ac9dc40dab1ef20cd374dfc

In short, Sass is not prepending .cdk-high-contrast to each selector and instead just putting it once before the whole rule.

Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like this fix may be viable, where we loop over each item in the active selector:

  @at-root {
    @each $i in & {
      .cdk-high-contrast #{$i} {
        @content
      }
    }
  }

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks for looking into the failures. Using @each here will duplicate the content for each part of the compound selector. Instead I did this:

  @at-root {
    $selector-context: #{&};
    .cdk-high-contrast-#{$target} {
      #{$selector-context} {
        @content
      }
    }
  }

For the "body not present yet" issue, I'm going to just add the check for now and we can figure out if further action is necessary as a follow-up. I suspect this is really just an issue with tests and not with real applications.

export {
HighContrastModeDetector,
HighContrastMode,
}from './high-contrast-mode/high-contrast-mode-detector';
Copy link
Member

Choose a reason for hiding this comment

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

Needs a space before from.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

@@ -53,7 +54,7 @@ export interface GranularSanityChecks {
* This module should be imported to each top-level component module (e.g., MatTabsModule).
*/
@NgModule({
imports: [BidiModule],
imports: [A11yModule, BidiModule],
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure whether we should be adding the A11yModule to all of the Material modules since it's one of our largest modules and we only need the high contrast detection. It might be better to just inject the HighContrastModeDetector since it's a tree-shakeable provider and add the call to set the body class. We can do the same in the A11yModule to cover users that import the CDK, but not Material.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done out of additional caution, but adding A11yModule shouldn't increase any size if we don't actually consume any of the directives or injectables.


const mode = this.getHighContrastMode();
if (mode === HighContrastMode.BLACK_ON_WHITE) {
body.classList.add(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS, BLACK_ON_WHITE_CSS_CLASS);
Copy link
Member

Choose a reason for hiding this comment

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

IE doesn't support add with multiple arguments either.

Copy link
Member Author

Choose a reason for hiding this comment

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

Done


switch (computedColor) {
case 'rgb(0,0,0)': return HighContrastMode.WHITE_ON_BLACK;
case 'rgb(1,1,1)': return HighContrastMode.BLACK_ON_WHITE;
Copy link
Member

Choose a reason for hiding this comment

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

The white-on-black detection here works in IE, but not the black-on-white. The return value here should be rgb(255,255,255).

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep, this should always have been 255; pure brain misfire. Done.

crisbeto added a commit to crisbeto/material2 that referenced this pull request Oct 13, 2019
Once in a while we hit an issue because we use some of the `classList` methods in a way that isn't supported in all browsers (e.g. passing multiple parameters to `add` in angular#17378). These changes add a simple lint rule to catch these cases earlier.
crisbeto added a commit to crisbeto/material2 that referenced this pull request Oct 13, 2019
Once in a while we hit an issue because we use some of the `classList` methods in a way that isn't supported in all browsers (e.g. passing multiple parameters to `add` in angular#17378). These changes add a simple lint rule to catch these cases earlier.
crisbeto added a commit to crisbeto/material2 that referenced this pull request Oct 13, 2019
Once in a while we hit an issue because we use some of the `classList` methods in a way that isn't supported in all browsers (e.g. passing multiple parameters to `add` in angular#17378). These changes add a simple lint rule to catch these cases earlier.
crisbeto added a commit to crisbeto/material2 that referenced this pull request Oct 13, 2019
Once in a while we hit an issue because we use some of the `classList` methods in a way that isn't supported in all browsers (e.g. passing multiple parameters to `add` in angular#17378). These changes add a simple lint rule to catch these cases earlier.
mmalerba pushed a commit that referenced this pull request Oct 14, 2019
Once in a while we hit an issue because we use some of the `classList` methods in a way that isn't supported in all browsers (e.g. passing multiple parameters to `add` in #17378). These changes add a simple lint rule to catch these cases earlier.
Copy link
Member Author

@jelbourn jelbourn left a comment

Choose a reason for hiding this comment

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

@crisbeto addressed comments, could you take another look? In particular, I want to see if removing the ms- media queries still works for IE

// 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 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.

@@ -28,4 +28,10 @@
@media (-ms-high-contrast: $target) {
@content;
}

@at-root {
.cdk-high-contrast-#{$target} #{&} {
Copy link
Member Author

Choose a reason for hiding this comment

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

Done



/** Set of possible high-contrast mode backgrounds. */
export enum HighContrastMode {
Copy link
Member Author

Choose a reason for hiding this comment

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

Done, meant to do that

// Get the computed style ofor 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 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.


switch (computedColor) {
case 'rgb(0,0,0)': return HighContrastMode.WHITE_ON_BLACK;
case 'rgb(1,1,1)': return HighContrastMode.BLACK_ON_WHITE;
Copy link
Member Author

Choose a reason for hiding this comment

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

Yep, this should always have been 255; pure brain misfire. Done.

_applyBodyHighContrastModeCssClasses(): void {
if (this._platfrom.isBrowser) {
const body = this._document.body;
body.classList.remove(
Copy link
Member Author

Choose a reason for hiding this comment

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

Done


const mode = this.getHighContrastMode();
if (mode === HighContrastMode.BLACK_ON_WHITE) {
body.classList.add(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS, BLACK_ON_WHITE_CSS_CLASS);
Copy link
Member Author

Choose a reason for hiding this comment

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

Done

export {
HighContrastModeDetector,
HighContrastMode,
}from './high-contrast-mode/high-contrast-mode-detector';
Copy link
Member Author

Choose a reason for hiding this comment

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

Done

@@ -53,7 +54,7 @@ export interface GranularSanityChecks {
* This module should be imported to each top-level component module (e.g., MatTabsModule).
*/
@NgModule({
imports: [BidiModule],
imports: [A11yModule, BidiModule],
Copy link
Member Author

Choose a reason for hiding this comment

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

Done out of additional caution, but adding A11yModule shouldn't increase any size if we don't actually consume any of the directives or injectables.

Copy link
Member

@crisbeto crisbeto left a comment

Choose a reason for hiding this comment

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

LGTM. I re-tested it and it seems to work without the -ms- media queries. Some of the workarounds that I did for IE/Edge don't seem to be working for Firefox though (e.g. the one in the form field I mentioned above).

@jelbourn jelbourn force-pushed the high-contrast-mode-detector branch 2 times, most recently from c212d57 to 5f30ca0 Compare October 15, 2019 18:26
@jelbourn
Copy link
Member Author

Comments addressed. For cases like form-field, we can potentially add handing in specific components that checks both the platform and the high-contrast mode to apply alternative styles.

@jelbourn jelbourn added G This is is related to a Google internal issue P2 The issue is important to a large percentage of users, with a workaround labels Oct 15, 2019
@mmalerba mmalerba added pr: lgtm action: merge The PR is ready for merge by the caretaker labels Oct 16, 2019
/** Applies CSS classes indicating high-contrast mode to document body (browser-only). */
_applyBodyHighContrastModeCssClasses(): void {
if (this._platfrom.isBrowser) {
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

@andrewseguin
Copy link
Contributor

Confirmed that two changes need to occur, and then all internal tests pass.

In _a11y.scss, the mixin needs to be changed to loop over selectors.

@at-root {
  @each $i in & {
    .cdk-high-contrast-#{$target} #{$i} {
      @content
    }
  }
}

In high-contrast-mode-detector.ts, document.body may not exist at the moment and needs to be delayed until it does.

@jelbourn jelbourn force-pushed the high-contrast-mode-detector branch 2 times, most recently from 2ede62b to 4f2af2c Compare November 7, 2019 11:19
* 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
@jelbourn
Copy link
Member Author

jelbourn commented Nov 7, 2019

@andrewseguin updated the code so that it should work universally now. I didn't use the Sass loop since that would end up duplicating the content; what I did instead should still fix the problem.

I tried to run a presubmit (since it's 4am in California now), but I can't get my ssh session to pick up my security key

@andrewseguin andrewseguin merged commit 6b7f091 into angular:master Nov 8, 2019
@angular-automatic-lock-bot
Copy link

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot bot locked and limited conversation to collaborators Dec 9, 2019
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Accessibility This issue is related to accessibility (a11y) action: merge The PR is ready for merge by the caretaker cla: yes PR author has agreed to Google's Contributor License Agreement G This is is related to a Google internal issue P2 The issue is important to a large percentage of users, with a workaround target: major This PR is targeted for the next major release
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants