diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index ee54135042..12575d429e 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -85,7 +85,6 @@ const config = { Banner: 'Banner', BottomNavigation: { BottomNavigation: 'BottomNavigation/BottomNavigation', - BottomNavigationBar: 'BottomNavigation/BottomNavigationBar', }, Button: { Button: 'Button/Button', @@ -148,6 +147,9 @@ const config = { MenuItem: 'Menu/MenuItem', }, Modal: 'Modal', + NavigationBar: { + NavigationBar: 'NavigationBar/NavigationBar', + }, Portal: { Portal: 'Portal/Portal', PortalHost: 'Portal/PortalHost', diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 53132e299f..5c8aba19da 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -27,6 +27,7 @@ import ListAccordionExampleGroup from './Examples/ListAccordionGroupExample'; import ListItemExample from './Examples/ListItemExample'; import ListSectionExample from './Examples/ListSectionExample'; import MenuExample from './Examples/MenuExample'; +import NavigationBarExample from './Examples/NavigationBarExample'; import ProgressBarExample from './Examples/ProgressBarExample'; import RadioButtonExample from './Examples/RadioButtonExample'; import RadioButtonGroupExample from './Examples/RadioButtonGroupExample'; @@ -72,6 +73,7 @@ export const mainExamples = { ListSection: ListSectionExample, ListItem: ListItemExample, Menu: MenuExample, + NavigationBar: NavigationBarExample, Progressbar: ProgressBarExample, Radio: RadioButtonExample, RadioGroup: RadioButtonGroupExample, diff --git a/example/src/Examples/BottomNavigationBarExample.tsx b/example/src/Examples/BottomNavigationBarExample.tsx index 9fbddf7f23..4b4d98d94d 100644 --- a/example/src/Examples/BottomNavigationBarExample.tsx +++ b/example/src/Examples/BottomNavigationBarExample.tsx @@ -11,7 +11,7 @@ import { SFSymbol, MaterialSymbol, } from '@react-navigation/native'; -import { Text, BottomNavigation } from 'react-native-paper'; +import { Text, NavigationBar } from 'react-native-paper'; function HomeScreen() { return ( @@ -34,7 +34,7 @@ const BottomNavigationBarExample = createBottomTabNavigator({ headerShown: false, }, tabBar: ({ navigation, state, descriptors }) => ( - { const event = navigation.emit({ @@ -119,7 +119,7 @@ const BottomNavigationBarExample = createBottomTabNavigator({ }); export default Object.assign(BottomNavigationBarExample, { - title: 'Bottom Navigation Bar', + title: 'Navigation Bar (React Navigation)', }); const styles = StyleSheet.create({ diff --git a/example/src/Examples/NavigationBarExample.tsx b/example/src/Examples/NavigationBarExample.tsx new file mode 100644 index 0000000000..070bbc28c2 --- /dev/null +++ b/example/src/Examples/NavigationBarExample.tsx @@ -0,0 +1,108 @@ +import * as React from 'react'; +import { StyleSheet, View, useWindowDimensions } from 'react-native'; + +import { + NavigationBar, + SegmentedButtons, + Switch, + Text, +} from 'react-native-paper'; + +type VariantMode = 'auto' | 'stacked' | 'horizontal'; + +// The flexible navigation bar switches to a horizontal item arrangement in +// medium-width windows. M3 recommends ~600dp as the breakpoint, but the +// component leaves the decision to the consumer — here it is driven by +// `useWindowDimensions`. +const MEDIUM_WINDOW_WIDTH = 600; + +const routes = [ + { key: 'album', title: 'Album', focusedIcon: 'image-album', badge: 3 }, + { key: 'library', title: 'Library', focusedIcon: 'bookshelf' }, + { + key: 'favorites', + title: 'Favorites', + focusedIcon: 'heart', + unfocusedIcon: 'heart-outline', + }, + { + key: 'settings', + title: 'Settings', + focusedIcon: 'cog', + unfocusedIcon: 'cog-outline', + }, +]; + +const NavigationBarExample = () => { + const [index, setIndex] = React.useState(0); + const [labeled, setLabeled] = React.useState(true); + const [variantMode, setVariantMode] = React.useState('auto'); + + const { width } = useWindowDimensions(); + const autoVariant = width >= MEDIUM_WINDOW_WIDTH ? 'horizontal' : 'stacked'; + const variant = variantMode === 'auto' ? autoVariant : variantMode; + + return ( + + + {routes[index].title} + + + + Show labels + + + + setVariantMode(value as VariantMode)} + buttons={[ + { value: 'auto', label: `Auto (${autoVariant})` }, + { value: 'stacked', label: 'Stacked' }, + { value: 'horizontal', label: 'Horizontal' }, + ]} + /> + + + + { + const nextIndex = routes.findIndex((r) => r.key === route.key); + if (nextIndex !== -1) { + setIndex(nextIndex); + } + }} + /> + + ); +}; + +NavigationBarExample.title = 'Navigation Bar (flexible)'; + +export default NavigationBarExample; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 16, + }, + controls: { + marginTop: 32, + width: '100%', + maxWidth: 480, + gap: 24, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, +}); diff --git a/src/components/BottomNavigation/BottomNavigation.tsx b/src/components/BottomNavigation/BottomNavigation.tsx index 1f18b17aae..08313484e9 100644 --- a/src/components/BottomNavigation/BottomNavigation.tsx +++ b/src/components/BottomNavigation/BottomNavigation.tsx @@ -9,12 +9,12 @@ import type { import useLatestCallback from 'use-latest-callback'; -import BottomNavigationBar from './BottomNavigationBar'; import BottomNavigationRouteScreen from './BottomNavigationRouteScreen'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; import useAnimatedValueArray from '../../utils/useAnimatedValueArray'; import type { IconSource } from '../Icon'; +import NavigationBar from '../NavigationBar/NavigationBar'; import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; export type BaseRoute = { @@ -48,14 +48,6 @@ type TouchableProps = TouchableRippleProps & { }; export type Props = { - /** - * 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. - * - * By default, this is `false` with theme version 3 and `true` when you have more than 3 tabs. - * Pass `shifting={false}` to explicitly disable this animation, or `shifting={true}` to always use this animation. - * Note that you need at least 2 tabs be able to run this animation. - */ - shifting?: boolean; /** * Whether to show labels in tabs. When `false`, only icons will be displayed. */ @@ -200,8 +192,8 @@ export type Props = { */ inactiveColor?: string; /** - * Whether animation is enabled for scenes transitions in `shifting` mode. - * By default, the scenes cross-fade during tab change when `shifting` is enabled. + * Whether animation is enabled for scene transitions. + * By default, the scenes cross-fade during tab change. * Specify `sceneAnimationEnabled` as `false` to disable the animation. */ sceneAnimationEnabled?: boolean; @@ -261,7 +253,7 @@ const SceneComponent = React.memo(({ component, ...rest }: any) => /** * BottomNavigation provides quick navigation between top-level views of an app with a bottom navigation bar. - * It is primarily designed for use on mobile. If you want to use the navigation bar only see [`BottomNavigation.Bar`](BottomNavigationBar). + * It is primarily designed for use on mobile. If you want to use the navigation bar only see [`NavigationBar`](../NavigationBar). * * By default BottomNavigation uses primary color as a background, in dark theme with `adaptive` mode it will use surface colour instead. * See [Dark Theme](https://callstack.github.io/react-native-paper/docs/guides/theming#dark-theme) for more information. @@ -330,7 +322,6 @@ const BottomNavigation = ({ onTabPress, onTabLongPress, onIndexChange, - shifting: shiftingProp, safeAreaInsets, labelMaxFontSizeMultiplier = 1, compact: compactProp, @@ -341,14 +332,6 @@ const BottomNavigation = ({ const theme = useInternalTheme(themeOverrides); const { scale } = theme.animation; const compact = compactProp ?? false; - let shifting = shiftingProp ?? false; - - if (shifting && navigationState.routes.length < 2) { - shifting = false; - console.warn( - 'BottomNavigation needs at least 2 tabs to run shifting animation' - ); - } const focusedKey = navigationState.routes[navigationState.index].key; @@ -556,7 +539,7 @@ const BottomNavigation = ({ ); })} - ({ style={barStyle} activeIndicatorStyle={activeIndicatorStyle} labeled={labeled} - animationEasing={sceneAnimationEasing} onTabPress={handleTabPress} onTabLongPress={onTabLongPress} - shifting={shifting} safeAreaInsets={safeAreaInsets} labelMaxFontSizeMultiplier={labelMaxFontSizeMultiplier} compact={compact} @@ -612,9 +593,6 @@ BottomNavigation.SceneMap = (scenes: { ); }; -// @component ./BottomNavigationBar.tsx -BottomNavigation.Bar = BottomNavigationBar; - export default BottomNavigation; const styles = StyleSheet.create({ diff --git a/src/components/BottomNavigation/BottomNavigationBar.tsx b/src/components/BottomNavigation/BottomNavigationBar.tsx deleted file mode 100644 index 83998977b9..0000000000 --- a/src/components/BottomNavigation/BottomNavigationBar.tsx +++ /dev/null @@ -1,862 +0,0 @@ -import * as React from 'react'; -import { Animated, Platform, StyleSheet, Pressable, View } from 'react-native'; -import type { - ColorValue, - EasingFunction, - StyleProp, - ViewStyle, -} from 'react-native'; - -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { - getActiveTintColor, - getInactiveTintColor, - getLabelColor, -} from './utils'; -import { useInternalTheme } from '../../core/theming'; -import type { Theme, ThemeProp } from '../../types'; -import useAnimatedValue from '../../utils/useAnimatedValue'; -import useAnimatedValueArray from '../../utils/useAnimatedValueArray'; -import useIsKeyboardShown from '../../utils/useIsKeyboardShown'; -import useLayout from '../../utils/useLayout'; -import Badge from '../Badge'; -import Icon from '../Icon'; -import type { IconSource } from '../Icon'; -import Surface from '../Surface'; -import TouchableRipple from '../TouchableRipple/TouchableRipple'; -import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; -import Text from '../Typography/Text'; - -type BaseRoute = { - key: string; - title?: string; - focusedIcon?: IconSource; - unfocusedIcon?: IconSource; - badge?: string | number | boolean; - accessibilityLabel?: string; - testID?: string; - lazy?: boolean; -}; - -type NavigationState = { - index: number; - routes: Route[]; -}; - -type TabPressEvent = { - defaultPrevented: boolean; - preventDefault(): void; -}; - -type TouchableProps = TouchableRippleProps & { - key: string; - route: Route; - children: React.ReactNode; - borderless?: boolean; - centered?: boolean; - rippleColor?: ColorValue; -}; - -export type Props = { - /** - * 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. - * - * By default, this is `false` with theme version 3 and `true` when you have more than 3 tabs. - * Pass `shifting={false}` to explicitly disable this animation, or `shifting={true}` to always use this animation. - * Note that you need at least 2 tabs be able to run this animation. - */ - shifting?: boolean; - /** - * Whether to show labels in tabs. When `false`, only icons will be displayed. - */ - labeled?: boolean; - /** - * Whether tabs should be spread across the entire width. - */ - compact?: boolean; - /** - * State for the bottom navigation. The state should contain the following properties: - * - * - `index`: a number representing the index of the active route in the `routes` array - * - `routes`: an array containing a list of route objects used for rendering the tabs - * - * Each route object should contain the following properties: - * - * - `key`: a unique key to identify the route (required) - * - `title`: title of the route to use as the tab label - * - `focusedIcon`: icon to use as the focused tab icon, can be a string, an image source or a react component @renamed Renamed from 'icon' to 'focusedIcon' in v5.x - * - `unfocusedIcon`: icon to use as the unfocused tab icon, can be a string, an image source or a react component @supported Available in v5.x with theme version 3 - * - `badge`: badge to show on the tab icon, can be `true` to show a dot, `string` or `number` to show text. - * - `accessibilityLabel`: accessibility label for the tab button - * - `testID`: test id for the tab button - * - * Example: - * - * ```js - * { - * index: 1, - * routes: [ - * { key: 'music', title: 'Favorites', focusedIcon: 'heart', unfocusedIcon: 'heart-outline'}, - * { key: 'albums', title: 'Albums', focusedIcon: 'album' }, - * { key: 'recents', title: 'Recents', focusedIcon: 'history' }, - * { key: 'notifications', title: 'Notifications', focusedIcon: 'bell', unfocusedIcon: 'bell-outline' }, - * ] - * } - * ``` - * - * `BottomNavigation.Bar` is a controlled component, which means the `index` needs to be updated via the `onTabPress` callback. - */ - navigationState: NavigationState; - /** - * Callback which returns a React Element to be used as tab icon. - */ - renderIcon?: (props: { - route: Route; - focused: boolean; - color: ColorValue; - }) => React.ReactNode; - /** - * Callback which React Element to be used as tab label. - */ - renderLabel?: (props: { - route: Route; - focused: boolean; - color: ColorValue; - }) => React.ReactNode; - /** - * Callback which returns a React element to be used as the touchable for the tab item. - * Renders a `TouchableRipple` on Android and `Pressable` on iOS. - */ - renderTouchable?: (props: TouchableProps) => React.ReactNode; - /** - * Get accessibility label for the tab button. This is read by the screen reader when the user taps the tab. - * Uses `route.accessibilityLabel` by default. - */ - getAccessibilityLabel?: (props: { route: Route }) => string | undefined; - /** - * Get badge for the tab, uses `route.badge` by default. - */ - getBadge?: (props: { route: Route }) => boolean | number | string | undefined; - /** - * Get label text for the tab, uses `route.title` by default. Use `renderLabel` to replace label component. - */ - getLabelText?: (props: { route: Route }) => string | undefined; - /** - * Get the id to locate this tab button in tests, uses `route.testID` by default. - */ - getTestID?: (props: { route: Route }) => string | undefined; - /** - * Function to execute on tab press. It receives the route for the pressed tab. Use this to update the navigation state. - */ - onTabPress: (props: { route: Route } & TabPressEvent) => void; - /** - * Function to execute on tab long press. It receives the route for the pressed tab - */ - onTabLongPress?: (props: { route: Route } & TabPressEvent) => void; - /** - * Custom color for icon and label in the active tab. - */ - activeColor?: string; - /** - * Custom color for icon and label in the inactive tab. - */ - inactiveColor?: string; - /** - * The scene animation Easing. - */ - animationEasing?: EasingFunction | undefined; - /** - * Whether the bottom navigation bar is hidden when keyboard is shown. - * On Android, this works best when [`windowSoftInputMode`](https://developer.android.com/guide/topics/manifest/activity-element#wsoft) is set to `adjustResize`. - */ - keyboardHidesNavigationBar?: boolean; - /** - * Safe area insets for the tab bar. This can be used to avoid elements like the navigation bar on Android and bottom safe area on iOS. - * The bottom insets for iOS is added by default. You can override the behavior with this option. - */ - safeAreaInsets?: { - top?: number; - right?: number; - bottom?: number; - left?: number; - }; - /** - * Specifies the largest possible scale a label font can reach. - */ - labelMaxFontSizeMultiplier?: number; - style?: Animated.WithAnimatedValue>; - activeIndicatorStyle?: StyleProp; - /** - * @optional - */ - theme?: ThemeProp; - /** - * TestID used for testing purposes - */ - testID?: string; -}; - -const MIN_TAB_WIDTH = 96; -const MAX_TAB_WIDTH = 168; -const BAR_HEIGHT = 56; -const OUTLINE_WIDTH = 64; - -const Touchable = ({ - route: _0, - style, - children, - borderless, - centered, - rippleColor, - ...rest -}: TouchableProps) => - TouchableRipple.supported ? ( - - {children} - - ) : ( - - {children} - - ); - -/** - * A navigation bar which can easily be integrated with [React Navigation's Bottom Tabs Navigator](https://reactnavigation.org/docs/bottom-tab-navigator/). - * - * ## Usage - * ### without React Navigation - * ```js - * import React from 'react'; - * import { useState } from 'react'; - * import { View } from 'react-native'; - * import { BottomNavigation, Text, Provider } from 'react-native-paper'; - * import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; - * - * function HomeScreen() { - * return ( - * - * Home! - * - * ); - * } - * - * function SettingsScreen() { - * return ( - * - * Settings! - * - * ); - * } - * - * export default function MyComponent() { - * const [index, setIndex] = useState(0); - * - * const routes = [ - * { key: 'home', title: 'Home', icon: 'home' }, - * { key: 'settings', title: 'Settings', icon: 'cog' }, - * ]; - - * const renderScene = ({ route }) => { - * switch (route.key) { - * case 'home': - * return ; - * case 'settings': - * return ; - * default: - * return null; - * } - * }; - * - * return ( - * - * {renderScene({ route: routes[index] })} - * { - * const newIndex = routes.findIndex((r) => r.key === route.key); - * if (newIndex !== -1) { - * setIndex(newIndex); - * } - * }} - * renderIcon={({ route, color }) => ( - * - * )} - * getLabelText={({ route }) => route.title} - * /> - * - * ); - * } - * ``` - */ -const BottomNavigationBar = ({ - navigationState, - renderIcon, - renderLabel, - renderTouchable = ({ key, ...props }: TouchableProps) => ( - - ), - getLabelText = ({ route }: { route: Route }) => route.title, - getBadge = ({ route }: { route: Route }) => route.badge, - getAccessibilityLabel = ({ route }: { route: Route }) => - route.accessibilityLabel, - getTestID = ({ route }: { route: Route }) => route.testID, - activeColor, - inactiveColor, - keyboardHidesNavigationBar = Platform.OS === 'android', - style, - activeIndicatorStyle, - labeled = true, - animationEasing, - onTabPress, - onTabLongPress, - shifting: shiftingProp, - safeAreaInsets, - labelMaxFontSizeMultiplier = 1, - compact: compactProp, - testID = 'bottom-navigation-bar', - theme: themeOverrides, -}: Props) => { - const theme = useInternalTheme(themeOverrides); - const { colors } = theme as Theme; - const { bottom, left, right } = useSafeAreaInsets(); - const { scale } = theme.animation; - const compact = compactProp ?? false; - let shifting = shiftingProp ?? false; - - if (shifting && navigationState.routes.length < 2) { - shifting = false; - console.warn( - 'BottomNavigation.Bar needs at least 2 tabs to run shifting animation' - ); - } - - /** - * Visibility of the navigation bar, visible state is 1 and invisible is 0. - */ - const visibleAnim = useAnimatedValue(1); - - /** - * Active state of individual tab items, active state is 1 and inactive state is 0. - */ - const tabsAnims = useAnimatedValueArray( - navigationState.routes.map( - // focused === 1, unfocused === 0 - (_, i) => (i === navigationState.index ? 1 : 0) - ) - ); - - /** - * Layout of the navigation bar. - */ - const [layout, onLayout] = useLayout(); - - /** - * Track whether the keyboard is visible to show and hide the navigation bar. - */ - const [keyboardVisible, setKeyboardVisible] = React.useState(false); - - const handleKeyboardShow = React.useCallback(() => { - setKeyboardVisible(true); - Animated.timing(visibleAnim, { - toValue: 0, - duration: 150 * scale, - useNativeDriver: true, - }).start(); - }, [scale, visibleAnim]); - - const handleKeyboardHide = React.useCallback(() => { - Animated.timing(visibleAnim, { - toValue: 1, - duration: 100 * scale, - useNativeDriver: true, - }).start(() => { - setKeyboardVisible(false); - }); - }, [scale, visibleAnim]); - - const animateToIndex = React.useCallback( - (index: number) => { - Animated.parallel( - navigationState.routes.map((_, i) => - Animated.timing(tabsAnims[i], { - toValue: i === index ? 1 : 0, - duration: 150 * scale, - useNativeDriver: true, - easing: animationEasing, - }) - ) - ).start(() => { - // Workaround a bug in native animations where this is reset after first animation - tabsAnims.map((tab, i) => tab.setValue(i === index ? 1 : 0)); - }); - }, - [scale, navigationState.routes, tabsAnims, animationEasing] - ); - - React.useEffect(() => { - // Workaround for native animated bug in react-native@^0.57 - // Context: https://github.com/callstack/react-native-paper/pull/637 - animateToIndex(navigationState.index); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useIsKeyboardShown({ - onShow: handleKeyboardShow, - onHide: handleKeyboardHide, - }); - - React.useEffect(() => { - animateToIndex(navigationState.index); - }, [navigationState.index, animateToIndex]); - - const eventForIndex = (index: number) => { - const event = { - route: navigationState.routes[index], - defaultPrevented: false, - preventDefault: () => { - event.defaultPrevented = true; - }, - }; - - return event; - }; - - const { routes } = navigationState; - - const { backgroundColor: customBackground } = (StyleSheet.flatten(style) || - {}) as { - elevation?: number; - backgroundColor?: ColorValue; - }; - - const backgroundColor = customBackground || colors.surfaceContainer; - - const activeTintColor = getActiveTintColor({ - activeColor, - theme, - }); - - const inactiveTintColor = getInactiveTintColor({ - inactiveColor, - theme, - }); - - const maxTabWidth = routes.length > 3 ? MIN_TAB_WIDTH : MAX_TAB_WIDTH; - const maxTabBarWidth = maxTabWidth * routes.length; - - const insets = { - left: safeAreaInsets?.left ?? left, - right: safeAreaInsets?.right ?? right, - bottom: safeAreaInsets?.bottom ?? bottom, - }; - - return ( - - ); -}; - -BottomNavigationBar.displayName = 'BottomNavigation.Bar'; - -export default BottomNavigationBar; - -const styles = StyleSheet.create({ - bar: { - left: 0, - right: 0, - bottom: 0, - }, - barContent: { - alignItems: 'center', - overflow: 'hidden', - }, - items: { - flexDirection: 'row', - ...(Platform.OS === 'web' - ? { - width: '100%', - } - : null), - }, - item: { - flex: 1, - // Top padding is 6 and bottom padding is 10 - // The extra 4dp bottom padding is offset by label's height - paddingVertical: 6, - }, - v3Item: { - paddingVertical: 0, - }, - iconContainer: { - height: 24, - width: 24, - marginTop: 2, - marginHorizontal: 12, - alignSelf: 'center', - }, - v3IconContainer: { - height: 32, - width: 32, - marginBottom: 4, - marginTop: 0, - justifyContent: 'center', - }, - iconWrapper: { - ...StyleSheet.absoluteFill, - alignItems: 'center', - }, - v3IconWrapper: { - top: 4, - }, - labelContainer: { - height: 16, - paddingBottom: 2, - }, - labelWrapper: { - ...StyleSheet.absoluteFill, - }, - // eslint-disable-next-line react-native/no-color-literals - label: { - fontSize: 12, - height: BAR_HEIGHT, - textAlign: 'center', - backgroundColor: 'transparent', - ...(Platform.OS === 'web' - ? { - whiteSpace: 'nowrap', - alignSelf: 'center', - } - : null), - }, - badgeContainer: { - position: 'absolute', - left: 0, - }, - v3TouchableContainer: { - paddingTop: 12, - paddingBottom: 16, - }, - v3NoLabelContainer: { - height: 80, - justifyContent: 'center', - alignItems: 'center', - }, - outline: { - width: OUTLINE_WIDTH, - height: OUTLINE_WIDTH / 2, - borderRadius: OUTLINE_WIDTH / 4, - alignSelf: 'center', - }, -}); diff --git a/src/components/NavigationBar/NavigationBar.tsx b/src/components/NavigationBar/NavigationBar.tsx new file mode 100644 index 0000000000..2ff7379aa4 --- /dev/null +++ b/src/components/NavigationBar/NavigationBar.tsx @@ -0,0 +1,889 @@ +import * as React from 'react'; +import { Platform, StyleSheet, Pressable, View } from 'react-native'; +import type { + Animated as RNAnimated, + ColorValue, + StyleProp, + TextStyle, + ViewStyle, +} from 'react-native'; + +import Animated, { + Easing, + ReduceMotion, + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { + BAR_HEIGHT, + colorRoles, + ICON_LABEL_GAP, + ICON_SIZE, + INDICATOR_BORDER_RADIUS, + INDICATOR_HEIGHT, + INDICATOR_WIDTH, + MAX_TAB_WIDTH, + MIN_TAB_WIDTH, + NO_LABEL_BAR_HEIGHT, +} from './tokens'; +import { + getActiveTintColor, + getInactiveTintColor, + getLabelColor, +} from './utils'; +import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import { toRawSpring } from '../../theme/tokens/sys/motion'; +import { getStateLayer } from '../../theme/utils/state'; +import type { Theme, ThemeProp } from '../../types'; +import useIsKeyboardShown from '../../utils/useIsKeyboardShown'; +import useLayout from '../../utils/useLayout'; +import Badge from '../Badge'; +import Icon from '../Icon'; +import type { IconSource } from '../Icon'; +import Surface from '../Surface'; +import TouchableRipple from '../TouchableRipple/TouchableRipple'; +import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple'; +import Text from '../Typography/Text'; + +export type BaseRoute = { + key: string; + title?: string; + focusedIcon?: IconSource; + unfocusedIcon?: IconSource; + badge?: string | number | boolean; + accessibilityLabel?: string; + testID?: string; + lazy?: boolean; +}; + +type NavigationState = { + index: number; + routes: Route[]; +}; + +type TabPressEvent = { + defaultPrevented: boolean; + preventDefault(): void; +}; + +type TouchableProps = TouchableRippleProps & { + key: string; + route: Route; + children: React.ReactNode; + borderless?: boolean; + centered?: boolean; + rippleColor?: ColorValue; + onHoverIn?: () => void; + onHoverOut?: () => void; + onFocus?: () => void; + onBlur?: () => void; +}; + +export type Props = { + /** + * Whether to show labels in tabs. When `false`, only icons will be displayed. + */ + labeled?: boolean; + /** + * The item layout variant of the flexible navigation bar. + * + * - `stacked` (default): the icon sits above the label. + * - `horizontal`: the icon sits beside the label and the active indicator + * hugs both. Recommended for medium-width windows (e.g. foldables and + * tablets). Has no effect when `labeled` is `false`. + */ + variant?: 'stacked' | 'horizontal'; + /** + * Whether tabs should be spread across the entire width. + */ + compact?: boolean; + /** + * State for the bottom navigation. The state should contain the following properties: + * + * - `index`: a number representing the index of the active route in the `routes` array + * - `routes`: an array containing a list of route objects used for rendering the tabs + * + * Each route object should contain the following properties: + * + * - `key`: a unique key to identify the route (required) + * - `title`: title of the route to use as the tab label + * - `focusedIcon`: icon to use as the focused tab icon, can be a string, an image source or a react component @renamed Renamed from 'icon' to 'focusedIcon' in v5.x + * - `unfocusedIcon`: icon to use as the unfocused tab icon, can be a string, an image source or a react component @supported Available in v5.x with theme version 3 + * - `badge`: badge to show on the tab icon, can be `true` to show a dot, `string` or `number` to show text. + * - `accessibilityLabel`: accessibility label for the tab button + * - `testID`: test id for the tab button + * + * Example: + * + * ```js + * { + * index: 1, + * routes: [ + * { key: 'music', title: 'Favorites', focusedIcon: 'heart', unfocusedIcon: 'heart-outline'}, + * { key: 'albums', title: 'Albums', focusedIcon: 'album' }, + * { key: 'recents', title: 'Recents', focusedIcon: 'history' }, + * { key: 'notifications', title: 'Notifications', focusedIcon: 'bell', unfocusedIcon: 'bell-outline' }, + * ] + * } + * ``` + * + * `NavigationBar` is a controlled component, which means the `index` needs to be updated via the `onTabPress` callback. + */ + navigationState: NavigationState; + /** + * Callback which returns a React Element to be used as tab icon. + */ + renderIcon?: (props: { + route: Route; + focused: boolean; + color: ColorValue; + }) => React.ReactNode; + /** + * Callback which React Element to be used as tab label. + */ + renderLabel?: (props: { + route: Route; + focused: boolean; + color: ColorValue; + }) => React.ReactNode; + /** + * Callback which returns a React element to be used as the touchable for the tab item. + * Renders a `TouchableRipple` on Android and `Pressable` on iOS. + */ + renderTouchable?: (props: TouchableProps) => React.ReactNode; + /** + * Get accessibility label for the tab button. This is read by the screen reader when the user taps the tab. + * Uses `route.accessibilityLabel` by default. + */ + getAccessibilityLabel?: (props: { route: Route }) => string | undefined; + /** + * Get badge for the tab, uses `route.badge` by default. + */ + getBadge?: (props: { route: Route }) => boolean | number | string | undefined; + /** + * Get label text for the tab, uses `route.title` by default. Use `renderLabel` to replace label component. + */ + getLabelText?: (props: { route: Route }) => string | undefined; + /** + * Get the id to locate this tab button in tests, uses `route.testID` by default. + */ + getTestID?: (props: { route: Route }) => string | undefined; + /** + * Function to execute on tab press. It receives the route for the pressed tab. Use this to update the navigation state. + */ + onTabPress: (props: { route: Route } & TabPressEvent) => void; + /** + * Function to execute on tab long press. It receives the route for the pressed tab + */ + onTabLongPress?: (props: { route: Route } & TabPressEvent) => void; + /** + * Custom color for icon and label in the active tab. + */ + activeColor?: string; + /** + * Custom color for icon and label in the inactive tab. + */ + inactiveColor?: string; + /** + * Whether the bottom navigation bar is hidden when keyboard is shown. + * On Android, this works best when [`windowSoftInputMode`](https://developer.android.com/guide/topics/manifest/activity-element#wsoft) is set to `adjustResize`. + */ + keyboardHidesNavigationBar?: boolean; + /** + * Safe area insets for the tab bar. This can be used to avoid elements like the navigation bar on Android and bottom safe area on iOS. + * The bottom insets for iOS is added by default. You can override the behavior with this option. + */ + safeAreaInsets?: { + top?: number; + right?: number; + bottom?: number; + left?: number; + }; + /** + * Specifies the largest possible scale a label font can reach. + */ + labelMaxFontSizeMultiplier?: number; + style?: RNAnimated.WithAnimatedValue>; + activeIndicatorStyle?: StyleProp; + /** + * @optional + */ + theme?: ThemeProp; + /** + * TestID used for testing purposes + */ + testID?: string; +}; + +const Touchable = ({ + route: _0, + style, + children, + borderless, + centered, + rippleColor, + ...rest +}: TouchableProps) => + TouchableRipple.supported ? ( + + {children} + + ) : ( + + {children} + + ); + +type ItemProps = { + route: Route; + focused: boolean; + labeled: boolean; + variant: 'stacked' | 'horizontal'; + activeTintColor: ColorValue; + inactiveTintColor: ColorValue; + activeColor?: string; + inactiveColor?: string; + renderIcon?: Props['renderIcon']; + renderLabel?: Props['renderLabel']; + renderTouchable: NonNullable['renderTouchable']>; + getLabelText: NonNullable['getLabelText']>; + getBadge: NonNullable['getBadge']>; + getTestID: NonNullable['getTestID']>; + getAccessibilityLabel: NonNullable['getAccessibilityLabel']>; + onPress: () => void; + onLongPress: () => void; + activeIndicatorStyle?: StyleProp; + labelMaxFontSizeMultiplier?: number; + theme: Theme; +}; + +const NavigationBarItem = ({ + route, + focused, + labeled, + variant, + activeTintColor, + inactiveTintColor, + activeColor, + inactiveColor, + renderIcon, + renderLabel, + renderTouchable, + getLabelText, + getBadge, + getTestID, + getAccessibilityLabel, + onPress, + onLongPress, + activeIndicatorStyle, + labelMaxFontSizeMultiplier = 1, + theme, +}: ItemProps) => { + const { colors } = theme; + + const [hovered, setHovered] = React.useState(false); + const [keyboardFocused, setKeyboardFocused] = React.useState(false); + const [pressed, setPressed] = React.useState(false); + + const reduceMotion = useReduceMotion(); + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + // Selection progress for the active indicator: 1 when focused, 0 otherwise. + // Each item springs its own progress from its `focused` prop, so there's no + // shared animation array to keep in sync. + const progress = useSharedValue(focused ? 1 : 0); + const isFirstRender = React.useRef(true); + + React.useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + progress.value = withSpring(focused ? 1 : 0, { + ...toRawSpring(theme.motion.spring.fast.spatial), + reduceMotion: reanimatedReduceMotion, + }); + }, [focused, progress, theme.motion, reanimatedReduceMotion]); + + // The active indicator is always mounted and cross-fades via opacity (the + // stacked layout also scales it horizontally 0.5 → 1, the horizontal layout + // scales it 0.8 → 1). + const stackedIndicatorAnimatedStyle = useAnimatedStyle(() => ({ + opacity: progress.value, + transform: [{ scaleX: 0.5 + progress.value * 0.5 }], + })); + const horizontalIndicatorAnimatedStyle = useAnimatedStyle(() => ({ + opacity: progress.value, + transform: [{ scale: 0.8 + progress.value * 0.2 }], + })); + + const iconColor = focused ? activeTintColor : inactiveTintColor; + const labelColor = getLabelColor({ + tintColor: iconColor, + hasColor: Boolean(focused ? activeColor : inactiveColor), + focused, + theme, + }); + + const badge = getBadge({ route }); + const badgeStyle = { + top: typeof badge === 'boolean' ? 4 : 2, + right: + badge != null && typeof badge !== 'boolean' + ? String(badge).length * -2 + : 0, + }; + + const font = theme.fonts.labelMedium; + + // MD3 state layer: visible on hover (8%) and focus/press (10%). Active items + // use the on-secondary-container role, inactive the on-surface-variant role. + const stateLayerRole = focused + ? colorRoles.activeIcon + : colorRoles.inactiveIcon; + const stateLayer = pressed + ? getStateLayer(theme, stateLayerRole, 'pressed') + : keyboardFocused + ? getStateLayer(theme, stateLayerRole, 'focused') + : hovered + ? getStateLayer(theme, stateLayerRole, 'hovered') + : null; + const stateLayerColor = stateLayer + ? { backgroundColor: stateLayer.color, opacity: stateLayer.opacity } + : null; + + const itemTestID = getTestID({ route }); + const indicatorTestID = itemTestID + ? `${itemTestID}-active-indicator` + : undefined; + const stateLayerTestID = itemTestID ? `${itemTestID}-state-layer` : undefined; + + // The horizontal arrangement places the label beside the icon and only + // applies when labels are shown; otherwise it falls back to stacked icon-only. + const horizontal = variant === 'horizontal' && labeled; + + // Item pieces shared across both layouts. The active/inactive distinction is + // a plain color swap (no cross-fade), so a single icon and label suffice. + const icon = renderIcon ? ( + renderIcon({ route, focused, color: iconColor }) + ) : ( + + ); + + const tabBadge = ( + + {typeof badge === 'boolean' ? ( + + ) : ( + + {badge} + + )} + + ); + + const renderTabLabel = (labelStyle: StyleProp) => + renderLabel ? ( + renderLabel({ route, focused, color: labelColor }) + ) : ( + + {getLabelText({ route })} + + ); + + const stackedContent = ( + + + + + + + {icon} + {tabBadge} + + {labeled ? ( + + {renderTabLabel(styles.label)} + + ) : null} + + ); + + const horizontalContent = ( + + + + + + {icon} + {tabBadge} + + {renderTabLabel(styles.horizontalLabel)} + + + ); + + return renderTouchable({ + key: route.key, + route, + borderless: true, + centered: true, + rippleColor: 'transparent', + onPress, + onLongPress, + onPressIn: () => setPressed(true), + onPressOut: () => setPressed(false), + onHoverIn: () => setHovered(true), + onHoverOut: () => setHovered(false), + onFocus: () => setKeyboardFocused(true), + onBlur: () => setKeyboardFocused(false), + testID: itemTestID, + accessibilityLabel: getAccessibilityLabel({ route }), + accessibilityRole: Platform.OS === 'ios' ? 'button' : 'tab', + accessibilityState: { selected: focused }, + style: styles.item, + children: horizontal ? horizontalContent : stackedContent, + }); +}; + +/** + * The Material Design 3 flexible navigation bar. It can easily be integrated + * with [React Navigation's Bottom Tabs Navigator](https://reactnavigation.org/docs/bottom-tab-navigator/). + * + * Set the `variant` prop to `'horizontal'` to lay items out horizontally + * (icon beside label) in medium-width windows. + * + * ## Usage + * ### without React Navigation + * ```js + * import * as React from 'react'; + * import { View } from 'react-native'; + * import { NavigationBar, Text, Provider } from 'react-native-paper'; + * + * function HomeScreen() { + * return ( + * + * Home! + * + * ); + * } + * + * function SettingsScreen() { + * return ( + * + * Settings! + * + * ); + * } + * + * export default function MyComponent() { + * const [index, setIndex] = React.useState(0); + * + * const routes = [ + * { key: 'home', title: 'Home', focusedIcon: 'home' }, + * { key: 'settings', title: 'Settings', focusedIcon: 'cog' }, + * ]; + * + * const renderScene = ({ route }) => { + * switch (route.key) { + * case 'home': + * return ; + * case 'settings': + * return ; + * default: + * return null; + * } + * }; + * + * return ( + * + * {renderScene({ route: routes[index] })} + * { + * const newIndex = routes.findIndex((r) => r.key === route.key); + * if (newIndex !== -1) { + * setIndex(newIndex); + * } + * }} + * getLabelText={({ route }) => route.title} + * /> + * + * ); + * } + * ``` + */ +const NavigationBar = ({ + navigationState, + renderIcon, + renderLabel, + renderTouchable = ({ key, ...props }: TouchableProps) => ( + + ), + getLabelText = ({ route }: { route: Route }) => route.title, + getBadge = ({ route }: { route: Route }) => route.badge, + getAccessibilityLabel = ({ route }: { route: Route }) => + route.accessibilityLabel, + getTestID = ({ route }: { route: Route }) => route.testID, + activeColor, + inactiveColor, + keyboardHidesNavigationBar = Platform.OS === 'android', + style, + activeIndicatorStyle, + labeled = true, + variant = 'stacked', + onTabPress, + onTabLongPress, + safeAreaInsets, + labelMaxFontSizeMultiplier = 1, + compact: compactProp, + testID = 'bottom-navigation-bar', + theme: themeOverrides, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const { colors, motion } = theme as Theme; + const { bottom, left, right } = useSafeAreaInsets(); + const compact = compactProp ?? false; + + const reduceMotion = useReduceMotion(); + const reanimatedReduceMotion = reduceMotion + ? ReduceMotion.Always + : ReduceMotion.Never; + + /** + * Visibility of the navigation bar, visible state is 1 and invisible is 0. + */ + const visible = useSharedValue(1); + + /** + * Layout of the navigation bar. + */ + const [layout, onLayout] = useLayout(); + + /** + * Track whether the keyboard is visible to show and hide the navigation bar. + */ + const [keyboardVisible, setKeyboardVisible] = React.useState(false); + + const handleKeyboardShow = React.useCallback(() => { + setKeyboardVisible(true); + visible.value = withTiming(0, { + // The bar slides out, so accelerate (exit). + duration: motion.duration.short3, + easing: Easing.bezier(...motion.easing.standardAccelerate), + reduceMotion: reanimatedReduceMotion, + }); + }, [motion, reanimatedReduceMotion, visible]); + + const handleKeyboardHide = React.useCallback(() => { + visible.value = withTiming( + 1, + { + // The bar slides back in, so decelerate (enter). + duration: motion.duration.short2, + easing: Easing.bezier(...motion.easing.standardDecelerate), + reduceMotion: reanimatedReduceMotion, + }, + (finished) => { + if (finished) { + runOnJS(setKeyboardVisible)(false); + } + } + ); + }, [motion, reanimatedReduceMotion, visible]); + + // Slide the bar down by its own height when the keyboard hides it. + const barAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: (1 - visible.value) * layout.height }], + })); + + useIsKeyboardShown({ + onShow: handleKeyboardShow, + onHide: handleKeyboardHide, + }); + + const eventForIndex = (index: number) => { + const event = { + route: navigationState.routes[index], + defaultPrevented: false, + preventDefault: () => { + event.defaultPrevented = true; + }, + }; + + return event; + }; + + const { routes } = navigationState; + + const { backgroundColor: customBackground } = (StyleSheet.flatten(style) || + {}) as { + elevation?: number; + backgroundColor?: ColorValue; + }; + + const backgroundColor = customBackground || colors.surfaceContainer; + + const activeTintColor = getActiveTintColor({ + activeColor, + theme, + }); + + const inactiveTintColor = getInactiveTintColor({ + inactiveColor, + theme, + }); + + const maxTabWidth = routes.length > 3 ? MIN_TAB_WIDTH : MAX_TAB_WIDTH; + const maxTabBarWidth = maxTabWidth * routes.length; + + const insets = { + left: safeAreaInsets?.left ?? left, + right: safeAreaInsets?.right ?? right, + bottom: safeAreaInsets?.bottom ?? bottom, + }; + + return ( + + + + + {routes.map((route, index) => { + const focused = navigationState.index === index; + + return ( + onTabPress(eventForIndex(index))} + onLongPress={() => onTabLongPress?.(eventForIndex(index))} + activeIndicatorStyle={activeIndicatorStyle} + labelMaxFontSizeMultiplier={labelMaxFontSizeMultiplier} + theme={theme as Theme} + /> + ); + })} + + + + + ); +}; + +NavigationBar.displayName = 'NavigationBar'; + +export default NavigationBar; + +const styles = StyleSheet.create({ + bar: { + left: 0, + right: 0, + bottom: 0, + }, + absolute: { + position: 'absolute', + }, + barContent: { + alignItems: 'center', + overflow: 'hidden', + }, + items: { + flexDirection: 'row', + ...(Platform.OS === 'web' + ? { + width: '100%', + } + : null), + }, + item: { + flex: 1, + paddingVertical: 0, + }, + iconContainer: { + height: INDICATOR_HEIGHT, + width: INDICATOR_HEIGHT, + marginTop: 0, + marginBottom: ICON_LABEL_GAP, + marginHorizontal: 12, + alignSelf: 'center', + justifyContent: 'center', + }, + iconWrapper: { + ...StyleSheet.absoluteFill, + top: 4, + alignItems: 'center', + }, + labelContainer: { + height: 16, + paddingBottom: 2, + }, + // eslint-disable-next-line react-native/no-color-literals + label: { + fontSize: 12, + textAlign: 'center', + backgroundColor: 'transparent', + ...(Platform.OS === 'web' + ? { + whiteSpace: 'nowrap', + alignSelf: 'center', + } + : null), + }, + badgeContainer: { + position: 'absolute', + left: 0, + }, + stackedContainer: { + height: BAR_HEIGHT, + justifyContent: 'center', + }, + noLabelContainer: { + height: NO_LABEL_BAR_HEIGHT, + justifyContent: 'center', + alignItems: 'center', + }, + stackedIndicator: { + width: INDICATOR_WIDTH, + height: INDICATOR_HEIGHT, + borderRadius: INDICATOR_BORDER_RADIUS, + alignSelf: 'center', + }, + stateLayerWrapper: { + ...StyleSheet.absoluteFill, + alignItems: 'center', + justifyContent: 'center', + }, + stateLayer: { + width: INDICATOR_WIDTH, + height: INDICATOR_HEIGHT, + borderRadius: INDICATOR_BORDER_RADIUS, + }, + horizontalContainer: { + height: BAR_HEIGHT, + justifyContent: 'center', + alignItems: 'center', + }, + horizontalItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + height: INDICATOR_HEIGHT, + paddingHorizontal: 16, + }, + horizontalIndicator: { + borderRadius: INDICATOR_BORDER_RADIUS, + }, + horizontalLabel: { + marginLeft: ICON_LABEL_GAP, + textAlign: 'center', + ...(Platform.OS === 'web' + ? { + whiteSpace: 'nowrap', + } + : null), + }, +}); diff --git a/src/components/NavigationBar/index.tsx b/src/components/NavigationBar/index.tsx new file mode 100644 index 0000000000..3567b88e01 --- /dev/null +++ b/src/components/NavigationBar/index.tsx @@ -0,0 +1,2 @@ +export { default } from './NavigationBar'; +export type { Props, BaseRoute } from './NavigationBar'; diff --git a/src/components/NavigationBar/tokens.ts b/src/components/NavigationBar/tokens.ts new file mode 100644 index 0000000000..cdcca922b3 --- /dev/null +++ b/src/components/NavigationBar/tokens.ts @@ -0,0 +1,39 @@ +import type { ColorRole, TypescaleKey } from '../../theme/types'; + +/** + * Resolved M3 "flexible navigation bar" spec values. + * @see https://m3.material.io/components/navigation-bar/specs + */ + +// Container heights (dp). The flexible navigation bar is shorter than the +// original navigation bar (which was 80dp). +export const BAR_HEIGHT = 64; +export const NO_LABEL_BAR_HEIGHT = 64; + +// Active indicator / state-layer pill (dp). Matches the M3 `large` corner (16). +export const INDICATOR_WIDTH = 64; +export const INDICATOR_HEIGHT = 32; +export const INDICATOR_BORDER_RADIUS = 16; + +// Icon + spacing (dp). +export const ICON_SIZE = 24; +export const ICON_LABEL_GAP = 4; + +// Tab width clamps used by the `compact` layout (dp). +export const MIN_TAB_WIDTH = 96; +export const MAX_TAB_WIDTH = 168; + +export const LABEL_TYPESCALE: TypescaleKey = 'labelMedium'; + +/** + * Color roles per the M3 spec. `activeLabel` is `secondary` (changed from + * `onSurface` in Material 3 / Compose Material3 1.4.0). + */ +export const colorRoles = { + container: 'surfaceContainer', + activeIcon: 'onSecondaryContainer', + activeIndicator: 'secondaryContainer', + activeLabel: 'secondary', + inactiveIcon: 'onSurfaceVariant', + inactiveLabel: 'onSurfaceVariant', +} as const satisfies Record; diff --git a/src/components/BottomNavigation/utils.ts b/src/components/NavigationBar/utils.ts similarity index 76% rename from src/components/BottomNavigation/utils.ts rename to src/components/NavigationBar/utils.ts index ede88105e4..3e10c84f5e 100644 --- a/src/components/BottomNavigation/utils.ts +++ b/src/components/NavigationBar/utils.ts @@ -1,5 +1,6 @@ import type { ColorValue } from 'react-native'; +import { colorRoles } from './tokens'; import type { InternalTheme, Theme } from '../../types'; export const getActiveTintColor = ({ @@ -13,7 +14,7 @@ export const getActiveTintColor = ({ return activeColor; } - return (theme as Theme).colors.onSecondaryContainer; + return (theme as Theme).colors[colorRoles.activeIcon]; }; export const getInactiveTintColor = ({ @@ -27,7 +28,7 @@ export const getInactiveTintColor = ({ return inactiveColor; } - return (theme as Theme).colors.onSurfaceVariant; + return (theme as Theme).colors[colorRoles.inactiveIcon]; }; export const getLabelColor = ({ @@ -46,8 +47,5 @@ export const getLabelColor = ({ return tintColor; } - if (focused) { - return colors.onSurface; - } - return colors.onSurfaceVariant; + return colors[focused ? colorRoles.activeLabel : colorRoles.inactiveLabel]; }; diff --git a/src/components/__tests__/BottomNavigation.test.tsx b/src/components/__tests__/BottomNavigation.test.tsx index 4effa0b9c0..87450d1abc 100644 --- a/src/components/__tests__/BottomNavigation.test.tsx +++ b/src/components/__tests__/BottomNavigation.test.tsx @@ -8,12 +8,13 @@ import { render } from '../../test-utils'; import { Palette } from '../../theme/tokens'; import BottomNavigation from '../BottomNavigation/BottomNavigation'; import BottomNavigationRouteScreen from '../BottomNavigation/BottomNavigationRouteScreen'; +import Icon from '../Icon'; +import NavigationBar from '../NavigationBar/NavigationBar'; import { getActiveTintColor, getInactiveTintColor, getLabelColor, -} from '../BottomNavigation/utils'; -import Icon from '../Icon'; +} from '../NavigationBar/utils'; const styles = StyleSheet.create({ backgroundColor: { @@ -33,10 +34,9 @@ const createState = (index: number, length: number) => ({ })), }); -it('renders shifting bottom navigation', () => { +it('renders bottom navigation', () => { const tree = render( route.title} @@ -49,7 +49,6 @@ it('renders shifting bottom navigation', () => { it('renders bottom navigation with scene animation', () => { const tree = render( { const tree = render( { const onIndexChange = jest.fn(); const tree = render( route.title} /> ); + // The active scene also renders the route title, so target the last match + // (the tab label) to fire the tab press. // pressing same index as active navigation state does not call onIndexChange - fireEvent(tree.getByText('Route: 0'), 'onPress'); + fireEvent(tree.getAllByText('Route: 0').at(-1)!, 'onPress'); expect(onIndexChange).not.toHaveBeenCalled(); - fireEvent(tree.getByText('Route: 1'), 'onPress'); + fireEvent(tree.getAllByText('Route: 1').at(-1)!, 'onPress'); expect(onIndexChange).toHaveBeenCalledTimes(1); }); @@ -151,14 +150,13 @@ it('calls onTabPress', () => { const tree = render( route.title} /> ); - fireEvent(tree.getByText('Route: 1'), 'onPress'); + fireEvent(tree.getAllByText('Route: 1').at(-1)!, 'onPress'); expect(onTabPress).toHaveBeenCalled(); expect(onTabPress).toHaveBeenCalledTimes(1); expect(onTabPress).toHaveBeenLastCalledWith( @@ -178,14 +176,13 @@ it('calls onTabLongPress', () => { const tree = render( route.title} /> ); - fireEvent(tree.getByText('Route: 2'), 'onLongPress'); + fireEvent(tree.getAllByText('Route: 2').at(-1)!, 'onLongPress'); expect(onTabLongPress).toHaveBeenCalled(); expect(onTabLongPress).toHaveBeenCalledTimes(1); expect(onTabLongPress).toHaveBeenLastCalledWith( @@ -199,10 +196,9 @@ it('calls onTabLongPress', () => { ); }); -it('renders non-shifting bottom navigation', () => { +it('renders bottom navigation with three tabs', () => { const tree = render( route.title} @@ -212,29 +208,9 @@ it('renders non-shifting bottom navigation', () => { expect(tree).toMatchSnapshot(); }); -it('does not crash when shifting is true and the number of tabs in the navigationState is less than 2', () => { - jest.spyOn(console, 'warn').mockImplementation(() => {}); - - render( - route.title} - /> - ); - - expect(console.warn).toHaveBeenCalledWith( - 'BottomNavigation needs at least 2 tabs to run shifting animation' - ); - - jest.restoreAllMocks(); -}); - -it('renders custom icon and label in shifting bottom navigation', () => { +it('renders custom icon and label', () => { const tree = render( route.title} @@ -252,46 +228,9 @@ it('renders custom icon and label in shifting bottom navigation', () => { expect(tree).toMatchSnapshot(); }); -it('renders custom icon and label in non-shifting bottom navigation', () => { - const tree = render( - route.title} - renderIcon={({ route, color }) => ( - - )} - renderLabel={({ route, color }) => ( - - {route.title} - - )} - /> - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders custom icon and label with custom colors in shifting bottom navigation', () => { - const tree = render( - route.title} - activeColor="#FBF7DB" - inactiveColor="#853D4B" - /> - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('renders custom icon and label with custom colors in non-shifting bottom navigation', () => { +it('renders with custom active and inactive colors', () => { const tree = render( route.title} @@ -303,24 +242,9 @@ it('renders custom icon and label with custom colors in non-shifting bottom navi expect(tree).toMatchSnapshot(); }); -it('hides labels in shifting bottom navigation', () => { +it('hides labels when labeled is false', () => { const tree = render( route.title} - /> - ).toJSON(); - - expect(tree).toMatchSnapshot(); -}); - -it('hides labels in non-shifting bottom navigation', () => { - const tree = render( - { const labelMaxFontSizeMultiplier = 2; const { getAllByText } = render( { it('renders custom background color passed to barStyle property', () => { const { getByTestId } = render( { it('renders a single tab', () => { const { queryByTestId } = render( route.title} @@ -420,7 +341,6 @@ it('applies maxTabBarWidth styling if compact prop is truthy', () => { onIndexChange={jest.fn()} renderScene={({ route }) => route.title} getLazy={({ route }) => route.key === 'key-2'} - shifting={false} testID="bottom-navigation" compact /> @@ -438,7 +358,6 @@ it('does not apply maxTabBarWidth styling if compact prop is falsy', () => { onIndexChange={jest.fn()} renderScene={({ route }) => route.title} getLazy={({ route }) => route.key === 'key-2'} - shifting={false} testID="bottom-navigation" compact={false} /> @@ -449,7 +368,7 @@ it('does not apply maxTabBarWidth styling if compact prop is falsy', () => { }); }); -it('renders bar content when shifting is enabled', () => { +it('renders bar content', () => { const { getByTestId } = render( { renderScene={({ route }) => route.title} getLazy={({ route }) => route.key === 'key-2'} testID="bottom-navigation" - shifting /> ); expect(getByTestId('bottom-navigation-bar-content')).toBeDefined(); }); -it('does not render legacy ripple overlay when shifting is disabled', () => { +it('does not render the legacy ripple overlay', () => { const { queryByTestId } = render( { renderScene={({ route }) => route.title} getLazy={({ route }) => route.key === 'key-2'} testID="bottom-navigation" - shifting={false} /> ); expect(queryByTestId('bottom-navigation-bar-content-ripple')).toBeNull(); }); +it('renders tab labels when labeled', () => { + const { getAllByText } = render( + + ); + + // Each tab renders a single label (no cross-fade layers). + expect(getAllByText('Alpha').length).toBeGreaterThan(0); + expect(getAllByText('Beta').length).toBeGreaterThan(0); +}); + +it('renders the horizontal (flexible) variant', () => { + const tree = render( + + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('falls back to icon-only when horizontal is combined with labeled=false', () => { + const { queryByText } = render( + + ); + + // `horizontal` is a no-op without labels, so no label text is rendered. + expect(queryByText('Route: 0')).toBeNull(); + expect(queryByText('Route: 1')).toBeNull(); +}); + +it('renders MD3 state layers on hover, focus and press', () => { + const navigationState = { + index: 0, + routes: [ + { key: 'a', title: 'Route: 0', focusedIcon: 'magnify', testID: 'tab-a' }, + { key: 'b', title: 'Route: 1', focusedIcon: 'camera', testID: 'tab-b' }, + ], + }; + + const { getByTestId } = render( + + ); + + const layerOpacity = () => + StyleSheet.flatten(getByTestId('tab-b-state-layer').props.style).opacity; + + // Idle: no visible state layer. + expect(layerOpacity()).toBeUndefined(); + + // Hovered: 8% state layer. + fireEvent(getByTestId('tab-b'), 'hoverIn'); + expect(layerOpacity()).toBe(0.08); + fireEvent(getByTestId('tab-b'), 'hoverOut'); + expect(layerOpacity()).toBeUndefined(); + + // Focused: 10% state layer. + fireEvent(getByTestId('tab-b'), 'focus'); + expect(layerOpacity()).toBe(0.1); + fireEvent(getByTestId('tab-b'), 'blur'); + + // Pressed: 10% state layer. + fireEvent(getByTestId('tab-b'), 'pressIn'); + expect(layerOpacity()).toBe(0.1); + fireEvent(getByTestId('tab-b'), 'pressOut'); + expect(layerOpacity()).toBeUndefined(); +}); + +it('colors the focused tab label with secondary and others with onSurfaceVariant', () => { + const navigationState = { + index: 0, + routes: [ + { key: 'a', title: 'Alpha', focusedIcon: 'magnify' }, + { key: 'b', title: 'Beta', focusedIcon: 'camera' }, + ], + }; + + const { getAllByText } = render( + + ); + + const colorsOf = (text: string) => + getAllByText(text).map( + (node) => StyleSheet.flatten(node.props.style).color + ); + + expect(colorsOf('Alpha')).toContain(getTheme().colors.secondary); + expect(colorsOf('Beta')).toContain(getTheme().colors.onSurfaceVariant); +}); + +it('renders the active indicator with the secondaryContainer color', () => { + const navigationState = { + index: 0, + routes: [ + { key: 'a', title: 'Alpha', focusedIcon: 'magnify', testID: 'tab-a' }, + { key: 'b', title: 'Beta', focusedIcon: 'camera', testID: 'tab-b' }, + ], + }; + + const { getByTestId } = render( + + ); + + expect( + StyleSheet.flatten(getByTestId('tab-a-active-indicator').props.style) + .backgroundColor + ).toBe(getTheme().colors.secondaryContainer); +}); + +it('renders a badge for routes that define one', () => { + const navigationState = { + index: 0, + routes: [ + { key: 'a', title: 'Alpha', focusedIcon: 'magnify', badge: 3 }, + { key: 'b', title: 'Beta', focusedIcon: 'camera' }, + ], + }; + + const { getByText } = render( + + ); + + expect(getByText('3')).toBeTruthy(); +}); + describe('getActiveTintColor', () => { it.each` activeColor | expected @@ -516,7 +573,7 @@ describe('getLabelColor', () => { it.each([ { tintColor: '#FBF7DB', focused: true, expected: '#FBF7DB' }, { tintColor: '#853D4B', focused: true, expected: '#853D4B' }, - { tintColor: undefined, focused: true, expected: Palette.neutral10 }, + { tintColor: undefined, focused: true, expected: Palette.secondary40 }, { tintColor: undefined, focused: false, diff --git a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap index 7b5d8ad45e..5b8c5cbaeb 100644 --- a/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/BottomNavigation.test.tsx.snap @@ -71,32 +71,25 @@ exports[`allows customizing Route's type via generics 1`] = ` - - + - + - + + + + + + - - + + + + + + + + + + + + + + @@ -384,11 +552,10 @@ exports[`allows customizing Route's type via generics 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { - "color": "rgba(29, 27, 32, 1)", + "color": "rgba(73, 69, 79, 1)", "fontFamily": "System", "fontSize": 12, "fontWeight": "500", @@ -400,261 +567,7 @@ exports[`allows customizing Route's type via generics 1`] = ` ] } > - First - - - - - - - - - - - - - - - - - - Second - - - - - Second + Second @@ -667,7 +580,7 @@ exports[`allows customizing Route's type via generics 1`] = ` `; -exports[`hides labels in non-shifting bottom navigation 1`] = ` +exports[`hides labels when labeled is false 1`] = ` - - - magnify - - - - + + - magnify - - - + + - + > + + magnify + + + + + - - - - + + + + + + camera + + + - camera - + + + + + + - - camera - - - + - - - - - - - - - - + + + - inbox - - - - + inbox + + + - inbox - - - - + /> + @@ -1408,7 +1315,7 @@ exports[`hides labels in non-shifting bottom navigation 1`] = ` `; -exports[`hides labels in shifting bottom navigation 1`] = ` +exports[`renders bottom navigation 1`] = ` - - + + + + + + magnify + + + - magnify - + + - magnify + Route: 0 - - - - - - - + + + + + + camera + + + - camera - + + - camera + Route: 1 - - - - - - - + + + + + + inbox + + + - inbox - + + - inbox + Route: 2 + + + + + + + + + + + heart + + + + + + + + Route: 3 + + + + + + + + + + > + + + + + shopping-music + + + + + + + + + Route: 4 + @@ -2352,32 +2884,25 @@ exports[`renders bottom navigation with getLazy 1`] = ` - - + + + + + + magnify + + + - magnify - + + - magnify + Route: 0 - - - + + - + + + + + + camera + + + - Route: 0 - + + @@ -2725,11 +3425,10 @@ exports[`renders bottom navigation with getLazy 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { - "color": "rgba(29, 27, 32, 1)", + "color": "rgba(73, 69, 79, 1)", "fontFamily": "System", "fontSize": 12, "fontWeight": "500", @@ -2741,277 +3440,448 @@ exports[`renders bottom navigation with getLazy 1`] = ` ] } > - Route: 0 + Route: 1 - - - - + + + + + + inbox + + + - camera - + + - camera + Route: 2 - - - + + - + + + + + + heart + + + - Route: 1 - + + @@ -3039,7 +3909,6 @@ exports[`renders bottom navigation with getLazy 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -3055,277 +3924,206 @@ exports[`renders bottom navigation with getLazy 1`] = ` ] } > - Route: 1 + Route: 3 - - - - - inbox - - - - + + - inbox - - - + + - - - - - - + + shopping-music + + + - Route: 2 - + + @@ -3353,7 +4151,6 @@ exports[`renders bottom navigation with getLazy 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -3369,277 +4166,362 @@ exports[`renders bottom navigation with getLazy 1`] = ` ] } > - Route: 2 + Route: 4 - + + + + +`; + +exports[`renders bottom navigation with scene animation 1`] = ` + + + + + Route: 0 + + + + + + + + - - heart - - - - + + - heart - - - + + - - - - - - + + magnify + + + - Route: 3 - + + @@ -3667,11 +4549,10 @@ exports[`renders bottom navigation with getLazy 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { - "color": "rgba(73, 69, 79, 1)", + "color": "rgba(98, 91, 113, 1)", "fontFamily": "System", "fontSize": 12, "fontWeight": "500", @@ -3683,277 +4564,448 @@ exports[`renders bottom navigation with getLazy 1`] = ` ] } > - Route: 3 + Route: 0 - - - - + + + + + + camera + + + - shopping-music - + + - shopping-music + Route: 1 - - - + + - + + + + + + inbox + + + - Route: 4 - + + @@ -3981,7 +5033,6 @@ exports[`renders bottom navigation with getLazy 1`] = ` { "backgroundColor": "transparent", "fontSize": 12, - "height": 56, "textAlign": "center", }, { @@ -3997,4662 +5048,454 @@ exports[`renders bottom navigation with getLazy 1`] = ` ] } > - Route: 4 + Route: 2 - - - - - - -`; - -exports[`renders bottom navigation with scene animation 1`] = ` - - - - - Route: 0 - - - - - - - - - - - - - - magnify - - - - - magnify - - - - - - - - - - Route: 0 - - - - - - - - - - - camera - - - - - camera - - - - - - - - - - Route: 1 - - - - - - - - - - - inbox - - - - - inbox - - - - - - - - - - Route: 2 - - - - - - - - - - - heart - - - - - heart - - - - - - - - - - Route: 3 - - - - - - - - - - - shopping-music - - - - - shopping-music - - - - - - - - - - Route: 4 - - - - - - - - - - -`; - -exports[`renders custom icon and label in non-shifting bottom navigation 1`] = ` - - - - - Route: 0 - - - - - - - - - - - - - - - - - - - - - Route: 0 - - - - - Route: 0 - - - - - - - - - - - - - - - - - - Route: 1 - - - - - Route: 1 - - - - - - - - - - - - - - - - - - Route: 2 - - - - - Route: 2 - - - - - - - - - - -`; - -exports[`renders custom icon and label in shifting bottom navigation 1`] = ` - - - - - Route: 0 - - - - - - - - - - - - - - - - - - - - - Route: 0 - - - - - - - - - - - - - - - - - - Route: 1 - - - - - - - - - - - - - - - - - - Route: 2 - - - - - - - - - - - - - - - - - - Route: 3 - - - - - - - - - - - - - - - - - - Route: 4 - - - - - - - - - - -`; - -exports[`renders custom icon and label with custom colors in non-shifting bottom navigation 1`] = ` - - - - - Route: 0 - - - - - - - - - - - - - - magnify - - - - - magnify - - - - - - - - - - Route: 0 - - - - - Route: 0 - - - - - - - - - - - camera - - - - - camera - - - - - - - - - - Route: 1 - - - - - Route: 1 - - - - - - - - - - - inbox - - - - - inbox - - - - - - - - - - Route: 2 - - - - - Route: 2 - - - - - - - - - - -`; - -exports[`renders custom icon and label with custom colors in shifting bottom navigation 1`] = ` - - - - - Route: 0 - - - - - - - - - - - + + + + + + heart + + + - magnify - + + - magnify + Route: 3 + + + + + + - + + + + > + + shopping-music + + + + + - - - Route: 0 + Route: 4 - + + + + +`; + +exports[`renders bottom navigation with three tabs 1`] = ` + + + + + Route: 0 + + + + + + + + - - camera - - - - + - camera - - - + - + + + + > + + magnify + + + + + - - - Route: 1 + Route: 0 - - - - + + + + + + camera + + + + + + + + - inbox + Route: 1 + + + + - - inbox - - - + - + + + + > + + inbox + + + + + - - `; -exports[`renders non-shifting bottom navigation 1`] = ` +exports[`renders custom icon and label 1`] = ` - - - magnify - - - - - magnify - - - + - + + + - - - - - - Route: 0 - + + - Route: 0 - + - - - - + - camera - - - + + - + - camera - + + - + + Route: 1 + + + - + - Route: 1 - - - + + - + - Route: 1 - + + + + + + Route: 2 + - - - - + - inbox - - - + + - + - inbox - + + - + } + > + + Route: 3 + + + - + - Route: 2 - - - + + - + - Route: 2 - + + + + + + Route: 4 + @@ -10330,84 +7463,27 @@ exports[`renders non-shifting bottom navigation 1`] = ` `; -exports[`renders shifting bottom navigation 1`] = ` +exports[`renders the horizontal (flexible) variant 1`] = ` - - - - Route: 0 - - - @@ -10473,7 +7550,221 @@ exports[`renders shifting bottom navigation 1`] = ` "checked": undefined, "disabled": undefined, "expanded": undefined, - "selected": true, + "selected": true, + } + } + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + { + "flex": 1, + "paddingVertical": 0, + } + } + > + + + + + + + magnify + + + + + + + Route: 0 + + + + + + /> + - magnify + camera - - - - magnify - + + - - - - - - - - Route: 0 - - + ], + ] + } + > + Route: 1 + @@ -10777,59 +8002,79 @@ exports[`renders shifting bottom navigation 1`] = ` onResponderTerminationRequest={[Function]} onStartShouldSetResponder={[Function]} style={ - [ - { - "flex": 1, - "paddingVertical": 6, - }, - { - "paddingVertical": 0, - }, - ] + { + "flex": 1, + "paddingVertical": 0, + } } > + /> + + - camera + inbox - - - - camera - + + - - - - - - - - Route: 1 - - + ], + ] + } + > + Route: 2 + - + + + + +`; + +exports[`renders with custom active and inactive colors 1`] = ` + + + + + Route: 0 + + + + + + + + - - inbox - - - - + + - inbox - - - + + - + > + + magnify + + + + + - - - Route: 2 + Route: 0 - - - - - heart - - - - + - heart - - - + - + + + + > + + camera + + + + + - - - Route: 3 + Route: 1 - - - - - shopping-music - - - - + + - shopping-music - - - + + - + > + + inbox + + + + + - - - Route: 4 + Route: 2 diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..3bc5390de0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -37,6 +37,7 @@ export { default as Icon } from './components/Icon'; export { default as IconButton } from './components/IconButton/IconButton'; export { default as Menu } from './components/Menu/Menu'; export { default as Modal } from './components/Modal'; +export { default as NavigationBar } from './components/NavigationBar'; export { default as Portal } from './components/Portal/Portal'; export { default as ProgressBar } from './components/ProgressBar'; export { default as RadioButton } from './components/RadioButton'; @@ -115,6 +116,10 @@ export type { Props as ListSubheaderProps } from './components/List/ListSubheade export type { Props as MenuProps } from './components/Menu/Menu'; export type { Props as MenuItemProps } from './components/Menu/MenuItem'; export type { Props as ModalProps } from './components/Modal'; +export type { + Props as NavigationBarProps, + BaseRoute as NavigationBarRoute, +} from './components/NavigationBar'; export type { Props as PortalProps } from './components/Portal/Portal'; export type { Props as PortalHostProps } from './components/Portal/PortalHost'; export type { Props as ProgressBarProps } from './components/ProgressBar';