diff --git a/src/__tests__/compiler/selectors.test.tsx b/src/__tests__/compiler/selectors.test.tsx
index 1df75cf0..bd77a42e 100644
--- a/src/__tests__/compiler/selectors.test.tsx
+++ b/src/__tests__/compiler/selectors.test.tsx
@@ -1,3 +1,5 @@
+import { compile } from "react-native-css/compiler";
+
import { getClassNameSelectors } from "../../compiler/selector-builder";
test("empty", () => {
@@ -150,3 +152,17 @@ 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]]);
+});
diff --git a/src/__tests__/native/attributes.test.tsx b/src/__tests__/native/attributes.test.tsx
index 900dde70..222ae632 100644
--- a/src/__tests__/native/attributes.test.tsx
+++ b/src/__tests__/native/attributes.test.tsx
@@ -280,3 +280,49 @@ 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(
+ ,
+ );
+ 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();
+ 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(
+ ,
+ );
+ expect(screen.getByTestId(testID).props.style.width).toBe(
+ matches ? 80 : 40,
+ );
+ });
+});
diff --git a/src/__tests__/native/direction-media.test.tsx b/src/__tests__/native/direction-media.test.tsx
index 8ed38ee4..014c087a 100644
--- a/src/__tests__/native/direction-media.test.tsx
+++ b/src/__tests__/native/direction-media.test.tsx
@@ -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();
+ expect(screen.getByTestId("t")).toHaveStyle({ width: matches ? 37 : 10 });
+});
diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts
index 6787c0ca..f66a5c1c 100644
--- a/src/compiler/selector-builder.ts
+++ b/src/compiler/selector-builder.ts
@@ -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,
@@ -249,7 +250,12 @@ 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] =
diff --git a/src/native/conditions/attributes.ts b/src/native/conditions/attributes.ts
index e856e79e..f6b53ec5 100644
--- a/src/native/conditions/attributes.ts
+++ b/src/native/conditions/attributes.ts
@@ -1,7 +1,10 @@
import type { AttributeQuery } from "react-native-css/compiler";
+import { asciiLowerCase } from "../../utilities/ascii-case";
import type { RenderGuard } from "./guards";
+type AttributeQueryType = AttributeQuery[0];
+
export function testAttributes(
queries: AttributeQuery[],
props: Record | undefined | null,
@@ -29,43 +32,55 @@ function testAttribute(
guards.push([type, prop, value]);
if (!operator) {
- return value !== undefined && value !== null && value !== false;
+ return isPresent(type, value);
}
if (operator === "!") return !value;
- if (value === undefined || value === null || testValue === undefined)
- return false;
- if (
- typeof value !== "string" &&
- typeof value !== "number" &&
- typeof value !== "boolean"
- )
- return false;
- let actual = String(value);
- if (caseSensitivity === "i") {
- // CSS attribute flags fold ASCII letters only, not Unicode characters.
- actual = actual.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
- testValue = testValue.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
- }
+ const attribute = attributeText(type, value);
+ if (attribute === undefined || testValue === undefined) return false;
+
+ const insensitive = caseSensitivity === "i";
+ const actual = insensitive ? asciiLowerCase(attribute) : attribute;
+ const expected = insensitive ? asciiLowerCase(testValue) : testValue;
switch (operator) {
case "=":
- return actual === testValue;
+ return actual === expected;
case "~=":
- return (
- testValue !== "" && actual.split(/[\t\n\f\r ]+/).includes(testValue)
- );
+ return expected !== "" && actual.split(/[\t\n\f\r ]+/).includes(expected);
case "|=":
- return actual === testValue || actual.startsWith(testValue + "-");
+ return actual === expected || actual.startsWith(expected + "-");
case "^=":
- return testValue !== "" && actual.startsWith(testValue);
+ return expected !== "" && actual.startsWith(expected);
case "$=":
- return testValue !== "" && actual.endsWith(testValue);
+ return expected !== "" && actual.endsWith(expected);
case "*=":
- return testValue !== "" && actual.includes(testValue);
+ return expected !== "" && actual.includes(expected);
default:
operator satisfies never;
return false;
}
}
+
+// react-native-web writes a dataSet entry as a data-* attribute for every value but null and undefined, while a false prop is an absent boolean attribute
+function isPresent(type: AttributeQueryType, value: unknown): boolean {
+ return (
+ value !== undefined && value !== null && (type === "d" || value !== false)
+ );
+}
+
+function attributeText(
+ type: AttributeQueryType,
+ value: unknown,
+): string | undefined {
+ if (
+ typeof value === "string" ||
+ typeof value === "number" ||
+ typeof value === "boolean"
+ ) {
+ return String(value);
+ }
+
+ return type === "d" && Array.isArray(value) ? value.join(",") : undefined;
+}
diff --git a/src/utilities/ascii-case.ts b/src/utilities/ascii-case.ts
new file mode 100644
index 00000000..748b92c5
--- /dev/null
+++ b/src/utilities/ascii-case.ts
@@ -0,0 +1,4 @@
+// Selectors ยง6.3 and HTML fold ASCII letters only: toLowerCase would also fold U+212A KELVIN SIGN onto k
+export function asciiLowerCase(text: string): string {
+ return text.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
+}