diff --git a/.changeset/config.json b/.changeset/config.json index d66664ad..0016a0bd 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -5,7 +5,8 @@ "fixed": [ [ "react-native-bottom-tabs", - "@bottom-tabs/react-navigation" + "@bottom-tabs/react-navigation", + "@bottom-tabs/standard-navigation" ] ], "linked": [], diff --git a/.changeset/drop-color-dependency.md b/.changeset/drop-color-dependency.md new file mode 100644 index 00000000..718ebae2 --- /dev/null +++ b/.changeset/drop-color-dependency.md @@ -0,0 +1,5 @@ +--- +'@bottom-tabs/react-navigation': patch +--- + +Drop the `color` dependency in favour of React Native's `processColor`. `color` is ESM-only, so consumers had to extend `transformIgnorePatterns` before Jest would run at all diff --git a/.changeset/standard-navigation-shared-adapter.md b/.changeset/standard-navigation-shared-adapter.md new file mode 100644 index 00000000..e496c195 --- /dev/null +++ b/.changeset/standard-navigation-shared-adapter.md @@ -0,0 +1,6 @@ +--- +'@bottom-tabs/standard-navigation': minor +'@bottom-tabs/react-navigation': minor +--- + +Move the native tabs implementation into a new framework-agnostic `@bottom-tabs/standard-navigation` package built on the `standard-navigation` contract, so Expo Router SDK 56+ apps can drive the same navigator through `unstable_createStandardRouterNavigator` without loading a second copy of React Navigation. diff --git a/README.md b/README.md index 2452dfd3..f9cffc61 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ https://github.com/user-attachments/assets/09e96ac3-827d-4ac0-add0-e7b88ee9197c | [react-native-bottom-tabs](/packages/react-native-bottom-tabs) | [![badge](https://img.shields.io/npm/v/react-native-bottom-tabs?style=for-the-badge)](https://www.npmjs.com/package/react-native-bottom-tabs) | | [@bottom-tabs/expo-template](/packages/expo-template) | [![badge](https://img.shields.io/npm/v/@bottom-tabs/expo-template.svg?style=for-the-badge)](https://www.npmjs.com/package/@bottom-tabs/expo-template) | | [@bottom-tabs/react-navigation](/packages/react-navigation) | [![badge](https://img.shields.io/npm/v/@bottom-tabs/react-navigation.svg?style=for-the-badge)](https://www.npmjs.com/package/@bottom-tabs/react-navigation) | +| [@bottom-tabs/standard-navigation](/packages/standard-navigation) | [![badge](https://img.shields.io/npm/v/@bottom-tabs/standard-navigation.svg?style=for-the-badge)](https://www.npmjs.com/package/@bottom-tabs/standard-navigation) | ## Documentation diff --git a/apps/expo-router-test/jest.config.js b/apps/expo-router-test/jest.config.js new file mode 100644 index 00000000..5381bc82 --- /dev/null +++ b/apps/expo-router-test/jest.config.js @@ -0,0 +1,32 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const PROJECT_COPIES = ['react', 'react-dom']; + +const reactNavigationScope = path.dirname( + path.dirname(require.resolve('@react-navigation/native/package.json')) +); + +const reactNavigationCopies = fs + .readdirSync(reactNavigationScope) + .map((name) => [ + `^@react-navigation/${name}($|/.*)`, + `${reactNavigationScope}/${name}$1`, + ]); + +module.exports = { + preset: 'jest-expo', + + setupFilesAfterEnv: ['/jest.setup.ts'], + + moduleNameMapper: { + ...Object.fromEntries( + PROJECT_COPIES.map((name) => [ + `^${name}($|/.*)`, + path.dirname(require.resolve(`${name}/package.json`)) + '$1', + ]) + ), + ...Object.fromEntries(reactNavigationCopies), + }, + +}; diff --git a/apps/expo-router-test/jest.setup.ts b/apps/expo-router-test/jest.setup.ts new file mode 100644 index 00000000..fb9746b2 --- /dev/null +++ b/apps/expo-router-test/jest.setup.ts @@ -0,0 +1,23 @@ +const consoleMethods = ['warn', 'error'] as const; + +let output: string[] = []; + +beforeEach(() => { + output = []; + + for (const method of consoleMethods) { + jest.spyOn(console, method).mockImplementation((...args: unknown[]) => { + output.push(`console.${method}: ${args.join(' ')}`); + }); + } +}); + +afterEach(() => { + const captured = output; + + jest.restoreAllMocks(); + + if (captured.length > 0) { + throw new Error(`Expected no console output, got:\n${captured.join('\n')}`); + } +}); diff --git a/apps/expo-router-test/package.json b/apps/expo-router-test/package.json new file mode 100644 index 00000000..527620fa --- /dev/null +++ b/apps/expo-router-test/package.json @@ -0,0 +1,38 @@ +{ + "name": "expo-router-test", + "version": "0.0.1", + "private": true, + "scripts": { + "test": "jest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@bottom-tabs/react-navigation": "*", + "@bottom-tabs/standard-navigation": "*", + "@react-navigation/native": "^7.3.0", + "expo": "~57.0.21", + "expo-constants": "~57.0.17", + "expo-linking": "~57.0.9", + "expo-router": "~57.0.20", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-native": "0.86.3", + "react-native-bottom-tabs": "*", + "react-native-gesture-handler": "~2.32.0", + "react-native-reanimated": "4.5.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.0", + "react-native-web": "~0.21.0", + "react-native-worklets": "0.10.1" + }, + "devDependencies": { + "@react-native/jest-preset": "^0.86.3", + "@testing-library/react-native": "^14.0.1", + "@types/jest": "^29.5.5", + "@types/react": "~19.2.2", + "jest": "^29.7.0", + "jest-expo": "~57.0.5", + "test-renderer": "^1.2.0", + "typescript": "^5.9.2" + } +} diff --git a/apps/expo-router-test/src/__tests__/adapters.test.tsx b/apps/expo-router-test/src/__tests__/adapters.test.tsx new file mode 100644 index 00000000..bcc4a494 --- /dev/null +++ b/apps/expo-router-test/src/__tests__/adapters.test.tsx @@ -0,0 +1,116 @@ +import { NavigationContainer } from '@react-navigation/native'; +import { act, render } from '@testing-library/react-native'; +import { renderRouter } from 'expo-router/testing-library'; +import { Text } from 'react-native'; + +import { ExpoRouterTabs, ReactNavigationTabs } from '../tabs'; + +let tabViewProps: Record; + +jest.mock('react-native-bottom-tabs', () => ({ + __esModule: true, + default: (props: Record) => { + tabViewProps = props; + return null; + }, +})); + +const OPTIONS = { + index: { title: 'Home', tabBarBadge: '3' }, + explore: { title: 'Explore' }, +}; + +const adapters = [ + { + name: 'expo-router', + render: async () => { + await renderRouter( + { + _layout: () => ( + + + + + ), + index: () => index scene, + explore: () => explore scene, + }, + { initialUrl: '/' } + ); + }, + }, + { + name: 'react-navigation', + render: async () => { + await render( + + + + {() => index scene} + + + {() => explore scene} + + + + ); + }, + }, +]; + +beforeEach(() => { + tabViewProps = {}; +}); + +describe.each(adapters)('$name', ({ render: renderAdapter }) => { + const routeNamed = (name: string) => + tabViewProps.navigationState.routes.find( + (it: { name: string }) => it.name === name + ); + + it('drives the shared navigator', async () => { + await renderAdapter(); + + expect( + tabViewProps.navigationState.routes.map((it: { name: string }) => it.name) + ).toEqual(['index', 'explore']); + expect(tabViewProps.navigationState.index).toBe(0); + }); + + it('maps screen options onto the native view', async () => { + await renderAdapter(); + + expect(tabViewProps.getLabelText({ route: routeNamed('index') })).toBe( + 'Home' + ); + expect(tabViewProps.getBadge({ route: routeNamed('index') })).toBe('3'); + expect( + tabViewProps.getBadge({ route: routeNamed('explore') }) + ).toBeUndefined(); + }); + + it('navigates when a tab is pressed', async () => { + await renderAdapter(); + + await act(async () => { + tabViewProps.onIndexChange(1); + }); + + expect(tabViewProps.navigationState.index).toBe(1); + }); + + it('exposes only the declared screens as tabs', async () => { + await renderAdapter(); + + expect(tabViewProps.navigationState.routes).toHaveLength(2); + }); + + it('does not forward router options to the native view', async () => { + await renderAdapter(); + + expect(tabViewProps).not.toHaveProperty('backBehavior'); + }); +}); diff --git a/apps/expo-router-test/src/__tests__/types.test.tsx b/apps/expo-router-test/src/__tests__/types.test.tsx new file mode 100644 index 00000000..b58abd7f --- /dev/null +++ b/apps/expo-router-test/src/__tests__/types.test.tsx @@ -0,0 +1,47 @@ +import { ExpoRouterTabs } from '../tabs'; + +export const tintColors = () => ( + +); + +export const defaultTintColors = () => ( + +); + +export const customTabBar = () => ( + state.routes.map((it) => it.name).join()} + /> +); + +export const nativeProps = () => ( + +); + +export const screenOptions = () => ( + + + +); + +export const rejectsAWrongNavigatorProp = () => ( + // @ts-expect-error - a number is not a valid tint color + +); + +export const rejectsAnUnknownScreenOption = () => ( + + {/* @ts-expect-error - not a screen option this navigator accepts */} + + +); + +it('type checks the navigator surface', () => { + expect(typeof ExpoRouterTabs).toBe('object'); +}); diff --git a/apps/expo-router-test/src/tabs.tsx b/apps/expo-router-test/src/tabs.tsx new file mode 100644 index 00000000..374947bd --- /dev/null +++ b/apps/expo-router-test/src/tabs.tsx @@ -0,0 +1,10 @@ +import { NativeBottomTabsContent } from '@bottom-tabs/standard-navigation'; +import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation'; +import { TabRouter, unstable_createStandardRouterNavigator } from 'expo-router'; + +export const ExpoRouterTabs = unstable_createStandardRouterNavigator( + NativeBottomTabsContent, + TabRouter +); + +export const ReactNavigationTabs = createNativeBottomTabNavigator(); diff --git a/apps/expo-router-test/tsconfig.json b/apps/expo-router-test/tsconfig.json new file mode 100644 index 00000000..808fd9fa --- /dev/null +++ b/apps/expo-router-test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "types": ["jest"] + }, + "include": ["**/*.ts", "**/*.tsx"] +} diff --git a/docs/docs/docs/getting-started/quick-start.mdx b/docs/docs/docs/getting-started/quick-start.mdx index e8de61c8..e0e7c025 100644 --- a/docs/docs/docs/getting-started/quick-start.mdx +++ b/docs/docs/docs/getting-started/quick-start.mdx @@ -6,7 +6,6 @@ import { PackageManagerTabs } from '@theme'; If you don't have an existing project, you can create a new [Expo](https://expo.dev) app using the following command: - ```sh npx create-expo-app@latest NativeTabs --template @bottom-tabs/expo-template ``` @@ -15,16 +14,18 @@ npx create-expo-app@latest NativeTabs --template @bottom-tabs/expo-template -If you are going to use [React Navigation / Expo Router Integration](/docs/guides/usage-with-react-navigation) make sure to install `@bottom-tabs/react-navigation`. +If you are going to use the [React Navigation integration](/docs/guides/usage-with-react-navigation), or [Expo Router](/docs/guides/usage-with-expo-router) on SDK 52 to 55, also install `@bottom-tabs/react-navigation`. +For [Expo Router](/docs/guides/usage-with-expo-router) on SDK 56 or newer, install `@bottom-tabs/standard-navigation` instead. + + ### Expo Add the library plugin in your `app.json` config file and [create a new build](https://docs.expo.dev/develop/development-builds/create-a-build/). - ```diff "expo": { + "plugins": ["react-native-bottom-tabs"] @@ -42,7 +43,6 @@ This library is not supported in [Expo Go](https://expo.dev/go). Edit `android/app/src/main/res/values/styles.xml` to inherit from provided theme in order to customize the appearance of the native bottom tabs. - ```diff -