@vaadin/vaadin-themable-mixin 25.0.0-alpha3 → 25.0.0-alpha4

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.
@@ -65,17 +65,24 @@ export const CSSInjectionMixin = (superClass) =>
65
65
  connectedCallback() {
66
66
  super.connectedCallback();
67
67
 
68
- const root = findRoot(this);
69
- root.__cssInjector ||= new CSSInjector(root);
70
- this.__cssInjector = root.__cssInjector;
71
- this.__cssInjector.componentConnected(this);
68
+ if (this.isConnected) {
69
+ const root = findRoot(this);
70
+ root.__cssInjector ||= new CSSInjector(root);
71
+ this.__cssInjector = root.__cssInjector;
72
+ this.__cssInjector.componentConnected(this);
73
+ }
72
74
  }
73
75
 
74
76
  /** @protected */
75
77
  disconnectedCallback() {
76
78
  super.disconnectedCallback();
77
79
 
78
- this.__cssInjector.componentDisconnected(this);
79
- this.__cssInjector = undefined;
80
+ // Check if CSSInjector is defined. It might be unavailable if the component
81
+ // is moved within the DOM during connectedCallback and becomes disconnected
82
+ // before CSSInjector is assigned.
83
+ if (this.__cssInjector) {
84
+ this.__cssInjector.componentDisconnected(this);
85
+ this.__cssInjector = undefined;
86
+ }
80
87
  }
81
88
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaadin/vaadin-themable-mixin",
3
- "version": "25.0.0-alpha3",
3
+ "version": "25.0.0-alpha4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -33,15 +33,14 @@
33
33
  ],
34
34
  "dependencies": {
35
35
  "@open-wc/dedupe-mixin": "^1.3.0",
36
- "lit": "^3.0.0",
37
- "style-observer": "^0.0.8"
36
+ "lit": "^3.0.0"
38
37
  },
39
38
  "devDependencies": {
40
39
  "@polymer/polymer": "^3.0.0",
41
- "@vaadin/chai-plugins": "25.0.0-alpha3",
42
- "@vaadin/test-runner-commands": "25.0.0-alpha3",
40
+ "@vaadin/chai-plugins": "25.0.0-alpha4",
41
+ "@vaadin/test-runner-commands": "25.0.0-alpha4",
43
42
  "@vaadin/testing-helpers": "^2.0.0",
44
43
  "sinon": "^18.0.0"
45
44
  },
46
- "gitHead": "8367dd20a47f53ca5589ad349a8e286ec2673055"
45
+ "gitHead": "ce4421f0daf26027b863b91787a474e4cc264344"
47
46
  }
@@ -3,9 +3,7 @@
3
3
  * Copyright (c) 2021 - 2025 Vaadin Ltd.
4
4
  * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
5
  */
6
-
7
- /* eslint-disable es/no-optional-chaining */
8
- import StyleObserver from 'style-observer';
6
+ import { CSSPropertyObserver } from './css-property-observer.js';
9
7
  import { extractTagScopedCSSRules } from './css-rules.js';
10
8
  import { cleanupStyleSheet, injectStyleSheet } from './css-utils.js';
11
9
 
@@ -36,31 +34,25 @@ import { cleanupStyleSheet, injectStyleSheet } from './css-utils.js';
36
34
  * rather than the main document.
37
35
  *
38
36
  * WARNING: For internal use only. Do not use this class in custom components.
37
+ *
38
+ * @private
39
39
  */
40
40
  export class CSSInjector {
41
41
  /** @type {Document | ShadowRoot} */
42
42
  #root;
43
43
 
44
- /** @type {Map<string, HTMLElement[]>} */
45
- #componentsByTag = new Map();
44
+ /** @type {CSSPropertyObserver} */
45
+ #cssPropertyObserver;
46
46
 
47
47
  /** @type {Map<string, CSSStyleSheet>} */
48
48
  #styleSheetsByTag = new Map();
49
49
 
50
- #styleObserver = new StyleObserver((records) => {
51
- records.forEach((record) => {
52
- const { property, value, oldValue } = record;
53
- const tagName = property.slice(2).replace('-css-inject', '');
54
- if (value === '1') {
55
- this.#componentStylesAdded(tagName);
56
- } else if (oldValue === '1') {
57
- this.#componentStylesRemoved(tagName);
58
- }
59
- });
60
- });
61
-
62
50
  constructor(root = document) {
63
51
  this.#root = root;
52
+ this.#cssPropertyObserver = new CSSPropertyObserver(this.#root, 'vaadin-css-injector', (propertyName) => {
53
+ const tagName = propertyName.slice(2).replace('-css-inject', '');
54
+ this.#updateComponentStyleSheet(tagName);
55
+ });
64
56
  }
