@ui5/webcomponents-tools 2.27.2 → 2.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,7 +3,7 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
- ## [2.27.2](https://github.com/UI5/webcomponents/compare/v2.27.1...v2.27.2) (2026-09-25)
6
+ # [2.28.0](https://github.com/UI5/webcomponents/compare/v2.28.0-rc.0...v2.28.0) (2026-10-21)
7
7
 
8
8
  **Note:** Version bump only for package @ui5/webcomponents-tools
9
9
 
@@ -11,7 +11,24 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
11
11
 
12
12
 
13
13
 
14
- ## [2.27.1](https://github.com/UI5/webcomponents/compare/v2.27.0...v2.27.1) (2026-09-23)
14
+ # [2.28.0-rc.0](https://github.com/UI5/webcomponents/compare/v2.27.1-rc.0...v2.28.0-rc.0) (2026-10-15)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **tools:** enable CEM generation for tsx files ([#14114](https://github.com/UI5/webcomponents/issues/14114)) ([cc73f73](https://github.com/UI5/webcomponents/commit/cc73f736d010fb8494e5ee56f31749e3a9baedc7)), closes [#11145](https://github.com/UI5/webcomponents/issues/11145)
20
+ * **tools:** use correct CSS_VARIABLES_TARGET in auto theme selector ([#14136](https://github.com/UI5/webcomponents/issues/14136)) ([06e6369](https://github.com/UI5/webcomponents/commit/06e6369d1f2fa42b97c02b64a37167d3db42f881))
21
+
22
+
23
+ ### Features
24
+
25
+ * **base:** allow components to provide their own slot accessor ([#14128](https://github.com/UI5/webcomponents/issues/14128)) ([2196121](https://github.com/UI5/webcomponents/commit/2196121936bfef06989b4b04be7d07ece415ea9c))
26
+
27
+
28
+
29
+
30
+
31
+ ## [2.27.1-rc.0](https://github.com/UI5/webcomponents/compare/v2.27.0...v2.27.1-rc.0) (2026-09-25)
15
32
 
16
33
  **Note:** Version bump only for package @ui5/webcomponents-tools
17
34
 
@@ -6,6 +6,7 @@ import {
6
6
  getDeprecatedStatus,
7
7
  getExperimentalStatus,
8
8
  getSinceStatus,
9
+ getSubcomponentStatus,
9
10
  getPrivacyStatus,
10
11
  getReference,
11
12
  validateJSDocComment,
@@ -105,9 +106,10 @@ function processClass(ts, classNode, moduleDoc) {
105
106
  currClass.kind = "class";
106
107
  currClass.deprecated = getDeprecatedStatus(classParsedJsDoc);
107
108
  currClass._ui5experimental = getExperimentalStatus(classParsedJsDoc);
108
- currClass._ui5since = getSinceStatus(classParsedJsDoc);
109
+ currClass._ui5since = getSinceStatus(classParsedJsDoc, moduleDoc.path);
109
110
  currClass._ui5privacy = getPrivacyStatus(classParsedJsDoc);
110
111
  currClass._ui5abstract = hasTag(classParsedJsDoc, "abstract") ? true : undefined;
112
+ currClass._ui5subcomponents = getSubcomponentStatus(classParsedJsDoc);
111
113
  currClass.description = normalizeDescription(classParsedJsDoc.description || findTag(classParsedJsDoc, "class")?.description);
112
114
  currClass._ui5implements = findAllTags(classParsedJsDoc, "implements")
113
115
  .map(tag => {
@@ -210,7 +212,7 @@ function processClass(ts, classNode, moduleDoc) {
210
212
 
211
213
  const memberParsedJsDoc = parse(classNodeMemberJSdoc?.getText())[0];
212
214
 
213
- member._ui5since = getSinceStatus(memberParsedJsDoc);
215
+ member._ui5since = getSinceStatus(memberParsedJsDoc, moduleDoc.path);
214
216
  member.deprecated === "true" && (member.deprecated = true)
215
217
 
216
218
  // Slots with accessors are treated like fields by the tool, so we have to convert them into slots.
@@ -248,6 +250,7 @@ function processClass(ts, classNode, moduleDoc) {
248
250
  delete member.type;
249
251
  delete member.privacy;
250
252
  delete slot.kind;
253
+ delete slot.readonly;
251
254
 
252
255
  currClass.slots.push(slot);
253
256
  i--;
@@ -386,7 +389,7 @@ function processInterface(ts, interfaceNode, moduleDoc) {
386
389
  description: normalizeDescription(interfaceParsedJsDoc?.description),
387
390
  _ui5experimental: getExperimentalStatus(interfaceParsedJsDoc),
388
391
  _ui5privacy: getPrivacyStatus(interfaceParsedJsDoc),
389
- _ui5since: getSinceStatus(interfaceParsedJsDoc),
392
+ _ui5since: getSinceStatus(interfaceParsedJsDoc, moduleDoc.path),
390
393
  deprecated: getDeprecatedStatus(interfaceParsedJsDoc),
391
394
  });
392
395
  }
@@ -407,7 +410,7 @@ function processEnum(ts, enumNode, moduleDoc) {
407
410
  description: normalizeDescription(enumJSdoc?.comment),
408
411
  _ui5experimental: getExperimentalStatus(enumParsedJsDoc),
409
412
  _ui5privacy: getPrivacyStatus(enumParsedJsDoc),
410
- _ui5since: getSinceStatus(enumParsedJsDoc),
413
+ _ui5since: getSinceStatus(enumParsedJsDoc, moduleDoc.path),
411
414
  deprecated: getDeprecatedStatus(enumParsedJsDoc) || undefined,
412
415
  members: (enumNode?.members || []).map(member => {
413
416
  const memberJSdoc = member?.jsDoc?.[0];
@@ -422,7 +425,7 @@ function processEnum(ts, enumNode, moduleDoc) {
422
425
  kind: "field",
423
426
  static: true,
424
427
  privacy: getPrivacyStatus(memberParsedJsDoc),
425
- _ui5since: getSinceStatus(memberParsedJsDoc),
428
+ _ui5since: getSinceStatus(memberParsedJsDoc, moduleDoc.path),
426
429
  description: memberJSdoc?.comment,
427
430
  default: member.initializer?.text,
428
431
  deprecated: getDeprecatedStatus(memberParsedJsDoc),
@@ -447,7 +450,7 @@ const processPublicAPI = object => {
447
450
  if (((key === "privacy" && object[key] !== "public") || (key === "_ui5privacy" && object[key] !== "public")) && !object.customElement) {
448
451
  return true;
449
452
  } else if (typeof object[key] === "object") {
450
- if (key === "cssParts" || key === "cssStates" || key === "attributes" || key === "_ui5implements") {
453
+ if (key === "cssParts" || key === "cssStates" || key === "attributes" || key === "_ui5implements" || key === "_ui5subcomponents") {
451
454
  continue;
452
455
  }
453
456
 
@@ -472,7 +475,7 @@ let typeChecker;
472
475
  let typeProgram;
473
476
 
474
477
  export default {
475
- globs: ["src/!(*generated)/*.ts", "src/!(*bundle)*.ts"],
478
+ globs: ["src/!(*generated)/*.{ts,tsx}", "src/!(*bundle)*.{ts,tsx}"],
476
479
  outdir: 'dist',
477
480
  overrideModuleCreation: ({ ts, globs }) => {
478
481
  typeProgram = ts.createProgram(globs, {
@@ -518,7 +521,7 @@ export default {
518
521
  }
519
522
  },
520
523
  moduleLinkPhase({ moduleDoc }) {
521
- moduleDoc.path = moduleDoc.path?.replace(/^src/, "dist").replace(/\.ts$/, ".js");
524
+ moduleDoc.path = moduleDoc.path?.replace(/^src/, "dist").replace(/\.tsx?$/, ".js");
522
525
 
523
526
  moduleDoc.exports = moduleDoc.exports.
524
527
  filter(e => !(e.kind === "custom-element-definition" && !moduleDoc.declarations?.find(d => d.name === e.name)?.tagName))
@@ -527,7 +530,7 @@ export default {
527
530
  const classNode = moduleDoc.declarations.find(c => c.name === e.declaration.name);
528
531
 
529
532
  if (e.declaration && e.declaration.module) {
530
- e.declaration.module = e.declaration.module.replace(/^src/, "dist").replace(/\.ts$/, ".js");
533
+ e.declaration.module = e.declaration.module.replace(/^src/, "dist").replace(/\.tsx?$/, ".js");
531
534
  }
532
535
 
533
536
  if (classNode?.customElement && classNode.tagName && e.kind !== "custom-element-definition") {
@@ -591,6 +594,25 @@ export default {
591
594
  })
592
595
  });
593
596
 
597
+ const publicCustomElements = new Set(
598
+ customElementsManifest.modules.flatMap(m => m.declarations)
599
+ .filter(d => d.customElement && d.tagName && d._ui5privacy === "public")
600
+ .map(d => d.name)
601
+ );
602
+
603
+ customElementsManifest.modules.forEach(moduleDoc => {
604
+ moduleDoc.declarations.forEach(declaration => {
605
+ declaration._ui5subcomponents?.forEach(name => {
606
+ if (!publicCustomElements.has(name)) {
607
+ logDocumentationError(
608
+ moduleDoc.path?.replace(/^dist/, "src").replace(/\.js$/, ".ts"),
609
+ `@ui5subcomponent '${name}' is not a public custom element with a tag name in this package.`
610
+ );
611
+ }
612
+ });
613
+ });
614
+ });
615
+
594
616
  if (devMode) {
595
617
  displayDocumentationErrors();
596
618
  }
@@ -523,6 +523,13 @@
523
523
  "_ui5abstract": {
524
524
  "type": "boolean"
525
525
  },
526
+ "_ui5subcomponents": {
527
+ "description": "Names of sub-components that belong to this component's documentation group",
528
+ "items": {
529
+ "type": "string"
530
+ },
531
+ "type": "array"
532
+ },
526
533
  "_ui5privacy": {
527
534
  "$ref": "#/definitions/Privacy"
528
535
  },
@@ -224,6 +224,10 @@ export interface CustomElementDeclaration
224
224
  * Marks when the field was introduced
225
225
  */
226
226
  _ui5since?: string
227
+ /**
228
+ * Names of sub-components that belong to this component's documentation group
229
+ */
230
+ _ui5subcomponents?: string[]
227
231
  }
228
232
 
229
233
  /**
package/lib/cem/utils.mjs CHANGED
@@ -67,13 +67,27 @@ const getTypeRefs = (ts, node, member) => {
67
67
  return typeRefs?.length ? typeRefs : undefined;
68
68
  };
69
69
 
70
- const getSinceStatus = (jsdocComment) => {
70
+ const sinceVersionRegExp = /^\d+\.\d+\.\d+$/;
71
+
72
+ const getSinceStatus = (jsdocComment, modulePath) => {
71
73
  const sinceTag = findTag(jsdocComment, "since");
72
- return sinceTag
73
- ? sinceTag.description
74
- ? `${sinceTag.name} ${sinceTag.description}`
75
- : sinceTag.name
76
- : undefined;
74
+ if (!sinceTag) return undefined;
75
+
76
+ const value = sinceTag.description
77
+ ? `${sinceTag.name} ${sinceTag.description}`
78
+ : sinceTag.name;
79
+
80
+ if (modulePath && !sinceVersionRegExp.test(value)) {
81
+ logDocumentationError(modulePath, `@since value "${value}" must follow the pattern number.number.number (e.g. 2.0.0)`);
82
+ }
83
+
84
+ return value;
85
+ };
86
+
87
+ const getSubcomponentStatus = (jsdocComment) => {
88
+ const tags = findAllTags(jsdocComment, "ui5subcomponent");
89
+ if (!tags.length) return undefined;
90
+ return tags.map(tag => tag.name).filter(Boolean);
77
91
  };
78
92
 
79
93
  const getPrivacyStatus = (jsdocComment) => {
@@ -148,7 +162,7 @@ const findImportPath = (ts, sourceFile, typeName, modulePath) => {
148
162
 
149
163
  if (isLocalDeclared) {
150
164
  return (
151
- modulePath?.replace("src", "dist")?.replace(".ts", ".js") || undefined
165
+ modulePath?.replace("src", "dist")?.replace(/\.tsx?$/, ".js") || undefined
152
166
  );
153
167
  } else {
154
168
  const importStatements = sourceFile.statements?.filter(
@@ -167,7 +181,7 @@ const findImportPath = (ts, sourceFile, typeName, modulePath) => {
167
181
  if (currentModuleSpecifier?.text?.startsWith(".")) {
168
182
  return (
169
183
  path.join(path.dirname(modulePath), currentModuleSpecifier.text)
170
- ?.replace("src", "dist")?.replace(".ts", ".js") || undefined
184
+ ?.replace("src", "dist")?.replace(/\.tsx?$/, ".js") || undefined
171
185
  );
172
186
  } else {
173
187
  let packageName = currentModuleSpecifier?.text?.replace(packageRegex, "") || undefined;
@@ -240,7 +254,7 @@ const allowedTags = {
240
254
  event: [...commonTags, "param", "native", "allowPreventDefault"],
241
255
  eventParam: [...commonTags],
242
256
  method: [...commonTags, "param", "returns", "override"],
243
- class: [...commonTags, "constructor", "class", "abstract", "experimental", "implements", "extends", "slot", "csspart", "cssstate", "cssState"],
257
+ class: [...commonTags, "constructor", "class", "abstract", "experimental", "implements", "extends", "slot", "csspart", "cssstate", "cssState", "ui5subcomponent"],
244
258
  enum: [...commonTags, "experimental",],
245
259
  enumMember: [...commonTags, "experimental",],
246
260
  interface: [...commonTags, "experimental",],
@@ -335,6 +349,8 @@ const validateJSDocTag = (tag) => {
335
349
  return !tag.type && tag.name && tag.description;
336
350
  case "since":
337
351
  return !tag.type && tag.name;
352
+ case "ui5subcomponent":
353
+ return !tag.type && tag.name && !tag.description;
338
354
  case "returns":
339
355
  return !tag.type && tag.name;
340
356
  case "default":
@@ -411,6 +427,7 @@ const formatSlotTypes = (typeText) => {
411
427
  export {
412
428
  getPrivacyStatus,
413
429
  getSinceStatus,
430
+ getSubcomponentStatus,
414
431
  getDeprecatedStatus,
415
432
  getExperimentalStatus,
416
433
  getType,
@@ -5,7 +5,6 @@ import * as path from "path";
5
5
  import { writeFile, mkdir } from "fs/promises";
6
6
  import postcss from "postcss";
7
7
  import combineDuplicatedSelectors from "../postcss-combine-duplicated-selectors/index.js"
8
- import postcssPlugin from "./postcss-plugin.mjs";
9
8
  import { writeFileIfChanged, getFileContent } from "./shared.mjs";
10
9
  import scopeVariables from "./scope-variables.mjs";
11
10
  import { mergeLightDark } from "./merge-light-dark.mjs";
@@ -75,8 +74,7 @@ const generate = async (argv) => {
75
74
  const processComponentPackageFile = async (f) => {
76
75
  if (CSS_VARIABLES_TARGET) {
77
76
  const result = await postcss([
78
- combineDuplicatedSelectors,
79
- postcssPlugin
77
+ combineDuplicatedSelectors
80
78
  ]).process(f.text, { from: undefined });
81
79
 
82
80
  return { css: result.css };
@@ -92,8 +90,8 @@ const generate = async (argv) => {
92
90
 
93
91
  /**
94
92
  * Generate auto theme CSS by merging light and dark theme outputs.
95
- * For the theming package (:root), adds color-scheme and toggle variables.
96
- * For component packages (:host), just merges the variables.
93
+ * For the theming package adds color-scheme.
94
+ * For component packages merges the variables.
97
95
  */
98
96
  const generateAutoThemes = async (processedCSS) => {
99
97
  for (const { light, dark, auto } of AUTO_THEME_PAIRS) {
@@ -104,13 +102,12 @@ const generate = async (argv) => {
104
102
 
105
103
  const darkPath = lightPath.replace(lightPattern, (match) => match.replace(light, dark));
106
104
  const darkCSS = processedCSS.get(darkPath);
107
- if (!darkCSS) continue;
105
+ if (darkCSS === undefined) continue;
108
106
 
109
- const selector = isThemingPackage(lightPath) ? ":root" : ":host";
110
- let autoCSS = mergeLightDark(lightCSS, darkCSS, selector);
107
+ let autoCSS = mergeLightDark(lightCSS, darkCSS);
111
108
 
112
109
  // For the theming package, add color-scheme and toggle setup
113
- if (selector === ":root") {
110
+ if (isThemingPackage(lightPath)) {
114
111
  autoCSS = addAutoThemeSetup(autoCSS);
115
112
  }
116
113
 
@@ -163,51 +160,17 @@ const generate = async (argv) => {
163
160
  await esbuild.build(config);
164
161
  }
165
162
  }
166
-
167
163
  /**
168
- * Adds auto theme setup CSS: color-scheme declaration and
169
- * light/dark toggle variables with @media switching.
170
- *
171
- * In light mode (default):
172
- * --_ui5-light-scheme is guaranteed-invalid → var() fallbacks resolve to light values
173
- * --_ui5-dark-scheme is valid empty → var() fallbacks are NOT used
174
- *
175
- * In dark mode (@media prefers-color-scheme: dark):
176
- * --_ui5-light-scheme is valid empty → var() fallbacks are NOT used
177
- * --_ui5-dark-scheme is guaranteed-invalid → var() fallbacks resolve to dark values
178
- *
164
+ * Adds auto theme setup CSS: color-scheme declaration.
179
165
  * @param {string} mergedCSS - The merged :root CSS from mergeLightDark()
180
166
  * @returns {string} CSS with toggle setup and @media rule appended
181
167
  */
182
168
  const addAutoThemeSetup = (mergedCSS) => {
183
169
  const root = postcss.parse(mergedCSS);
184
170
 
185
- // Add color-scheme and toggle defaults to the :root rule
186
171
  root.walkRules(":root", rule => {
187
- rule.prepend(
188
- postcss.decl({ prop: "--_ui5-dark-scheme", value: " " }),
189
- );
190
- rule.prepend(
191
- postcss.decl({ prop: "--_ui5-light-scheme", value: "var(--_ui5-f2d95f8)" }),
192
- );
193
- rule.prepend(
194
- postcss.decl({ prop: "color-scheme", value: "light dark" }),
195
- );
196
- });
197
-
198
- // Add @media rule for dark mode - flips the toggles
199
- const mediaRule = postcss.atRule({
200
- name: "media",
201
- params: "(prefers-color-scheme: dark)",
172
+ rule.prepend(postcss.decl({ prop: "color-scheme", value: "light dark" }));
202
173
  });
203
- const darkRoot = postcss.rule({ selector: ":root" });
204
- darkRoot.append(
205
- postcss.decl({ prop: "--_ui5-light-scheme", value: " " }),
206
- postcss.decl({ prop: "--_ui5-dark-scheme", value: "var(--_ui5-f2d95f8)" }),
207
- postcss.decl({ prop: "color-scheme", value: "dark" }),
208
- );
209
- mediaRule.append(darkRoot);
210
- root.append(mediaRule);
211
174
 
212
175
  return root.toString();
213
176
  };
@@ -1,91 +1,156 @@
1
1
  import postcss from "postcss";
2
2
 
3
3
  /**
4
- * Merges two CSS theme files (light and dark) into a single file using
5
- * CSS light-dark() for color values and space toggles for non-color values.
4
+ * Merges two CSS theme files (light and dark) into a single auto-theme file.
6
5
  *
7
- * The space toggle pattern uses --_ui5-dark-scheme / --_ui5-light-scheme,
8
- * following the same approach as the compact/cozy density toggles.
6
+ * Walks the entire CSS tree of both files without targeting a specific selector.
7
+ * Every rule found (under any at-rule context) is merged by (atRuleContext, selector):
8
+ * - identical values → base selector block
9
+ * - differing colors → light-dark() in base selector block
10
+ * - differing other → split into @media (prefers-color-scheme: light/dark) blocks
9
11
  *
10
- * @param {string} lightCSS - The light theme CSS string
11
- * @param {string} darkCSS - The dark theme CSS string
12
- * @param {string} selector - The selector to match (":root" for theming package, ":host" for component packages)
13
- * @returns {string} Merged CSS string with light-dark() and space toggle values
12
+ * @param {string} lightCSS
13
+ * @param {string} darkCSS
14
+ * @returns {string} Merged CSS string
14
15
  */
15
- const mergeLightDark = (lightCSS, darkCSS, selector = ":root") => {
16
- const lightRoot = postcss.parse(lightCSS);
17
- const darkRoot = postcss.parse(darkCSS);
18
-
16
+ const mergeLightDark = (lightCSS, darkCSS) => {
19
17
  // Extract all custom property declarations from each theme
20
- const lightVars = extractVars(lightRoot, selector);
21
- const darkVars = extractVars(darkRoot, selector);
18
+ const lightGroups = extractVarGroups(postcss.parse(lightCSS));
19
+ const darkGroups = extractVarGroups(postcss.parse(darkCSS));
20
+
21
+ const allContexts = new Set([...lightGroups.keys(), ...darkGroups.keys()]);
22
+ const root = postcss.root();
23
+
24
+ for (const context of allContexts) {
25
+ const lightSelectors = lightGroups.get(context) ?? new Map();
26
+ const darkSelectors = darkGroups.get(context) ?? new Map();
27
+
28
+ const allSelectors = new Set([...lightSelectors.keys(), ...darkSelectors.keys()]);
29
+
30
+ const sharedNodes = [];
31
+ const lightNodes = [];
32
+ const darkNodes = [];
33
+
34
+ for (const selector of allSelectors) {
35
+ const lightVars = lightSelectors.get(selector) ?? new Map();
36
+ const darkVars = darkSelectors.get(selector) ?? new Map();
22
37
 
23
- // Build merged declarations
24
- const allProps = new Set([...lightVars.keys(), ...darkVars.keys()]);
25
- const mergedRule = postcss.rule({ selector });
38
+ const allProps = new Set([...lightVars.keys(), ...darkVars.keys()]);
26
39
 
27
- for (const prop of allProps) {
28
- const lightVal = lightVars.get(prop);
29
- const darkVal = darkVars.get(prop);
40
+ const sharedRule = postcss.rule({ selector });
41
+ const lightRule = postcss.rule({ selector });
42
+ const darkRule = postcss.rule({ selector });
30
43
 
31
- if (prop.startsWith("--sapThemeMetaData")) {
32
- // Keep light version only for metadata
33
- if (lightVal !== undefined) {
34
- mergedRule.append(postcss.decl({ prop, value: lightVal }));
44
+ for (const prop of allProps) {
45
+ const lightVal = lightVars.get(prop);
46
+ const darkVal = darkVars.get(prop);
47
+
48
+ if (prop.startsWith("--sapThemeMetaData")) {
49
+ if (lightVal !== undefined) {
50
+ sharedRule.append(postcss.decl({ prop, value: lightVal }));
51
+ }
52
+ continue;
53
+ }
54
+
55
+ if (lightVal !== undefined && darkVal !== undefined) {
56
+ if (lightVal === darkVal) {
57
+ // Identical values - output once
58
+ sharedRule.append(postcss.decl({ prop, value: lightVal }));
59
+ } else if (isColorValue(lightVal) && isColorValue(darkVal)) {
60
+ // Different color values - use light-dark()
61
+ sharedRule.append(postcss.decl({ prop, value: `light-dark(${lightVal}, ${darkVal})` }));
62
+ } else {
63
+ // Different non-color values - use space toggle
64
+ lightRule.append(postcss.decl({ prop, value: lightVal }));
65
+ darkRule.append(postcss.decl({ prop, value: darkVal }));
66
+ }
67
+ } else if (lightVal !== undefined) {
68
+ // Only in light theme - use space toggle with guaranteed-invalid for dark
69
+ lightRule.append(postcss.decl({ prop, value: lightVal }));
70
+ } else {
71
+ // Only in dark theme - use space toggle with guaranteed-invalid for light
72
+ darkRule.append(postcss.decl({ prop, value: darkVal }));
73
+ }
35
74
  }
36
- continue;
75
+
76
+ if (sharedRule.nodes.length > 0) sharedNodes.push(sharedRule);
77
+ if (lightRule.nodes.length > 0) lightNodes.push(lightRule);
78
+ if (darkRule.nodes.length > 0) darkNodes.push(darkRule);
37
79
  }
38
80
 
39
- if (lightVal !== undefined && darkVal !== undefined) {
40
- if (lightVal === darkVal) {
41
- // Identical values - output once
42
- mergedRule.append(postcss.decl({ prop, value: lightVal }));
43
- } else if (isColorValue(lightVal) && isColorValue(darkVal)) {
44
- // Different color values - use light-dark()
45
- mergedRule.append(postcss.decl({ prop, value: `light-dark(${lightVal}, ${darkVal})` }));
81
+ const appendGroup = (nodes, mediaParams) => {
82
+ if (nodes.length === 0) return;
83
+ const wrapped = context ? wrapInAtRule(context, nodes) : nodes;
84
+ if (mediaParams) {
85
+ const media = postcss.atRule({ name: "media", params: mediaParams });
86
+ if (Array.isArray(wrapped)) {
87
+ wrapped.forEach(n => media.append(n));
88
+ } else {
89
+ media.append(wrapped);
90
+ }
91
+ root.append(media);
46
92
  } else {
47
- // Different non-color values - use space toggle
48
- mergedRule.append(postcss.decl({
49
- prop,
50
- value: `var(--_ui5-light-scheme, ${lightVal}) var(--_ui5-dark-scheme, ${darkVal})`,
51
- }));
93
+ if (Array.isArray(wrapped)) {
94
+ wrapped.forEach(n => root.append(n));
95
+ } else {
96
+ root.append(wrapped);
97
+ }
52
98
  }
53
- } else if (lightVal !== undefined) {
54
- // Only in light theme - use space toggle with guaranteed-invalid for dark
55
- mergedRule.append(postcss.decl({
56
- prop,
57
- value: `var(--_ui5-light-scheme, ${lightVal}) var(--_ui5-dark-scheme, var(--_ui5-f2d95f8))`,
58
- }));
59
- } else {
60
- // Only in dark theme - use space toggle with guaranteed-invalid for light
61
- mergedRule.append(postcss.decl({
62
- prop,
63
- value: `var(--_ui5-dark-scheme, ${darkVal}) var(--_ui5-light-scheme, var(--_ui5-f2d95f8))`,
64
- }));
65
- }
99
+ };
100
+
101
+ appendGroup(sharedNodes, null);
102
+ appendGroup(lightNodes, "(prefers-color-scheme: light)");
103
+ appendGroup(darkNodes, "(prefers-color-scheme: dark)");
66
104
  }
67
105
 
68
- return mergedRule.toString();
106
+ return root.toString();
69
107
  };
70
108
 
71
109
  /**
72
- * Extract custom property declarations from a parsed CSS root for a given selector.
73
- * @param {postcss.Root} root
74
- * @param {string} selector
75
- * @returns {Map<string, string>}
110
+ * Wraps an array of rules inside a clone of an at-rule identified by its signature.
111
+ * The signature is "{name} {params}", e.g. "container style(--foo: bar)".
76
112
  */
77
- const extractVars = (root, selector) => {
78
- const vars = new Map();
79
- root.walkRules(rule => {
80
- if (rule.selector === selector) {
81
- rule.walkDecls(decl => {
82
- if (decl.prop.startsWith("--")) {
83
- vars.set(decl.prop, decl.value);
84
- }
85
- });
86
- }
113
+ const wrapInAtRule = (signature, rules) => {
114
+ const spaceIdx = signature.indexOf(" ");
115
+ const name = signature.slice(0, spaceIdx);
116
+ const params = signature.slice(spaceIdx + 1);
117
+ const atRule = postcss.atRule({ name, params });
118
+ rules.forEach(r => atRule.append(r));
119
+ return atRule;
120
+ };
121
+
122
+ /**
123
+ * Extract custom property declarations grouped by (atRuleContext, selector).
124
+ *
125
+ * Returns: Map<atRuleContext, Map<selector, Map<prop, value>>>
126
+ * atRuleContext — "" for top-level rules, "{name} {params}" for rules inside an at-rule
127
+ */
128
+ const extractVarGroups = (root) => {
129
+ const groups = new Map();
130
+
131
+ const addVar = (context, selector, prop, value) => {
132
+ if (!groups.has(context)) groups.set(context, new Map());
133
+ const bySelector = groups.get(context);
134
+ if (!bySelector.has(selector)) bySelector.set(selector, new Map());
135
+ bySelector.get(selector).set(prop, value);
136
+ };
137
+
138
+ root.walk(node => {
139
+ if (node.type !== "rule") return;
140
+
141
+ const parent = node.parent;
142
+ const context = (parent && parent.type === "atrule")
143
+ ? `${parent.name} ${parent.params}`
144
+ : "";
145
+
146
+ node.walkDecls(decl => {
147
+ if (decl.prop.startsWith("--")) {
148
+ addVar(context, node.selector, decl.prop, decl.value);
149
+ }
150
+ });
87
151
  });
88
- return vars;
152
+
153
+ return groups;
89
154
  };
90
155
 
91
156
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ui5/webcomponents-tools",
3
- "version": "2.27.2",
3
+ "version": "2.28.0",
4
4
  "description": "UI5 Web Components: webcomponents.tools",
5
5
  "author": "SAP SE (https://www.sap.com)",
6
6
  "license": "Apache-2.0",
@@ -82,5 +82,5 @@
82
82
  "esbuild": "^0.25.0",
83
83
  "yargs": "^17.5.1"
84
84
  },
85
- "gitHead": "5529f5dc2aa697c9152ca73f5b0c0211bd0b557e"
85
+ "gitHead": "8fdaadeb2c7cbf0eb3a42e6dbdbdca1f27bb6797"
86
86
  }
@@ -1,153 +0,0 @@
1
-
2
- import postcss from "postcss";
3
-
4
- const hostVariables = new Map();
5
-
6
- const SELECTOR = ":host";
7
-
8
- const saveVariable = (variable, value, density) => {
9
- if (!hostVariables.has(variable)) {
10
- hostVariables.set(variable, {});
11
- }
12
- hostVariables.get(variable)[density] = value;
13
- }
14
-
15
- /**
16
- * PostCSS plugin for handling CSS variables across density modes (cozy and compact).
17
- *
18
- * The plugin scans CSS for `:host` rules that define density-specific custom properties.
19
- * Variables declared in a root-level `:host` rule are treated as **cozy** values, while
20
- * variables declared inside `@container style(--ui5_content_density: compact)` `:host` rules
21
- * are treated as **compact** values.
22
- *
23
- * All discovered variables are merged into a single root `:host` rule. For variables that
24
- * exist in both modes, the plugin generates a value that uses CSS variable fallbacks to
25
- * dynamically switch between compact and cozy values based on the active density.
26
- *
27
- * Variables that exist in only one mode are preserved, with appropriate fallbacks added
28
- * when needed.
29
- *
30
- * Example input:
31
- *
32
- * ```css
33
- * :host {
34
- * --my-variable: cozy-value;
35
- * --cozy-only-variable: cozy-only-value;
36
- * }
37
- *
38
- * @container style(--ui5_content_density: compact) {
39
- * :host {
40
- * --my-variable: compact-value;
41
- * --compact-only-variable: compact-only;
42
- * }
43
- * }
44
- * ```
45
- *
46
- * Output:
47
- *
48
- * ```css
49
- * :host {
50
- * --my-variable: var(--_ui5-compact-size, compact-value) var(--_ui5-cozy-size, cozy-value);
51
- * --compact-only-variable: var(--_ui5-compact-size, compact-only) var(--_ui5-cozy-size, initial);
52
- * --cozy-only-variable: cozy-only-value;
53
- * }
54
- * ```
55
- *
56
- * This enables seamless runtime switching between density modes using CSS variables alone.
57
- */
58
-
59
- export default function postcssPlugin() {
60
- return {
61
- postcssPlugin: 'postcss-content-density-variables',
62
- Once(root) {
63
- let hasRootHost = false;
64
- let hostRule;
65
-
66
- root.walkRules((rule) => {
67
- // Only process :host rules
68
- if (rule.selector !== SELECTOR) {
69
- return;
70
- }
71
-
72
- // Handle root-level :host rules (cozy)
73
- if (rule.parent.type === "root") {
74
- if (!hasRootHost) {
75
- hasRootHost = true;
76
- hostRule = rule;
77
- }
78
-
79
- rule.walkDecls((decl) => {
80
- if (decl.prop.startsWith('--')) {
81
- saveVariable(decl.prop, decl.value, 'cozy');
82
- decl.remove();
83
- }
84
- });
85
- }
86
-
87
- // Handle :host rules inside @container (compact)
88
- if (rule.parent.type === "atrule") {
89
- if (rule.parent.params.replaceAll("\s", "") === 'style(--ui5_content_density: compact)'.replaceAll("\s", "")) {
90
- rule.walkDecls((decl) => {
91
- if (decl.prop.startsWith('--')) {
92
- saveVariable(decl.prop, decl.value, 'compact');
93
- decl.remove();
94
- }
95
- });
96
- }
97
- }
98
-
99
- let current = rule;
100
- // Remove up empty rules
101
- while (current) {
102
- if (current.nodes.length === 0) {
103
- const parent = current.parent;
104
- current.remove();
105
- current = parent;
106
- }
107
-
108
- if (current.type === "root") {
109
- break;
110
- }
111
- }
112
- });
113
-
114
- if (!hasRootHost) {
115
- hostRule = postcss.rule({ selector: SELECTOR });
116
- }
117
-
118
- // Construct merged variable declarations depending on available modes
119
- for (const [variable, variableData] of hostVariables) {
120
- if (variableData.cozy && variableData.compact) {
121
- hostRule.append({
122
- prop: variable,
123
- value: `var(--_ui5-compact-size, ${variableData.compact}) var(--_ui5-cozy-size, ${variableData.cozy})`,
124
- });
125
- } else if (variableData.compact) {
126
-
127
- // Use a non-existent variable to always trigger the fallback.
128
- // Because this variable is never defined, the fallback is used
129
- // in all cases.
130
-
131
- // Using `initial` as a fallback can work only for properties
132
- // that treat it as an invalid value. To avoid this inconsistency,
133
- // we intentionally reference an undefined variable, which is
134
- // always considered invalid and undefined.
135
- hostRule.append({
136
- prop: variable,
137
- value: `var(--_ui5-compact-size, ${variableData.compact}) var(--_ui5-cozy-size, var(--_ui5-f2d95f8))`,
138
- });
139
- } else {
140
- hostRule.append({
141
- prop: variable,
142
- value: variableData.cozy,
143
- });
144
- }
145
- }
146
-
147
- root.prepend(hostRule);
148
- hostVariables.clear();
149
- }
150
- }
151
- }
152
-
153
- postcssPlugin.postcss = true;