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": "~=",