diff --git a/src/__tests__/native/components.test.tsx b/src/__tests__/native/components.test.tsx index 4ec527a9..55e58624 100644 --- a/src/__tests__/native/components.test.tsx +++ b/src/__tests__/native/components.test.tsx @@ -1,19 +1,26 @@ +import { memo } from "react"; import { + Animated, + PlatformColor, Button as RNButton, + Image as RNImage, KeyboardAvoidingView as RNKeyboardAvoidingView, + Text as RNText, TextInput as RNTextInput, StyleSheet, View, type ButtonProps, type TextInputProps, + type ViewProps, } from "react-native"; -import { render } from "@testing-library/react-native"; +import { render, screen } from "@testing-library/react-native"; +import { Button } from "react-native-css/components/Button"; import { copyComponentProperties } from "react-native-css/components/copyComponentProperties"; import { KeyboardAvoidingView } from "react-native-css/components/KeyboardAvoidingView"; import { TextInput } from "react-native-css/components/TextInput"; import { registerCSS, testID } from "react-native-css/jest"; -import { useCssElement } from "react-native-css/native"; +import { styled, useCssElement, useNativeCss } from "react-native-css/native"; import type { StyledConfiguration, StyledProps, @@ -151,3 +158,920 @@ test("KeyboardAvoidingView maps content classes through replacement, removal, an expect(contentStyle).toEqual({ opacity: 0.5 }); } }); + +interface LabelledProps extends ViewProps { + readonly labelColor?: string; + readonly badgeColor?: string; +} + +// nativeStyleMapping is typed against the target's props, so a target:false config needs a component that has some +const Labelled = (props: LabelledProps) => ; + +test("a target:false config does not take a style-target config's declarations", () => { + registerCSS(`.c1 { color: orange; } .c2 { color: blue; }`); + + const Styled = styled(Labelled, { + className: { target: "style" }, + labelClassName: { + target: false, + nativeStyleMapping: { color: "labelColor" }, + }, + }); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#ffa500" }); + expect(component.props.labelColor).toBe("#00f"); +}); + +test("a target:false config emits no style prop", () => { + registerCSS(`.c3 { color: orange; font-size: 20px; }`); + + const Styled = styled(Labelled, { + labelClassName: { + target: false, + nativeStyleMapping: { color: "labelColor" }, + }, + }); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.labelColor).toBe("#ffa500"); + expect(component.props).not.toHaveProperty("style"); +}); + +test("two target:false configs each keep their own declarations", () => { + registerCSS(`.c4 { color: orange; } .c5 { color: blue; }`); + + const Styled = styled(Labelled, { + labelClassName: { + target: false, + nativeStyleMapping: { color: "labelColor" }, + }, + badgeClassName: { + target: false, + nativeStyleMapping: { color: "badgeColor" }, + }, + }); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.labelColor).toBe("#ffa500"); + expect(component.props.badgeColor).toBe("#00f"); + expect(component.props).not.toHaveProperty("labelClassName"); + expect(component.props).not.toHaveProperty("badgeClassName"); +}); + +test("declaration order does not change what either config gets", () => { + registerCSS( + `.c6 { color: orange; font-size: 20px; } .c7 { background-color: blue; }`, + ); + + const Styled = styled(Labelled, { + labelClassName: { + target: false, + nativeStyleMapping: { color: "labelColor" }, + }, + className: { target: "style" }, + }); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.labelColor).toBe("#ffa500"); + expect(component.props.style).toStrictEqual({ backgroundColor: "#00f" }); + expect(component.props).not.toHaveProperty("labelClassName"); +}); + +test("an !important declaration keeps to its own config", () => { + registerCSS(` + .i1 { color: orange !important; } + .i2 { color: blue !important; font-size: 11px !important; } + `); + + const Styled = styled(Labelled, { + className: { target: "style" }, + labelClassName: { + target: false, + nativeStyleMapping: { color: "labelColor" }, + }, + }); + + const component = render( + , + ).getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#ffa500" }); + expect(component.props.labelColor).toBe("#00f"); +}); + +test("a declaration no mapping entry names is dropped, not moved into style", () => { + registerCSS(`.d1 { transform: translateX(10px); color: orange; }`); + + const Mapped = styled(Labelled, { + labelClassName: { + target: false, + nativeStyleMapping: { color: "labelColor" }, + }, + }); + const Shorthand = styled(Labelled, { labelClassName: false }); + + const mapped = render( + , + ).getByTestId(testID); + expect(mapped.props.labelColor).toBe("#ffa500"); + expect(mapped.props).not.toHaveProperty("style"); + + const shorthand = render( + , + ).getByTestId("shorthand"); + expect(shorthand.props).not.toHaveProperty("style"); +}); + +test("a target:false mapping on an image with object-fit holds on every render", () => { + registerCSS( + `.contain { object-fit: contain; width: 10px; } .tint { color: red; }`, + ); + const TintedImage = styled(RNImage, { + tintClassName: { + target: false, + nativeStyleMapping: { color: "tintColor" }, + }, + className: "style", + }); + const element = () => ( + + ); + + render(element()); + for (let round = 0; round < 3; round++) { + const image = screen.getByTestId(testID); + expect(image.props.tintColor).toBe("#f00"); + expect(StyleSheet.flatten(image.props.style)).toEqual({ + width: 10, + objectFit: "contain", + }); + screen.rerender(element()); + } +}); + +test("a target:false mapping after a style config leaves style alone on every render", () => { + registerCSS(`.base { font-size: 12px; } .selection { color: red; }`); + const SelectableText = styled(RNText, { + className: "style", + selectionClassName: { + target: false, + nativeStyleMapping: { color: "selectionColor" }, + }, + }); + const element = () => ( + + ); + + render(element()); + for (let round = 0; round < 3; round++) { + const text = screen.getByTestId(testID); + expect(text.props.selectionColor).toBe("#f00"); + expect(text.props.style).toStrictEqual({ fontSize: 12 }); + screen.rerender(element()); + } +}); + +test("a mapping into an earlier config's target keeps its value on every render", () => { + registerCSS(`.faded { opacity: 0.5; } .label { color: blue; }`); + const Bordered = styled(Labelled, { + className: "style", + labelClassName: { + target: false, + nativeStyleMapping: { color: "style.borderColor" }, + }, + }); + const element = () => ( + + ); + + render(element()); + for (let round = 0; round < 3; round++) { + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + opacity: 0.5, + borderColor: "#00f", + }); + screen.rerender(element()); + } +}); + +test("a target:false mapping into style keeps its value where no inline style meets it", () => { + registerCSS(`.label { color: blue; }`); + const Bordered = styled(Labelled, { + labelClassName: { + target: false, + nativeStyleMapping: { color: "style.borderColor" }, + }, + }); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + borderColor: "#00f", + }); +}); + +const animatedOpacity = new Animated.Value(0.5); +const inlineStyleFunction = () => ({ margin: 1 }); + +const inlineStyleArray = [{ margin: 1 }]; + +test.each([ + [ + "an inline style object", + "style", + "style.borderColor", + { margin: 1 }, + { margin: 1, borderColor: "#00f" }, + { margin: 1, borderColor: "#00f" }, + ], + [ + "an inline style holding an Animated value", + "style", + "style.borderColor", + { opacity: animatedOpacity }, + { opacity: animatedOpacity, borderColor: "#00f" }, + { opacity: animatedOpacity, borderColor: "#00f" }, + ], + [ + "an inline ripple", + "android_ripple", + "android_ripple.color", + { borderless: true }, + { borderless: true, color: "#00f" }, + { borderless: true, color: "#00f" }, + ], + [ + "an inline style function", + "style", + "style.borderColor", + inlineStyleFunction, + inlineStyleFunction, + { borderColor: "#00f" }, + ], + [ + "an inline style array", + "style", + "style.borderColor", + inlineStyleArray, + inlineStyleArray, + { borderColor: "#00f" }, + ], + [ + "an inline style of false", + "style", + "style.borderColor", + false, + false, + { borderColor: "#00f" }, + ], + [ + "an inline style of null", + "style", + "style.borderColor", + null, + null, + { borderColor: "#00f" }, + ], +] as const)( + "%s beside a target:false mapping into it settles key by key where it is an object, and whole where it is not", + (_, prop, destination, inlineValue, withNormal, withImportant) => { + for (const [css, expected] of [ + [`.label { color: blue; }`, withNormal], + [`.label { color: blue !important; }`, withImportant], + ] as const) { + registerCSS(css); + let received: Readonly> = {}; + const Received = (props: Readonly>) => { + received = props; + return null; + }; + const Mapped = styled(Received, { + labelClassName: { + target: false, + nativeStyleMapping: { color: destination }, + }, + }); + + const { unmount } = render( + , + ); + + expect({ css, value: received[prop] }).toStrictEqual({ + css, + value: expected, + }); + unmount(); + } + }, +); + +test("the built-in Button takes an !important class's color over its color prop", () => { + registerCSS(`.important { color: red !important; } .plain { color: red; }`); + + render(