Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"fixed": [
[
"react-native-bottom-tabs",
"@bottom-tabs/react-navigation"
"@bottom-tabs/react-navigation",
"@bottom-tabs/navigation"
]
],
"linked": [],
Expand Down
5 changes: 5 additions & 0 deletions .changeset/drop-color-dependency.md
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions .changeset/standard-navigation-shared-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@bottom-tabs/navigation': minor
'@bottom-tabs/react-navigation': minor
---

Move the native tabs implementation into a new framework-agnostic `@bottom-tabs/navigation` package built on the `standard-navigation` contract. This lets Expo Router SDK 56+ apps drive the same navigator through `unstable_createStandardRouterNavigator` without loading a second copy of React Navigation.

`NativeBottomTabsContent` carries its event map and navigator props on phantom properties, so an integrator can type `tabBarActiveTintColor`, `tabBar` and the rest. Neither is inferable from the contract on its own - the event map appears only as an argument to `emitter.emit`, and the navigator props only inside an `Omit<…>`.

`createNativeBottomTabNavigator` renders through the shared navigator, and keeps building its own state with `useNavigationBuilder`, so `state.key` and the per-route `navigation` and `route` objects a custom `tabBar` reads stay intact.

`@bottom-tabs/react-navigation` now requires `@react-navigation/native` 7.3.0 or newer, which is the version that introduced `createStandardNavigationFactories` used by the documented shared-navigator recipe
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/navigation](/packages/navigation) | [![badge](https://img.shields.io/npm/v/@bottom-tabs/navigation.svg?style=for-the-badge)](https://www.npmjs.com/package/@bottom-tabs/navigation) |

## Documentation

Expand Down
32 changes: 32 additions & 0 deletions apps/expo-router-test/jest.config.js
Original file line number Diff line number Diff line change
@@ -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: ['<rootDir>/jest.setup.ts'],

moduleNameMapper: {
...Object.fromEntries(
PROJECT_COPIES.map((name) => [
`^${name}($|/.*)`,
path.dirname(require.resolve(`${name}/package.json`)) + '$1',
])
),
...Object.fromEntries(reactNavigationCopies),
},

};
23 changes: 23 additions & 0 deletions apps/expo-router-test/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -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')}`);
}
});
38 changes: 38 additions & 0 deletions apps/expo-router-test/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "expo-router-test",
"version": "0.0.1",
"private": true,
"scripts": {
"test": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@bottom-tabs/navigation": "*",
"@bottom-tabs/react-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"
}
}
116 changes: 116 additions & 0 deletions apps/expo-router-test/src/__tests__/adapters.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, any>;

jest.mock('react-native-bottom-tabs', () => ({
__esModule: true,
default: (props: Record<string, any>) => {
tabViewProps = props;
return null;
},
}));

const OPTIONS = {
index: { title: 'Home', tabBarBadge: '3' },
explore: { title: 'Explore' },
};

const adapters = [
{
name: 'expo-router',
render: async () => {
await renderRouter(
{
_layout: () => (
<ExpoRouterTabs>
<ExpoRouterTabs.Screen name="index" options={OPTIONS.index} />
<ExpoRouterTabs.Screen name="explore" options={OPTIONS.explore} />
</ExpoRouterTabs>
),
index: () => <Text>index scene</Text>,
explore: () => <Text>explore scene</Text>,
},
{ initialUrl: '/' }
);
},
},
{
name: 'react-navigation',
render: async () => {
await render(
<NavigationContainer>
<ReactNavigationTabs.Navigator>
<ReactNavigationTabs.Screen name="index" options={OPTIONS.index}>
{() => <Text>index scene</Text>}
</ReactNavigationTabs.Screen>
<ReactNavigationTabs.Screen
name="explore"
options={OPTIONS.explore}
>
{() => <Text>explore scene</Text>}
</ReactNavigationTabs.Screen>
</ReactNavigationTabs.Navigator>
</NavigationContainer>
);
},
},
];

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');
});
});
47 changes: 47 additions & 0 deletions apps/expo-router-test/src/__tests__/types.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ExpoRouterTabs } from '../tabs';

export const tintColors = () => (
<ExpoRouterTabs
tabBarActiveTintColor="#ff0000"
tabBarInactiveTintColor="#808080"
/>
);

export const defaultTintColors = () => (
<ExpoRouterTabs defaultTintColors={{ active: '#ff0000' }} />
);

export const customTabBar = () => (
<ExpoRouterTabs
tabBar={({ state }) => state.routes.map((it) => it.name).join()}
/>
);

export const nativeProps = () => (
<ExpoRouterTabs labeled sidebarAdaptable hapticFeedbackEnabled />
);

export const screenOptions = () => (
<ExpoRouterTabs>
<ExpoRouterTabs.Screen
name="index"
options={{ title: 'Home', tabBarBadge: '3' }}
/>
</ExpoRouterTabs>
);

export const rejectsAWrongNavigatorProp = () => (
// @ts-expect-error - a number is not a valid tint color
<ExpoRouterTabs tabBarActiveTintColor={42} />
);

export const rejectsAnUnknownScreenOption = () => (
<ExpoRouterTabs>
{/* @ts-expect-error - not a screen option this navigator accepts */}
<ExpoRouterTabs.Screen name="index" options={{ notARealOption: true }} />
</ExpoRouterTabs>
);

it('type checks the navigator surface', () => {
expect(typeof ExpoRouterTabs).toBe('object');
});
10 changes: 10 additions & 0 deletions apps/expo-router-test/src/tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NativeBottomTabsContent } from '@bottom-tabs/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();
8 changes: 8 additions & 0 deletions apps/expo-router-test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"types": ["jest"]
},
"include": ["**/*.ts", "**/*.tsx"]
}
6 changes: 5 additions & 1 deletion docs/docs/docs/getting-started/quick-start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@ npx create-expo-app@latest NativeTabs --template @bottom-tabs/expo-template

<PackageManagerTabs command="install react-native-bottom-tabs" />

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), also install `@bottom-tabs/react-navigation`.

<PackageManagerTabs command="install @bottom-tabs/react-navigation" />

For [Expo Router](/docs/guides/usage-with-expo-router) on SDK 56 or newer, install `@bottom-tabs/navigation` instead.

<PackageManagerTabs command="install @bottom-tabs/navigation" />


### Expo

Expand Down
Loading
Loading