From 99dc7628f296640affba2a90f8ebd9ec67b0c02c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 15:50:16 +0300 Subject: [PATCH 1/2] refactor(compiler): delete the unused selectors module and give toRNProperty one home src/compiler/selectors.ts's getSelectors is imported by nothing; the compiler builds selectors in selector-builder.ts. The module stayed alive only through its toRNProperty, which atRules.ts and declarations.ts imported while selector-builder.ts and stylesheet.ts used a second copy. toRNProperty moves to utilities/property-names.ts and every caller imports it from there. --- src/compiler/atRules.ts | 2 +- src/compiler/declarations.ts | 3 +- src/compiler/selector-builder.ts | 13 +- src/compiler/selectors.ts | 505 ------------------------------- src/compiler/stylesheet.ts | 3 +- src/utilities/index.ts | 1 + src/utilities/property-names.ts | 10 + 7 files changed, 16 insertions(+), 521 deletions(-) delete mode 100644 src/compiler/selectors.ts create mode 100644 src/utilities/property-names.ts diff --git a/src/compiler/atRules.ts b/src/compiler/atRules.ts index 6943f672..730aef41 100644 --- a/src/compiler/atRules.ts +++ b/src/compiler/atRules.ts @@ -6,8 +6,8 @@ import type { TokenOrValue, } from "lightningcss"; +import { toRNProperty } from "../utilities"; import type { StyleRuleMapping } from "./compiler.types"; -import { toRNProperty } from "./selectors"; import { splitByDelimiter } from "./split-by-delimiter"; import type { StylesheetBuilder } from "./stylesheet"; diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..62777dfb 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -36,7 +36,7 @@ import type { UnresolvedColor, } from "lightningcss"; -import { isStyleFunction } from "../utilities"; +import { isStyleFunction, toRNProperty } from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, @@ -44,7 +44,6 @@ import type { StyleRule, } from "./compiler.types"; import { parseEasingFunction, parseIterationCount } from "./keyframes"; -import { toRNProperty } from "./selectors"; import type { StylesheetBuilder } from "./stylesheet"; const CommaSeparator = Symbol("CommaSeparator"); diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 6787c0ca..f2a2cd75 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -1,6 +1,6 @@ import type { AttrOperation, Selector, SelectorList } from "lightningcss"; -import { Specificity } from "../utilities"; +import { Specificity, toRNProperty } from "../utilities"; import type { AttributeQuery, AttrSelectorOperator, @@ -597,17 +597,6 @@ function isUniversalSelector([first, second]: Selector) { return first && first.type === "universal" && !second; } -export function toRNProperty(str: T) { - return str - .replace(/^-rn-/, "") - .replace(/-./g, (x) => x[1]?.toUpperCase() ?? "") as CamelCase; -} - -type CamelCase = - S extends `${infer P1}-${infer P2}${infer P3}` - ? `${Lowercase}${Uppercase}${CamelCase}` - : Lowercase; - const operatorMap: Record = { "equal": "=", "includes": "~=", diff --git a/src/compiler/selectors.ts b/src/compiler/selectors.ts deleted file mode 100644 index b8623072..00000000 --- a/src/compiler/selectors.ts +++ /dev/null @@ -1,505 +0,0 @@ -/* eslint-disable */ -import type { Selector, SelectorList } from "lightningcss"; - -import { Specificity } from "../utilities"; -import type { - AttributeQuery, - AttrSelectorOperator, - CompilerOptions, - ContainerQuery, - MediaCondition, - PseudoClassesQuery, - SpecificityArray, -} from "./compiler.types"; -import { StylesheetBuilder } from "./stylesheet"; - -export type NormalizeSelector = - | ClassNameSelector - | { - type: "rootVariables" | "universalVariables"; - subtype: "light" | "dark"; - }; - -type ClassNameSelector = { - type: "className"; - specificity: SpecificityArray; - className: string; - mediaQuery?: MediaCondition[]; - containerQuery?: ContainerQuery[]; - pseudoClassesQuery?: PseudoClassesQuery; - attributeQuery?: AttributeQuery[]; -}; - -/** - * Turns a CSS selector into a `react-native-css` selector. - */ -export function getSelectors( - selectorList: SelectorList, - isDarkMode: boolean, - builder: StylesheetBuilder, - selectors: NormalizeSelector[] = [], -) { - for (let cssSelector of selectorList) { - // Ignore `:is()` & `:where()`, and just process its selectors - if (isIsPseudoClass(cssSelector) || isWherePseudoClass(cssSelector)) { - getSelectors(cssSelector[0].selectors, isDarkMode, builder, selectors); - } else if ( - // Matches: :root {} - isRootVariableSelector(cssSelector) - ) { - // Matches: @media(prefers-dark-mode) { :root {} } - if (isDarkMode) { - selectors.push({ - type: "rootVariables", - subtype: "dark", - }); - } else { - selectors.push({ - type: "rootVariables", - subtype: "light", - }); - } - } else if ( - // Matches: .dark:root {} || :root[class~="dark"] - isRootDarkVariableSelector(cssSelector) - ) { - selectors.push({ - type: "rootVariables", - subtype: "dark", - }); - } else if ( - // Matches @media(prefers-dark-mode) { * {} } - isDarkMode && - isDefaultVariableSelector(cssSelector) - ) { - selectors.push({ - type: "universalVariables", - subtype: "dark", - }); - } else if ( - // Matches: * {} - isDefaultVariableSelector(cssSelector) - ) { - selectors.push({ - type: "universalVariables", - subtype: "light", - }); - // } else if ( - // // Matches: .dark * {} - // isDarkUniversalSelector(cssSelector) - // ) { - // selectors.push({ - // type: "universalVariables", - // subtype: "dark", - // }); - // } else if ( - // // Matches: .dark {} - // isDarkClassLegacySelector(cssSelector, collection) - // ) { - // const [_, __, third, ...rest] = cssSelector; - - // getSelectors(extractedStyle, [[third!, ...rest]], collection, selectors, [ - // ["=", "prefers-color-scheme", "dark"], - // ]); - // } else if ( - // // Matches: :is(.dark *) {} - // isDarkClassSelector(cssSelector, collection) - // ) { - // const [first] = cssSelector; - - // getSelectors(extractedStyle, [[first!]], collection, selectors, [ - // ["=", "prefers-color-scheme", "dark"], - // ]); - } else { - const selector = classNameSelector(cssSelector, builder.getOptions()); - - if (selector === null) { - continue; - } - - selectors.push(selector); - } - } - - return selectors; -} - -function classNameSelector( - selector: Selector, - options: CompilerOptions, -): ClassNameSelector | null { - let primaryClassName: string | undefined; - const specificity: SpecificityArray = []; - let mediaQuery: MediaCondition[] | undefined; - let containerQuery: ContainerQuery[] | undefined; - let attributeQuery: AttributeQuery[] | undefined; - let pseudoClassesQuery: PseudoClassesQuery | undefined; - - let currentContainerQuery: ContainerQuery | undefined; - - let isInClassBlock = false; - let newBlock = false; - - function getAttributeQuery() { - if (newBlock) { - currentContainerQuery ??= {}; - } - - if (currentContainerQuery) { - if (!currentContainerQuery.a) { - currentContainerQuery.a = []; - } - return currentContainerQuery.a; - } else { - attributeQuery ??= []; - return attributeQuery; - } - } - - function getPseudoClassesQuery() { - if (newBlock) { - currentContainerQuery ??= {}; - } - - if (currentContainerQuery) { - if (!currentContainerQuery.p) { - currentContainerQuery.p = {}; - } - return currentContainerQuery.p; - } else { - pseudoClassesQuery ??= {}; - return pseudoClassesQuery; - } - } - - /* - * Loop over each token in reverse order. - */ - for (const component of selector.reverse()) { - switch (component.type) { - case "universal": - case "namespace": - case "id": - case "pseudo-element": - // We don't support these selectors at all - return null; - case "nesting": - continue; - case "class": { - if (!primaryClassName) { - primaryClassName = component.name; - } else if (isInClassBlock) { - getAttributeQuery().unshift(["a", "className", "*=", component.name]); - } else if (component.name !== options.selectorPrefix) { - if (currentContainerQuery?.n) { - getAttributeQuery().unshift([ - "a", - "className", - "*=", - component.name, - ]); - } else { - currentContainerQuery ??= {}; - currentContainerQuery.n = component.name; - - containerQuery ??= []; - containerQuery.unshift(currentContainerQuery); - } - } - - isInClassBlock = true; - newBlock = false; - - specificity[Specificity.ClassName] = - (specificity[Specificity.ClassName] ?? 0) + 1; - - break; - } - case "pseudo-class": { - specificity[Specificity.ClassName] = - (specificity[Specificity.ClassName] ?? 0) + 1; - - // if (component.kind === "is") { - // if (isDarkUniversalSelector(component.selectors[0]!, collection)) { - // const lastGroup = acc.classNames[acc.classNames.length - 1]!; - // lastGroup[1] ??= {}; - // lastGroup[1].m ??= []; - // lastGroup[1].m.push(["=", "prefers-color-scheme", "dark"]); - // break; - // } - // } - - switch (component.kind) { - case "hover": { - getPseudoClassesQuery().h = 1; - break; - } - case "active": { - getPseudoClassesQuery().a = 1; - break; - } - case "focus": { - getPseudoClassesQuery().f = 1; - break; - } - case "disabled": { - getAttributeQuery().push(["a", "disabled"]); - break; - } - case "empty": { - getAttributeQuery().push(["a", "children", "!"]); - break; - } - default: { - // We don't support other pseudo-classes - return null; - } - } - break; - } - case "attribute": { - // We don't support attribute selectors as standalone selectors - // Except for a top level [dir] selector - // if (classNames.length === 0) { - // if ( - // component.name === "dir" && - // component.operation?.operator === "equal" - // ) { - // mediaQuery ??= []; - // mediaQuery.push(["!!", component.operation.value]); - // break; - // } - - // // Ignore any other attributes that are not part of a class selector - // return null; - // } - - // Turn attribute selectors into AttributeConditions - specificity[Specificity.ClassName] = - (specificity[Specificity.ClassName] ?? 0) + 1; - - // [data-*] are turned into `dataSet` queries - // Everything else is turned into `attribute` queries - const attributeQuery: AttributeQuery = component.name.startsWith( - "data-", - ) - ? ["d", toRNProperty(component.name.replace("data-", ""))] - : ["a", toRNProperty(component.name)]; - - if (component.operation) { - let operator: AttrSelectorOperator | undefined; - - switch (component.operation.operator) { - case "equal": - operator = "="; - break; - case "includes": - operator = "~="; - break; - case "dash-match": - operator = "|="; - break; - case "prefix": - operator = "^="; - break; - case "substring": - operator = "*="; - break; - case "suffix": - operator = "$="; - break; - default: - component.operation.operator satisfies never; - break; - } - - if (operator) { - attributeQuery.push(operator, component.operation.value); - } - } - - getAttributeQuery().push(attributeQuery); - 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) { - break; - } - return null; - } - case "combinator": { - // We only support the descendant combinator - if (component.value === "descendant") { - isInClassBlock = false; - newBlock = true; - currentContainerQuery = undefined; - break; - } - - return null; - } - } - } - - if (!primaryClassName) { - // No class name found, return null - return null; - } - - return { - type: "className", - specificity: specificity, - className: primaryClassName, - mediaQuery, - containerQuery, - pseudoClassesQuery, - attributeQuery, - }; -} - -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 isWherePseudoClass( - selector: Selector, -): selector is [ - { type: "pseudo-class"; kind: "where"; selectors: Selector[] }, -] { - return ( - selector.length === 1 && - selector[0]?.type === "pseudo-class" && - selector[0].kind === "where" - ); -} - -// function isDarkModeMediaQuery(query?: MediaCondition): boolean { -// if (!query) return false; - -// return ( -// query[0] === "=" && -// query[1] === "prefers-color-scheme" && -// query[2] === "dark" -// ); -// } - -// Matches: :is(.dark *) -// function isDarkClassSelector( -// [first, second, third]: Selector, -// collection: CompilerCollection, -// ) { -// if (!collection.darkMode) { -// 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 === collection.darkMode && -// 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, -// collection: CompilerCollection, -// ) { -// if (!collection.darkMode) { -// return false; -// } - -// return ( -// first && -// second && -// third && -// first.type === "class" && -// first.name === collection.darkMode && -// 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) { - return ( - first && - second && - // .dark:root {} - ((first.type === "class" && - 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 && - ["includes", "equal"].includes(second.operation.operator))) - ); -} - -// Matches: .dark * {} -// function isDarkUniversalSelector( -// [first, second, third]: Selector, -// collection: CompilerCollection, -// ) { -// if (!collection.darkMode) { -// return false; -// } -// return ( -// first && -// second && -// third && -// first.type === "class" && -// first.name === collection.darkMode && -// second.type === "combinator" && -// second.value === "descendant" && -// third.type === "universal" -// ); -// } - -export function toRNProperty(str: T) { - return str - .replace(/^-rn-/, "") - .replace(/-./g, (x) => x[1]!.toUpperCase()) as CamelCase; -} - -type CamelCase = - S extends `${infer P1}-${infer P2}${infer P3}` - ? `${Lowercase}${Uppercase}${CamelCase}` - : Lowercase; diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..ffb4c581 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -4,6 +4,7 @@ import { isStyleDescriptorArray, Specificity, specificityCompareFn, + toRNProperty, } from "../utilities"; import type { AnimationKeyframes, @@ -25,7 +26,7 @@ import { modifyRuleForPlaceholder, modifyRuleForSelection, } from "./pseudo-elements"; -import { getClassNameSelectors, toRNProperty } from "./selector-builder"; +import { getClassNameSelectors } from "./selector-builder"; type BuilderMode = "style" | "media" | "container" | "keyframes"; diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 0c95da63..4470e985 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,3 +1,4 @@ export * from "./specificity"; export * from "./style-descriptor"; export * from "./dot-notation.types"; +export * from "./property-names"; diff --git a/src/utilities/property-names.ts b/src/utilities/property-names.ts new file mode 100644 index 00000000..ee8fabe2 --- /dev/null +++ b/src/utilities/property-names.ts @@ -0,0 +1,10 @@ +type CamelCase = + S extends `${infer P1}-${infer P2}${infer P3}` + ? `${Lowercase}${Uppercase}${CamelCase}` + : Lowercase; + +export function toRNProperty(str: T) { + return str + .replace(/^-rn-/, "") + .replace(/-./g, (x) => x[1]?.toUpperCase() ?? "") as CamelCase; +} From aa2c34abad5322b20e24e0e2e7c567b353c12499 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 18:19:17 +0300 Subject: [PATCH 2/2] fix(native): expand a transition held in a variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `transition: var(--t)` reached React Native as a raw `transition` array and `transition-property: var(--p)` as the variable's names, because the transition grammar and the property names lived in the compiler. The written path also named a transition by the property's camelCase rather than by the key the property is written to, so `background-image` named `backgroundImage` where its style is `experimental_backgroundImage`. `propertyRename` moves to `utilities` beside `toRNProperty`, with `transitionStyleKey` naming the key a transition animates for both routes. A runtime `transition` resolver reads each comma group by CSS Transitions 1 §2.5 — any order, the first time the duration and the second the delay — into the longhands the compiler builds for a written value, and `transitionProperty` maps a variable's names the same way. --- .../native/transition-variables.test.tsx | 83 +++++++++++ .../utilities/property-names.test.ts | 29 ++++ src/compiler/declarations.ts | 31 ++-- src/native/styles/shorthands/index.ts | 1 + src/native/styles/shorthands/transition.ts | 133 ++++++++++++++++++ src/utilities/property-names.ts | 21 +++ 6 files changed, 277 insertions(+), 21 deletions(-) create mode 100644 src/__tests__/native/transition-variables.test.tsx create mode 100644 src/__tests__/utilities/property-names.test.ts create mode 100644 src/native/styles/shorthands/transition.ts diff --git a/src/__tests__/native/transition-variables.test.tsx b/src/__tests__/native/transition-variables.test.tsx new file mode 100644 index 00000000..2971441e --- /dev/null +++ b/src/__tests__/native/transition-variables.test.tsx @@ -0,0 +1,83 @@ +import { StyleSheet } from "react-native"; + +import { render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; + +// Observe the engine's transition contract before Reanimated consumes it. +jest.mock("../../native/reanimated", () => ({ + animatedComponentFamily: (component: unknown) => component, +})); + +// A second definition keeps each variable from being inlined, so it resolves at runtime +const decoy = `.decoy { --transition: opacity 9s; --transition-property: opacity; }`; + +const renderStyle = (className: string) => { + render(); + return StyleSheet.flatten(screen.getByTestId(testID).props.style) as + | Record + | undefined; +}; + +test.each([ + "width 1s ease-in 200ms", + "width 1s, opacity 2s linear", + "width, opacity", + "width 1s cubic-bezier(0.25, 0.5, 0.75, 1)", + "background-image 1s", + "all 300ms ease-out 50ms", + "1s", +])( + "a transition held in a variable expands like the written one: %s", + (value) => { + registerCSS(` + ${decoy} + .written { transition: ${value}; } + .held { --transition: ${value}; transition: var(--transition); } + `); + + const written = renderStyle("written"); + + expect(written).toHaveProperty("transitionDuration"); + expect(renderStyle("held")).toStrictEqual(written); + }, +); + +test("a transition names the style key each property is written to", () => { + registerCSS( + `.written { transition-property: width, background-image, border-inline-start-color; }`, + ); + + expect(renderStyle("written")).toStrictEqual({ + transitionProperty: [ + "width", + "experimental_backgroundImage", + "borderStartColor", + ], + }); +}); + +test("a transition-property held in a variable names the keys the written one names", () => { + registerCSS(` + ${decoy} + .written { transition-property: width, background-image, border-inline-start-color; } + .held { --transition-property: width, background-image, border-inline-start-color; transition-property: var(--transition-property); } + `); + + expect(renderStyle("held")).toStrictEqual(renderStyle("written")); +}); + +test.each([ + ["three times", "width 1s 2s 3s"], + ["two easing functions", "width 1s ease-in linear"], +])( + "a transition held in a variable with %s is invalid and writes no transition", + (_case, value) => { + registerCSS(` + ${decoy} + .held { --transition: ${value}; transition: var(--transition); } + `); + + expect(renderStyle("held")).toStrictEqual({}); + }, +); diff --git a/src/__tests__/utilities/property-names.test.ts b/src/__tests__/utilities/property-names.test.ts new file mode 100644 index 00000000..f64fb5e4 --- /dev/null +++ b/src/__tests__/utilities/property-names.test.ts @@ -0,0 +1,29 @@ +import { + propertyRename, + toRNProperty, + transitionStyleKey, +} from "react-native-css/utilities"; + +test("the rename census is not empty", () => { + expect(Object.keys(propertyRename).length).toBeGreaterThan(0); +}); + +test.each(Object.entries(propertyRename))( + "a transition on %s names the key it is written to", + (property, renamed) => { + expect(transitionStyleKey(property)).toBe(toRNProperty(renamed)); + }, +); + +test.each([ + ["width", "width"], + ["background-color", "backgroundColor"], + ["all", "all"], + ["none", "none"], +])("a transition on %s names %s", (property, key) => { + expect(transitionStyleKey(property)).toBe(key); +}); + +test("visibility names no key to animate", () => { + expect(transitionStyleKey("visibility")).toBeUndefined(); +}); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 62777dfb..f4787fa4 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -36,7 +36,12 @@ import type { UnresolvedColor, } from "lightningcss"; -import { isStyleFunction, toRNProperty } from "../utilities"; +import { + isStyleFunction, + propertyRename, + toRNProperty, + transitionStyleKey, +} from "../utilities"; import { isMappingProperty } from "./atRules"; import type { StyleDescriptor, @@ -60,19 +65,6 @@ type Parser = ( // eslint-disable-next-line @typescript-eslint/no-invalid-void-type ) => StyleDescriptor | void; -const propertyRename: Record = { - "background-image": "experimental_backgroundImage", - // React Native has no border-inline-* props, but ships the equivalent - // RTL-aware border-start-* / border-end-* props - "border-inline-end-color": "border-end-color", - "border-inline-end-width": "border-end-width", - "border-inline-start-color": "border-start-color", - "border-inline-start-width": "border-start-width", - "border-block-start-width": "border-top-width", - "border-block-end-width": "border-bottom-width", - "font-variant-caps": "font-variant", -}; - // React Native only supports a uniform borderStyle, so per-side border // styles have no native equivalent and are dropped. "solid" is dropped // silently as it matches React Native's default rendering. @@ -98,6 +90,8 @@ const unparsedRuntimeParsing = new Set([ "scale", "text-shadow", "transform", + "transition", + "transition-property", "translate", ]); @@ -3059,13 +3053,8 @@ export function addTransitionValue( declaration.property, declaration.value .map((v) => v.property) - .filter( - (v) => - (v in parsers && !["visibility"].includes(v)) || - v === "all" || - v === "none", - ) - .map((v) => toRNProperty(v)), + .filter((v) => v in parsers || v === "all" || v === "none") + .flatMap((v) => transitionStyleKey(v) ?? []), ); return; } diff --git a/src/native/styles/shorthands/index.ts b/src/native/styles/shorthands/index.ts index f9ed4196..0e6202bf 100644 --- a/src/native/styles/shorthands/index.ts +++ b/src/native/styles/shorthands/index.ts @@ -4,3 +4,4 @@ export * from "./logical-border-width"; export * from "./box-shadow"; export * from "./text-shadow"; export * from "./transform"; +export * from "./transition"; diff --git a/src/native/styles/shorthands/transition.ts b/src/native/styles/shorthands/transition.ts new file mode 100644 index 00000000..c804f1ce --- /dev/null +++ b/src/native/styles/shorthands/transition.ts @@ -0,0 +1,133 @@ +import { transitionStyleKey } from "react-native-css/utilities"; + +import { ShortHandSymbol } from "../constants"; +import type { StyleFunctionResolver } from "../resolve"; + +const EASING_KEYWORDS: ReadonlySet = new Set([ + "linear", + "ease", + "ease-in", + "ease-out", + "ease-in-out", + "step-start", + "step-end", +]); + +/** One `` (CSS Transitions 1 §2.5), whose components come in any order. */ +interface SingleTransition { + readonly property: string; + readonly duration: number; + readonly delay: number; + readonly timingFunction: unknown; +} + +function isPropertyName(token: unknown): token is string { + return typeof token === "string" && !EASING_KEYWORDS.has(token); +} + +function isTimingFunction(token: unknown): boolean { + return ( + (typeof token === "string" && EASING_KEYWORDS.has(token)) || + (typeof token === "object" && token !== null && !Array.isArray(token)) + ); +} + +/** The first time is the duration and the second the delay; anything else makes the transition invalid. */ +function readSingleTransition( + tokens: readonly unknown[], +): SingleTransition | undefined { + const properties = tokens.filter(isPropertyName); + const times = tokens.filter((token) => typeof token === "number"); + const timingFunctions = tokens.filter(isTimingFunction); + + if ( + properties.length > 1 || + times.length > 2 || + timingFunctions.length > 1 || + properties.length + times.length + timingFunctions.length !== tokens.length + ) { + return undefined; + } + + return { + property: properties[0] ?? "all", + duration: times[0] ?? 0, + delay: times[1] ?? 0, + timingFunction: timingFunctions[0] ?? "ease", + }; +} + +/** + * A variable's transition list as its comma groups. A group of one token arrives + * bare rather than as a list of one, so a list holding no list is one group + * unless a second property name opens the next. + */ +function readTransitionGroups(value: unknown): readonly (readonly unknown[])[] { + if (!Array.isArray(value)) { + return [[value]]; + } + + if (value.some((token) => Array.isArray(token))) { + return value.map((group): readonly unknown[] => + Array.isArray(group) ? group : [group], + ); + } + + const groups: unknown[][] = []; + let group: unknown[] = []; + + for (const token of value) { + if (isPropertyName(token) && group.some(isPropertyName)) { + groups.push(group); + group = []; + } + group.push(token); + } + + groups.push(group); + return groups; +} + +function isSingleTransition( + transition: SingleTransition | undefined, +): transition is SingleTransition { + return transition !== undefined; +} + +/** `transition` held in a variable, expanded into the longhands the compiler builds for a written one. */ +export const transition: StyleFunctionResolver = (resolveValue, func) => { + const parsed = readTransitionGroups(resolveValue(func[2])).map( + readSingleTransition, + ); + const transitions = parsed.filter(isSingleTransition); + + if (transitions.length === 0 || transitions.length !== parsed.length) { + return; + } + + const timingFunctions = transitions.map((single) => single.timingFunction); + + return { + [ShortHandSymbol]: true, + transitionProperty: transitions.flatMap( + (single) => transitionStyleKey(single.property) ?? [], + ), + transitionDuration: transitions.map((single) => single.duration), + transitionDelay: transitions.map((single) => single.delay), + transitionTimingFunction: + timingFunctions.length === 1 ? timingFunctions[0] : timingFunctions, + }; +}; + +/** `transition-property` held in a variable, naming the style keys a written one names. */ +export const transitionProperty: StyleFunctionResolver = ( + resolveValue, + func, +) => { + const value = resolveValue(func[2]); + const names = (Array.isArray(value) ? value.flat() : [value]).filter( + (name): name is string => typeof name === "string", + ); + + return names.flatMap((name) => transitionStyleKey(name) ?? []); +}; diff --git a/src/utilities/property-names.ts b/src/utilities/property-names.ts index ee8fabe2..1aed060a 100644 --- a/src/utilities/property-names.ts +++ b/src/utilities/property-names.ts @@ -1,3 +1,17 @@ +/** The React Native key a CSS property is written to, where it is not the property's own camelCase. */ +export const propertyRename: Record = { + "background-image": "experimental_backgroundImage", + // React Native has no border-inline-* props, but ships the equivalent + // RTL-aware border-start-* / border-end-* props + "border-inline-end-color": "border-end-color", + "border-inline-end-width": "border-end-width", + "border-inline-start-color": "border-start-color", + "border-inline-start-width": "border-start-width", + "border-block-start-width": "border-top-width", + "border-block-end-width": "border-bottom-width", + "font-variant-caps": "font-variant", +}; + type CamelCase = S extends `${infer P1}-${infer P2}${infer P3}` ? `${Lowercase}${Uppercase}${CamelCase}` @@ -8,3 +22,10 @@ export function toRNProperty(str: T) { .replace(/^-rn-/, "") .replace(/-./g, (x) => x[1]?.toUpperCase() ?? "") as CamelCase; } + +/** The style key a transition on `property` animates, which is the key the property is written to; `visibility` names none, as no style key carries it. */ +export function transitionStyleKey(property: string): string | undefined { + return property === "visibility" + ? undefined + : toRNProperty(propertyRename[property] ?? property); +}