material-web/testing/transform-pseudo-classes.ts
Elizabeth Mitchell c390291687 chore: format files with prettier
PiperOrigin-RevId: 576601342
2023-10-25 11:59:00 -07:00

198 lines
5.4 KiB
TypeScript

/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Array of pseudo classes to transform by default. These pseudo classes
* represent state interactions from the user (such as :hover) or the browser
* (such as :autofill) that cannot be reproduced with HTML markup.
*/
export const defaultTransformPseudoClasses = [
':active',
':autofill',
':focus',
':focus-visible',
':focus-within',
':hover',
':invalid',
':link',
':paused',
':playing',
':user-invalid',
':valid',
':visited',
];
/**
* Retrieves the transformed class name for a given pseudo class.
*
* @param pseudoClass The pseudo class to transform.
* @return The transform pseudo class string.
*/
export function getTransformedPseudoClass(pseudoClass: string) {
return `_${pseudoClass.substring(1)}`;
}
/**
* A weak set of stylesheets to use as reference for whether or not a stylesheet
* has been transformed.
*/
const transformedStyleSheets = new WeakSet<CSSStyleSheet>();
/**
* Transforms a document's stylesheets' pseudo classes into normal classes with
* a new stylesheet.
*
* Pseudo classes are given an underscore in their transformation. For example,
* `:hover` transforms to `._hover`.
*
* ```css
* .mdc-foo:hover {
* color: teal;
* }
* ```
* ```css
* .mdc-foo._hover {
* color: teal;
* }
* ```
*
* @param pseudoClasses An optional array of pseudo class names to transform.
*/
export function transformPseudoClasses(
stylesheets: Iterable<CSSStyleSheet>,
pseudoClasses = defaultTransformPseudoClasses,
) {
for (const stylesheet of stylesheets) {
if (transformedStyleSheets.has(stylesheet)) {
continue;
}
let rules: CSSRuleList;
try {
rules = stylesheet.cssRules;
} catch {
continue;
}
for (let j = rules.length - 1; j >= 0; j--) {
visitRule(rules[j], stylesheet, j, pseudoClasses);
}
transformedStyleSheets.add(stylesheet);
}
}
/**
* Determines whether or not the CSSRule is a CSSGroupingRule.
*
* Cannot check instanceof because FF treats a CSSStyleRule as a subclass of
* CSSGroupingRule unlike Chrome and Safari
*/
function isCSSGroupingRule(rule: CSSRule): rule is CSSGroupingRule {
return (
!!(rule as CSSGroupingRule)?.cssRules &&
!(rule as CSSStyleRule).selectorText
);
}
/**
* Visits a rule for the given stylesheet and adds a rule that replaces any
* pseudo classes with a regular transformed class for simulation styling.
*
* @param rule The CSS rule to transform.
* @param stylesheet The rule's parent stylesheet to update.
* @param index The index of the rule in the parent stylesheet.
* @param pseudoClasses An array of pseudo classes to search for and replace.
*/
function visitRule(
rule: CSSRule,
stylesheet: CSSStyleSheet | CSSGroupingRule,
index: number,
pseudoClasses: string[],
) {
if (isCSSGroupingRule(rule)) {
for (let i = rule.cssRules.length - 1; i >= 0; i--) {
visitRule(rule.cssRules[i], rule, i, pseudoClasses);
}
return;
}
if (!(rule instanceof CSSStyleRule)) {
return;
}
try {
let {selectorText} = rule;
// match :foo, ensuring that it does not have a paren at the end
// (no pseudo class functions like :foo())
const regex = /(:(?![\w-]+\()[\w-]+)/g;
const matches = Array.from(selectorText.matchAll(regex)).filter((match) => {
// don't match pseudo elements like ::foo
if (match.index != null && selectorText[match.index - 1] === ':') {
return false;
}
return pseudoClasses.includes(match[1]);
});
if (!matches.length) {
return;
}
matches.reverse();
selectorText = rearrangePseudoElements(selectorText);
for (const match of matches) {
selectorText =
selectorText.substring(0, match.index!) +
`.${getTransformedPseudoClass(match[1])}` +
selectorText.substring(match.index! + match[1].length);
}
const css = `${selectorText} {${rule.style.cssText}}`;
stylesheet.insertRule(css, index + 1);
} catch (error: unknown) {
// Catch exception to skip the rule that cannot be parsed.
console.error(error);
}
}
/**
* Re-arranges a selector's pseudo elements to appear at the end of the
* selector. This prevents invalid CSS when replacing pseudo classes that
* appear after a pseudo element.
*
* @example
* // '.foo::before:hover' -> '.foo::before._hover' is invalid
*
* rearrangePseudoElements('.foo::before:hover'); // '.foo:hover::before'
* // '.foo:hover::before' -> '.foo._hover::before' is valid
*
* @param selectorText The selector text string to re-arrange.
* @return The re-arranged selector text.
*/
function rearrangePseudoElements(selectorText: string) {
const pseudoElementsBeforeClasses = Array.from(
selectorText.matchAll(/(?:::[\w-]+)+(?=:[\w-])/g),
);
pseudoElementsBeforeClasses.reverse();
for (const match of pseudoElementsBeforeClasses) {
const pseudoElement = match[0];
const pseudoElementIndex = match.index!;
const endOfCompoundSelector = selectorText
.substring(pseudoElementIndex)
.match(/(\s(?!([^\s].)*\))|,|$)/)!;
const index = endOfCompoundSelector.index! + pseudoElementIndex;
selectorText =
selectorText.substring(0, index) +
pseudoElement +
selectorText.substring(index);
selectorText =
selectorText.substring(0, pseudoElementIndex) +
selectorText.substring(pseudoElementIndex + pseudoElement.length);
}
return selectorText;
}