diff --git a/src/__tests__/compiler/ancestor-conditions.test.ts b/src/__tests__/compiler/ancestor-conditions.test.ts new file mode 100644 index 00000000..22c75c94 --- /dev/null +++ b/src/__tests__/compiler/ancestor-conditions.test.ts @@ -0,0 +1,175 @@ +import { compile } from "react-native-css/compiler"; + +const RED = { color: "#f00" } as const; +const INHERITED_COLOR = [["__rn-css-color", "#f00"]] as const; + +function rulesFor(css: string, className = "subject") { + return compile(css) + .stylesheet() + .s?.find(([name]) => name === className)?.[1]; +} + +describe("an ancestor identified by a condition alone", () => { + test("an attribute compiles to a container query carrying it", () => { + expect( + rulesFor(`[data-state="on"] .subject { color: red; }`), + ).toStrictEqual([ + { + s: [1, 2], + d: [RED], + v: INHERITED_COLOR, + cq: [{ a: [["d", "state", "=", "on"]] }], + }, + ]); + }); + + test("an attribute with no operation compiles to a presence query", () => { + expect(rulesFor(`[aria-busy] .subject { color: red; }`)).toStrictEqual([ + { + s: [1, 2], + d: [RED], + v: INHERITED_COLOR, + cq: [{ a: [["a", "ariaBusy"]] }], + }, + ]); + }); + + test("every pseudo-class the builder answers compiles to a container query", () => { + expect(rulesFor(`:hover .subject { color: red; }`)).toStrictEqual([ + { s: [1, 2], d: [RED], v: INHERITED_COLOR, cq: [{ p: { h: 1 } }] }, + ]); + expect(rulesFor(`:active .subject { color: red; }`)).toStrictEqual([ + { s: [1, 2], d: [RED], v: INHERITED_COLOR, cq: [{ p: { a: 1 } }] }, + ]); + expect(rulesFor(`:focus .subject { color: red; }`)).toStrictEqual([ + { s: [1, 2], d: [RED], v: INHERITED_COLOR, cq: [{ p: { f: 1 } }] }, + ]); + expect(rulesFor(`:disabled .subject { color: red; }`)).toStrictEqual([ + { + s: [1, 2], + d: [RED], + v: INHERITED_COLOR, + cq: [{ a: [["a", "disabled"]] }], + }, + ]); + expect(rulesFor(`:empty .subject { color: red; }`)).toStrictEqual([ + { + s: [1, 2], + d: [RED], + v: INHERITED_COLOR, + cq: [{ a: [["a", "children", "!"]] }], + }, + ]); + }); + + test("the descendant combinator compiles to what the :where() spelling of it already did", () => { + const combinator = rulesFor(`[data-state="on"] .subject { color: red; }`); + const isWhere = rulesFor( + `.subject:where([data-state="on"] *) { color: red; }`, + ); + + expect(combinator?.[0]?.cq).toStrictEqual(isWhere?.[0]?.cq); + }); + + test("each conditioned ancestor in a chain contributes its own query", () => { + expect( + rulesFor(`[data-state="on"] [aria-busy] .subject { color: red; }`), + ).toStrictEqual([ + { + s: [1, 3], + d: [RED], + v: INHERITED_COLOR, + cq: [{ a: [["d", "state", "=", "on"]] }, { a: [["a", "ariaBusy"]] }], + }, + ]); + }); +}); + +describe("a named ancestor is unchanged", () => { + test("its conditions still land on the query its class names", () => { + expect( + rulesFor(`.group[data-state="on"] .subject { color: red; }`), + ).toStrictEqual([ + { + s: [1, 3], + d: [RED], + v: INHERITED_COLOR, + cq: [{ a: [["d", "state", "=", "on"]], n: "g:group" }], + }, + ]); + }); + + test("a compound naming several classes still yields ONE query", () => { + expect(rulesFor(`.a.b .subject { color: red; }`)).toStrictEqual([ + { s: [1, 3], d: [RED], v: INHERITED_COLOR, cq: [{ n: "g:b.a" }] }, + ]); + }); + + test("a named and a classless ancestor each keep their own query", () => { + expect( + rulesFor(`.group [data-state="on"] .subject { color: red; }`), + ).toStrictEqual([ + { + s: [1, 3], + d: [RED], + v: INHERITED_COLOR, + cq: [{ n: "g:group" }, { a: [["d", "state", "=", "on"]] }], + }, + ]); + }); +}); + +describe("what is not an ancestor query", () => { + test("a condition on the SUBJECT stays on the rule", () => { + expect(rulesFor(`.subject[data-state="on"] { color: red; }`)).toStrictEqual( + [ + { + s: [1, 2], + d: [RED], + v: INHERITED_COLOR, + aq: [["d", "state", "=", "on"]], + }, + ], + ); + expect(rulesFor(`.subject:hover { color: red; }`)).toStrictEqual([ + { s: [1, 2], d: [RED], v: INHERITED_COLOR, p: { h: 1 } }, + ]); + }); + + test("an unconditioned ancestor contributes nothing to compile", () => { + expect(rulesFor(`div .subject { color: red; }`)).toStrictEqual(undefined); + }); + + test("a compound on ONE element is the subject's, not an ancestor's", () => { + expect(rulesFor(`[data-state="on"].subject { color: red; }`)).toStrictEqual( + [ + { + s: [1, 2], + d: [RED], + v: INHERITED_COLOR, + aq: [["d", "state", "=", "on"]], + }, + ], + ); + }); + + test("a combinator the builder does not answer drops the rule rather than widening it", () => { + expect( + rulesFor(`[data-state="on"] > .subject { color: red; }`), + ).toStrictEqual(undefined); + expect(rulesFor(`:hover > .subject { color: red; }`)).toStrictEqual( + undefined, + ); + }); +}); + +describe("determinism", () => { + test("compiling the same stylesheet twice yields equal output", () => { + const css = ` + [data-state="on"] .subject { color: red; } + .group:hover [aria-busy] .subject { color: blue; } + `; + + expect(compile(css).stylesheet()).toStrictEqual(compile(css).stylesheet()); + }); +}); diff --git a/src/__tests__/native/ancestor-conditions.test.tsx b/src/__tests__/native/ancestor-conditions.test.tsx new file mode 100644 index 00000000..5196d7ae --- /dev/null +++ b/src/__tests__/native/ancestor-conditions.test.tsx @@ -0,0 +1,129 @@ +import { fireEvent, render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS } from "react-native-css/jest"; + +const RED = { color: "#f00" }; + +/** Any rule declaring a container makes its element the default container a nameless query resolves against. */ +const CONTAINER_CSS = `.container { container-type: inline-size; }`; + +test("a rule scoped to a hovered ancestor does not apply under an ancestor that is not hovered", () => { + registerCSS(` + ${CONTAINER_CSS} + :hover .subject { color: red; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("subject")).not.toHaveStyle(RED); + + fireEvent(screen.getByTestId("container"), "hoverIn"); + + expect(screen.getByTestId("subject")).toHaveStyle(RED); +}); + +test("the same rule does not apply where there is no ancestor to answer it", () => { + registerCSS(`:hover .subject { color: red; }`); + + render(); + + expect(screen.getByTestId("subject").props.style).toStrictEqual(undefined); +}); + +test("an ancestor condition is withheld from a sibling of the subject's ancestor", () => { + registerCSS(` + ${CONTAINER_CSS} + :active .subject { color: red; } + `); + + render( + <> + + + + + + + , + ); + + fireEvent(screen.getByTestId("pressed"), "pressIn"); + + expect(screen.getByTestId("under-pressed")).toHaveStyle(RED); + expect(screen.getByTestId("under-idle")).not.toHaveStyle(RED); +}); + +test("an attribute-identified ancestor is withheld where no ancestor can answer it", () => { + registerCSS(`[data-state="on"] .subject { color: red; }`); + + render( + + + , + ); + + expect(screen.getByTestId("subject").props.style).toStrictEqual(undefined); +}); + +test("a named ancestor still answers from the element its class names", () => { + registerCSS(`.group:hover .subject { color: red; }`); + + render( + + + , + ); + + expect(screen.getByTestId("subject")).not.toHaveStyle(RED); + + fireEvent(screen.getByTestId("group"), "hoverIn"); + + expect(screen.getByTestId("subject")).toHaveStyle(RED); +}); + +test("the ancestor itself is not a subject, even when it carries the class", () => { + registerCSS(` + ${CONTAINER_CSS} + :hover .subject { color: red; } + `); + + render( + + + , + ); + + fireEvent(screen.getByTestId("container"), "hoverIn"); + + expect(screen.getByTestId("descendant")).toHaveStyle(RED); + expect(screen.getByTestId("container")).not.toHaveStyle(RED); +}); + +test("a hovered ancestor moves every subject beneath it, and only those", () => { + registerCSS(` + ${CONTAINER_CSS} + :hover .subject { color: red; } + `); + + render( + <> + + + + + + + + , + ); + + fireEvent(screen.getByTestId("container"), "hoverIn"); + + expect(screen.getByTestId("direct")).toHaveStyle(RED); + expect(screen.getByTestId("nested")).toHaveStyle(RED); + expect(screen.getByTestId("outside")).not.toHaveStyle(RED); +}); diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 88561b78..69139ea4 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -145,30 +145,35 @@ function parseComponents( case "pseudo-class": { switch (component.kind) { case "hover": { + attachContainerQuery(root, ref); getPseudoClassesQuery(ref).h = 1; specificity[Specificity.PseudoClass] = (specificity[Specificity.PseudoClass] ?? 0) + 1; return parseComponents(rest, options, root, ref, specificity); } case "active": { + attachContainerQuery(root, ref); getPseudoClassesQuery(ref).a = 1; specificity[Specificity.PseudoClass] = (specificity[Specificity.PseudoClass] ?? 0) + 1; return parseComponents(rest, options, root, ref, specificity); } case "focus": { + attachContainerQuery(root, ref); getPseudoClassesQuery(ref).f = 1; specificity[Specificity.PseudoClass] = (specificity[Specificity.PseudoClass] ?? 0) + 1; return parseComponents(rest, options, root, ref, specificity); } case "disabled": { + attachContainerQuery(root, ref); getAttributeQuery(ref).push(["a", "disabled"]); specificity[Specificity.PseudoClass] = (specificity[Specificity.PseudoClass] ?? 0) + 1; return parseComponents(rest, options, root, ref, specificity); } case "empty": { + attachContainerQuery(root, ref); getAttributeQuery(ref).push(["a", "children", "!"]); specificity[Specificity.PseudoClass] = (specificity[Specificity.PseudoClass] ?? 0) + 1; @@ -291,6 +296,7 @@ function parseComponents( attributeQuery.push(operator, component.operation.value); } } + attachContainerQuery(root, ref); getAttributeQuery(ref).push(attributeQuery); specificity[Specificity.ClassName] = (specificity[Specificity.ClassName] ?? 0) + 1; @@ -315,16 +321,7 @@ function parseComponents( component.name, ]); } else { - let containerQueries = containerQueryMap.get(root); - if (!containerQueries) { - containerQueries = []; - root.containerQuery = containerQueries; - containerQueryMap.set(root, containerQueries); - } - if (!ref.n) { - containerQueries.unshift(ref); - } - + attachContainerQuery(root, ref); ref.n = ref.n ? `${ref.n}.${component.name}` : `g:${component.name}`; } @@ -505,6 +502,34 @@ function isContainerQuery( return !("type" in value); } +/** + * An ancestor compound is a container query, whether or not it names a class. + * + * The rule's `cq` list is what the runtime evaluates, and a ref reaches it here. Only the class + * arm used to attach one, so a compound identified by a condition alone — `[data-state="on"] .x`, + * `:hover .x` — wrote that condition onto an object nothing read, and the rule applied to every + * element the class named, in every state. + */ +function attachContainerQuery( + root: PartialSelector, + ref: PartialSelector | ContainerQuery, +): void { + if (!isContainerQuery(ref)) { + return; + } + + let containerQueries = containerQueryMap.get(root); + if (!containerQueries) { + containerQueries = []; + root.containerQuery = containerQueries; + containerQueryMap.set(root, containerQueries); + } + + if (!containerQueries.includes(ref)) { + containerQueries.unshift(ref); + } +} + function getPseudoClassesQuery(key: PartialSelector | ContainerQuery) { let pseudoClassesQuery = pseudoClassesQueryMap.get(key); if (!pseudoClassesQuery) {