@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.
- package/css-injection-mixin.js +13 -6
- package/package.json +5 -6
- package/src/css-injector.js +20 -72
- package/src/css-property-observer.js +56 -0
- package/src/css-rules.js +71 -28
- package/src/css-utils.js +3 -1
package/css-injection-mixin.js
CHANGED
|
@@ -65,17 +65,24 @@ export const CSSInjectionMixin = (superClass) =>
|
|
|
65
65
|
connectedCallback() {
|
|
66
66
|
super.connectedCallback();
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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-
|
|
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-
|
|
42
|
-
"@vaadin/test-runner-commands": "25.0.0-
|
|
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": "
|
|
45
|
+
"gitHead": "ce4421f0daf26027b863b91787a474e4cc264344"
|
|
47
46
|
}
|
package/src/css-injector.js
CHANGED
|
@@ -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 {
|
|
45
|
-
#
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
117
|
-
const
|
|
90
|
+
#updateComponentStyleSheet(tagName) {
|
|
91
|
+
const roots = new Set([document, this.#root]);
|
|
118
92
|
|
|
119
|
-
const cssText =
|
|
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
|
-
*
|
|
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 {
|
|
29
|
+
* @param {(rule: CSSRule) => boolean} predicate
|
|
30
|
+
* @return {Array<CSSMediaRule | CSSImportRule>}
|
|
39
31
|
*/
|
|
40
|
-
function
|
|
41
|
-
const
|
|
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 (
|
|
48
|
-
|
|
49
|
-
|
|
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 (
|
|
59
|
-
|
|
47
|
+
if (predicate(rule)) {
|
|
48
|
+
result.push(rule);
|
|
60
49
|
}
|
|
61
50
|
}
|
|
62
51
|
}
|
|
63
52
|
|
|
64
|
-
return
|
|
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(
|
|
85
|
-
const adoptedStyleSheets = new Set(
|
|
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
|
|
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
|
|
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;
|