-
Notifications
You must be signed in to change notification settings - Fork 87
feat: implement initial version of CSSInjectionMixin #8934
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1cd3369
0736778
9e73fc3
81b5659
31f0505
06695f3
fe06cef
94ea87a
30cd347
25acba3
577bd94
5a360eb
fe77bf8
5c70b61
b1b6cca
5c0c099
6f7fd37
3900059
4a3746b
c062b51
165a18b
3198457
42397e7
9b4f91c
6e2527a
6b8fa35
a683788
3647593
84c17dc
983c7ce
f5037f3
d1b8df7
ceae501
b0a3285
5de67f2
b3a13f0
a2f917e
8fe712f
de2e674
960eb8a
e92d5b5
f4ab8aa
7d7c8c1
6a6e485
9095f69
3fcf97a
ff458ca
ce7a9bc
a5d9716
b9e5a3d
76f7feb
c5d3d76
fbbac5a
0a86240
b743f03
7c3eb65
2c34760
d1f7e90
ae46b23
7b4a0e6
2ab5923
0a26d88
7ff0491
d8b2daa
6fec8e8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
/** | ||
* @license | ||
* Copyright (c) 2021 - 2025 Vaadin Ltd. | ||
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ | ||
*/ | ||
import { CSSInjector } from './src/css-injector.js'; | ||
|
||
/** | ||
* @type {string[]} | ||
*/ | ||
const registeredProperties = new Set(); | ||
|
||
/** | ||
* Find enclosing root for given element to gather style rules from. | ||
* | ||
* @param {HTMLElement} element | ||
* @return {DocumentOrShadowRoot} | ||
*/ | ||
function findRoot(element) { | ||
const root = element.getRootNode(); | ||
|
||
if (root.host && root.host.constructor.cssInjectPropName) { | ||
return findRoot(root.host); | ||
} | ||
|
||
return root; | ||
} | ||
|
||
/** | ||
* Mixin for internal use only. Do not use it in custom components. | ||
* | ||
* @polymerMixin | ||
*/ | ||
export const CSSInjectionMixin = (superClass) => | ||
class CSSInjectionMixinClass extends superClass { | ||
static finalize() { | ||
super.finalize(); | ||
|
||
const propName = this.cssInjectPropName; | ||
|
||
// Prevent registering same property twice when a class extends | ||
// another class using this mixin, since `finalize()` is called | ||
// by LitElement for all superclasses in the prototype chain. | ||
if (this.is && !registeredProperties.has(propName)) { | ||
registeredProperties.add(propName); | ||
|
||
// Initialize custom property for this class with 0 as default | ||
// so that changing it to 1 would inject styles to instances | ||
// Use `inherits: true` so that property defined on `<html>` | ||
// would apply to components instances within shadow roots | ||
CSS.registerProperty({ | ||
name: propName, | ||
syntax: '<number>', | ||
inherits: true, | ||
initialValue: '0', | ||
}); | ||
} | ||
} | ||
|
||
static get cssInjectPropName() { | ||
return `--${this.is}-css-inject`; | ||
} | ||
|
||
/** @protected */ | ||
connectedCallback() { | ||
super.connectedCallback(); | ||
|
||
const root = findRoot(this); | ||
root.__cssInjector ||= new CSSInjector(root); | ||
this.__cssInjector = root.__cssInjector; | ||
this.__cssInjector.componentConnected(this); | ||
} | ||
|
||
/** @protected */ | ||
disconnectedCallback() { | ||
super.disconnectedCallback(); | ||
|
||
this.__cssInjector.componentDisconnected(this); | ||
this.__cssInjector = undefined; | ||
} | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
/** | ||
* @license | ||
* Copyright (c) 2021 - 2025 Vaadin Ltd. | ||
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ | ||
*/ | ||
|
||
/* eslint-disable es/no-optional-chaining */ | ||
import StyleObserver from 'style-observer'; | ||
import { extractTagScopedCSSRules } from './css-rules.js'; | ||
import { cleanupStyleSheet, injectStyleSheet } from './css-utils.js'; | ||
|
||
/** | ||
* Implements auto-injection of component-scoped CSS styles from document | ||
* style sheets into the Shadow DOM of the corresponding Vaadin components. | ||
* | ||
* Styles are scoped to a component using the following syntax: | ||
* | ||
* 1. `@media vaadin-text-field { ... }` - a media query with a tag name | ||
* 2. `@import "styles.css" vaadin-text-field` - an import rule with a tag name | ||
* | ||
* The class observes the custom property `--{tagName}-css-inject`, | ||
* which indicates the presence of styles for the given component in | ||
* the document style sheets. When the property is set to `1`, the class | ||
* recursively searches all document style sheets for any CSS rules that | ||
* are scoped to the given component tag name using the syntax described | ||
* above. The found rules are then injected into the shadow DOM of all | ||
* subscribed components through the adoptedStyleSheets API. | ||
* | ||
* The class also observes the custom property to remove the styles when | ||
* the property is set to `0`. | ||
* | ||
* If a root element is provided, the class will additionally search for | ||
* component-scoped styles in the root element's style sheets. This is | ||
* useful for embedded Flow applications that are fully isolated from | ||
* the main document and load styles into a component's shadow DOM | ||
* rather than the main document. | ||
* | ||
* WARNING: For internal use only. Do not use this class in custom components. | ||
*/ | ||
export class CSSInjector { | ||
/** @type {Document | ShadowRoot} */ | ||
#root; | ||
|
||
/** @type {Map<string, HTMLElement[]>} */ | ||
#componentsByTag = new Map(); | ||
|
||
/** @type {Map<string, CSSStyleSheet>} */ | ||
#styleSheetsByTag = new Map(); | ||
|
||
#styleObserver = new StyleObserver((records) => { | ||
records.forEach((record) => { | ||
const { property, value, oldValue } = record; | ||
const tagName = property.slice(2).replace('-css-inject', ''); | ||
if (value === '1') { | ||
this.#componentStylesAdded(tagName); | ||
} else if (oldValue === '1') { | ||
this.#componentStylesRemoved(tagName); | ||
} | ||
}); | ||
}); | ||
|
||
constructor(root = document) { | ||
this.#root = root; | ||
} | ||
|
||
/** | ||
* Adds a component to the list of elements monitored for component-scoped | ||
* styles in global style sheets. If the styles have already been detected, | ||
* they are injected into the component's shadow DOM immediately. Otherwise, | ||
* the class watches the custom property `--{tagName}-css-inject` to trigger | ||
* injection when the styles are added to the document or root element. | ||
* | ||
* @param {HTMLElement} component | ||
*/ | ||
componentConnected(component) { | ||
const { is: tagName, cssInjectPropName } = component.constructor; | ||
|
||
if (this.#componentsByTag.has(tagName)) { | ||
this.#componentsByTag.get(tagName).add(component); | ||
} else { | ||
this.#componentsByTag.set(tagName, new Set([component])); | ||
} | ||
|
||
const stylesheet = this.#styleSheetsByTag.get(tagName); | ||
if (stylesheet) { | ||
injectStyleSheet(component, stylesheet); | ||
return; | ||
} | ||
|
||
// If styles for custom property are already loaded for this root, | ||
// store corresponding tag name so that we can inject styles | ||
const value = getComputedStyle(this.#rootHost).getPropertyValue(cssInjectPropName); | ||
if (value === '1') { | ||
this.#componentStylesAdded(tagName); | ||
} | ||
|
||
// Observe custom property that would trigger injection for this class | ||
this.#styleObserver.observe(this.#rootHost, cssInjectPropName); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this also be unobserved in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was thinking that maybe we could unobserve root host when there are no more components added to it (especially if it's not the document but a shadow root host). This could be implemented in a follow-up PR. |
||
} | ||
|
||
/** | ||
* Removes the component from the list of elements monitored for | ||
* component-scoped styles and cleans up any previously injected | ||
* styles from the component's shadow DOM. | ||
* | ||
* @param {HTMLElement} component | ||
*/ | ||
componentDisconnected(component) { | ||
const { is: tagName } = component.constructor; | ||
|
||
cleanupStyleSheet(component); | ||
|
||
this.#componentsByTag.get(tagName)?.delete(component); | ||
} | ||
|
||
#componentStylesAdded(tagName) { | ||
const stylesheet = this.#styleSheetsByTag.get(tagName) || new CSSStyleSheet(); | ||
|
||
const cssText = this.#extractComponentScopedCSSRules(tagName) | ||
.map((rule) => rule.cssText) | ||
.join('\n'); | ||
stylesheet.replaceSync(cssText); | ||
|
||
this.#componentsByTag.get(tagName)?.forEach((component) => { | ||
injectStyleSheet(component, stylesheet); | ||
tomivirkki marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}); | ||
|
||
this.#styleSheetsByTag.set(tagName, stylesheet); | ||
} | ||
|
||
#componentStylesRemoved(tagName) { | ||
this.#componentsByTag.get(tagName)?.forEach((component) => { | ||
cleanupStyleSheet(component); | ||
}); | ||
|
||
this.#styleSheetsByTag.delete(tagName); | ||
} | ||
|
||
#extractComponentScopedCSSRules(tagName) { | ||
// Global stylesheets | ||
const rules = extractTagScopedCSSRules(document, tagName); | ||
|
||
// Scoped stylesheets | ||
if (this.#root !== document) { | ||
rules.push(...extractTagScopedCSSRules(this.#root, tagName)); | ||
} | ||
|
||
return rules; | ||
} | ||
|
||
get #rootHost() { | ||
return this.#root === document ? this.#root.documentElement : this.#root.host; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
/** | ||
* @license | ||
* Copyright (c) 2021 - 2025 Vaadin Ltd. | ||
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ | ||
*/ | ||
|
||
// Based on https://github.com/jouni/j-elements/blob/main/test/old-components/Stylable.js | ||
|
||
/** | ||
* Check if the media query is a non-standard "tag scoped selector". | ||
* | ||
* Examples of such media queries: | ||
* - `@media vaadin-text-field { ... }` | ||
* - `@import "styles.css" vaadin-text-field`. | ||
* | ||
* @param {string} media | ||
* @return {boolean} | ||
*/ | ||
function isTagScopedMedia(media) { | ||
return /^\w+(-\w+)+$/u.test(media); | ||
} | ||
|
||
/** | ||
* Check if the media query string matches the given tag name. | ||
* | ||
* @param {string} media | ||
* @param {string} tagName | ||
* @return {boolean} | ||
*/ | ||
function matchesTagScopedMedia(media, tagName) { | ||
return media === tagName; | ||
} | ||
|
||
/** | ||
* Recursively processes a style sheet for matching "tag scoped" rules. | ||
* | ||
* @param {CSSStyleSheet} styleSheet | ||
* @param {string} tagName | ||
*/ | ||
function extractStyleSheetTagScopedCSSRules(styleSheet, tagName) { | ||
const matchingRules = []; | ||
|
||
for (const rule of styleSheet.cssRules) { | ||
const ruleType = rule.constructor.name; | ||
|
||
if (ruleType === 'CSSImportRule') { | ||
if (!isTagScopedMedia(rule.media.mediaText)) { | ||
matchingRules.push(...extractStyleSheetTagScopedCSSRules(rule.styleSheet, tagName)); | ||
continue; | ||
} | ||
|
||
if (matchesTagScopedMedia(rule.media.mediaText, tagName)) { | ||
matchingRules.push(...rule.styleSheet.cssRules); | ||
} | ||
} | ||
|
||
if (ruleType === 'CSSMediaRule') { | ||
if (matchesTagScopedMedia(rule.media.mediaText, tagName)) { | ||
matchingRules.push(...rule.cssRules); | ||
} | ||
} | ||
} | ||
|
||
return matchingRules; | ||
} | ||
|
||
/** | ||
* Recursively processes style sheets of the specified root element, including both | ||
* `adoptedStyleSheets` and regular `styleSheets`, and returns all CSS rules from | ||
* `@media` and `@import` blocks where the media query is (a) "tag scoped selector", | ||
* and (b) matches the specified tag name. | ||
* | ||
* Examples of such media queries: | ||
* - `@media vaadin-text-field { ... }` | ||
* - `@import "styles.css" vaadin-text-field` | ||
* | ||
* The returned rules are ordered in the same way as they are in the original stylesheet. | ||
* | ||
* @param {DocumentOrShadowRoot} root | ||
* @param {string} tagName | ||
* @return {CSSRule[]} | ||
*/ | ||
export function extractTagScopedCSSRules(root, tagName) { | ||
const styleSheets = new Set([...root.styleSheets]); | ||
const adoptedStyleSheets = new Set([...root.adoptedStyleSheets]); | ||
|
||
return [...styleSheets.union(adoptedStyleSheets)].flatMap((styleSheet) => { | ||
return extractStyleSheetTagScopedCSSRules(styleSheet, tagName); | ||
}); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
Set<string>