65
57
 
66
58
  /**
@@ -75,27 +67,13 @@ export class CSSInjector {
75
67
  componentConnected(component) {
76
68
  const { is: tagName, cssInjectPropName } = component.constructor;
77
69
 
78
- if (this.#componentsByTag.has(tagName)) {
79
- this.#componentsByTag.get(tagName).add(component);
80
- } else {
81
- this.#componentsByTag.set(tagName, new Set([component]));
82
- }
83
-
84
- const stylesheet = this.#styleSheetsByTag.get(tagName);
85
- if (stylesheet) {
86
- injectStyleSheet(component, stylesheet);
87
- return;
88
- }
70
+ const stylesheet = this.#styleSheetsByTag.get(tagName) ?? new CSSStyleSheet();
71
+ injectStyleSheet(component, stylesheet);
72
+ this.#styleSheetsByTag.set(tagName, stylesheet);
89
73
 
90
- // If styles for custom property are already loaded for this root,
91
- // store corresponding tag name so that we can inject styles
92
- const value = getComputedStyle(this.#rootHost).getPropertyValue(cssInjectPropName);
93
- if (value === '1') {
94
- this.#componentStylesAdded(tagName);
95
- }
74
+ this.#updateComponentStyleSheet(tagName);
96
75
 
97
- // Observe custom property that would trigger injection for this class
98
- this.#styleObserver.observe(this.#rootHost, cssInjectPropName);
76
+ this.#cssPropertyObserver.observe(cssInjectPropName);
99
77
  }
100
78
 
101
79
  /**
@@ -106,49 +84,19 @@ export class CSSInjector {
106
84
  * @param {HTMLElement} component
107
85
  */
108
86
  componentDisconnected(component) {
109
- const { is: tagName } = component.constructor;
110
-
111
87
  cleanupStyleSheet(component);
112
-
113
- this.#componentsByTag.get(tagName)?.delete(component);
114
88
  }
115
89
 
116
- #componentStylesAdded(tagName) {
117
- const stylesheet = this.#styleSheetsByTag.get(tagName) || new CSSStyleSheet();
90
+ #updateComponentStyleSheet(tagName) {
91
+ const roots = new Set([document, this.#root]);
118
92
 
119
- const cssText = this.#extractComponentScopedCSSRules(tagName)
93
+ const cssText = [...roots]
94
+ .flatMap((root) => extractTagScopedCSSRules(root, tagName))
120
95
  .map((rule) => rule.cssText)
121
96
  .join('\n');
122
- stylesheet.replaceSync(cssText);
123
-
124
- this.#componentsByTag.get(tagName)?.forEach((component) => {
125
- injectStyleSheet(component, stylesheet);
126
- });
127
97
 
98
+ const stylesheet = this.#styleSheetsByTag.get(tagName) ?? new CSSStyleSheet();
99
+ stylesheet.replaceSync(cssText);
128
100
  this.#styleSheetsByTag.set(tagName, stylesheet);
129
101
  }
