import type { MediaQuery, Selector, SelectorComponent, SelectorList, } from "lightningcss"; import { SpecificityIndex } from "../shared"; import { AttributeCondition, ExtractRuleOptions, Specificity, StyleRule, } from "../types"; export type NormalizeSelector = | { type: "rootVariables" | "universalVariables"; subtype: "light" | "dark"; } | { type: "className"; className: string; media?: MediaQuery[]; groupClassName?: string; pseudoClasses?: Record; groupPseudoClasses?: Record; groupAttrs?: AttributeCondition[]; attrs?: AttributeCondition[]; specificity: Specificity; }; /** * Turns a CSS selector into a `react-native-css-interop` selector. */ export function normalizeSelectors( extractedStyle: StyleRule, selectorList: SelectorList, options: ExtractRuleOptions, selectors: NormalizeSelector[] = [], defaults: Partial = {}, ) { for (let cssSelector of selectorList) { // Ignore `:is()`, and just process its selectors if (isIsPseudoClass(cssSelector)) { normalizeSelectors( extractedStyle, cssSelector[0].selectors, options, selectors, ); } else if ( // Matches: :root {} isRootVariableSelector(cssSelector) ) { if ( // Matches: @media(prefers-dark-mode) { :root {} } isDarkModeMediaQuery(extractedStyle.media?.[0]) ) { selectors.push({ type: "rootVariables", subtype: "dark", }); } else { selectors.push({ type: "rootVariables", subtype: "light", }); } } else if ( // Matches: .dark:root {} || :root[class~="dark"] isRootDarkVariableSelector(cssSelector, options) ) { selectors.push({ type: "rootVariables", subtype: "dark", }); } else if ( // Matches: * {} isDefaultVariableSelector(cssSelector) ) { if ( // Matches @media(prefers-dark-mode) { * {} } isDarkModeMediaQuery(extractedStyle.media?.[0]) ) { selectors.push({ type: "universalVariables", subtype: "dark", }); } else { selectors.push({ type: "universalVariables", subtype: "light", }); } } else if ( // Matches: .dark * {} isDarkUniversalSelector(cssSelector, options) ) { selectors.push({ type: "universalVariables", subtype: "dark", }); } else if ( // Matches: .dark {} isDarkClassLegacySelector(cssSelector, options) ) { const [_, __, third, ...rest] = cssSelector; normalizeSelectors( extractedStyle, [[third, ...rest]], options, selectors, { media: [ { mediaType: "all", condition: { type: "feature", value: { type: "plain", name: "prefers-color-scheme", value: { type: "ident", value: "dark" }, }, }, }, ], }, ); } else if ( // Matches: :is(.dark *) {} isDarkClassSelector(cssSelector, options) ) { const [first] = cssSelector; normalizeSelectors(extractedStyle, [[first]], options, selectors, { media: [ { mediaType: "all", condition: { type: "feature", value: { type: "plain", name: "prefers-color-scheme", value: { type: "ident", value: "dark" }, }, }, }, ], }); } else { const selector = reduceSelector( { ...defaults, type: "className", className: "", specificity: [], }, cssSelector, options, ); if (selector === null || !selector.className) { continue; } selectors.push(selector); } } return selectors; } function reduceSelector( acc: Extract, selectorComponents: Selector, options: ExtractRuleOptions, ) { let previousType: SelectorComponent["type"] = "combinator"; let inGroup = false; /* * Loop over each token and the cssSelector and parse it into a `react-native-css-interop` selector */ for (const component of selectorComponents) { switch (component.type) { case "universal": case "namespace": case "nesting": case "id": case "pseudo-element": // We don't support these selectors at all return null; case "attribute": { if (!acc.groupClassName && !acc.className) { if ( component.name === "dir" && component.operation?.operator === "equal" ) { acc.media ??= []; acc.media.push({ mediaType: "all", condition: { type: "feature", value: { type: "boolean", name: component.operation.value, }, }, }); } else { return null; } break; } // Turn attribute selectors into AttributeConditions acc.specificity[SpecificityIndex.ClassName] = (acc.specificity[SpecificityIndex.ClassName] ?? 0) + 1; let attrs: AttributeCondition[]; if (inGroup) { acc.groupAttrs ??= []; attrs = acc.groupAttrs; } else { acc.attrs ??= []; attrs = acc.attrs; } if (component.name.startsWith("data-")) { attrs.push({ ...component, name: toRNProperty(component.name.replace("data-", "")), type: "data-attribute", }); } else { attrs.push(component); } break; } case "type": { /* * We only support type selectors as part of the selector prefix * For example: `html .my-class` * * NOTE: We ignore specificity for this */ if (component.name !== options.selectorPrefix) { return null; } break; } case "combinator": { // We only support the descendant combinator, this is used for groups if (component.value !== "descendant") { return null; } inGroup = false; break; } case "class": { acc.specificity[SpecificityIndex.ClassName] = (acc.specificity[SpecificityIndex.ClassName] ?? 0) + 1; // .class.otherClass is only valid if the previous class was a valid group, or the last token was a combinator switch (previousType) { // .class case "combinator": { // We can only have two classnames in a selector if the first one is a valid group if (acc.className) { // .className .otherClassName // This will only occur if the first className is not a group return null; } else if (component.name === options.selectorPrefix?.slice(1)) { // If the name matches the selectorPrefix, just ignore it! // E.g .dark .myClass break; } else { const groupingValid = !acc.groupClassName && options.grouping.some((group) => { return group.test(component.name); }); if (groupingValid) { // Otherwise make the current className the group acc.groupClassName = component.name; acc.groupPseudoClasses = acc.pseudoClasses; acc.pseudoClasses = {}; inGroup = true; } else if (!acc.className) { acc.className = component.name; } else { return null; } } break; } // .class.otherClass case "class": { if (!inGroup) { return null; } // We are in a group selector, so any additional classes are groupAttributes acc.groupAttrs ??= []; acc.groupAttrs.push({ type: "attribute", name: "className", operation: { operator: "includes", value: component.name }, }); break; } default: { return null; } } break; } case "pseudo-class": { acc.specificity[SpecificityIndex.ClassName] = (acc.specificity[SpecificityIndex.ClassName] ?? 0) + 1; let pseudoClasses: Record; let attrs: AttributeCondition[]; if (component.kind === "is") { if (isDarkUniversalSelector(component.selectors[0], options)) { acc.media ??= []; acc.media.push({ mediaType: "all", condition: { type: "feature", value: { type: "plain", name: "prefers-color-scheme", value: { type: "ident", value: "dark" }, }, }, }); break; } } switch (previousType) { case "pseudo-class": case "class": { if (acc.className) { acc.pseudoClasses ??= {}; pseudoClasses = acc.pseudoClasses; acc.attrs ??= []; attrs = acc.attrs; } else if (acc.groupClassName) { acc.groupPseudoClasses ??= {}; pseudoClasses = acc.groupPseudoClasses; acc.groupAttrs ??= []; attrs = acc.groupAttrs; } else { return null; } break; } default: { return null; } } switch (component.kind) { case "hover": case "active": case "focus": pseudoClasses ??= {}; pseudoClasses[component.kind] = true; break; case "disabled": attrs ??= []; attrs.push({ type: "attribute", name: "disabled", operation: { operator: "truthy" }, }); break; case "empty": attrs ??= []; attrs.push({ type: "attribute", name: "children", operation: { operator: "empty" }, }); break; } } } previousType = component.type; } return acc; } function isIsPseudoClass( selector: Selector, ): selector is [{ type: "pseudo-class"; kind: "is"; selectors: Selector[] }] { return ( selector.length === 1 && selector[0].type === "pseudo-class" && selector[0].kind === "is" ); } function isDarkModeMediaQuery(query?: MediaQuery): boolean { return Boolean( query?.condition && query.condition.type === "feature" && query.condition.value.type === "plain" && query.condition.value.name === "prefers-color-scheme" && query.condition.value.value.value === "dark", ); } // Matches: :is(.dark *) function isDarkClassSelector( [first, second, third]: Selector, options: ExtractRuleOptions, ) { if (options.darkMode?.type !== "class" || !options.darkMode.value) { return false; } return ( first && second && !third && first.type === "class" && second.type === "pseudo-class" && second.kind === "is" && second.selectors.length === 1 && second.selectors[0].length === 3 && second.selectors[0][0].type === "class" && second.selectors[0][0].name === options.darkMode.value && second.selectors[0][1].type === "combinator" && second.selectors[0][1].value === "descendant" && second.selectors[0][2].type === "universal" ); } // Matches: .dark {} function isDarkClassLegacySelector( [first, second, third]: Selector, options: ExtractRuleOptions, ) { if (options.darkMode?.type !== "class" || !options.darkMode.value) { return false; } return ( first && second && third && first.type === "class" && first.name === options.darkMode?.value && second.type === "combinator" && second.value === "descendant" && third.type === "class" ); } // Matches: :root {} function isRootVariableSelector([first, second]: Selector) { return ( first && !second && first.type === "pseudo-class" && first.kind === "root" ); } // Matches: * {} function isDefaultVariableSelector([first, second]: Selector) { return first && !second && first.type === "universal"; } // Matches: .dark:root {} function isRootDarkVariableSelector( [first, second]: Selector, options: ExtractRuleOptions, ) { return ( first && second && options.darkMode?.type === "class" && // .dark:root {} ((first.type === "class" && first.name === options.darkMode.value && second.type === "pseudo-class" && second.kind === "root") || // :root[class~=dark] {} (first.type === "pseudo-class" && first.kind === "root" && second.type === "attribute" && second.name === "class" && second.operation && second.operation.value === options.darkMode.value && ["includes", "equal"].includes(second.operation.operator))) ); } // Matches: .dark * {} function isDarkUniversalSelector( [first, second, third]: Selector, options: ExtractRuleOptions, ) { return ( options.darkMode?.type === "class" && first && second && third && first.type === "class" && first.name === options.darkMode.value && second.type === "combinator" && second.value === "descendant" && third.type === "universal" ); } export function toRNProperty(str: string) { return str.replace(/^-rn-/, "").replace(/-./g, (x) => x[1].toUpperCase()); }