From 55253748bcb7fc6fa7707aeddfbd280dd8bfb5b9 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 03:50:14 +0300 Subject: [PATCH 1/5] fix: match a dataSet value and a dir attribute the way the DOM does react-native-web writes a dataSet entry as a data-* attribute for every value but null and undefined, so [data-x] matches data-x="false" in a browser. The runtime treated false as absent there, as it rightly does for a boolean prop, and compared an array value as no value, where the DOM holds its comma-joined text. HTML compares a dir value ASCII case-insensitively, so [dir="RTL"] matches dir="rtl" unless the selector carries the s flag. The compiler emitted the operand as written, and the direction condition never matched it. It now folds the operand unless s is given. Both folds share one ASCII lowercasing helper, which the runtime's i flag also uses. --- src/__tests__/compiler/selectors.test.tsx | 16 +++++ src/__tests__/native/attributes.test.tsx | 46 ++++++++++++++ src/__tests__/native/direction-media.test.tsx | 14 +++++ src/compiler/selector-builder.ts | 8 ++- src/native/conditions/attributes.ts | 61 ++++++++++++------- src/utilities/ascii-case.ts | 4 ++ 6 files changed, 125 insertions(+), 24 deletions(-) create mode 100644 src/utilities/ascii-case.ts diff --git a/src/__tests__/compiler/selectors.test.tsx b/src/__tests__/compiler/selectors.test.tsx index 1df75cf0..bd77a42e 100644 --- a/src/__tests__/compiler/selectors.test.tsx +++ b/src/__tests__/compiler/selectors.test.tsx @@ -1,3 +1,5 @@ +import { compile } from "react-native-css/compiler"; + import { getClassNameSelectors } from "../../compiler/selector-builder"; test("empty", () => { @@ -150,3 +152,17 @@ test(".group-[.test]:text-white { &:is(:where(.group):is(.test) *) {}", () => { }, ]); }); + +// HTML compares a dir value ASCII case-insensitively unless the selector carries s +test.each([ + ['[dir="RTL"]', "rtl"], + ['[dir="Rtl"]', "rtl"], + ['[dir="rtl"]', "rtl"], + ['[dir="RTL" i]', "rtl"], + ['[dir="RTL" s]', "RTL"], +])(".t%s compiles to the direction %p", (attribute, direction) => { + const rule = compile(`.t${attribute} { color: red }`).stylesheet() + .s?.[0]?.[1][0]; + + expect(rule?.m).toStrictEqual([["=", "dir", direction]]); +}); diff --git a/src/__tests__/native/attributes.test.tsx b/src/__tests__/native/attributes.test.tsx index 900dde70..222ae632 100644 --- a/src/__tests__/native/attributes.test.tsx +++ b/src/__tests__/native/attributes.test.tsx @@ -280,3 +280,49 @@ test.each(["=", "|=", "~=", "^=", "$=", "*="] as const)( expect(screen.getByTestId(testID).props.style.width).toBe(40); }, ); + +// react-native-web writes a dataSet value as a data-* attribute unless it is null or undefined +describe("a dataSet value projects as react-native-web writes it", () => { + test.each([ + [false, true], + [0, true], + ["", true], + [null, false], + [undefined, false], + ])("[data-test] over %p is present: %s", (value, present) => { + registerCSS(`.test { width: 40px; } .test[data-test] { width: 80px; }`); + render( + , + ); + expect(screen.getByTestId(testID).props.style.width).toBe( + present ? 80 : 40, + ); + }); + + test("a false plain prop is an absent boolean attribute", () => { + registerCSS(`.test { width: 40px; } .test[disabled] { width: 80px; }`); + render(); + expect(screen.getByTestId(testID).props.style.width).toBe(40); + }); + + test.each([ + ['[data-test="a,b"]', true], + ['[data-test~="a"]', false], + ])("%s over the array [a, b] matches: %s", (attribute, matches) => { + registerCSS(`.test { width: 40px; } .test${attribute} { width: 80px; }`); + render( + , + ); + expect(screen.getByTestId(testID).props.style.width).toBe( + matches ? 80 : 40, + ); + }); +}); diff --git a/src/__tests__/native/direction-media.test.tsx b/src/__tests__/native/direction-media.test.tsx index 8ed38ee4..014c087a 100644 --- a/src/__tests__/native/direction-media.test.tsx +++ b/src/__tests__/native/direction-media.test.tsx @@ -33,3 +33,17 @@ test.each([false, true])( expect(screen.getByTestId("ltr")).toHaveStyle({ width: isRTL ? 10 : 37 }); }, ); + +test.each([ + ['[dir="RTL"]', true], + ['[dir="RTL" i]', true], + ['[dir="RTL" s]', false], +])(".t%s matches a right-to-left layout: %s", (attribute, matches) => { + Object.defineProperty(I18nManager, "isRTL", { + configurable: true, + value: true, + }); + registerCSS(`.t { width: 10px; } .t${attribute} { width: 37px; }`); + render(); + expect(screen.getByTestId("t")).toHaveStyle({ width: matches ? 37 : 10 }); +}); diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 6787c0ca..f66a5c1c 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -1,6 +1,7 @@ import type { AttrOperation, Selector, SelectorList } from "lightningcss"; import { Specificity } from "../utilities"; +import { asciiLowerCase } from "../utilities/ascii-case"; import type { AttributeQuery, AttrSelectorOperator, @@ -249,7 +250,12 @@ function parseComponents( return []; } - getMediaQuery(ref).push([operator, "dir", component.operation.value]); + // HTML compares a dir value ASCII case-insensitively unless the selector carries s + const direction = + component.operation.caseSensitivity === "explicit-case-sensitive" + ? component.operation.value + : asciiLowerCase(component.operation.value); + getMediaQuery(ref).push([operator, "dir", direction]); return parseComponents(rest, options, root, ref, specificity); } else { // specificity[Specificity.ClassName] = diff --git a/src/native/conditions/attributes.ts b/src/native/conditions/attributes.ts index e856e79e..f6b53ec5 100644 --- a/src/native/conditions/attributes.ts +++ b/src/native/conditions/attributes.ts @@ -1,7 +1,10 @@ import type { AttributeQuery } from "react-native-css/compiler"; +import { asciiLowerCase } from "../../utilities/ascii-case"; import type { RenderGuard } from "./guards"; +type AttributeQueryType = AttributeQuery[0]; + export function testAttributes( queries: AttributeQuery[], props: Record | undefined | null, @@ -29,43 +32,55 @@ function testAttribute( guards.push([type, prop, value]); if (!operator) { - return value !== undefined && value !== null && value !== false; + return isPresent(type, value); } if (operator === "!") return !value; - if (value === undefined || value === null || testValue === undefined) - return false; - if ( - typeof value !== "string" && - typeof value !== "number" && - typeof value !== "boolean" - ) - return false; - let actual = String(value); - if (caseSensitivity === "i") { - // CSS attribute flags fold ASCII letters only, not Unicode characters. - actual = actual.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); - testValue = testValue.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); - } + const attribute = attributeText(type, value); + if (attribute === undefined || testValue === undefined) return false; + + const insensitive = caseSensitivity === "i"; + const actual = insensitive ? asciiLowerCase(attribute) : attribute; + const expected = insensitive ? asciiLowerCase(testValue) : testValue; switch (operator) { case "=": - return actual === testValue; + return actual === expected; case "~=": - return ( - testValue !== "" && actual.split(/[\t\n\f\r ]+/).includes(testValue) - ); + return expected !== "" && actual.split(/[\t\n\f\r ]+/).includes(expected); case "|=": - return actual === testValue || actual.startsWith(testValue + "-"); + return actual === expected || actual.startsWith(expected + "-"); case "^=": - return testValue !== "" && actual.startsWith(testValue); + return expected !== "" && actual.startsWith(expected); case "$=": - return testValue !== "" && actual.endsWith(testValue); + return expected !== "" && actual.endsWith(expected); case "*=": - return testValue !== "" && actual.includes(testValue); + return expected !== "" && actual.includes(expected); default: operator satisfies never; return false; } } + +// react-native-web writes a dataSet entry as a data-* attribute for every value but null and undefined, while a false prop is an absent boolean attribute +function isPresent(type: AttributeQueryType, value: unknown): boolean { + return ( + value !== undefined && value !== null && (type === "d" || value !== false) + ); +} + +function attributeText( + type: AttributeQueryType, + value: unknown, +): string | undefined { + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return String(value); + } + + return type === "d" && Array.isArray(value) ? value.join(",") : undefined; +} diff --git a/src/utilities/ascii-case.ts b/src/utilities/ascii-case.ts new file mode 100644 index 00000000..748b92c5 --- /dev/null +++ b/src/utilities/ascii-case.ts @@ -0,0 +1,4 @@ +// Selectors §6.3 and HTML fold ASCII letters only: toLowerCase would also fold U+212A KELVIN SIGN onto k +export function asciiLowerCase(text: string): string { + return text.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); +} From e5c3148b27de373a099c77225387480b391fa3e6 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 03:54:26 +0300 Subject: [PATCH 2/5] refactor(compiler): build an attribute query in one place The compound path and the :is() / :where() path each built the same query from an attribute selector, one with its own copy of the operator map. Both now call one builder. --- src/compiler/selector-builder.ts | 102 +++++++++---------------------- 1 file changed, 28 insertions(+), 74 deletions(-) diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index f66a5c1c..02bcc7b1 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -260,58 +260,7 @@ function parseComponents( } else { // specificity[Specificity.ClassName] = // (specificity[Specificity.ClassName] ?? 0) + 1; - const attributeQuery: AttributeQuery = component.name.startsWith( - "data-", - ) - ? // [data-*] are turned into `dataSet` queries - ["d", toRNProperty(component.name.replace("data-", ""))] - : // Everything else is turned into `attribute` queries - [ - "a", - component.name.startsWith("aria-") - ? component.name - : 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) { - // Append the operator onto the attribute query - attributeQuery.push(operator, component.operation.value); - if ( - component.operation.caseSensitivity === "ascii-case-insensitive" - ) { - attributeQuery.push("i"); - } else if ( - component.operation.caseSensitivity === "explicit-case-sensitive" - ) { - attributeQuery.push("s"); - } - } - } - getAttributeQuery(ref).push(attributeQuery); + getAttributeQuery(ref).push(attributeQueryFor(component)); specificity[Specificity.ClassName] = (specificity[Specificity.ClassName] ?? 0) + 1; return parseComponents(rest, options, root, ref, specificity); @@ -480,28 +429,7 @@ function parseIsWhereComponents( // specificity[Specificity.ClassName] = // (specificity[Specificity.ClassName] ?? 0) + 1; } - const attributeQuery: AttributeQuery = component.name.startsWith("data-") - ? // [data-*] are turned into `dataSet` queries - ["d", toRNProperty(component.name.replace("data-", ""))] - : // Everything else is turned into `attribute` queries - [ - "a", - component.name.startsWith("aria-") - ? component.name - : toRNProperty(component.name), - ]; - if (component.operation) { - const operator = operatorMap[component.operation.operator]; - // Append the operator onto the attribute query - attributeQuery.push(operator, component.operation.value); - if (component.operation.caseSensitivity === "ascii-case-insensitive") { - attributeQuery.push("i"); - } else if ( - component.operation.caseSensitivity === "explicit-case-sensitive" - ) { - attributeQuery.push("s"); - } - } + const attributeQuery = attributeQueryFor(component); queries ??= [{ specificity: [] }]; for (const query of queries) { if (type === "is") { @@ -614,6 +542,32 @@ type CamelCase = ? `${Lowercase}${Uppercase}${CamelCase}` : Lowercase; +function attributeQueryFor( + component: Extract, +): AttributeQuery { + const query: AttributeQuery = component.name.startsWith("data-") + ? ["d", toRNProperty(component.name.replace("data-", ""))] + : [ + "a", + component.name.startsWith("aria-") + ? component.name + : toRNProperty(component.name), + ]; + + const operation = component.operation; + if (!operation) { + return query; + } + + query.push(operatorMap[operation.operator], operation.value); + if (operation.caseSensitivity === "ascii-case-insensitive") { + query.push("i"); + } else if (operation.caseSensitivity === "explicit-case-sensitive") { + query.push("s"); + } + return query; +} + const operatorMap: Record = { "equal": "=", "includes": "~=", From 291ce56d31122fc22a65343a3555f938f0edddf7 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 03:55:20 +0300 Subject: [PATCH 3/5] =?UTF-8?q?fix(compiler):=20read=20[class=3D=E2=80=A6]?= =?UTF-8?q?=20from=20the=20prop=20the=20class=20list=20arrives=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Native carries the class list as className, so an attribute query reading props.class answered false for every element, and [class~="x"], [class*="x"] and [class] never matched. The attribute builder maps class to className. --- src/__tests__/compiler/selectors.test.tsx | 26 ++++++++++++++++++ src/__tests__/native/attributes.test.tsx | 33 +++++++++++++++++++++++ src/compiler/selector-builder.ts | 16 ++++++----- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/__tests__/compiler/selectors.test.tsx b/src/__tests__/compiler/selectors.test.tsx index bd77a42e..8088dac4 100644 --- a/src/__tests__/compiler/selectors.test.tsx +++ b/src/__tests__/compiler/selectors.test.tsx @@ -166,3 +166,29 @@ test.each([ expect(rule?.m).toStrictEqual([["=", "dir", direction]]); }); + +test.each([ + ['[class="a b"]', ["a", "className", "=", "a b"]], + ['[class~="b"]', ["a", "className", "~=", "b"]], + ['[class*="b"]', ["a", "className", "*=", "b"]], + ["[class]", ["a", "className"]], + ['[data-state="open"]', ["d", "state", "=", "open"]], + ["[disabled]", ["a", "disabled"]], +])(".t%s reads the prop it arrives on", (attribute, query) => { + const rule = compile(`.t${attribute} { color: red }`).stylesheet() + .s?.[0]?.[1][0]; + + expect(rule?.aq).toStrictEqual([query]); +}); + +test("an ancestor [class~=…] reads className through the :is() site", () => { + const rule = compile( + `.child:is(:where(.group)[class~="active"] *) { color: red }`, + ) + .stylesheet() + .s?.find(([name]) => name === "child")?.[1][0]; + + expect(rule?.cq).toStrictEqual([ + { n: "g:group", a: [["a", "className", "~=", "active"]] }, + ]); +}); diff --git a/src/__tests__/native/attributes.test.tsx b/src/__tests__/native/attributes.test.tsx index 222ae632..2c4288d9 100644 --- a/src/__tests__/native/attributes.test.tsx +++ b/src/__tests__/native/attributes.test.tsx @@ -326,3 +326,36 @@ describe("a dataSet value projects as react-native-web writes it", () => { ); }); }); + +test.each([ + ["[class='test example']", "example", true], + ["[class='example']", "example", false], + ["[class~='example']", "example", true], + ["[class~='example']", "other", false], + ["[class*='xamp']", "example", true], + ["[class]", "example", true], +])( + ".test%s over the class list test %s matches: %s", + (selector, name, matches) => { + registerCSS(`.test { width: 40px; } .test${selector} { width: 80px; }`); + render(); + expect(screen.getByTestId(testID).props.style.width).toBe( + matches ? 80 : 40, + ); + }, +); + +test.each([ + ["group active", true], + ["group", false], +])("an ancestor class list %p answers [class~=…]: %s", (name, matches) => { + registerCSS( + `.child { width: 40px; } .child:is(:where(.group)[class~="active"] *) { width: 80px; }`, + ); + render( + + + , + ); + expect(screen.getByTestId(testID).props.style.width).toBe(matches ? 80 : 40); +}); diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 02bcc7b1..cc8b8684 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -542,17 +542,21 @@ type CamelCase = ? `${Lowercase}${Uppercase}${CamelCase}` : Lowercase; +// React Native names the class list className, and keeps an aria-* prop as written +function attributePropName(name: string): string { + if (name === "class") { + return "className"; + } + + return name.startsWith("aria-") ? name : toRNProperty(name); +} + function attributeQueryFor( component: Extract, ): AttributeQuery { const query: AttributeQuery = component.name.startsWith("data-") ? ["d", toRNProperty(component.name.replace("data-", ""))] - : [ - "a", - component.name.startsWith("aria-") - ? component.name - : toRNProperty(component.name), - ]; + : ["a", attributePropName(component.name)]; const operation = component.operation; if (!operation) { From 590d395eed9656d45c22cdd5a4059717335e2311 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 04:00:42 +0300 Subject: [PATCH 4/5] fix(compiler): drop a namespace-qualified attribute selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [ns|att] represents only an attribute in the namespace ns (Selectors 4 §6.4), and a React Native prop is in no namespace, so the selector can match nothing. The compiler ignored the namespace and read the prop, so [ns|data-x="a"] matched where every browser matches nothing. An undeclared prefix is an invalid selector, with the same outcome. The attribute builder now answers no query for a namespace-qualified attribute, and both of its callers drop the selector; [|att] and [*|att] are unchanged. --- src/__tests__/compiler/selectors.test.tsx | 33 +++++++++++++++++++++++ src/__tests__/native/attributes.test.tsx | 12 +++++++++ src/compiler/selector-builder.ts | 16 +++++++++-- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/__tests__/compiler/selectors.test.tsx b/src/__tests__/compiler/selectors.test.tsx index 8088dac4..3414b2a2 100644 --- a/src/__tests__/compiler/selectors.test.tsx +++ b/src/__tests__/compiler/selectors.test.tsx @@ -192,3 +192,36 @@ test("an ancestor [class~=…] reads className through the :is() site", () => { { n: "g:group", a: [["a", "className", "~=", "active"]] }, ]); }); + +test.each([ + ['[data-x="a"]', ["test"]], + ['[|data-x="a"]', ["test"]], + ['[*|data-x="a"]', ["test"]], + ['[undeclared|data-x="a"]', []], +])(".test%s emits the rules %p", (attribute, rules) => { + expect( + compile(`.test${attribute} { width: 10px }`) + .stylesheet() + .s?.map(([name]) => name) ?? [], + ).toStrictEqual(rules); +}); + +test("a declared namespace prefix emits no rule, and a sibling rule survives", () => { + expect( + compile( + `@namespace ns url(http://example.com/ns); .dropped[ns|data-x="a"] { width: 10px } .kept[data-x="a"] { width: 10px }`, + ) + .stylesheet() + .s?.map(([name]) => name), + ).toStrictEqual(["kept"]); +}); + +test("an ancestor's namespace-qualified attribute emits no rule through the :is() site", () => { + expect( + compile( + `.child:is(:where(.group)[undeclared|data-x="a"] *) { width: 10px }`, + ) + .stylesheet() + .s?.find(([name]) => name === "child"), + ).toBeUndefined(); +}); diff --git a/src/__tests__/native/attributes.test.tsx b/src/__tests__/native/attributes.test.tsx index 2c4288d9..7d9c9ca5 100644 --- a/src/__tests__/native/attributes.test.tsx +++ b/src/__tests__/native/attributes.test.tsx @@ -359,3 +359,15 @@ test.each([ ); expect(screen.getByTestId(testID).props.style.width).toBe(matches ? 80 : 40); }); + +test.each([ + ['[|data-x="a"]', true], + ['[*|data-x="a"]', true], + ['[undeclared|data-x="a"]', false], +])(".test%s over data-x=a matches: %s", (attribute, matches) => { + registerCSS(`.test { width: 40px; } .test${attribute} { width: 80px; }`); + render( + , + ); + expect(screen.getByTestId(testID).props.style.width).toBe(matches ? 80 : 40); +}); diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index cc8b8684..3a5d287f 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -260,7 +260,11 @@ function parseComponents( } else { // specificity[Specificity.ClassName] = // (specificity[Specificity.ClassName] ?? 0) + 1; - getAttributeQuery(ref).push(attributeQueryFor(component)); + const attributeQuery = attributeQueryFor(component); + if (!attributeQuery) { + return []; + } + getAttributeQuery(ref).push(attributeQuery); specificity[Specificity.ClassName] = (specificity[Specificity.ClassName] ?? 0) + 1; return parseComponents(rest, options, root, ref, specificity); @@ -430,6 +434,9 @@ function parseIsWhereComponents( // (specificity[Specificity.ClassName] ?? 0) + 1; } const attributeQuery = attributeQueryFor(component); + if (!attributeQuery) { + return null; + } queries ??= [{ specificity: [] }]; for (const query of queries) { if (type === "is") { @@ -551,9 +558,14 @@ function attributePropName(name: string): string { return name.startsWith("aria-") ? name : toRNProperty(name); } +// A React Native prop is in no namespace, so [ns|att] represents nothing (Selectors 4 §6.4) function attributeQueryFor( component: Extract, -): AttributeQuery { +): AttributeQuery | undefined { + if (component.namespace?.type === "specific") { + return undefined; + } + const query: AttributeQuery = component.name.startsWith("data-") ? ["d", toRNProperty(component.name.replace("data-", ""))] : ["a", attributePropName(component.name)]; From f74e7ad5373901d21c0f0df1adfe4262130ba51b Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 04:13:34 +0300 Subject: [PATCH 5/5] fix(native): answer :dir() per element, and compile every spelling of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :dir() matches an element's own directionality (Selectors 4 §7.1): its dir, or else its nearest ancestor's. React Native had no per-element direction, so the runtime answered every direction condition from I18nManager for the whole app, and a View with dir="ltr" inside a right-to-left app still matched :dir(rtl). View and Text take a dir prop. An element publishes the directions declared on it and its ancestors, nearest first, and takes the user-agent rule [dir] { direction }. The platform's direction is the root's directionality and no element's dir, as for a document whose html carries none. The compiler emits each spelling as the question it asks. :dir() on the element is its directionality and [dir] on the element is its own prop. [dir] on an ancestor asks whether any ancestor declares it, :dir() on an ancestor adds the root's directionality, and html:dir() is the root's. Tailwind's rtl: and ltr: arms become one rule each, so rtl: reaches into an ltr island as it does in a browser. A directionality the runtime cannot place on its ancestor compiles to nothing. --- src/__tests__/compiler/directionality.test.ts | 425 ++++++++++++ src/__tests__/compiler/selectors.test.tsx | 14 +- src/__tests__/native/direction-media.test.tsx | 13 +- src/__tests__/native/directionality.test.tsx | 608 ++++++++++++++++++ src/compiler/compiler.types.ts | 5 +- src/compiler/selector-builder.ts | 410 +++++++++--- src/native-internal/root.ts | 1 + src/native/conditions/directionality.ts | 100 +++ src/native/conditions/index.ts | 4 +- src/native/conditions/media-query.ts | 48 +- src/native/react/rules.ts | 39 ++ src/utilities/specificity.ts | 4 + types.d.ts | 3 + 13 files changed, 1571 insertions(+), 103 deletions(-) create mode 100644 src/__tests__/compiler/directionality.test.ts create mode 100644 src/__tests__/native/directionality.test.tsx create mode 100644 src/native/conditions/directionality.ts diff --git a/src/__tests__/compiler/directionality.test.ts b/src/__tests__/compiler/directionality.test.ts new file mode 100644 index 00000000..00a186c4 --- /dev/null +++ b/src/__tests__/compiler/directionality.test.ts @@ -0,0 +1,425 @@ +import { compile } from "react-native-css/compiler"; + +const RTL = ["=", "dir", "rtl"] as const; +const LTR = ["=", "dir", "ltr"] as const; +const ANCESTOR_RTL = ["=", "ancestor-dir", "rtl"] as const; +const ROOT_RTL = ["=", "root-dir", "rtl"] as const; +const SELF_RTL = ["a", "dir", "=", "rtl", "i"] as const; +const PADDING = { paddingLeft: 4 } as const; + +describe("the subject compound", () => { + test("a bare :dir() compiles to the dir condition", () => { + expect( + compile(`.bare:dir(rtl) { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [["bare", [{ s: [1, 2], d: [PADDING], m: [RTL] }]]], + }); + }); + + test("both directions are answerable", () => { + expect( + compile(`.bare:dir(ltr) { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [["bare", [{ s: [1, 2], d: [PADDING], m: [LTR] }]]], + }); + }); + + test("a [dir] attribute is the element's own prop, read ASCII-case-insensitively", () => { + expect( + compile(`.self[dir="rtl"] { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "self", + [{ s: [1, 2], d: [PADDING], aq: [["a", "dir", "=", "rtl", "i"]] }], + ], + ], + }); + }); + + test(":dir() composes with the other pseudo-classes on the element", () => { + expect( + compile(`.hover:dir(rtl):hover { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [["hover", [{ s: [1, 3], d: [PADDING], m: [RTL], p: { h: 1 } }]]], + }); + }); +}); + +describe("an ancestor compound", () => { + test("a [dir] attribute on an ancestor asks whether any ancestor declares it", () => { + expect( + compile(`[dir="rtl"] .descendant { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [["descendant", [{ s: [1, 2], d: [PADDING], m: [ANCESTOR_RTL] }]]], + }); + }); + + test("a :dir() on an ancestor is the root's directionality or any ancestor's declaration", () => { + expect( + compile(`:dir(rtl) .descendant { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "descendant", + [ + { + s: [1, 2], + d: [PADDING], + m: [["|", [ROOT_RTL, ANCESTOR_RTL]]], + }, + ], + ], + ], + }); + }); + + test("html:dir() is the root's directionality", () => { + expect( + compile(`html:dir(rtl) .descendant { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [["descendant", [{ s: [1, 2], d: [PADDING], m: [ROOT_RTL] }]]], + }); + }); + + test("html[dir] matches nothing, since the root's direction is the platform's and no attribute", () => { + expect( + compile( + `html[dir="rtl"] .descendant { padding-left: 4px; }`, + ).stylesheet(), + ).toStrictEqual({}); + }); + + test("a [dir] beside an ancestor class is that container's own prop", () => { + expect( + compile(`.group[dir="rtl"] .item { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [ + ["group", [{ s: [0], c: ["g:group"] }]], + [ + "item", + [ + { + s: [1, 3], + d: [PADDING], + cq: [{ n: "g:group", a: [SELF_RTL] }], + }, + ], + ], + ], + }); + }); + + test("a directionality the runtime cannot place on its ancestor matches nothing", () => { + const names = + compile(` + .group:dir(rtl) .item { padding-left: 4px; } + [dir="rtl"] .group .outer { padding-left: 4px; } + .group [dir="rtl"] .inner { padding-left: 4px; } + [dir="rtl"][data-state="on"] .both { padding-left: 4px; } + `) + .stylesheet() + .s?.map(([name]) => name) ?? []; + + expect(names.filter((name) => name !== "group")).toStrictEqual([]); + }); +}); + +describe(":is() and :where()", () => { + test("Tailwind's rtl: variant compiles each arm to what it asks", () => { + expect( + compile( + `.tw:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { padding-left: 4px; }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "tw", + [ + { s: [1, 1], d: [PADDING], m: [RTL] }, + { s: [1, 1], d: [PADDING], aq: [SELF_RTL] }, + { s: [1, 1], d: [PADDING], m: [ANCESTOR_RTL] }, + ], + ], + ], + }); + }); + + test("a [dir] attribute inside :where() is the element's own prop, with :where()'s specificity", () => { + expect( + compile(`.where:where([dir="rtl"]) { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [["where", [{ s: [1, 1], d: [PADDING], aq: [SELF_RTL] }]]], + }); + }); + + test("each arm of :is() carries its own specificity", () => { + expect( + compile( + `.is:is(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { padding-left: 4px; }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "is", + [ + { s: [1, 2], d: [PADDING], m: [RTL] }, + { s: [1, 2], d: [PADDING], aq: [SELF_RTL] }, + { s: [1, 2], d: [PADDING], m: [ANCESTOR_RTL] }, + ], + ], + ], + }); + }); + + test("an ancestor :dir() arm is the root's directionality or any ancestor's declaration", () => { + expect( + compile(`.where:where(:dir(rtl) *) { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "where", + [ + { + s: [1, 1], + d: [PADDING], + m: [["|", [ROOT_RTL, ANCESTOR_RTL]]], + }, + ], + ], + ], + }); + }); + + test("a self [dir] arm with any value or operator is an attribute query on the element", () => { + expect( + compile(` + .auto:where([dir="auto"]) { padding-left: 4px; } + .present:where([dir]) { padding-left: 4px; } + `).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "auto", + [{ s: [1, 1], d: [PADDING], aq: [["a", "dir", "=", "auto", "i"]] }], + ], + ["present", [{ s: [1, 1], d: [PADDING], aq: [["a", "dir"]] }]], + ], + }); + }); + + test("an arm asking more than a directionality matches nothing", () => { + expect( + compile(` + .descendant:where([dir="rtl"] .other) { padding-left: 4px; } + .compound:where(.other:dir(rtl)) { padding-left: 4px; } + .child:where([dir="rtl"] > *) { padding-left: 4px; } + `).stylesheet(), + ).toStrictEqual({}); + }); + + test("a directionality arm on an ancestor compound matches nothing", () => { + expect( + compile( + `.group:where(:dir(rtl), [dir="rtl"]) .item { padding-left: 4px; }`, + ) + .stylesheet() + .s?.find(([name]) => name === "item"), + ).toBeUndefined(); + }); + + test("lightningcss flattens a one-argument :is(), so its [dir] is the element's own", () => { + expect( + compile(`.is:is([dir="rtl"]) { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "is", + [{ s: [1, 2], d: [PADDING], aq: [["a", "dir", "=", "rtl", "i"]] }], + ], + ], + }); + }); + + test("arms that are not the same query stay separate rules", () => { + expect( + compile( + `.mixed:is(:dir(rtl), .group) { padding-left: 4px; }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "mixed", + [ + { s: [1, 2], d: [PADDING], m: [RTL] }, + { s: [1, 2], d: [PADDING], cq: [{ n: "g:group" }] }, + ], + ], + ["group", [{ s: [0], c: ["g:group"] }]], + ], + }); + }); +}); + +describe("media queries", () => { + test("the (dir) media feature is the same condition", () => { + expect( + compile( + `@media (dir: rtl) { .media { padding-left: 4px; } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [["media", [{ s: [2, 1], m: [RTL], d: [PADDING] }]]], + }); + }); + + test("a negated feature keeps its negation", () => { + expect( + compile( + `@media not (dir: rtl) { .media { padding-left: 4px; } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [["media", [{ s: [2, 1], m: [["!", RTL]], d: [PADDING] }]]], + }); + }); + + test("an author's own `and` is carried whole beside the dir condition", () => { + expect( + compile(` + @media (min-width: 10px) and (prefers-color-scheme: dark) { + .both:dir(rtl) { padding-left: 4px; } + } + `).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "both", + [ + { + s: [2, 2], + m: [ + [ + "&", + [ + [">=", "width", 10], + ["=", "prefers-color-scheme", "dark"], + ], + ], + RTL, + ], + d: [PADDING], + }, + ], + ], + ], + }); + }); + + test("a dir condition composes with a media query and a container query", () => { + expect( + compile(` + @media (prefers-color-scheme: dark) { + .group .dark:dir(rtl) { padding-left: 8px; } + } + `).stylesheet(), + ).toStrictEqual({ + s: [ + ["group", [{ s: [0], c: ["g:group"] }]], + [ + "dark", + [ + { + s: [2, 3], + m: [["=", "prefers-color-scheme", "dark"], RTL], + d: [{ paddingLeft: 8 }], + cq: [{ n: "g:group" }], + }, + ], + ], + ], + }); + }); +}); + +describe("an ancestor's [dir] value", () => { + test("is read ASCII-case-insensitively, as HTML defines the attribute", () => { + const lower = compile( + `[dir="rtl"] .upper { padding-left: 4px; }`, + ).stylesheet(); + + expect( + compile(`[dir="RTL"] .upper { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual(lower); + expect( + compile(`[dir="RTL" i] .upper { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual(lower); + }); + + test("the selector's own s flag is honoured, so only a lower-case value matches a declaration", () => { + expect( + compile(`[dir="RTL" s] .upper { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({}); + expect( + compile(`[dir="rtl" s] .lower { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [["lower", [{ s: [1, 2], d: [PADDING], m: [ANCESTOR_RTL] }]]], + }); + }); + + test("a value the engine cannot answer matches nothing", () => { + expect( + compile(` + [dir="auto"] .auto { padding-left: 4px; } + [dir] .present { padding-left: 4px; } + `).stylesheet(), + ).toStrictEqual({}); + }); + + test("every operator but equality matches nothing, so a partial match never widens the rule", () => { + expect( + compile(` + [dir^="r"] .prefix { padding-left: 4px; } + [dir$="tl"] .suffix { padding-left: 4px; } + [dir*="rtl"] .substring { padding-left: 4px; } + [dir~="rtl"] .includes { padding-left: 4px; } + [dir|="rtl"] .dash { padding-left: 4px; } + `).stylesheet(), + ).toStrictEqual({}); + }); + + test("a negated :dir() is outside the builder's negation and matches nothing", () => { + expect( + compile(`.not:not(:dir(rtl)) { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({}); + }); + + test("an unanswerable ancestor arm emits no rule", () => { + expect( + compile(` + .whereauto:where([dir="auto"] *) { padding-left: 4px; } + .wherepresent:where([dir] *) { padding-left: 4px; } + .whereop:where([dir^="r"] *) { padding-left: 4px; } + `).stylesheet(), + ).toStrictEqual({}); + }); + + test("an unanswerable arm drops only its own rule, leaving its siblings", () => { + expect( + compile( + `.mixedauto:is([dir="auto"] *, :dir(rtl)) { padding-left: 4px; }`, + ).stylesheet(), + ).toStrictEqual({ + s: [["mixedauto", [{ s: [1, 2], d: [PADDING], m: [RTL] }]]], + }); + }); +}); + +describe("determinism", () => { + test("compiling the same stylesheet twice yields equal output", () => { + const css = ` + .tw:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { padding-left: 4px; } + [dir="ltr"] .tw { padding-left: 8px; } + `; + + expect(compile(css).stylesheet()).toStrictEqual(compile(css).stylesheet()); + }); +}); diff --git a/src/__tests__/compiler/selectors.test.tsx b/src/__tests__/compiler/selectors.test.tsx index 3414b2a2..a4c9111d 100644 --- a/src/__tests__/compiler/selectors.test.tsx +++ b/src/__tests__/compiler/selectors.test.tsx @@ -155,16 +155,16 @@ test(".group-[.test]:text-white { &:is(:where(.group):is(.test) *) {}", () => { // HTML compares a dir value ASCII case-insensitively unless the selector carries s test.each([ - ['[dir="RTL"]', "rtl"], - ['[dir="Rtl"]', "rtl"], - ['[dir="rtl"]', "rtl"], - ['[dir="RTL" i]', "rtl"], - ['[dir="RTL" s]', "RTL"], -])(".t%s compiles to the direction %p", (attribute, direction) => { + ['[dir="RTL"]', ["a", "dir", "=", "RTL", "i"]], + ['[dir="rtl"]', ["a", "dir", "=", "rtl", "i"]], + ['[dir="RTL" i]', ["a", "dir", "=", "RTL", "i"]], + ['[dir="RTL" s]', ["a", "dir", "=", "RTL", "s"]], + ["[dir]", ["a", "dir"]], +])(".t%s reads the element's own dir prop", (attribute, query) => { const rule = compile(`.t${attribute} { color: red }`).stylesheet() .s?.[0]?.[1][0]; - expect(rule?.m).toStrictEqual([["=", "dir", direction]]); + expect(rule?.aq).toStrictEqual([query]); }); test.each([ diff --git a/src/__tests__/native/direction-media.test.tsx b/src/__tests__/native/direction-media.test.tsx index 014c087a..d8b78bad 100644 --- a/src/__tests__/native/direction-media.test.tsx +++ b/src/__tests__/native/direction-media.test.tsx @@ -34,16 +34,17 @@ test.each([false, true])( }, ); -test.each([ - ['[dir="RTL"]', true], - ['[dir="RTL" i]', true], - ['[dir="RTL" s]', false], -])(".t%s matches a right-to-left layout: %s", (attribute, matches) => { +test.each<[string, "rtl" | undefined, boolean]>([ + ['[dir="RTL"]', "rtl", true], + ['[dir="RTL" i]', "rtl", true], + ['[dir="RTL" s]', "rtl", false], + ['[dir="rtl"]', undefined, false], +])(".t%s over the dir prop %p matches: %s", (attribute, direction, matches) => { Object.defineProperty(I18nManager, "isRTL", { configurable: true, value: true, }); registerCSS(`.t { width: 10px; } .t${attribute} { width: 37px; }`); - render(); + render(); expect(screen.getByTestId("t")).toHaveStyle({ width: matches ? 37 : 10 }); }); diff --git a/src/__tests__/native/directionality.test.tsx b/src/__tests__/native/directionality.test.tsx new file mode 100644 index 00000000..154707af --- /dev/null +++ b/src/__tests__/native/directionality.test.tsx @@ -0,0 +1,608 @@ +import { useEffect, type ReactElement } from "react"; +import { I18nManager, type StyleProp, type ViewStyle } from "react-native"; + +import { render, screen } from "@testing-library/react-native"; +import { compile } from "react-native-css/compiler"; +import { Text } from "react-native-css/components/Text"; +import { View } from "react-native-css/components/View"; +import { registerCSS } from "react-native-css/jest"; +import { + resolveUaDirectionRule, + type Directionality, +} from "react-native-css/native/conditions/directionality"; +import { specificityCompareFn } from "react-native-css/utilities"; + +const originalIsRTL = Object.getOwnPropertyDescriptor(I18nManager, "isRTL"); + +afterEach(() => { + if (originalIsRTL) { + Object.defineProperty(I18nManager, "isRTL", originalIsRTL); + } +}); + +const answerIsRTL = (isRTL: boolean) => { + Object.defineProperty(I18nManager, "isRTL", { + configurable: true, + value: isRTL, + }); +}; + +/** Tailwind's `rtl:` / `ltr:` output, verbatim. */ +const DIRECTION_CSS = ` + .paint:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { color: red; } + .paint:where(:dir(ltr), [dir="ltr"], [dir="ltr"] *) { color: blue; } +`; + +const RTL = { color: "#f00" }; +const LTR = { color: "#00f" }; + +interface Tree { + name: string; + ancestors: readonly (Directionality | undefined)[]; + own?: Directionality; +} + +const TREES: readonly Tree[] = [ + { name: "x", ancestors: [] }, + { name: "rtl > x", ancestors: ["rtl"] }, + { name: "ltr > x", ancestors: ["ltr"] }, + { name: "rtl > ltr > x", ancestors: ["rtl", "ltr"] }, + { name: "ltr > rtl > x", ancestors: ["ltr", "rtl"] }, + { name: "rtl > x[ltr]", ancestors: ["rtl"], own: "ltr" }, + { name: "x[rtl]", ancestors: [], own: "rtl" }, + { name: "x[ltr]", ancestors: [], own: "ltr" }, + { name: "rtl > div > x", ancestors: ["rtl", undefined] }, +]; + +// What Firefox 155 and WebKit 26.6 render over TREES, Y for a match: the platform's direction is the root's, carrying no dir +const BROWSER_MATCHES: readonly [ + selector: string, + onLtrPlatform: string, + onRtlPlatform: string, +][] = [ + [".x:dir(rtl)", ".Y..Y.Y.Y", "YY..Y.Y.Y"], + [".x:dir(ltr)", "Y.YY.Y.Y.", "..YY.Y.Y."], + ['.x[dir="rtl"]', "......Y..", "......Y.."], + ['.x:where([dir="rtl"])', "......Y..", "......Y.."], + ['[dir="rtl"] .x', ".Y.YYY..Y", ".Y.YYY..Y"], + ['[dir="ltr"] .x', "..YYY....", "..YYY...."], + ['.x:where([dir="rtl"] *)', ".Y.YYY..Y", ".Y.YYY..Y"], + [":dir(rtl) .x", ".Y.YYY..Y", "YYYYYYYYY"], + [":dir(ltr) .x", "YYYYYYYYY", "..YYY...."], + [".x:where(:dir(rtl) *)", ".Y.YYY..Y", "YYYYYYYYY"], + ['html[dir="rtl"] .x', ".........", "........."], + ['html[dir="ltr"] .x', ".........", "........."], + ["html:dir(rtl) .x", ".........", "YYYYYYYYY"], + ["html:dir(ltr) .x", "YYYYYYYYY", "........."], + ['[dir="RTL"] .x', ".Y.YYY..Y", ".Y.YYY..Y"], + ['[dir="RTL" s] .x', ".........", "........."], + ['[dir="rtl" s] .x', ".Y.YYY..Y", ".Y.YYY..Y"], + ['.x:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *)', ".Y.YYYY.Y", "YY.YYYY.Y"], + ['.x:where(:dir(ltr), [dir="ltr"], [dir="ltr"] *)', "Y.YYYY.Y.", "..YYYY.Y."], +]; + +const renderTree = ({ ancestors, own }: Tree): ReactElement => + ancestors.reduceRight( + (child, dir) => {child}, + , + ); + +test.each( + BROWSER_MATCHES.flatMap(([selector, onLtrPlatform, onRtlPlatform]) => + TREES.flatMap((tree, index) => [ + [selector, tree.name, false, onLtrPlatform[index] === "Y", tree] as const, + [selector, tree.name, true, onRtlPlatform[index] === "Y", tree] as const, + ]), + ), +)( + "%s over %s with isRTL=%s matches: %s", + (selector, _treeName, isRTL, matches, tree) => { + answerIsRTL(isRTL); + registerCSS(`.x { width: 1px; } ${selector} { width: 2px; }`); + + render(renderTree(tree)); + + expect(screen.getByTestId("x")).toHaveStyle({ width: matches ? 2 : 1 }); + }, +); + +test("every browser row answers every tree", () => { + expect(TREES.length).toBeGreaterThan(0); + expect(BROWSER_MATCHES.length).toBeGreaterThan(0); + for (const [, onLtrPlatform, onRtlPlatform] of BROWSER_MATCHES) { + expect([onLtrPlatform.length, onRtlPlatform.length]).toStrictEqual([ + TREES.length, + TREES.length, + ]); + } +}); + +test("an element declaring dir=rtl matches :dir(rtl) and not :dir(ltr)", () => { + registerCSS(DIRECTION_CSS); + + render(); + + expect(screen.getByTestId("subject")).toHaveStyle(RTL); + expect(screen.getByTestId("subject")).not.toHaveStyle(LTR); +}); + +test("an element with no declaration above it reads the platform's direction", () => { + registerCSS(DIRECTION_CSS); + + render(); + + expect(screen.getByTestId("subject")).toHaveStyle(LTR); + expect(screen.getByTestId("subject")).not.toHaveStyle(RTL); +}); + +test("on a right-to-left platform an undeclared element reads rtl", () => { + registerCSS(DIRECTION_CSS); + answerIsRTL(true); + + render(); + + expect(screen.getByTestId("subject")).toHaveStyle(RTL); + expect(screen.getByTestId("subject")).not.toHaveStyle(LTR); +}); + +test("directionality is inherited from the closest declaring ancestor", () => { + registerCSS(DIRECTION_CSS); + + render( + + + + + , + ); + + expect(screen.getByTestId("descendant")).toHaveStyle(RTL); + expect(screen.getByTestId("descendant")).not.toHaveStyle(LTR); +}); + +test("a nested declaration overrides its ancestor for its own subtree", () => { + registerCSS(DIRECTION_CSS); + + render( + + + + + + , + ); + + expect(screen.getByTestId("outer")).toHaveStyle(RTL); + expect(screen.getByTestId("inner")).toHaveStyle(LTR); + expect(screen.getByTestId("inner")).not.toHaveStyle(RTL); +}); + +test("a declaration overrides the platform's direction for its subtree", () => { + registerCSS(DIRECTION_CSS); + answerIsRTL(true); + + render( + <> + + + + + , + ); + + expect(screen.getByTestId("undeclared")).toHaveStyle(RTL); + expect(screen.getByTestId("declared")).toHaveStyle(LTR); + expect(screen.getByTestId("declared")).not.toHaveStyle(RTL); +}); + +test("an ancestor [dir] selector matches when any ancestor declares it", () => { + registerCSS(`[dir="rtl"] .paint { color: red; }`); + + render( + <> + + + + + + + + + + + + + + , + ); + + expect(screen.getByTestId("under-rtl")).toHaveStyle(RTL); + expect(screen.getByTestId("under-nested-ltr")).toHaveStyle(RTL); + expect(screen.getByTestId("declaring-ltr")).toHaveStyle(RTL); + expect(screen.getByTestId("declaring-rtl")).not.toHaveStyle(RTL); + expect(screen.getByTestId("undeclared")).not.toHaveStyle(RTL); +}); + +test("the platform's direction is the root's directionality, not a declaration", () => { + answerIsRTL(true); + registerCSS(` + [dir="rtl"] .declared { color: red; } + :dir(rtl) .directional { color: red; } + html:dir(rtl) .root { color: red; } + `); + + render( + + + + + , + ); + + expect(screen.getByTestId("declared")).not.toHaveStyle(RTL); + expect(screen.getByTestId("directional")).toHaveStyle(RTL); + expect(screen.getByTestId("root")).toHaveStyle(RTL); +}); + +test("an ancestor :dir() matches when any ancestor, the root included, has the directionality", () => { + registerCSS(` + :dir(ltr) .from-root { color: blue; } + :dir(rtl) .from-outer { color: red; } + `); + + render( + + + + + + , + ); + + expect(screen.getByTestId("from-root")).toHaveStyle(LTR); + expect(screen.getByTestId("from-outer")).toHaveStyle(RTL); +}); + +test("each of Tailwind's arms answers its own question, so rtl: reaches into an ltr island", () => { + registerCSS(` + .rtl-variant:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { color: red; } + .ltr-variant:where(:dir(ltr), [dir="ltr"], [dir="ltr"] *) { color: blue; } + `); + + render( + + + + + + , + ); + + expect(screen.getByTestId("island")).toHaveStyle(RTL); + expect(screen.getByTestId("rtl-only")).not.toHaveStyle(LTR); +}); + +test("a [dir] on the element itself reads its own prop, never an inherited direction", () => { + registerCSS(` + .own[dir="rtl"] { color: red; } + .where:where([dir="rtl"]) { color: red; } + `); + + render( + + + + + + , + ); + + expect(screen.getByTestId("inherits-own")).not.toHaveStyle(RTL); + expect(screen.getByTestId("inherits-where")).not.toHaveStyle(RTL); + expect(screen.getByTestId("declares-own")).toHaveStyle(RTL); + expect(screen.getByTestId("declares-where")).toHaveStyle(RTL); +}); + +test("the @media (dir) spelling answers the same way as :dir()", () => { + registerCSS(` + @media (dir: rtl) { .paint { color: red; } } + @media (dir: ltr) { .paint { color: blue; } } + `); + + render( + <> + + + , + ); + + expect(screen.getByTestId("rtl")).toHaveStyle(RTL); + expect(screen.getByTestId("ltr")).toHaveStyle(LTR); +}); + +test("a declaring element takes the UA stylesheet's direction rule", () => { + registerCSS(".paint { color: red; }"); + + render( + <> + + + + , + ); + + expect(screen.getByTestId("rtl")).toHaveStyle({ + direction: "rtl", + writingDirection: "rtl", + }); + expect(screen.getByTestId("ltr")).toHaveStyle({ + direction: "ltr", + writingDirection: "ltr", + }); + expect(screen.getByTestId("undeclared").props.style).toStrictEqual({ + color: "#f00", + }); +}); + +test("an author direction declaration outranks the UA rule and leaves the directionality untouched", () => { + registerCSS(` + .paint:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { color: red; } + .author { direction: ltr; } + `); + + render(); + + expect(screen.getByTestId("subject")).toHaveStyle({ + color: "#f00", + direction: "ltr", + }); +}); + +test("a dir value outside the enumeration is not a declaration", () => { + registerCSS(DIRECTION_CSS); + + render( + + {/* Outside the prop's type on purpose; the runtime is what is under test. */} + + , + ); + + expect(screen.getByTestId("subject")).toHaveStyle(RTL); + expect(screen.getByTestId("subject")).not.toHaveStyle({ direction: "ltr" }); +}); + +test("dir=auto is not a declaration — the component holding the text resolves it", () => { + registerCSS(DIRECTION_CSS); + + render( + + {/* `auto` is outside the prop's type on purpose; the runtime is what is under test. */} + + , + ); + + expect(screen.getByTestId("subject")).toHaveStyle(RTL); + expect(screen.getByTestId("subject")).not.toHaveStyle({ direction: "rtl" }); +}); + +test("a declaring element carrying inline variables still publishes its directionality", () => { + registerCSS(` + ${DIRECTION_CSS} + .marker { color: var(--marker); } + `); + + // The inline-variable object `vars()` builds, spelled through the registered symbol it is keyed on. + const inlineVariables = { + [Symbol.for("react-native-css.var")]: "inline", + marker: "green", + } as unknown as StyleProp; + + render( + + + + + , + ); + + expect(screen.getByTestId("declaring")).toHaveStyle({ color: "green" }); + expect(screen.getByTestId("descendant")).toHaveStyle(LTR); + expect(screen.getByTestId("descendant")).not.toHaveStyle(RTL); +}); + +test("a changed dir prop re-derives the element and its descendants", () => { + registerCSS(DIRECTION_CSS); + + const tree = (dir: "ltr" | "rtl") => ( + + + + + + ); + + const { rerender } = render(tree("rtl")); + expect(screen.getByTestId("descendant")).toHaveStyle(RTL); + + rerender(tree("ltr")); + expect(screen.getByTestId("descendant")).toHaveStyle(LTR); + expect(screen.getByTestId("descendant")).not.toHaveStyle(RTL); +}); + +test("a removed declaration falls back to what the element would have had without it", () => { + registerCSS(DIRECTION_CSS); + + const tree = (dir: "rtl" | undefined) => ( + + + + ); + + const { rerender } = render(tree("rtl")); + expect(screen.getByTestId("descendant")).toHaveStyle(RTL); + + rerender(tree(undefined)); + expect(screen.getByTestId("descendant")).toHaveStyle(LTR); + expect(screen.getByTestId("descendant")).not.toHaveStyle(RTL); +}); + +test("a dir that comes or goes keeps the element's children mounted", () => { + registerCSS(DIRECTION_CSS); + + let mounts = 0; + const Descendant = () => { + useEffect(() => { + mounts += 1; + }, []); + return ; + }; + const tree = (dir: "rtl" | undefined) => ( + + + + ); + + const { rerender } = render(tree(undefined)); + rerender(tree("rtl")); + expect(screen.getByTestId("descendant")).toHaveStyle(RTL); + + rerender(tree(undefined)); + expect(screen.getByTestId("descendant")).toHaveStyle(LTR); + expect(mounts).toBe(1); +}); + +test("a Text declares its directionality the same way, and takes the text-side UA rule", () => { + registerCSS(DIRECTION_CSS); + + render( + + مرحبا + , + ); + + expect(screen.getByTestId("subject")).toHaveStyle({ + ...RTL, + writingDirection: "rtl", + }); + expect(screen.getByTestId("subject")).not.toHaveStyle(LTR); +}); + +test("a declaration reaches descendants only — a sibling reads the platform's direction", () => { + registerCSS(DIRECTION_CSS); + + render( + + + + + + , + ); + + expect(screen.getByTestId("descendant")).toHaveStyle(RTL); + expect(screen.getByTestId("sibling")).toHaveStyle(LTR); + expect(screen.getByTestId("sibling")).not.toHaveStyle(RTL); +}); + +test("the UA rule lands on the declaring element alone; a descendant inherits the directionality, not the style", () => { + registerCSS(".paint { color: red; }"); + + render( + + + , + ); + + expect(screen.getByTestId("declaring")).toHaveStyle({ direction: "rtl" }); + expect(screen.getByTestId("descendant").props.style).toStrictEqual({ + color: "#f00", + }); +}); + +test("every media-condition operator threads the element's directionality", () => { + registerCSS(` + @media not (dir: rtl) { .negated { color: red; } } + @media (dir: rtl) and (platform: ios) { .conjoined { color: red; } } + @media (dir: ltr) or (dir: rtl) { .either { color: red; } } + `); + + render( + <> + + + + + + , + ); + + expect(screen.getByTestId("negated-rtl")).not.toHaveStyle(RTL); + expect(screen.getByTestId("negated-ltr")).toHaveStyle(RTL); + expect(screen.getByTestId("conjoined-rtl")).toHaveStyle(RTL); + expect(screen.getByTestId("conjoined-ltr")).not.toHaveStyle(RTL); + expect(screen.getByTestId("either")).toHaveStyle(RTL); +}); + +test("a :root declaration belongs to no element and reads the platform's direction", () => { + answerIsRTL(true); + registerCSS(` + :root { --marker: blue; } + @media (dir: rtl) { :root { --marker: red; } } + .marker { color: var(--marker); } + `); + + render( + + + , + ); + + expect(screen.getByTestId("subject")).toHaveStyle({ color: "red" }); +}); + +test("rendering the same tree twice yields the same styles", () => { + registerCSS(DIRECTION_CSS); + + const tree = ( + + + + ); + + const { rerender } = render(tree); + const first = screen.getByTestId("descendant").props.style; + + rerender(tree); + + expect(screen.getByTestId("descendant").props.style).toStrictEqual(first); + expect(screen.getByTestId("descendant")).toHaveStyle(RTL); +}); + +test("the UA rule sorts beneath an author rule of equal specificity", () => { + const containerMarker = compile(`.child:is(.group *) { color: red; }`) + .stylesheet() + .s?.find(([name]) => name === "group")?.[1][0]; + const uaRule = resolveUaDirectionRule("rtl"); + + const rules = [containerMarker, uaRule].filter((rule) => rule !== undefined); + + expect(containerMarker?.s).toStrictEqual([0]); + expect(rules.sort(specificityCompareFn)).toStrictEqual([ + uaRule, + containerMarker, + ]); +}); diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..3ad1a0fa 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -185,7 +185,7 @@ export type MediaCondition = // Comparison | [ MediaFeatureComparison, - MediaFeatureNameFor_MediaFeatureId | "dir", + MediaFeatureNameFor_MediaFeatureId | DirectionalityFeature, StyleDescriptor, ] // [Start, End] @@ -200,6 +200,9 @@ export type MediaCondition = export type MediaFeatureComparison = "=" | ">" | ">=" | "<" | "<="; +/** The element's directionality, the root's (the platform's), and a direction an ancestor declares with its dir prop */ +export type DirectionalityFeature = "dir" | "root-dir" | "ancestor-dir"; + export interface PseudoClassesQuery { /** Hover */ h?: 1; diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 3a5d287f..a7b58552 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -39,9 +39,38 @@ const pseudoClassesQueryMap = new WeakMap(); type ContainerQueryWithSpecificity = ContainerQuery & { specificity: SpecificityArray; - m?: MediaCondition; }; +type SelectorComponent = Selector[number]; + +type IsWhereVariant = + | { + kind: "container"; + specificity: SpecificityArray; + query: ContainerQuery; + } + | { + kind: "condition"; + specificity: SpecificityArray; + condition: MediaCondition; + } + | { + kind: "attribute"; + specificity: SpecificityArray; + query: AttributeQuery; + }; + +interface Compound { + components: SelectorComponent[]; + combinator: Extract | undefined; +} + +interface AncestorDirectionality { + selector: Selector; + conditions: MediaCondition[]; + specificity: number; +} + export function getClassNameSelectors( selectors: SelectorList, options: CompilerOptions = {}, @@ -64,9 +93,19 @@ export function getClassNameSelectors( } else if (isUniversalSelector(selector)) { return [{ type: "universalVariables" }]; } else { + const ancestry = extractAncestorDirectionality(selector, options); + if (!ancestry) { + return []; + } + + if (ancestry.conditions.length) { + getMediaQuery(root).push(...ancestry.conditions); + specificity[Specificity.ClassName] = ancestry.specificity; + } + return ( parseComponents( - selector.reverse(), + ancestry.selector.reverse(), options, root, root, @@ -145,6 +184,16 @@ function parseComponents( } case "pseudo-class": { switch (component.kind) { + case "dir": { + // A container's directionality is not known to the element; its [dir] is a prop like any other + if (isContainerQuery(ref)) { + return []; + } + getMediaQuery(root).push(["=", "dir", component.direction]); + specificity[Specificity.PseudoClass] = + (specificity[Specificity.PseudoClass] ?? 0) + 1; + return parseComponents(rest, options, root, ref, specificity); + } case "hover": { getPseudoClassesQuery(ref).h = 1; specificity[Specificity.PseudoClass] = @@ -178,11 +227,13 @@ function parseComponents( case "where": case "is": { // Now get the selectors inside the `is` or `where` pseudo-class - const isWhereContainerQueries = component.selectors.flatMap( - (selector) => { - return parseIsWhereComponents(component.kind, selector) ?? []; - }, - ); + const variants = component.selectors.flatMap((selector) => { + return parseIsWhereArm( + component.kind, + selector, + isContainerQuery(ref), + ); + }); // Remember we're looping in reverse order, // So `rest` contains the selectors BEFORE this one @@ -199,23 +250,16 @@ function parseComponents( } // Each parent selector should be combined with each pseudo-class selector - return parents.flatMap((parent) => { - const originalParent = { ...parent }; - - return isWhereContainerQueries.map((containerQuery) => { - const { specificity, m, ...query } = containerQuery; - parent = { ...originalParent }; - parent.specificity = [...originalParent.specificity]; - - if (m && m.length > 1) { - parent.mediaQuery = originalParent.mediaQuery - ? [["&", [...originalParent.mediaQuery, m]]] - : [m]; - } + return parents.flatMap((originalParent) => { + return variants.map((variant) => { + const parent = { + ...originalParent, + specificity: [...originalParent.specificity], + }; if (component.kind === "is") { - for (let i = 0; i < specificity.length; i++) { - const value = specificity[i]; + for (let i = 0; i < variant.specificity.length; i++) { + const value = variant.specificity[i]; if (value !== undefined) { parent.specificity[i] = (parent.specificity[i] ?? 0) + value; @@ -223,14 +267,7 @@ function parseComponents( } } - if (query.a || query.p || query.n !== undefined) { - parent.containerQuery = [ - ...(originalParent.containerQuery ?? []), - ]; - parent.containerQuery.push(query); - } - - return parent; + return applyIsWhereVariant(parent, variant); }); }); } @@ -240,35 +277,14 @@ function parseComponents( } } case "attribute": { - if (component.name === "dir") { - if (!component.operation) { - return []; - } - const operator = operatorMap[component.operation.operator]; - - if (operator !== "=") { - return []; - } - - // HTML compares a dir value ASCII case-insensitively unless the selector carries s - const direction = - component.operation.caseSensitivity === "explicit-case-sensitive" - ? component.operation.value - : asciiLowerCase(component.operation.value); - getMediaQuery(ref).push([operator, "dir", direction]); - return parseComponents(rest, options, root, ref, specificity); - } else { - // specificity[Specificity.ClassName] = - // (specificity[Specificity.ClassName] ?? 0) + 1; - const attributeQuery = attributeQueryFor(component); - if (!attributeQuery) { - return []; - } - getAttributeQuery(ref).push(attributeQuery); - specificity[Specificity.ClassName] = - (specificity[Specificity.ClassName] ?? 0) + 1; - return parseComponents(rest, options, root, ref, specificity); + const attributeQuery = attributeQueryFor(component); + if (!attributeQuery) { + return []; } + getAttributeQuery(ref).push(attributeQuery); + specificity[Specificity.ClassName] = + (specificity[Specificity.ClassName] ?? 0) + 1; + return parseComponents(rest, options, root, ref, specificity); } case "class": { if (component.name === options.selectorPrefix) { @@ -368,13 +384,6 @@ function parseIsWhereComponents( // specificity[Specificity.ClassName] = // (specificity[Specificity.ClassName] ?? 0) + 1; switch (component.kind) { - case "dir": { - queries ??= [{ specificity: [] }]; - queries.forEach((query) => { - getMediaQuery(query).push(["=", "dir", component.direction]); - }); - return parseIsWhereComponents(type, selector, index + 1, queries); - } case "hover": { queries ??= [{ specificity: [] }]; queries.forEach((query) => { @@ -506,18 +515,11 @@ function getAttributeQuery( return attributeQuery; } -function getMediaQuery( - key: PartialSelector | ContainerQuery, -): MediaCondition[] { +function getMediaQuery(key: PartialSelector): MediaCondition[] { let mediaQuery = mediaQueryMap.get(key); if (!mediaQuery) { - if ("type" in key) { - mediaQuery = []; - key.mediaQuery = mediaQuery; - } else { - mediaQuery = []; - key.m ??= ["&", mediaQuery]; - } + mediaQuery = []; + key.mediaQuery = mediaQuery; mediaQueryMap.set(key, mediaQuery); } @@ -576,10 +578,14 @@ function attributeQueryFor( } query.push(operatorMap[operation.operator], operation.value); - if (operation.caseSensitivity === "ascii-case-insensitive") { - query.push("i"); - } else if (operation.caseSensitivity === "explicit-case-sensitive") { + if (operation.caseSensitivity === "explicit-case-sensitive") { query.push("s"); + } else if ( + operation.caseSensitivity === "ascii-case-insensitive" || + component.name === "dir" + ) { + // HTML compares a dir value ASCII case-insensitively, as the web build of the same element does + query.push("i"); } return query; } @@ -592,3 +598,251 @@ const operatorMap: Record = { "substring": "*=", "suffix": "$=", }; + +function parseIsWhereArm( + type: "is" | "where", + selector: Selector, + isAncestorCompound: boolean, +): IsWhereVariant[] { + if (selector.some(isDirectionalityComponent)) { + const variant = isAncestorCompound + ? undefined + : parseDirectionalityArm(type, selector); + return variant ? [variant] : []; + } + + return (parseIsWhereComponents(type, selector) ?? []).map( + ({ specificity, ...query }) => ({ kind: "container", specificity, query }), + ); +} + +// Tailwind's rtl: and ltr: ask after the element's directionality, its own dir and an ancestor's dir, one arm each +function parseDirectionalityArm( + type: "is" | "where", + [first, combinator, last, ...rest]: Selector, +): IsWhereVariant | undefined { + const specificity: SpecificityArray = []; + if (type === "is") { + specificity[Specificity.ClassName] = 1; + } + + if (!first || rest.length) { + return undefined; + } + + if (!combinator) { + if (first.type === "pseudo-class" && first.kind === "dir") { + return { + kind: "condition", + specificity, + condition: ["=", "dir", first.direction], + }; + } + const query = + first.type === "attribute" ? attributeQueryFor(first) : undefined; + return query && { kind: "attribute", specificity, query }; + } + + if ( + combinator.type !== "combinator" || + combinator.value !== "descendant" || + last?.type !== "universal" + ) { + return undefined; + } + + const condition = parseAncestorDirectionality(first); + return condition && { kind: "condition", specificity, condition }; +} + +function applyIsWhereVariant( + parent: PartialSelector, + variant: IsWhereVariant, +): PartialSelector { + switch (variant.kind) { + case "container": { + const { query } = variant; + if (query.a || query.p || query.n !== undefined) { + parent.containerQuery = [...(parent.containerQuery ?? []), query]; + } + return parent; + } + case "condition": + parent.mediaQuery = [...(parent.mediaQuery ?? []), variant.condition]; + return parent; + case "attribute": + parent.attributeQuery = [...(parent.attributeQuery ?? []), variant.query]; + return parent; + } +} + +// A directionality on an ancestor compound is answered before the compound becomes a container query; undefined is one the runtime cannot place +function extractAncestorDirectionality( + selector: Selector, + options: CompilerOptions, +): AncestorDirectionality | undefined { + if (!selector.some(isDirectionalityComponent)) { + return { selector, conditions: [], specificity: 0 }; + } + + const compounds = splitCompounds(selector); + const subjectIndex = compounds.length - 1; + const kept: Compound[] = []; + const conditions: MediaCondition[] = []; + let specificity = 0; + + for (const [index, compound] of compounds.entries()) { + const directionality = compound.components.filter( + isDirectionalityComponent, + ); + + if (index === subjectIndex || !directionality.length) { + kept.push(compound); + } else if (compound.components.some(isRootComponent)) { + // The root's direction is the platform's, and the root carries no dir + for (const component of directionality) { + if (component.type !== "pseudo-class") { + return undefined; + } + conditions.push(["=", "root-dir", component.direction]); + specificity += 1; + } + kept.push({ + ...compound, + components: compound.components.filter( + (component) => !isDirectionalityComponent(component), + ), + }); + } else if ( + compound.components.some((component) => + isContainerClass(component, options), + ) + ) { + kept.push(compound); + } else { + const [component, ...others] = compound.components; + const isOnlyAncestor = + index === subjectIndex - 1 && + compound.combinator?.value === "descendant" && + compounds + .slice(0, index) + .every((outer) => isRootOrPrefixCompound(outer, options)); + const condition = + component && !others.length && isOnlyAncestor + ? parseAncestorDirectionality(component) + : undefined; + if (!condition) { + return undefined; + } + conditions.push(condition); + specificity += 1; + } + } + + if (!conditions.length) { + return { selector, conditions, specificity }; + } + + return { + selector: kept.flatMap(({ components, combinator }) => + combinator ? [...components, combinator] : components, + ), + conditions, + specificity, + }; +} + +// An ancestor's :dir() is the root's directionality or a declaration above the element; an ancestor's [dir] is a declaration +function parseAncestorDirectionality( + component: SelectorComponent, +): MediaCondition | undefined { + if (component.type === "pseudo-class" && component.kind === "dir") { + return [ + "|", + [ + ["=", "root-dir", component.direction], + ["=", "ancestor-dir", component.direction], + ], + ]; + } + + if ( + component.type !== "attribute" || + component.name !== "dir" || + component.namespace?.type === "specific" + ) { + return undefined; + } + + const direction = resolveDirectionAttributeValue(component.operation); + return direction && ["=", "ancestor-dir", direction]; +} + +function splitCompounds(selector: Selector): Compound[] { + const compounds: Compound[] = []; + let components: SelectorComponent[] = []; + + for (const component of selector) { + if (component.type === "combinator") { + compounds.push({ components, combinator: component }); + components = []; + } else { + components.push(component); + } + } + + compounds.push({ components, combinator: undefined }); + return compounds; +} + +function isDirectionalityComponent( + component: SelectorComponent, +): component is + | Extract + | Extract { + return ( + (component.type === "pseudo-class" && component.kind === "dir") || + (component.type === "attribute" && component.name === "dir") + ); +} + +function isRootComponent(component: SelectorComponent): boolean { + return component.type === "type" && component.name === "html"; +} + +function isContainerClass( + component: SelectorComponent, + options: CompilerOptions, +): boolean { + return ( + component.type === "class" && component.name !== options.selectorPrefix + ); +} + +function isRootOrPrefixCompound( + compound: Compound, + options: CompilerOptions, +): boolean { + return ( + compound.components.some(isRootComponent) || + compound.components.every( + (component) => + (component.type === "class" || component.type === "type") && + component.name === options.selectorPrefix, + ) + ); +} + +// HTML's dir takes ltr, rtl and auto, compared ASCII case-insensitively unless s; only an equality naming ltr or rtl can match +function resolveDirectionAttributeValue( + operation: AttrOperation | null | undefined, +): "ltr" | "rtl" | undefined { + if (!operation || operation.operator !== "equal") { + return undefined; + } + const value = + operation.caseSensitivity === "explicit-case-sensitive" + ? operation.value + : asciiLowerCase(operation.value); + return value === "ltr" || value === "rtl" ? value : undefined; +} diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index e45a7d11..e5d7df38 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -16,6 +16,7 @@ const rootVariableFamily = () => { return value; } + // A `:root` declaration belongs to no element, so it reads the root's directionality. if (testMediaQuery(mediaQuery, read)) { return value; } diff --git a/src/native/conditions/directionality.ts b/src/native/conditions/directionality.ts new file mode 100644 index 00000000..ab1194c7 --- /dev/null +++ b/src/native/conditions/directionality.ts @@ -0,0 +1,100 @@ +// An element's directionality is HTML's dir, which :dir() matches and descendants inherit (Selectors 4 §7.1); auto would need the element's text, so it is not a value here +import { I18nManager } from "react-native"; + +import type { StyleRule } from "react-native-css/compiler"; +import { uaSpecificity } from "react-native-css/utilities"; + +import type { VariableContextValue } from "../reactivity"; + +export type Directionality = "ltr" | "rtl"; + +const DECLARED_DIRECTIONS = ["ltr", "rtl", "ltr rtl", "rtl ltr"] as const; + +/** The directions declared on an element and its ancestors, the nearest first */ +export type DeclaredDirections = (typeof DECLARED_DIRECTIONS)[number]; + +export interface ElementDirectionality { + readonly declared: Directionality | undefined; + readonly inherited: DeclaredDirections | undefined; +} + +export const DECLARED_DIRECTIONS_VARIABLE = "__rn-css-declared-directions"; + +export const NO_DECLARATIONS: ElementDirectionality = { + declared: undefined, + inherited: undefined, +}; + +const DECLARATIONS: Record< + DeclaredDirections, + readonly [Directionality, ...Directionality[]] +> = { + "ltr": ["ltr"], + "rtl": ["rtl"], + "ltr rtl": ["ltr", "rtl"], + "rtl ltr": ["rtl", "ltr"], +}; + +const BENEATH_THE_OPPOSITE: Record = { + ltr: "ltr rtl", + rtl: "rtl ltr", +}; + +// The user-agent rule [dir] { direction } (HTML §15.3.5), with writingDirection as its text twin +const UA_DIRECTION_RULES: Record = { + ltr: { s: uaSpecificity, d: [{ direction: "ltr", writingDirection: "ltr" }] }, + rtl: { s: uaSpecificity, d: [{ direction: "rtl", writingDirection: "rtl" }] }, +}; + +// React Native has no document element: the platform's layout direction is the root's directionality, and no element's dir +export function resolveRootDirectionality(): Directionality { + return I18nManager.isRTL ? "rtl" : "ltr"; +} + +export function resolveDeclaredDirectionality( + value: unknown, +): Directionality | undefined { + return value === "ltr" || value === "rtl" ? value : undefined; +} + +export function resolveInheritedDirections( + inheritedVariables: VariableContextValue, +): DeclaredDirections | undefined { + const value = inheritedVariables[DECLARED_DIRECTIONS_VARIABLE]; + return DECLARED_DIRECTIONS.find((directions) => directions === value); +} + +export function resolveDirectionality({ + declared, + inherited, +}: ElementDirectionality): Directionality { + return ( + declared ?? + (inherited === undefined + ? resolveRootDirectionality() + : DECLARATIONS[inherited][0]) + ); +} + +export function isDeclaredByAncestor( + { inherited }: ElementDirectionality, + direction: Directionality, +): boolean { + return inherited !== undefined && DECLARATIONS[inherited].includes(direction); +} + +export function resolvePublishedDirections( + declared: Directionality, + inherited: DeclaredDirections | undefined, +): DeclaredDirections { + const isOppositeDeclaredAbove = + inherited !== undefined && + DECLARATIONS[inherited].some((direction) => direction !== declared); + return isOppositeDeclaredAbove ? BENEATH_THE_OPPOSITE[declared] : declared; +} + +export function resolveUaDirectionRule( + directionality: Directionality, +): StyleRule { + return UA_DIRECTION_RULES[directionality]; +} diff --git a/src/native/conditions/index.ts b/src/native/conditions/index.ts index 8886f689..dac4cf73 100644 --- a/src/native/conditions/index.ts +++ b/src/native/conditions/index.ts @@ -10,6 +10,7 @@ import { } from "../reactivity"; import { testAttributes } from "./attributes"; import { testContainerQueries } from "./container-query"; +import type { ElementDirectionality } from "./directionality"; import type { RenderGuard } from "./guards"; import { testMediaQuery } from "./media-query"; @@ -19,11 +20,12 @@ export function testRule( props: Props, guards: RenderGuard[], containerContext: ContainerContextValue, + element: ElementDirectionality, ) { if (rule.p && !pseudoClasses(rule.p, get)) { return false; } - if (rule.m && !testMediaQuery(rule.m, get)) { + if (rule.m && !testMediaQuery(rule.m, get, element)) { return false; } if (rule.aq && !testAttributes(rule.aq, props, guards)) { diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index afd51250..eed581fc 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -1,45 +1,73 @@ /* eslint-disable */ -import { I18nManager, PixelRatio, Platform } from "react-native"; +import { PixelRatio, Platform } from "react-native"; import type { MediaCondition } from "react-native-css/compiler"; import { colorScheme, vh, vw, type Getter } from "../reactivity"; +import { + isDeclaredByAncestor, + NO_DECLARATIONS, + resolveDeclaredDirectionality, + resolveDirectionality, + resolveRootDirectionality, + type ElementDirectionality, +} from "./directionality"; -export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { - return mediaQueries.every((query) => test(query, get)); +export function testMediaQuery( + mediaQueries: MediaCondition[], + get: Getter, + element: ElementDirectionality = NO_DECLARATIONS, +) { + return mediaQueries.every((query) => test(query, get, element)); } -function test(mediaQuery: MediaCondition, get: Getter): Boolean { +function test( + mediaQuery: MediaCondition, + get: Getter, + element: ElementDirectionality, +): Boolean { switch (mediaQuery[0]) { case "[]": case "!!": return false; case "!": - return !test(mediaQuery[1], get); + return !test(mediaQuery[1], get, element); case "&": return mediaQuery[1].every((query) => { - return test(query, get); + return test(query, get, element); }); case "|": return mediaQuery[1].some((query) => { - return test(query, get); + return test(query, get, element); }); case ">": case ">=": case "<": case "<=": case "=": { - return testComparison(mediaQuery, get); + return testComparison(mediaQuery, get, element); } } } -function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { +function testComparison( + mediaQuery: MediaCondition, + get: Getter, + element: ElementDirectionality, +): Boolean { const value = mediaQuery[2]; switch (mediaQuery[1]) { case "dir": - return value === (I18nManager.isRTL ? "rtl" : "ltr"); + return value === resolveDirectionality(element); + case "root-dir": + return value === resolveRootDirectionality(); + case "ancestor-dir": { + const direction = resolveDeclaredDirectionality(value); + return ( + direction !== undefined && isDeclaredByAncestor(element, direction) + ); + } case "hover": return true; case "platform": diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index e0fe8d1a..f9e38877 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -4,6 +4,14 @@ import { StyleCollection } from "react-native-css/native-internal"; import { testRule } from "../conditions"; import { DEFAULT_CONTAINER_NAME } from "../conditions/container-query"; +import { + DECLARED_DIRECTIONS_VARIABLE, + resolveDeclaredDirectionality, + resolveInheritedDirections, + resolvePublishedDirections, + resolveUaDirectionRule, + type ElementDirectionality, +} from "../conditions/directionality"; import type { RenderGuard } from "../conditions/guards"; import { getDeepPath } from "../objects"; import { @@ -47,6 +55,28 @@ export function updateRules( let animated = false; let pressable = false; + const directionality: ElementDirectionality = { + declared: resolveDeclaredDirectionality(currentProps?.dir), + inherited: resolveInheritedDirections(inheritedVariables), + }; + guards.push(["a", "dir", currentProps?.dir]); + guards.push([ + "v", + DECLARED_DIRECTIONS_VARIABLE, + inheritedVariables[DECLARED_DIRECTIONS_VARIABLE], + ]); + + if (directionality.declared !== undefined) { + variables = { ...inheritedVariables }; + rules.add(resolveUaDirectionRule(directionality.declared)); + } else if ( + currentProps && + Object.prototype.hasOwnProperty.call(currentProps, "dir") + ) { + // Provide a scope while nothing is declared too, so a dir that comes or goes keeps a consistent render tree + variables = inheritedVariables; + } + for (const config of state.configs) { const source = currentProps?.[config.source]; const shallowTarget = Array.isArray(config.target) @@ -125,6 +155,7 @@ export function updateRules( currentProps, guards, inheritedContainers, + directionality, ) ) { continue; @@ -238,6 +269,14 @@ export function updateRules( } } + // Written after every merge above: a declaration is the element's own, so nothing inherited or inline may shadow it. + if (directionality.declared !== undefined && variables) { + variables[DECLARED_DIRECTIONS_VARIABLE] = resolvePublishedDirections( + directionality.declared, + directionality.inherited, + ); + } + // Generate a StyleObservable for this unique set of rules / variables const stylesObs = stylesFamily(generateStateHash(state, rules), rules); diff --git a/src/utilities/specificity.ts b/src/utilities/specificity.ts index 342f0a38..548652d3 100644 --- a/src/utilities/specificity.ts +++ b/src/utilities/specificity.ts @@ -22,6 +22,10 @@ const Order = Specificity.Order; export const inlineSpecificity: SpecificityArray = []; inlineSpecificity[Specificity.Inline] = 1; +/** A user-agent rule sits beneath every author rule — CSS Cascade 5 §6.1, origin precedence. */ +export const uaSpecificity: SpecificityArray = []; +uaSpecificity[Specificity.Order] = Number.NEGATIVE_INFINITY; + export const specificityCompareFn = ( a: StyleRule | InlineStyleRecord, b: StyleRule | InlineStyleRecord, diff --git a/types.d.ts b/types.d.ts index 924b9a7d..d8a8f022 100644 --- a/types.d.ts +++ b/types.d.ts @@ -37,9 +37,12 @@ declare module "react-native" { } interface ViewProps { className?: string; + /** The element's directionality: HTML's dir, which :dir() matches and descendants inherit */ + dir?: "ltr" | "rtl"; } interface TextProps { className?: string; + dir?: "ltr" | "rtl"; } interface SwitchProps { className?: string;