@vaadin/vaadin-themable-mixin 24.8.0-alpha9 → 25.0.0-alpha1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaadin/vaadin-themable-mixin",
3
- "version": "24.8.0-alpha9",
3
+ "version": "25.0.0-alpha1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -20,6 +20,7 @@
20
20
  "module": "vaadin-themable-mixin.js",
21
21
  "type": "module",
22
22
  "files": [
23
+ "src",
23
24
  "*.d.ts",
24
25
  "register-styles.js",
25
26
  "vaadin-*.js"
@@ -27,19 +28,19 @@
27
28
  "keywords": [
28
29
  "Vaadin",
29
30
  "web-components",
30
- "web-component",
31
- "polymer"
31
+ "web-component"
32
32
  ],
33
33
  "dependencies": {
34
34
  "@open-wc/dedupe-mixin": "^1.3.0",
35
- "lit": "^3.0.0"
35
+ "lit": "^3.0.0",
36
+ "style-observer": "^0.0.8"
36
37
  },
37
38
  "devDependencies": {
38
39
  "@polymer/polymer": "^3.0.0",
39
- "@vaadin/chai-plugins": "24.8.0-alpha9",
40
- "@vaadin/test-runner-commands": "24.8.0-alpha9",
40
+ "@vaadin/chai-plugins": "25.0.0-alpha1",
41
+ "@vaadin/test-runner-commands": "25.0.0-alpha1",
41
42
  "@vaadin/testing-helpers": "^1.1.0",
42
43
  "sinon": "^18.0.0"
43
44
  },
44
- "gitHead": "4de3809275ddfd733b0d13fd02af8faf73eb6770"
45
+ "gitHead": "b8c22a4a0c64156210d0daac96b43ae4e5526d49"
45
46
  }
@@ -0,0 +1,154 @@
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
+ }
@@ -0,0 +1,90 @@
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
+ }
@@ -0,0 +1,69 @@
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 { adoptStyles } from 'lit';
7
+
8
+ /**
9
+ * Returns the effective styles that should apply to the component
10
+ * in the correct order, to place injected stylesheet after styles
11
+ * defined with `static styles` and before any custom theme styles
12
+ * that the user provided using `registerStyles()` function.
13
+ *
14
+ * @param {HTMLElement} component
15
+ * @return {CSSStyleSheet[]}
16
+ */
17
+ function getEffectiveStyles(component) {
18
+ const componentClass = component.constructor;
19
+
20
+ const styleSheet = component.__cssInjectorStyleSheet;
21
+ if (styleSheet) {
22
+ return [...componentClass.baseStyles, styleSheet, ...componentClass.themeStyles];
23
+ }
24
+
25
+ return componentClass.elementStyles;
26
+ }
27
+
28
+ /**
29
+ * Apply styles on the instance of the component in the correct order.
30
+ *
31
+ * @param {HTMLElement} component
32
+ */
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
+ adoptStyles(component.shadowRoot, getEffectiveStyles(component));
39
+ }
40
+
41
+ /**
42
+ * Injects the given stylesheet into the shadow root of the component
43
+ * through the adoptedStyleSheets API. This will override any styles
44
+ * that were injected previously since we only expect one stylesheet
45
+ * to be injected into each component.
46
+ *
47
+ * @param {HTMLElement} component
48
+ * @param {CSSStyleSheet} styleSheet
49
+ */
50
+ export function injectStyleSheet(component, styleSheet) {
51
+ // Store the new stylesheet so that it can be removed later.
52
+ component.__cssInjectorStyleSheet = styleSheet;
53
+ applyInstanceStyles(component);
54
+ }
55
+
56
+ /**
57
+ * Removes the stylesheet from the component's shadow root that was added
58
+ * by the `injectStyleSheet` function.
59
+ *
60
+ * @param {HTMLElement} component
61
+ */
62
+ export function cleanupStyleSheet(component) {
63
+ const adoptedStyleSheets = component.shadowRoot.adoptedStyleSheets.filter(
64
+ (s) => s !== component.__cssInjectorStyleSheet,
65
+ );
66
+
67
+ component.shadowRoot.adoptedStyleSheets = adoptedStyleSheets;
68
+ component.__cssInjectorStyleSheet = undefined;
69
+ }
@@ -3,7 +3,8 @@
3
3
  * Copyright (c) 2017 - 2025 Vaadin Ltd.
4
4
  * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
5
  */
6
- import { adoptStyles, css, CSSResult, LitElement, unsafeCSS } from 'lit';
6
+ import { css, CSSResult, LitElement, unsafeCSS } from 'lit';
7
+ import { applyInstanceStyles } from './src/css-utils.js';
7
8
  import { ThemePropertyMixin } from './vaadin-theme-property-mixin.js';
8
9
 
9
10
  export { css, unsafeCSS };
@@ -115,13 +116,7 @@ function updateInstanceStyles(instance) {
115
116
 
116
117
  if (instance instanceof LitElement) {
117
118
  // LitElement
118
-
119
- // The adoptStyles function may fall back to appending style elements to shadow root.
120
- // Remove them first to avoid duplicates.
121
- [...instance.shadowRoot.querySelectorAll('style')].forEach((style) => style.remove());
122
-
123
- // Adopt the updated styles
124
- adoptStyles(instance.shadowRoot, componentClass.elementStyles);
119
+ applyInstanceStyles(instance);
125
120
  } else {
126
121
  // PolymerElement
127
122
 
@@ -203,16 +198,12 @@ function hasMatchingStyle(componentClass, styles) {
203
198
  export function registerStyles(themeFor, styles, options = {}) {
204
199
  styles = flattenStyles(styles);
205
200
 
206
- if (window.Vaadin && window.Vaadin.styleModules) {
207
- window.Vaadin.styleModules.registerStyles(themeFor, styles, options);
208
- } else {
209
- themeRegistry.push({
210
- themeFor,
211
- styles,
212
- include: options.include,
213
- moduleId: options.moduleId,
214
- });
215
- }
201
+ themeRegistry.push({
202
+ themeFor,
203
+ styles,
204
+ include: options.include,
205
+ moduleId: options.moduleId,
206
+ });
216
207
 
217
208
  if (themeFor) {
218
209
  // Update styles of the component types that match themeFor and have already been finalized
@@ -362,11 +353,16 @@ export const ThemableMixin = (superClass) =>
362
353
  * @protected
363
354
  */
364
355
  static finalizeStyles(styles) {
365
- // The "styles" object originates from the "static get styles()" function of
366
- // a LitElement based component. The theme styles are added after it
367
- // so that they can override the component styles.
368
- const themeStyles = this.getStylesForThis();
369
- return styles ? [...[styles].flat(Infinity), ...themeStyles] : themeStyles;
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 `CSSInjector`.
358
+ this.baseStyles = styles ? [styles].flat(Infinity) : [];
359
+
360
+ // Preserve the theme styles the user supplied via the `registerStyles()` API
361
+ // so that they will always be injected after styles added by `CSSInjector`.
362
+ this.themeStyles = this.getStylesForThis();
363
+
364
+ // Merged styles are stored in `elementStyles` and passed to `adoptStyles()`.
365
+ return [...this.baseStyles, ...this.themeStyles];
370
366
  }
371
367
 
372
368
  /**