Skip to content

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

Open
wants to merge 65 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
1cd3369
Prototype CSS injection mixin based on Stylable
web-padawan Apr 7, 2025
0736778
set up vite server
vursen Apr 8, 2025
9e73fc3
set up vite production config
vursen Apr 8, 2025
81b5659
revert previous css structure
vursen Apr 8, 2025
31f0505
include all index html files in build
vursen Apr 8, 2025
06695f3
start vite server after build
vursen Apr 8, 2025
fe06cef
Implement dynamic style injection per component class
web-padawan Apr 8, 2025
94ea87a
Do not wait for all stylesheets to be loaded
web-padawan Apr 8, 2025
30cd347
Remove no longer needed await
web-padawan Apr 8, 2025
25acba3
Use CSS.registerProperty instead of style tag
web-padawan Apr 8, 2025
577bd94
set vite build target to esnext
vursen Apr 8, 2025
5a360eb
fix rule order
vursen Apr 8, 2025
fe77bf8
Add list-box and Lumo style modules
web-padawan Apr 8, 2025
5c70b61
clean up css injection code
vursen Apr 8, 2025
b1b6cca
Revert unintended changes to dev-server config
web-padawan Apr 8, 2025
5c0c099
Use html selector for consistency
web-padawan Apr 9, 2025
6f7fd37
Rewrite item and list-box to use shared styles
web-padawan Apr 9, 2025
3900059
Adapt injecting logic to work with registerStyles API
web-padawan Apr 9, 2025
4a3746b
Support document.adoptedStyleSheets
web-padawan Apr 9, 2025
c062b51
Simplify code, add JSDoc comments
web-padawan Apr 9, 2025
165a18b
Update to use element for consistency
web-padawan Apr 9, 2025
3198457
Add more JSDoc annotations
web-padawan Apr 9, 2025
42397e7
Fix cleanup instance styles logic
web-padawan Apr 9, 2025
9b4f91c
Store tag names instead of component classes
web-padawan Apr 9, 2025
6e2527a
Move CSS injection logic to ThemableMixin package
web-padawan Apr 9, 2025
6b8fa35
Add comments about web-dev-server
web-padawan Apr 9, 2025
a683788
Do not use incorrect import syntax
web-padawan Apr 9, 2025
3647593
Add web-dev-server plugin to inline CSS imports
web-padawan Apr 9, 2025
84c17dc
Restructure CSS to use separate base folder
web-padawan Apr 10, 2025
983c7ce
Implement observing shadow root hosts
web-padawan Apr 10, 2025
f5037f3
Fix dynamic loading dev page
web-padawan Apr 10, 2025
d1b8df7
Update style-observer to latest version
web-padawan Apr 10, 2025
ceae501
Fix JSDoc annotations for host map
web-padawan Apr 10, 2025
b0a3285
Add text-field and API to observe parent root
web-padawan Apr 10, 2025
5de67f2
Find proper root for gathering matching styles
web-padawan Apr 10, 2025
b3a13f0
Simplify findRoot logic
web-padawan Apr 10, 2025
a2f917e
Inline observeRoot logic to connectedCallback
web-padawan Apr 10, 2025
8fe712f
refactor: extract injection logic into a class"
vursen Apr 10, 2025
de2e674
fix themable-mixin, cleanup utilities
vursen Apr 11, 2025
960eb8a
rename css-injection-utils to css-rules, refactor css-rules
vursen Apr 11, 2025
e92d5b5
refactor extractMatchingStyleRules to return css rules as array
vursen Apr 11, 2025
f4ab8aa
use rule.constructor.name instead of deprecated rule.type
vursen Apr 11, 2025
7d7c8c1
Add some basic tests
web-padawan Apr 11, 2025
6a6e485
clean up css rule collecting logic
vursen Apr 11, 2025
9095f69
Add tests for parent shadow scope
web-padawan Apr 11, 2025
3fcf97a
Move utils to separate file
web-padawan Apr 11, 2025
ff458ca
Add tests for combining with registerStyles
web-padawan Apr 11, 2025
ce7a9bc
Revert changes from the prototype branch
web-padawan Apr 11, 2025
a5d9716
Capitalize CSS in the mixin name
web-padawan Apr 11, 2025
b9e5a3d
Small tweaks to comments
web-padawan Apr 11, 2025
76f7feb
add missing license header
vursen Apr 11, 2025
c5d3d76
Update style-observer to latest version
web-padawan Apr 11, 2025
fbbac5a
improve JSDoc
vursen Apr 11, 2025
0a86240
simplify tag selector regexp
vursen Apr 14, 2025
b743f03
remove unnecessary workaround
vursen Apr 14, 2025
7c3eb65
polish CSSInjector
vursen Apr 14, 2025
2c34760
Always inject instance styles in the right order
web-padawan Apr 14, 2025
d1f7e90
Remove no longer needed function
web-padawan Apr 14, 2025
ae46b23
polish CSSInjector, simplify css rule extraction logic
vursen Apr 14, 2025
7b4a0e6
add css rule extraction tests
vursen Apr 14, 2025
2ab5923
Do not register same custom CSS property twice
web-padawan Apr 14, 2025
0a26d88
add more tests
vursen Apr 14, 2025
7ff0491
fix duplicate stylesheets, add more tests
vursen Apr 14, 2025
d8b2daa
Remove extra whitespace
web-padawan Apr 14, 2025
6fec8e8
use Set.union, add more tests
vursen Apr 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions packages/vaadin-themable-mixin/css-injection-mixin.js
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[]}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Set<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;
}
};
3 changes: 2 additions & 1 deletion packages/vaadin-themable-mixin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
],
"dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0",
"lit": "^3.0.0"
"lit": "^3.0.0",
"style-observer": "^0.0.7"
},
"devDependencies": {
"@polymer/polymer": "^3.0.0",
Expand Down
154 changes: 154 additions & 0 deletions packages/vaadin-themable-mixin/src/css-injector.js
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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this also be unobserved in componentDisconnected?

Copy link
Member Author

Choose a reason for hiding this comment

The 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);
});

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;
}
}
90 changes: 90 additions & 0 deletions packages/vaadin-themable-mixin/src/css-rules.js
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);
});
}
Loading
Loading