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';