130
-
131
- #componentStylesRemoved(tagName) {
132
- this.#componentsByTag.get(tagName)?.forEach((component) => {
133
- cleanupStyleSheet(component);
134
- });
135
-
136
- this.#styleSheetsByTag.delete(tagName);
137
- }
138
-
139
- #extractComponentScopedCSSRules(tagName) {
140
- // Global stylesheets
141
- const rules = extractTagScopedCSSRules(document, tagName);
142
-
143
- // Scoped stylesheets
144
- if (this.#root !== document) {
145
- rules.push(...extractTagScopedCSSRules(this.#root, tagName));
146
- }
147
-
148
- return rules;
149
- }
150
-
151
- get #rootHost() {
152
- return this.#root === document ? this.#root.documentElement : this.#root.host;
153
- }
154
102
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @license
3
+ * Copyright (c) 2021 - 2025 Vaadin Ltd.
4
+ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
+ */
6
+
7
+ /**
8
+ * WARNING: For internal use only. Do not use this class in custom components.
9
+ *
10
+ * @private
11
+ */
12
+ export class CSSPropertyObserver {
13
+ #root;
14
+ #name;
15
+ #callback;
16
+ #properties = new Set();
17
+
18
+ constructor(root, name, callback) {
19
+ this.#root = root;
20
+ this.#name = name;
21
+ this.#callback = callback;
22
+
23
+ const styleSheet = new CSSStyleSheet();
24
+ styleSheet.replaceSync(`
25
+ :is(:root, :host)::before {
26
+ content: '';
27
+ position: absolute;
28
+ top: -9999px;
29
+ left: -9999px;
30
+ visibility: hidden;
31
+ transition: 1ms allow-discrete step-end;
32
+ transition-property: var(--${this.#name}-props);
33
+ }
34
+ `);
35
+ this.#root.adoptedStyleSheets.unshift(styleSheet);
36
+
37
+ this.#rootHost.addEventListener('transitionstart', (event) => this.#handleTransitionEvent(event));
38
+ this.#rootHost.addEventListener('transitionend', (event) => this.#handleTransitionEvent(event));
39
+ }
40
+
41
+ #handleTransitionEvent(event) {
42
+ const { propertyName } = event;
43
+ if (this.#properties.has(propertyName)) {
44
+ this.#callback(propertyName);
45
+ }
46
+ }
47
+
48
+ observe(property) {
49
+ this.#properties.add(property);
50
+ this.#rootHost.style.setProperty(`--${this.#name}-props`, [...this.#properties].join(', '));
51
+ }
52
+
53
+ get #rootHost() {
54
+ return this.#root.documentElement ?? this.#root.host;
55
+ }
56
+ }
package/src/css-rules.js CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  // Based on https://github.com/jouni/j-elements/blob/main/test/old-components/Stylable.js
8
+ const mediaRulesCache = new WeakMap();
8
9
 
9
10
  /**
10
11
  * Check if the media query is a non-standard "tag scoped selector".
@@ -21,47 +22,89 @@ function isTagScopedMedia(media) {
21
22
  }
22
23
 
23
24
  /**
24
- * Check if the media query string matches the given tag name.
25
- *
26
- * @param {string} media
27
- * @param {string} tagName
28
- * @return {boolean}
29
- */
30
- function matchesTagScopedMedia(media, tagName) {
31
- return media === tagName;
32
- }
33
-
34
- /**
35
- * Recursively processes a style sheet for matching "tag scoped" rules.
25
+ * Recursively processes a style sheet for media rules that match
26
+ * the specified predicate.
36
27
  *
37
28
  * @param {CSSStyleSheet} styleSheet
38
- * @param {string} tagName
29
+ * @param {(rule: CSSRule) => boolean} predicate
30
+ * @return {Array<CSSMediaRule | CSSImportRule>}
39
31
  */
