Skip to content

Commit c665f5b

Browse files
authored
chore: use TS generics for BottomTab types (#3741)
1 parent c8c0d8b commit c665f5b

10 files changed

Lines changed: 788 additions & 31 deletions

File tree

example/src/Examples/BottomNavigationBarExample.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import { View, StyleSheet } from 'react-native';
33

44
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
5+
import { CommonActions } from '@react-navigation/native';
56
import { Text, BottomNavigation } from 'react-native-paper';
67
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
78

@@ -43,7 +44,10 @@ export default function BottomNavigationBarExample() {
4344
if (event.defaultPrevented) {
4445
preventDefault();
4546
} else {
46-
navigation.navigate(route);
47+
navigation.dispatch({
48+
...CommonActions.navigate(route.name, route.params),
49+
target: state.key,
50+
});
4751
}
4852
}}
4953
renderIcon={({ route, focused, color }) =>

src/components/BottomNavigation/BottomNavigation.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import type { IconSource } from '../Icon';
1919
import BottomNavigationBar from './BottomNavigationBar';
2020
import BottomNavigationRouteScreen from './BottomNavigationRouteScreen';
2121

22-
type Route = {
22+
type BaseRoute = {
2323
key: string;
2424
title?: string;
2525
focusedIcon?: IconSource;
@@ -31,7 +31,7 @@ type Route = {
3131
lazy?: boolean;
3232
};
3333

34-
type NavigationState = {
34+
type NavigationState<Route extends BaseRoute> = {
3535
index: number;
3636
routes: Route[];
3737
};
@@ -41,7 +41,7 @@ type TabPressEvent = {
4141
preventDefault(): void;
4242
};
4343

44-
type TouchableProps = TouchableWithoutFeedbackProps & {
44+
type TouchableProps<Route extends BaseRoute> = TouchableWithoutFeedbackProps & {
4545
key: string;
4646
route: Route;
4747
children: React.ReactNode;
@@ -50,7 +50,7 @@ type TouchableProps = TouchableWithoutFeedbackProps & {
5050
rippleColor?: string;
5151
};
5252

53-
export type Props = {
53+
export type Props<Route extends BaseRoute> = {
5454
/**
5555
* Whether the shifting style is used, the active tab icon shifts up to show the label and the inactive tabs won't have a label.
5656
*
@@ -100,7 +100,7 @@ export type Props = {
100100
*
101101
* `BottomNavigation` is a controlled component, which means the `index` needs to be updated via the `onIndexChange` callback.
102102
*/
103-
navigationState: NavigationState;
103+
navigationState: NavigationState<Route>;
104104
/**
105105
* Callback which is called on tab change, receives the index of the new tab as argument.
106106
* The navigation state needs to be updated when it's called, otherwise the change is dropped.
@@ -165,7 +165,7 @@ export type Props = {
165165
* Callback which returns a React element to be used as the touchable for the tab item.
166166
* Renders a `TouchableRipple` on Android and `TouchableWithoutFeedback` with `View` on iOS.
167167
*/
168-
renderTouchable?: (props: TouchableProps) => React.ReactNode;
168+
renderTouchable?: (props: TouchableProps<Route>) => React.ReactNode;
169169
/**
170170
* Get accessibility label for the tab button. This is read by the screen reader when the user taps the tab.
171171
* Uses `route.accessibilityLabel` by default.
@@ -267,10 +267,10 @@ const SceneComponent = React.memo(({ component, ...rest }: any) =>
267267
);
268268

269269
/**
270-
* Bottom navigation provides quick navigation between top-level views of an app with a bottom navigation bar.
270+
* BottomNavigation provides quick navigation between top-level views of an app with a bottom navigation bar.
271271
* It is primarily designed for use on mobile. If you want to use the navigation bar only see [`BottomNavigation.Bar`](BottomNavigationBar).
272272
*
273-
* By default Bottom navigation uses primary color as a background, in dark theme with `adaptive` mode it will use surface colour instead.
273+
* By default BottomNavigation uses primary color as a background, in dark theme with `adaptive` mode it will use surface colour instead.
274274
* See [Dark Theme](https://callstack.github.io/react-native-paper/docs/guides/theming#dark-theme) for more information.
275275
*
276276
* <div class="screenshots">
@@ -318,7 +318,7 @@ const SceneComponent = React.memo(({ component, ...rest }: any) =>
318318
* export default MyComponent;
319319
* ```
320320
*/
321-
const BottomNavigation = ({
321+
const BottomNavigation = <Route extends BaseRoute>({
322322
navigationState,
323323
renderScene,
324324
renderIcon,
@@ -348,7 +348,7 @@ const BottomNavigation = ({
348348
testID = 'bottom-navigation',
349349
theme: themeOverrides,
350350
getLazy = ({ route }: { route: Route }) => route.lazy,
351-
}: Props) => {
351+
}: Props<Route>) => {
352352
const theme = useInternalTheme(themeOverrides);
353353
const { scale } = theme.animation;
354354
const compact = compactProp ?? !theme.isV3;
@@ -439,7 +439,7 @@ const BottomNavigation = ({
439439
// eslint-disable-next-line react-hooks/exhaustive-deps
440440
}, []);
441441

442-
const prevNavigationState = React.useRef<NavigationState>();
442+
const prevNavigationState = React.useRef<NavigationState<Route>>();
443443

444444
React.useEffect(() => {
445445
// Reset offsets of previous and current tabs before animation
@@ -602,7 +602,7 @@ const BottomNavigation = ({
602602
* Pure components are used to minimize re-rendering of the pages.
603603
* This drastically improves the animation performance.
604604
*/
605-
BottomNavigation.SceneMap = (scenes: {
605+
BottomNavigation.SceneMap = <Route extends BaseRoute>(scenes: {
606606
[key: string]: React.ComponentType<{
607607
route: Route;
608608
jumpTo: (key: string) => void;

src/components/BottomNavigation/BottomNavigationBar.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
getLabelColor,
3535
} from './utils';
3636

37-
type Route = {
37+
type BaseRoute = {
3838
key: string;
3939
title?: string;
4040
focusedIcon?: IconSource;
@@ -46,7 +46,7 @@ type Route = {
4646
lazy?: boolean;
4747
};
4848

49-
type NavigationState = {
49+
type NavigationState<Route extends BaseRoute> = {
5050
index: number;
5151
routes: Route[];
5252
};
@@ -56,7 +56,7 @@ type TabPressEvent = {
5656
preventDefault(): void;
5757
};
5858

59-
type TouchableProps = TouchableWithoutFeedbackProps & {
59+
type TouchableProps<Route extends BaseRoute> = TouchableWithoutFeedbackProps & {
6060
key: string;
6161
route: Route;
6262
children: React.ReactNode;
@@ -65,7 +65,7 @@ type TouchableProps = TouchableWithoutFeedbackProps & {
6565
rippleColor?: string;
6666
};
6767

68-
export type Props = {
68+
export type Props<Route extends BaseRoute> = {
6969
/**
7070
* Whether the shifting style is used, the active tab icon shifts up to show the label and the inactive tabs won't have a label.
7171
*
@@ -115,7 +115,7 @@ export type Props = {
115115
*
116116
* `BottomNavigation.Bar` is a controlled component, which means the `index` needs to be updated via the `onTabPress` callback.
117117
*/
118-
navigationState: NavigationState;
118+
navigationState: NavigationState<Route>;
119119
/**
120120
* Callback which returns a React Element to be used as tab icon.
121121
*/
@@ -136,7 +136,7 @@ export type Props = {
136136
* Callback which returns a React element to be used as the touchable for the tab item.
137137
* Renders a `TouchableRipple` on Android and `TouchableWithoutFeedback` with `View` on iOS.
138138
*/
139-
renderTouchable?: (props: TouchableProps) => React.ReactNode;
139+
renderTouchable?: (props: TouchableProps<Route>) => React.ReactNode;
140140
/**
141141
* Get accessibility label for the tab button. This is read by the screen reader when the user taps the tab.
142142
* Uses `route.accessibilityLabel` by default.
@@ -214,15 +214,15 @@ const MAX_TAB_WIDTH = 168;
214214
const BAR_HEIGHT = 56;
215215
const OUTLINE_WIDTH = 64;
216216

217-
const Touchable = ({
217+
const Touchable = <Route extends BaseRoute>({
218218
route: _0,
219219
style,
220220
children,
221221
borderless,
222222
centered,
223223
rippleColor,
224224
...rest
225-
}: TouchableProps) =>
225+
}: TouchableProps<Route>) =>
226226
TouchableRipple.supported ? (
227227
<TouchableRipple
228228
{...rest}
@@ -278,7 +278,10 @@ const Touchable = ({
278278
* if (event.defaultPrevented) {
279279
* preventDefault();
280280
* } else {
281-
* navigation.navigate(route);
281+
* navigation.dispatch({
282+
* ...CommonActions.navigate(route.name, route.params),
283+
* target: state.key,
284+
* });
282285
* }
283286
* }}
284287
* renderIcon={({ route, focused, color }) => {
@@ -352,11 +355,11 @@ const Touchable = ({
352355
* });
353356
* ```
354357
*/
355-
const BottomNavigationBar = ({
358+
const BottomNavigationBar = <Route extends BaseRoute>({
356359
navigationState,
357360
renderIcon,
358361
renderLabel,
359-
renderTouchable = (props: TouchableProps) => <Touchable {...props} />,
362+
renderTouchable = (props: TouchableProps<Route>) => <Touchable {...props} />,
360363
getLabelText = ({ route }: { route: Route }) => route.title,
361364
getBadge = ({ route }: { route: Route }) => route.badge,
362365
getColor = ({ route }: { route: Route }) => route.color,
@@ -377,7 +380,7 @@ const BottomNavigationBar = ({
377380
compact: compactProp,
378381
testID = 'bottom-navigation-bar',
379382
theme: themeOverrides,
380-
}: Props) => {
383+
}: Props<Route>) => {
381384
const theme = useInternalTheme(themeOverrides);
382385
const { bottom, left, right } = useSafeAreaInsets();
383386
const { scale } = theme.animation;

src/components/__tests__/BottomNavigation.test.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ const createState = (index: number, length: number) => ({
7979
routes: Array.from({ length }, (_, i) => ({
8080
key: `key-${i}`,
8181
focusedIcon: icons[i],
82+
unfocusedIcon: undefined,
8283
title: `Route: ${i}`,
8384
})),
8485
});
@@ -623,3 +624,36 @@ it('barStyle animated value changes correctly', () => {
623624
transform: [{ scale: 1.5 }],
624625
});
625626
});
627+
628+
it("allows customizing Route's type via generics", () => {
629+
type CustomRoute = {
630+
key: string;
631+
customPropertyName: string;
632+
};
633+
634+
type CustomState = {
635+
index: number;
636+
routes: CustomRoute[];
637+
};
638+
639+
const state: CustomState = {
640+
index: 0,
641+
routes: [
642+
{ key: 'a', customPropertyName: 'First' },
643+
{ key: 'b', customPropertyName: 'Second' },
644+
],
645+
};
646+
647+
const tree = renderer
648+
.create(
649+
<BottomNavigation
650+
navigationState={state}
651+
onIndexChange={jest.fn()}
652+
getLabelText={({ route }) => route.customPropertyName}
653+
renderScene={({ route }) => route.customPropertyName}
654+
/>
655+
)
656+
.toJSON();
657+
658+
expect(tree).toMatchSnapshot();
659+
});

0 commit comments

Comments
 (0)