Skip to content

Commit

Permalink
feat(rule): css-orientation-lock (wcag21) (#1081)
Browse files Browse the repository at this point in the history
**Note: This PR was branched from previous PR - #971 to have a cleaner commit history (without all the trials to fix CI failures). All reviews/ comments from the above PR has been tacked.**

New rule: 
- WCAG 2.1 rule
- Rule Id: `css-orientation-lock`
- Description: Ensures that the content is not locked to any specific display orientation, and functionality of the content is operable in all display orientations (portrait/ landscape).

Closes Issue:
- #851

## Reviewer checks

**Required fields, to be filled out by PR reviewer(s)**
- [ ] Follows the commit message policy, appropriate for next version
- [ ] Has documentation updated, a DU ticket, or requires no documentation change
- [ ] Includes new tests, or was unnecessary
- [ ] Code is reviewed for security by: << Name here >>
  • Loading branch information
jeeyyy authored and WilcoFiers committed Aug 21, 2018
1 parent 6bfff2b commit 4ae4ea0
Show file tree
Hide file tree
Showing 23 changed files with 1,138 additions and 333 deletions.
17 changes: 11 additions & 6 deletions build/tasks/test-webdriver.js
Expand Up @@ -40,12 +40,6 @@ module.exports = function(grunt) {
var url = urls.shift();
errors = errors || [];

// Give each page enough time
driver
.manage()
.timeouts()
.setScriptTimeout(!isMobile ? 60000 * 5 : 60000 * 10);

return (
driver
.get(url)
Expand Down Expand Up @@ -177,6 +171,17 @@ module.exports = function(grunt) {
return done();
}

// Give driver timeout options for scripts
driver
.manage()
.timeouts()
.setScriptTimeout(!isMobile ? 60000 * 5 : 60000 * 10);
// allow to wait for page load implicitly
driver
.manage()
.timeouts()
.implicitlyWait(50000);

// Test all pages
runTestUrls(driver, isMobile, options.urls)
.then(function(testErrors) {
Expand Down
1 change: 1 addition & 0 deletions doc/rule-descriptions.md
Expand Up @@ -19,6 +19,7 @@
| bypass | Ensures each page has at least one mechanism for a user to bypass navigation and jump straight to the content | Serious | cat.keyboard, wcag2a, wcag241, section508, section508.22.o | true |
| checkboxgroup | Ensures related &lt;input type=&quot;checkbox&quot;&gt; elements have a group and that the group designation is consistent | Critical | cat.forms, best-practice | true |
| color-contrast | Ensures the contrast between foreground and background colors meets WCAG 2 AA contrast ratio thresholds | Serious | cat.color, wcag2aa, wcag143 | true |
| css-orientation-lock | Ensures content is not locked to any specific display orientation, and the content is operable in all display orientations | Serious | cat.structure, wcag262, wcag21aa, experimental | true |
| definition-list | Ensures &lt;dl&gt; elements are structured correctly | Serious | cat.structure, wcag2a, wcag131 | true |
| dlitem | Ensures &lt;dt&gt; and &lt;dd&gt; elements are contained by a &lt;dl&gt; | Serious | cat.structure, wcag2a, wcag131 | true |
| document-title | Ensures each HTML document contains a non-empty &lt;title&gt; element | Serious | cat.text-alternatives, wcag2a, wcag242 | true |
Expand Down
132 changes: 132 additions & 0 deletions lib/checks/mobile/css-orientation-lock.js
@@ -0,0 +1,132 @@
/* global context */

// extract asset of type `cssom` from context
const { cssom = undefined } = context || {};

// if there is no cssom <- return incomplete
if (!cssom || !cssom.length) {
return undefined;
}

// combine all rules from each sheet into one array
const rulesGroupByDocumentFragment = cssom.reduce(
(out, { sheet, root, shadowId }) => {
// construct key based on shadowId or top level document
const key = shadowId ? shadowId : 'topDocument';
// init property if does not exist
if (!out[key]) {
out[key] = {
root,
rules: []
};
}
// check if sheet and rules exist
if (!sheet || !sheet.cssRules) {
//return
return out;
}
const rules = Array.from(sheet.cssRules);
// add rules into same document fragment
out[key].rules = out[key].rules.concat(rules);

//return
return out;
},
{}
);

// Note:
// Some of these functions can be extracted to utils, but best to do it when other cssom rules are authored.

// extract styles for each orientation rule to verify transform is applied
let isLocked = false;
let relatedElements = [];

Object.keys(rulesGroupByDocumentFragment).forEach(key => {
const { root, rules } = rulesGroupByDocumentFragment[key];

// filter media rules from all rules
const mediaRules = rules.filter(r => {
// doc: https://developer.mozilla.org/en-US/docs/Web/API/CSSMediaRule
// type value of 4 (CSSRule.MEDIA_RULE) pertains to media rules
return r.type === 4;
});
if (!mediaRules || !mediaRules.length) {
return;
}

// narrow down to media rules with `orientation` as a keyword
const orientationRules = mediaRules.filter(r => {
// conditionText exists on media rules, which contains only the @media condition
// eg: screen and (max-width: 767px) and (min-width: 320px) and (orientation: landscape)
const cssText = r.cssText;
return (
/orientation:\s+landscape/i.test(cssText) ||
/orientation:\s+portrait/i.test(cssText)
);
});
if (!orientationRules || !orientationRules.length) {
return;
}

orientationRules.forEach(r => {
// r.cssRules is a RULEList and not an array
if (!r.cssRules.length) {
return;
}
// cssRules ia a list of rules
// a media query has framents of css styles applied to various selectors
// iteration through cssRules and see if orientation lock has been applied
Array.from(r.cssRules).forEach(cssRule => {
/* eslint max-statements: ["error", 20], complexity: ["error", 15] */

// ensure selectorText exists
if (!cssRule.selectorText) {
return;
}
// ensure the given selector has styles declared (non empty selector)
if (cssRule.style.length <= 0) {
return;
}

// check if transform style exists
const transformStyleValue = cssRule.style.transform || false;
// transformStyleValue -> is the value applied to property
// eg: "rotate(-90deg)"
if (!transformStyleValue) {
return;
}

const rotate = transformStyleValue.match(/rotate\(([^)]+)deg\)/);
const deg = parseInt((rotate && rotate[1]) || 0);
const locked = deg % 90 === 0 && deg % 180 !== 0;

// if locked
// and not root HTML
// preserve as relatedNodes
if (locked && cssRule.selectorText.toUpperCase() !== 'HTML') {
const selector = cssRule.selectorText;
const elms = Array.from(root.querySelectorAll(selector));
if (elms && elms.length) {
relatedElements = relatedElements.concat(elms);
}
}

// set locked boolean
isLocked = locked;
});
});
});

if (!isLocked) {
// return
return true;
}

// set relatedNodes
if (relatedElements.length) {
this.relatedNodes(relatedElements);
}

// return fail
return false;
11 changes: 11 additions & 0 deletions lib/checks/mobile/css-orientation-lock.json
@@ -0,0 +1,11 @@
{
"id": "css-orientation-lock",
"evaluate": "css-orientation-lock.js",
"metadata": {
"impact": "serious",
"messages": {
"pass": "Display is operable, and orientation lock does not exist",
"fail": "CSS Orientation lock is applied, and makes display inoperable"
}
}
}
59 changes: 45 additions & 14 deletions lib/core/utils/preload-cssom.js
Expand Up @@ -28,7 +28,8 @@ function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) {
const sheet = convertTextToStylesheetFn({
data,
isExternal: true,
shadowId
shadowId,
root
});
resolve(sheet);
})
Expand All @@ -37,12 +38,44 @@ function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) {

const q = axe.utils.queue();

// iterate to decipher multi-level nested sheets if any (this is essential to retrieve styles from shadowDOM)
Array.from(root.styleSheets).forEach(sheet => {
// ignore disabled sheets
if (sheet.disabled) {
return;
// handle .styleSheets non existent on certain shadowDOM root
const rootStyleSheets = root.styleSheets
? Array.from(root.styleSheets)
: null;
if (!rootStyleSheets) {
return q;
}

// convenience array fot help unique sheets if duplicated by same `href`
// both external and internal sheets
let sheetHrefs = [];

// filter out sheets, that should not be accounted for...
const sheets = rootStyleSheets.filter(sheet => {
// FILTER > sheets with the same href (if exists)
let sheetAlreadyExists = false;
if (sheet.href) {
if (!sheetHrefs.includes(sheet.href)) {
sheetHrefs.push(sheet.href);
} else {
sheetAlreadyExists = true;
}
}
// FILTER > media='print'
// Note:
// Chrome does this automagically, Firefox returns every sheet
// hence the need to filter
const isPrintMedia = Array.from(sheet.media).includes('print');
// FILTER > disabled
// Firefox does not respect `disabled` attribute on stylesheet
// Hence decided not to filter out disabled for the time being

// return
return !isPrintMedia && !sheetAlreadyExists;
});

// iterate to decipher multi-level nested sheets if any (this is essential to retrieve styles from shadowDOM)
sheets.forEach(sheet => {
// attempt to retrieve cssRules, or for external sheets make a XMLHttpRequest
try {
// accessing .cssRules throws for external (cross-domain) sheets, which is handled in the catch
Expand All @@ -60,7 +93,8 @@ function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) {
resolve({
sheet,
isExternal: false,
shadowId
shadowId,
root
})
);
return;
Expand Down Expand Up @@ -89,16 +123,12 @@ function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) {
convertTextToStylesheetFn({
data: inlineRulesCssText,
shadowId,
root,
isExternal: false
})
)
);
} catch (e) {
// if no href, do not attempt to make an XHR, but this is preventive check
// NOTE: as further enhancements to resolve nested @imports are done, a decision to throw an Error if necessary here will be made.
if (!sheet.href) {
return;
}
// external sheet -> make an xhr and q the response
q.defer((resolve, reject) => {
getExternalStylesheet({ resolve, reject, url: sheet.href });
Expand Down Expand Up @@ -166,15 +196,16 @@ axe.utils.preloadCssom = function preloadCssom({
* @property {Object} param.doc implementation document to create style elements
* @property {String} param.shadowId (Optional) shadowId if shadowDOM
*/
function convertTextToStylesheet({ data, isExternal, shadowId }) {
function convertTextToStylesheet({ data, isExternal, shadowId, root }) {
const style = dynamicDoc.createElement('style');
style.type = 'text/css';
style.appendChild(dynamicDoc.createTextNode(data));
dynamicDoc.head.appendChild(style);
return {
sheet: style.sheet,
isExternal,
shadowId
shadowId,
root
};
}

Expand Down
20 changes: 20 additions & 0 deletions lib/rules/css-orientation-lock.json
@@ -0,0 +1,20 @@
{
"id": "css-orientation-lock",
"selector": "html",
"tags": [
"cat.structure",
"wcag262",
"wcag21aa",
"experimental"
],
"metadata": {
"description": "Ensures content is not locked to any specific display orientation, and the content is operable in all display orientations",
"help": "CSS Media queries are not used to lock display orientation"
},
"all": [
"css-orientation-lock"
],
"any": [],
"none": [],
"preload": true
}

1 comment on commit 4ae4ea0

@jeankaplansky
Copy link
Contributor

Choose a reason for hiding this comment

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

Please sign in to comment.