40
- function extractStyleSheetTagScopedCSSRules(styleSheet, tagName) {
41
- const matchingRules = [];
32
+ function extractMediaRulesFromStyleSheet(styleSheet, predicate) {
33
+ const result = [];
42
34
 
43
35
  for (const rule of styleSheet.cssRules) {
44
36
  const ruleType = rule.constructor.name;
45
37
 
46
38
  if (ruleType === 'CSSImportRule') {
47
- if (!isTagScopedMedia(rule.media.mediaText)) {
48
- matchingRules.push(...extractStyleSheetTagScopedCSSRules(rule.styleSheet, tagName));
49
- continue;
50
- }
51
-
52
- if (matchesTagScopedMedia(rule.media.mediaText, tagName)) {
53
- matchingRules.push(...rule.styleSheet.cssRules);
39
+ if (predicate(rule)) {
40
+ result.push(rule);
41
+ } else {
42
+ result.push(...extractMediaRulesFromStyleSheet(rule.styleSheet, predicate));
54
43
  }
55
44
  }
56
45
 
57
46
  if (ruleType === 'CSSMediaRule') {
58
- if (matchesTagScopedMedia(rule.media.mediaText, tagName)) {
59
- matchingRules.push(...rule.cssRules);
47
+ if (predicate(rule)) {
48
+ result.push(rule);
60
49
  }
61
50
  }
62
51
  }
63
52
 
64
- return matchingRules;
53
+ return result;
54
+ }
55
+
56
+ /**
57
+ * Deduplicates media rules by their CSS text, keeping the last occurrence.
58
+ *
59
+ * @param {Array<CSSMediaRule | CSSImportRule>} rules
60
+ * @return {Array<CSSMediaRule | CSSImportRule>}
61
+ */
62
+ function deduplicateMediaRules(rules) {
63
+ const seen = new Set();
64
+ return rules.reduceRight((deduped, rule) => {
65
+ const key = rule.styleSheet?.cssText ?? rule.cssText;
66
+ if (!seen.has(key)) {
67
+ seen.add(key);
68
+ deduped.unshift(rule);
69
+ }
70
+ return deduped;
71
+ }, []);
72
+ }
73
+
74
+ /**
75
+ * Extracts all CSS rules from a style sheet that are contained in media queries
76
+ * with a "tag scoped selector" matching the specified tag name.
77
+ *
78
+ * This function caches the results for each style sheet to avoid
79
+ * reprocessing the same style sheet multiple times.
80
+ *
81
+ * @param {CSSStyleSheet} styleSheet
82
+ * @param {string} tagName
83
+ * @return {CSSRule[]}
84
+ */
85
+ function extractTagScopedCSSRulesFromStyleSheet(styleSheet, tagName) {
86
+ let mediaRules = mediaRulesCache.get(styleSheet);
87
+ if (!mediaRules) {
88
+ // Collect all media rules that look like "tag scoped selectors", e.g. "@media vaadin-text-field { ... }"
89
+ mediaRules = extractMediaRulesFromStyleSheet(styleSheet, (rule) => isTagScopedMedia(rule.media.mediaText));
90
+
91
+ // Remove duplicate media rules which may result from multiple imports of the same stylesheet
92
+ mediaRules = deduplicateMediaRules(mediaRules);
93
+
94
+ // Group rules by tag name specified in the media query
95
+ mediaRules = mediaRules.reduce((acc, rule) => {
96
+ const rules = acc.get(rule.media.mediaText) ?? [];
97
+ rules.push(rule);
98
+ return acc.set(rule.media.mediaText, rules);
99
+ }, new Map());
100
+
101
+ // Save the processed media rules in the cache
102
+ mediaRulesCache.set(styleSheet, mediaRules);
103
+ }
104
+
105
+ return (mediaRules.get(tagName) ?? []).flatMap((mediaRule) =>
106
+ Array.from(mediaRule.styleSheet?.cssRules ?? mediaRule.cssRules),
107
+ );
65
108
  }
66
109
 
67
110
  /**
@@ -81,10 +124,10 @@ function extractStyleSheetTagScopedCSSRules(styleSheet, tagName) {
81
124
  * @return {CSSRule[]}
82
125
  */
83
126
  export function extractTagScopedCSSRules(root, tagName) {
84
- const styleSheets = new Set([...root.styleSheets]);
85
- const adoptedStyleSheets = new Set([...root.adoptedStyleSheets]);
127
+ const styleSheets = new Set(root.styleSheets);
128
+ const adoptedStyleSheets = new Set(root.adoptedStyleSheets);
86
129
 
87
130
  return [...styleSheets.union(adoptedStyleSheets)].flatMap((styleSheet) => {
88
- return extractStyleSheetTagScopedCSSRules(styleSheet, tagName);
131
+ return extractTagScopedCSSRulesFromStyleSheet(styleSheet, tagName);
89
132
  });
90
133
  }
package/src/css-utils.js CHANGED
@@ -19,7 +19,9 @@ function getEffectiveStyles(component) {
19
19
 
20
20
  const styleSheet = component.__cssInjectorStyleSheet;
21
21
  if (styleSheet) {
22
- return [...componentClass.baseStyles, styleSheet, ...componentClass.themeStyles];
22
+ return (componentClass.baseStyles ?? componentClass.themeStyles)
23
+ ? [...componentClass.baseStyles, styleSheet, ...componentClass.themeStyles]
24
+ : [styleSheet, ...componentClass.elementStyles];
23
25
  }
24
26
 
25
27
  return componentClass.elementStyles;