diff --git a/src/__tests__/compiler/directionality.test.ts b/src/__tests__/compiler/directionality.test.ts
new file mode 100644
index 00000000..606e22e7
--- /dev/null
+++ b/src/__tests__/compiler/directionality.test.ts
@@ -0,0 +1,324 @@
+import { compile } from "react-native-css/compiler";
+
+const RTL = ["=", "dir", "rtl"] as const;
+const LTR = ["=", "dir", "ltr"] 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 compiles to the same condition, with an attribute's specificity", () => {
+ expect(
+ compile(`.self[dir="rtl"] { padding-left: 4px; }`).stylesheet(),
+ ).toStrictEqual({
+ s: [["self", [{ s: [1, 2], d: [PADDING], m: [RTL] }]]],
+ });
+ });
+
+ 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 is the directionality the subject inherits", () => {
+ expect(
+ compile(`[dir="rtl"] .descendant { padding-left: 4px; }`).stylesheet(),
+ ).toStrictEqual({
+ s: [["descendant", [{ s: [1, 2], d: [PADDING], m: [RTL] }]]],
+ });
+ });
+
+ test("a :dir() on an ancestor answers the same way", () => {
+ expect(
+ compile(`:dir(rtl) .descendant { padding-left: 4px; }`).stylesheet(),
+ ).toStrictEqual({
+ s: [["descendant", [{ s: [1, 2], d: [PADDING], m: [RTL] }]]],
+ });
+ });
+
+ test("html[dir] and :root[dir] name the document element, which every element descends from", () => {
+ const fromHtml = compile(
+ `html[dir="rtl"] .descendant { padding-left: 4px; }`,
+ ).stylesheet();
+
+ expect(fromHtml).toStrictEqual({
+ s: [["descendant", [{ s: [1, 2], d: [PADDING], m: [RTL] }]]],
+ });
+ expect(
+ compile(
+ `:root[dir="rtl"] .descendant { padding-left: 4px; }`,
+ ).stylesheet(),
+ ).toStrictEqual(fromHtml);
+ });
+
+ test(":root as an ancestor is transparent, and no element IS the root", () => {
+ expect(
+ compile(`:root .under { padding-left: 4px; }`).stylesheet(),
+ ).toStrictEqual({
+ s: [["under", [{ s: [1, 1], d: [PADDING] }]]],
+ });
+ expect(
+ compile(`.self:root { padding-left: 4px; }`).stylesheet(),
+ ).toStrictEqual({});
+ });
+
+ test("a directionality beside an ancestor class keeps the container query", () => {
+ expect(
+ compile(`.group:dir(rtl) .item { padding-left: 4px; }`).stylesheet(),
+ ).toStrictEqual({
+ s: [
+ ["group", [{ s: [0], c: ["g:group"] }]],
+ [
+ "item",
+ [{ s: [1, 3], d: [PADDING], m: [RTL], cq: [{ n: "g:group" }] }],
+ ],
+ ],
+ });
+ });
+});
+
+describe(":is() and :where()", () => {
+ test("Tailwind's rtl: variant compiles to ONE rule carrying ONE dir condition", () => {
+ expect(
+ compile(
+ `.tw:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { padding-left: 4px; }`,
+ ).stylesheet(),
+ ).toStrictEqual({
+ s: [["tw", [{ s: [1, 1], d: [PADDING], m: [RTL] }]]],
+ });
+ });
+
+ test("the variant's condition is the same shape a bare :dir() compiles to", () => {
+ const bare = compile(`.x:dir(rtl) { padding-left: 4px; }`).stylesheet();
+ const variant = compile(
+ `.x:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { padding-left: 4px; }`,
+ ).stylesheet();
+
+ expect(variant.s?.[0]?.[1][0]?.m).toStrictEqual(bare.s?.[0]?.[1][0]?.m);
+ });
+
+ test("a [dir] attribute inside :where() answers like :dir(), with :where()'s specificity", () => {
+ expect(
+ compile(`.where:where([dir="rtl"]) { padding-left: 4px; }`).stylesheet(),
+ ).toStrictEqual({
+ s: [["where", [{ s: [1, 1], d: [PADDING], m: [RTL] }]]],
+ });
+ });
+
+ test("a [dir] attribute inside :is() carries its specificity", () => {
+ expect(
+ compile(`.is:is([dir="rtl"]) { padding-left: 4px; }`).stylesheet(),
+ ).toStrictEqual({
+ s: [["is", [{ s: [1, 2], d: [PADDING], m: [RTL] }]]],
+ });
+ });
+
+ 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, 1], d: [PADDING], m: [RTL] },
+ { s: [1, 2], d: [PADDING], cq: [{ n: "g:group" }] },
+ ],
+ ],
+ ["group", [{ s: [0], c: ["g:group"] }]],
+ ],
+ });
+ });
+
+ test("identical arms of any kind emit one rule", () => {
+ expect(
+ compile(`.dup:is(.group, .group) { padding-left: 4px; }`).stylesheet(),
+ ).toStrictEqual({
+ s: [
+ ["group", [{ s: [0], c: ["g:group"] }]],
+ ["dup", [{ s: [1, 2], d: [PADDING], cq: [{ n: "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:where(:dir(rtl)) .dark { padding-left: 8px; }
+ }
+ `).stylesheet(),
+ ).toStrictEqual({
+ s: [
+ ["group", [{ s: [0], c: ["g:group"] }]],
+ [
+ "dark",
+ [
+ {
+ s: [2, 2],
+ m: [["=", "prefers-color-scheme", "dark"], RTL],
+ d: [{ paddingLeft: 8 }],
+ cq: [{ n: "g:group" }],
+ },
+ ],
+ ],
+ ],
+ });
+ });
+});
+
+describe("the [dir] value", () => {
+ test("is read ASCII-case-insensitively, as HTML defines the attribute", () => {
+ const lower = compile(
+ `.upper[dir="rtl"] { padding-left: 4px; }`,
+ ).stylesheet();
+
+ expect(
+ compile(`.upper[dir="RTL"] { padding-left: 4px; }`).stylesheet(),
+ ).toStrictEqual(lower);
+ expect(
+ compile(`.upper[dir="RTL" i] { padding-left: 4px; }`).stylesheet(),
+ ).toStrictEqual(lower);
+ });
+
+ test("the selector's own s flag is honoured, so an upper-case value matches nothing", () => {
+ expect(
+ compile(`.upper[dir="RTL" s] { padding-left: 4px; }`).stylesheet(),
+ ).toStrictEqual({});
+ });
+
+ test("a value the engine cannot answer matches nothing", () => {
+ expect(
+ compile(`
+ .auto[dir="auto"] { padding-left: 4px; }
+ .present[dir] { padding-left: 4px; }
+ `).stylesheet(),
+ ).toStrictEqual({});
+ });
+
+ test("every operator but equality matches nothing, so a partial match never widens the rule", () => {
+ expect(
+ compile(`
+ .prefix[dir^="r"] { padding-left: 4px; }
+ .suffix[dir$="tl"] { padding-left: 4px; }
+ .substring[dir*="rtl"] { padding-left: 4px; }
+ .includes[dir~="rtl"] { padding-left: 4px; }
+ .dash[dir|="rtl"] { 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 value inside :is() drops the rule rather than the arm", () => {
+ // The fail-closed arm the subject compound already has, one nesting in: the `:is()` path
+ // resolves the value itself, so a rule kept there would apply in every direction.
+ expect(
+ compile(`
+ .isauto:is([dir="auto"]) { padding-left: 4px; }
+ .wherepresent:where([dir]) { padding-left: 4px; }
+ .isop:is([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, 1], 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__/native/directionality.test.tsx b/src/__tests__/native/directionality.test.tsx
new file mode 100644
index 00000000..e2647cef
--- /dev/null
+++ b/src/__tests__/native/directionality.test.tsx
@@ -0,0 +1,420 @@
+import { useEffect } from "react";
+import { I18nManager, type StyleProp, type ViewStyle } from "react-native";
+
+import { render, screen } from "@testing-library/react-native";
+import { Text } from "react-native-css/components/Text";
+import { View } from "react-native-css/components/View";
+import { registerCSS } from "react-native-css/jest";
+
+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" };
+
+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 answers from the directionality the element inherits", () => {
+ registerCSS(`[dir="rtl"] .paint { color: red; }`);
+
+ render(
+ <>
+
+
+
+
+
+
+
+
+
+ >,
+ );
+
+ expect(screen.getByTestId("under-rtl")).toHaveStyle(RTL);
+ expect(screen.getByTestId("under-nested-ltr")).not.toHaveStyle(RTL);
+ expect(screen.getByTestId("undeclared")).not.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);
+});
diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts
index 88561b78..54cce7b3 100644
--- a/src/compiler/selector-builder.ts
+++ b/src/compiler/selector-builder.ts
@@ -144,6 +144,20 @@ function parseComponents(
}
case "pseudo-class": {
switch (component.kind) {
+ case "dir": {
+ // Directionality is inherited, so an ancestor's `:dir()` is the element's own answer: the condition lands on the rule.
+ getMediaQuery(root).push(["=", "dir", component.direction]);
+ specificity[Specificity.PseudoClass] =
+ (specificity[Specificity.PseudoClass] ?? 0) + 1;
+ return parseComponents(rest, options, root, ref, specificity);
+ }
+ case "root": {
+ // `:root` names the document element: every element descends from it, and none IS it.
+ if (!isContainerQuery(ref)) {
+ return [];
+ }
+ return parseComponents(rest, options, root, ref, specificity);
+ }
case "hover": {
getPseudoClassesQuery(ref).h = 1;
specificity[Specificity.PseudoClass] =
@@ -177,10 +191,10 @@ function parseComponents(
case "where":
case "is": {
// Now get the selectors inside the `is` or `where` pseudo-class
- const isWhereContainerQueries = component.selectors.flatMap(
- (selector) => {
+ const isWhereContainerQueries = dedupeContainerQueries(
+ component.selectors.flatMap((selector) => {
return parseIsWhereComponents(component.kind, selector) ?? [];
- },
+ }),
);
// Remember we're looping in reverse order,
@@ -206,10 +220,11 @@ function parseComponents(
parent = { ...originalParent };
parent.specificity = [...originalParent.specificity];
- if (m && m.length > 1) {
+ if (m) {
+ const condition = unwrapConjunction(m);
parent.mediaQuery = originalParent.mediaQuery
- ? [["&", [...originalParent.mediaQuery, m]]]
- : [m];
+ ? [["&", [...originalParent.mediaQuery, condition]]]
+ : [condition];
}
if (component.kind === "is") {
@@ -240,16 +255,14 @@ function parseComponents(
}
case "attribute": {
if (component.name === "dir") {
- if (!component.operation) {
+ const direction = resolveDirectionAttributeValue(component.operation);
+ if (direction === undefined) {
return [];
}
- const operator = operatorMap[component.operation.operator];
- if (operator !== "=") {
- return [];
- }
-
- getMediaQuery(ref).push([operator, "dir", component.operation.value]);
+ getMediaQuery(root).push(["=", "dir", direction]);
+ specificity[Specificity.ClassName] =
+ (specificity[Specificity.ClassName] ?? 0) + 1;
return parseComponents(rest, options, root, ref, specificity);
} else {
// specificity[Specificity.ClassName] =
@@ -453,7 +466,19 @@ function parseIsWhereComponents(
}
case "attribute": {
if (component.name === "dir") {
- return null;
+ const direction = resolveDirectionAttributeValue(component.operation);
+ if (direction === undefined) {
+ return null;
+ }
+ queries ??= [{ specificity: [] }];
+ for (const query of queries) {
+ if (type === "is") {
+ query.specificity[Specificity.ClassName] =
+ (query.specificity[Specificity.ClassName] ?? 0) + 1;
+ }
+ getMediaQuery(query).push(["=", "dir", direction]);
+ }
+ return parseIsWhereComponents(type, selector, index + 1, queries);
}
if (type !== "where") {
@@ -590,3 +615,50 @@ const operatorMap: Record = {
"substring": "*=",
"suffix": "$=",
};
+
+/**
+ * Two arms of one `:is()` / `:where()` that compile to the same query would emit the same rule
+ * twice. Tailwind's `rtl:` is the live case: `:dir(rtl)`, `[dir="rtl"]` and `[dir="rtl"] *` all
+ * name the directionality the element inherits.
+ */
+function dedupeContainerQueries(
+ queries: ContainerQueryWithSpecificity[],
+): ContainerQueryWithSpecificity[] {
+ const seen = new Set();
+ return queries.filter((query) => {
+ const key = JSON.stringify(query);
+ if (seen.has(key)) {
+ return false;
+ }
+ seen.add(key);
+ return true;
+ });
+}
+
+/** An arm holding one condition is that condition; the `&` around it is the builder's, and the bare `:dir()` form has none. */
+function unwrapConjunction(condition: MediaCondition): MediaCondition {
+ if (condition[0] !== "&" || condition[1].length !== 1) {
+ return condition;
+ }
+ const [single] = condition[1];
+ return single ?? condition;
+}
+
+/**
+ * The directionality a `[dir=…]` attribute selector names. HTML `dir` takes `ltr`, `rtl` and
+ * `auto`, ASCII-case-insensitively unless the selector's `s` flag says otherwise; only the first
+ * two are directionalities the engine can answer, and only an equality test names one of them,
+ * so every other operation is a selector that matches nothing here.
+ */
+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
+ : operation.value.toLowerCase();
+ return value === "ltr" || value === "rtl" ? value : undefined;
+}
diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts
index e45a7d11..4b88d1c4 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 initial 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..c1c6b8c2
--- /dev/null
+++ b/src/native/conditions/directionality.ts
@@ -0,0 +1,60 @@
+/**
+ * An element's directionality — HTML's `dir`, which `:dir()` matches and descendants inherit
+ * (Selectors 4 §7.1). Held beside the `direction` property rather than derived from it, because
+ * the property does not affect whether `:dir()` matches. `auto` never reaches the engine: it
+ * resolves from text content, which only the component rendering that text can read.
+ */
+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";
+
+export const DIRECTIONALITY_VARIABLE = "__rn-css-directionality";
+
+/**
+ * The directionality of the root — what an element with no declaration above it has.
+ *
+ * React Native has no document element: the platform's own layout direction is the root's
+ * (`I18nManager.isRTL` is what already mirrors every flex row), so an app that declares nothing
+ * keeps reading `rtl:` utilities the way it always has, and a `dir` on any element overrides it
+ * for that subtree.
+ */
+export function resolveInitialDirectionality(): Directionality {
+ return I18nManager.isRTL ? "rtl" : "ltr";
+}
+
+/** The UA stylesheet rule `[dir=…] { direction: … }` (HTML §15.3.5); `writingDirection` is its text-side twin. */
+const UA_DIRECTION_RULES: Record = {
+ ltr: { s: uaSpecificity, d: [{ direction: "ltr", writingDirection: "ltr" }] },
+ rtl: { s: uaSpecificity, d: [{ direction: "rtl", writingDirection: "rtl" }] },
+};
+
+export function resolveDeclaredDirectionality(
+ value: unknown,
+): Directionality | undefined {
+ return value === "ltr" || value === "rtl" ? value : undefined;
+}
+
+/**
+ * What an element inherits, which is only consulted when it declares nothing itself.
+ *
+ * Separate from the declared half so a caller that has already resolved `props.dir` — every caller
+ * does, to decide whether to publish and take the UA rule — spends one resolve rather than two.
+ */
+export function resolveInheritedDirectionality(
+ inheritedVariables: VariableContextValue,
+): Directionality | undefined {
+ return resolveDeclaredDirectionality(
+ inheritedVariables[DIRECTIONALITY_VARIABLE],
+ );
+}
+
+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..ce411713 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 { Directionality } 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,
+ directionality: Directionality | undefined,
) {
if (rule.p && !pseudoClasses(rule.p, get)) {
return false;
}
- if (rule.m && !testMediaQuery(rule.m, get)) {
+ if (rule.m && !testMediaQuery(rule.m, get, directionality)) {
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 75cd9006..3a44d34c 100644
--- a/src/native/conditions/media-query.ts
+++ b/src/native/conditions/media-query.ts
@@ -1,45 +1,66 @@
/* 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 {
+ resolveInitialDirectionality,
+ type Directionality,
+} from "./directionality";
-export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) {
- return mediaQueries.every((query) => test(query, get));
+/**
+ * `directionality` is the ELEMENT's, resolved by `updateRules` from its `dir` prop and its
+ * inherited scope: `:dir()` answers per element (Selectors 4 §7.1), never per process. Absent —
+ * a `:root` declaration, a test over a global feature — the platform's root direction answers.
+ */
+export function testMediaQuery(
+ mediaQueries: MediaCondition[],
+ get: Getter,
+ directionality?: Directionality,
+) {
+ return mediaQueries.every((query) => test(query, get, directionality));
}
-function test(mediaQuery: MediaCondition, get: Getter): Boolean {
+function test(
+ mediaQuery: MediaCondition,
+ get: Getter,
+ directionality: Directionality | undefined,
+): Boolean {
switch (mediaQuery[0]) {
case "[]":
case "!!":
return false;
case "!":
- return !test(mediaQuery[1], get);
+ return !test(mediaQuery[1], get, directionality);
case "&":
return mediaQuery[1].every((query) => {
- return test(query, get);
+ return test(query, get, directionality);
});
case "|":
return mediaQuery[1].some((query) => {
- return test(query, get);
+ return test(query, get, directionality);
});
case ">":
case ">=":
case "<":
case "<=":
case "=": {
- return testComparison(mediaQuery, get);
+ return testComparison(mediaQuery, get, directionality);
}
}
}
-function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean {
+function testComparison(
+ mediaQuery: MediaCondition,
+ get: Getter,
+ directionality: Directionality | undefined,
+): Boolean {
const value = mediaQuery[2];
switch (mediaQuery[1]) {
case "dir":
- return (I18nManager.isRTL && value === "rtl") || value === "ltr";
+ return value === (directionality ?? resolveInitialDirectionality());
case "hover":
return true;
case "platform":
diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts
index f85a66f9..7f5a42f3 100644
--- a/src/native/react/rules.ts
+++ b/src/native/react/rules.ts
@@ -4,6 +4,12 @@ import { StyleCollection } from "react-native-css/native-internal";
import { testRule } from "../conditions";
import { DEFAULT_CONTAINER_NAME } from "../conditions/container-query";
+import {
+ DIRECTIONALITY_VARIABLE,
+ resolveDeclaredDirectionality,
+ resolveInheritedDirectionality,
+ resolveUaDirectionRule,
+} from "../conditions/directionality";
import type { RenderGuard } from "../conditions/guards";
import { getDeepPath } from "../objects";
import {
@@ -45,6 +51,31 @@ export function updateRules(
let animated = false;
let pressable = false;
+ // `:dir()` matches per element (Selectors 4 §7.1): the element's own `dir`, else its ancestors'.
+ const declaredDirectionality = resolveDeclaredDirectionality(
+ currentProps?.dir,
+ );
+ const directionality =
+ declaredDirectionality ??
+ resolveInheritedDirectionality(inheritedVariables);
+ guards.push(["a", "dir", currentProps?.dir]);
+ guards.push([
+ "v",
+ DIRECTIONALITY_VARIABLE,
+ inheritedVariables[DIRECTIONALITY_VARIABLE],
+ ]);
+
+ if (declaredDirectionality !== undefined) {
+ variables = { ...inheritedVariables };
+ rules.add(resolveUaDirectionRule(declaredDirectionality));
+ } 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)
@@ -123,6 +154,7 @@ export function updateRules(
currentProps,
guards,
inheritedContainers,
+ directionality,
)
) {
continue;
@@ -230,6 +262,11 @@ export function updateRules(
}
}
+ // Written after every merge above: a declaration is the element's own, so nothing inherited or inline may shadow it.
+ if (declaredDirectionality !== undefined && variables) {
+ variables[DIRECTIONALITY_VARIABLE] = declaredDirectionality;
+ }
+
// 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 1ba5b856..06c46764 100644
--- a/types.d.ts
+++ b/types.d.ts
@@ -40,6 +40,8 @@ declare module "react-native" {
interface ViewProps {
className?: string;
cssInterop?: boolean;
+ /** The element's directionality — HTML's `dir`, which `:dir()` matches and descendants inherit. */
+ dir?: "ltr" | "rtl";
}
interface TextInputProps {
placeholderClassName?: string;
@@ -47,6 +49,7 @@ declare module "react-native" {
interface TextProps {
className?: string;
cssInterop?: boolean;
+ dir?: "ltr" | "rtl";
}
interface SwitchProps {
className?: string;