diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ceda7e5..10b90756 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,9 @@ 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`
+ - CSS custom properties are read from the computed style of an element now instead of collecting them from the CSSOM, so returned values are always resolved values, e.g. `var()` references are already replaced
+ - `CssCustomProperties` no longer provides the CSSOM based helpers `listLocalStylesheets()`, `listLocalCssRules()`, `listLocalCssStyleRules()` and `listLocalCssStyleRuleProperties()`
### Fixed
@@ -25,6 +28,10 @@ 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 the `:root` rule defining them is nested inside a cascade layer (`@layer`) or any other grouping rule; this affects `textToColorHash()`, `getEnabledColorsFromPalette()`, `getEnabledColorPropertiesFromPalette()` and `getColorConfiguration()`
+ - 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
diff --git a/src/common/utils/CssCustomProperties.ts b/src/common/utils/CssCustomProperties.ts
index 4d0b3c28..7bb2fe70 100644
--- a/src/common/utils/CssCustomProperties.ts
+++ b/src/common/utils/CssCustomProperties.ts
@@ -1,26 +1,43 @@
/**
- * Based on CSS Tricks tutorial.
- * @see https://css-tricks.com/how-to-get-all-custom-properties-on-a-page-in-javascript/
+ * Read CSS custom properties from the DOM.
+ *
+ * We do not collect them from the CSSOM (`document.styleSheets`) on purpose:
+ * declarations can be nested inside grouping rules (`@layer`, `@media`, `@supports`, `@container`),
+ * they can sit in cross origin stylesheets that must not be read at all, and their document order
+ * does not represent the cascade anymore (unlayered declarations win over layered ones).
+ * Reading the computed style of an element instead always returns the values the browser really
+ * applies, including already resolved `var()` references.
*/
-type AllowedCSSRule = CSSStyleRule | CSSPageRule; // they have necessary `selectorText` and `style` properties
+type CustomPropertyEntry = [string, string];
-interface getLocalCssStyleRulesProps {
- cssRuleType?: "CSSStyleRule";
+/** 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-]*)+$/;
+
+interface getCustomPropertiesProps {
+ /**
+ * Selector of the element the custom properties are 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.
+ */
selectorText?: string;
-}
-interface getLocalCssStyleRulePropertiesProps extends getLocalCssStyleRulesProps {
- propertyType?: "all" | "normal" | "custom";
-}
-interface getCustomPropertiesProps extends getLocalCssStyleRulesProps {
+ /** Only return custom properties whose name (including the `--` prefix) passes this test. */
filterName?: (name: string) => boolean;
+ /** Remove the leading `--` from the returned property names. */
removeDashPrefix?: boolean;
+ /** Return an object instead of a list of name and value pairs. */
returnObject?: boolean;
}
export default class CssCustomProperties {
getterDefaultProps = {} as getCustomPropertiesProps;
- customprops = {};
+ customprops = {} as CustomPropertyEntry[] | Record;
constructor(props: getCustomPropertiesProps = {}) {
this.getterDefaultProps = props;
@@ -28,13 +45,13 @@ 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) {
+ // in case of performance issues other cache strategies could be also tested
+ 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,
@@ -43,88 +60,93 @@ export default class CssCustomProperties {
return customprops;
};
- static listLocalStylesheets = (): CSSStyleSheet[] => {
- if (document && document.styleSheets) {
- return (Array.from(document.styleSheets) as CSSStyleSheet[]).filter((stylesheet) => {
- // is inline stylesheet or from same domain
- if (!stylesheet.href) {
- return true;
- }
- return stylesheet.href.indexOf(window.location.origin) === 0;
- });
+ /**
+ * Return the element the custom properties of a selector can be read from.
+ * The second item of the returned tuple removes a temporarily created element again.
+ */
+ static targetElement = (selectorText: string = ":root"): [Element | undefined, (() => void) | undefined] => {
+ if (typeof document === "undefined") {
+ return [undefined, undefined];
}
- return [] as CSSStyleSheet[];
- };
+ if (rootSelectors.includes(selectorText.trim().toLowerCase())) {
+ return [document.documentElement, undefined];
+ }
- static listLocalCssRules = (): CSSRule[] => {
- return CssCustomProperties.listLocalStylesheets()
- .map((stylesheet) => {
- return Array.from(stylesheet.cssRules);
- })
- .flat();
+ 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()];
};
- 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 {
- return false;
+ static listElementCustomProperties = (element: Element): CustomPropertyEntry[] => {
+ const documentView = element.ownerDocument?.defaultView;
+ if (!documentView) {
+ return [];
+ }
+
+ const computedStyle = documentView.getComputedStyle(element);
+ const propertyNames = new Set();
+
+ for (let i = 0; i < computedStyle.length; i++) {
+ const propertyName = computedStyle.item(i);
+ if (propertyName.startsWith("--")) {
+ propertyNames.add(propertyName);
}
- });
- return cssStyleRules as CSSStyleRule[];
- };
+ }
- 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()];
- });
- })
- .flat()
- .filter((declaration) => {
- switch (propertyType) {
- case "normal":
- return declaration[0].indexOf("--") !== 0;
- case "custom":
- return declaration[0].indexOf("--") === 0;
+ 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 true; // case "all"
});
+ }
+
+ return [...propertyNames].map((propertyName) => [
+ propertyName,
+ computedStyle.getPropertyValue(propertyName).trim(),
+ ]);
};
static listCustomProperties = (
props: getCustomPropertiesProps = {},
- ): [string, string][] | 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;
- });
+ ): CustomPropertyEntry[] | Record => {
+ const { selectorText = ":root", removeDashPrefix = true, returnObject = true, filterName = () => true } = props;
+
+ const [element, removePlaceholder] = CssCustomProperties.targetElement(selectorText);
- return returnObject
- ? (Object.fromEntries(customProperties) as Record)
- : (customProperties as [string, string][]);
+ try {
+ const customProperties = (element ? CssCustomProperties.listElementCustomProperties(element) : [])
+ .filter(([propertyName]) => filterName(propertyName))
+ .map(([propertyName, value]): CustomPropertyEntry => {
+ return [removeDashPrefix ? propertyName.slice(2) : propertyName, value];
+ });
+
+ 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..eb562649 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, `var()` references 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)!;
};