diff --git a/src/__tests__/native/attributes.test.tsx b/src/__tests__/native/attributes.test.tsx index 78e1814c..f88186af 100644 --- a/src/__tests__/native/attributes.test.tsx +++ b/src/__tests__/native/attributes.test.tsx @@ -1,8 +1,12 @@ import { render, screen } from "@testing-library/react-native"; +import type { AttrSelectorOperator } from "react-native-css/compiler"; +import { compile } from "react-native-css/compiler"; import { Text } from "react-native-css/components/Text"; import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; +import { testAttributes } from "../../native/conditions/attributes"; + test(":disabled", () => { registerCSS(`.test:disabled { width: 10px; }`); @@ -133,3 +137,750 @@ describe("dataSet attribute selector", () => { }); }); }); + +/** + * A `dataSet` value and the DOM attribute react-native-web projects it onto must + * answer an attribute selector the same way, because one class string is written + * for both platforms. + * + * The matrix is asserted through a helper rather than the whole-props shape the + * tests above use: every case here differs only in whether the rule applied, and + * a rendered `style` says that exactly. + */ +describe("dataSet attribute values compare as strings", () => { + const matchedWidth = (value: unknown): number | undefined => { + render( + , + ); + const style = screen.getByTestId(testID).props.style as + | { width?: number } + | undefined; + return style?.width; + }; + + test("a boolean matches the spelling react-native-web renders", () => { + registerCSS(`.test[data-test='true'] { width: 10px; }`); + + // `true == "true"` is FALSE — both operands coerce to numbers and + // `Number("true")` is `NaN` — so loose equality rejects the one value the + // DOM says matches. + expect(matchedWidth(true)).toBe(10); + expect(matchedWidth("true")).toBe(10); + expect(matchedWidth(false)).toBeUndefined(); + }); + + test("a false boolean matches 'false' and nothing else", () => { + registerCSS(`.test[data-test='false'] { width: 10px; }`); + + expect(matchedWidth(false)).toBe(10); + expect(matchedWidth("false")).toBe(10); + expect(matchedWidth(true)).toBeUndefined(); + }); + + test("a boolean never matches a numeric operand", () => { + registerCSS(`.test[data-test='1'] { width: 10px; }`); + + // The FALSE POSITIVE direction: `true == "1"` is `true`, so loose equality + // matched a rule the DOM does not, on an element whose attribute reads + // `data-test="true"`. + expect(matchedWidth(true)).toBeUndefined(); + expect(matchedWidth(1)).toBe(10); + expect(matchedWidth("1")).toBe(10); + }); + + test("an empty operand needs an empty value, not a falsy one", () => { + registerCSS(`.test[data-test=''] { width: 10px; }`); + + // This direction needs no boolean at all: `0 == ""` is `true`, so a numeric + // zero matched an empty-string selector. + expect(matchedWidth(0)).toBeUndefined(); + expect(matchedWidth(false)).toBeUndefined(); + expect(matchedWidth("")).toBe(10); + }); + + test("equality agrees with the substring operators about one element", () => { + // The internal inconsistency that localises the defect: every other operator + // already coerces with `.toString()`, so before this change `[data-test='true']` + // was the one spelling that disagreed with `[data-test*='ru']` about the same + // element. + registerCSS(` + .contains[data-test*='ru'] { width: 10px; } + .equality[data-test='true'] { width: 10px; } + `); + + for (const className of ["contains", "equality"]) { + render( + , + ); + expect(screen.getByTestId(testID).props.style).toEqual({ width: 10 }); + } + }); +}); + +describe("dataSet attribute presence follows the DOM", () => { + test("a false value still renders the attribute, so it is present", () => { + registerCSS(`.test[data-test] { width: 10px; }`); + + // react-native-web writes `data-test="false"` for `{ test: false }` — it skips + // only a NULLISH value — so `[data-test]` matches on the web. A plain PROP is + // the opposite case and keeps its `!== false` rule: there `false` means the + // boolean attribute is absent, which is what `:disabled` above depends on. + // + // `null` is asserted beside `false` and `undefined` because presence is defined + // as nullish-ness: covering only the other two leaves the `!== null` half of + // that definition unpinned. + render( + , + ); + expect(screen.getByTestId(testID).props.style).toEqual({ width: 10 }); + + for (const absent of [undefined, null]) { + render( + , + ); + expect(screen.getByTestId(testID).props.style).toBeUndefined(); + } + }); +}); + +/** + * Selectors §6.1's remaining operators, each measured against the spec's own + * wording rather than the shape the implementation happens to have. + */ +describe("dataSet attribute operators follow Selectors §6.1", () => { + const matchedWidth = ( + className: string, + value: unknown, + ): number | undefined => { + render( + , + ); + const style = screen.getByTestId(testID).props.style as + | { width?: number } + | undefined; + return style?.width; + }; + + test("[att|=val] matches a value that is exactly val", () => { + registerCSS(`.test[data-test|='en'] { width: 10px; }`); + + // "an element whose att attribute value is a hyphen-separated list of values + // beginning with val" — the spec spells that out as exactly `val`, OR `val` + // followed by "-". Only the second half was implemented, so `[lang|="en"]` + // did not match `lang="en"` — the language it names. + expect(matchedWidth("test", "en")).toBe(10); + expect(matchedWidth("test", "en-GB")).toBe(10); + expect(matchedWidth("test", "english")).toBeUndefined(); + expect(matchedWidth("test", "fr")).toBeUndefined(); + }); + + // §6.1's "whitespace" links to INFRA, whose ASCII whitespace is exactly these + // five code points. Derived rather than restated: the test iterates the set, so + // a separator dropped from the runtime fails here by name. + const ASCII_WHITESPACE = [ + ["TAB", "\u0009"], + ["LF", "\u000A"], + ["FF", "\u000C"], + ["CR", "\u000D"], + ["SPACE", "\u0020"], + ] as const; + + // `\s` matches these too. None of them is ASCII whitespace, so none separates + // a word — a value holding one is a single word that no operand equals. + const NOT_ASCII_WHITESPACE = [ + ["VT", "\u000B"], + ["NBSP", "\u00A0"], + ["OGHAM SPACE MARK", "\u1680"], + ["EN QUAD", "\u2000"], + ["HAIR SPACE", "\u200A"], + ["LINE SEPARATOR", "\u2028"], + ["PARAGRAPH SEPARATOR", "\u2029"], + ["NARROW NBSP", "\u202F"], + ["MEDIUM MATHEMATICAL SPACE", "\u205F"], + ["IDEOGRAPHIC SPACE", "\u3000"], + ["BOM", "\uFEFF"], + ] as const; + + test("the separator censuses are non-empty", () => { + expect(ASCII_WHITESPACE.length).toBe(5); + expect(NOT_ASCII_WHITESPACE.length).toBeGreaterThan(0); + }); + + test.each(ASCII_WHITESPACE)( + "[att~=val] treats %s as a word separator", + (_name, separator) => { + registerCSS(`.test[data-test~='b'] { width: 10px; }`); + + expect(matchedWidth("test", `a${separator}b`)).toBe(10); + }, + ); + + test.each(NOT_ASCII_WHITESPACE)( + "[att~=val] does not treat %s as a word separator", + (_name, character) => { + registerCSS(`.test[data-test~='b'] { width: 10px; }`); + + expect(matchedWidth("test", `a${character}b`)).toBeUndefined(); + }, + ); + + test("[att~=val] splits on whitespace, not on the space character", () => { + registerCSS(`.test[data-test~='b'] { width: 10px; }`); + + // "a whitespace-separated list of words, one of which is exactly val". + // A newline separates words exactly as a space does; splitting on `" "` + // yields one word and finds nothing. + expect(matchedWidth("test", "a b c")).toBe(10); + expect(matchedWidth("test", "a\nb")).toBe(10); + expect(matchedWidth("test", "a\tb")).toBe(10); + expect(matchedWidth("test", "ab")).toBeUndefined(); + }); + + test("[att~=val] represents nothing when val contains whitespace", () => { + // "If val contains whitespace, it will never represent anything." + registerCSS(`.test[data-test~='a b'] { width: 10px; }`); + + expect(matchedWidth("test", "a b")).toBeUndefined(); + expect(matchedWidth("test", "a b c")).toBeUndefined(); + }); + + test("a substring operator represents nothing when val is empty", () => { + // "If val is the empty string then the selector does not represent anything." + registerCSS(` + .starts[data-test^=''] { width: 10px; } + .ends[data-test$=''] { width: 10px; } + .contains[data-test*=''] { width: 10px; } + .includes[data-test~=''] { width: 10px; } + `); + + for (const className of ["starts", "ends", "contains", "includes"]) { + expect(matchedWidth(className, "anything")).toBeUndefined(); + expect(matchedWidth(className, "")).toBeUndefined(); + } + }); + + test("the substring operators read the same string equality does", () => { + registerCSS(` + .starts[data-test^='tr'] { width: 10px; } + .ends[data-test$='ue'] { width: 10px; } + .contains[data-test*='ru'] { width: 10px; } + `); + + for (const className of ["starts", "ends", "contains"]) { + expect(matchedWidth(className, true)).toBe(10); + expect(matchedWidth(className, "true")).toBe(10); + expect(matchedWidth(className, false)).toBeUndefined(); + } + }); +}); + +describe("dataSet attribute case-sensitivity follows Selectors §6.3", () => { + const matchedWidth = ( + className: string, + value: unknown, + ): number | undefined => { + render( + , + ); + const style = screen.getByTestId(testID).props.style as + | { width?: number } + | undefined; + return style?.width; + }; + + // Every value-matching operator takes the flag, so the census is the operators + // rather than a chosen few: an operator that stops honouring it fails by name. + // Keyed by the operator union and bound with `satisfies`, so a new value-matching + // operator is a COMPILE error here rather than a census that silently stays at six. + const CASE_INSENSITIVE_SELECTORS = { + "=": "[data-test='abc' i]", + "~=": "[data-test~='abc' i]", + "|=": "[data-test|='abc' i]", + "^=": "[data-test^='abc' i]", + "$=": "[data-test$='abc' i]", + "*=": "[data-test*='abc' i]", + } satisfies Record; + + test.each(Object.entries(CASE_INSENSITIVE_SELECTORS))( + "%s honours the i flag", + (_operator, selector) => { + registerCSS(`.test${selector} { width: 10px; }`); + + expect(matchedWidth("test", "abc")).toBe(10); + expect(matchedWidth("test", "ABC")).toBe(10); + expect(matchedWidth("test", "AbC")).toBe(10); + expect(matchedWidth("test", "xyz")).toBeUndefined(); + }, + ); + + const CASE_SENSITIVE_SELECTORS = { + "=": "[data-test='abc' s]", + "~=": "[data-test~='abc' s]", + "|=": "[data-test|='abc' s]", + "^=": "[data-test^='abc' s]", + "$=": "[data-test$='abc' s]", + "*=": "[data-test*='abc' s]", + } satisfies Record; + + test.each(Object.entries(CASE_SENSITIVE_SELECTORS))( + "%s honours the s flag", + (_operator, selector) => { + registerCSS(`.test${selector} { width: 10px; }`); + + expect(matchedWidth("test", "abc")).toBe(10); + expect(matchedWidth("test", "ABC")).toBeUndefined(); + }, + ); + + test("the s flag and no flag are the same comparison", () => { + registerCSS(` + .explicit[data-test='abc' s] { width: 10px; } + .default[data-test='abc'] { width: 10px; } + `); + + for (const className of ["explicit", "default"]) { + expect(matchedWidth(className, "abc")).toBe(10); + expect(matchedWidth(className, "ABC")).toBeUndefined(); + } + }); + + test("the flag letter is case-insensitive itself", () => { + // CSS accepts `i`/`I` and `s`/`S`; lightningcss normalises both onto the same + // `caseSensitivity` value, so the runtime never sees the spelling. + registerCSS(` + .upperI[data-test='abc' I] { width: 10px; } + .upperS[data-test='abc' S] { width: 10px; } + `); + + expect(matchedWidth("upperI", "ABC")).toBe(10); + expect(matchedWidth("upperS", "ABC")).toBeUndefined(); + expect(matchedWidth("upperS", "abc")).toBe(10); + }); + + test("a single-compound :is() carries the flag", () => { + // Measured: lightningcss FLATTENS a single-branch, single-compound `:is()`, so + // this compiles to a plain `aq` on the rule and never enters + // `parseIsWhereComponents`. It is here as the shape an author writes, not as + // coverage of that second builder — an attribute query built inside `:is()` or + // `:where()` is stored as a container query and never matched against the + // element at all, with or without a flag. + registerCSS(`.test:is([data-test='abc' i]) { width: 10px; }`); + + expect(matchedWidth("test", "ABC")).toBe(10); + expect(matchedWidth("test", "xyz")).toBeUndefined(); + }); + + test("the i flag applies to a plain prop, not only to dataSet", () => { + registerCSS(`.test[aria-label='abc' i] { width: 10px; }`); + + render(); + expect(screen.getByTestId(testID).props.style).toEqual({ width: 10 }); + }); + + test("the i flag lowers the operand as well as the value", () => { + // Both sides are lowered, so an UPPERCASE operand matches a lowercase value. + registerCSS(`.test[data-test='ABC' i] { width: 10px; }`); + + expect(matchedWidth("test", "abc")).toBe(10); + }); + + test("the i flag does not change which characters separate words", () => { + // Lowering happens before the split, and it touches no separator. + registerCSS(`.test[data-test~='ABC' i] { width: 10px; }`); + + expect(matchedWidth("test", "xyz\tabc")).toBe(10); + expect(matchedWidth("test", "xyz\u00A0abc")).toBeUndefined(); + }); + + test("the i flag lowers ASCII only", () => { + // "any (ASCII-range) case-permutation". `toLowerCase` also maps U+212A KELVIN + // SIGN onto `k`, which would match a value no browser matches. + registerCSS(`.test[data-test='k' i] { width: 10px; }`); + + expect(matchedWidth("test", "K")).toBe(10); + expect(matchedWidth("test", "\u212A")).toBeUndefined(); + }); + + // lightningcss reports these — and only these, measured — as + // `ascii-case-insensitive-if-in-html-element-in-html-document`. A `data-*` name + // never carries it, so a `data-` selector cannot reach this branch at all. + const HTML_CONDITIONAL_SELECTORS = [ + ["type", "[type='abc']", { type: "ABC" }], + ["lang", "[lang|='en']", { lang: "EN-GB" }], + ["frame", "[frame='hsides']", { frame: "HSIDES" }], + ] as const; + + test.each(HTML_CONDITIONAL_SELECTORS)( + "%s is compared case-sensitively off the web", + (_name, selector, props) => { + // The condition the value names — an HTML element in an HTML document — is + // false everywhere this runtime runs, so it resolves to the default. Mapping + // it to `i` instead turns each of these green, which is what pins the branch. + registerCSS(`.test${selector} { width: 10px; }`); + + render(); + expect(screen.getByTestId(testID).props.style).toBeUndefined(); + }, + ); +}); + +describe("[dir=val] folds its operand, as HTML's attribute list does", () => { + // `dir` does not become an attribute query — it compiles to a media condition + // that compares literally against the app's direction. `dir` is on HTML's + // ASCII-case-insensitive list, so a browser answers `[dir="RTL"]` for `dir="rtl"` + // with no flag written; folding at compile time is what makes the two agree, and + // it is also what stops an explicit `i` flag being silently dropped here. + const dirConditions = (selector: string): unknown => + compile(`.probe${selector} { width: 1px; }`).stylesheet().s?.[0]?.[1]?.[0] + ?.m; + + test.each([ + ["lowercase", `[dir="rtl"]`], + ["uppercase", `[dir="RTL"]`], + ["mixed case", `[dir="RtL"]`], + ["uppercase with an explicit i flag", `[dir="RTL" i]`], + ])("%s compiles to the same condition", (_label, selector) => { + expect(dirConditions(selector)).toStrictEqual([["=", "dir", "rtl"]]); + }); +}); + +describe("the negation arm never coerces the prop it does not read", () => { + // `:empty` compiles to `["a", "children", "!"]`, and `children` is whatever was + // rendered — an array of elements normally, and anything at all in principle. + // Building the comparison string before the switch runs a conversion this arm + // never uses: it throws outright on a null-prototype object, and on the ordinary + // array case it walks and stringifies every child on every render. + // Driven through `testAttributes` rather than a render: React refuses a plain + // object as a child, so the only way to put one in front of this arm is to call + // the matcher the way `useNativeCss` does. + const negationHolds = (value: unknown): boolean => + testAttributes([["a", "children", "!"]], { children: value }, []); + + test.each([ + ["a null-prototype object", Object.create(null) as unknown], + ["an object whose toString is not callable", { toString: 5 }], + ["an array of elements", [1, 2, 3]], + ])(":empty answers for %s", (_label, value) => { + expect(() => negationHolds(value)).not.toThrow(); + expect(negationHolds(value)).toBe(false); + }); + + test(":empty still answers true for an empty child list", () => { + expect(negationHolds(undefined)).toBe(true); + expect(negationHolds("")).toBe(true); + }); +}); + +describe("§6 edges every operator has to agree on", () => { + const matchedWidth = ( + className: string, + value: unknown, + ): number | undefined => { + render( + , + ); + const style = screen.getByTestId(testID).props.style as + | { width?: number } + | undefined; + return style?.width; + }; + + // The census is every operator that compares a value, so a new one cannot be + // added without deciding what it answers on an absent attribute. + const VALUE_OPERATORS = { + "=": "[data-test='abc']", + "~=": "[data-test~='abc']", + "|=": "[data-test|='abc']", + "^=": "[data-test^='abc']", + "$=": "[data-test$='abc']", + "*=": "[data-test*='abc']", + } satisfies Record; + + test.each(Object.entries(VALUE_OPERATORS))( + "%s represents nothing when the attribute is absent", + (_operator, selector) => { + registerCSS(`.test${selector} { width: 10px; }`); + + render(); + expect(screen.getByTestId(testID).props.style).toBeUndefined(); + }, + ); + + test.each(Object.entries(VALUE_OPERATORS))( + "%s represents nothing when the value is nullish", + (_operator, selector) => { + registerCSS(`.test${selector} { width: 10px; }`); + + // react-native-web writes no `data-*` attribute for a nullish value, so + // there is nothing on the web for an operator to compare against. + expect(matchedWidth("test", undefined)).toBeUndefined(); + expect(matchedWidth("test", null)).toBeUndefined(); + }, + ); + + // The cases above use an operand no stringified nullish could contain, so they + // hold even if the coercion started answering "undefined" / "null". These are + // the operands that WOULD match those spellings — the only ones that pin the + // nullish branch rather than the operand's own letters. + const NULLISH_SPELLING_SELECTORS = [ + ["=", "[data-test='undefined']", undefined], + ["*=", "[data-test*='efi']", undefined], + ["^=", "[data-test^='und']", undefined], + ["$=", "[data-test$='ned']", undefined], + ["~=", "[data-test~='undefined']", undefined], + ["=", "[data-test='null']", null], + ["*=", "[data-test*='ul']", null], + ["^=", "[data-test^='nu']", null], + ["$=", "[data-test$='ll']", null], + ] as const; + + test("the nullish-spelling census can generate cases", () => { + expect(NULLISH_SPELLING_SELECTORS.length).toBeGreaterThan(0); + }); + + test.each(NULLISH_SPELLING_SELECTORS)( + "%s does not match a nullish value against its own spelling", + (_operator, selector, value) => { + registerCSS(`.test${selector} { width: 10px; }`); + + expect(matchedWidth("test", value)).toBeUndefined(); + }, + ); + + // Each of these three "begins with / ends with / contains" operators matches a + // value identical to its operand — a string begins with, ends with and contains + // itself. `~=` and `|=` do too, by their own clauses. + test.each(Object.entries(VALUE_OPERATORS))( + "%s matches a value equal to the operand", + (_operator, selector) => { + registerCSS(`.test${selector} { width: 10px; }`); + + expect(matchedWidth("test", "abc")).toBe(10); + }, + ); + + test("an unquoted operand is the same operand", () => { + // lightningcss reports an identifier operand and a string operand alike. + registerCSS(`.test[data-test=abc] { width: 10px; }`); + + expect(matchedWidth("test", "abc")).toBe(10); + expect(matchedWidth("test", "abd")).toBeUndefined(); + }); + + test("a CSS-escaped operand is unescaped before it is compared", () => { + // `\62` is `b`. The runtime compares the unescaped operand, so a selector + // written with escapes matches the plain value. + registerCSS(`.test[data-test='a\\62 c'] { width: 10px; }`); + + expect(matchedWidth("test", "abc")).toBe(10); + }); + + test("[att~=val] finds a word beside separators of any kind or number", () => { + // What this pins is the OUTCOME, not the tokenisation: whether the split + // collapses a run of separators is unobservable here, because an operand is + // guaranteed non-empty and no empty token can equal one. The runtime collapses + // them because INFRA's algorithm does, and the comment there says so. + registerCSS(`.test[data-test~='abc'] { width: 10px; }`); + + expect(matchedWidth("test", " abc ")).toBe(10); + expect(matchedWidth("test", "\t\nabc\r ")).toBe(10); + expect(matchedWidth("test", "x abc")).toBe(10); + expect(matchedWidth("test", " ")).toBeUndefined(); + expect(matchedWidth("test", "")).toBeUndefined(); + }); + + test("[att|=val] matches val followed by nothing but the hyphen", () => { + registerCSS(`.test[data-test|='en'] { width: 10px; }`); + + expect(matchedWidth("test", "en")).toBe(10); + expect(matchedWidth("test", "en-")).toBe(10); + expect(matchedWidth("test", "en-GB-oed")).toBe(10); + expect(matchedWidth("test", "en_GB")).toBeUndefined(); + expect(matchedWidth("test", "eng")).toBeUndefined(); + expect(matchedWidth("test", "fr-en")).toBeUndefined(); + }); + + test("an empty operand is excluded by four operators and not by |=", () => { + // §6.1/§6.2 give `~=`, `^=`, `$=` and `*=` an explicit empty-`val` exclusion. + // `|=` has NONE — it is "either being exactly val or beginning with val + // immediately followed by -" — so an empty operand matches the empty value and + // every value starting with a hyphen. Measured in Chromium. + registerCSS(` + .equals[data-test=''] { width: 10px; } + .starts[data-test^=''] { width: 10px; } + .ends[data-test$=''] { width: 10px; } + .contains[data-test*=''] { width: 10px; } + .includes[data-test~=''] { width: 10px; } + .dash[data-test|=''] { width: 10px; } + `); + + expect(matchedWidth("equals", "")).toBe(10); + for (const className of ["starts", "ends", "contains", "includes"]) { + expect(matchedWidth(className, "")).toBeUndefined(); + expect(matchedWidth(className, "anything")).toBeUndefined(); + } + + expect(matchedWidth("dash", "")).toBe(10); + expect(matchedWidth("dash", "-foo")).toBe(10); + expect(matchedWidth("dash", "-")).toBe(10); + expect(matchedWidth("dash", "foo")).toBeUndefined(); + }); + + test("a numeric value compares as the string the DOM would show", () => { + registerCSS(` + .equals[data-test='1.5'] { width: 10px; } + .contains[data-test*='.'] { width: 10px; } + `); + + expect(matchedWidth("equals", 1.5)).toBe(10); + expect(matchedWidth("contains", 1.5)).toBe(10); + }); + + test("a numeric value matches its own spelling, not an equivalent one", () => { + // The DOM attribute reads `data-test="1.5"`, so `[data-test='1.50']` matches + // nothing — the comparison is between strings, never between numbers. + registerCSS(`.test[data-test='1.50'] { width: 10px; }`); + + expect(matchedWidth("test", 1.5)).toBeUndefined(); + expect(matchedWidth("test", "1.50")).toBe(10); + }); +}); + +/** + * The specifications' own worked examples, kept in their original spelling so a + * reader can check each against the section it comes from. + * + * These run against PLAIN PROPS rather than `dataSet`, which is the other query + * type and the one the rest of this file exercises least. + */ +describe("the examples Selectors and CSS 2.1 give for section 6", () => { + const matchedWidth = ( + className: string, + props: Record, + ): number | undefined => { + render(); + const style = screen.getByTestId(testID).props.style as + | { width?: number } + | undefined; + return style?.width; + }; + + test("h1[title] — presence, whatever the value", () => { + registerCSS(`.test[title] { width: 10px; }`); + + expect(matchedWidth("test", { title: "anything" })).toBe(10); + expect(matchedWidth("test", { title: "" })).toBe(10); + expect(matchedWidth("test", {})).toBeUndefined(); + }); + + test("span[hello='Cleveland'][goodbye='Columbus'] — every query must hold", () => { + // CSS 2.1 §5.8.1's conjunction example. `testAttributes` answers a list with + // `every`, and this is the only case in the file that has two queries in it. + registerCSS( + `.test[hello='Cleveland'][goodbye='Columbus'] { width: 10px; }`, + ); + + expect( + matchedWidth("test", { hello: "Cleveland", goodbye: "Columbus" }), + ).toBe(10); + expect( + matchedWidth("test", { hello: "Cleveland", goodbye: "Cleveland" }), + ).toBeUndefined(); + expect(matchedWidth("test", { hello: "Cleveland" })).toBeUndefined(); + expect(matchedWidth("test", { goodbye: "Columbus" })).toBeUndefined(); + }); + + test("a[rel~='copyright'] — one word of a whitespace-separated list", () => { + registerCSS(`.test[rel~='copyright'] { width: 10px; }`); + + expect(matchedWidth("test", { rel: "copyright copyleft copyeditor" })).toBe( + 10, + ); + expect(matchedWidth("test", { rel: "copyright" })).toBe(10); + // A word is matched WHOLE — a prefix of one is not a member of the list. + expect(matchedWidth("test", { rel: "copyrights" })).toBeUndefined(); + expect( + matchedWidth("test", { rel: "copyleft copyeditor" }), + ).toBeUndefined(); + }); + + test("a[href='http://www.w3.org/'] — exact, punctuation and all", () => { + registerCSS(`.test[href='http://www.w3.org/'] { width: 10px; }`); + + expect(matchedWidth("test", { href: "http://www.w3.org/" })).toBe(10); + expect(matchedWidth("test", { href: "http://www.w3.org" })).toBeUndefined(); + }); + + test("*[lang|='en'] — en, en-US and en-cockney", () => { + // CSS 2.1 §5.8.1 names exactly these three. + registerCSS(`.test[lang|='en'] { width: 10px; }`); + + for (const lang of ["en", "en-US", "en-cockney"]) { + expect(matchedWidth("test", { lang })).toBe(10); + } + expect(matchedWidth("test", { lang: "english" })).toBeUndefined(); + expect(matchedWidth("test", { lang: "fr" })).toBeUndefined(); + }); + + test("object[type^='image/'] — Selectors §6.2's prefix example", () => { + registerCSS(`.test[type^='image/'] { width: 10px; }`); + + expect(matchedWidth("test", { type: "image/png" })).toBe(10); + expect(matchedWidth("test", { type: "image/" })).toBe(10); + expect(matchedWidth("test", { type: "text/image/png" })).toBeUndefined(); + }); + + test("a[href$='.html'] — Selectors §6.2's suffix example", () => { + registerCSS(`.test[href$='.html'] { width: 10px; }`); + + expect(matchedWidth("test", { href: "index.html" })).toBe(10); + expect(matchedWidth("test", { href: ".html" })).toBe(10); + expect(matchedWidth("test", { href: "index.htm" })).toBeUndefined(); + expect(matchedWidth("test", { href: "a.html.bak" })).toBeUndefined(); + }); + + test("p[title*='hello'] — Selectors §6.2's substring example", () => { + registerCSS(`.test[title*='hello'] { width: 10px; }`); + + expect(matchedWidth("test", { title: "say hello there" })).toBe(10); + expect(matchedWidth("test", { title: "hello" })).toBe(10); + expect(matchedWidth("test", { title: "HELLO" })).toBeUndefined(); + }); + + test("[frame=hsides i] — Selectors §6.3's flag example", () => { + registerCSS(`.test[frame='hsides' i] { width: 10px; }`); + + for (const frame of ["hsides", "HSIDES", "hSides"]) { + expect(matchedWidth("test", { frame })).toBe(10); + } + expect(matchedWidth("test", { frame: "vsides" })).toBeUndefined(); + }); +}); diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 88561b78..e616af76 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -249,49 +249,21 @@ function parseComponents( return []; } - getMediaQuery(ref).push([operator, "dir", component.operation.value]); + // `dir` is on HTML's ASCII-case-insensitive attribute list, so a browser + // answers `[dir="RTL"]` for `dir="rtl"` with no `i` flag written. The + // media condition this compiles to compares literally, so the operand is + // folded here — which also makes an explicit `i` flag a no-op rather than + // something silently dropped. + getMediaQuery(ref).push([ + operator, + "dir", + component.operation.value.toLowerCase(), + ]); 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", 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); - } - } - getAttributeQuery(ref).push(attributeQuery); + getAttributeQuery(ref).push(attributeQueryFor(component)); specificity[Specificity.ClassName] = (specificity[Specificity.ClassName] ?? 0) + 1; return parseComponents(rest, options, root, ref, specificity); @@ -460,16 +432,7 @@ 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", 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); - } + const attributeQuery = attributeQueryFor(component); queries ??= [{ specificity: [] }]; for (const query of queries) { if (type === "is") { @@ -582,6 +545,42 @@ type CamelCase = ? `${Lowercase}${Uppercase}${CamelCase}` : Lowercase; +/** + * One attribute query, built in one place. + * + * Both callers — the ordinary compound path and the `:is()` / `:where()` one — used + * to build this inline, which is how `[dir=…]` came to be the third construction + * site with none of the operator handling and how a §6.3 flag could reach one + * caller and not the other. + * + * §6.3's flags: only `i` changes a comparison, so only `i` is emitted. `s` asks for + * the default, and a query carrying it would be a fifth element the runtime reads + * and then ignores. The HTML-document-conditional value never applies either — + * there is no HTML element and no HTML document for its condition to be true in, so + * it resolves to the default as well. + */ +function attributeQueryFor( + component: Extract, +): AttributeQuery { + const isData = component.name.startsWith("data-"); + const query: AttributeQuery = isData + ? // [data-*] are turned into `dataSet` queries + ["d", toRNProperty(component.name.replace("data-", ""))] + : // Everything else is turned into `attribute` queries + ["a", toRNProperty(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"); + } + return query; +} + const operatorMap: Record = { "equal": "=", "includes": "~=", diff --git a/src/native/conditions/attributes.ts b/src/native/conditions/attributes.ts index 23b72804..257f3e42 100644 --- a/src/native/conditions/attributes.ts +++ b/src/native/conditions/attributes.ts @@ -10,8 +10,53 @@ export function testAttributes( return queries.every((query) => testAttribute(query, props, guards)); } +/** + * INFRA's ASCII whitespace, which §6.1's "whitespace-separated list" links to: + * TAB, LF, FF, CR and SPACE, and nothing else. + * + * `\s` is a different set — it also matches VT, NBSP, the U+2000 block, the line + * and paragraph separators and the BOM, twenty further code points, none of which + * separate a word here. Hoisted so the includes-match does not build a RegExp per + * call on a render path. + */ +const ASCII_WHITESPACE_RUN = /[\t\n\f\r ]+/u; + +/** + * The string an attribute selector compares against, or `undefined` when the + * attribute is absent. + * + * §6 compares attribute VALUES, and a DOM attribute value is always a string. A + * `dataSet` value is not — a React Native prop carries whatever the author wrote + * — so the comparison has to coerce. Every operator reads its value through here, + * so they answer one string rather than each carrying a spelling of its own. + * + * `null` and `undefined` answer `undefined` rather than `"null"` / `"undefined"`, + * because react-native-web omits those keys entirely (`createDOMProps` skips a + * nullish `dataSet` value), so on the web there is no attribute to compare. + * + * React writes an attribute with `"" + value` — ToPrimitive with hint `default` — + * where this is ToString, so the two disagree for an object carrying `valueOf` or + * `Symbol.toPrimitive`. Measured, they agree on every other value a prop carries, + * including `NaN`, `-0`, `1e21`, arrays and `Date`. + */ +function attributeValueText(value: unknown): string | undefined { + return value?.toString(); +} + +/** + * ASCII-range lowercasing, which is what §6.3's `i` flag asks for — "any + * (ASCII-range) case-permutation". + * + * `String.prototype.toLowerCase` applies the full Unicode case mapping, which + * folds U+212A KELVIN SIGN onto `k` — so a selector written `[att="k" i]` would + * match a value no browser matches. + */ +function asciiLowerCase(text: string): string { + return text.replace(/[A-Z]/gu, (letter) => letter.toLowerCase()); +} + function testAttribute( - [type, prop, operator, testValue]: AttributeQuery, + [type, prop, operator, testValue, caseSensitivity]: AttributeQuery, props: Record | undefined | null, guards: RenderGuard[], ) { @@ -29,24 +74,94 @@ function testAttribute( guards.push([type, prop, value]); if (!operator) { + /** + * §6.1 — `[attr]` matches when the attribute EXISTS, whatever it holds. + * + * The two query types answer that differently, because they project onto the + * DOM differently. A `dataSet` key becomes a `data-*` attribute for every + * non-nullish value — `false` included, which react-native-web forwards and + * React writes as `data-x="false"` — so existence is nullish-ness. A plain + * prop is the boolean-attribute case (`disabled`, `checked`), where `false` + * means the attribute is not there at all. + */ + if (type === "d") { + return value !== undefined && value !== null; + } return value !== undefined && value !== null && value !== false; } + // §6.1's `:not()` arm reads the prop's own falsiness rather than a string, so it + // is answered before anything coerces. Coercing first would run the conversion on + // a value this arm never compares — which throws on a null-prototype object, and + // walks the whole array on the `:empty` query, whose prop is `children`. + if (operator === "!") { + return !value; + } + + /** + * §6.3 — `i` compares any ASCII-range case permutation of the operand, so both + * sides are folded once here rather than at each arm below. `s` asks for the + * default comparison and is never emitted, so an absent flag is the only other + * state a query can carry. + */ + const insensitive = caseSensitivity === "i"; + const valueText = attributeValueText(value); + const attributeText = + insensitive && valueText !== undefined + ? asciiLowerCase(valueText) + : valueText; + const operand = + insensitive && testValue !== undefined + ? asciiLowerCase(testValue) + : testValue; + + // An absent attribute matches no value selector, whatever the operand — including + // an absent operand, which `undefined === undefined` would otherwise answer true. + if (attributeText === undefined || operand === undefined) { + return false; + } + switch (operator) { - case "!": - return !value; case "=": - return value == testValue; - case "~=": - return testValue && value?.toString().split(" ").includes(testValue); + // §6.1 — an exact comparison of two strings. Loose equality is not that: + // `true == "true"` is `false` (both coerce to numbers, and `Number("true")` + // is `NaN`), while `false == "0"` and `0 == ""` are both `true`. So the + // untouched form is wrong in both directions, and the second needs no + // boolean at all. + return attributeText === operand; case "|=": - return testValue && value?.toString().startsWith(testValue + "-"); + // §6.1 dash-match: the value is EXACTLY `operand`, or begins with `operand` + // immediately followed by "-". Only the second half was implemented, so + // `[lang|="en"]` did not match `lang="en"` — the language it names — while + // matching every subtag of it. + // + // This is the one operator §6 gives NO empty-operand exclusion, and the + // difference is observable: a browser matches `[att|=""]` against `""`, + // `"-"` and `"-foo"`. + return ( + attributeText === operand || attributeText.startsWith(operand + "-") + ); + case "~=": + // §6.1 includes-match: the value is a whitespace-separated list of words, + // one of which is exactly `operand`. A newline or a tab separates words + // exactly as a space does, so splitting on `" "` alone reads a + // newline-separated pair as one word and finds neither of the two it holds. + // + // The emptiness guard is the spec's own two exclusions, and it covers both: + // an empty operand "will never represent anything", and one containing + // whitespace cannot be a member of a whitespace-separated list. + return ( + operand !== "" && + attributeText.split(ASCII_WHITESPACE_RUN).includes(operand) + ); + // §6.2's three substring operators share one exclusion: an empty operand + // represents nothing. case "^=": - return testValue && value?.toString().startsWith(testValue); + return operand !== "" && attributeText.startsWith(operand); case "$=": - return testValue && value?.toString().endsWith(testValue); + return operand !== "" && attributeText.endsWith(operand); case "*=": - return testValue && value?.toString().includes(testValue); + return operand !== "" && attributeText.includes(operand); default: operator satisfies never; return false;