diff --git a/src/__tests__/compiler/logical-borders.test.ts b/src/__tests__/compiler/logical-borders.test.ts index 00a2c709..16739b10 100644 --- a/src/__tests__/compiler/logical-borders.test.ts +++ b/src/__tests__/compiler/logical-borders.test.ts @@ -63,29 +63,6 @@ describe("logical border widths", () => { }); describe("logical border shorthands", () => { - test("border-block", () => { - expect(getRule("border-block: 2px solid red;").rule).toStrictEqual([ - { - s: [1, 1], - d: [ - { borderBlockColor: "#f00", borderTopWidth: 2, borderBottomWidth: 2 }, - ], - }, - ]); - }); - - test("border-block-start", () => { - expect(getRule("border-block-start: 2px solid red;").rule).toStrictEqual([ - { s: [1, 1], d: [{ borderBlockStartColor: "#f00", borderTopWidth: 2 }] }, - ]); - }); - - test("border-block-end", () => { - expect(getRule("border-block-end: 2px solid red;").rule).toStrictEqual([ - { s: [1, 1], d: [{ borderBlockEndColor: "#f00", borderBottomWidth: 2 }] }, - ]); - }); - test("border-inline-start", () => { expect(getRule("border-inline-start: 2px solid red;").rule).toStrictEqual([ { s: [1, 1], d: [{ borderStartColor: "#f00", borderStartWidth: 2 }] }, @@ -116,29 +93,6 @@ describe("logical border shorthands", () => { }); describe("logical border styles", () => { - test.each([ - "border-block-style", - "border-block-start-style", - "border-block-end-style", - ])( - "%s solid uses the native default without emitting an unsupported prop", - (property) => { - const { rule, warnings } = getRule(`${property}: solid;`); - expect(rule).toBeUndefined(); - expect(warnings).toEqual({}); - }, - ); - - test("a dashed block style reports the unsupported side styles", () => { - const { rule, warnings } = getRule("border-block-style: dashed;"); - expect(rule).toBeUndefined(); - expect(warnings).toEqual({ - values: { - "border-block-start-style": ["dashed"], - "border-block-end-style": ["dashed"], - }, - }); - }); // React Native only has a uniform borderStyle. solid matches the native // default and is dropped silently, anything else drops with a warning test("border-inline-start-style: solid is dropped without warning", () => { @@ -162,3 +116,379 @@ describe("logical border styles", () => { }); }); }); + +describe("logical border styles via var() (unparsed path)", () => { + /** + * A var() keeps the declaration unparsed, so its value is unknown at compile + * time. React Native has no per-side border style either way, so the + * declaration drops — and an unknown value is not a known non-solid one, so + * it drops as quietly as `solid` does. Tailwind v4 puts every + * `border-{x,s,e}-*` utility through here via `var(--tw-border-style)`. + */ + test.each([ + "border-inline-style", + "border-inline-start-style", + "border-inline-end-style", + ])("%s with a var() drops without warning", (property) => { + const { rule, warnings } = getRule(`${property}: var(--tw-border-style);`); + + expect(rule).toBeUndefined(); + expect(warnings).toStrictEqual({}); + }); + + test("a var() style leaves the width beside it alone", () => { + const { rule, warnings } = getRule( + "border-inline-start-style: var(--tw-border-style); border-inline-start-width: 1px;", + ); + + expect(rule).toStrictEqual([{ s: [1, 1], d: [{ borderStartWidth: 1 }] }]); + expect(warnings).toStrictEqual({}); + }); + + test("a var() style over the whole inline axis leaves both widths alone", () => { + const { rule, warnings } = getRule( + "border-inline-style: var(--tw-border-style); border-inline-width: 1px;", + ); + + expect(rule).toStrictEqual([ + { s: [1, 1], d: [{ borderStartWidth: 1, borderEndWidth: 1 }] }, + ]); + expect(warnings).toStrictEqual({}); + }); +}); + +describe("a two-edge logical property via var() compiles to a runtime call", () => { + // A var() can substitute any number of values, so the runtime handler reads the arity once it resolves, as it does for border + test.each([ + [ + "border-inline-color: hsl(var(--primary));", + "borderInlineColor", + [{}, "hsl", [{}, "var", "primary", 1]], + ], + [ + "border-inline-color: var(--c);", + "borderInlineColor", + [{}, "var", "c", 1], + ], + [ + "border-inline-color: var(--c, red);", + "borderInlineColor", + [{}, "var", ["c", "red"], 1], + ], + [ + "border-inline-color: var(--a) var(--b);", + "borderInlineColor", + [ + [{}, "var", "a", 1], + [{}, "var", "b", 1], + ], + ], + [ + "border-inline-color: red var(--b);", + "borderInlineColor", + ["red", [{}, "var", "b", 1]], + ], + [ + "border-inline-width: var(--w);", + "borderInlineWidth", + [{}, "var", "w", 1], + ], + [ + "border-inline-width: var(--w, 3px);", + "borderInlineWidth", + [{}, "var", ["w", 3], 1], + ], + [ + "border-inline-width: calc(var(--w) * 2);", + "borderInlineWidth", + [{}, "calc", [[{}, "var", "w", 1], "*", 2]], + ], + [ + "border-inline-width: var(--a) var(--b);", + "borderInlineWidth", + [ + [{}, "var", "a", 1], + [{}, "var", "b", 1], + ], + ], + [ + "border-inline-width: var(--a) var(--b) var(--c);", + "borderInlineWidth", + [ + [{}, "var", "a", 1], + [{}, "var", "b", 1], + [{}, "var", "c", 1], + ], + ], + [ + "border-inline-width: 1px var(--b);", + "borderInlineWidth", + [1, [{}, "var", "b", 1]], + ], + ["border-block-color: var(--c);", "borderBlockColor", [{}, "var", "c", 1]], + [ + "border-block-color: var(--a) var(--b);", + "borderBlockColor", + [ + [{}, "var", "a", 1], + [{}, "var", "b", 1], + ], + ], + ["border-block-width: var(--w);", "borderBlockWidth", [{}, "var", "w", 1]], + [ + "border-block-width: var(--a) var(--b);", + "borderBlockWidth", + [ + [{}, "var", "a", 1], + [{}, "var", "b", 1], + ], + ], + [ + "border-block-width: var(--a) var(--b) var(--c);", + "borderBlockWidth", + [ + [{}, "var", "a", 1], + [{}, "var", "b", 1], + [{}, "var", "c", 1], + ], + ], + ] as const)("%s", (css, resolver, args) => { + const { rule, warnings } = getRule(css); + + expect(rule).toStrictEqual([ + { s: [1, 1], d: [[[{}, resolver, args, 1], resolver, 1]], dv: 1 }, + ]); + expect(warnings).toStrictEqual({}); + }); + + test("a value that compiles to nothing declares nothing", () => { + const { rule, warnings } = getRule("border-inline-color: inherit;"); + + expect(rule).toBeUndefined(); + expect(warnings).toStrictEqual({ + values: { "border-inline-color": ["inherit"] }, + }); + }); +}); + +describe("logical border three-part shorthands via var() (unparsed path)", () => { + // border-inline / -start / -end each pack width, style and colour into one + // value that stays opaque until the variable resolves, exactly as `border` + // does. They compile to the same runtime-call shape, and the native resolver + // fans the resolved list out across the inline edges. + test.each([ + ["border-inline", "borderInline"], + ["border-inline-start", "borderInlineStart"], + ["border-inline-end", "borderInlineEnd"], + ])("%s with a var() compiles to a runtime call", (property, resolver) => { + const { rule, warnings } = getRule(`${property}: var(--b);`); + + expect(rule).toStrictEqual([ + { + s: [1, 1], + d: [[[{}, resolver, [{}, "var", "b", 1], 1], resolver, 1]], + dv: 1, + }, + ]); + expect(warnings).toStrictEqual({}); + }); + + // The same shape `border` compiles to, which is what makes one runtime + // handler serve both. + test("the runtime-call shape matches the one `border` compiles to", () => { + const inline = getRule("border-inline: var(--b);").rule; + const uniform = getRule("border: var(--b);").rule; + + expect(inline).toStrictEqual([ + { + s: [1, 1], + d: [[[{}, "borderInline", [{}, "var", "b", 1], 1], "borderInline", 1]], + dv: 1, + }, + ]); + expect(uniform).toStrictEqual([ + { + s: [1, 1], + d: [[[{}, "border", [{}, "var", "b", 1], 1], "border", 1]], + dv: 1, + }, + ]); + }); + + test.each(["border-inline", "border-inline-start", "border-inline-end"])( + "%s without a var() still expands at compile time", + (property) => { + expect(getRule(`${property}: 2px solid red;`).warnings).toStrictEqual({}); + }, + ); +}); + +/** + * The block axis, which React Native supports differently from the inline one. + * + * The per-EDGE block colours are real props — `borderBlockStartColor` and + * `borderBlockEndColor` are in `ReactNativeStyleAttributes`, in both + * `BaseViewConfig`s and in `ViewStyle` — and each is the highest-precedence + * name for its edge on both platforms, so they are emitted as-is. The block + * WIDTHS appear only in `BaseViewConfig.ios.js`, so emitting them paints on + * iOS and nowhere else, and the axis-wide `borderBlockColor` is real but + * ordered against `borderTopColor` oppositely by the two platforms; both map + * to the physical edges instead. `direction` never flips the block axis, so + * block-start is the top edge on every platform. + */ +describe("block border widths", () => { + test("border-block-start-width", () => { + expect(getRule("border-block-start-width: 2px;").rule).toStrictEqual([ + { s: [1, 1], d: [{ borderTopWidth: 2 }] }, + ]); + }); + + test("border-block-end-width", () => { + expect(getRule("border-block-end-width: 2px;").rule).toStrictEqual([ + { s: [1, 1], d: [{ borderBottomWidth: 2 }] }, + ]); + }); + + test("border-block-width", () => { + expect(getRule("border-block-width: 2px;").rule).toStrictEqual([ + { s: [1, 1], d: [{ borderTopWidth: 2, borderBottomWidth: 2 }] }, + ]); + }); + + test("border-block-width with two values", () => { + expect(getRule("border-block-width: 1px 2px;").rule).toStrictEqual([ + { s: [1, 1], d: [{ borderTopWidth: 1, borderBottomWidth: 2 }] }, + ]); + }); +}); + +describe("block border colors", () => { + test.each([ + ["border-block-start-color", "borderBlockStartColor"], + ["border-block-end-color", "borderBlockEndColor"], + ])("%s keeps React Native's own prop", (property, key) => { + expect(getRule(`${property}: red;`).rule).toStrictEqual([ + { s: [1, 1], d: [{ [key]: "#f00" }] }, + ]); + }); + + test("border-block-color reaches the edge pair", () => { + expect(getRule("border-block-color: red;").rule).toStrictEqual([ + { s: [1, 1], d: [{ borderTopColor: "#f00", borderBottomColor: "#f00" }] }, + ]); + }); +}); + +describe("block border styles", () => { + // React Native has no per-edge border style on either axis. + test.each([ + "border-block-style", + "border-block-start-style", + "border-block-end-style", + ])("%s: solid is dropped without warning", (property) => { + const { rule, warnings } = getRule(`${property}: solid;`); + + expect(rule).toBeUndefined(); + expect(warnings).toStrictEqual({}); + }); + + test("border-block-start-style: dashed is dropped with a warning", () => { + const { rule, warnings } = getRule("border-block-start-style: dashed;"); + + expect(rule).toBeUndefined(); + expect(warnings).toStrictEqual({ + values: { "border-block-start-style": ["dashed"] }, + }); + }); + + test("border-block-style: two values warn per edge", () => { + expect( + getRule("border-block-style: dashed dotted;").warnings, + ).toStrictEqual({ + values: { + "border-block-start-style": ["dashed"], + "border-block-end-style": ["dotted"], + }, + }); + }); +}); + +describe("block border shorthands", () => { + test("border-block", () => { + expect(getRule("border-block: 2px solid red;").rule).toStrictEqual([ + { + s: [1, 1], + d: [ + { + borderTopColor: "#f00", + borderBottomColor: "#f00", + borderTopWidth: 2, + borderBottomWidth: 2, + }, + ], + }, + ]); + }); + + test("border-block-start", () => { + expect(getRule("border-block-start: 2px solid red;").rule).toStrictEqual([ + { s: [1, 1], d: [{ borderBlockStartColor: "#f00", borderTopWidth: 2 }] }, + ]); + }); + + test("border-block-end", () => { + expect(getRule("border-block-end: 2px solid red;").rule).toStrictEqual([ + { + s: [1, 1], + d: [{ borderBlockEndColor: "#f00", borderBottomWidth: 2 }], + }, + ]); + }); + + test("a block shorthand with a dashed style warns", () => { + expect(getRule("border-block: 2px dashed red;").warnings).toStrictEqual({ + values: { "border-block-style": ["dashed"] }, + }); + }); +}); + +describe("block borders via var() (unparsed path)", () => { + test.each([ + ["border-block-start-width", "borderTopWidth"], + ["border-block-end-width", "borderBottomWidth"], + ])("%s renames on the unparsed path too", (property, key) => { + expect(getRule(`${property}: var(--w);`).rule).toStrictEqual([ + { s: [1, 1], d: [[[{}, "var", "w", 1], key, 1]], dv: 1 }, + ]); + }); + + test.each([ + "border-block-style", + "border-block-start-style", + "border-block-end-style", + ])("%s with a var() drops without warning", (property) => { + const { rule, warnings } = getRule(`${property}: var(--tw-border-style);`); + + expect(rule).toBeUndefined(); + expect(warnings).toStrictEqual({}); + }); + + // The same runtime-call shape the inline axis and `border` compile to, which + // is what lets one handler grammar serve all seven. + test.each([ + ["border-block", "borderBlock"], + ["border-block-start", "borderBlockStart"], + ["border-block-end", "borderBlockEnd"], + ])("%s with a var() compiles to a runtime call", (property, resolver) => { + const { rule, warnings } = getRule(`${property}: var(--b);`); + + expect(rule).toStrictEqual([ + { + s: [1, 1], + d: [[[{}, resolver, [{}, "var", "b", 1], 1], resolver, 1]], + dv: 1, + }, + ]); + expect(warnings).toStrictEqual({}); + }); +}); diff --git a/src/__tests__/native/deferred-declarations.test.tsx b/src/__tests__/native/deferred-declarations.test.tsx new file mode 100644 index 00000000..cf3a4599 --- /dev/null +++ b/src/__tests__/native/deferred-declarations.test.tsx @@ -0,0 +1,111 @@ +import { render } from "@testing-library/react-native"; +import { Text } from "react-native-css/components/Text"; +import { registerCSS, testID } from "react-native-css/jest"; + +// A second definition keeps each variable from being inlined, so it resolves at runtime +const decoy = `.decoy { --c: green; --n: 9; --b: 1px solid green; --w: 9px; }`; + +function renderText(css: string, className = "my-class") { + registerCSS(`${decoy} ${css}`); + + return render().getByTestId( + testID, + ).props; +} + +// A deferred declaration resolves after every other one in its rule, against the target it was declared on +describe("a deferred declaration beside a nested one", () => { + test("color: var() beside a box-shadow", () => { + expect( + renderText( + `.my-class { --c: red; color: var(--c); box-shadow: 1px 1px blue; }`, + ).style, + ).toStrictEqual({ + color: "red", + boxShadow: [ + { + color: "#00f", + offsetX: 1, + offsetY: 1, + blurRadius: 0, + spreadDistance: 0, + }, + ], + }); + }); + + test("color: var() beside a text-shadow", () => { + expect( + renderText( + `.my-class { --c: red; color: var(--c); text-shadow: 1px 1px 2px blue; }`, + ).style, + ).toStrictEqual({ + color: "red", + textShadowColor: "#00f", + textShadowRadius: 2, + textShadowOffset: { width: 1, height: 1 }, + }); + }); + + test("color: var() beside a prop", () => { + const props = renderText( + `.my-class { --c: red; color: var(--c); -webkit-line-clamp: 2; }`, + ); + + expect(props.style).toStrictEqual({ color: "red" }); + expect(props.numberOfLines).toBe(2); + }); + + test("a deferred prop beside a style", () => { + const props = renderText( + `.my-class { --n: 2; -webkit-line-clamp: var(--n); --c: red; color: var(--c); }`, + ); + + expect(props.style).toStrictEqual({ color: "red" }); + expect(props.numberOfLines).toBe(2); + }); +}); + +// Chromium 153, Firefox 155 and WebKit 26.6: each key belongs to the declaration that set it last, so a later longhand beats an earlier shorthand however late the shorthand resolves +describe("a deferred shorthand keeps no key a later declaration set", () => { + test.each([ + [ + "border: var() then border-color, in one rule", + ".my-class { --b: 2px solid red; border: var(--b); border-color: blue; }", + "my-class", + { borderWidth: 2, borderStyle: "solid", borderColor: "#00f" }, + ], + [ + "border-color then border: var(), in one rule", + ".my-class { border-color: blue; --b: 2px solid red; border: var(--b); }", + "my-class", + { borderWidth: 2, borderStyle: "solid", borderColor: "red" }, + ], + [ + "border: var() then border-color, across rules", + ".earlier { --b: 2px solid red; border: var(--b); } .my-class { border-color: blue; }", + "earlier my-class", + { borderWidth: 2, borderStyle: "solid", borderColor: "#00f" }, + ], + [ + "border-color then border: var(), across rules", + ".earlier { border-color: blue; } .my-class { --b: 2px solid red; border: var(--b); }", + "earlier my-class", + { borderWidth: 2, borderStyle: "solid", borderColor: "red" }, + ], + [ + "border-inline-width: var() then border-inline-start-width", + ".my-class { --w: 2px 3px; border-inline-width: var(--w); border-inline-start-width: 5px; }", + "my-class", + { borderStartWidth: 5, borderEndWidth: 3 }, + ], + [ + "border-inline-start-width then border-inline-width: var()", + ".my-class { border-inline-start-width: 5px; --w: 2px 3px; border-inline-width: var(--w); }", + "my-class", + { borderStartWidth: 2, borderEndWidth: 3 }, + ], + ] as const)("%s", (_name, css, className, style) => { + expect(renderText(css, className).style).toStrictEqual(style); + }); +}); diff --git a/src/__tests__/native/logical-borders.test.tsx b/src/__tests__/native/logical-borders.test.tsx new file mode 100644 index 00000000..1d511859 --- /dev/null +++ b/src/__tests__/native/logical-borders.test.tsx @@ -0,0 +1,1095 @@ +import type { ViewStyle } from "react-native"; + +import { act, fireEvent, render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { colorScheme } from "react-native-css/runtime"; + +import { dimensions } from "../../native/reactivity"; + +const children = undefined; + +/** + * Every border style key React Native declares. + * + * The membership is written by hand; what is not is the CONSTRAINT on it. + * `satisfies readonly (keyof ViewStyle)[]` makes React Native's own type + * decide which names may appear, so a name it does not declare cannot be added + * here to let a dead key through, and a name it drops in a later release turns + * the type-check red. Note which logical names are absent — there is no + * `borderInline*` of any kind, no `borderBlockWidth`, and no per-edge + * `border*Style`. Those are the keys this file exists to keep out of a + * rendered component. + */ +const REACT_NATIVE_BORDER_KEYS = [ + "borderBlockColor", + "borderBlockEndColor", + "borderBlockStartColor", + "borderBottomColor", + "borderBottomWidth", + "borderColor", + "borderEndColor", + "borderEndWidth", + "borderLeftColor", + "borderLeftWidth", + "borderRightColor", + "borderRightWidth", + "borderStartColor", + "borderStartWidth", + "borderStyle", + "borderTopColor", + "borderTopWidth", + "borderWidth", +] as const satisfies readonly (keyof ViewStyle)[]; + +/** + * Whether React Native understands a style key. + * + * A key it does not declare never reaches a shadow node — React Native's view + * config is a whitelist, so an unknown key is dropped with no error, no + * warning and no paint. That silence is why the assertion has to be made here, + * against the props a component actually received. + */ +const isRealStyleKey = (key: string): boolean => + (REACT_NATIVE_BORDER_KEYS as readonly string[]).includes(key); + +/** + * The style keys a rendered component actually received, sorted. + * + * `props` is untyped, and every assertion below that counts keys rather than + * comparing whole objects needs a real `string[]` to work from. + */ +const styleKeys = (id: string): string[] => { + const { style } = screen.getByTestId(id).props as { style?: object }; + return style === undefined ? [] : Object.keys(style).sort(); +}; + +/** + * The distinct values a rendered component's style holds, sorted. + * + * A key-set assertion cannot see a scheme whose colour never applied — the + * keys are identical either way — so any test that switches scheme asserts + * over this as well. + */ +const styleValues = (id: string): unknown[] => { + const { style } = screen.getByTestId(id).props as { style?: object }; + return style === undefined + ? [] + : [...new Set(Object.values(style))].sort((first, second) => + String(first).localeCompare(String(second)), + ); +}; + +/** + * React Native has no `borderInline*` style attribute of any kind, so a key + * shaped like one is inert whatever value it carries. + */ +const isInlineKey = (key: string): boolean => key.startsWith("borderInline"); + +/** + * A variable with a single definition is inlined by the compiler, so the + * declaration never reaches the unparsed path these tests exercise. Every + * variable below is defined twice to keep it unresolved at compile time. + */ +const twiceDefined = ` + :root { --width: 1px; --other-width: 2px; --color: red; --other-color: blue; } + .redefine { --width: 9px; --other-width: 9px; --color: black; --other-color: black; } +`; + +afterEach(() => { + act(() => { + colorScheme.set("light"); + }); +}); + +describe("border-inline-color / -width via var()", () => { + // Chromium 153, Firefox 155 and WebKit 26.6: a substituted value the grammar does not take is invalid at computed-value time, so neither edge is set (css-variables-1 §3.1) + test.each([ + [".my-class { border-inline-color: var(--number); }", "a number"], + [".my-class { border-inline-color: var(--three); }", "three colours"], + [".my-class { border-block-color: var(--number); }", "a number"], + [".my-class { border-block-color: var(--three); }", "three colours"], + ])("%s holding %s sets no edge", (css) => { + registerCSS(`${css} + :root { --number: 5; --three: red green blue; } + .redefine { --number: 9; --three: black black black; } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); + }); + + test("a single var() reaches both edges of a rendered View", () => { + registerCSS( + `.my-class { border-inline-color: var(--color); } ${twiceDefined}`, + ); + + render(); + const component = screen.getByTestId(testID); + + expect(component.type).toBe("View"); + expect(component.props).toStrictEqual({ + children, + testID, + style: { borderStartColor: "red", borderEndColor: "red" }, + }); + }); + + test("var() with a fallback", () => { + registerCSS(` + .colour { border-inline-color: var(--missing, red); } + .width { border-inline-width: var(--missing, 3px); } + .redefine-a { --missing: 1px; } + .redefine-b { --missing: 2px; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("colour").props.style).toStrictEqual({ + borderStartColor: "red", + borderEndColor: "red", + }); + expect(screen.getByTestId("width").props.style).toStrictEqual({ + borderStartWidth: 3, + borderEndWidth: 3, + }); + }); + + test("calc() over a var()", () => { + registerCSS( + `.my-class { border-inline-width: calc(var(--width) * 2); } ${twiceDefined}`, + ); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderStartWidth: 2, + borderEndWidth: 2, + }); + }); + + test("a var() that resolves to nothing paints nothing", () => { + registerCSS(` + .my-class { border-inline-color: var(--undefined-everywhere); } + .redefine-a { --undefined-everywhere: red; } + .redefine-b { --undefined-everywhere: blue; } + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); + }); +}); + +describe("border-inline-color / -width two-value expansion", () => { + /** + * The grammar is `{1,2}`: the second component is the END edge, not a + * second value for both edges. The parsed path already does this, so the + * unparsed path is asserted against it as well as against the literal props. + */ + test("width: the unparsed pair matches the parsed pair", () => { + registerCSS(` + .unparsed { border-inline-width: var(--width) var(--other-width); } + .parsed { border-inline-width: 1px 2px; } + ${twiceDefined} + `); + + render( + + + , + ); + + const unparsed = screen.getByTestId("unparsed").props.style; + const parsed = screen.getByTestId("parsed").props.style; + + expect(unparsed).toStrictEqual({ + borderStartWidth: 1, + borderEndWidth: 2, + }); + expect(unparsed).toStrictEqual(parsed); + }); + + test("colour: the unparsed pair matches the parsed pair", () => { + registerCSS(` + .unparsed { border-inline-color: var(--color) var(--other-color); } + .parsed { border-inline-color: red blue; } + ${twiceDefined} + `); + + render( + + + , + ); + + expect(screen.getByTestId("unparsed").props.style).toStrictEqual({ + borderStartColor: "red", + borderEndColor: "blue", + }); + expect(screen.getByTestId("parsed").props.style).toStrictEqual({ + borderStartColor: "#f00", + borderEndColor: "#00f", + }); + }); + + test("a literal start and a var() end", () => { + registerCSS( + `.my-class { border-inline-width: 1px var(--other-width); } ${twiceDefined}`, + ); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderStartWidth: 1, + borderEndWidth: 2, + }); + }); + + test("a literal start colour and a var() end colour", () => { + registerCSS( + `.my-class { border-inline-color: red var(--other-color); } ${twiceDefined}`, + ); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderStartColor: "red", + borderEndColor: "blue", + }); + }); +}); + +describe("border-inline-color via var() under a condition", () => { + test("inside @media", () => { + registerCSS(` + .my-class { border-inline-color: red; } + @media (min-width: 500px) { + .my-class { border-inline-color: var(--other-color); } + } + ${twiceDefined} + `); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 100 }); + }); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + borderStartColor: "#f00", + borderEndColor: "#f00", + }); + + act(() => { + dimensions.set({ ...dimensions.get(), width: 500 }); + }); + + expect(component.props.style).toStrictEqual({ + borderStartColor: "blue", + borderEndColor: "blue", + }); + }); + + test("on :hover", () => { + registerCSS(` + .my-class { border-inline-color: red; } + .my-class:hover { border-inline-color: var(--other-color); } + ${twiceDefined} + `); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + borderStartColor: "#f00", + borderEndColor: "#f00", + }); + + act(() => { + fireEvent(component, "hoverIn"); + }); + + expect(component.props.style).toStrictEqual({ + borderStartColor: "blue", + borderEndColor: "blue", + }); + }); + + test("!important beats a later longhand", () => { + registerCSS(` + .my-class { border-inline-color: var(--color) !important; } + .my-class { border-inline-end-color: black; } + ${twiceDefined} + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderStartColor: "red", + borderEndColor: "red", + }); + }); + + test("a later longhand beats the shorthand", () => { + registerCSS(` + .my-class { border-inline-color: var(--color); border-inline-end-color: black; } + ${twiceDefined} + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderStartColor: "red", + borderEndColor: "#000", + }); + }); +}); + +describe("border-inline style longhands via var()", () => { + /** + * React Native has no per-side border style attribute, so an inline border + * style paints nothing whatever its value resolves to. The width beside it + * still reaches its edge — that pair is what every Tailwind v4 + * `border-{x,s,e}-*` utility emits. + */ + test.each([ + "border-inline-style", + "border-inline-start-style", + "border-inline-end-style", + ])("%s paints nothing", (property) => { + registerCSS(` + .my-class { ${property}: var(--style); } + :root { --style: solid; } + .redefine { --style: dashed; } + `); + + render(); + expect(screen.getByTestId(testID).props).toStrictEqual({ + children, + testID, + }); + }); + + test("the width beside a var() style still reaches its edge", () => { + registerCSS(` + .my-class { + border-inline-start-style: var(--style); + border-inline-start-width: var(--width); + } + :root { --style: solid; } + .redefine { --style: dashed; } + ${twiceDefined} + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderStartWidth: 1, + }); + }); +}); + +/** + * React Native has no per-edge border STYLE attribute, at any layer: + * `BaseViewConfig.{android,ios}.js` lists `borderStyle` and nothing per-edge, + * `ViewStyle` declares only `borderStyle`, and Android's `BorderDrawable` + * holds one `borderStyle` for the whole path. So the style component of an + * inline-axis shorthand is dropped rather than widened to `borderStyle`, which + * would paint the block edges the declaration never mentioned. + * + * The width and colour components have RTL-aware per-edge props + * (`borderStartWidth` / `borderEndWidth` / `borderStartColor` / + * `borderEndColor`) and do reach the component. + */ +const shorthandDefinitions = ` + :root { --shorthand: 1px solid red; } + .redefine { --shorthand: 2px dashed blue; } +`; + +describe("border-inline / -start / -end shorthands via var()", () => { + test.each([ + [ + "border-inline", + { + borderStartWidth: 1, + borderEndWidth: 1, + borderStartColor: "red", + borderEndColor: "red", + }, + ], + ["border-inline-start", { borderStartWidth: 1, borderStartColor: "red" }], + ["border-inline-end", { borderEndWidth: 1, borderEndColor: "red" }], + ])("%s expands onto its edges", (property, expected) => { + registerCSS( + `.my-class { ${property}: var(--shorthand); } ${shorthandDefinitions}`, + ); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual(expected); + }); + + test("the unparsed expansion matches the parsed expansion", () => { + registerCSS(` + .unparsed { border-inline: var(--shorthand); } + .parsed { border-inline: 1px solid red; } + ${shorthandDefinitions} + `); + + render( + + + , + ); + + expect(screen.getByTestId("unparsed").props.style).toStrictEqual({ + borderStartWidth: 1, + borderEndWidth: 1, + borderStartColor: "red", + borderEndColor: "red", + }); + // The parsed path resolves `red` to #f00 at compile time; every other key + // must agree, which is what makes the two paths one behaviour. + expect(styleKeys("unparsed")).toStrictEqual(styleKeys("parsed")); + }); + + test.each(["border-inline", "border-inline-start", "border-inline-end"])( + "%s never widens its style component to borderStyle", + (property) => { + registerCSS(` + .my-class { ${property}: var(--dashed); } + :root { --dashed: 1px dashed red; } + .redefine { --dashed: 2px dotted blue; } + `); + + render(); + expect(screen.getByTestId(testID).props.style).not.toHaveProperty( + "borderStyle", + ); + }, + ); + + /** + * An empty style object, not an absent one: the declaration compiled to a + * runtime call, so the descriptor exists and resolves to nothing. The style + * LONGHANDS above have no descriptor at all, which is why they assert the + * stricter shape. What both share is that no `borderInline*` key survives. + */ + test("a var() that resolves to nothing paints nothing", () => { + registerCSS(` + .my-class { border-inline: var(--undefined-everywhere); } + .redefine-a { --undefined-everywhere: 1px solid red; } + .redefine-b { --undefined-everywhere: 2px solid blue; } + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({}); + }); + + /** + * The runtime route has its own way to leak the dead key the parsed path was + * fixed for: an unmatched or partly-matched value could fall back to writing + * the descriptor under its own `borderInline*` name. Assert against the + * component for every arity the grammar accepts, plus one it does not. + */ + test.each([ + "1px solid red", + "solid red", + "1px solid", + "solid", + "1px solid red blue extra", + ])("no borderInline* key reaches the component for `%s`", (value) => { + registerCSS(` + .my-class { border-inline: var(--shorthand); } + :root { --shorthand: ${value}; } + .redefine { --shorthand: 2px dotted blue; } + `); + + render(); + expect(styleKeys(testID).filter(isInlineKey)).toStrictEqual([]); + }); + + // Chromium 153, Firefox 155 and WebKit 26.6: each key belongs to the declaration that set it last + test("a later longhand beats a var() shorthand, as it beats `border`", () => { + registerCSS(` + .inline { border-inline: var(--shorthand); border-inline-end-color: black; } + .uniform { border: var(--shorthand); border-color: black; } + .literal { border-inline: 1px solid red; border-inline-end-color: black; } + ${shorthandDefinitions} + `); + + render( + + + + , + ); + + expect(screen.getByTestId("inline").props.style.borderEndColor).toBe( + "#000", + ); + expect(screen.getByTestId("uniform").props.style.borderColor).toBe("#000"); + expect(screen.getByTestId("literal").props.style.borderEndColor).toBe( + "#000", + ); + }); + + // Chromium 153, Firefox 155 and WebKit 26.6 read the values a var() substitutes, so a pair feeds one edge each + test("a two-value var() feeds one edge each", () => { + registerCSS(` + .inline { border-inline-width: var(--pair); border-inline-color: var(--colours); } + .block { border-block-width: var(--pair); border-block-color: var(--colours); } + :root { --pair: 1px 2px; --colours: red blue; } + .redefine { --pair: 9px 9px; --colours: black black; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("inline").props.style).toStrictEqual({ + borderStartWidth: 1, + borderEndWidth: 2, + borderStartColor: "red", + borderEndColor: "blue", + }); + expect(screen.getByTestId("block").props.style).toStrictEqual({ + borderTopWidth: 1, + borderBottomWidth: 2, + borderTopColor: "red", + borderBottomColor: "blue", + }); + }); +}); + +describe("the literal border-inline shorthand reaching the component", () => { + /** + * The compiler IR cannot see this defect: a `borderInlineStyle` entry in the + * emitted declarations looks exactly like a real one. Only the rendered + * component shows that React Native has no such style attribute, so the key + * is inert. Assert on the props React Native actually receives. + */ + test.each(["solid", "dashed", "dotted"])( + "border-inline: 6px %s #2266ee reaches the edges and carries no dead key", + (style) => { + registerCSS(`.my-class { border-inline: 6px ${style} #2266ee; }`); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderStartColor: "#26e", + borderEndColor: "#26e", + borderStartWidth: 6, + borderEndWidth: 6, + }); + // Named separately from the exact match above, so a regression reports + // the dead key rather than a whole-object diff. + expect(styleKeys(testID).filter(isInlineKey)).toStrictEqual([]); + }, + ); + + test.each(["border-inline-start", "border-inline-end"])( + "%s carries no dead key either", + (property) => { + registerCSS(`.my-class { ${property}: 6px dashed #2266ee; }`); + + render(); + expect(styleKeys(testID).filter(isInlineKey)).toStrictEqual([]); + }, + ); +}); + +describe("the block axis reaching the component", () => { + /** + * The block axis is the inline axis's twin and React Native supports it + * differently, which is why it needs its own expectations rather than a + * mirrored copy of the ones above. The per-EDGE block colours are real props + * — `borderBlockStartColor` and `borderBlockEndColor` are in + * `ReactNativeStyleAttributes`, in both `BaseViewConfig`s and in `ViewStyle`, + * and each outranks every other name for its edge on both platforms — so + * they are kept. The block WIDTHS are in `BaseViewConfig.ios.js` only, and + * the axis-wide `borderBlockColor` is read in the opposite order by the two + * platforms, so both map to the physical edges every platform agrees on. + * `direction` never flips the block axis, so block-start is the top edge and + * block-end the bottom one on every platform. + */ + test.each([ + [ + "border-block", + { + borderTopColor: "#26e", + borderBottomColor: "#26e", + borderTopWidth: 6, + borderBottomWidth: 6, + }, + ], + [ + "border-block-start", + { borderBlockStartColor: "#26e", borderTopWidth: 6 }, + ], + ["border-block-end", { borderBlockEndColor: "#26e", borderBottomWidth: 6 }], + ])("%s: 6px dashed #2266ee reaches real props", (property, expected) => { + registerCSS(`.my-class { ${property}: 6px dashed #2266ee; }`); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual(expected); + }); + + test.each([ + ["border-block-width", { borderTopWidth: 6, borderBottomWidth: 6 }], + ["border-block-start-width", { borderTopWidth: 6 }], + ["border-block-end-width", { borderBottomWidth: 6 }], + ])("%s: 6px reaches a width React Native reads", (property, expected) => { + registerCSS(`.my-class { ${property}: 6px; }`); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual(expected); + }); + + test("border-block-width takes its second value as the bottom edge", () => { + registerCSS(`.my-class { border-block-width: 1px 2px; }`); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderTopWidth: 1, + borderBottomWidth: 2, + }); + }); + + test.each([ + "border-block-style", + "border-block-start-style", + "border-block-end-style", + ])("%s paints nothing", (property) => { + registerCSS(`.my-class { ${property}: dashed; }`); + + render(); + expect(screen.getByTestId(testID).props).toStrictEqual({ + children, + testID, + }); + }); + + test.each([ + [ + "border-block", + { + borderTopWidth: 1, + borderBottomWidth: 1, + borderTopColor: "red", + borderBottomColor: "red", + }, + ], + ["border-block-start", { borderTopWidth: 1, borderBlockStartColor: "red" }], + ["border-block-end", { borderBottomWidth: 1, borderBlockEndColor: "red" }], + ])("%s via var() expands onto the same props", (property, expected) => { + registerCSS( + `.my-class { ${property}: var(--shorthand); } ${shorthandDefinitions}`, + ); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual(expected); + }); + + test("the unparsed block expansion matches the parsed one", () => { + registerCSS(` + .unparsed { border-block: var(--shorthand); } + .parsed { border-block: 1px solid red; } + ${shorthandDefinitions} + `); + + render( + + + , + ); + + // The parsed path resolves `red` to #f00 at compile time; every key must + // agree, which is what makes the two routes one behaviour. + expect(styleKeys("unparsed")).toStrictEqual(styleKeys("parsed")); + }); + + test.each(["border-block", "border-block-start", "border-block-end"])( + "%s never widens its style component to borderStyle", + (property) => { + registerCSS(` + .my-class { ${property}: var(--dashed); } + :root { --dashed: 1px dashed red; } + .redefine { --dashed: 2px dotted blue; } + `); + + render(); + expect(screen.getByTestId(testID).props.style).not.toHaveProperty( + "borderStyle", + ); + }, + ); + + test("border-block-width via a two-value var() pair splits the edges", () => { + registerCSS(` + .my-class { border-block-width: var(--width) var(--other-width); } + ${twiceDefined} + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderTopWidth: 1, + borderBottomWidth: 2, + }); + }); + + /** + * The longhands, at both arities the grammar accepts. + * + * The shorthand parity test above is not enough on its own: `border-block` + * takes no two-value form, so it cannot see a route that agrees with the + * parsed path at one arity and departs from it at the other. The block + * colours are exactly that shape — React Native gives the axis a property of + * its own only for colour, and the parsed path collapses onto it only when + * both edges agree — so each arity is asserted separately. + */ + test.each([ + ["border-block-color", "var(--color)", "red"], + ["border-block-color", "var(--color) var(--other-color)", "red blue"], + ["border-block-width", "var(--width)", "1px"], + ["border-block-width", "var(--width) var(--other-width)", "1px 2px"], + ])( + "%s: %s reaches the same keys as %s", + (property, unparsedValue, parsedValue) => { + registerCSS(` + .unparsed { ${property}: ${unparsedValue}; } + .parsed { ${property}: ${parsedValue}; } + ${twiceDefined} + `); + + render( + + + , + ); + + expect(styleKeys("unparsed")).toStrictEqual(styleKeys("parsed")); + }, + ); + + /** + * Why the parity above is a correctness requirement and not a tidiness one. + * + * Two declarations of the same property have to resolve as one — later wins. + * They only can if they land on the same keys: React Native's style object + * is flat, so two DISJOINT key sets both survive, and the platforms then + * disagree about which of them paints. A route that emitted + * `borderBlockStartColor` / `borderBlockEndColor` here would leave the var() + * painting both edges while the `green` written after it sat unused. + * + * The matrix is the full cross product of the two arities and not its + * diagonal. Two routes that agree at each arity separately still disagree + * across arities, and only an off-diagonal cell can see it. + */ + test.each([ + [ + "var(--color)", + "green", + { borderTopColor: "#008000", borderBottomColor: "#008000" }, + ], + [ + "var(--color) var(--other-color)", + "green", + { borderTopColor: "#008000", borderBottomColor: "#008000" }, + ], + [ + "var(--color)", + "green lime", + { borderTopColor: "#008000", borderBottomColor: "#0f0" }, + ], + [ + "var(--color) var(--other-color)", + "green lime", + { borderTopColor: "#008000", borderBottomColor: "#0f0" }, + ], + ])( + "border-block-color: %s is overridden by a later %s", + (unparsedValue, override, expected) => { + registerCSS(` + .base { border-block-color: ${unparsedValue}; } + .override { border-block-color: ${override}; } + ${twiceDefined} + `); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual(expected); + }, + ); + + /** + * The same requirement stated over the whole property rather than over one + * pair of declarations, because arity is not the only thing that moved the + * target. + * + * `parseBorderColor` chose between the axis property and the edge pair by + * comparing the two parsed components with `===`, which is a REFERENCE + * comparison: `red red` collapses because both components parse to the same + * interned string, while `currentcolor` splits because `parseColor` builds a + * fresh `[{}, "var", "__rn-css-color"]` array per call. Two declarations + * whose CSS says the same thing about both edges therefore landed on + * disjoint keys depending on how their value happened to be represented, + * which no author could predict and no cascade could reconcile. + */ + test.each([ + ["red", "parsed, one component"], + ["red blue", "parsed, two components"], + ["red red", "parsed, two equal components"], + ["currentcolor", "parsed, one non-primitive component"], + ["var(--color)", "unparsed, one component"], + ["var(--color) var(--other-color)", "unparsed, two components"], + ["var(--color) var(--color)", "unparsed, two equal components"], + ])("border-block-color: %s (%s) reaches the block edge pair", (value) => { + registerCSS(` + .my-class { color: black; border-block-color: ${value}; } + ${twiceDefined} + `); + + render(); + expect(styleKeys(testID)).toStrictEqual([ + "borderBottomColor", + "borderTopColor", + "color", + ]); + }); +}); + +/** + * The whole logical-border family, against React Native's own census of style + * attributes. + * + * This is the guard for the CLASS rather than for the rows fixed today. The + * defect it catches is invisible in the compiler IR — a `borderBlockWidth` + * entry in the emitted declarations looks exactly like a real one — and it is + * invisible to a hand-written expectation too, because a test author has to + * already know which of React Native's near-identical logical props exist. + * + * The census below is written out by hand — it has to be, because no type + * enumerates "the border keys". What is derived is the CONSTRAINT on it: + * `satisfies readonly (keyof ViewStyle)[]` makes React Native's own type the + * authority on which names may appear, so a dead key cannot be added here to + * make a failing case pass, and a name React Native drops in a later release + * turns `yarn typecheck` red without anyone editing a list. A name React + * Native ADDS does not appear on its own; widening the census stays a + * deliberate edit, which is why the negative control below pins the eleven + * names this file exists to keep out. + */ +describe("no logical border property reaches a key React Native lacks", () => { + /** Every `border-{inline,block}[-start|-end][-width|-style|-color]`. */ + const FAMILY: string[] = ["inline", "block"].flatMap((axis) => + ["", "-start", "-end"].flatMap((edge) => + ["", "-width", "-style", "-color"].map( + (suffix) => `border-${axis}${edge}${suffix}`, + ), + ), + ); + + const literalFor = (property: string): string => { + if (property.endsWith("-color")) return "#2266ee"; + if (property.endsWith("-width")) return "6px"; + if (property.endsWith("-style")) return "dashed"; + return "6px dashed #2266ee"; + }; + + const varFor = (property: string): string => { + if (property.endsWith("-color")) return "var(--color)"; + if (property.endsWith("-width")) return "var(--width)"; + if (property.endsWith("-style")) return "var(--style)"; + return "var(--shorthand)"; + }; + + /** + * The members that can carry a colour, which is every member that is not a + * width or a style — the six shorthands and the six `-color` longhands. + * Derived from the census rather than listed, so a member added to one is + * added to the other. + */ + const COLOUR_BEARING = FAMILY.filter( + (property) => !property.endsWith("-width") && !property.endsWith("-style"), + ); + + /** + * The two halves as written in CSS, and as the compiler emits them. Both + * routes emit the compressed form — a custom property's value is compressed + * where it is defined, so a var() carries the same text a literal does by + * the time it reaches a style object. + */ + const LIGHT_SOURCE = "#2266ee"; + const DARK_SOURCE = "#66aaff"; + const LIGHT_COLOUR = "#26e"; + const DARK_COLOUR = "#6af"; + + const lightDarkFor = (property: string): string => + property.endsWith("-color") + ? `light-dark(${LIGHT_SOURCE}, ${DARK_SOURCE})` + : `6px dashed light-dark(${LIGHT_SOURCE}, ${DARK_SOURCE})`; + + /** + * The same value with the two halves behind variables, which is a different + * ROUTE rather than a different spelling: a var() keeps the declaration off + * the parsed path, so the light-dark() is reduced by the unparsed reducer + * instead of by `parseColor`. Both reducers open the dark rule the same way, + * so both have to address it the same way. + * + * The census is the `-color` members only, and the three-part shorthands are + * left out deliberately rather than overlooked. A var() inside one of those + * compiles to a single runtime call carrying width, style and colour + * together, and the dark rule the reducer opens holds the colour ALONE — so + * whichever of the two rules lands second wins the whole set, and no choice + * of target for the dark rule can fix that. Making it correct means giving + * the reducer a scheme so the shorthand is reduced twice, once per branch, + * which is machinery every runtime-parsed shorthand shares (`border`, + * `border-top`, `box-shadow` and `text-shadow` all miss the same way today) + * and is not this family's to change. + */ + // An axis colour resolves at runtime, where a light-dark() holding a var() has no resolver yet + const LIGHT_DARK_VAR_COVERED = COLOUR_BEARING.filter( + (property) => + property.endsWith("-start-color") || property.endsWith("-end-color"), + ); + + const lightDarkVarFor = (): string => "light-dark(var(--light), var(--dark))"; + + const lightDarkDefinitions = ` + :root { --light: ${LIGHT_SOURCE}; --dark: ${DARK_SOURCE}; } + .redefine { --light: #000; --dark: #000; } + `; + + /** + * Generating the census trades a drift failure for a vacuity one: a family + * that stopped being generated makes every case below pass over nothing. + * + * 24 is the closed set CSS defines for this family — two axes, three edges, + * four value slots — so a different count means the generator changed rather + * than that CSS did. The two members are spot-checked because a generator + * producing 24 wrong strings would satisfy the count alone. + */ + test("the family census is the whole family", () => { + expect(FAMILY).toHaveLength(24); + expect(FAMILY).toContain("border-inline-start-width"); + expect(FAMILY).toContain("border-block-end-color"); + }); + + /** + * The negative control for the oracle above. Every case in this describe is + * an assertion that a set is EMPTY, and such an assertion passes just as + * happily when the predicate can never say no — so the predicate is pinned + * against the exact names this whole file exists to keep out. + */ + test.each([ + "borderInlineWidth", + "borderInlineStyle", + "borderInlineColor", + "borderInlineStartWidth", + "borderInlineEndColor", + "borderBlockWidth", + "borderBlockStartWidth", + "borderBlockEndWidth", + "borderBlockStyle", + "borderBlockStartStyle", + "borderBlockEndStyle", + ])("%s is not a key React Native understands", (key) => { + expect(isRealStyleKey(key)).toBe(false); + }); + + test("the three block COLOURS are keys React Native does understand", () => { + expect(isRealStyleKey("borderBlockColor")).toBe(true); + expect(isRealStyleKey("borderBlockStartColor")).toBe(true); + expect(isRealStyleKey("borderBlockEndColor")).toBe(true); + }); + + test.each(FAMILY)("%s (literal) emits only real style keys", (property) => { + registerCSS(`.my-class { ${property}: ${literalFor(property)}; }`); + + render(); + expect( + styleKeys(testID).filter((key) => !isRealStyleKey(key)), + ).toStrictEqual([]); + }); + + test.each(FAMILY)("%s (var) emits only real style keys", (property) => { + registerCSS(` + .my-class { ${property}: ${varFor(property)}; } + :root { --style: solid; } + .redefine { --style: dashed; } + ${twiceDefined} + ${shorthandDefinitions} + `); + + render(); + expect( + styleKeys(testID).filter((key) => !isRealStyleKey(key)), + ).toStrictEqual([]); + }); + + /** + * The same guard over the scheme the two spellings above cannot reach. + * + * `light-dark()` does not return its dark half through the value the parser + * hands back — it writes it straight to the builder as a second rule carried + * by `descriptorProperties`. Every expectation in this file that renders + * only the light scheme is therefore blind to where that second rule landed, + * and a dark rule addressed to a name React Native drops paints nothing with + * no error, no warning and no fallback. + * + * Both halves are asserted because a dead key is only one of the two ways + * the dark rule can miss. Landing on a REAL key that the light scheme did not + * use is the other: the two schemes then disagree about which key holds the + * colour, so the style object keeps both and the value that paints is + * whichever one the platform ranks higher, not the one the scheme selected. + */ + test.each(COLOUR_BEARING)( + "%s (light-dark) emits only real style keys in both schemes", + (property) => { + registerCSS(`.my-class { ${property}: ${lightDarkFor(property)}; }`); + + render(); + const light = styleKeys(testID); + + expect(light.filter((key) => !isRealStyleKey(key))).toStrictEqual([]); + expect(styleValues(testID)).toContain(LIGHT_COLOUR); + + act(() => { + colorScheme.set("dark"); + }); + + const dark = styleKeys(testID); + + expect(dark.filter((key) => !isRealStyleKey(key))).toStrictEqual([]); + expect(dark).toStrictEqual(light); + + // The half a key-set assertion cannot see: a dark rule that landed on a + // real key can still be the one that never applied. + expect(styleValues(testID)).toContain(DARK_COLOUR); + expect(styleValues(testID)).not.toContain(LIGHT_COLOUR); + }, + ); + + test.each(LIGHT_DARK_VAR_COVERED)( + "%s (light-dark over var) carries its scheme's colour to every key it sets", + (property) => { + registerCSS(` + .my-class { ${property}: ${lightDarkVarFor()}; } + ${lightDarkDefinitions} + `); + + render(); + const light = styleKeys(testID); + + expect(light.filter((key) => !isRealStyleKey(key))).toStrictEqual([]); + expect(styleValues(testID)).toStrictEqual([LIGHT_COLOUR]); + + act(() => { + colorScheme.set("dark"); + }); + + expect(styleKeys(testID)).toStrictEqual(light); + expect(styleValues(testID)).toStrictEqual([DARK_COLOUR]); + }, + ); +}); diff --git a/src/__tests__/vendor/tailwind/borders.test.tsx b/src/__tests__/vendor/tailwind/borders.test.tsx index 6de1c0be..dc12ba23 100644 --- a/src/__tests__/vendor/tailwind/borders.test.tsx +++ b/src/__tests__/vendor/tailwind/borders.test.tsx @@ -41,6 +41,10 @@ describe("Border - Border Width", () => { }, }); }); + // The block-axis twin of border-x-1 above. React Native reads no + // borderBlockWidth on Android or the old architecture and no per-edge + // border style anywhere, so both keys this used to assert were inert and + // the utility painted nothing. test("border-y-1", async () => { expect(await renderCurrentTest()).toStrictEqual({ props: { @@ -170,7 +174,8 @@ describe("Border - Border Color", () => { expect(await renderCurrentTest()).toStrictEqual({ props: { style: { - borderBlockColor: "#fff", + borderTopColor: "#fff", + borderBottomColor: "#fff", }, }, }); @@ -238,6 +243,25 @@ describe("Border - Border Color", () => { }, }); }); + + // An arbitrary var() Tailwind cannot fold at build time (unlike --spacing) + // keeps `border-inline-color` on the compiler's unparsed path. Two + // definitions keep it off the single-definition inliner as well. + test("border-x-[color:var(--c)]", async () => { + expect( + await renderSimple({ + className: "border-x-[color:var(--c)]", + extraCss: `:root { --c: red; } .redefine { --c: blue; }`, + }), + ).toStrictEqual({ + props: { + style: { + borderStartColor: "red", + borderEndColor: "red", + }, + }, + }); + }); test("border-y-current", async () => { expect( await renderSimple({ className: "border-y-current text-red-500" }), diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index 9ca2aab8..a25ee564 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -74,23 +74,41 @@ const propertyRename: Record = { "font-variant-caps": "font-variant", }; -// React Native only supports a uniform borderStyle, so per-side border -// styles have no native equivalent and are dropped. "solid" is dropped -// silently as it matches React Native's default rendering. -const unsupportedLogicalStyles = new Set([ - "border-inline-style", - "border-inline-start-style", - "border-inline-end-style", +// React Native has one borderStyle for the whole border, so a per-edge style has no native equivalent; "solid" is its default +const unsupportedEdgeStyles = new Set([ "border-block-style", "border-block-start-style", "border-block-end-style", + "border-inline-style", + "border-inline-start-style", + "border-inline-end-style", ]); +// One key set per property, so a later declaration replaces an earlier one; Android and iOS rank borderBlockColor against borderTopColor oppositely +const blockColorEdges = [ + "border-top-color", + "border-bottom-color", +] as const satisfies readonly [string, string]; + +// React Native has no borderInlineColor, so the RTL-aware pair is the inline axis's only target +const inlineColorEdges = [ + "border-start-color", + "border-end-color", +] as const satisfies readonly [string, string]; + const unparsedRuntimeParsing = new Set([ "animation", "animation-name", "border", + "border-block", + "border-block-color", + "border-block-end", + "border-block-start", "border-block-width", + "border-inline", + "border-inline-color", + "border-inline-end", + "border-inline-start", "border-inline-width", "box-shadow", "filter", @@ -127,13 +145,13 @@ const parsers: { "border-block-color": parseBorderColor, "border-block-end": parseBorderBlockEnd, "border-block-end-color": parseColorDeclaration, - "border-block-end-style": parseBorderInlineStyle, + "border-block-end-style": parseUnsupportedEdgeStyle, "border-block-end-width": parseBorderSideWidthDeclaration, "border-block-start": parseBorderBlockStart, "border-block-start-color": parseColorDeclaration, - "border-block-start-style": parseBorderInlineStyle, + "border-block-start-style": parseUnsupportedEdgeStyle, "border-block-start-width": parseBorderSideWidthDeclaration, - "border-block-style": parseBorderBlockStyle, + "border-block-style": parseUnsupportedEdgeStyle, "border-block-width": parseBorderBlockWidth, "border-bottom": parseBorderSide, "border-bottom-color": parseColorDeclaration, @@ -148,13 +166,13 @@ const parsers: { "border-inline-color": parseBorderColor, "border-inline-end": parseBorderInlineEnd, "border-inline-end-color": parseColorDeclaration, - "border-inline-end-style": parseBorderInlineStyle, + "border-inline-end-style": parseUnsupportedEdgeStyle, "border-inline-end-width": parseBorderSideWidthDeclaration, "border-inline-start": parseBorderInlineStart, "border-inline-start-color": parseColorDeclaration, - "border-inline-start-style": parseBorderInlineStyle, + "border-inline-start-style": parseUnsupportedEdgeStyle, "border-inline-start-width": parseBorderSideWidthDeclaration, - "border-inline-style": parseBorderInlineStyle, + "border-inline-style": parseUnsupportedEdgeStyle, "border-inline-width": parseBorderInlineWidth, "border-left": parseBorderSide, "border-left-color": parseColorDeclaration, @@ -315,17 +333,17 @@ export function parseDeclaration( function parseWithParser(declaration: Declaration, builder: StylesheetBuilder) { if (declaration.property in parsers) { const parser = parsers[declaration.property] as Parser; + const renamed = + propertyRename[declaration.property] ?? declaration.property; - builder.descriptorProperty = declaration.property; + // The renamed name, since light-dark() addresses its dark half to whatever this names + builder.descriptorProperties = [renamed]; builder.setWarningProperty(declaration.property); const value = parser(declaration, builder, declaration.property); if (value !== undefined) { - builder.addDescriptor( - propertyRename[declaration.property] ?? declaration.property, - value, - ); + builder.addDescriptor(renamed, value); } } else { builder.addWarning("property", declaration.property); @@ -391,6 +409,16 @@ function parseBorderRadius( }); } +// light-dark() addresses its dark half to descriptorProperties, so a parser that expands onto other properties names them +function parseColorFor( + targets: readonly string[], + cssColor: CssColor, + builder: StylesheetBuilder, +) { + builder.descriptorProperties = targets; + return parseColor(cssColor, builder); +} + function parseBorderColor( declaration: DeclarationType< "border-color" | "border-block-color" | "border-inline-color" @@ -405,18 +433,20 @@ function parseBorderColor( "border-right-color": parseColor(declaration.value.right, builder), }); } else { - const start = parseColor(declaration.value.start, builder); - const end = parseColor(declaration.value.end, builder); - - if (declaration.property === "border-inline-color") { - builder.addDescriptor("border-start-color", start); - builder.addDescriptor("border-end-color", end); - } else if (start === end) { - builder.addDescriptor(declaration.property, start); - } else { - builder.addDescriptor("border-top-color", start); - builder.addDescriptor("border-bottom-color", end); - } + // Each edge parses its own colour, so a light-dark() component opens its dark rule over the edge it feeds + const [startProperty, endProperty] = + declaration.property === "border-inline-color" + ? inlineColorEdges + : blockColorEdges; + + builder.addDescriptor( + startProperty, + parseColorFor([startProperty], declaration.value.start, builder), + ); + builder.addDescriptor( + endProperty, + parseColorFor([endProperty], declaration.value.end, builder), + ); } } @@ -463,11 +493,14 @@ function parseBorderBlock( { value }: DeclarationType<"border-block">, builder: StylesheetBuilder, ) { - builder.addDescriptor("border-block-color", parseColor(value.color, builder)); + const color = parseColorFor(blockColorEdges, value.color, builder); const width = parseBorderSideWidth(value.width, builder); + + builder.addDescriptor(blockColorEdges[0], color); + builder.addDescriptor(blockColorEdges[1], color); builder.addDescriptor("border-top-width", width); builder.addDescriptor("border-bottom-width", width); - dropUnsupportedInlineStyle( + dropUnsupportedEdgeStyle( parseBorderStyle(value.style, builder), builder, "border-block-style", @@ -480,13 +513,13 @@ function parseBorderBlockStart( ) { builder.addDescriptor( "border-block-start-color", - parseColor(value.color, builder), + parseColorFor(["border-block-start-color"], value.color, builder), ); builder.addDescriptor( "border-top-width", parseBorderSideWidth(value.width, builder), ); - dropUnsupportedInlineStyle( + dropUnsupportedEdgeStyle( parseBorderStyle(value.style, builder), builder, "border-block-start-style", @@ -499,13 +532,13 @@ function parseBorderBlockEnd( ) { builder.addDescriptor( "border-block-end-color", - parseColor(value.color, builder), + parseColorFor(["border-block-end-color"], value.color, builder), ); builder.addDescriptor( "border-bottom-width", parseBorderSideWidth(value.width, builder), ); - dropUnsupportedInlineStyle( + dropUnsupportedEdgeStyle( parseBorderStyle(value.style, builder), builder, "border-block-end-style", @@ -516,14 +549,14 @@ function parseBorderInline( { value }: DeclarationType<"border-inline">, builder: StylesheetBuilder, ) { - const color = parseColor(value.color, builder); + const color = parseColorFor(inlineColorEdges, value.color, builder); const width = parseBorderSideWidth(value.width, builder); - builder.addDescriptor("border-start-color", color); - builder.addDescriptor("border-end-color", color); + builder.addDescriptor(inlineColorEdges[0], color); + builder.addDescriptor(inlineColorEdges[1], color); builder.addDescriptor("border-start-width", width); builder.addDescriptor("border-end-width", width); - dropUnsupportedInlineStyle( + dropUnsupportedEdgeStyle( parseBorderStyle(value.style, builder), builder, "border-inline-style", @@ -534,12 +567,15 @@ function parseBorderInlineStart( { value }: DeclarationType<"border-inline-start">, builder: StylesheetBuilder, ) { - builder.addDescriptor("border-start-color", parseColor(value.color, builder)); + builder.addDescriptor( + "border-start-color", + parseColorFor(["border-start-color"], value.color, builder), + ); builder.addDescriptor( "border-start-width", parseBorderSideWidth(value.width, builder), ); - dropUnsupportedInlineStyle( + dropUnsupportedEdgeStyle( parseBorderStyle(value.style, builder), builder, "border-inline-start-style", @@ -550,12 +586,15 @@ function parseBorderInlineEnd( { value }: DeclarationType<"border-inline-end">, builder: StylesheetBuilder, ) { - builder.addDescriptor("border-end-color", parseColor(value.color, builder)); + builder.addDescriptor( + "border-end-color", + parseColorFor(["border-end-color"], value.color, builder), + ); builder.addDescriptor( "border-end-width", parseBorderSideWidth(value.width, builder), ); - dropUnsupportedInlineStyle( + dropUnsupportedEdgeStyle( parseBorderStyle(value.style, builder), builder, "border-inline-end-style", @@ -576,37 +615,45 @@ export function parseBorderInlineWidth( ); } -export function parseBorderInlineStyle( +// React Native has no per-edge border style, so each of the six longhands drops and its warning names the edge +function parseUnsupportedEdgeStyle( declaration: DeclarationType< + | "border-block-style" + | "border-block-start-style" + | "border-block-end-style" | "border-inline-style" | "border-inline-start-style" | "border-inline-end-style" - | "border-block-start-style" - | "border-block-end-style" >, builder: StylesheetBuilder, ) { if (typeof declaration.value === "string") { - dropUnsupportedInlineStyle( + dropUnsupportedEdgeStyle( parseBorderStyle(declaration.value, builder), builder, declaration.property, ); - } else { - dropUnsupportedInlineStyle( - parseBorderStyle(declaration.value.start, builder), - builder, - "border-inline-start-style", - ); - dropUnsupportedInlineStyle( - parseBorderStyle(declaration.value.end, builder), - builder, - "border-inline-end-style", - ); + return; } + + const [startProperty, endProperty] = + declaration.property === "border-block-style" + ? (["border-block-start-style", "border-block-end-style"] as const) + : (["border-inline-start-style", "border-inline-end-style"] as const); + + dropUnsupportedEdgeStyle( + parseBorderStyle(declaration.value.start, builder), + builder, + startProperty, + ); + dropUnsupportedEdgeStyle( + parseBorderStyle(declaration.value.end, builder), + builder, + endProperty, + ); } -function dropUnsupportedInlineStyle( +function dropUnsupportedEdgeStyle( style: string | undefined, builder: StylesheetBuilder, property: string, @@ -963,8 +1010,8 @@ export function parseUnparsedDeclaration( return; } - if (unsupportedLogicalStyles.has(property)) { - builder.addWarning("property", property); + // A var() keeps the value unknown, and an unknown value is not a known non-solid one, so it drops as quietly as solid + if (unsupportedEdgeStyles.has(property)) { return; } @@ -981,11 +1028,16 @@ export function parseUnparsedDeclaration( /** * Unparsed shorthand properties need to be parsed at runtime */ - builder.descriptorProperty = property; + builder.descriptorProperties = [property]; if (unparsedRuntimeParsing.has(property)) { const args = parseUnparsed(declaration.value.value, builder, property); + // A value that compiled to nothing declares nothing, and its warning names it + if (args === undefined) { + return; + } + if (property === "animation") { builder.addDescriptor("animation", [{}, property, args]); } else { @@ -2214,22 +2266,14 @@ export function parseBorderBlockWidth( declaration: DeclarationType<"border-block-width">, builder: StylesheetBuilder, ) { - const start = parseBorderSideWidth(declaration.value.start, builder); - const end = parseBorderSideWidth(declaration.value.end, builder); - - builder.addDescriptor("border-top-width", start); - builder.addDescriptor("border-bottom-width", end); -} - -function parseBorderBlockStyle( - declaration: DeclarationType<"border-block-style">, - builder: StylesheetBuilder, -) { - const start = parseBorderStyle(declaration.value.start, builder); - const end = parseBorderStyle(declaration.value.end, builder); - - dropUnsupportedInlineStyle(start, builder, "border-block-start-style"); - dropUnsupportedInlineStyle(end, builder, "border-block-end-style"); + builder.addDescriptor( + "border-top-width", + parseBorderSideWidth(declaration.value.start, builder), + ); + builder.addDescriptor( + "border-bottom-width", + parseBorderSideWidth(declaration.value.end, builder), + ); } export function parseBorderSideWidthDeclaration( diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..a5a7c07c 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -58,7 +58,8 @@ export class StylesheetBuilder { }, // Any default mapping should be included in the @nativeMapping parsing private mapping: StyleRuleMapping = {}, - public descriptorProperty?: string, + // Every property the declaration being parsed writes, so an unnamed descriptor such as a light-dark() dark half reaches each + public descriptorProperties?: readonly string[], private shared: { ruleSets: Record; rootVariables?: VariableRecord; @@ -102,7 +103,7 @@ export class StylesheetBuilder { mode, this.cloneRule(), { ...this.mapping }, - this.descriptorProperty, + this.descriptorProperties, this.shared, selectors, ); @@ -305,11 +306,13 @@ export class StylesheetBuilder { forceTuple?: boolean, rule = this.rule, ) { - if (this.descriptorProperty === undefined) { + if (this.descriptorProperties === undefined) { return; } - this.addDescriptor(this.descriptorProperty, value, forceTuple, rule); + for (const property of this.descriptorProperties) { + this.addDescriptor(property, value, forceTuple, rule); + } } addDescriptor( diff --git a/src/native/styles/calculate-props.ts b/src/native/styles/calculate-props.ts index 28b9ed36..af92b8db 100644 --- a/src/native/styles/calculate-props.ts +++ b/src/native/styles/calculate-props.ts @@ -13,6 +13,7 @@ import { type Getter, type VariableContextValue, } from "../reactivity"; +import { ShortHandSymbol } from "./constants"; import { transformKeys } from "./defaults"; import { resolveValue } from "./resolve"; @@ -32,6 +33,7 @@ export function calculateProps( const delayedStyles: (() => void)[] = []; const transformStyles: (() => void)[] = []; + const cascade = createCascade(); for (const rule of rules) { if (VAR_SYMBOL in rule) { @@ -75,6 +77,7 @@ export function calculateProps( guards, target, topLevelTarget, + cascade, ); } } @@ -94,6 +97,73 @@ export function calculateProps( }; } +// The cascade position of the declaration that last set each key of each target, since a deferred declaration resolves after every later one +interface Cascade { + readonly writers: WeakMap>; + readonly next: () => number; +} + +function createCascade(): Cascade { + let position = 0; + + return { writers: new WeakMap(), next: () => position++ }; +} + +function claim( + cascade: Cascade, + target: object, + key: string | number, + position: number, +): boolean { + let writers = cascade.writers.get(target); + + if (writers === undefined) { + writers = new Map(); + cascade.writers.set(target, writers); + } + + const holder = writers.get(key); + + if (holder !== undefined && holder > position) { + return false; + } + + writers.set(key, position); + return true; +} + +function isShorthandObject(value: unknown): value is Record { + return ( + typeof value === "object" && value !== null && ShortHandSymbol in value + ); +} + +// A shorthand learns its keys only once it resolves, so each key is claimed on its own +function applyInCascade( + cascade: Cascade, + target: Record, + prop: string | number, + value: unknown, + position: number, +) { + if (!isShorthandObject(value)) { + if (claim(cascade, target, prop, position)) { + applyValue(target, prop as string, value); + } + return; + } + + const kept: Record = { [ShortHandSymbol]: true }; + + for (const [key, keyValue] of Object.entries(value)) { + if (claim(cascade, target, key, position)) { + kept[key] = keyValue; + } + } + + applyValue(target, prop as string, kept); +} + export function applyDeclarations( get: Getter, declarations: StyleDeclaration[], @@ -104,15 +174,20 @@ export function applyDeclarations( guards: RenderGuard[] = [], target: Record = {}, topLevelTarget = target, + cascade: Cascade = createCascade(), ) { - const originalTarget = target; - for (const declaration of declarations) { - target = originalTarget; + // Each declaration's own binding, since its deferred closure runs after the walk has moved on + let declarationTarget = target; + const position = cascade.next(); if (!Array.isArray(declaration)) { // Static styles - Object.assign(target, declaration); + for (const key of Object.keys(declaration)) { + claim(cascade, declarationTarget, key, position); + } + + Object.assign(declarationTarget, declaration); } else { // Dynamic styles let value: any = declaration[0]; @@ -131,7 +206,7 @@ export function applyDeclarations( if (final) { if (first !== "&") { topLevelTarget[first] ??= {}; - target = topLevelTarget[first]; + declarationTarget = topLevelTarget[first]; } let previousProp: string | number = first; @@ -143,75 +218,79 @@ export function applyDeclarations( if (!Array.isArray(previousTarget[previousProp])) { previousTarget[previousProp] = []; - target = previousTarget[previousProp]; + declarationTarget = previousTarget[previousProp]; } } - previousTarget = target; + previousTarget = declarationTarget; previousProp = prop; - target[prop] ??= {}; - target = target[prop]; + declarationTarget[prop] ??= {}; + declarationTarget = declarationTarget[prop]; } prop = final; } else { - target = topLevelTarget; + declarationTarget = topLevelTarget; prop = first; } } else { prop = propPath; } - const shouldDelay = declaration[2]; - - if (shouldDelay || transformKeys.has(prop)) { - /** - * We need to delay the resolution of this value until after all - * styles have been calculated. But another style might override - * this value. So we set a placeholder value and only override - * if the placeholder is preserved - * - * This also ensures the props exist, so setValue will properly - * mutate the props object and not create a new one - */ + if (transformKeys.has(prop)) { const originalValue = value; - // This needs to be a object with the [prop] so we can discover in transform arrays + // An object keyed by the prop lets the transform array find this entry value = { [prop]: true }; - if (transformKeys.has(prop)) { - transformStyles.push(() => { - value = resolveValue(originalValue, get, { - inlineVariables, - inheritedVariables, - renderGuards: guards, - calculateProps, - }); - applyValue(target, prop, value); + transformStyles.push(() => { + value = resolveValue(originalValue, get, { + inlineVariables, + inheritedVariables, + renderGuards: guards, + calculateProps, }); - } else { - delayedStyles.push(() => { - if (getDeepPath(target, prop) === value) { - delete target[prop]; - value = resolveValue(originalValue, get, { + applyValue(declarationTarget, prop, value); + }); + + applyValue(declarationTarget, prop, value); + } else if (declaration[2]) { + const originalValue = value; + // Resolved once every declaration is walked; a later declaration of the same key replaces the placeholder + const placeholder = { [prop]: true }; + + delayedStyles.push(() => { + if (getDeepPath(declarationTarget, prop) === placeholder) { + delete declarationTarget[prop]; + applyInCascade( + cascade, + declarationTarget, + prop, + resolveValue(originalValue, get, { inlineVariables, inheritedVariables, renderGuards: guards, calculateProps, - }); - applyValue(target, prop, value); - } - }); - } - } else { - value = resolveValue(value, get, { - inlineVariables, - inheritedVariables, - renderGuards: guards, - calculateProps, + }), + position, + ); + } }); - } - applyValue(target, prop, value); + applyInCascade(cascade, declarationTarget, prop, placeholder, position); + } else { + applyInCascade( + cascade, + declarationTarget, + prop, + resolveValue(value, get, { + inlineVariables, + inheritedVariables, + renderGuards: guards, + calculateProps, + }), + position, + ); + } } } } diff --git a/src/native/styles/shorthands/border.ts b/src/native/styles/shorthands/border.ts index cefdb005..1a60e90c 100644 --- a/src/native/styles/shorthands/border.ts +++ b/src/native/styles/shorthands/border.ts @@ -1,10 +1,84 @@ +import { ShortHandSymbol } from "../constants"; +import type { StyleResolver } from "../resolve"; import { shorthandHandler } from "./_handler"; const width = ["borderWidth", "number"] as const; const style = ["borderStyle", "string"] as const; const color = ["borderColor", "color", "color"] as const; -export const border = shorthandHandler( - [[width, style, color], [style, color], [width, style], [style]], - [], +// ` || || `, the grammar `border` and the six logical-axis shorthands share +const mappings = [ + [width, style, color], + [style, color], + [width, style], + [style], +]; + +export const border = shorthandHandler(mappings, []); + +const matchBorder = shorthandHandler(mappings, [], "object"); + +// React Native has no inline-axis prop and no per-edge style, so the inline axis lands on borderStart/borderEnd, the block axis on its physical edges, and a style component is dropped +const axisTargets = { + borderInline: { + borderWidth: ["borderStartWidth", "borderEndWidth"], + borderColor: ["borderStartColor", "borderEndColor"], + }, + borderInlineStart: { + borderWidth: ["borderStartWidth"], + borderColor: ["borderStartColor"], + }, + borderInlineEnd: { + borderWidth: ["borderEndWidth"], + borderColor: ["borderEndColor"], + }, + borderBlock: { + borderWidth: ["borderTopWidth", "borderBottomWidth"], + borderColor: ["borderTopColor", "borderBottomColor"], + }, + borderBlockStart: { + borderWidth: ["borderTopWidth"], + borderColor: ["borderBlockStartColor"], + }, + borderBlockEnd: { + borderWidth: ["borderBottomWidth"], + borderColor: ["borderBlockEndColor"], + }, +} as const; + +type AxisTargets = (typeof axisTargets)[keyof typeof axisTargets]; + +function axisBorderHandler(targets: AxisTargets): StyleResolver { + return (resolveValue, value, get, options) => { + const parsed = matchBorder(resolveValue, value, get, options); + + if (typeof parsed !== "object" || !parsed) { + return; + } + + const target: Record = { [ShortHandSymbol]: true }; + + if ("borderWidth" in parsed) { + for (const property of targets.borderWidth) { + target[property] = parsed.borderWidth; + } + } + + if ("borderColor" in parsed) { + for (const property of targets.borderColor) { + target[property] = parsed.borderColor; + } + } + + return target; + }; +} + +export const borderInline = axisBorderHandler(axisTargets.borderInline); +export const borderInlineStart = axisBorderHandler( + axisTargets.borderInlineStart, ); +export const borderInlineEnd = axisBorderHandler(axisTargets.borderInlineEnd); +export const borderBlock = axisBorderHandler(axisTargets.borderBlock); +export const borderBlockStart = axisBorderHandler(axisTargets.borderBlockStart); +export const borderBlockEnd = axisBorderHandler(axisTargets.borderBlockEnd); diff --git a/src/native/styles/shorthands/index.ts b/src/native/styles/shorthands/index.ts index f9ed4196..8fc81501 100644 --- a/src/native/styles/shorthands/index.ts +++ b/src/native/styles/shorthands/index.ts @@ -1,5 +1,6 @@ export * from "./animation"; export * from "./border"; +export * from "./logical-border-color"; export * from "./logical-border-width"; export * from "./box-shadow"; export * from "./text-shadow"; diff --git a/src/native/styles/shorthands/logical-border-color.ts b/src/native/styles/shorthands/logical-border-color.ts new file mode 100644 index 00000000..244ecd84 --- /dev/null +++ b/src/native/styles/shorthands/logical-border-color.ts @@ -0,0 +1,34 @@ +import { ShortHandSymbol } from "../constants"; +import type { StyleFunctionResolver } from "../resolve"; + +function isColor(value: unknown): boolean { + return ( + typeof value === "string" || + (typeof value === "object" && value !== null && !Array.isArray(value)) + ); +} + +function logicalColor(start: string, end: string): StyleFunctionResolver { + return (resolve, descriptor) => { + const resolved = resolve(descriptor[2]); + const values: unknown[] = Array.isArray(resolved) ? resolved : [resolved]; + if (values.length < 1 || values.length > 2 || !values.every(isColor)) { + return; + } + return { + [ShortHandSymbol]: true, + [start]: values[0], + [end]: values[1] ?? values[0], + }; + }; +} + +// One key set per property, as the compiler writes for the same declaration +export const borderBlockColor = logicalColor( + "borderTopColor", + "borderBottomColor", +); +export const borderInlineColor = logicalColor( + "borderStartColor", + "borderEndColor", +);