Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/__tests__/native/attributes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Text testID={testID} className={`test ${className}`} />);
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);
});
});
29 changes: 21 additions & 8 deletions src/compiler/selector-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -582,6 +582,19 @@ type CamelCase<S extends string> =
? `${Lowercase<P1>}${Uppercase<P2>}${CamelCase<P3>}`
: Lowercase<S>;

/**
* 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<AttrOperation["operator"], AttrSelectorOperator> = {
"equal": "=",
"includes": "~=",
Expand Down