Skip to content

Commit 4ae4ea0

Browse files
jeeyyyWilcoFiers
authored andcommitted
feat(rule): css-orientation-lock (wcag21) (#1081)
**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 >>
1 parent 6bfff2b commit 4ae4ea0

23 files changed

+1138
-333
lines changed

build/tasks/test-webdriver.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,6 @@ module.exports = function(grunt) {
4040
var url = urls.shift();
4141
errors = errors || [];
4242

43-
// Give each page enough time
44-
driver
45-
.manage()
46-
.timeouts()
47-
.setScriptTimeout(!isMobile ? 60000 * 5 : 60000 * 10);
48-
4943
return (
5044
driver
5145
.get(url)
@@ -177,6 +171,17 @@ module.exports = function(grunt) {
177171
return done();
178172
}
179173

174+
// Give driver timeout options for scripts
175+
driver
176+
.manage()
177+
.timeouts()
178+
.setScriptTimeout(!isMobile ? 60000 * 5 : 60000 * 10);
179+
// allow to wait for page load implicitly
180+
driver
181+
.manage()
182+
.timeouts()
183+
.implicitlyWait(50000);
184+
180185
// Test all pages
181186
runTestUrls(driver, isMobile, options.urls)
182187
.then(function(testErrors) {

doc/rule-descriptions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
| 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 |
2020
| 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 |
2121
| color-contrast | Ensures the contrast between foreground and background colors meets WCAG 2 AA contrast ratio thresholds | Serious | cat.color, wcag2aa, wcag143 | true |
22+
| 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 |
2223
| definition-list | Ensures &lt;dl&gt; elements are structured correctly | Serious | cat.structure, wcag2a, wcag131 | true |
2324
| dlitem | Ensures &lt;dt&gt; and &lt;dd&gt; elements are contained by a &lt;dl&gt; | Serious | cat.structure, wcag2a, wcag131 | true |
2425
| document-title | Ensures each HTML document contains a non-empty &lt;title&gt; element | Serious | cat.text-alternatives, wcag2a, wcag242 | true |
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/* global context */
2+
3+
// extract asset of type `cssom` from context
4+
const { cssom = undefined } = context || {};
5+
6+
// if there is no cssom <- return incomplete
7+
if (!cssom || !cssom.length) {
8+
return undefined;
9+
}
10+
11+
// combine all rules from each sheet into one array
12+
const rulesGroupByDocumentFragment = cssom.reduce(
13+
(out, { sheet, root, shadowId }) => {
14+
// construct key based on shadowId or top level document
15+
const key = shadowId ? shadowId : 'topDocument';
16+
// init property if does not exist
17+
if (!out[key]) {
18+
out[key] = {
19+
root,
20+
rules: []
21+
};
22+
}
23+
// check if sheet and rules exist
24+
if (!sheet || !sheet.cssRules) {
25+
//return
26+
return out;
27+
}
28+
const rules = Array.from(sheet.cssRules);
29+
// add rules into same document fragment
30+
out[key].rules = out[key].rules.concat(rules);
31+
32+
//return
33+
return out;
34+
},
35+
{}
36+
);
37+
38+
// Note:
39+
// Some of these functions can be extracted to utils, but best to do it when other cssom rules are authored.
40+
41+
// extract styles for each orientation rule to verify transform is applied
42+
let isLocked = false;
43+
let relatedElements = [];
44+
45+
Object.keys(rulesGroupByDocumentFragment).forEach(key => {
46+
const { root, rules } = rulesGroupByDocumentFragment[key];
47+
48+
// filter media rules from all rules
49+
const mediaRules = rules.filter(r => {
50+
// doc: https://developer.mozilla.org/en-US/docs/Web/API/CSSMediaRule
51+
// type value of 4 (CSSRule.MEDIA_RULE) pertains to media rules
52+
return r.type === 4;
53+
});
54+
if (!mediaRules || !mediaRules.length) {
55+
return;
56+
}
57+
58+
// narrow down to media rules with `orientation` as a keyword
59+
const orientationRules = mediaRules.filter(r => {
60+
// conditionText exists on media rules, which contains only the @media condition
61+
// eg: screen and (max-width: 767px) and (min-width: 320px) and (orientation: landscape)
62+
const cssText = r.cssText;
63+
return (
64+
/orientation:\s+landscape/i.test(cssText) ||
65+
/orientation:\s+portrait/i.test(cssText)
66+
);
67+
});
68+
if (!orientationRules || !orientationRules.length) {
69+
return;
70+
}
71+
72+
orientationRules.forEach(r => {
73+
// r.cssRules is a RULEList and not an array
74+
if (!r.cssRules.length) {
75+
return;
76+
}
77+
// cssRules ia a list of rules
78+
// a media query has framents of css styles applied to various selectors
79+
// iteration through cssRules and see if orientation lock has been applied
80+
Array.from(r.cssRules).forEach(cssRule => {
81+
/* eslint max-statements: ["error", 20], complexity: ["error", 15] */
82+
83+
// ensure selectorText exists
84+
if (!cssRule.selectorText) {
85+
return;
86+
}
87+
// ensure the given selector has styles declared (non empty selector)
88+
if (cssRule.style.length <= 0) {
89+
return;
90+
}
91+
92+
// check if transform style exists
93+
const transformStyleValue = cssRule.style.transform || false;
94+
// transformStyleValue -> is the value applied to property
95+
// eg: "rotate(-90deg)"
96+
if (!transformStyleValue) {
97+
return;
98+
}
99+
100+
const rotate = transformStyleValue.match(/rotate\(([^)]+)deg\)/);
101+
const deg = parseInt((rotate && rotate[1]) || 0);
102+
const locked = deg % 90 === 0 && deg % 180 !== 0;
103+
104+
// if locked
105+
// and not root HTML
106+
// preserve as relatedNodes
107+
if (locked && cssRule.selectorText.toUpperCase() !== 'HTML') {
108+
const selector = cssRule.selectorText;
109+
const elms = Array.from(root.querySelectorAll(selector));
110+
if (elms && elms.length) {
111+
relatedElements = relatedElements.concat(elms);
112+
}
113+
}
114+
115+
// set locked boolean
116+
isLocked = locked;
117+
});
118+
});
119+
});
120+
121+
if (!isLocked) {
122+
// return
123+
return true;
124+
}
125+
126+
// set relatedNodes
127+
if (relatedElements.length) {
128+
this.relatedNodes(relatedElements);
129+
}
130+
131+
// return fail
132+
return false;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"id": "css-orientation-lock",
3+
"evaluate": "css-orientation-lock.js",
4+
"metadata": {
5+
"impact": "serious",
6+
"messages": {
7+
"pass": "Display is operable, and orientation lock does not exist",
8+
"fail": "CSS Orientation lock is applied, and makes display inoperable"
9+
}
10+
}
11+
}

lib/core/utils/preload-cssom.js

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) {
2828
const sheet = convertTextToStylesheetFn({
2929
data,
3030
isExternal: true,
31-
shadowId
31+
shadowId,
32+
root
3233
});
3334
resolve(sheet);
3435
})
@@ -37,12 +38,44 @@ function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) {
3738

3839
const q = axe.utils.queue();
3940

40-
// iterate to decipher multi-level nested sheets if any (this is essential to retrieve styles from shadowDOM)
41-
Array.from(root.styleSheets).forEach(sheet => {
42-
// ignore disabled sheets
43-
if (sheet.disabled) {
44-
return;
41+
// handle .styleSheets non existent on certain shadowDOM root
42+
const rootStyleSheets = root.styleSheets
43+
? Array.from(root.styleSheets)
44+
: null;
45+
if (!rootStyleSheets) {
46+
return q;
47+
}
48+
49+
// convenience array fot help unique sheets if duplicated by same `href`
50+
// both external and internal sheets
51+
let sheetHrefs = [];
52+
53+
// filter out sheets, that should not be accounted for...
54+
const sheets = rootStyleSheets.filter(sheet => {
55+
// FILTER > sheets with the same href (if exists)
56+
let sheetAlreadyExists = false;
57+
if (sheet.href) {
58+
if (!sheetHrefs.includes(sheet.href)) {
59+
sheetHrefs.push(sheet.href);
60+
} else {
61+
sheetAlreadyExists = true;
62+
}
4563
}
64+
// FILTER > media='print'
65+
// Note:
66+
// Chrome does this automagically, Firefox returns every sheet
67+
// hence the need to filter
68+
const isPrintMedia = Array.from(sheet.media).includes('print');
69+
// FILTER > disabled
70+
// Firefox does not respect `disabled` attribute on stylesheet
71+
// Hence decided not to filter out disabled for the time being
72+
73+
// return
74+
return !isPrintMedia && !sheetAlreadyExists;
75+
});
76+
77+
// iterate to decipher multi-level nested sheets if any (this is essential to retrieve styles from shadowDOM)
78+
sheets.forEach(sheet => {
4679
// attempt to retrieve cssRules, or for external sheets make a XMLHttpRequest
4780
try {
4881
// accessing .cssRules throws for external (cross-domain) sheets, which is handled in the catch
@@ -60,7 +93,8 @@ function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) {
6093
resolve({
6194
sheet,
6295
isExternal: false,
63-
shadowId
96+
shadowId,
97+
root
6498
})
6599
);
66100
return;
@@ -89,16 +123,12 @@ function loadCssom({ root, shadowId }, timeout, convertTextToStylesheetFn) {
89123
convertTextToStylesheetFn({
90124
data: inlineRulesCssText,
91125
shadowId,
126+
root,
92127
isExternal: false
93128
})
94129
)
95130
);
96131
} catch (e) {
97-
// if no href, do not attempt to make an XHR, but this is preventive check
98-
// NOTE: as further enhancements to resolve nested @imports are done, a decision to throw an Error if necessary here will be made.
99-
if (!sheet.href) {
100-
return;
101-
}
102132
// external sheet -> make an xhr and q the response
103133
q.defer((resolve, reject) => {
104134
getExternalStylesheet({ resolve, reject, url: sheet.href });
@@ -166,15 +196,16 @@ axe.utils.preloadCssom = function preloadCssom({
166196
* @property {Object} param.doc implementation document to create style elements
167197
* @property {String} param.shadowId (Optional) shadowId if shadowDOM
168198
*/
169-
function convertTextToStylesheet({ data, isExternal, shadowId }) {
199+
function convertTextToStylesheet({ data, isExternal, shadowId, root }) {
170200
const style = dynamicDoc.createElement('style');
171201
style.type = 'text/css';
172202
style.appendChild(dynamicDoc.createTextNode(data));
173203
dynamicDoc.head.appendChild(style);
174204
return {
175205
sheet: style.sheet,
176206
isExternal,
177-
shadowId
207+
shadowId,
208+
root
178209
};
179210
}
180211

lib/rules/css-orientation-lock.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"id": "css-orientation-lock",
3+
"selector": "html",
4+
"tags": [
5+
"cat.structure",
6+
"wcag262",
7+
"wcag21aa",
8+
"experimental"
9+
],
10+
"metadata": {
11+
"description": "Ensures content is not locked to any specific display orientation, and the content is operable in all display orientations",
12+
"help": "CSS Media queries are not used to lock display orientation"
13+
},
14+
"all": [
15+
"css-orientation-lock"
16+
],
17+
"any": [],
18+
"none": [],
19+
"preload": true
20+
}

0 commit comments

Comments
 (0)