@vaadin/vaadin-themable-mixin 25.0.0-alpha1 → 25.0.0-alpha11
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/lumo-injection-mixin.js +94 -0
- package/package.json +7 -7
- package/src/css-property-observer.js +80 -0
- package/src/css-utils.js +11 -19
- package/src/lumo-injector.js +172 -0
- package/src/lumo-modules.js +137 -0
- package/vaadin-themable-mixin.js +2 -2
- package/src/css-injector.js +0 -154
- package/src/css-rules.js +0 -90
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
import { LumoInjector } from './src/lumo-injector.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @type {Set<string>}
|
|
10
|
+
*/
|
|
11
|
+
const registeredProperties = new Set();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Find enclosing root for given element to gather style rules from.
|
|
15
|
+
*
|
|
16
|
+
* @param {HTMLElement} element
|
|
17
|
+
* @return {DocumentOrShadowRoot}
|
|
18
|
+
*/
|
|
19
|
+
function findRoot(element) {
|
|
20
|
+
const root = element.getRootNode();
|
|
21
|
+
|
|
22
|
+
if (root.host && root.host.constructor.version) {
|
|
23
|
+
return findRoot(root.host);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return root;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Mixin for internal use only. Do not use it in custom components.
|
|
31
|
+
*
|
|
32
|
+
* @polymerMixin
|
|
33
|
+
*/
|
|
34
|
+
export const LumoInjectionMixin = (superClass) =>
|
|
35
|
+
class LumoInjectionMixinClass extends superClass {
|
|
36
|
+
static finalize() {
|
|
37
|
+
super.finalize();
|
|
38
|
+
|
|
39
|
+
const propName = this.lumoInjectPropName;
|
|
40
|
+
|
|
41
|
+
// Prevent registering same property twice when a class extends
|
|
42
|
+
// another class using this mixin, since `finalize()` is called
|
|
43
|
+
// by LitElement for all superclasses in the prototype chain.
|
|
44
|
+
if (this.is && !registeredProperties.has(propName)) {
|
|
45
|
+
registeredProperties.add(propName);
|
|
46
|
+
|
|
47
|
+
// Initialize custom property for this class with 0 as default
|
|
48
|
+
// so that changing it to 1 would inject styles to instances
|
|
49
|
+
// Use `inherits: true` so that property defined on `<html>`
|
|
50
|
+
// would apply to components instances within shadow roots
|
|
51
|
+
CSS.registerProperty({
|
|
52
|
+
name: propName,
|
|
53
|
+
syntax: '<number>',
|
|
54
|
+
inherits: true,
|
|
55
|
+
initialValue: '0',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static get lumoInjectPropName() {
|
|
61
|
+
return `--${this.is}-lumo-inject`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static get lumoInjector() {
|
|
65
|
+
return {
|
|
66
|
+
includeBaseStyles: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @protected */
|
|
71
|
+
connectedCallback() {
|
|
72
|
+
super.connectedCallback();
|
|
73
|
+
|
|
74
|
+
if (this.isConnected) {
|
|
75
|
+
const root = findRoot(this);
|
|
76
|
+
root.__lumoInjector ||= new LumoInjector(root);
|
|
77
|
+
this.__lumoInjector = root.__lumoInjector;
|
|
78
|
+
this.__lumoInjector.componentConnected(this);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @protected */
|
|
83
|
+
disconnectedCallback() {
|
|
84
|
+
super.disconnectedCallback();
|
|
85
|
+
|
|
86
|
+
// Check if LumoInjector is defined. It might be unavailable if the component
|
|
87
|
+
// is moved within the DOM during connectedCallback and becomes disconnected
|
|
88
|
+
// before LumoInjector is assigned.
|
|
89
|
+
if (this.__lumoInjector) {
|
|
90
|
+
this.__lumoInjector.componentDisconnected(this);
|
|
91
|
+
this.__lumoInjector = undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
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-alpha11",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"src",
|
|
24
24
|
"*.d.ts",
|
|
25
|
+
"lumo-injection-mixin.js",
|
|
25
26
|
"register-styles.js",
|
|
26
27
|
"vaadin-*.js"
|
|
27
28
|
],
|
|
@@ -32,15 +33,14 @@
|
|
|
32
33
|
],
|
|
33
34
|
"dependencies": {
|
|
34
35
|
"@open-wc/dedupe-mixin": "^1.3.0",
|
|
35
|
-
"lit": "^3.0.0"
|
|
36
|
-
"style-observer": "^0.0.8"
|
|
36
|
+
"lit": "^3.0.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@polymer/polymer": "^3.0.0",
|
|
40
|
-
"@vaadin/chai-plugins": "25.0.0-
|
|
41
|
-
"@vaadin/test-runner-commands": "25.0.0-
|
|
42
|
-
"@vaadin/testing-helpers": "^
|
|
40
|
+
"@vaadin/chai-plugins": "25.0.0-alpha11",
|
|
41
|
+
"@vaadin/test-runner-commands": "25.0.0-alpha11",
|
|
42
|
+
"@vaadin/testing-helpers": "^2.0.0",
|
|
43
43
|
"sinon": "^18.0.0"
|
|
44
44
|
},
|
|
45
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "abfd315ba5a7484a613e0768635a4e8fe945a44b"
|
|
46
46
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
#styleSheet;
|
|
18
|
+
#isConnected = false;
|
|
19
|
+
|
|
20
|
+
constructor(root, name, callback) {
|
|
21
|
+
this.#root = root;
|
|
22
|
+
this.#name = name;
|
|
23
|
+
this.#callback = callback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#handleTransitionEvent(event) {
|
|
27
|
+
const { propertyName } = event;
|
|
28
|
+
if (this.#properties.has(propertyName)) {
|
|
29
|
+
this.#callback(propertyName);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
observe(property) {
|
|
34
|
+
this.connect();
|
|
35
|
+
|
|
36
|
+
this.#properties.add(property);
|
|
37
|
+
this.#rootHost.style.setProperty(`--${this.#name}-props`, [...this.#properties].join(', '));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
connect() {
|
|
41
|
+
if (this.#isConnected) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
this.#styleSheet = new CSSStyleSheet();
|
|
46
|
+
this.#styleSheet.replaceSync(`
|
|
47
|
+
:is(:root, :host)::before {
|
|
48
|
+
content: '' !important;
|
|
49
|
+
position: absolute !important;
|
|
50
|
+
top: -9999px !important;
|
|
51
|
+
left: -9999px !important;
|
|
52
|
+
visibility: hidden !important;
|
|
53
|
+
transition: 1ms allow-discrete step-end !important;
|
|
54
|
+
transition-property: var(--${this.#name}-props) !important;
|
|
55
|
+
}
|
|
56
|
+
`);
|
|
57
|
+
this.#root.adoptedStyleSheets.unshift(this.#styleSheet);
|
|
58
|
+
|
|
59
|
+
this.#rootHost.addEventListener('transitionstart', (event) => this.#handleTransitionEvent(event));
|
|
60
|
+
this.#rootHost.addEventListener('transitionend', (event) => this.#handleTransitionEvent(event));
|
|
61
|
+
|
|
62
|
+
this.#isConnected = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
disconnect() {
|
|
66
|
+
this.#properties.clear();
|
|
67
|
+
|
|
68
|
+
this.#root.adoptedStyleSheets = this.#root.adoptedStyleSheets.filter((s) => s !== this.#styleSheet);
|
|
69
|
+
|
|
70
|
+
this.#rootHost.removeEventListener('transitionstart', this.#handleTransitionEvent);
|
|
71
|
+
this.#rootHost.removeEventListener('transitionend', this.#handleTransitionEvent);
|
|
72
|
+
this.#rootHost.style.removeProperty(`--${this.#name}-props`);
|
|
73
|
+
|
|
74
|
+
this.#isConnected = false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get #rootHost() {
|
|
78
|
+
return this.#root.documentElement ?? this.#root.host;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/css-utils.js
CHANGED
|
@@ -15,14 +15,14 @@ import { adoptStyles } from 'lit';
|
|
|
15
15
|
* @return {CSSStyleSheet[]}
|
|
16
16
|
*/
|
|
17
17
|
function getEffectiveStyles(component) {
|
|
18
|
-
const
|
|
18
|
+
const { baseStyles, themeStyles, elementStyles, lumoInjector } = component.constructor;
|
|
19
|
+
const lumoStyleSheet = component.__lumoStyleSheet;
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return [...componentClass.baseStyles, styleSheet, ...componentClass.themeStyles];
|
|
21
|
+
if (lumoStyleSheet && (baseStyles || themeStyles)) {
|
|
22
|
+
return [...(lumoInjector.includeBaseStyles ? baseStyles : []), lumoStyleSheet, ...themeStyles];
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
return
|
|
25
|
+
return [lumoStyleSheet, ...elementStyles].filter(Boolean);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -31,10 +31,6 @@ function getEffectiveStyles(component) {
|
|
|
31
31
|
* @param {HTMLElement} component
|
|
32
32
|
*/
|
|
33
33
|
export function applyInstanceStyles(component) {
|
|
34
|
-
// The adoptStyles function may fall back to appending style elements to shadow root.
|
|
35
|
-
// Remove them first to avoid duplicates.
|
|
36
|
-
[...component.shadowRoot.querySelectorAll('style')].forEach((style) => style.remove());
|
|
37
|
-
|
|
38
34
|
adoptStyles(component.shadowRoot, getEffectiveStyles(component));
|
|
39
35
|
}
|
|
40
36
|
|
|
@@ -47,23 +43,19 @@ export function applyInstanceStyles(component) {
|
|
|
47
43
|
* @param {HTMLElement} component
|
|
48
44
|
* @param {CSSStyleSheet} styleSheet
|
|
49
45
|
*/
|
|
50
|
-
export function
|
|
46
|
+
export function injectLumoStyleSheet(component, styleSheet) {
|
|
51
47
|
// Store the new stylesheet so that it can be removed later.
|
|
52
|
-
component.
|
|
48
|
+
component.__lumoStyleSheet = styleSheet;
|
|
53
49
|
applyInstanceStyles(component);
|
|
54
50
|
}
|
|
55
51
|
|
|
56
52
|
/**
|
|
57
53
|
* Removes the stylesheet from the component's shadow root that was added
|
|
58
|
-
* by the `
|
|
54
|
+
* by the `injectLumoStyleSheet` function.
|
|
59
55
|
*
|
|
60
56
|
* @param {HTMLElement} component
|
|
61
57
|
*/
|
|
62
|
-
export function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
component.shadowRoot.adoptedStyleSheets = adoptedStyleSheets;
|
|
68
|
-
component.__cssInjectorStyleSheet = undefined;
|
|
58
|
+
export function removeLumoStyleSheet(component) {
|
|
59
|
+
component.__lumoStyleSheet = undefined;
|
|
60
|
+
applyInstanceStyles(component);
|
|
69
61
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
import { CSSPropertyObserver } from './css-property-observer.js';
|
|
7
|
+
import { injectLumoStyleSheet, removeLumoStyleSheet } from './css-utils.js';
|
|
8
|
+
import { parseStyleSheets } from './lumo-modules.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Implements auto-injection of CSS styles from document style sheets
|
|
12
|
+
* into the Shadow DOM of corresponding Vaadin components.
|
|
13
|
+
*
|
|
14
|
+
* Styles to be injected are defined as reusable modules using the
|
|
15
|
+
* following syntax, based on media queries and custom properties:
|
|
16
|
+
*
|
|
17
|
+
* ```css
|
|
18
|
+
* \@media lumo_base-field {
|
|
19
|
+
* #label {
|
|
20
|
+
* color: gray;
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* \@media lumo_text-field {
|
|
25
|
+
* #input {
|
|
26
|
+
* color: yellow;
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* \@media lumo_email-field {
|
|
31
|
+
* #input {
|
|
32
|
+
* color: green;
|
|
33
|
+
* }
|
|
34
|
+
* }
|
|
35
|
+
*
|
|
36
|
+
* html {
|
|
37
|
+
* --vaadin-text-field-lumo-inject: 1;
|
|
38
|
+
* --vaadin-text-field-lumo-inject-modules:
|
|
39
|
+
* lumo_base-field,
|
|
40
|
+
* lumo_text-field;
|
|
41
|
+
*
|
|
42
|
+
* --vaadin-email-field-lumo-inject: 1;
|
|
43
|
+
* --vaadin-email-field-lumo-inject-modules:
|
|
44
|
+
* lumo_base-field,
|
|
45
|
+
* lumo_email-field;
|
|
46
|
+
* }
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* The class observes the custom property `--{tagName}-lumo-inject`,
|
|
50
|
+
* which indicates whether styles are present for the given component
|
|
51
|
+
* in the document style sheets. When the property is set to `1`, the
|
|
52
|
+
* class recursively searches all document style sheets for CSS modules
|
|
53
|
+
* listed in the `--{tagName}-lumo-inject-modules` property that apply to
|
|
54
|
+
* the given component tag name. The found rules are then injected
|
|
55
|
+
* into the component's Shadow DOM using the `adoptedStyleSheets` API,
|
|
56
|
+
* in the order specified in the `--{tagName}-lumo-inject-modules` property.
|
|
57
|
+
* The same module can be used in multiple components.
|
|
58
|
+
*
|
|
59
|
+
* The class also removes the injected styles when the property is set to `0`.
|
|
60
|
+
*
|
|
61
|
+
* If a root element is provided, the class will additionally search for
|
|
62
|
+
* CSS modules in the root element's style sheets. This is useful for
|
|
63
|
+
* embedded Flow applications that are fully isolated from the main document
|
|
64
|
+
* and load styles into a component’s Shadow DOM instead of the main document.
|
|
65
|
+
*
|
|
66
|
+
* WARNING: For internal use only. Do not use this class in custom components.
|
|
67
|
+
*
|
|
68
|
+
* @private
|
|
69
|
+
*/
|
|
70
|
+
export class LumoInjector {
|
|
71
|
+
/** @type {Document | ShadowRoot} */
|
|
72
|
+
#root;
|
|
73
|
+
|
|
74
|
+
/** @type {CSSPropertyObserver} */
|
|
75
|
+
#cssPropertyObserver;
|
|
76
|
+
|
|
77
|
+
/** @type {Map<string, CSSStyleSheet>} */
|
|
78
|
+
#styleSheetsByTag = new Map();
|
|
79
|
+
|
|
80
|
+
/** @type {Map<string, Set<HTMLElement>>} */
|
|
81
|
+
#componentsByTag = new Map();
|
|
82
|
+
|
|
83
|
+
constructor(root = document) {
|
|
84
|
+
this.#root = root;
|
|
85
|
+
this.#cssPropertyObserver = new CSSPropertyObserver(this.#root, 'vaadin-lumo-injector', (propertyName) => {
|
|
86
|
+
const tagName = propertyName.slice(2).replace('-lumo-inject', '');
|
|
87
|
+
this.#updateStyleSheet(tagName);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
disconnect() {
|
|
92
|
+
this.#cssPropertyObserver.disconnect();
|
|
93
|
+
this.#styleSheetsByTag.clear();
|
|
94
|
+
this.#componentsByTag.values().forEach((components) => components.forEach(removeLumoStyleSheet));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Adds a component to the list of elements monitored for style injection.
|
|
99
|
+
* If the styles have already been detected, they are injected into the
|
|
100
|
+
* component's shadow DOM immediately. Otherwise, the class watches the
|
|
101
|
+
* custom property `--{tagName}-lumo-inject` to trigger injection when
|
|
102
|
+
* the styles are added to the document or root element.
|
|
103
|
+
*
|
|
104
|
+
* @param {HTMLElement} component
|
|
105
|
+
*/
|
|
106
|
+
componentConnected(component) {
|
|
107
|
+
const { is: tagName, lumoInjectPropName } = component.constructor;
|
|
108
|
+
|
|
109
|
+
this.#componentsByTag.set(tagName, this.#componentsByTag.get(tagName) ?? new Set());
|
|
110
|
+
this.#componentsByTag.get(tagName).add(component);
|
|
111
|
+
|
|
112
|
+
const stylesheet = this.#styleSheetsByTag.get(tagName);
|
|
113
|
+
if (stylesheet) {
|
|
114
|
+
if (stylesheet.cssRules.length > 0) {
|
|
115
|
+
injectLumoStyleSheet(component, stylesheet);
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
this.#initStyleSheet(tagName);
|
|
121
|
+
this.#cssPropertyObserver.observe(lumoInjectPropName);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Removes the component from the list of elements monitored for
|
|
126
|
+
* style injection and cleans up any previously injected styles.
|
|
127
|
+
*
|
|
128
|
+
* @param {HTMLElement} component
|
|
129
|
+
*/
|
|
130
|
+
componentDisconnected(component) {
|
|
131
|
+
const { is: tagName } = component.constructor;
|
|
132
|
+
this.#componentsByTag.get(tagName)?.delete(component);
|
|
133
|
+
|
|
134
|
+
removeLumoStyleSheet(component);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#initStyleSheet(tagName) {
|
|
138
|
+
this.#styleSheetsByTag.set(tagName, new CSSStyleSheet());
|
|
139
|
+
this.#updateStyleSheet(tagName);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#updateStyleSheet(tagName) {
|
|
143
|
+
const { tags, modules } = parseStyleSheets(this.#rootStyleSheets);
|
|
144
|
+
|
|
145
|
+
const cssText = (tags.get(tagName) ?? [])
|
|
146
|
+
.flatMap((moduleName) => modules.get(moduleName) ?? [])
|
|
147
|
+
.map((rule) => rule.cssText)
|
|
148
|
+
.join('\n');
|
|
149
|
+
|
|
150
|
+
const stylesheet = this.#styleSheetsByTag.get(tagName);
|
|
151
|
+
stylesheet.replaceSync(cssText);
|
|
152
|
+
|
|
153
|
+
this.#componentsByTag.get(tagName)?.forEach((component) => {
|
|
154
|
+
if (cssText) {
|
|
155
|
+
injectLumoStyleSheet(component, stylesheet);
|
|
156
|
+
} else {
|
|
157
|
+
removeLumoStyleSheet(component);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
get #rootStyleSheets() {
|
|
163
|
+
let styleSheets = new Set();
|
|
164
|
+
|
|
165
|
+
for (const root of [this.#root, document]) {
|
|
166
|
+
styleSheets = styleSheets.union(new Set(root.styleSheets));
|
|
167
|
+
styleSheets = styleSheets.union(new Set(root.adoptedStyleSheets));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return [...styleSheets];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright (c) 2000 - 2025 Vaadin Ltd.
|
|
4
|
+
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
|
|
5
|
+
*/
|
|
6
|
+
/** @type {WeakMap<CSSStyleSheet, Record<string, Map>>} */
|
|
7
|
+
const cache = new WeakMap();
|
|
8
|
+
|
|
9
|
+
function parseStyleSheet(
|
|
10
|
+
styleSheet,
|
|
11
|
+
result = {
|
|
12
|
+
tags: new Map(),
|
|
13
|
+
modules: new Map(),
|
|
14
|
+
},
|
|
15
|
+
) {
|
|
16
|
+
let cssRules;
|
|
17
|
+
try {
|
|
18
|
+
cssRules = styleSheet.cssRules;
|
|
19
|
+
} catch {
|
|
20
|
+
// External stylesheets may not be accessible due to CORS security restrictions.
|
|
21
|
+
cssRules = [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
for (const rule of cssRules) {
|
|
25
|
+
const { media } = rule;
|
|
26
|
+
|
|
27
|
+
if (rule instanceof CSSImportRule) {
|
|
28
|
+
if (media?.mediaText.startsWith('lumo_')) {
|
|
29
|
+
result.modules.set(media.mediaText, [...rule.styleSheet.cssRules]);
|
|
30
|
+
} else {
|
|
31
|
+
parseStyleSheet(rule.styleSheet, result);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (rule instanceof CSSMediaRule) {
|
|
38
|
+
if (media?.mediaText.startsWith('lumo_')) {
|
|
39
|
+
result.modules.set(media.mediaText, [...rule.cssRules]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (rule instanceof CSSStyleRule && rule.cssText.includes('-lumo-inject')) {
|
|
46
|
+
for (const property of rule.style) {
|
|
47
|
+
const tagName = property.match(/^--(.*)-lumo-inject-modules$/u)?.[1];
|
|
48
|
+
if (!tagName) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const value = rule.style.getPropertyValue(property);
|
|
53
|
+
|
|
54
|
+
result.tags.set(
|
|
55
|
+
tagName,
|
|
56
|
+
value.split(',').map((module) => module.trim().replace(/'|"/gu, '')),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parses the provided CSSStyleSheet objects and returns an object with
|
|
69
|
+
* tag-to-modules mappings and module rules.
|
|
70
|
+
*
|
|
71
|
+
* Modules are defined using CSS media rules with names starting with `lumo_`:
|
|
72
|
+
*
|
|
73
|
+
* ```css
|
|
74
|
+
* \@media lumo_base-field {
|
|
75
|
+
* #label {
|
|
76
|
+
* color: gray;
|
|
77
|
+
* }
|
|
78
|
+
* }
|
|
79
|
+
*
|
|
80
|
+
* \@media lumo_text-field {
|
|
81
|
+
* #input {
|
|
82
|
+
* color: yellow;
|
|
83
|
+
* }
|
|
84
|
+
* }
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* Also, an entire CSS import can be defined as a module:
|
|
88
|
+
*
|
|
89
|
+
* ```css
|
|
90
|
+
* \@import 'lumo-base-field.css' lumo_base-field;
|
|
91
|
+
* ```
|
|
92
|
+
*
|
|
93
|
+
* Tag-to-modules mappings are defined as CSS custom properties that list
|
|
94
|
+
* the module names to be applied to specific tags, e.g. `vaadin-text-field`:
|
|
95
|
+
*
|
|
96
|
+
* ```css
|
|
97
|
+
* html {
|
|
98
|
+
* --vaadin-text-field-lumo-inject-modules:
|
|
99
|
+
* lumo_base-field,
|
|
100
|
+
* lumo_text-field;
|
|
101
|
+
* }
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* Example output:
|
|
105
|
+
*
|
|
106
|
+
* ```js
|
|
107
|
+
* {
|
|
108
|
+
* tags: Map {
|
|
109
|
+
* 'vaadin-text-field': ['lumo_base-field', 'lumo_text-field']
|
|
110
|
+
* },
|
|
111
|
+
* modules: Map {
|
|
112
|
+
* 'lumo_base-field': [CSSStyleRule],
|
|
113
|
+
* 'lumo_text-field': [CSSStyleRule]
|
|
114
|
+
* }
|
|
115
|
+
* }
|
|
116
|
+
* ```
|
|
117
|
+
*
|
|
118
|
+
* @param {CSSStyleSheet[]} styleSheets - An array of CSSStyleSheet objects to parse.
|
|
119
|
+
* @return {{tags: Map<string, string[]>, modules: Map<string, CSSRule[]>}}
|
|
120
|
+
*/
|
|
121
|
+
export function parseStyleSheets(styleSheets) {
|
|
122
|
+
let tags = new Map();
|
|
123
|
+
let modules = new Map();
|
|
124
|
+
|
|
125
|
+
for (const styleSheet of styleSheets) {
|
|
126
|
+
let result = cache.get(styleSheet);
|
|
127
|
+
if (!result) {
|
|
128
|
+
result = parseStyleSheet(styleSheet);
|
|
129
|
+
cache.set(styleSheet, result);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
tags = new Map([...tags, ...result.tags]);
|
|
133
|
+
modules = new Map([...modules, ...result.modules]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { tags, modules };
|
|
137
|
+
}
|
package/vaadin-themable-mixin.js
CHANGED
|
@@ -354,11 +354,11 @@ export const ThemableMixin = (superClass) =>
|
|
|
354
354
|
*/
|
|
355
355
|
static finalizeStyles(styles) {
|
|
356
356
|
// Preserve the styles the user supplied via the `static get styles()` getter
|
|
357
|
-
// so that they will always be injected before styles added by `
|
|
357
|
+
// so that they will always be injected before styles added by `LumoInjector`.
|
|
358
358
|
this.baseStyles = styles ? [styles].flat(Infinity) : [];
|
|
359
359
|
|
|
360
360
|
// Preserve the theme styles the user supplied via the `registerStyles()` API
|
|
361
|
-
// so that they will always be injected after styles added by `
|
|
361
|
+
// so that they will always be injected after styles added by `LumoInjector`.
|
|
362
362
|
this.themeStyles = this.getStylesForThis();
|
|
363
363
|
|
|
364
364
|
// Merged styles are stored in `elementStyles` and passed to `adoptStyles()`.
|
package/src/css-injector.js
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
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
|
-
/* eslint-disable es/no-optional-chaining */
|
|
8
|
-
import StyleObserver from 'style-observer';
|
|
9
|
-
import { extractTagScopedCSSRules } from './css-rules.js';
|
|
10
|
-
import { cleanupStyleSheet, injectStyleSheet } from './css-utils.js';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Implements auto-injection of component-scoped CSS styles from document
|
|
14
|
-
* style sheets into the Shadow DOM of the corresponding Vaadin components.
|
|
15
|
-
*
|
|
16
|
-
* Styles are scoped to a component using the following syntax:
|
|
17
|
-
*
|
|
18
|
-
* 1. `@media vaadin-text-field { ... }` - a media query with a tag name
|
|
19
|
-
* 2. `@import "styles.css" vaadin-text-field` - an import rule with a tag name
|
|
20
|
-
*
|
|
21
|
-
* The class observes the custom property `--{tagName}-css-inject`,
|
|
22
|
-
* which indicates the presence of styles for the given component in
|
|
23
|
-
* the document style sheets. When the property is set to `1`, the class
|
|
24
|
-
* recursively searches all document style sheets for any CSS rules that
|
|
25
|
-
* are scoped to the given component tag name using the syntax described
|
|
26
|
-
* above. The found rules are then injected into the shadow DOM of all
|
|
27
|
-
* subscribed components through the adoptedStyleSheets API.
|
|
28
|
-
*
|
|
29
|
-
* The class also observes the custom property to remove the styles when
|
|
30
|
-
* the property is set to `0`.
|
|
31
|
-
*
|
|
32
|
-
* If a root element is provided, the class will additionally search for
|
|
33
|
-
* component-scoped styles in the root element's style sheets. This is
|
|
34
|
-
* useful for embedded Flow applications that are fully isolated from
|
|
35
|
-
* the main document and load styles into a component's shadow DOM
|
|
36
|
-
* rather than the main document.
|
|
37
|
-
*
|
|
38
|
-
* WARNING: For internal use only. Do not use this class in custom components.
|
|
39
|
-
*/
|
|
40
|
-
export class CSSInjector {
|
|
41
|
-
/** @type {Document | ShadowRoot} */
|
|
42
|
-
#root;
|
|
43
|
-
|
|
44
|
-
/** @type {Map<string, HTMLElement[]>} */
|
|
45
|
-
#componentsByTag = new Map();
|
|
46
|
-
|
|
47
|
-
/** @type {Map<string, CSSStyleSheet>} */
|
|
48
|
-
#styleSheetsByTag = new Map();
|
|
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
|
-
constructor(root = document) {
|
|
63
|
-
this.#root = root;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Adds a component to the list of elements monitored for component-scoped
|
|
68
|
-
* styles in global style sheets. If the styles have already been detected,
|
|
69
|
-
* they are injected into the component's shadow DOM immediately. Otherwise,
|
|
70
|
-
* the class watches the custom property `--{tagName}-css-inject` to trigger
|
|
71
|
-
* injection when the styles are added to the document or root element.
|
|
72
|
-
*
|
|
73
|
-
* @param {HTMLElement} component
|
|
74
|
-
*/
|
|
75
|
-
componentConnected(component) {
|
|
76
|
-
const { is: tagName, cssInjectPropName } = component.constructor;
|
|
77
|
-
|
|
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
|
-
}
|
|
89
|
-
|
|
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
|
-
}
|
|
96
|
-
|
|
97
|
-
// Observe custom property that would trigger injection for this class
|
|
98
|
-
this.#styleObserver.observe(this.#rootHost, cssInjectPropName);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Removes the component from the list of elements monitored for
|
|
103
|
-
* component-scoped styles and cleans up any previously injected
|
|
104
|
-
* styles from the component's shadow DOM.
|
|
105
|
-
*
|
|
106
|
-
* @param {HTMLElement} component
|
|
107
|
-
*/
|
|
108
|
-
componentDisconnected(component) {
|
|
109
|
-
const { is: tagName } = component.constructor;
|
|
110
|
-
|
|
111
|
-
cleanupStyleSheet(component);
|
|
112
|
-
|
|
113
|
-
this.#componentsByTag.get(tagName)?.delete(component);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
#componentStylesAdded(tagName) {
|
|
117
|
-
const stylesheet = this.#styleSheetsByTag.get(tagName) || new CSSStyleSheet();
|
|
118
|
-
|
|
119
|
-
const cssText = this.#extractComponentScopedCSSRules(tagName)
|
|
120
|
-
.map((rule) => rule.cssText)
|
|
121
|
-
.join('\n');
|
|
122
|
-
stylesheet.replaceSync(cssText);
|
|
123
|
-
|
|
124
|
-
this.#componentsByTag.get(tagName)?.forEach((component) => {
|
|
125
|
-
injectStyleSheet(component, stylesheet);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
this.#styleSheetsByTag.set(tagName, stylesheet);
|
|
129
|
-
}
|
|
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
|
-
}
|
package/src/css-rules.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
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
|
-
// Based on https://github.com/jouni/j-elements/blob/main/test/old-components/Stylable.js
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Check if the media query is a non-standard "tag scoped selector".
|
|
11
|
-
*
|
|
12
|
-
* Examples of such media queries:
|
|
13
|
-
* - `@media vaadin-text-field { ... }`
|
|
14
|
-
* - `@import "styles.css" vaadin-text-field`.
|
|
15
|
-
*
|
|
16
|
-
* @param {string} media
|
|
17
|
-
* @return {boolean}
|
|
18
|
-
*/
|
|
19
|
-
function isTagScopedMedia(media) {
|
|
20
|
-
return /^\w+(-\w+)+$/u.test(media);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
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.
|
|
36
|
-
*
|
|
37
|
-
* @param {CSSStyleSheet} styleSheet
|
|
38
|
-
* @param {string} tagName
|
|
39
|
-
*/
|
|
40
|
-
function extractStyleSheetTagScopedCSSRules(styleSheet, tagName) {
|
|
41
|
-
const matchingRules = [];
|
|
42
|
-
|
|
43
|
-
for (const rule of styleSheet.cssRules) {
|
|
44
|
-
const ruleType = rule.constructor.name;
|
|
45
|
-
|
|
46
|
-
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);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (ruleType === 'CSSMediaRule') {
|
|
58
|
-
if (matchesTagScopedMedia(rule.media.mediaText, tagName)) {
|
|
59
|
-
matchingRules.push(...rule.cssRules);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return matchingRules;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Recursively processes style sheets of the specified root element, including both
|
|
69
|
-
* `adoptedStyleSheets` and regular `styleSheets`, and returns all CSS rules from
|
|
70
|
-
* `@media` and `@import` blocks where the media query is (a) "tag scoped selector",
|
|
71
|
-
* and (b) matches the specified tag name.
|
|
72
|
-
*
|
|
73
|
-
* Examples of such media queries:
|
|
74
|
-
* - `@media vaadin-text-field { ... }`
|
|
75
|
-
* - `@import "styles.css" vaadin-text-field`
|
|
76
|
-
*
|
|
77
|
-
* The returned rules are ordered in the same way as they are in the original stylesheet.
|
|
78
|
-
*
|
|
79
|
-
* @param {DocumentOrShadowRoot} root
|
|
80
|
-
* @param {string} tagName
|
|
81
|
-
* @return {CSSRule[]}
|
|
82
|
-
*/
|
|
83
|
-
export function extractTagScopedCSSRules(root, tagName) {
|
|
84
|
-
const styleSheets = new Set([...root.styleSheets]);
|
|
85
|
-
const adoptedStyleSheets = new Set([...root.adoptedStyleSheets]);
|
|
86
|
-
|
|
87
|
-
return [...styleSheets.union(adoptedStyleSheets)].flatMap((styleSheet) => {
|
|
88
|
-
return extractStyleSheetTagScopedCSSRules(styleSheet, tagName);
|
|
89
|
-
});
|
|
90
|
-
}
|