diff --git a/packages/survey-core/entries/chunks/model.ts b/packages/survey-core/entries/chunks/model.ts index 5d65a4a4d5..e72a6e0908 100644 --- a/packages/survey-core/entries/chunks/model.ts +++ b/packages/survey-core/entries/chunks/model.ts @@ -316,6 +316,7 @@ export { export { IsMobile, IsTouch, _setIsTouch, _setIsTablet } from "../../src/utils/devices"; export * from "../../src/utils/browser"; export * from "../../src/utils/color"; +export * from "../../src/utils/css-variables"; export * from "../../src/utils/confirm-dialog"; export * from "../../src/utils/dom-utils"; export * from "../../src/utils/file-utils"; diff --git a/packages/survey-core/src/global_variables_utils.ts b/packages/survey-core/src/global_variables_utils.ts index 926df46bab..a551e8140f 100644 --- a/packages/survey-core/src/global_variables_utils.ts +++ b/packages/survey-core/src/global_variables_utils.ts @@ -101,6 +101,20 @@ export class DomDocumentHelper { if (!DomDocumentHelper.isAvailable()) return; return document.createElement(tagName); } + public static setStyles(element: HTMLElement, styles: Record): void { + if (!element || !styles) return; + + Object.keys(styles).forEach(property => { + element.style.setProperty(property, styles[property]); + }); + } + public static removeStyles(element: HTMLElement, properties: string[]): void { + if (!element || !properties) return; + + properties.forEach(property => { + element.style.removeProperty(property); + }); + } public static getComputedStyle(elt: Element): CSSStyleDeclaration { if (!DomDocumentHelper.isAvailable()) return new CSSStyleDeclaration(); return document.defaultView.getComputedStyle(elt); diff --git a/packages/survey-core/src/question_rating.ts b/packages/survey-core/src/question_rating.ts index 4a64afc886..09ed773ad1 100644 --- a/packages/survey-core/src/question_rating.ts +++ b/packages/survey-core/src/question_rating.ts @@ -12,8 +12,8 @@ import { DropdownListModel } from "./dropdownListModel"; import { SurveyModel } from "./survey"; import { ISurveyImpl } from "./base-interfaces"; import { IsTouch } from "./utils/devices"; -import { getColorFromProperty } from "./utils/utils"; import { getRGBaColor } from "./utils/color"; +import { getComputedCssVariableValue } from "./utils/css-variables"; import { ITheme } from "./themes"; import { DomDocumentHelper } from "./global_variables_utils"; import { HashTable } from "./helpers"; @@ -23,7 +23,7 @@ const RGBA_BLACK = "rgba(0, 0, 0, 1)"; function getRGBColor(themeVariables: any, colorName: string, varName: string, rootElement: HTMLElement): number[] | null { let str: string = !!themeVariables && themeVariables[colorName] as any; - const fallback = getColorFromProperty(varName, rootElement); + const fallback = getComputedCssVariableValue(varName, rootElement); if (!str) str = fallback; if (!str) return null; diff --git a/packages/survey-core/src/question_signaturepad.ts b/packages/survey-core/src/question_signaturepad.ts index 7ceb6f60a7..490e423807 100644 --- a/packages/survey-core/src/question_signaturepad.ts +++ b/packages/survey-core/src/question_signaturepad.ts @@ -7,12 +7,12 @@ import { SurveyModel } from "./survey"; import { ConsoleWarnings } from "./console-warnings"; import { ITheme } from "./themes"; import { dataUrl2File, FileLoader, QuestionFileModelBase } from "./question_file"; -import { getColorFromProperty } from "./utils/utils"; import { isBase64URL } from "./utils/dom-utils"; import { DomDocumentHelper, DomWindowHelper } from "./global_variables_utils"; import { Action } from "./actions/action"; import { ComputedUpdater } from "./base"; import { ActionContainer } from "./actions/container"; +import { getComputedCssVariableValue } from "./utils/css-variables"; var defaultWidth = 300; var defaultHeight = 200; @@ -34,12 +34,9 @@ export class QuestionSignaturePadModel extends QuestionFileModelBase { } } } - private getPenColorFromTheme(element: HTMLElement): string { - const cssVariable = "--sjs2-color-bg-brand-primary"; - return getColorFromProperty(cssVariable, element); - } + private updateColors(signaturePad: SignaturePad, element?: HTMLElement) { - const penColorFromTheme = this.getPenColorFromTheme(element); + const penColorFromTheme = getComputedCssVariableValue("--sjs2-color-bg-brand-primary", element); const penColorProperty = this.getPropertyByName("penColor"); signaturePad.penColor = this.penColor || penColorFromTheme || penColorProperty.defaultValue || "#1ab394"; diff --git a/packages/survey-core/src/utils/css-variables.ts b/packages/survey-core/src/utils/css-variables.ts new file mode 100644 index 0000000000..d6c2e72009 --- /dev/null +++ b/packages/survey-core/src/utils/css-variables.ts @@ -0,0 +1,103 @@ +import { DomDocumentHelper } from "../global_variables_utils"; +import { getRGBaColor } from "./color"; +import { createBoxShadow, parseBoxShadow } from "./shadow-effects"; + +const THEME_ROOT_CLASS = "sd-theme-root"; + +function getCssVariableProxyProperty(varName: string): string | null { + if (varName.indexOf("color") !== -1 || varName.indexOf("palette") !== -1) return "color"; + if (varName.indexOf("border-effect") !== -1) return "box-shadow"; + if (varName.indexOf("font-family") !== -1) return null; + if (varName.indexOf("font-weight") !== -1) return null; + if (varName.indexOf("text-case") !== -1) return null; + if (varName.indexOf("opacity") !== -1) return null; + if (varName.indexOf("scale") !== -1) return null; + return "width"; +} + +function normalizeCssVariableValue(value: string, proxyProperty: string | null): string { + if (value == null) return value; + if (proxyProperty === "color" && value !== "transparent") { + return getRGBaColor(value) || value; + } + if (proxyProperty === "box-shadow") { + return createBoxShadow(parseBoxShadow(value)) || value; + } + return value; +} + +function createCssVariableProbeElement(host: HTMLElement): HTMLElement | null { + const div = DomDocumentHelper.createElement("div") as HTMLElement; + if (!div) return null; + div.style.position = "absolute"; + div.style.visibility = "hidden"; + div.style.top = "0"; + div.style.left = "0"; + host.appendChild(div); + return div; +} + +export function getComputedCssVariableValue(varName: string, element?: HTMLElement): string { + const el = element || DomDocumentHelper.getBody() || DomDocumentHelper.getDocumentElement(); + if (!el) return ""; + + const computedStyle = DomDocumentHelper.getComputedStyle(el); + if (!computedStyle || !computedStyle.getPropertyValue) return ""; + + const value = computedStyle.getPropertyValue(varName); + const proxyProperty = getCssVariableProxyProperty(varName); + return normalizeCssVariableValue(value, proxyProperty); +} + +function resolveComputedCssVariableValue( + probe: HTMLElement, + computed: CSSStyleDeclaration, + varName: string +): string { + const rawValue = computed.getPropertyValue(varName); + if (typeof rawValue !== "string" || rawValue.trim() === "") return ""; + const proxyProperty = getCssVariableProxyProperty(varName); + if (proxyProperty === null) return rawValue.trim(); + + probe.style.setProperty(proxyProperty, `var(${varName})`); + const raw = computed.getPropertyValue(proxyProperty); + probe.style.removeProperty(proxyProperty); + const proxyValue = normalizeCssVariableValue(raw, proxyProperty); + + return typeof proxyValue === "string" ? proxyValue.trim() : ""; +} + +export function getComputedCssVariableValues( + sourceCssVariables: { [key: string]: string } = {}, + additionalCssVariables: string[] = [], + rootElement?: HTMLElement +): { [key: string]: string } { + const fallbackResult = { ...sourceCssVariables }; + if (!DomDocumentHelper.isAvailable()) return fallbackResult; + + const host = rootElement || DomDocumentHelper.getBody() || DomDocumentHelper.getDocumentElement(); + const probe = host ? createCssVariableProbeElement(host) : null; + if (!probe) return fallbackResult; + + if (Object.keys(sourceCssVariables).length !== 0) { + DomDocumentHelper.setStyles(probe, sourceCssVariables); + probe.classList.add(THEME_ROOT_CLASS); + } + + const result: { [key: string]: string } = {}; + try { + const computed = DomDocumentHelper.getComputedStyle(probe); + const varNames = [...Object.keys(sourceCssVariables), ...additionalCssVariables]; + for (const varName of varNames) { + const value = resolveComputedCssVariableValue(probe, computed, varName); + if (value !== "") { + result[varName] = value; + } else if (sourceCssVariables[varName] !== undefined) { + result[varName] = sourceCssVariables[varName]; + } + } + } finally { + host.removeChild(probe); + } + return result; +} \ No newline at end of file diff --git a/packages/survey-core/tests/question_ratingtests.ts b/packages/survey-core/tests/question_ratingtests.ts index 905482aea0..b1b9094c54 100644 --- a/packages/survey-core/tests/question_ratingtests.ts +++ b/packages/survey-core/tests/question_ratingtests.ts @@ -129,35 +129,40 @@ test("check rating resize observer behavior", () => { }, ], }; - const survey = new SurveyModel(json); - survey.css = defaultCss; - const q1 = survey.getQuestionByName("q1"); - q1.afterRender(rootElement); - expect(q1["resizeObserver"]).toBeTruthy(); - expect(q1.renderAs).toBe("default"); - currentOffsetWidth = 300; - currentScrollWidth = 300; - (q1["resizeObserver"]).call(); - expect(q1.renderAs).toBe("default"); - currentOffsetWidth = 200; - (q1["resizeObserver"]).call(); - (q1["resizeObserver"]).call(); //double process to reset isProcessed flag - expect(q1.renderAs).toBe("dropdown"); - currentOffsetWidth = 400; - (q1["resizeObserver"]).call(); - (q1["resizeObserver"]).call(); //double process to reset isProcessed flag - expect(q1.renderAs).toBe("default"); - currentOffsetWidth = 200; - (q1["resizeObserver"]).call(); - (q1["resizeObserver"]).call(); //double process to reset isProcessed flag - expect(q1.renderAs).toBe("dropdown"); - q1["destroyResizeObserver"](); - expect(q1.renderAs, "https://github.com/surveyjs/survey-creator/issues/2966: after destroying resize observer renderAs should return to default state").toBe("default"); - window.getComputedStyle = getComputedStyle; - window.ResizeObserver = ResizeObserver; - contentElement.remove(); - rootElement.remove(); + try { + const survey = new SurveyModel(json); + survey.css = defaultCss; + const q1 = survey.getQuestionByName("q1"); + q1.afterRender(rootElement); + expect(q1["resizeObserver"]).toBeTruthy(); + expect(q1.renderAs).toBe("default"); + currentOffsetWidth = 300; + currentScrollWidth = 300; + (q1["resizeObserver"]).call(); + expect(q1.renderAs).toBe("default"); + currentOffsetWidth = 200; + (q1["resizeObserver"]).call(); + (q1["resizeObserver"]).call(); //double process to reset isProcessed flag + expect(q1.renderAs).toBe("dropdown"); + currentOffsetWidth = 400; + (q1["resizeObserver"]).call(); + (q1["resizeObserver"]).call(); //double process to reset isProcessed flag + expect(q1.renderAs).toBe("default"); + currentOffsetWidth = 200; + (q1["resizeObserver"]).call(); + (q1["resizeObserver"]).call(); //double process to reset isProcessed flag + expect(q1.renderAs).toBe("dropdown"); + q1["destroyResizeObserver"](); + expect(q1.renderAs, "https://github.com/surveyjs/survey-creator/issues/2966: after destroying resize observer renderAs should return to default state").toBe("default"); + + } finally { + window.getComputedStyle = getComputedStyle; + window.ResizeObserver = ResizeObserver; + + contentElement.remove(); + rootElement.remove(); + } }); test("check rating in case of state 'collapsed'", () => { diff --git a/packages/survey-core/tests/question_signaturepadtests.ts b/packages/survey-core/tests/question_signaturepadtests.ts index 90b4dff77f..d628d27e3a 100644 --- a/packages/survey-core/tests/question_signaturepadtests.ts +++ b/packages/survey-core/tests/question_signaturepadtests.ts @@ -223,6 +223,9 @@ describe("question signaturepad", () => { }); test("check penColor & background color from theme", () => { + const defaultThemePenColor = "#1ab394"; + const defaultBackgroundColor = "transparent"; + const json = { elements: [ { @@ -242,7 +245,7 @@ describe("question signaturepad", () => { expect(signaturepadQuestion.penColor, "penColor undefined").toBeUndefined(); expect(signaturepadQuestion.backgroundColor, "backgroundColor undefined").toBeUndefined(); - expect(signaturepadQuestion.signaturePad.penColor, "signaturePad.penColor default").toBe("#1ab394"); + expect(signaturepadQuestion.signaturePad.penColor, "signaturePad.penColor default").toBe(defaultThemePenColor); expect(signaturepadQuestion.signaturePad.backgroundColor, "signaturePad.backgroundColor default").toBe("#ffffff"); containerEl.style.setProperty("--sjs2-color-bg-brand-primary", "rgba(103, 58, 176, 1)"); @@ -250,19 +253,21 @@ describe("question signaturepad", () => { expect(signaturepadQuestion.penColor, "penColor undefined").toBeUndefined(); expect(signaturepadQuestion.backgroundColor, "backgroundColor undefined").toBeUndefined(); expect(signaturepadQuestion.signaturePad.penColor, "signaturePad.penColor from theme").toBe("rgba(103, 58, 176, 1)"); - expect(signaturepadQuestion.signaturePad.backgroundColor, "signaturePad.backgroundColor from theme").toBe("transparent"); + expect(signaturepadQuestion.signaturePad.backgroundColor, "signaturePad.backgroundColor from theme").toBe(defaultBackgroundColor); containerEl.style.setProperty("--sjs2-color-bg-brand-primary", ""); survey.applyTheme({ "cssVariables": {} }); expect(signaturepadQuestion.penColor, "penColor undefined").toBeUndefined(); expect(signaturepadQuestion.backgroundColor, "backgroundColor undefined").toBeUndefined(); - expect(signaturepadQuestion.signaturePad.penColor, "signaturePad.penColor default").toBe("#1ab394"); + expect(signaturepadQuestion.signaturePad.penColor, "signaturePad.penColor default").toBe(defaultThemePenColor); expect(signaturepadQuestion.signaturePad.backgroundColor, "signaturePad.backgroundColor default").toBe("#ffffff"); containerEl.remove(); }); test("check penColor & background color if background image", () => { + const defaultThemePenColor = "#1ab394"; + const defaultBackgroundColor = "transparent"; const json = { elements: [ { @@ -283,28 +288,28 @@ describe("question signaturepad", () => { expect(signaturepadQuestion.penColor, "penColor undefined").toBeUndefined(); expect(signaturepadQuestion.backgroundColor, "backgroundColor undefined").toBeUndefined(); - expect(signaturepadQuestion.signaturePad.penColor, "signaturePad.penColor #1ab394").toBe("#1ab394"); - expect(signaturepadQuestion.signaturePad.backgroundColor, "signaturePad.backgroundColor transparent").toBe("transparent"); + expect(signaturepadQuestion.signaturePad.penColor, "signaturePad.penColor default").toBe(defaultThemePenColor); + expect(signaturepadQuestion.signaturePad.backgroundColor, "signaturePad.backgroundColor transparent").toBe(defaultBackgroundColor); containerEl.style.setProperty("--sjs2-color-bg-brand-primary", "rgba(103, 58, 176, 1)"); survey.applyTheme({ "cssVariables": { "--sjs2-color-bg-brand-primary": "rgba(103, 58, 176, 1)" } }); expect(signaturepadQuestion.penColor, "penColor undefined").toBeUndefined(); expect(signaturepadQuestion.backgroundColor, "backgroundColor undefined").toBeUndefined(); expect(signaturepadQuestion.signaturePad.penColor, "signaturePad.penColor from theme").toBe("rgba(103, 58, 176, 1)"); - expect(signaturepadQuestion.signaturePad.backgroundColor, "signaturePad.backgroundColor from theme").toBe("transparent"); + expect(signaturepadQuestion.signaturePad.backgroundColor, "signaturePad.backgroundColor from theme").toBe(defaultBackgroundColor); containerEl.style.setProperty("--sjs2-color-bg-brand-primary", ""); survey.applyTheme({ "cssVariables": {} }); expect(signaturepadQuestion.penColor, "penColor undefined").toBeUndefined(); expect(signaturepadQuestion.backgroundColor, "backgroundColor undefined").toBeUndefined(); - expect(signaturepadQuestion.signaturePad.penColor, "signaturePad.penColor #1ab394").toBe("#1ab394"); - expect(signaturepadQuestion.signaturePad.backgroundColor, "signaturePad.backgroundColor transparent").toBe("transparent"); + expect(signaturepadQuestion.signaturePad.penColor, "signaturePad.penColor default").toBe(defaultThemePenColor); + expect(signaturepadQuestion.signaturePad.backgroundColor, "signaturePad.backgroundColor transparent").toBe(defaultBackgroundColor); signaturepadQuestion.backgroundColor = "#dde6db"; expect(signaturepadQuestion.penColor, "penColor undefined").toBeUndefined(); expect(signaturepadQuestion.backgroundColor, "backgroundColor #dde6db").toBe("#dde6db"); - expect(signaturepadQuestion.signaturePad.penColor, "signaturePad.penColor #1ab394").toBe("#1ab394"); - expect(signaturepadQuestion.signaturePad.backgroundColor, "signaturePad.backgroundColor transparent").toBe("transparent"); + expect(signaturepadQuestion.signaturePad.penColor, "signaturePad.penColor default").toBe(defaultThemePenColor); + expect(signaturepadQuestion.signaturePad.backgroundColor, "signaturePad.backgroundColor transparent").toBe(defaultBackgroundColor); containerEl.remove(); });