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) {