Skip to content
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

feat: implement adoptedStyleSheets + optional native CSS nesting #420

Closed
wants to merge 3 commits into from
Closed
Changes from all commits
Commits
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
258 changes: 105 additions & 153 deletions packages/controllers/style-controller/src/style-controller.ts
Original file line number Diff line number Diff line change
@@ -1,178 +1,130 @@
import {
CSSResult,
CSSResultGroup,
ReactiveController,
ReactiveControllerHost,
} from 'lit';

import * as cssTools from '@adobe/css-tools';

/**
* Helper function to add Scope to a specific CSS Selector.
* Handles :host and ::slotted conversions.
*/
const _scopeSelector = (selector: string, scopeId: string) => {
// Check if selector is already scoped.
if (selector.startsWith(scopeId)) {
return selector;
}
// Skip :root selectors.
else if (selector.includes(':root')) {
return selector;
}
// Convert ":host(Something)" to "ScopeSomething"
else if (selector.includes(':host(')) {
return selector.replace(/:host\((.+?)\)/, scopeId + '$1');
/*
Instructions for using in a component:
`outline-styled-text.ts`
import slotStyles from './outline-styled-text.css.lit';
stylesManager = new StyleManager(this, slotStyles);

When using postcss-nested-import plugin we can use @nested-import
`outline-styled-text.css`
outline-styled-text {
@nested-import './outline-styled-text-slot-styles.css';
}
// Convert remaining ":host" to "Scope"
else if (selector.includes(':host')) {
return selector.replace(':host', scopeId);
}
// Convert "::slotted(Something)" to "Scope Something".
else if (selector.includes('::slotted(')) {
return selector.replace(/::slotted\((.+?)\)/, scopeId + ' $1');
}
// Otherwise, just prefix selector with Scope.
return scopeId + ' ' + selector;
};

/**
* Add scope to a single CSS rule.
*/
const _processCssRule = (rule: cssTools.CssAtRuleAST, scopeId: string) => {
if ('selectors' in rule && rule.selectors.length > 0) {
for (let i = 0; i < rule.selectors.length; i++) {
rule.selectors[i] = _scopeSelector(rule.selectors[i], scopeId);
}
} else if ('rules' in rule) {
// Handle rules that have recursive rules (such as media)
rule.rules?.forEach((innerRule: cssTools.CssAtRuleAST) => {
_processCssRule(innerRule, scopeId);
});
}
};

/**
* Add the scopeId to each CSS rule selector.
* Handle the :host and ::slotted selectors.
*/
const addScopeToStyles = (cssStyles: string, scopeId: string) => {
// Use css-tools to parse the string into a tree.
const ast = cssTools.parse(cssStyles);
if (ast && ast.stylesheet && ast.stylesheet.rules.length > 0) {
ast.stylesheet.rules.forEach(function (rule: cssTools.CssAtRuleAST) {
_processCssRule(rule, scopeId);
});
}

// Convert tree back to a string and return the new css.
return cssTools.stringify(ast, { compress: true });
};

export declare type ComponentStyles = {
name: string;
styles: CSSResultGroup;
};
*/
import { CSSResult, ReactiveController, ReactiveControllerHost } from 'lit';

/**
* StyleController class
* StyleManager class
* @implements {ReactiveController}
*/
export class StyleController implements ReactiveController {
// The scope to wrap the rules, defaults to the component name (tag).
// If scopeId is empty, rules are added to light dom but not scoped.
scopeId: string;

// The parent component (host).
export class StyleManager implements ReactiveController {
protected host: ReactiveControllerHost & Element;
protected cssStyles: CSSResult;
protected generateRandomId: boolean;

// The host component name
protected componentName: string;

// Internal holder of styles to be processed.
protected cssStyles: CSSResultGroup;

// Counter used internally for multiple rules in a group.
protected ruleIndex = 0;

constructor(
host: ReactiveControllerHost & Element,
cssStyles: CSSResultGroup,
scopeId = ''
) {
// Store a reference to the host
constructor(host: ReactiveControllerHost & Element, cssStyles: CSSResult, generateRandomId = false) {
this.host = host;
this.componentName = host.tagName.toLowerCase();
// Store the css styles to be injected.
this.cssStyles = cssStyles;
this.scopeId = scopeId;
// Register for lifecycle updates
this.generateRandomId = generateRandomId;
host.addController(this);
}

hostConnected(): void {
// Add a comment that this component is using light-dom.
const annotationComment = `'${this.componentName}' using light DOM styles injected into <head>`;
this.host.before(document.createComment(annotationComment));

// Only add light-dom styles for this scopeId (component name) once.
if (!document.getElementById(this.componentName)) {
this.ruleIndex = 0;
this._addLightDomGroup(this.cssStyles);
}
// @TODO: Add an HTML comment when debug/dev mode is on
this.addSlotStyles(this.cssStyles);
}

/**
* Add styles from a CSSResult to the light-dom, scoped by the scopeId.
* Only add if style element doesn't already exist.
* Adds the CSS styles to the host element by creating a nested CSS rule with the element's unique ID.
* @param cssStyles - The CSS styles to be applied to the host element.
*/
_addLightDomStyle(cssStyles: CSSResult) {
// If this is the first rule, use the component name as the element id,
// otherwise add a "-INDEX" counter to the element id.
const elementId =
this.ruleIndex !== 0
? this.componentName + '-' + this.ruleIndex
: this.componentName;
if (!document.getElementById(elementId)) {
const scopedStyleElement = document.createElement('style');
scopedStyleElement.id = elementId;

// Add scope to css rules if scope isn't "none"
let rawStyles = `${cssStyles}`;
if (this.scopeId !== '') {
rawStyles = addScopeToStyles(rawStyles, this.scopeId);
}
scopedStyleElement.innerHTML = rawStyles;

// Prepend so consumer theme can still override.
document.head.prepend(scopedStyleElement);

// Add a comment to the style to indicate which component injected it.
const annotationComment = `styles injected into DOM by '${this.componentName}'`;
scopedStyleElement.before(document.createComment(annotationComment));
addSlotStyles(cssStyles: CSSResult) {
const documentSheet = new CSSStyleSheet();

if (this.generateRandomId) {
// Add a random ID for this specific instance of the component
this.host.id = `${this.host.tagName}-${this.generateRandomString(4)}`;

// Create nested CSS rule with the element's unique id
const ruleStringWithElementAsPrefix =
'#' +
this.host.id +
` { ${this.addPrefixToCurlyGroups(cssStyles, '& ')}; }`;
// Add all the rules to the document's stylesheet
documentSheet.replaceSync(ruleStringWithElementAsPrefix);
}
else {
// assuming postcss-nested-import plugin is used
documentSheet.replaceSync(cssStyles.cssText);
}
this.ruleIndex++;

document.adoptedStyleSheets = [
...document.adoptedStyleSheets,
documentSheet,
];
}

/**
* Add styles from a CSSResultGroup to the light-dom.
* Removes the given rules from the input string.
* @param inputString - The string to remove rules from.
* @param rules - An array of rules to remove from the input string.
* @returns The modified string with the specified rules removed.
*/
_addLightDomGroup(cssStyles: CSSResultGroup, depth = 0) {
if (cssStyles instanceof Array) {
// Loop through style array backwards since addLightDomStyle uses
// prepend and we want to keep the original order.
for (let index = cssStyles.length - 1; index >= 0; index--) {
const item = cssStyles[index];
if (item instanceof Array) {
// A CSSResultGroup can be an array of other result groups.
this._addLightDomGroup(item, depth + 1);
} else if (item instanceof CSSResult) {
this._addLightDomStyle(item);
}
}
} else if (cssStyles instanceof CSSResult) {
// Process a single result.
this._addLightDomStyle(cssStyles);
removeRules(inputString: string, rules: string[]) {
let modifiedString = inputString;

rules.forEach(rule => {
modifiedString = modifiedString.replace(rule, '');
});

return modifiedString;
}

/**
* Adds a special prefix before each group of curly brackets in the input CSS.
* @param inputCSS - The CSS to modify.
* @param prefix - The prefix to add before each group of curly brackets.
* @returns The modified CSS with the special prefix added before each group of curly brackets.
*/
addPrefixToCurlyGroups(inputCSS: CSSResult, prefix = '& ') {
// Convert the CSSResult object to a string
const inputString = this.removeRules(inputCSS.cssText, [':host', ':root']);

// This regular expression matches a string containing any characters that
// are not curly braces, followed by a pair of curly braces containing any
// whitespace characters and non-whitespace characters.
// The `g` flag at the end of the regular expression indicates that it
// should match all occurrences in the input string, not just the first one.
// This regular expression is used to extract CSS selectors and their
// corresponding styles from a CSS file.

const regex = /([^{}]*)(\{[\s\S]*?\})/g;

// Replace each match found by the regular expression with a modified string
const modifiedString = inputString.replace(regex, (_, before, group) => {
// Return the modified string with the special prefix added before the captured group
return `${prefix}${before}${group}`;
});

// Return the final modified string
return modifiedString;
}

/**
* Generates a random string of the specified length.
* @param length - The length of the string to generate.
* @returns A random string of the specified length.
*/
generateRandomString(length: number) {
const charset =
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const charsetArray = charset.split('');
let result = '';

for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * charsetArray.length);
result += charsetArray[randomIndex];
}

return result;
}
}
Loading