Skip to content

Commit

Permalink
fix: compute orientation lock from various transformation func… (#1937)
Browse files Browse the repository at this point in the history
  • Loading branch information
jeeyyy committed Dec 18, 2019
1 parent 4d9dac9 commit c987de0
Show file tree
Hide file tree
Showing 6 changed files with 451 additions and 267 deletions.
315 changes: 216 additions & 99 deletions lib/checks/mobile/css-orientation-lock.js
@@ -1,134 +1,251 @@
/* global context */

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

// if there is no cssom <- return incomplete
const { degreeThreshold = 0 } = options || {};
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
let isLocked = false;
let relatedElements = [];
const rulesGroupByDocumentFragment = groupCssomByDocument(cssom);

for (const key of Object.keys(rulesGroupByDocumentFragment)) {
const { root, rules } = rulesGroupByDocumentFragment[key];
const orientationRules = rules.filter(isMediaRuleWithOrientation);
if (!orientationRules.length) {
continue;
}

orientationRules.forEach(({ cssRules }) => {
Array.from(cssRules).forEach(cssRule => {
const locked = getIsOrientationLocked(cssRule);

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

isLocked = isLocked || locked;
});
});
}

if (!isLocked) {
return true;
}
if (relatedElements.length) {
this.relatedNodes(relatedElements);
}
return false;

/**
* Group given cssom by document/ document fragment
* @param {Array<Object>} allCssom cssom
* @return {Object}
*/
function groupCssomByDocument(cssObjectModel) {
return cssObjectModel.reduce((out, { sheet, root, shadowId }) => {
const key = shadowId ? shadowId : 'topDocument';
// init property if does not exist

if (!out[key]) {
out[key] = {
root,
rules: []
};
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.
/**
* Filter CSS Rules that target Orientation CSS Media Features
* @param {Array<Object>} cssRules
* @returns {Array<Object>}
*/
function isMediaRuleWithOrientation({ type, cssText }) {
/**
* Filter:
* CSSRule.MEDIA_Rule
* -> https://developer.mozilla.org/en-US/docs/Web/API/CSSMediaRule
*/
if (type !== 4) {
return false;
}

// extract styles for each orientation rule to verify transform is applied
let isLocked = false;
let relatedElements = [];
/**
* Filter:
* CSSRule with conditionText of `orientation`
*/
return (
/orientation:\s*landscape/i.test(cssText) ||
/orientation:\s*portrait/i.test(cssText)
);
}

Object.keys(rulesGroupByDocumentFragment).forEach(key => {
const { root, rules } = rulesGroupByDocumentFragment[key];
/**
* Interpolate a given CSS Rule to ascertain if orientation is locked by use of any transformation functions that affect rotation along the Z Axis
* @param {Object} cssRule given CSS Rule
* @property {String} cssRule.selectorText selector text targetted by given cssRule
* @property {Object} cssRule.style style
* @return {Boolean}
*/
function getIsOrientationLocked({ selectorText, style }) {
if (!selectorText || style.length <= 0) {
return false;
}

// 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;
const transformStyle =
style.transform || style.webkitTransform || style.msTransform || false;
if (!transformStyle) {
return false;
}

// 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;
/**
* get last match/occurence of a transformation function that can affect rotation along Z axis
*/
const matches = transformStyle.match(
/(rotate|rotateZ|rotate3d|matrix|matrix3d)\(([^)]+)\)(?!.*(rotate|rotateZ|rotate3d|matrix|matrix3d))/
);
if (!matches) {
return false;
}

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 => {
// ensure selectorText exists
if (!cssRule.selectorText) {
return;
}
// ensure the given selector has styles declared (non empty selector)
if (cssRule.style.length <= 0) {
return;
}
const [, transformFn, transformFnValue] = matches;
let degrees = getRotationInDegrees(transformFn, transformFnValue);
if (!degrees) {
return false;
}
degrees = Math.abs(degrees);

/**
* When degree is a multiple of 180, it is not considered an orientation lock
*/
if (Math.abs(degrees - 180) % 180 <= degreeThreshold) {
return false;
}

return Math.abs(degrees - 90) % 90 <= degreeThreshold;
}

// check if transform style exists (don't forget vendor prefixes)
const transformStyleValue =
cssRule.style.transform ||
cssRule.style.webkitTransform ||
cssRule.style.msTransform ||
false;
// transformStyleValue -> is the value applied to property
// eg: "rotate(-90deg)"
if (!transformStyleValue) {
/**
* Interpolate rotation along the z axis from a given value to a transform function
* @param {String} transformFunction CSS transformation function
* @param {String} transformFnValue value applied to a transform function (contains a unit)
* @returns {Number}
*/
function getRotationInDegrees(transformFunction, transformFnValue) {
switch (transformFunction) {
case 'rotate':
case 'rotateZ':
return getAngleInDegrees(transformFnValue);
case 'rotate3d':
const [, , z, angleWithUnit] = transformFnValue
.split(',')
.map(value => value.trim());
if (parseInt(z) === 0) {
// no transform is applied along z axis -> ignore
return;
}
return getAngleInDegrees(angleWithUnit);
case 'matrix':
case 'matrix3d':
return getAngleInDegreesFromMatrixTransform(transformFnValue);
default:
return;
}
}

const rotate = transformStyleValue.match(/rotate\(([^)]+)deg\)/);
const deg = parseInt((rotate && rotate[1]) || 0);
const locked = deg % 90 === 0 && deg % 180 !== 0;
/**
* Get angle in degrees from a transform value by interpolating the unit of measure
* @param {String} angleWithUnit value applied to a transform function (Eg: 1turn)
* @returns{Number|undefined}
*/
function getAngleInDegrees(angleWithUnit) {
const [unit] = angleWithUnit.match(/(deg|grad|rad|turn)/) || [];
if (!unit) {
return;
}

// 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);
}
}
const angle = parseFloat(angleWithUnit.replace(unit, ``));
switch (unit) {
case 'rad':
return convertRadToDeg(angle);
case 'grad':
return convertGradToDeg(angle);
case 'turn':
return convertTurnToDeg(angle);
case 'deg':
default:
return parseInt(angle);
}
}

// set locked boolean
isLocked = locked;
});
});
});
/**
* Get angle in degrees from a transform value applied to `matrix` or `matrix3d` transform functions
* @param {String} transformFnValue value applied to a transform function (contains a unit)
* @returns {Number}
*/
function getAngleInDegreesFromMatrixTransform(transformFnValue) {
const values = transformFnValue.split(',');

/**
* Matrix 2D
* Notes: https://css-tricks.com/get-value-of-css-rotation-through-javascript/
*/
if (values.length <= 6) {
const [a, b] = values;
const radians = Math.atan2(parseFloat(b), parseFloat(a));
return convertRadToDeg(radians);
}

if (!isLocked) {
// return
return true;
/**
* Matrix 3D
* Notes: https://drafts.csswg.org/css-transforms-2/#decomposing-a-3d-matrix
*/
const sinB = parseFloat(values[8]);
const b = Math.asin(sinB);
const cosB = Math.cos(b);
const rotateZRadians = Math.acos(parseFloat(values[0]) / cosB);
return convertRadToDeg(rotateZRadians);
}

// set relatedNodes
if (relatedElements.length) {
this.relatedNodes(relatedElements);
/**
* Convert angle specified in unit radians to degrees
* See - https://drafts.csswg.org/css-values-3/#rad
* @param {Number} radians radians
* @return {Number}
*/
function convertRadToDeg(radians) {
return Math.round(radians * (180 / Math.PI));
}

// return fail
return false;
/**
* Convert angle specified in unit grad to degrees
* See - https://drafts.csswg.org/css-values-3/#grad
* @param {Number} grad grad
* @return {Number}
*/
function convertGradToDeg(grad) {
grad = grad % 400;
if (grad < 0) {
grad += 400;
}
return Math.round((grad / 400) * 360);
}

/**
* Convert angle specifed in unit turn to degrees
* See - https://drafts.csswg.org/css-values-3/#turn
* @param {Number} turn
* @returns {Number}
*/
function convertTurnToDeg(turn) {
return Math.round(360 / (1 / turn));
}
3 changes: 3 additions & 0 deletions lib/checks/mobile/css-orientation-lock.json
@@ -1,6 +1,9 @@
{
"id": "css-orientation-lock",
"evaluate": "css-orientation-lock.js",
"options": {
"degreeThreshold": 2
},
"metadata": {
"impact": "serious",
"messages": {
Expand Down
7 changes: 4 additions & 3 deletions lib/core/utils/preload-cssom.js
Expand Up @@ -33,9 +33,10 @@ axe.utils.preloadCssom = function preloadCssom({ treeRoot = axe._tree[0] }) {

const convertDataToStylesheet = axe.utils.getStyleSheetFactory(dynamicDoc);

return getCssomForAllRootNodes(rootNodes, convertDataToStylesheet).then(
assets => flattenAssets(assets)
);
return getCssomForAllRootNodes(
rootNodes,
convertDataToStylesheet
).then(assets => flattenAssets(assets));
};

/**
Expand Down

0 comments on commit c987de0

Please sign in to comment.