From 647a99ac521119af3bf0a92bf5349aab47ea398d Mon Sep 17 00:00:00 2001 From: hazrid93 Date: Mon, 20 Jul 2026 10:28:09 +0000 Subject: [PATCH] feat: add NavigationRail component Closes #4247. Also references #4982. Adds a Material 3 navigation rail for tablet and desktop layouts. Takes a controlled active destination and a list of destinations, each with a key, an optional label, a focused icon, an optional unfocused icon, an optional badge, and an optional accessibility label. Renders each destination as a pressable item that shows the focused icon when active and the unfocused icon otherwise, calls a change callback with the destination key, and highlights the active destination with an indicator pill. Supports showing labels for all, only the active, or no destinations, top center or bottom alignment of the destinations, and an optional header slot. The component and its public types are exported from the public surface. --- .../NavigationRail/NavigationRail.tsx | 249 ++++++++++++++++++ src/index.tsx | 7 + 2 files changed, 256 insertions(+) create mode 100644 src/components/NavigationRail/NavigationRail.tsx diff --git a/src/components/NavigationRail/NavigationRail.tsx b/src/components/NavigationRail/NavigationRail.tsx new file mode 100644 index 0000000000..49aa749e74 --- /dev/null +++ b/src/components/NavigationRail/NavigationRail.tsx @@ -0,0 +1,249 @@ +import * as React from 'react'; +import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; + +import { useInternalTheme } from '../../core/theming'; +import type { ThemeProp } from '../../types'; +import Badge from '../Badge'; +import Icon, { IconSource } from '../Icon'; +import TouchableRipple from '../TouchableRipple/TouchableRipple'; +import Text from '../Typography/Text'; + +export type NavigationRailLabelType = 'all' | 'selected' | 'none'; + +export type NavigationRailAlignment = 'top' | 'center' | 'bottom'; + +export type NavigationRailDestination = { + /** + * Unique key for the destination, used to determine the active item. + */ + key: string; + /** + * Text label shown under the icon (subject to the `labeled` prop). + */ + label?: string; + /** + * Icon shown when the destination is active. + */ + focusedIcon: IconSource; + /** + * Icon shown when the destination is inactive. Falls back to `focusedIcon`. + */ + unfocusedIcon?: IconSource; + /** + * Badge to show on the destination. `true` renders a small dot. + */ + badge?: string | number | boolean; + /** + * Accessibility label for the destination. Falls back to `label`. + */ + accessibilityLabel?: string; + /** + * testID used to build the destination's test identifiers. + */ + testID?: string; +}; + +export type Props = { + /** + * Destinations rendered by the rail (3-7 recommended by Material 3). + */ + destinations: NavigationRailDestination[]; + /** + * Key of the currently active destination (controlled). + */ + activeKey: string; + /** + * Callback fired with the destination key when a destination is pressed. + */ + onChange: (key: string) => void; + /** + * When to show destination labels: always (`all`), only the active one + * (`selected`), or never (`none`). + * + * @default 'all' + */ + labeled?: NavigationRailLabelType; + /** + * Vertical alignment of the destinations group within the rail. + * + * @default 'top' + */ + alignment?: NavigationRailAlignment; + /** + * Optional header rendered above the destinations, typically a menu button + * or a floating action button. + */ + header?: React.ReactNode; + style?: StyleProp; + testID?: string; + theme?: ThemeProp; +}; + +const alignmentToJustify = { + top: 'flex-start', + center: 'center', + bottom: 'flex-end', +} as const; + +/** + * A vertical Material 3 navigation rail for tablet and desktop layouts. It + * shows 3-7 destinations along the side of an app, each with an icon, an + * optional label, and an optional badge, plus an optional header slot. + */ +const NavigationRail = ({ + destinations, + activeKey, + onChange, + labeled = 'all', + alignment = 'top', + header, + style, + testID = 'navigation-rail', + theme: themeOverrides, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const { colors } = theme; + + const activeColor = theme.isV3 + ? theme.colors.onSecondaryContainer + : colors.primary; + const inactiveColor = theme.isV3 + ? theme.colors.onSurfaceVariant + : colors.onSurface; + const indicatorColor = theme.isV3 + ? theme.colors.secondaryContainer + : colors.primary; + + return ( + + {header ? ( + + {header} + + ) : null} + + {destinations.map((destination) => { + const focused = destination.key === activeKey; + const color = focused ? activeColor : inactiveColor; + const itemTestID = + destination.testID ?? `${testID}-${destination.key}`; + const showLabel = + labeled === 'all' || (labeled === 'selected' && focused); + const icon = + !focused && destination.unfocusedIcon + ? destination.unfocusedIcon + : destination.focusedIcon; + const hasBadge = + destination.badge !== undefined && destination.badge !== false; + + return ( + onChange(destination.key)} + accessibilityRole="menuitem" + accessibilityState={{ selected: focused }} + accessibilityLabel={ + destination.accessibilityLabel ?? destination.label + } + borderless + style={styles.item} + > + + + + {hasBadge ? ( + + {typeof destination.badge === 'boolean' + ? undefined + : destination.badge} + + ) : null} + + {showLabel && destination.label ? ( + + {destination.label} + + ) : null} + + + ); + })} + + + ); +}; + +const styles = StyleSheet.create({ + rail: { + width: 80, + flexDirection: 'column', + alignItems: 'center', + paddingVertical: 8, + }, + header: { + alignItems: 'center', + marginBottom: 8, + }, + destinations: { + flex: 1, + width: '100%', + alignItems: 'center', + }, + item: { + width: '100%', + alignItems: 'center', + paddingVertical: 6, + }, + itemContent: { + alignItems: 'center', + }, + indicator: { + width: 56, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, + badge: { + position: 'absolute', + top: 2, + right: 12, + }, + label: { + marginTop: 4, + textAlign: 'center', + }, +}); + +export default NavigationRail; diff --git a/src/index.tsx b/src/index.tsx index 1b20528787..7ba4e07626 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,6 +30,7 @@ export { default as Badge } from './components/Badge'; export { default as ActivityIndicator } from './components/ActivityIndicator'; export { default as Banner } from './components/Banner'; export { default as BottomNavigation } from './components/BottomNavigation/BottomNavigation'; +export { default as NavigationRail } from './components/NavigationRail/NavigationRail'; export { default as Button } from './components/Button/Button'; export { default as Card } from './components/Card/Card'; export { default as Checkbox } from './components/Checkbox'; @@ -84,6 +85,12 @@ export type { Props as BottomNavigationProps, BaseRoute as BottomNavigationRoute, } from './components/BottomNavigation/BottomNavigation'; +export type { + Props as NavigationRailProps, + NavigationRailDestination, + NavigationRailLabelType, + NavigationRailAlignment, +} from './components/NavigationRail/NavigationRail'; export type { Props as ButtonProps } from './components/Button/Button'; export type { Props as CardProps } from './components/Card/Card'; export type { Props as CardActionsProps } from './components/Card/CardActions';