From 157b7690809653d91d127ca0e08a4d904a1bf422 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Wed, 19 Aug 2026 16:02:43 +0200 Subject: [PATCH 1/4] fix fetch of css custom properties values --- src/common/utils/CssCustomProperties.ts | 199 ++++++++++++++++++---- src/common/utils/colorHash.ts | 25 +-- src/common/utils/getColorConfiguration.ts | 67 +++----- 3 files changed, 207 insertions(+), 84 deletions(-) diff --git a/src/common/utils/CssCustomProperties.ts b/src/common/utils/CssCustomProperties.ts index 4d0b3c28..b51eda51 100644 --- a/src/common/utils/CssCustomProperties.ts +++ b/src/common/utils/CssCustomProperties.ts @@ -1,12 +1,30 @@ /** * Based on CSS Tricks tutorial. * @see https://css-tricks.com/how-to-get-all-custom-properties-on-a-page-in-javascript/ + * + * The names of the custom properties are collected from the CSSOM, but their values are resolved + * via the computed style of a matching element. + * The CSSOM is only a reliable source for the names: declarations can be nested inside grouping + * rules (`@layer`, `@media`, `@supports`, `@container`), and their document order does not + * represent the cascade anymore, e.g. unlayered declarations win over layered ones. */ type AllowedCSSRule = CSSStyleRule | CSSPageRule; // they have necessary `selectorText` and `style` properties +/** Rules that contain other rules, e.g. `@layer`, `@media`, `@supports`, `@container` or `@import`. */ +type CssRuleWithChildren = CSSRule & { cssRules?: CSSRuleList; styleSheet?: CSSStyleSheet }; + +type CustomPropertyEntry = [string, string]; + +const rootSelectors = [":root", "html", ":root:root"]; +const classSelectorPattern = /^(?:\.-?[_a-zA-Z][\w-]*)+$/; + interface getLocalCssStyleRulesProps { cssRuleType?: "CSSStyleRule"; + /** + * Selector the rule needs to use, e.g. `:root`. + * A rule matches if the selector is part of its selector list, e.g. `:root, :host`. + */ selectorText?: string; } interface getLocalCssStyleRulePropertiesProps extends getLocalCssStyleRulesProps { @@ -20,7 +38,7 @@ interface getCustomPropertiesProps extends getLocalCssStyleRulesProps { export default class CssCustomProperties { getterDefaultProps = {} as getCustomPropertiesProps; - customprops = {}; + customprops = {} as CustomPropertyEntry[] | Record; constructor(props: getCustomPropertiesProps = {}) { this.getterDefaultProps = props; @@ -28,13 +46,14 @@ export default class CssCustomProperties { // Methods - customProperties = (props: getCustomPropertiesProps = {}): [string, string][] | Record => { + customProperties = (props: getCustomPropertiesProps = {}): CustomPropertyEntry[] | Record => { // FIXME: // in case of performance issues results should get saved at least into intern variables // other cache strategies could be also tested - if (Object.keys(this.customprops).length > 1) { + if (Object.keys(this.customprops).length > 0) { return this.customprops; } + // an empty result is not cached, the stylesheets may be loaded later on const customprops = CssCustomProperties.listCustomProperties({ ...this.getterDefaultProps, ...props, @@ -44,7 +63,7 @@ export default class CssCustomProperties { }; static listLocalStylesheets = (): CSSStyleSheet[] => { - if (document && document.styleSheets) { + if (typeof document !== "undefined" && document.styleSheets) { return (Array.from(document.styleSheets) as CSSStyleSheet[]).filter((stylesheet) => { // is inline stylesheet or from same domain if (!stylesheet.href) { @@ -57,29 +76,79 @@ export default class CssCustomProperties { return [] as CSSStyleSheet[]; }; + /** Rules of a stylesheet are not readable if it was loaded from another origin. */ + static readCssRules = (stylesheet: CSSStyleSheet): CSSRuleList | undefined => { + try { + return stylesheet.cssRules; + } catch { + return undefined; + } + }; + static listLocalCssRules = (): CSSRule[] => { + const readStylesheets = new Set(); + + const collectRules = (rules: CSSRuleList | undefined): CSSRule[] => { + if (!rules) { + return []; + } + + return Array.from(rules) + .map((rule) => { + const ruleWithChildren = rule as CssRuleWithChildren; + + if (ruleWithChildren.styleSheet) { + // `@import` rule, e.g. `@import url(theme.css) layer(theme)` + if (readStylesheets.has(ruleWithChildren.styleSheet)) { + return []; + } + readStylesheets.add(ruleWithChildren.styleSheet); + return collectRules(CssCustomProperties.readCssRules(ruleWithChildren.styleSheet)); + } + + if (ruleWithChildren.cssRules) { + // rule that groups or nests other rules, e.g. `@layer`, `@media` or `@container` + return [rule, ...collectRules(ruleWithChildren.cssRules)]; + } + + return [rule]; + }) + .flat(); + }; + return CssCustomProperties.listLocalStylesheets() .map((stylesheet) => { - return Array.from(stylesheet.cssRules); + readStylesheets.add(stylesheet); + return collectRules(CssCustomProperties.readCssRules(stylesheet)); }) .flat(); }; + static isCssStyleRule = (rule: CSSRule): rule is CSSStyleRule => { + if (typeof CSSStyleRule !== "undefined") { + return rule instanceof CSSStyleRule; + } + const cssrule = rule as AllowedCSSRule; + return !!cssrule.style && cssrule.selectorText !== undefined; + }; + + static matchesSelectorText = (rule: CSSStyleRule, selectorText: string): boolean => { + return (rule.selectorText ?? "") + .split(",") + .map((selector) => selector.trim()) + .includes(selectorText.trim()); + }; + static listLocalCssStyleRules = (filter: getLocalCssStyleRulesProps = {}): CSSStyleRule[] => { const { cssRuleType = "CSSStyleRule", selectorText } = filter; const cssStyleRules = CssCustomProperties.listLocalCssRules().filter((rule) => { - const cssrule = rule as AllowedCSSRule; - if (cssrule.style) { - if (cssrule.constructor.name !== cssRuleType) { - return false; - } - if (!!selectorText && cssrule.selectorText !== selectorText) { - return false; - } - return true; - } else { + if (cssRuleType === "CSSStyleRule" && !CssCustomProperties.isCssStyleRule(rule)) { return false; } + if (!!selectorText && !CssCustomProperties.matchesSelectorText(rule as CSSStyleRule, selectorText)) { + return false; + } + return true; }); return cssStyleRules as CSSStyleRule[]; }; @@ -104,27 +173,95 @@ export default class CssCustomProperties { }); }; + /** + * Return the element the values of custom properties can be read from. + * `:root` and `html` are mapped to the root element of the document, for any other selector the + * first matching element is used. + * If nothing matches and the selector consists of class names only, then a temporary hidden + * element is created; the second item of the returned tuple removes it again. + */ + static targetElement = (selectorText: string = ":root"): [Element | undefined, (() => void) | undefined] => { + if (typeof document === "undefined") { + return [undefined, undefined]; + } + + if (rootSelectors.includes(selectorText.trim().toLowerCase())) { + return [document.documentElement, undefined]; + } + + try { + const existingElement = document.querySelector(selectorText); + if (existingElement) { + return [existingElement, undefined]; + } + } catch { + // selector cannot be used by the DOM API, we try to create a placeholder below + } + + if (!classSelectorPattern.test(selectorText)) { + return [undefined, undefined]; + } + + // we need an element inside the DOM, otherwise the browser does not calculate the values for us + const placeholder = document.createElement("div"); + placeholder.classList.add(...selectorText.split(".").filter(Boolean)); + placeholder.setAttribute("style", "display: none"); + (document.body ?? document.documentElement).appendChild(placeholder); + + return [placeholder, () => placeholder.remove()]; + }; + + /** + * Resolve the values of custom properties as they are applied to an element. + * Properties without a value are removed, they do not apply to the element, e.g. because they + * are only defined inside a currently not matching `@media` rule. + */ + static resolveCustomPropertyValues = (element: Element, propertyNames: string[]): CustomPropertyEntry[] => { + const documentView = element.ownerDocument?.defaultView; + if (!documentView) { + return []; + } + + const computedStyle = documentView.getComputedStyle(element); + + return propertyNames + .map((propertyName): CustomPropertyEntry => { + return [propertyName, computedStyle.getPropertyValue(propertyName).trim()]; + }) + .filter(([, value]) => value !== ""); + }; + static listCustomProperties = ( props: getCustomPropertiesProps = {}, - ): [string, string][] | Record => { + ): CustomPropertyEntry[] | Record => { const { removeDashPrefix = true, returnObject = true, filterName = () => true, ...filterProps } = props; - const customProperties = CssCustomProperties.listLocalCssStyleRuleProperties({ - ...filterProps, - propertyType: "custom", - }) - .filter((declaration) => { - return filterName(declaration[0]); - }) - .map((declaration) => { - if (removeDashPrefix) { - return [declaration[0].substr(2), declaration[1]]; - } - return declaration; + // the CSSOM is used to get the names only, the cascade decides about the values + const propertyNames = [ + ...new Set( + CssCustomProperties.listLocalCssStyleRuleProperties({ + ...filterProps, + propertyType: "custom", + }) + .map((declaration) => declaration[0]) + .filter((propertyName) => filterName(propertyName)), + ), + ]; + + const [element, removePlaceholder] = CssCustomProperties.targetElement(filterProps.selectorText); + + try { + const customProperties = ( + element ? CssCustomProperties.resolveCustomPropertyValues(element, propertyNames) : [] + ).map(([propertyName, value]): CustomPropertyEntry => { + return [removeDashPrefix ? propertyName.slice(2) : propertyName, value]; }); - return returnObject - ? (Object.fromEntries(customProperties) as Record) - : (customProperties as [string, string][]); + return returnObject + ? (Object.fromEntries(customProperties) as Record) + : (customProperties as CustomPropertyEntry[]); + } finally { + removePlaceholder?.(); + } }; } diff --git a/src/common/utils/colorHash.ts b/src/common/utils/colorHash.ts index 300ec562..b7af083f 100644 --- a/src/common/utils/colorHash.ts +++ b/src/common/utils/colorHash.ts @@ -27,22 +27,23 @@ export function getEnabledColorsFromPalette(props: getEnabledColorsProps): Color const configId = JSON.stringify({ includePaletteGroup: props.includePaletteGroup, includeColorWeight: props.includeColorWeight, + minimalColorDistance: props.minimalColorDistance, }); if (getEnabledColorsFromPaletteCache.has(configId)) { return getEnabledColorsFromPaletteCache.get(configId)!; } - const colorPropertiesFromPalette = Object.values(getEnabledColorPropertiesFromPalette(props)); + const colorsFromPalette = getEnabledColorPropertiesFromPalette(props).map((color) => { + return Color(color[1]); + }); - getEnabledColorsFromPaletteCache.set( - configId, - colorPropertiesFromPalette.map((color) => { - return Color(color[1]); - }), - ); + if (colorsFromPalette.length > 0) { + // an empty result is not cached, the stylesheets may be loaded later on + getEnabledColorsFromPaletteCache.set(configId, colorsFromPalette); + } - return getEnabledColorsFromPaletteCache.get(configId)!; + return colorsFromPalette; } export function getEnabledColorPropertiesFromPalette({ @@ -54,6 +55,7 @@ export function getEnabledColorPropertiesFromPalette({ const configId = JSON.stringify({ includePaletteGroup, includeColorWeight, + minimalColorDistance, }); if (getEnabledColorPropertiesFromPaletteCache.has(configId)) { @@ -93,9 +95,12 @@ export function getEnabledColorPropertiesFromPalette({ }, colorsFromPaletteValues) : colorsFromPaletteValues; - getEnabledColorPropertiesFromPaletteCache.set(configId, colorsFromPaletteWithEnoughDistance); + if (colorsFromPaletteWithEnoughDistance.length > 0) { + // an empty result is not cached, the stylesheets may be loaded later on + getEnabledColorPropertiesFromPaletteCache.set(configId, colorsFromPaletteWithEnoughDistance); + } - return getEnabledColorPropertiesFromPaletteCache.get(configId)!; + return colorsFromPaletteWithEnoughDistance; } function getColorcode(text: string): ColorOrFalse { diff --git a/src/common/utils/getColorConfiguration.ts b/src/common/utils/getColorConfiguration.ts index 11ac0371..8072697f 100644 --- a/src/common/utils/getColorConfiguration.ts +++ b/src/common/utils/getColorConfiguration.ts @@ -17,49 +17,30 @@ const colorConfigurationMemo = new Map>(); const getColorConfiguration = (configId: colorconfigs): Record => { if (!colorConfigurationMemo.has(configId)) { const selectorClass = `${eccgui}-configuration--colors__${configId}`; - colorConfigurationMemo.set( - configId, - Object.fromEntries( - ( - new CssCustomProperties({ - selectorText: `.${selectorClass}`, - removeDashPrefix: true, - returnObject: false, - }).customProperties() as string[][] - ).map((setting) => { - // check if the value could be a color - - let testColorValue = setting[1]; - // check if value itself is a reference to another css custom property - if (testColorValue.slice(0, 3) === "var") { - // we currently only extract the first part and ignore any fallbacks - const customPropertyName = /var\(\s*(--[a-zA-Z0-9_-]+)/g.exec(testColorValue); - if (customPropertyName && customPropertyName[1]) { - let selectorElement = document.getElementsByClassName(selectorClass)[0]; - if (!selectorElement) { - // we need to add an empty element that the JS API can read the value of the custom prop - selectorElement = document.createElement("div"); - selectorElement.classList.add(selectorClass); - selectorElement.setAttribute("style", "display: none"); - document.body.appendChild(selectorElement); - } - // only check 1 time, not recursive - testColorValue = getComputedStyle(selectorElement).getPropertyValue(customPropertyName[1]); - } - } - - try { - if (Color(testColorValue)) { - return [setting[0], testColorValue]; - } else { - return [setting[0], undefined]; - } - } catch { - return [setting[0], undefined]; - } - }), - ) as Record, - ); + const colorConfiguration = Object.fromEntries( + ( + new CssCustomProperties({ + selectorText: `.${selectorClass}`, + removeDashPrefix: true, + returnObject: false, + }).customProperties() as string[][] + ).map((setting) => { + // check if the value could be a color, references to other custom properties are already resolved + try { + Color(setting[1]); + return [setting[0], setting[1]]; + } catch { + return [setting[0], undefined]; + } + }), + ) as Record; + + if (Object.keys(colorConfiguration).length === 0) { + // an empty result is not cached, the stylesheets may be loaded later on + return colorConfiguration; + } + + colorConfigurationMemo.set(configId, colorConfiguration); } return colorConfigurationMemo.get(configId)!; }; From 49c6fcc76b05777c8ea94f4aa5ee650990baecb3 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Wed, 19 Aug 2026 16:02:53 +0200 Subject: [PATCH 2/4] update changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ceda7e5..2305b0de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - the two-column display is only used if the container is wide enough, this way property name columns do not get too small - in narrower containers, property name and value are displayed as stacked rows - make the breakpoint configurable via SCSS (`$eccgui-propertyvalue-size-column-breakpoint-small`) +- `utils` + - values of CSS custom properties are resolved via the computed style of a matching element now, so they always represent what the browser really applies, e.g. references to other custom properties are already replaced ### Fixed @@ -25,6 +27,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `` - default handle class names were removed as soon as an `intent` was given - fix runtime error if the element holding the handle tools is not available +- `utils` + - CSS custom properties are also found if their rule is nested inside a cascade layer (`@layer`) or another grouping rule like `@media`, `@supports` or `@container`; this affects `textToColorHash()`, `getEnabledColorsFromPalette()`, `getEnabledColorPropertiesFromPalette()` and `getColorConfiguration()` + - CSS custom properties are also found if the given selector is only one part of the selector list of a rule, e.g. `:root, :host` + - stylesheets that are loaded from another origin do not break the collection of CSS custom properties anymore + - empty results are not cached anymore, this way they are read again if the stylesheets are loaded later on + - `minimalColorDistance` is part of the cache key of `getEnabledColorsFromPalette()` and `getEnabledColorPropertiesFromPalette()` now ## [26.0.0] - 2026-07-08 From 44e6639407cd4cc85007ce805cc40a58dfd7fad3 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Wed, 19 Aug 2026 17:18:36 +0200 Subject: [PATCH 3/4] fix jsdom crash --- CHANGELOG.md | 1 + src/common/utils/CssCustomProperties.test.ts | 27 ++++++++++++++++++++ src/common/utils/CssCustomProperties.ts | 24 +++++++++++++++-- 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 src/common/utils/CssCustomProperties.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2305b0de..e2a60f0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - CSS custom properties are also found if their rule is nested inside a cascade layer (`@layer`) or another grouping rule like `@media`, `@supports` or `@container`; this affects `textToColorHash()`, `getEnabledColorsFromPalette()`, `getEnabledColorPropertiesFromPalette()` and `getColorConfiguration()` - CSS custom properties are also found if the given selector is only one part of the selector list of a rule, e.g. `:root, :host` - stylesheets that are loaded from another origin do not break the collection of CSS custom properties anymore + - collecting CSS custom properties does not throw an error anymore in test environments where the style declaration of a CSS rule is not an iterable object, e.g. in jsdom - empty results are not cached anymore, this way they are read again if the stylesheets are loaded later on - `minimalColorDistance` is part of the cache key of `getEnabledColorsFromPalette()` and `getEnabledColorPropertiesFromPalette()` now diff --git a/src/common/utils/CssCustomProperties.test.ts b/src/common/utils/CssCustomProperties.test.ts new file mode 100644 index 00000000..525f38ce --- /dev/null +++ b/src/common/utils/CssCustomProperties.test.ts @@ -0,0 +1,27 @@ +import CssCustomProperties from "./CssCustomProperties"; + +describe("CssCustomProperties in jsdom", () => { + beforeEach(() => { + const style = document.createElement("style"); + style.textContent = ` + :root { --eccgui-color-palette-blue-500: #1c6ecb; } + .config { --note-yellow: #ffde8f; } + `; + document.head.appendChild(style); + }); + + it("reads property names of a style rule without iterating the declaration", () => { + expect( + CssCustomProperties.listLocalCssStyleRuleProperties({ + selectorText: ":root", + propertyType: "custom", + }), + ).toEqual([["--eccgui-color-palette-blue-500", "#1c6ecb"]]); + }); + + it("does not throw for scoped selectors", () => { + expect(() => + new CssCustomProperties({ selectorText: ".config", returnObject: false }).customProperties(), + ).not.toThrow(); + }); +}); diff --git a/src/common/utils/CssCustomProperties.ts b/src/common/utils/CssCustomProperties.ts index b51eda51..8ac76ca8 100644 --- a/src/common/utils/CssCustomProperties.ts +++ b/src/common/utils/CssCustomProperties.ts @@ -153,12 +153,32 @@ export default class CssCustomProperties { return cssStyleRules as CSSStyleRule[]; }; + /** + * Return the property names of a style declaration. + * The declaration is not iterated directly because it is not always an iterable object, e.g. + * the declarations of style rules are not iterable in test environments using jsdom. + */ + static listStyleDeclarationPropertyNames = (style: CSSStyleDeclaration): string[] => { + const propertyNames = [] as string[]; + + for (let i = 0; i < style.length; i++) { + // `item()` is not available everywhere, the indexed getter is the more reliable one + const propertyName = style[i] ?? style.item?.(i); + if (propertyName) { + propertyNames.push(propertyName); + } + } + + return propertyNames; + }; + static listLocalCssStyleRuleProperties = (filter: getLocalCssStyleRulePropertiesProps = {}): string[][] => { const { propertyType = "all", ...otherFilters } = filter; return CssCustomProperties.listLocalCssStyleRules(otherFilters) .map((cssrule) => { - return [...(cssrule as CSSStyleRule).style].map((propertyname) => { - return [propertyname.trim(), (cssrule as CSSStyleRule).style.getPropertyValue(propertyname).trim()]; + const style = (cssrule as CSSStyleRule).style; + return CssCustomProperties.listStyleDeclarationPropertyNames(style).map((propertyname) => { + return [propertyname.trim(), style.getPropertyValue(propertyname).trim()]; }); }) .flat() From 2f2b493fd28bb7903fb42f37f264fae0eac0e1f7 Mon Sep 17 00:00:00 2001 From: Michael Haschke Date: Wed, 19 Aug 2026 17:46:19 +0200 Subject: [PATCH 4/4] add fallback that can be enabled to get css custom property values if they are constructed and not imorted via style sheets --- CHANGELOG.md | 5 ++ src/common/utils/CssCustomProperties.test.ts | 36 +++++++++++ src/common/utils/CssCustomProperties.ts | 68 +++++++++++++++++++- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2a60f0b..4bd639be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +### Added + +- `utils` + - `useComputedStyleFallback` option for `CssCustomProperties`: if the CSSOM does not provide any property name for the used selector, e.g. because the declarations are part of a constructed and adopted stylesheet, then the names are read from the computed style of the matching element; disabled by default because the computed style also contains all inherited custom properties + ### Changed - `` diff --git a/src/common/utils/CssCustomProperties.test.ts b/src/common/utils/CssCustomProperties.test.ts index 525f38ce..b3df7a92 100644 --- a/src/common/utils/CssCustomProperties.test.ts +++ b/src/common/utils/CssCustomProperties.test.ts @@ -24,4 +24,40 @@ describe("CssCustomProperties in jsdom", () => { new CssCustomProperties({ selectorText: ".config", returnObject: false }).customProperties(), ).not.toThrow(); }); + + describe("useComputedStyleFallback", () => { + beforeEach(() => { + // the property is not part of any stylesheet, so the CSSOM does not know its name + const element = document.createElement("div"); + element.classList.add("without-stylesheet"); + element.style.setProperty("--only-computed", "#c0ffee"); + document.body.appendChild(element); + }); + + it("is disabled by default", () => { + expect( + new CssCustomProperties({ + selectorText: ".without-stylesheet", + }).customProperties(), + ).toEqual({}); + }); + + it("reads the names from the computed style if the CSSOM does not provide any", () => { + expect( + new CssCustomProperties({ + selectorText: ".without-stylesheet", + useComputedStyleFallback: true, + }).customProperties(), + ).toEqual({ "only-computed": "#c0ffee" }); + }); + + it("is not used if the CSSOM provides names", () => { + expect( + new CssCustomProperties({ + selectorText: ".config", + useComputedStyleFallback: true, + }).customProperties(), + ).toEqual({ "note-yellow": "#ffde8f" }); + }); + }); }); diff --git a/src/common/utils/CssCustomProperties.ts b/src/common/utils/CssCustomProperties.ts index 8ac76ca8..1458e784 100644 --- a/src/common/utils/CssCustomProperties.ts +++ b/src/common/utils/CssCustomProperties.ts @@ -7,6 +7,9 @@ * The CSSOM is only a reliable source for the names: declarations can be nested inside grouping * rules (`@layer`, `@media`, `@supports`, `@container`), and their document order does not * represent the cascade anymore, e.g. unlayered declarations win over layered ones. + * + * If the CSSOM does not provide any name, then the names can optionally be read from the computed + * style of the element as well, see the `useComputedStyleFallback` option. */ type AllowedCSSRule = CSSStyleRule | CSSPageRule; // they have necessary `selectorText` and `style` properties @@ -16,6 +19,11 @@ type CssRuleWithChildren = CSSRule & { cssRules?: CSSRuleList; styleSheet?: CSSS type CustomPropertyEntry = [string, string]; +/** Element that supports the CSS typed object model, we only need to iterate over the property names. */ +type TypedOMElement = Element & { + computedStyleMap?: () => { forEach: (callback: (value: unknown, propertyName: string) => void) => void }; +}; + const rootSelectors = [":root", "html", ":root:root"]; const classSelectorPattern = /^(?:\.-?[_a-zA-Z][\w-]*)+$/; @@ -34,6 +42,16 @@ interface getCustomPropertiesProps extends getLocalCssStyleRulesProps { filterName?: (name: string) => boolean; removeDashPrefix?: boolean; returnObject?: boolean; + /** + * Read the property names from the computed style of the matching element if the CSSOM does not + * provide any name, e.g. because the declarations are part of a stylesheet that cannot be read + * or that is not listed by `document.styleSheets`, like constructed and adopted stylesheets. + * + * Disabled by default because it changes the result set: the computed style of an element also + * contains all custom properties it inherits from its ancestors, e.g. everything defined for + * `:root`, and it does not tell which rule declared them. + */ + useComputedStyleFallback?: boolean; } export default class CssCustomProperties { @@ -231,6 +249,39 @@ export default class CssCustomProperties { return [placeholder, () => placeholder.remove()]; }; + /** + * Return the names of all custom properties that apply to an element, they are read from its + * computed style. + * Inherited custom properties are part of the computed style, so the returned list also contains + * the names of custom properties that were declared for one of the ancestors of the element. + */ + static listElementCustomPropertyNames = (element: Element): string[] => { + const documentView = element.ownerDocument?.defaultView; + if (!documentView) { + return []; + } + + const computedStyle = documentView.getComputedStyle(element); + const propertyNames = new Set( + CssCustomProperties.listStyleDeclarationPropertyNames(computedStyle).filter((propertyName) => + propertyName.startsWith("--"), + ), + ); + + const typedOMElement = element as TypedOMElement; + if (propertyNames.size === 0 && typeof typedOMElement.computedStyleMap === "function") { + // Chromium before v141 does not enumerate custom properties in `getComputedStyle()`, + // but they are available via the typed object model + typedOMElement.computedStyleMap().forEach((_value, propertyName) => { + if (propertyName.startsWith("--")) { + propertyNames.add(propertyName); + } + }); + } + + return [...propertyNames]; + }; + /** * Resolve the values of custom properties as they are applied to an element. * Properties without a value are removed, they do not apply to the element, e.g. because they @@ -254,7 +305,13 @@ export default class CssCustomProperties { static listCustomProperties = ( props: getCustomPropertiesProps = {}, ): CustomPropertyEntry[] | Record => { - const { removeDashPrefix = true, returnObject = true, filterName = () => true, ...filterProps } = props; + const { + removeDashPrefix = true, + returnObject = true, + filterName = () => true, + useComputedStyleFallback = false, + ...filterProps + } = props; // the CSSOM is used to get the names only, the cascade decides about the values const propertyNames = [ @@ -271,8 +328,15 @@ export default class CssCustomProperties { const [element, removePlaceholder] = CssCustomProperties.targetElement(filterProps.selectorText); try { + const namesToResolve = + propertyNames.length === 0 && useComputedStyleFallback && element + ? CssCustomProperties.listElementCustomPropertyNames(element).filter((propertyName) => + filterName(propertyName), + ) + : propertyNames; + const customProperties = ( - element ? CssCustomProperties.resolveCustomPropertyValues(element, propertyNames) : [] + element ? CssCustomProperties.resolveCustomPropertyValues(element, namesToResolve) : [] ).map(([propertyName, value]): CustomPropertyEntry => { return [removeDashPrefix ? propertyName.slice(2) : propertyName, value]; });