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;