diff --git a/src/__tests__/compiler/attribute-selectors.test.tsx b/src/__tests__/compiler/attribute-selectors.test.tsx
new file mode 100644
index 00000000..00d153cc
--- /dev/null
+++ b/src/__tests__/compiler/attribute-selectors.test.tsx
@@ -0,0 +1,71 @@
+import { compile } from "react-native-css/compiler";
+
+/**
+ * The attribute query the compiler emits for one class's first rule.
+ *
+ * The runtime suite can only observe the RESULT of evaluating this, so a wrong
+ * prop name and a wrong evaluation are the same red there. This reads the
+ * emitted query itself, which is the half the runtime cannot see.
+ */
+function attributeQueryFor(css: string, className: string): unknown {
+ const rules = compile(css).stylesheet().s;
+ const entry = rules?.find(([name]) => name === className);
+ if (!entry) {
+ throw new Error(
+ `the compiler emitted no rule for .${className} — the stylesheet carries ${JSON.stringify(rules?.map(([name]) => name))}`,
+ );
+ }
+ const [, declarations] = entry;
+ return declarations.map((declaration) => declaration.aq);
+}
+
+/**
+ * `class` is the CSS attribute; `className` is the prop it arrives on in React
+ * Native. Emitting `class` names a prop no element has, so every `[class…]`
+ * selector matches nothing.
+ */
+const CLASS_ATTRIBUTE_CASES = [
+ [
+ "exact match",
+ `.exact[class="a b"] { color: red }`,
+ "exact",
+ ["a", "className", "=", "a b"],
+ ],
+ [
+ "whitespace-list match",
+ `.list[class~="b"] { color: red }`,
+ "list",
+ ["a", "className", "~=", "b"],
+ ],
+ [
+ "substring match",
+ `.part[class*="b"] { color: red }`,
+ "part",
+ ["a", "className", "*=", "b"],
+ ],
+ ["presence", `.present[class] { color: red }`, "present", ["a", "className"]],
+] as const;
+
+describe("[class…] reads the prop the class list arrives on", () => {
+ test("the census is not empty, so the cases below are not vacuous", () => {
+ expect(CLASS_ATTRIBUTE_CASES.length).toBeGreaterThan(0);
+ });
+
+ test.each(CLASS_ATTRIBUTE_CASES)("%s", (_label, css, className, expected) => {
+ expect(attributeQueryFor(css, className)).toStrictEqual([[expected]]);
+ });
+});
+
+describe("every other attribute keeps its own channel", () => {
+ test("a data-* attribute compiles to a dataSet query, not an attribute one", () => {
+ expect(
+ attributeQueryFor(`.d[data-state="open"] { color: red }`, "d"),
+ ).toStrictEqual([[["d", "state", "=", "open"]]]);
+ });
+
+ test("a plain attribute is untouched by the class mapping", () => {
+ expect(attributeQueryFor(`.p[disabled] { color: red }`, "p")).toStrictEqual(
+ [[["a", "disabled"]]],
+ );
+ });
+});
diff --git a/src/__tests__/native/attributes.test.tsx b/src/__tests__/native/attributes.test.tsx
index 78e1814c..3a222be0 100644
--- a/src/__tests__/native/attributes.test.tsx
+++ b/src/__tests__/native/attributes.test.tsx
@@ -133,3 +133,50 @@ describe("dataSet attribute selector", () => {
});
});
});
+
+describe("[class=…] reads the prop the class list actually arrives on", () => {
+ // CSS 2.1 §5.8.1's own example is `span[class=example]`. On React Native the
+ // class list is `className`, so an unmapped query reads `props.class` — which no
+ // element has — and answers false for every element. The compiler's own compound
+ // path already maps it, building `["a", "className", "*=", name]` for a second
+ // class name in the same selector.
+ const matchedWidth = (
+ selector: string,
+ className: string,
+ ): number | undefined => {
+ registerCSS(`.test${selector} { width: 10px; }`);
+ render();
+ const style = screen.getByTestId(testID).props.style as
+ | { width?: number }
+ | undefined;
+ return style?.width;
+ };
+
+ test("[class=val] compares against the WHOLE class list", () => {
+ // §6.1's `=` is an exact match on the attribute's value, and the value here
+ // is the entire class list — so the spec's own `span[class=example]` matches
+ // `class="example"` and not `class="test example"`, exactly as in a browser.
+ expect(matchedWidth(`[class='test example']`, "example")).toBe(10);
+ expect(matchedWidth(`[class='example']`, "example")).toBeUndefined();
+ });
+
+ test("[class~=val] finds one word of the class list", () => {
+ expect(matchedWidth(`[class~='example']`, "example")).toBe(10);
+ expect(matchedWidth(`[class~='example']`, "other")).toBeUndefined();
+ });
+
+ test("[class*=val] finds a substring of the class list", () => {
+ expect(matchedWidth(`[class*='xamp']`, "example")).toBe(10);
+ expect(matchedWidth(`[class*='xamp']`, "other")).toBeUndefined();
+ });
+
+ test("[class] is present whenever the element carries a class", () => {
+ expect(matchedWidth(`[class]`, "example")).toBe(10);
+ });
+
+ test("the name maps at the :is() build site too", () => {
+ // `:is()` builds its queries on a separate path, so the mapping has to reach
+ // it as well.
+ expect(matchedWidth(`:is([class~='example'])`, "example")).toBe(10);
+ });
+});
diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts
index 88561b78..41c2abbc 100644
--- a/src/compiler/selector-builder.ts
+++ b/src/compiler/selector-builder.ts
@@ -254,13 +254,12 @@ function parseComponents(
} else {
// specificity[Specificity.ClassName] =
// (specificity[Specificity.ClassName] ?? 0) + 1;
- const attributeQuery: AttributeQuery = component.name.startsWith(
- "data-",
- )
+ const name = attributePropName(component.name);
+ const attributeQuery: AttributeQuery = name.startsWith("data-")
? // [data-*] are turned into `dataSet` queries
- ["d", toRNProperty(component.name.replace("data-", ""))]
+ ["d", toRNProperty(name.replace("data-", ""))]
: // Everything else is turned into `attribute` queries
- ["a", toRNProperty(component.name)];
+ ["a", toRNProperty(name)];
if (component.operation) {
let operator: AttrSelectorOperator | undefined;
switch (component.operation.operator) {
@@ -460,11 +459,12 @@ function parseIsWhereComponents(
// specificity[Specificity.ClassName] =
// (specificity[Specificity.ClassName] ?? 0) + 1;
}
- const attributeQuery: AttributeQuery = component.name.startsWith("data-")
+ const name = attributePropName(component.name);
+ const attributeQuery: AttributeQuery = name.startsWith("data-")
? // [data-*] are turned into `dataSet` queries
- ["d", toRNProperty(component.name.replace("data-", ""))]
+ ["d", toRNProperty(name.replace("data-", ""))]
: // Everything else is turned into `attribute` queries
- ["a", toRNProperty(component.name)];
+ ["a", toRNProperty(name)];
if (component.operation) {
const operator = operatorMap[component.operation.operator];
// Append the operator onto the attribute query
@@ -582,6 +582,19 @@ type CamelCase =
? `${Lowercase}${Uppercase}${CamelCase}`
: Lowercase;
+/**
+ * The prop an attribute name is read from.
+ *
+ * `class` is the one attribute whose React Native spelling differs: the class
+ * list arrives as `className`, so an unmapped `[class=…]` query reads `props.class`
+ * — a prop no element has — and answers false for every element. The compound
+ * form already knows this, building `["a", "className", "*=", name]` for a second
+ * class name in the same selector.
+ */
+function attributePropName(name: string): string {
+ return name === "class" ? "className" : name;
+}
+
const operatorMap: Record = {
"equal": "=",
"includes": "~=",