diff --git a/example/src/Examples/BottomNavigationExample.tsx b/example/src/Examples/BottomNavigationExample.tsx
index 59c5fa8ead..90efef3fad 100644
--- a/example/src/Examples/BottomNavigationExample.tsx
+++ b/example/src/Examples/BottomNavigationExample.tsx
@@ -140,6 +140,13 @@ const BottomNavigationExample = ({ navigation }: Props) => {
}}
title="Scene animation: opacity"
/>
+
{
+ setIndex(-1);
+ }}
+ title="Unselect menu items"
+ />
({
}: Props) => {
const theme = useInternalTheme(themeOverrides);
const { scale } = theme.animation;
+ const isValidIndex =
+ navigationState.index >= 0 &&
+ navigationState.index < navigationState.routes.length;
const compact = compactProp ?? !theme.isV3;
let shifting =
shiftingProp ?? (theme.isV3 ? false : navigationState.routes.length > 3);
@@ -364,7 +367,9 @@ const BottomNavigation = ({
);
}
- const focusedKey = navigationState.routes[navigationState.index].key;
+ const focusedKey = isValidIndex
+ ? navigationState.routes[navigationState.index].key
+ : '';
/**
* Active state of individual tab item positions:
@@ -400,6 +405,7 @@ const BottomNavigation = ({
const animateToIndex = React.useCallback(
(index: number) => {
+ if (index < 0 || index > navigationState.routes.length) return;
Animated.parallel([
...navigationState.routes.map((_, i) =>
Animated.timing(tabsPositionAnims[i], {
diff --git a/src/components/BottomNavigation/BottomNavigationBar.tsx b/src/components/BottomNavigation/BottomNavigationBar.tsx
index 789c38829e..ee9a720e5e 100644
--- a/src/components/BottomNavigation/BottomNavigationBar.tsx
+++ b/src/components/BottomNavigation/BottomNavigationBar.tsx
@@ -456,6 +456,7 @@ const BottomNavigationBar = ({
const animateToIndex = React.useCallback(
(index: number) => {
+ if (index < 0 || index > navigationState.routes.length) return;
// Reset the ripple to avoid glitch if it's currently animating
rippleAnim.setValue(MIN_RIPPLE_SCALE);
diff --git a/src/components/__tests__/BottomNavigation.test.tsx b/src/components/__tests__/BottomNavigation.test.tsx
index d1a2a6ebd1..f04ab5e69b 100644
--- a/src/components/__tests__/BottomNavigation.test.tsx
+++ b/src/components/__tests__/BottomNavigation.test.tsx
@@ -1,5 +1,5 @@
import * as React from 'react';
-import { Animated, Easing, Platform, StyleSheet } from 'react-native';
+import { Animated, Easing, Platform, StyleSheet, Text } from 'react-native';
import { act, fireEvent, render } from '@testing-library/react-native';
import color from 'color';
@@ -656,3 +656,79 @@ it("allows customizing Route's type via generics", () => {
expect(tree).toMatchSnapshot();
});
+
+it('renders all icons as unfocused when index is -1', () => {
+ const navigationState = {
+ index: -1,
+ routes: Array.from({ length: 3 }, (_, i) => ({
+ key: `key-${i}`,
+ icon: icons[i],
+ title: `Route: ${i}`,
+ })),
+ };
+
+ const component = render(
+ route.title}
+ renderIcon={({ route, focused, color }) => (
+
+ {focused ? 'focused' : 'unfocused'}
+
+ )}
+ />
+ );
+ const { container } = component;
+ const iconNodes0 = container.findAll(
+ (node) => node.props.testID === 'icon-key-0'
+ );
+ const visibleIcon0 = iconNodes0.find((node) => {
+ // node.parent it's an Animated.View wrapping the icon so we can check that its style.opacity equals 1.
+ const parentStyle = node.parent?.props.style;
+ if (Array.isArray(parentStyle)) {
+ return parentStyle.some((style) => style && style.opacity === 1);
+ }
+ return parentStyle && parentStyle.opacity === 1;
+ });
+ expect(visibleIcon0!.props.children).toBe('unfocused');
+});
+
+it('renders all icons as unfocused when index is greater than the the routes length', () => {
+ const navigationState = {
+ index: 4,
+ routes: Array.from({ length: 3 }, (_, i) => ({
+ key: `key-${i}`,
+ icon: icons[i],
+ title: `Route: ${i}`,
+ })),
+ };
+
+ const component = render(
+ route.title}
+ renderIcon={({ route, focused, color }) => (
+
+ {focused ? 'focused' : 'unfocused'}
+
+ )}
+ />
+ );
+ const { container } = component;
+ const iconNodes0 = container.findAll(
+ (node) => node.props.testID === 'icon-key-2'
+ );
+ const visibleIcon2 = iconNodes0.find((node) => {
+ // node.parent it's an Animated.View wrapping the icon so we can check that its style.opacity equals 1.
+ const parentStyle = node.parent?.props.style;
+ if (Array.isArray(parentStyle)) {
+ return parentStyle.some((style) => style && style.opacity === 1);
+ }
+ return parentStyle && parentStyle.opacity === 1;
+ });
+ expect(visibleIcon2!.props.children).toBe('unfocused');
+});
diff --git a/src/styles/fonts.tsx b/src/styles/fonts.tsx
index 3f6889928d..e3b5017fc4 100644
--- a/src/styles/fonts.tsx
+++ b/src/styles/fonts.tsx
@@ -1,7 +1,7 @@
import { Platform, PlatformOSType } from 'react-native';
-import { typescale } from './themes/v3/tokens';
import type { Fonts, MD3Type, MD3Typescale, MD3TypescaleKey } from '../types';
+import { typescale } from './themes/v3/tokens';
export const fontConfig = {
web: {