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 1df75cf0..a4c9111d 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,76 @@ 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"]', ["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?.aq).toStrictEqual([query]);
+});
+
+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"]] },
+ ]);
+});
+
+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 900dde70..7d9c9ca5 100644
--- a/src/__tests__/native/attributes.test.tsx
+++ b/src/__tests__/native/attributes.test.tsx
@@ -280,3 +280,94 @@ 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,
+ );
+ });
+});
+
+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);
+});
+
+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/__tests__/native/direction-media.test.tsx b/src/__tests__/native/direction-media.test.tsx
index 8ed38ee4..d8b78bad 100644
--- a/src/__tests__/native/direction-media.test.tsx
+++ b/src/__tests__/native/direction-media.test.tsx
@@ -33,3 +33,18 @@ test.each([false, true])(
expect(screen.getByTestId("ltr")).toHaveStyle({ width: isRTL ? 10 : 37 });
},
);
+
+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();
+ 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 6787c0ca..a7b58552 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,
@@ -38,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 = {},
@@ -63,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,
@@ -144,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] =
@@ -177,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
@@ -198,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;
@@ -222,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);
});
});
}
@@ -239,77 +277,14 @@ function parseComponents(
}
}
case "attribute": {
- if (component.name === "dir") {
- if (!component.operation) {
- return [];
- }
- const operator = operatorMap[component.operation.operator];
-
- if (operator !== "=") {
- return [];
- }
-
- getMediaQuery(ref).push([operator, "dir", component.operation.value]);
- return parseComponents(rest, options, root, ref, specificity);
- } 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);
- 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) {
@@ -409,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) => {
@@ -474,27 +442,9 @@ 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);
+ if (!attributeQuery) {
+ return null;
}
queries ??= [{ specificity: [] }];
for (const query of queries) {
@@ -565,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);
}
@@ -608,6 +551,45 @@ 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);
+}
+
+// A React Native prop is in no namespace, so [ns|att] represents nothing (Selectors 4 §6.4)
+function attributeQueryFor(
+ component: Extract,
+): 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)];
+
+ const operation = component.operation;
+ if (!operation) {
+ return query;
+ }
+
+ query.push(operatorMap[operation.operator], operation.value);
+ 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;
+}
+
const operatorMap: Record = {
"equal": "=",
"includes": "~=",
@@ -616,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/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/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/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());
+}
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;