From 8caa6834a615872f914a03e123d16cc2316663ea Mon Sep 17 00:00:00 2001 From: Dan Hayworth Date: Wed, 12 Mar 2025 17:33:12 +1300 Subject: [PATCH 1/2] fix(bottomNavigation): fix bottom navigation to allow unselected state --- .../src/Examples/BottomNavigationExample.tsx | 7 ++ example/src/Examples/DialogExample.tsx | 2 +- .../BottomNavigation/BottomNavigation.tsx | 8 +- .../BottomNavigation/BottomNavigationBar.tsx | 1 + .../__tests__/BottomNavigation.test.tsx | 78 ++++++++++++++++++- src/styles/fonts.tsx | 2 +- 6 files changed, 94 insertions(+), 4 deletions(-) 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: { From 2977948fdd85709c2b226aa2eeb27e459bd5b425 Mon Sep 17 00:00:00 2001 From: Dan Hayworth Date: Wed, 12 Mar 2025 17:55:36 +1300 Subject: [PATCH 2/2] fix(bottomNavigation): fix linting --- example/src/Examples/DialogExample.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/src/Examples/DialogExample.tsx b/example/src/Examples/DialogExample.tsx index 0eaf086749..7a929c0ade 100644 --- a/example/src/Examples/DialogExample.tsx +++ b/example/src/Examples/DialogExample.tsx @@ -4,6 +4,7 @@ import { Platform, StyleSheet } from 'react-native'; import { Button } from 'react-native-paper'; import { useExampleTheme } from '..'; +import ScreenWrapper from '../ScreenWrapper'; import { DialogWithCustomColors, DialogWithDismissableBackButton, @@ -13,7 +14,6 @@ import { DialogWithRadioBtns, UndismissableDialog, } from './Dialogs'; -import ScreenWrapper from '../ScreenWrapper'; type ButtonVisibility = { [key: string]: boolean | undefined;