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
71 changes: 71 additions & 0 deletions src/__tests__/compiler/attribute-selectors.test.tsx
Original file line number Diff line number Diff line change
@@ -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"]]],
);
});
});
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