From bf4684b1625a5dd38976f1d6abf959f6bf464fc6 Mon Sep 17 00:00:00 2001 From: Nayden Naydenov Date: Thu, 11 Dec 2025 16:01:56 +0200 Subject: [PATCH] wip(framework): scope theming variables --- packages/base/lib/generate-styles/index.js | 12 +- packages/base/src/theming/applyTheme.ts | 50 +++----- packages/compat/cypress/specs/Table.cy.tsx | 1 - packages/main/test/pages/theming/Themes.html | 20 ++++ packages/main/test/pages/theming/Themes2.html | 25 ++++ packages/main/test/pages/theming/Themes3.html | 22 ++++ packages/main/test/pages/theming/Themes4.html | 28 +++++ packages/main/test/pages/theming/Themes5.html | 27 +++++ packages/main/test/pages/theming/Themes6.html | 31 +++++ packages/main/test/pages/theming/Themes7.html | 21 ++++ packages/main/test/pages/theming/Themes8.html | 21 ++++ packages/theming/package-scripts.cjs | 2 +- packages/theming/src/Assets-node.ts | 2 +- .../tools/lib/create-illustrations/index.js | 3 +- .../css-processor-components.mjs | 4 +- .../css-processors/css-processor-themes.mjs | 112 +++++++++++------- .../lib/css-processors/scope-variables.mjs | 26 ++-- 17 files changed, 318 insertions(+), 89 deletions(-) create mode 100644 packages/main/test/pages/theming/Themes.html create mode 100644 packages/main/test/pages/theming/Themes2.html create mode 100644 packages/main/test/pages/theming/Themes3.html create mode 100644 packages/main/test/pages/theming/Themes4.html create mode 100644 packages/main/test/pages/theming/Themes5.html create mode 100644 packages/main/test/pages/theming/Themes6.html create mode 100644 packages/main/test/pages/theming/Themes7.html create mode 100644 packages/main/test/pages/theming/Themes8.html diff --git a/packages/base/lib/generate-styles/index.js b/packages/base/lib/generate-styles/index.js index dfa76e645fe8..266f5681a6fe 100644 --- a/packages/base/lib/generate-styles/index.js +++ b/packages/base/lib/generate-styles/index.js @@ -1,16 +1,24 @@ import fs from 'fs/promises'; import path from "path"; import CleanCSS from "clean-css"; +import { processComponentPackageFile } from '@ui5/webcomponents-tools/lib/css-processors/css-processor-themes.mjs'; import { pathToFileURL } from "url"; const generate = async () => { + const packageJSON = JSON.parse(await fs.readFile("./package.json")) await fs.mkdir("src/generated/css/", { recursive: true }); const files = (await fs.readdir("src/css/")).filter(file => file.endsWith(".css")); const filesPromises = files.map(async file => { - let content = await fs.readFile(path.join("src/css/", file)); + const filePath = path.join("src/css/", file); + let content = await fs.readFile(filePath); const res = new CleanCSS().minify(`${content}`); - content = `export default \`${res.styles}\`;`; + + // Scope used variables + content = await processComponentPackageFile({ text: res.styles, path: filePath }, packageJSON); + + content = `export default \`${content}\`;`; + return fs.writeFile(path.join("src/generated/css/", `${file}.ts`), content); }); diff --git a/packages/base/src/theming/applyTheme.ts b/packages/base/src/theming/applyTheme.ts index c8f7d729c156..dfc0e799c4c2 100644 --- a/packages/base/src/theming/applyTheme.ts +++ b/packages/base/src/theming/applyTheme.ts @@ -1,10 +1,8 @@ import { getThemeProperties, getRegisteredPackages, isThemeRegistered } from "../asset-registries/Themes.js"; -import { removeStyle, createOrUpdateStyle } from "../ManagedStyles.js"; +import { createOrUpdateStyle } from "../ManagedStyles.js"; import getThemeDesignerTheme from "./getThemeDesignerTheme.js"; import { fireThemeLoaded } from "./ThemeLoaded.js"; -import { getFeature } from "../FeaturesRegistry.js"; import { attachCustomThemeStylesToHead, getThemeRoot } from "../config/ThemeRoot.js"; -import type OpenUI5Support from "../features/OpenUI5Support.js"; import { DEFAULT_THEME } from "../generated/AssetParameters.js"; import { getCurrentRuntimeIndex } from "../Runtimes.js"; @@ -31,10 +29,6 @@ const loadThemeBase = async (theme: string) => { } }; -const deleteThemeBase = () => { - removeStyle("data-ui5-theme-properties", BASE_THEME_PACKAGE); -}; - const loadComponentPackages = async (theme: string, externalThemeName?: string) => { const registeredPackages = getRegisteredPackages(); @@ -53,42 +47,34 @@ const loadComponentPackages = async (theme: string, externalThemeName?: string) }; const detectExternalTheme = async (theme: string) => { + if (getThemeRoot()) { + await attachCustomThemeStylesToHead(theme); + } + // If theme designer theme is detected, use this const extTheme = getThemeDesignerTheme(); if (extTheme) { return extTheme; } - - // If OpenUI5Support is enabled, try to find out if it loaded variables - const openUI5Support = getFeature("OpenUI5Support"); - if (openUI5Support && openUI5Support.isOpenUI5Detected()) { - const varsLoaded = openUI5Support.cssVariablesLoaded(); - if (varsLoaded) { - return { - themeName: openUI5Support.getConfigurationSettingsObject()?.theme, // just themeName - baseThemeName: "", // baseThemeName is only relevant for custom themes - }; - } - } else if (getThemeRoot()) { - await attachCustomThemeStylesToHead(theme); - - return getThemeDesignerTheme(); - } }; const applyTheme = async (theme: string) => { + // Detect external theme if available (e.g., from theme designer or custom theme root) const extTheme = await detectExternalTheme(theme); - // Only load theme_base properties if there is no externally loaded theme, or there is, but it is not being loaded - if (!extTheme || theme !== extTheme.themeName) { - await loadThemeBase(theme); - } else { - deleteThemeBase(); - } - - // Always load component packages properties. For non-registered themes, try with the base theme, if any + // Determine which theme to use for component packages: + // 1. If the requested theme is registered, use it directly + // 2. If external theme exists, use its base theme (e.g., "my_custom_theme" extends "sap_fiori_3") + // 3. Otherwise, fallback to the default theme const packagesTheme = isThemeRegistered(theme) ? theme : extTheme && extTheme.baseThemeName; - await loadComponentPackages(packagesTheme || DEFAULT_THEME, extTheme && extTheme.themeName === theme ? theme : undefined); + const effectiveTheme = packagesTheme || DEFAULT_THEME; + + // Load base theme properties + await loadThemeBase(effectiveTheme); + + // Load component-specific theme properties + // Pass external theme name only if it matches the requested theme to avoid conflicts + await loadComponentPackages(effectiveTheme, extTheme && extTheme.themeName === theme ? theme : undefined); fireThemeLoaded(theme); }; diff --git a/packages/compat/cypress/specs/Table.cy.tsx b/packages/compat/cypress/specs/Table.cy.tsx index 516307524dc4..31b4c1bc4a91 100644 --- a/packages/compat/cypress/specs/Table.cy.tsx +++ b/packages/compat/cypress/specs/Table.cy.tsx @@ -509,7 +509,6 @@ describe("Table general interaction", () => { cy.get("@popinChange") .should(stub => { expect(stub).to.have.been.calledTwice; - debugger // @ts-ignore expect(stub.args.slice(-1)[0][0].detail.poppedColumns.length).to.equal(2); }) diff --git a/packages/main/test/pages/theming/Themes.html b/packages/main/test/pages/theming/Themes.html new file mode 100644 index 000000000000..38045c1b8609 --- /dev/null +++ b/packages/main/test/pages/theming/Themes.html @@ -0,0 +1,20 @@ + + + + + + Theming + + + + + + +

Test Page 1: Default theming - Tests the component with default theme settings without any + external styles or theme changes.

+

Expected theme sap_horizon

+ + Some button + + + \ No newline at end of file diff --git a/packages/main/test/pages/theming/Themes2.html b/packages/main/test/pages/theming/Themes2.html new file mode 100644 index 000000000000..b85ec04d28c9 --- /dev/null +++ b/packages/main/test/pages/theming/Themes2.html @@ -0,0 +1,25 @@ + + + + + + Theming + + + + + + +

Test Page 6: Theme change without external styles - Tests programmatic theme switching + behavior without any external CSS interference to verify pure theme transition functionality.

+

Expected theme sap_horizon_hcb

+ Some button + + + + + \ No newline at end of file diff --git a/packages/main/test/pages/theming/Themes3.html b/packages/main/test/pages/theming/Themes3.html new file mode 100644 index 000000000000..d7ff5ae80ced --- /dev/null +++ b/packages/main/test/pages/theming/Themes3.html @@ -0,0 +1,22 @@ + + + + + + Theming + + + + + + + +

Test Page 2: Default theming with preloaded external styles - Tests how components behave when + external CSS is loaded before component initialization.

+

Expected theme sap_belize

+ + Some button + + + \ No newline at end of file diff --git a/packages/main/test/pages/theming/Themes4.html b/packages/main/test/pages/theming/Themes4.html new file mode 100644 index 000000000000..7285b09aa7fc --- /dev/null +++ b/packages/main/test/pages/theming/Themes4.html @@ -0,0 +1,28 @@ + + + + + + Theming + + + + + + +

Test Page 3: Default theming with external styles loaded later - Tests the impact of external + CSS loaded after component initialization on styling.

+

Expected theme sap_belize

+ + Some button + + + + \ No newline at end of file diff --git a/packages/main/test/pages/theming/Themes5.html b/packages/main/test/pages/theming/Themes5.html new file mode 100644 index 000000000000..ad596f7b1424 --- /dev/null +++ b/packages/main/test/pages/theming/Themes5.html @@ -0,0 +1,27 @@ + + + + + + Theming + + + + + + + +

Test Page 4: Default theming with theme change and preloaded external styles - Tests theme + switching behavior when external CSS is already present in the DOM.

+

Expected theme sap_belize

+ + Some button + + + + \ No newline at end of file diff --git a/packages/main/test/pages/theming/Themes6.html b/packages/main/test/pages/theming/Themes6.html new file mode 100644 index 000000000000..93a61a022769 --- /dev/null +++ b/packages/main/test/pages/theming/Themes6.html @@ -0,0 +1,31 @@ + + + + + + Theming + + + + + + +

Test Page 5: Default theming with theme change and external styles loaded later - Tests theme + switching followed by external CSS injection to verify style resolution order.

+

Expected theme sap_belize

+ Some button + + + + + \ No newline at end of file diff --git a/packages/main/test/pages/theming/Themes7.html b/packages/main/test/pages/theming/Themes7.html new file mode 100644 index 000000000000..d6932380a927 --- /dev/null +++ b/packages/main/test/pages/theming/Themes7.html @@ -0,0 +1,21 @@ + + + + + + Theming + + + + + + + + Some content + + + \ No newline at end of file diff --git a/packages/main/test/pages/theming/Themes8.html b/packages/main/test/pages/theming/Themes8.html new file mode 100644 index 000000000000..b724131f8cb4 --- /dev/null +++ b/packages/main/test/pages/theming/Themes8.html @@ -0,0 +1,21 @@ + + + + + + Theming + + + + + + + + Some content + + + \ No newline at end of file diff --git a/packages/theming/package-scripts.cjs b/packages/theming/package-scripts.cjs index 41afd71c7137..d9ed10c338b6 100644 --- a/packages/theming/package-scripts.cjs +++ b/packages/theming/package-scripts.cjs @@ -26,6 +26,6 @@ module.exports = { postcss: `ui5nps-script "${TOOLS_LIB}/css-processors/css-processor-themes.mjs"`, jsonImports: `ui5nps-script "${jsonImportsScript}" src/themes src/generated/json-imports`, }, - generateReport: `ui5nps-script "${generateReportScript}"`, + generateReport: `node "${generateReportScript}"`, }, }; diff --git a/packages/theming/src/Assets-node.ts b/packages/theming/src/Assets-node.ts index ed2fcb45449c..9e160f274a72 100644 --- a/packages/theming/src/Assets-node.ts +++ b/packages/theming/src/Assets-node.ts @@ -3,7 +3,7 @@ * * It serves as an alternative to the `Assets` and `Assets-fetch` modules and supports the * `with: { type: 'json' }` import attribute for loading JSON files. - * + * * This import attribute is required in some environments, such as Node.js with server-side rendering (SSR). * * Example usage: diff --git a/packages/tools/lib/create-illustrations/index.js b/packages/tools/lib/create-illustrations/index.js index 203f7bc88d31..bd5838a57a23 100644 --- a/packages/tools/lib/create-illustrations/index.js +++ b/packages/tools/lib/create-illustrations/index.js @@ -1,5 +1,6 @@ const fs = require("fs").promises; const path = require("path"); +const { scopeThemingVariables } = require("../css-processors/scope-variables.mjs"); const generate = async (argv) => { if (argv.length < 7) { @@ -91,7 +92,7 @@ const generate = async (argv) => { console.log(`Generating illustrations from ${srcPath} to ${destPath}`) const svgImportTemplate = svgContent => { - return `export default \`${svgContent}\`;` + return `export default \`${scopeThemingVariables(svgContent)}\`;` }; const svgToJs = async fileName => { const svg = await fs.readFile(path.join(srcPath, fileName), { encoding: "utf-8" }); diff --git a/packages/tools/lib/css-processors/css-processor-components.mjs b/packages/tools/lib/css-processors/css-processor-components.mjs index a305013079b9..35137567ca37 100644 --- a/packages/tools/lib/css-processors/css-processor-components.mjs +++ b/packages/tools/lib/css-processors/css-processor-components.mjs @@ -4,7 +4,7 @@ import * as fs from "fs"; import * as path from "path"; import { writeFile, mkdir } from "fs/promises"; import chokidar from "chokidar"; -import scopeVariables from "./scope-variables.mjs"; +import {scopeUi5Variables} from "./scope-variables.mjs"; import { writeFileIfChanged, getFileContent } from "./shared.mjs"; import { pathToFileURL } from "url"; @@ -24,7 +24,7 @@ const generate = async (argv) => { build.onEnd(result => { result.outputFiles.forEach(async f => { // scoping - let newText = scopeVariables(f.text, packageJSON); + let newText = scopeUi5Variables(f.text, packageJSON); newText = newText.replaceAll(/\\/g, "\\\\"); // Escape backslashes as they might appear in css rules await mkdir(path.dirname(f.path), { recursive: true }); writeFile(f.path, newText); diff --git a/packages/tools/lib/css-processors/css-processor-themes.mjs b/packages/tools/lib/css-processors/css-processor-themes.mjs index a334e560274a..fd9561691877 100644 --- a/packages/tools/lib/css-processors/css-processor-themes.mjs +++ b/packages/tools/lib/css-processors/css-processor-themes.mjs @@ -6,10 +6,66 @@ import { writeFile, mkdir } from "fs/promises"; import postcss from "postcss"; import combineDuplicatedSelectors from "../postcss-combine-duplicated-selectors/index.js" import { writeFileIfChanged, getFileContent } from "./shared.mjs"; -import scopeVariables from "./scope-variables.mjs"; +import { scopeUi5Variables, scopeThemingVariables } from "./scope-variables.mjs"; import { pathToFileURL } from "url"; -const generate = async (argv) => { +async function processThemingPackageFile(f) { + const selector = ':root'; + const selector2 = ':root:not(.ui5-test-css-loading)'; + const newRule = postcss.rule({ selector: selector }); + const newRule2 = postcss.rule({ selector: selector2 }); + const result = await postcss().process(f.text); + + result.root.walkRules(selector, rule => { + for (const decl of rule.nodes) { + if (decl.type !== 'decl') { + continue; + } else if (decl.prop.startsWith('--sapFontUrl')) { + continue; + } else if (!decl.prop.startsWith('--sap')) { + newRule.append(decl.clone()); + } else { + const originalProp = decl.prop; + const originalValue = decl.value; + + newRule.append(decl.clone({ prop: originalProp.replace("--sap", "--ui5-sap"), value: `var(${originalProp}, ${originalValue})` })); + newRule2.append(decl.clone()); + } + } + }); + + return `${newRule.toString()}\n${newRule2.toString()}`; +}; + +async function processComponentPackageFile(f, packageJSON) { + let result = await postcss(combineDuplicatedSelectors).process(f.text); + + result = scopeUi5Variables(result.css, packageJSON, f.path); + + result = scopeThemingVariables(result); + + return result; +} +async function writeProcessedContent(basePath, content, packageJSON, extension) { + const cssPath = basePath; + const jsonPath = basePath.replace(/dist[\/\\]css/, "dist/generated/assets").replace(".css", ".css.json"); + const jsPath = basePath.replace(/dist[\/\\]css/, "src/generated/").replace(".css", extension); + + // Write CSS file + await mkdir(path.dirname(cssPath), { recursive: true }); + await writeFile(cssPath, content); + + // Write JSON file + await mkdir(path.dirname(jsonPath), { recursive: true }); + await writeFileIfChanged(jsonPath, JSON.stringify(content)); + + // Write JS/TS file + const jsContent = getFileContent(packageJSON.name, `\`${content}\``); + await mkdir(path.dirname(jsPath), { recursive: true }); + await writeFileIfChanged(jsPath, jsContent); +} + +async function generate(argv) { const tsMode = process.env.UI5_TS === "true"; const extension = tsMode ? ".css.ts" : ".css.js"; @@ -20,50 +76,22 @@ const generate = async (argv) => { ]); const restArgs = argv.slice(2); - const processThemingPackageFile = async (f) => { - const selector = ':root'; - const result = await postcss().process(f.text); - - const newRule = postcss.rule({ selector }); - - result.root.walkRules(selector, rule => { - rule.walkDecls(decl => { - if (!decl.prop.startsWith('--sapFontUrl')) { - newRule.append(decl.clone()); - } - }); - }); - - return newRule.toString(); - }; - - const processComponentPackageFile = async (f) => { - const result = await postcss(combineDuplicatedSelectors).process(f.text); - - return scopeVariables(result.css, packageJSON, f.path); - } - - let scopingPlugin = { + const scopingPlugin = { name: 'scoping', setup(build) { build.initialOptions.write = false; build.onEnd(result => { result.outputFiles.forEach(async f => { - let newText = f.path.includes("packages/theming") ? await processThemingPackageFile(f) : await processComponentPackageFile(f); - - await mkdir(path.dirname(f.path), { recursive: true }); - writeFile(f.path, newText); - - // JSON - const jsonPath = f.path.replace(/dist[\/\\]css/, "dist/generated/assets").replace(".css", ".css.json"); - await mkdir(path.dirname(jsonPath), { recursive: true }); - writeFileIfChanged(jsonPath, JSON.stringify(newText)); - - // JS/TS - const jsPath = f.path.replace(/dist[\/\\]css/, "src/generated/").replace(".css", extension); - const jsContent = getFileContent(packageJSON.name, "\`" + newText + "\`"); - writeFileIfChanged(jsPath, jsContent); + if (f.path.includes("packages/theming")) { + const scopedText = await processThemingPackageFile(f); + + // Write scoped version + await writeProcessedContent(f.path, scopedText, packageJSON, extension); + } else { + const processedText = await processComponentPackageFile(f, packageJSON); + await writeProcessedContent(f.path, processedText, packageJSON, extension); + } }); }) }, @@ -99,4 +127,8 @@ if (import.meta.url === fileUrl) { export default { _ui5mainFn: generate +} + +export { + processComponentPackageFile } \ No newline at end of file diff --git a/packages/tools/lib/css-processors/scope-variables.mjs b/packages/tools/lib/css-processors/scope-variables.mjs index c824e6ba9d3e..35861e30b26e 100644 --- a/packages/tools/lib/css-processors/scope-variables.mjs +++ b/packages/tools/lib/css-processors/scope-variables.mjs @@ -9,9 +9,9 @@ const require = createRequire(import.meta.url); * @returns */ const getOverrideVersion = filePath => { - if (!filePath) { - return; - } + if (!filePath) { + return; + } if (!filePath.includes(`overrides${path.sep}`)) { return; // The "overrides/" directory is the marker @@ -36,14 +36,22 @@ const getOverrideVersion = filePath => { return overrideVersion; } -const scopeVariables = (cssText, packageJSON, inputFile) => { - const escapeVersion = version => "v" + version?.replaceAll(/[^0-9A-Za-z\-_]/g, "-"); - const versionStr = escapeVersion(getOverrideVersion(inputFile) || packageJSON.version); +const scopeUi5Variables = (cssText, packageJSON, inputFile) => { + const escapeVersion = version => "v" + version?.replaceAll(/[^0-9A-Za-z\-_]/g, "-"); + const versionStr = escapeVersion(getOverrideVersion(inputFile) || packageJSON.version); + const expr = /(--_?ui5)([^\,\:\)\s]+)/g; + let newText = cssText.replaceAll(expr, `$1-${versionStr}$2`); - const expr = /(--_?ui5)([^\,\:\)\s]+)/g; + return newText.replaceAll("--sap", `--ui5-sap`); +} - return cssText.replaceAll(expr, `$1-${versionStr}$2`); +// Used with CSS text and SVG file content (illustrations) +const scopeThemingVariables = (cssText) => { + return cssText.replaceAll("--sap", `--ui5-sap`); } -export default scopeVariables; +export { + scopeUi5Variables, + scopeThemingVariables, +};