Skip to content

Instantly share code, notes, and snippets.

@stracker-phil
Last active June 10, 2024 19:52
Show Gist options
  • Save stracker-phil/4ba0b9b9ad67dff268c9130f2fdbb473 to your computer and use it in GitHub Desktop.
Save stracker-phil/4ba0b9b9ad67dff268c9130f2fdbb473 to your computer and use it in GitHub Desktop.
Highly optimised and accurate alternative to the deprecated `getMatchedCSSRules()` method. StackOverflow question: https://stackoverflow.com/questions/66078682/get-the-css-definition-of-an-element/
/**
* Scans all CSS rules in the current document to find the most
* specific definiton of a single CSS property for a given element.
*
* Usage: getStyleDef('#my-element', 'width');
* --> returns the most specific "width" defintiion, e.g. "27em".
*
* @param {HTMLElement} element - The HTML Element to inspect.
* @param {string} prop - The CSS property to inspect.
* @return {string} The most specific CSS definition,
* or an empty string.
*/
function getStyleDef(element, prop) {
let result = '';
let highestSpecificity = false;
const allRules = [];
const relevantRules = [];
// Translate a selector string into an HTMLElement.
if ('string' === typeof element) {
element = document.querySelector(element);
}
// Translate a jQuery element to a HTMLElement.
if (element && element instanceof jQuery) {
element = element.get(0);
}
// Bail, if no valid element is specified.
if (!element || !(element instanceof HTMLElement)) {
return '';
}
// When an inline style is set, always return it.
if (element.style && '' !== element.style[prop]) {
return element.style[prop];
}
// Parse all stylesheet entries to find relevant CSS rules.
_getRelevantRules(element);
// Loop all relevant rules to find the most specific one.
for (let i = relevantRules.length - 1; i >= 0; i--) {
// Is there a rule for the required property?
if ('' === relevantRules[i].style[prop]) {
continue;
}
// Determine the specificity of the given CSS selector.
const specificity = _getCssSpecificity(
relevantRules[i].selectorText,
relevantRules[i].style[prop]
);
// Find the most specific CSS definition.
if (_compareCssSpecificity(specificity, highestSpecificity) > 0) {
highestSpecificity = specificity;
result = relevantRules[i].style[prop];
}
}
return result;
// -- Helper functions follow --
/**
* Convert an array-like object to array.
*
* @param {Iterable} list - A value-list that can be converted to an array.
* @return {array} An array representation of the list, or an empty array.
*/
function _toArray(list) {
if ('undefined' === typeof list || null === list) {
return [];
}
try {
if ('function' === typeof Array.from) {
return Array.from(list);
} else {
return [].slice.call(list);
}
} catch (exception) {
// In case the list cannot be converted, return an empty array.
return [];
}
}
/**
* Handles extraction of `cssRules` as an `Array` from a stylesheet
* or something that behaves the same.
*
* The rules are prepended to the allRules array. This function does
* not return a value.
*
* @param {CSSStyleSheet} stylesheet - The stylesheet to parse.
*/
function _extractSheetRules(stylesheet) {
try {
// Skip disabled rules.
if (stylesheet.disabled) {
return;
}
// Skip rules that do not match the current viewport.
const media = stylesheet.media;
if (media && !matchMedia(media.mediaText).matches) {
return;
}
// Prepend the rules to the `allRules` array.
Array.prototype.unshift.apply(allRules, _toArray(stylesheet.cssRules));
} catch (exception) {
/*
* CORS prevents us from accessing rules from other domains, such
* as google font styles.
*/
}
}
/**
* Parses all stylesheets and populates the `relevantRules` array with CSS
* rules that match the given element AND the current viewport size.
*
* @param {HTMLElement} element - The element to inspect.
* @private
*/
function _getRelevantRules(element) {
let rule;
// assuming the browser hands us stylesheets in order of appearance
// we iterate them from the beginning to follow proper cascade order
for (let i = 0; i < document.styleSheets.length; i++) {
// Extract the style rules of this sheet into `allRules`.
_extractSheetRules(document.styleSheets[i]);
// Loop the rules in order of appearance.
while (rule = allRules.shift()) {
if (rule.styleSheet) {
// Insert the `@import`ed stylesheet's rules at the
// beginning of this stylesheet's rules.
_extractSheetRules(rule.styleSheet);
// ... and skip the rest of this rule.
continue;
}
// If there's no stylesheet attribute BUT there IS a
// media attribute it's a media rule.
if (rule.media) {
// insert the contained rules of this media rule to
// the beginning of this stylesheet's rules.
_extractSheetRules(rule);
// ... and skip the rest it.
continue;
}
// check if this element matches this rule's selector
if (element.matches(rule.selectorText)) {
// push the rule to the results set
relevantRules.push(rule);
}
}
}
}
/**
* Calculates the CSS specificity of a CSS selector.
*
* @param input - The CSS selector.
* @param attribValue - Optional. A specific CSS attribute value. Only used
* to properly recognize `!important` values.
* @return {array} Always an array with 4 elements.
*/
function _getCssSpecificity(input, attribValue) {
const token = input.split(',');
if (token.length > 1) {
let result = [];
let singleSpecificity;
for (let i = 0; i < token.length; i++) {
singleSpecificity = _getCssSpecificity(token[i]);
if (_compareCssSpecificity(singleSpecificity, result) > 0) {
result = singleSpecificity;
}
}
return result;
}
let selector = input,
findMatch,
typeCount = {
'a': 0,
'b': 0,
'c': 0
},
// The following regular expressions assume that selectors matching the
// preceding regular expressions have been removed.
attributeRegex = /(\[[^\]]+\])/g,
idRegex = /(#[^\#\s\+>~\.\[:\)]+)/g,
classRegex = /(\.[^\s\+>~\.\[:\)]+)/g,
pseudoElementRegex = /(::[^\s\+>~\.\[:]+|:first-line|:first-letter|:before|:after)/gi,
// A regex for pseudo classes with brackets - :nth-child(),
// :nth-last-child(), :nth-of-type(), :nth-last-type(), :lang()
// The negation pseudo class (:not) is filtered out because specificity
// is calculated on its argument.
// :global and :local are filtered out - they look like pseudo classes
// but are an identifier for CSS Modules.
pseudoClassWithBracketsRegex = /(:(?!not|global|local)[\w-]+\([^\)]*\))/gi,
// A regex for other pseudo classes, which don't have brackets
pseudoClassRegex = /(:(?!not|global|local)[^\s\+>~\.\[:]+)/g,
elementRegex = /([^\s\+>~\.\[:]+)/g,
isImportant;
isImportant = 'string' === typeof attribValue && attribValue.indexOf('!important') > 0;
// Find matches for a regular expression in a string and push their details
// to parts. Type is "a" for IDs, "b" for classes, attributes and pseudo-
// classes and "c" for elements and pseudo-elements.
findMatch = function (regex, type) {
let matches, i, len, match, index, length;
if (regex.test(selector)) {
matches = selector.match(regex);
for (i = 0, len = matches.length; i < len; i += 1) {
typeCount[type] += 1;
match = matches[i];
index = selector.indexOf(match);
length = match.length;
// Replace this simple selector with whitespace so it won't be
// counted in further simple selectors.
selector = selector.replace(match, Array(length + 1).join(' '));
}
}
};
// Replace escaped characters with plain text, using the "A" character
// https://www.w3.org/TR/CSS21/syndata.html#characters
(function () {
const replaceWithPlainText = function (regex) {
let matches, i, len, match;
if (regex.test(selector)) {
matches = selector.match(regex);
for (i = 0, len = matches.length; i < len; i += 1) {
match = matches[i];
selector = selector.replace(match, Array(match.length + 1).join('A'));
}
}
},
// Matches a backslash followed by six hexadecimal digits followed
// by an optional single whitespace character.
escapeHexadecimalRegex = /\\[0-9A-Fa-f]{6}\s?/g,
// Matches a backslash followed by fewer than six hexadecimal digits
// followed by a mandatory single whitespace character.
escapeHexadecimalRegex2 = /\\[0-9A-Fa-f]{1,5}\s/g,
// Matches a backslash followed by any character.
escapeSpecialCharacter = /\\./g;
replaceWithPlainText(escapeHexadecimalRegex);
replaceWithPlainText(escapeHexadecimalRegex2);
replaceWithPlainText(escapeSpecialCharacter);
}());
// Remove anything after a left brace in case a user has pasted in a rule, not just a
// selector
(function () {
let regex = /{[^]*/gm,
matches, i, len, match;
if (regex.test(selector)) {
matches = selector.match(regex);
for (i = 0, len = matches.length; i < len; i += 1) {
match = matches[i];
selector = selector.replace(match, Array(match.length + 1).join(' '));
}
}
}());
// Add attribute selectors to parts collection (type b)
findMatch(attributeRegex, 'b');
// Add ID selectors to parts collection (type a)
findMatch(idRegex, 'a');
// Add class selectors to parts collection (type b)
findMatch(classRegex, 'b');
// Add pseudo-element selectors to parts collection (type c)
findMatch(pseudoElementRegex, 'c');
// Add pseudo-class selectors to parts collection (type b)
findMatch(pseudoClassWithBracketsRegex, 'b');
findMatch(pseudoClassRegex, 'b');
// Remove universal selector and separator characters
selector = selector.replace(/[\*\s\+>~]/g, ' ');
// Remove any stray dots or hashes which aren't attached to words
// These may be present if the user is live-editing this selector
selector = selector.replace(/[#\.]/g, ' ');
// Remove the negation pseudo-class (:not) but leave its argument because
// specificity is calculated on its argument. Remove non-standard :local and
// :global CSS Module identifiers because they do not effect the specificity.
selector = selector.replace(/:not/g, ' ');
selector = selector.replace(/:local/g, ' ');
selector = selector.replace(/:global/g, ' ');
selector = selector.replace(/[\(\)]/g, ' ');
// The only things left should be element selectors (type c)
findMatch(elementRegex, 'c');
return [isImportant ? 1 : 0, typeCount.a, typeCount.b, typeCount.c];
}
/**
* Compares two CSS Specificity terms to determine, which one is more
* specific.
*
* @param a - The first term
* @param b - The second term.
* @returns {number} 0 if both are equally specific. +1 if a is more
* specific, -1 if b is more specific.
*/
function _compareCssSpecificity(a, b) {
for (let i = 0; i < 4; i += 1) {
const valA = parseInt(isNaN(a[i]) ? 0 : a[i]);
const valB = parseInt(isNaN(b[i]) ? 0 : b[i]);
if (valA < valB) {
return -1;
} else if (valA > valB) {
return 1;
}
}
return 0;
}
}
@sekedus
Copy link

sekedus commented Jun 10, 2024

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment