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
75 changes: 75 additions & 0 deletions src/__tests__/compiler/selectors.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { compile } from "react-native-css/compiler";

import { getClassNameSelectors } from "../../compiler/selector-builder";

test("empty", () => {
Expand Down Expand Up @@ -150,3 +152,76 @@ test(".group-[.test]:text-white { &:is(:where(.group):is(.test) *) {}", () => {
},
]);
});

// HTML compares a dir value ASCII case-insensitively unless the selector carries s
test.each([
['[dir="RTL"]', "rtl"],
['[dir="Rtl"]', "rtl"],
['[dir="rtl"]', "rtl"],
['[dir="RTL" i]', "rtl"],
['[dir="RTL" s]', "RTL"],
])(".t%s compiles to the direction %p", (attribute, direction) => {
const rule = compile(`.t${attribute} { color: red }`).stylesheet()
.s?.[0]?.[1][0];

expect(rule?.m).toStrictEqual([["=", "dir", direction]]);
});

test.each([
['[class="a b"]', ["a", "className", "=", "a b"]],
['[class~="b"]', ["a", "className", "~=", "b"]],
['[class*="b"]', ["a", "className", "*=", "b"]],
["[class]", ["a", "className"]],
['[data-state="open"]', ["d", "state", "=", "open"]],
["[disabled]", ["a", "disabled"]],
])(".t%s reads the prop it arrives on", (attribute, query) => {
const rule = compile(`.t${attribute} { color: red }`).stylesheet()
.s?.[0]?.[1][0];

expect(rule?.aq).toStrictEqual([query]);
});

test("an ancestor [class~=…] reads className through the :is() site", () => {
const rule = compile(
`.child:is(:where(.group)[class~="active"] *) { color: red }`,
)
.stylesheet()
.s?.find(([name]) => name === "child")?.[1][0];

expect(rule?.cq).toStrictEqual([
{ n: "g:group", a: [["a", "className", "~=", "active"]] },
]);
});

test.each([
['[data-x="a"]', ["test"]],
['[|data-x="a"]', ["test"]],
['[*|data-x="a"]', ["test"]],
['[undeclared|data-x="a"]', []],
])(".test%s emits the rules %p", (attribute, rules) => {
expect(
compile(`.test${attribute} { width: 10px }`)
.stylesheet()
.s?.map(([name]) => name) ?? [],
).toStrictEqual(rules);
});

test("a declared namespace prefix emits no rule, and a sibling rule survives", () => {
expect(
compile(
`@namespace ns url(http://example.com/ns); .dropped[ns|data-x="a"] { width: 10px } .kept[data-x="a"] { width: 10px }`,
)
.stylesheet()
.s?.map(([name]) => name),
).toStrictEqual(["kept"]);
});

test("an ancestor's namespace-qualified attribute emits no rule through the :is() site", () => {
expect(
compile(
`.child:is(:where(.group)[undeclared|data-x="a"] *) { width: 10px }`,
)
.stylesheet()
.s?.find(([name]) => name === "child"),
).toBeUndefined();
});
91 changes: 91 additions & 0 deletions src/__tests__/native/attributes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,3 +280,94 @@ test.each(["=", "|=", "~=", "^=", "$=", "*="] as const)(
expect(screen.getByTestId(testID).props.style.width).toBe(40);
},
);

// react-native-web writes a dataSet value as a data-* attribute unless it is null or undefined
describe("a dataSet value projects as react-native-web writes it", () => {
test.each([
[false, true],
[0, true],
["", true],
[null, false],
[undefined, false],
])("[data-test] over %p is present: %s", (value, present) => {
registerCSS(`.test { width: 40px; } .test[data-test] { width: 80px; }`);
render(
<View
testID={testID}
className="test"
{...{ dataSet: { test: value } }}
/>,
);
expect(screen.getByTestId(testID).props.style.width).toBe(
present ? 80 : 40,
);
});

test("a false plain prop is an absent boolean attribute", () => {
registerCSS(`.test { width: 40px; } .test[disabled] { width: 80px; }`);
render(<View testID={testID} className="test" {...{ disabled: false }} />);
expect(screen.getByTestId(testID).props.style.width).toBe(40);
});

test.each([
['[data-test="a,b"]', true],
['[data-test~="a"]', false],
])("%s over the array [a, b] matches: %s", (attribute, matches) => {
registerCSS(`.test { width: 40px; } .test${attribute} { width: 80px; }`);
render(
<View
testID={testID}
className="test"
{...{ dataSet: { test: ["a", "b"] } }}
/>,
);
expect(screen.getByTestId(testID).props.style.width).toBe(
matches ? 80 : 40,
);
});
});

test.each([
["[class='test example']", "example", true],
["[class='example']", "example", false],
["[class~='example']", "example", true],
["[class~='example']", "other", false],
["[class*='xamp']", "example", true],
["[class]", "example", true],
])(
".test%s over the class list test %s matches: %s",
(selector, name, matches) => {
registerCSS(`.test { width: 40px; } .test${selector} { width: 80px; }`);
render(<View testID={testID} className={`test ${name}`} />);
expect(screen.getByTestId(testID).props.style.width).toBe(
matches ? 80 : 40,
);
},
);

test.each([
["group active", true],
["group", false],
])("an ancestor class list %p answers [class~=…]: %s", (name, matches) => {
registerCSS(
`.child { width: 40px; } .child:is(:where(.group)[class~="active"] *) { width: 80px; }`,
);
render(
<View className={name}>
<View testID={testID} className="child" />
</View>,
);
expect(screen.getByTestId(testID).props.style.width).toBe(matches ? 80 : 40);
});

test.each([
['[|data-x="a"]', true],
['[*|data-x="a"]', true],
['[undeclared|data-x="a"]', false],
])(".test%s over data-x=a matches: %s", (attribute, matches) => {
registerCSS(`.test { width: 40px; } .test${attribute} { width: 80px; }`);
render(
<View testID={testID} className="test" {...{ dataSet: { x: "a" } }} />,
);
expect(screen.getByTestId(testID).props.style.width).toBe(matches ? 80 : 40);
});
14 changes: 14 additions & 0 deletions src/__tests__/native/direction-media.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,17 @@ test.each([false, true])(
expect(screen.getByTestId("ltr")).toHaveStyle({ width: isRTL ? 10 : 37 });
},
);

test.each([
['[dir="RTL"]', true],
['[dir="RTL" i]', true],
['[dir="RTL" s]', false],
])(".t%s matches a right-to-left layout: %s", (attribute, matches) => {
Object.defineProperty(I18nManager, "isRTL", {
configurable: true,
value: true,
});
registerCSS(`.t { width: 10px; } .t${attribute} { width: 37px; }`);
render(<View testID="t" className="t" />);
expect(screen.getByTestId("t")).toHaveStyle({ width: matches ? 37 : 10 });
});
120 changes: 48 additions & 72 deletions src/compiler/selector-builder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AttrOperation, Selector, SelectorList } from "lightningcss";

import { Specificity } from "../utilities";
import { asciiLowerCase } from "../utilities/ascii-case";
import type {
AttributeQuery,
AttrSelectorOperator,
Expand Down Expand Up @@ -249,61 +250,19 @@ function parseComponents(
return [];
}

getMediaQuery(ref).push([operator, "dir", component.operation.value]);
// HTML compares a dir value ASCII case-insensitively unless the selector carries s
const direction =
component.operation.caseSensitivity === "explicit-case-sensitive"
? component.operation.value
: asciiLowerCase(component.operation.value);
getMediaQuery(ref).push([operator, "dir", direction]);
return parseComponents(rest, options, root, ref, specificity);
} else {
// specificity[Specificity.ClassName] =
// (specificity[Specificity.ClassName] ?? 0) + 1;
const attributeQuery: AttributeQuery = component.name.startsWith(
"data-",
)
? // [data-*] are turned into `dataSet` queries
["d", toRNProperty(component.name.replace("data-", ""))]
: // Everything else is turned into `attribute` queries
[
"a",
component.name.startsWith("aria-")
? component.name
: toRNProperty(component.name),
];
if (component.operation) {
let operator: AttrSelectorOperator | undefined;
switch (component.operation.operator) {
case "equal":
operator = "=";
break;
case "includes":
operator = "~=";
break;
case "dash-match":
operator = "|=";
break;
case "prefix":
operator = "^=";
break;
case "substring":
operator = "*=";
break;
case "suffix":
operator = "$=";
break;
default:
component.operation.operator satisfies never;
break;
}
if (operator) {
// Append the operator onto the attribute query
attributeQuery.push(operator, component.operation.value);
if (
component.operation.caseSensitivity === "ascii-case-insensitive"
) {
attributeQuery.push("i");
} else if (
component.operation.caseSensitivity === "explicit-case-sensitive"
) {
attributeQuery.push("s");
}
}
const attributeQuery = attributeQueryFor(component);
if (!attributeQuery) {
return [];
}
getAttributeQuery(ref).push(attributeQuery);
specificity[Specificity.ClassName] =
Expand Down Expand Up @@ -474,27 +433,9 @@ function parseIsWhereComponents(
// specificity[Specificity.ClassName] =
// (specificity[Specificity.ClassName] ?? 0) + 1;
}
const attributeQuery: AttributeQuery = component.name.startsWith("data-")
? // [data-*] are turned into `dataSet` queries
["d", toRNProperty(component.name.replace("data-", ""))]
: // Everything else is turned into `attribute` queries
[
"a",
component.name.startsWith("aria-")
? component.name
: toRNProperty(component.name),
];
if (component.operation) {
const operator = operatorMap[component.operation.operator];
// Append the operator onto the attribute query
attributeQuery.push(operator, component.operation.value);
if (component.operation.caseSensitivity === "ascii-case-insensitive") {
attributeQuery.push("i");
} else if (
component.operation.caseSensitivity === "explicit-case-sensitive"
) {
attributeQuery.push("s");
}
const attributeQuery = attributeQueryFor(component);
if (!attributeQuery) {
return null;
}
queries ??= [{ specificity: [] }];
for (const query of queries) {
Expand Down Expand Up @@ -608,6 +549,41 @@ type CamelCase<S extends string> =
? `${Lowercase<P1>}${Uppercase<P2>}${CamelCase<P3>}`
: Lowercase<S>;

// React Native names the class list className, and keeps an aria-* prop as written
function attributePropName(name: string): string {
if (name === "class") {
return "className";
}

return name.startsWith("aria-") ? name : toRNProperty(name);
}

// A React Native prop is in no namespace, so [ns|att] represents nothing (Selectors 4 §6.4)
function attributeQueryFor(
component: Extract<Selector[number], { type: "attribute" }>,
): AttributeQuery | undefined {
if (component.namespace?.type === "specific") {
return undefined;
}

const query: AttributeQuery = component.name.startsWith("data-")
? ["d", toRNProperty(component.name.replace("data-", ""))]
: ["a", attributePropName(component.name)];

const operation = component.operation;
if (!operation) {
return query;
}

query.push(operatorMap[operation.operator], operation.value);
if (operation.caseSensitivity === "ascii-case-insensitive") {
query.push("i");
} else if (operation.caseSensitivity === "explicit-case-sensitive") {
query.push("s");
}
return query;
}

const operatorMap: Record<AttrOperation["operator"], AttrSelectorOperator> = {
"equal": "=",
"includes": "~=",
Expand Down
Loading