diff --git a/package.json b/package.json index 7dd3962d..be5d51ef 100644 --- a/package.json +++ b/package.json @@ -203,6 +203,7 @@ "react-test-renderer": "^19.1.0", "release-it": "^19.0.4", "tailwindcss": "^4.1.12", + "tailwindcss-safe-area": "^1.1.0", "typescript": "^5.9.2", "typescript-eslint": "^8.40.0" }, diff --git a/src/__tests__/vendor/tailwind/_tailwind.tsx b/src/__tests__/vendor/tailwind/_tailwind.tsx index 098b6aab..d7a1395f 100644 --- a/src/__tests__/vendor/tailwind/_tailwind.tsx +++ b/src/__tests__/vendor/tailwind/_tailwind.tsx @@ -60,7 +60,7 @@ export async function render( css += `@import "tailwindcss/preflight.css" layer(base);\n`; } - css += `@import "tailwindcss/utilities.css" layer(utilities) source(none);\n`; + css += `@import "tailwindcss/utilities.css" layer(utilities) source(none);\n@import "tailwindcss-safe-area";`; } css += sourceInline diff --git a/src/__tests__/vendor/tailwind/safe-area.test.tsx b/src/__tests__/vendor/tailwind/safe-area.test.tsx new file mode 100644 index 00000000..4ef24b01 --- /dev/null +++ b/src/__tests__/vendor/tailwind/safe-area.test.tsx @@ -0,0 +1,357 @@ +import type { ViewProps } from "react-native"; + +import { SafeAreaProvider } from "react-native-css/components/react-native-safe-area-context"; +import { type Metrics } from "react-native-safe-area-context"; + +import { renderCurrentTest } from "./_tailwind"; + +const initialMetrics: Metrics = { + insets: { + top: 10, + bottom: 10, + left: 10, + right: 10, + }, + frame: { + x: 0, + y: 0, + height: 100, + width: 100, + }, +}; + +function Wrapper({ children }: ViewProps) { + return ( + + {children} + + ); +} + +test("m-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + marginBottom: 10, + marginLeft: 10, + marginRight: 10, + marginTop: 10, + }, + }, + }); +}); + +test("mx-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + marginLeft: 10, + marginRight: 10, + }, + }, + }); +}); +test("my-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + marginBottom: 10, + marginTop: 10, + }, + }, + }); +}); +test("ms-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + marginInlineStart: 10, + }, + }, + }); +}); +test("me-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + marginInlineEnd: 10, + }, + }, + }); +}); +test("mt-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + marginTop: 10, + }, + }, + }); +}); +test("mr-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + marginRight: 10, + }, + }, + }); +}); +test("mb-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + marginBottom: 10, + }, + }, + }); +}); + +test("ml-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + marginLeft: 10, + }, + }, + }); +}); + +test("p-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + paddingBottom: 10, + paddingLeft: 10, + paddingRight: 10, + paddingTop: 10, + }, + }, + }); +}); + +test("px-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + paddingLeft: 10, + paddingRight: 10, + }, + }, + }); +}); +test("py-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + paddingBottom: 10, + paddingTop: 10, + }, + }, + }); +}); +test("ps-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + paddingInlineStart: 10, + }, + }, + }); +}); +test("pe-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + paddingInlineEnd: 10, + }, + }, + }); +}); +test("pt-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + paddingTop: 10, + }, + }, + }); +}); +test("pr-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + paddingRight: 10, + }, + }, + }); +}); +test("pb-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + paddingBottom: 10, + }, + }, + }); +}); +test("pl-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + paddingLeft: 10, + }, + }, + }); +}); + +test("min-h-screen-safe", async () => { + // calc(100vh - (env(safe-area-inset-top) + env(safe-area-inset-bottom))); + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + minHeight: 1314, + }, + }, + }); +}); + +test("max-h-screen-safe", async () => { + // calc(100vh - (env(safe-area-inset-top) + env(safe-area-inset-bottom))); + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + maxHeight: 1314, + }, + }, + }); +}); + +test("h-screen-safe", async () => { + // calc(100vh - (env(safe-area-inset-top) + env(safe-area-inset-bottom))); + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + height: 1314, + }, + }, + }); +}); + +test("inset-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + bottom: 10, + left: 10, + right: 10, + top: 10, + }, + }, + }); +}); +test("inset-x-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + left: 10, + right: 10, + }, + }, + }); +}); +test("inset-y-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + bottom: 10, + top: 10, + }, + }, + }); +}); +test("start-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + insetInlineStart: 10, + }, + }, + }); +}); +test("end-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + insetInlineEnd: 10, + }, + }, + }); +}); +test("top-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + top: 10, + }, + }, + }); +}); +test("right-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + right: 10, + }, + }, + }); +}); +test("bottom-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + bottom: 10, + }, + }, + }); +}); +test("left-safe", async () => { + expect(await renderCurrentTest({ wrapper: Wrapper })).toStrictEqual({ + props: { + style: { + left: 10, + }, + }, + }); +}); + +test("pb-safe-offset-4", async () => { + // calc(env(safe-area-inset-right) + 2rem); + expect( + await renderCurrentTest({ + wrapper: Wrapper, + }), + ).toStrictEqual({ + props: { + style: { + paddingBottom: 24, // 10 + 14 + }, + }, + }); +}); + +test("pb-safe-or-20", async () => { + // --spacing: 3.5 + // max(env(safe-area-inset-bottom), calc(var(--spacing) * 20)); + expect( + await renderCurrentTest({ + wrapper: Wrapper, + }), + ).toStrictEqual({ + props: { + style: { + paddingBottom: 70, + }, + }, + }); +}); diff --git a/src/components/react-native-safe-area-context.native.tsx b/src/components/react-native-safe-area-context.native.tsx new file mode 100644 index 00000000..14d6e003 --- /dev/null +++ b/src/components/react-native-safe-area-context.native.tsx @@ -0,0 +1,40 @@ +import { useContext, useMemo, type PropsWithChildren } from "react"; + +import { + SafeAreaProvider as OriginalSafeAreaProvider, + useSafeAreaInsets, + type SafeAreaProviderProps, +} from "react-native-safe-area-context"; + +import { VariableContext } from "../runtime/native/reactivity"; + +export * from "react-native-safe-area-context"; + +export function SafeAreaProvider({ + children, + ...props +}: SafeAreaProviderProps) { + return ( + + {children} + + ); +} + +function SafeAreaEnv({ children }: PropsWithChildren) { + const insets = useSafeAreaInsets(); + const parentVars = useContext(VariableContext); + + const value = useMemo( + () => ({ + ...parentVars, + "--react-native-css-safe-area-inset-bottom": insets.bottom, + "--react-native-css-safe-area-inset-left": insets.left, + "--react-native-css-safe-area-inset-right": insets.right, + "--react-native-css-safe-area-inset-top": insets.top, + }), + [parentVars, insets], + ); + + return {children}; +} diff --git a/src/components/react-native-safe-area-context.tsx b/src/components/react-native-safe-area-context.tsx new file mode 100644 index 00000000..8f6206d4 --- /dev/null +++ b/src/components/react-native-safe-area-context.tsx @@ -0,0 +1 @@ +export * from "react-native-safe-area-context"; diff --git a/src/metro/resolver.ts b/src/metro/resolver.ts index 0cf731a7..3ce12e1f 100644 --- a/src/metro/resolver.ts +++ b/src/metro/resolver.ts @@ -45,6 +45,14 @@ export function nativeResolver( return resolver(context, `react-native-css/components`, platform); } + if (moduleName === "react-native-safe-area-context") { + return resolver( + context, + `react-native-css/components/react-native-safe-area-context`, + platform, + ); + } + // We only care about `react-native/Library/Components/.js` components const segments = resolution.filePath.split(sep); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion diff --git a/src/runtime/native/styles/functions/index.ts b/src/runtime/native/styles/functions/index.ts index fe2ec44c..3e41961e 100644 --- a/src/runtime/native/styles/functions/index.ts +++ b/src/runtime/native/styles/functions/index.ts @@ -1,6 +1,7 @@ export * from "./animation-timing"; export * from "./calc"; export * from "./filters"; +export * from "./numeric-functions"; export * from "./platform-functions"; export * from "./string-functions"; export * from "./transform-functions"; diff --git a/src/runtime/native/styles/functions/numeric-functions.ts b/src/runtime/native/styles/functions/numeric-functions.ts new file mode 100644 index 00000000..6ccc9dc9 --- /dev/null +++ b/src/runtime/native/styles/functions/numeric-functions.ts @@ -0,0 +1,38 @@ +import type { StyleFunctionResolver } from "../resolve"; + +export const max: StyleFunctionResolver = (resolveValue, value) => { + const args = resolveValue(value[2]); + + if (!Array.isArray(args) || args.some((arg) => typeof arg !== "number")) { + return; + } + + return Math.max(...(args as number[])); +}; + +export const min: StyleFunctionResolver = (resolveValue, value) => { + const args = resolveValue(value[2]); + + if (!Array.isArray(args) || args.some((arg) => typeof arg !== "number")) { + return; + } + + return Math.min(...(args as number[])); +}; + +export const clamp: StyleFunctionResolver = (resolveValue, value) => { + const args = resolveValue(value[2]); + + const [clampValue, min, max] = args as number[]; + + if ( + !Array.isArray(args) || + typeof clampValue !== "number" || + typeof min !== "number" || + typeof max !== "number" + ) { + return; + } + + return Math.min(Math.max(clampValue, min), max); +}; diff --git a/yarn.lock b/yarn.lock index d43c4745..72e4172b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11370,6 +11370,7 @@ __metadata: react-test-renderer: "npm:^19.1.0" release-it: "npm:^19.0.4" tailwindcss: "npm:^4.1.12" + tailwindcss-safe-area: "npm:^1.1.0" typescript: "npm:^5.9.2" typescript-eslint: "npm:^8.40.0" peerDependencies: @@ -12532,6 +12533,15 @@ __metadata: languageName: node linkType: hard +"tailwindcss-safe-area@npm:^1.1.0": + version: 1.1.0 + resolution: "tailwindcss-safe-area@npm:1.1.0" + peerDependencies: + tailwindcss: ^4.0.0 + checksum: 10c0/93e48186ebb06f7034f5a01160abad916fcce90f331017d7f350d7de5fd934d742fd7186819e48ff9987d4c2c8a6c4ba6da572af72a125312d8b3786c8415162 + languageName: node + linkType: hard + "tailwindcss@npm:4.1.11": version: 4.1.11 resolution: "tailwindcss@npm:4.1.11"