From 520991219b58a63525ec93e5fb39be121954d930 Mon Sep 17 00:00:00 2001 From: marius-ck Date: Thu, 3 Sep 2026 13:18:15 +0300 Subject: [PATCH 1/4] feat: add navigation rail --- docs/component-docs.config.ts | 5 + docs/src/data/themeColors.ts | 22 ++ example/src/ExampleList.tsx | 2 + .../src/Examples/NavigationRailExample.tsx | 160 ++++++++ .../NavigationRail/NavigationRail.tsx | 227 +++++++++++ .../NavigationRail/NavigationRailItem.tsx | 354 ++++++++++++++++++ .../NavigationRail/NavigationRailModal.tsx | 148 ++++++++ src/components/NavigationRail/context.ts | 7 + src/components/NavigationRail/index.ts | 16 + src/components/NavigationRail/tokens.ts | 60 +++ src/components/NavigationRail/utils.ts | 46 +++ src/index.tsx | 4 + 12 files changed, 1051 insertions(+) create mode 100644 example/src/Examples/NavigationRailExample.tsx create mode 100644 src/components/NavigationRail/NavigationRail.tsx create mode 100644 src/components/NavigationRail/NavigationRailItem.tsx create mode 100644 src/components/NavigationRail/NavigationRailModal.tsx create mode 100644 src/components/NavigationRail/context.ts create mode 100644 src/components/NavigationRail/index.ts create mode 100644 src/components/NavigationRail/tokens.ts create mode 100644 src/components/NavigationRail/utils.ts diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts index 4196e8e7bd..8276ad46b6 100644 --- a/docs/component-docs.config.ts +++ b/docs/component-docs.config.ts @@ -115,6 +115,11 @@ const pages = { MenuItem: 'Menu/MenuItem', }, Modal: 'Modal', + NavigationRail: { + NavigationRail: 'NavigationRail/NavigationRail', + NavigationRailItem: 'NavigationRail/NavigationRailItem', + NavigationRailModal: 'NavigationRail/NavigationRailModal', + }, Portal: { Portal: { source: 'Portal/Portal', diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts index 20f16962f7..9702e70af6 100644 --- a/docs/src/data/themeColors.ts +++ b/docs/src/data/themeColors.ts @@ -264,6 +264,28 @@ export const themeColors = { backgroundColor: 'theme.colors.backdrop', }, }, + NavigationRail: { + '-': { + backgroundColor: 'theme.colors.surface', + }, + }, + 'NavigationRail.Item': { + active: { + indicatorColor: 'theme.colors.secondaryContainer', + iconColor: 'theme.colors.onSecondaryContainer', + 'textColor (collapsed)': 'theme.colors.secondary', + 'textColor (expanded)': 'theme.colors.onSecondaryContainer', + }, + inactive: { + 'iconColor/textColor': 'theme.colors.onSurfaceVariant', + }, + }, + 'NavigationRail.Modal': { + '-': { + backgroundColor: 'theme.colors.surfaceContainer', + scrimColor: 'theme.colors.scrim', + }, + }, ProgressBar: { '-': { tintColor: 'theme.colors.primary', diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index 8a2646d798..c28e18c479 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 NavigationRailExample from './Examples/NavigationRailExample'; 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, + NavigationRail: NavigationRailExample, Progressbar: ProgressBarExample, Radio: RadioButtonExample, RadioGroup: RadioButtonGroupExample, diff --git a/example/src/Examples/NavigationRailExample.tsx b/example/src/Examples/NavigationRailExample.tsx new file mode 100644 index 0000000000..55116150b8 --- /dev/null +++ b/example/src/Examples/NavigationRailExample.tsx @@ -0,0 +1,160 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { + Button, + Chip, + FAB, + IconButton, + List, + NavigationRail, + Portal, + Switch, + Text, + useTheme, +} from 'react-native-paper'; +import type { NavigationRailProps } from 'react-native-paper'; + +type Alignment = NonNullable; + +const destinations = [ + { key: 'inbox', label: 'Inbox', icon: 'inbox-outline', activeIcon: 'inbox' }, + { + key: 'starred', + label: 'Starred', + icon: 'star-outline', + activeIcon: 'star', + }, + { key: 'sent', label: 'Sent', icon: 'send-outline', activeIcon: 'send' }, + { + key: 'drafts', + label: 'Drafts', + icon: 'file-outline', + activeIcon: 'file', + }, + { + key: 'trash', + label: 'Trash', + icon: 'delete-outline', + activeIcon: 'delete', + }, +]; + +const alignments: Alignment[] = ['top', 'center', 'bottom']; + +const NavigationRailExample = () => { + const { colors } = useTheme(); + const [active, setActive] = React.useState('inbox'); + const [expanded, setExpanded] = React.useState(false); + const [alignment, setAlignment] = React.useState('top'); + const [labeled, setLabeled] = React.useState(true); + const [modalVisible, setModalVisible] = React.useState(false); + + const renderItems = () => + destinations.map(({ key, label, ...rest }) => ( + setActive(key)} + /> + )); + + return ( + + + setExpanded((value) => !value)} + /> + {}} /> + + } + > + {renderItems()} + + + + {active} + ( + + + + )} + onPress={() => setExpanded((value) => !value)} + /> + ( + + + + )} + onPress={() => setLabeled((value) => !value)} + /> + + {alignments.map((option) => ( + setAlignment(option)} + > + {option} + + ))} + + + + + + setModalVisible(false)} + alignment={alignment} + header={ + setModalVisible(false)} + /> + } + > + {renderItems()} + + + + ); +}; + +NavigationRailExample.title = 'Navigation Rail'; + +const styles = StyleSheet.create({ + screen: { + flex: 1, + flexDirection: 'row', + }, + content: { + flex: 1, + padding: 16, + gap: 16, + }, + chips: { + flexDirection: 'row', + gap: 8, + }, +}); + +export default NavigationRailExample; diff --git a/src/components/NavigationRail/NavigationRail.tsx b/src/components/NavigationRail/NavigationRail.tsx new file mode 100644 index 0000000000..a6e10848f0 --- /dev/null +++ b/src/components/NavigationRail/NavigationRail.tsx @@ -0,0 +1,227 @@ +import * as React from 'react'; +import { ScrollView, StyleSheet, View } from 'react-native'; +import type { ColorValue, StyleProp, ViewStyle } from 'react-native'; + +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; +import type { AnimatedStyle } from 'react-native-reanimated'; + +import { ExpandedContext } from './context'; +import { NavigationRailTokens } from './tokens'; +import type { Alignment } from './tokens'; +import { clampExpandedWidth } from './utils'; +import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import { toRawSpring } from '../../theme/tokens/sys/motion'; +import type { ThemeProp } from '../../types'; + +export type Props = { + /** + * Navigation destinations, typically `NavigationRail.Item` elements. + */ + children: React.ReactNode; + /** + * Whether the rail is expanded (icon + label rows, 220–360dp wide) or + * collapsed (stacked icon + label, 96dp wide). The width animates on change. + */ + expanded?: boolean; + /** + * Width of the expanded rail. Clamped to the spec range of 220–360dp. + */ + expandedWidth?: number; + /** + * Vertical placement of the destinations. Defaults to `top`. + */ + alignment?: Alignment; + /** + * Content pinned above the destinations, e.g. a menu button and a `FAB`. + */ + header?: React.ReactNode; + /** + * Container color override. Defaults to `theme.colors.surface`. + */ + containerColor?: ColorValue; + style?: StyleProp>; + /** + * TestID used for testing purposes. + */ + testID?: string; + /** + * @optional + */ + theme?: ThemeProp; +}; + +const { rail, colors } = NavigationRailTokens; + +const justifyContent = { + top: 'flex-start', + center: 'center', + bottom: 'flex-end', +} as const satisfies Record; + +/** + * Navigation rails let people switch between UI views on mid-sized devices. + * The rail is placed at the start edge of the screen and can be collapsed + * (icons with short labels) or expanded (icons with full labels). + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { StyleSheet, View } from 'react-native'; + * import { FAB, IconButton, NavigationRail } from 'react-native-paper'; + * + * const MyComponent = () => { + * const [expanded, setExpanded] = React.useState(false); + * const [active, setActive] = React.useState('inbox'); + * + * return ( + * + * + * setExpanded((e) => !e)} /> + * {}} /> + * + * } + * > + * setActive('inbox')} + * /> + * setActive('sent')} + * /> + * + * + * ); + * }; + * + * const styles = StyleSheet.create({ + * screen: { flex: 1, flexDirection: 'row' }, + * }); + * + * export default MyComponent; + * ``` + * + * ## Theming + * Customize by overriding these `theme.colors` roles: + * - `surface`: container + * - `secondaryContainer` / `onSecondaryContainer`: active indicator / active icon + * - `secondary`: active label (collapsed), focus indicator + * - `onSurfaceVariant`: inactive icon and label + */ +const NavigationRail = ({ + children, + expanded = false, + expandedWidth = rail.expandedMinWidth, + alignment = 'top', + header, + containerColor, + style, + testID = 'navigation-rail', + theme: themeOverrides, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); + + const targetWidth = expanded + ? clampExpandedWidth(expandedWidth) + : rail.collapsedWidth; + const width = useSharedValue(targetWidth); + + React.useEffect(() => { + width.value = reduceMotion + ? targetWidth + : withSpring( + targetWidth, + toRawSpring(theme.motion.spring.default.spatial) + ); + }, [targetWidth, reduceMotion, theme, width]); + + const widthStyle = useAnimatedStyle(() => ({ width: width.value })); + + return ( + + + {header ? ( + + {header} + + ) : null} + + + {children} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + height: '100%', + overflow: 'hidden', + }, + content: { + flex: 1, + paddingTop: rail.topSpace, + }, + header: { + alignItems: 'center', + gap: rail.itemSpace, + marginBottom: rail.headerSpace, + }, + headerExpanded: { + alignItems: 'flex-start', + paddingHorizontal: rail.itemHorizontalPadding, + }, + items: { + flex: 1, + }, + itemsContent: { + flexGrow: 1, + gap: rail.itemSpace, + }, + itemsCollapsed: { + alignItems: 'center', + }, + itemsExpanded: { + paddingHorizontal: rail.itemHorizontalPadding, + }, +}); + +export default NavigationRail; + +// @component-docs ignore-next-line +export { NavigationRail }; diff --git a/src/components/NavigationRail/NavigationRailItem.tsx b/src/components/NavigationRail/NavigationRailItem.tsx new file mode 100644 index 0000000000..d150f4e3f1 --- /dev/null +++ b/src/components/NavigationRail/NavigationRailItem.tsx @@ -0,0 +1,354 @@ +import * as React from 'react'; +import { Platform, StyleSheet, View } from 'react-native'; +import type { + GestureResponderEvent, + NativeSyntheticEvent, + StyleProp, + TargetedEvent, + ViewStyle, +} from 'react-native'; + +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; + +import { ExpandedContext } from './context'; +import { NavigationRailTokens } from './tokens'; +import { resolveItemColors } from './utils'; +import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import { tokens } from '../../theme/tokens'; +import { toRawSpring } from '../../theme/tokens/sys/motion'; +import { resolveCornerRadius } from '../../theme/utils/shape'; +import type { ThemeProp } from '../../types'; +import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; +import Badge from '../Badge'; +import Icon from '../Icon'; +import type { IconSource } from '../Icon'; +import TouchableRipple from '../TouchableRipple/TouchableRipple'; +import Text from '../Typography/Text'; + +export type Props = { + /** + * Icon of the destination. + */ + icon: IconSource; + /** + * Icon shown while the destination is active. Falls back to `icon`. + */ + activeIcon?: IconSource; + /** + * Label of the destination. Optional in the collapsed rail. + */ + label?: string; + /** + * Whether the destination is the current one. + */ + active?: boolean; + /** + * Whether the destination is disabled. + */ + disabled?: boolean; + /** + * Badge shown on the icon: `true` for a dot, a `string` or `number` for text. + */ + badge?: string | number | boolean; + /** + * Function to execute on press. + */ + onPress?: (e: GestureResponderEvent) => void; + /** + * Function to execute on long press. + */ + onLongPress?: (e: GestureResponderEvent) => void; + /** + * Accessibility label. Falls back to `label`. + */ + 'aria-label'?: string; + /** + * Specifies the largest possible scale a label font can reach. + */ + labelMaxFontSizeMultiplier?: number; + style?: StyleProp; + /** + * TestID used for testing purposes. + */ + testID?: string; + /** + * @optional + */ + theme?: ThemeProp; +}; + +const { item } = NavigationRailTokens; +const { opacity: stateOpacity, focusIndicator } = tokens.md.sys.state; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const webNoOutline = { outline: 'none' } as unknown as ViewStyle; + +/** + * A destination inside a `NavigationRail`. Renders as a stacked icon and + * label in the collapsed rail and as a full-width row in the expanded rail. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { NavigationRail } from 'react-native-paper'; + * + * const MyComponent = () => ( + * + * ); + * + * export default MyComponent; + * ``` + */ +const NavigationRailItem = ({ + icon, + activeIcon, + label, + active = false, + disabled = false, + badge = false, + onPress, + onLongPress, + 'aria-label': ariaLabel = label, + labelMaxFontSizeMultiplier, + style, + testID = 'navigation-rail-item', + theme: themeOverrides, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const expanded = React.useContext(ExpandedContext); + const reduceMotion = useReduceMotion(); + const [focused, setFocused] = React.useState(false); + + const colors = resolveItemColors({ theme, active, expanded }); + const indicatorRadius = resolveCornerRadius(theme, item.indicatorShape); + const contentOpacity = disabled + ? stateOpacity.disabled + : stateOpacity.enabled; + const hasLabel = !!label; + + const selection = useSharedValue(active ? 1 : 0); + const pressed = useSharedValue(false); + const hovered = useSharedValue(false); + + React.useEffect(() => { + const target = active ? 1 : 0; + selection.value = reduceMotion + ? target + : withSpring(target, toRawSpring(theme.motion.spring.fast.spatial)); + }, [active, reduceMotion, theme, selection]); + + const indicatorStyle = useAnimatedStyle(() => ({ + opacity: selection.value, + transform: [{ scaleX: 0.5 + selection.value / 2 }], + })); + + const stateLayerStyle = useAnimatedStyle(() => ({ + opacity: pressed.value + ? stateOpacity.pressed + : hovered.value + ? stateOpacity.hovered + : 0, + })); + + const onFocus = (e: NativeSyntheticEvent) => { + if (!disabled && isKeyboardFocusEvent(e)) setFocused(true); + }; + + const badgeNode = + badge === false ? null : ( + + {typeof badge === 'boolean' ? undefined : badge} + + ); + + const iconNode = ( + + ); + + const labelNode = hasLabel ? ( + + {label} + + ) : null; + + const indicatorNode = ( + <> + + + {focused ? ( + + ) : null} + + ); + + return ( + { + pressed.value = true; + }} + onPressOut={() => { + pressed.value = false; + }} + onHoverIn={() => { + hovered.value = true; + }} + onHoverOut={() => { + hovered.value = false; + }} + onFocus={onFocus} + onBlur={() => setFocused(false)} + role="tab" + aria-selected={active} + aria-disabled={disabled} + aria-label={ariaLabel} + testID={testID} + style={[ + expanded + ? [styles.expandedItem, { borderRadius: indicatorRadius }] + : [styles.collapsedItem, hasLabel && styles.collapsedItemLabeled], + Platform.OS === 'web' ? webNoOutline : null, + style, + ]} + theme={theme} + > + {expanded ? ( + + {indicatorNode} + {iconNode} + {labelNode} + {badgeNode} + + ) : ( + + + {indicatorNode} + {iconNode} + {badgeNode} + + {labelNode} + + )} + + ); +}; + +NavigationRailItem.displayName = 'NavigationRail.Item'; + +const styles = StyleSheet.create({ + collapsedItem: { + width: item.collapsed.indicatorWidth, + justifyContent: 'center', + }, + collapsedItemLabeled: { + width: '100%', + minHeight: item.collapsed.minHeight, + }, + expandedItem: { + height: item.expanded.indicatorHeight, + }, + column: { + alignItems: 'center', + pointerEvents: 'none', + }, + row: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + paddingStart: item.expanded.leading, + paddingEnd: item.expanded.trailing, + gap: item.expanded.iconLabelGap, + pointerEvents: 'none', + }, + collapsedIndicator: { + width: item.collapsed.indicatorWidth, + height: item.collapsed.indicatorWidth, + alignItems: 'center', + justifyContent: 'center', + }, + collapsedIndicatorLabeled: { + height: item.collapsed.indicatorHeight, + }, + fill: { + ...StyleSheet.absoluteFill, + }, + focusRing: { + margin: -focusIndicator.outerOffset, + borderWidth: focusIndicator.thickness, + }, + labelCollapsed: { + marginTop: item.collapsed.iconLabelGap, + textAlign: 'center', + }, + labelExpanded: { + flex: 1, + }, + badge: { + alignSelf: 'center', + }, + badgeAnchor: { + position: 'absolute', + top: 0, + start: (item.collapsed.indicatorWidth + item.iconSize) / 2 - 4, + }, +}); + +export default NavigationRailItem; diff --git a/src/components/NavigationRail/NavigationRailModal.tsx b/src/components/NavigationRail/NavigationRailModal.tsx new file mode 100644 index 0000000000..8d524b88dc --- /dev/null +++ b/src/components/NavigationRail/NavigationRailModal.tsx @@ -0,0 +1,148 @@ +import * as React from 'react'; +import { StyleSheet } from 'react-native'; +import type { ViewStyle } from 'react-native'; + +import { cubicBezier } from 'react-native-reanimated'; +import type { AnimatedStyle } from 'react-native-reanimated'; + +import NavigationRail from './NavigationRail'; +import type { Props as NavigationRailProps } from './NavigationRail'; +import { NavigationRailTokens } from './tokens'; +import { clampExpandedWidth } from './utils'; +import { useLocale } from '../../core/locale'; +import { useInternalTheme } from '../../core/theming'; +import { resolveCornerRadius } from '../../theme/utils/shape'; +import Modal from '../Modal'; + +export type Props = Omit< + NavigationRailProps, + 'expanded' | 'containerColor' | 'style' +> & { + /** + * Whether the modal rail is visible. + */ + visible: boolean; + /** + * Callback that is called when the user dismisses the rail. + */ + onDismiss?: () => void; + /** + * Determines whether tapping the scrim dismisses the rail. + */ + dismissable?: boolean; + /** + * Accessibility label for the scrim. + */ + overlayAccessibilityLabel?: string; + style?: NavigationRailProps['style']; +}; + +const { rail, colors } = NavigationRailTokens; + +/** + * An expanded navigation rail shown above the content with a scrim, for + * layouts where the rail is not permanently visible. Wrap it in a `Portal` + * to render above other components. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { Button, NavigationRail, Portal } from 'react-native-paper'; + * + * const MyComponent = () => { + * const [visible, setVisible] = React.useState(false); + * + * return ( + * <> + * + * setVisible(false)} + * > + * + * + * + * + * + * + * ); + * }; + * + * export default MyComponent; + * ``` + */ +const NavigationRailModal = ({ + visible, + onDismiss, + dismissable, + overlayAccessibilityLabel, + expandedWidth = rail.expandedMinWidth, + style, + testID = 'navigation-rail-modal', + theme: themeOverrides, + ...rest +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); + const [shown, setShown] = React.useState(false); + + React.useEffect(() => { + const timeout = setTimeout(() => setShown(visible), 0); + return () => clearTimeout(timeout); + }, [visible]); + + const width = clampExpandedWidth(expandedWidth); + const offscreen = direction === 'rtl' ? width : -width; + + // Duration follows the Modal's fade, which `Surface` applies last. + const slideStyle: AnimatedStyle = { + transform: [{ translateX: shown ? 0 : offscreen }], + transitionProperty: 'transform', + transitionTimingFunction: cubicBezier( + ...(shown + ? theme.motion.easing.emphasizedDecelerate + : theme.motion.easing.emphasizedAccelerate) + ), + }; + + return ( + + + + ); +}; + +NavigationRailModal.displayName = 'NavigationRail.Modal'; + +const styles = StyleSheet.create({ + wrapper: { + marginTop: 0, + marginBottom: 0, + alignItems: 'flex-start', + }, + content: { + flex: 1, + }, +}); + +export default NavigationRailModal; diff --git a/src/components/NavigationRail/context.ts b/src/components/NavigationRail/context.ts new file mode 100644 index 0000000000..3a0b4cff86 --- /dev/null +++ b/src/components/NavigationRail/context.ts @@ -0,0 +1,7 @@ +import * as React from 'react'; + +/** + * Whether the enclosing rail is expanded. Items read this to switch between + * the stacked (collapsed) and row (expanded) layouts. + */ +export const ExpandedContext = React.createContext(false); diff --git a/src/components/NavigationRail/index.ts b/src/components/NavigationRail/index.ts new file mode 100644 index 0000000000..06dafb299a --- /dev/null +++ b/src/components/NavigationRail/index.ts @@ -0,0 +1,16 @@ +import NavigationRailComponent from './NavigationRail'; +import NavigationRailItem from './NavigationRailItem'; +import NavigationRailModal from './NavigationRailModal'; + +const NavigationRail = Object.assign( + // @component ./NavigationRail.tsx + NavigationRailComponent, + { + // @component ./NavigationRailItem.tsx + Item: NavigationRailItem, + // @component ./NavigationRailModal.tsx + Modal: NavigationRailModal, + } +); + +export default NavigationRail; diff --git a/src/components/NavigationRail/tokens.ts b/src/components/NavigationRail/tokens.ts new file mode 100644 index 0000000000..7347935968 --- /dev/null +++ b/src/components/NavigationRail/tokens.ts @@ -0,0 +1,60 @@ +import type { ColorRole, Elevation, TypescaleKey } from '../../theme/types'; +import type { ShapeToken } from '../../theme/utils/shape'; + +export type Alignment = 'top' | 'center' | 'bottom'; + +/** + * MD3 Navigation rail spec tokens. + * @see https://m3.material.io/components/navigation-rail/specs + */ +const rail = { + collapsedWidth: 96, + expandedMinWidth: 220, + expandedMaxWidth: 360, + topSpace: 44, + headerSpace: 40, + itemSpace: 4, + itemHorizontalPadding: 20, + elevation: 0, + modalElevation: 2, + modalShape: 'large', +} as const satisfies Record; + +const item = { + iconSize: 24, + indicatorShape: 'full', + collapsed: { + minHeight: 64, + indicatorWidth: 56, + indicatorHeight: 32, + iconLabelGap: 4, + labelTypescale: 'labelMedium', + }, + expanded: { + indicatorHeight: 56, + leading: 16, + trailing: 16, + iconLabelGap: 8, + labelTypescale: 'labelLarge', + }, +} as const satisfies { + iconSize: number; + indicatorShape: ShapeToken; + collapsed: Record; + expanded: Record; +}; + +const colors = { + container: 'surface', + modalContainer: 'surfaceContainer', + activeIcon: 'onSecondaryContainer', + activeLabel: 'secondary', + activeExpandedLabel: 'onSecondaryContainer', + activeIndicator: 'secondaryContainer', + inactiveIcon: 'onSurfaceVariant', + inactiveLabel: 'onSurfaceVariant', + stateLayer: 'onSecondaryContainer', + focusIndicator: 'secondary', +} as const satisfies Record; + +export const NavigationRailTokens = { rail, item, colors }; diff --git a/src/components/NavigationRail/utils.ts b/src/components/NavigationRail/utils.ts new file mode 100644 index 0000000000..af5a9abcf0 --- /dev/null +++ b/src/components/NavigationRail/utils.ts @@ -0,0 +1,46 @@ +import type { ColorValue } from 'react-native'; + +import { NavigationRailTokens } from './tokens'; +import type { InternalTheme } from '../../types'; + +const { rail, colors } = NavigationRailTokens; + +export type ItemColors = { + icon: ColorValue; + label: ColorValue; + indicator: ColorValue; + stateLayer: ColorValue; + focusIndicator: ColorValue; +}; + +/** + * Resolve item colors for the current selection state. The active label uses + * `secondary` in the collapsed rail and matches the icon in the expanded rail. + */ +export const resolveItemColors = ({ + theme, + active = false, + expanded = false, +}: { + theme: InternalTheme; + active?: boolean; + expanded?: boolean; +}): ItemColors => { + const c = theme.colors; + const activeLabel = expanded + ? colors.activeExpandedLabel + : colors.activeLabel; + return { + icon: c[active ? colors.activeIcon : colors.inactiveIcon], + label: c[active ? activeLabel : colors.inactiveLabel], + indicator: c[colors.activeIndicator], + stateLayer: c[colors.stateLayer], + focusIndicator: c[colors.focusIndicator], + }; +}; + +/** + * Clamp a requested expanded width to the spec range (220–360dp). + */ +export const clampExpandedWidth = (width: number): number => + Math.min(Math.max(width, rail.expandedMinWidth), rail.expandedMaxWidth); diff --git a/src/index.tsx b/src/index.tsx index 8863e2fa20..d4a4754130 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 NavigationRail } from './components/NavigationRail'; 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,9 @@ 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 NavigationRailProps } from './components/NavigationRail/NavigationRail'; +export type { Props as NavigationRailItemProps } from './components/NavigationRail/NavigationRailItem'; +export type { Props as NavigationRailModalProps } from './components/NavigationRail/NavigationRailModal'; 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'; From 88c503b5b3d96ec04c96595d3e648d2b2ec01b43 Mon Sep 17 00:00:00 2001 From: marius-ck Date: Tue, 8 Sep 2026 11:40:44 +0300 Subject: [PATCH 2/4] feat: add animations on actions --- .../src/Examples/NavigationRailExample.tsx | 53 ++- .../NavigationRail/NavigationRail.tsx | 210 ++++++---- .../NavigationRail/NavigationRailItem.tsx | 364 ++++++++++-------- .../NavigationRail/NavigationRailModal.tsx | 180 ++++++--- src/components/NavigationRail/context.ts | 17 +- src/components/NavigationRail/tokens.ts | 4 +- src/components/NavigationRail/utils.ts | 32 +- 7 files changed, 577 insertions(+), 283 deletions(-) diff --git a/example/src/Examples/NavigationRailExample.tsx b/example/src/Examples/NavigationRailExample.tsx index 55116150b8..2e3dbc3be7 100644 --- a/example/src/Examples/NavigationRailExample.tsx +++ b/example/src/Examples/NavigationRailExample.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; +import type { ViewStyle } from 'react-native'; import { Button, @@ -14,6 +15,8 @@ import { useTheme, } from 'react-native-paper'; import type { NavigationRailProps } from 'react-native-paper'; +import Animated, { cubicBezier } from 'react-native-reanimated'; +import type { AnimatedStyle } from 'react-native-reanimated'; type Alignment = NonNullable; @@ -43,13 +46,22 @@ const destinations = [ const alignments: Alignment[] = ['top', 'center', 'bottom']; const NavigationRailExample = () => { - const { colors } = useTheme(); + const { colors, motion } = useTheme(); const [active, setActive] = React.useState('inbox'); const [expanded, setExpanded] = React.useState(false); const [alignment, setAlignment] = React.useState('top'); const [labeled, setLabeled] = React.useState(true); + const [overlay, setOverlay] = React.useState(false); + const [animated, setAnimated] = React.useState(true); const [modalVisible, setModalVisible] = React.useState(false); + const toggleStyle: AnimatedStyle = { + transform: [{ rotate: expanded ? '0deg' : '180deg' }], + transitionProperty: 'transform', + transitionDuration: animated ? motion.duration.medium2 : 0, + transitionTimingFunction: cubicBezier(...motion.easing.emphasized), + }; + const renderItems = () => destinations.map(({ key, label, ...rest }) => ( { setExpanded(false)} header={ <> - setExpanded((value) => !value)} + + setExpanded((value) => !value)} + /> + + {}} /> - {}} /> } > @@ -93,6 +115,24 @@ const NavigationRailExample = () => { )} onPress={() => setExpanded((value) => !value)} /> + ( + + + + )} + onPress={() => setAnimated((value) => !value)} + /> + ( + + + + )} + onPress={() => setOverlay((value) => !value)} + /> ( @@ -124,6 +164,7 @@ const NavigationRailExample = () => { visible={modalVisible} onDismiss={() => setModalVisible(false)} alignment={alignment} + animated={animated} header={ void; + /** + * Whether expanding and collapsing is animated. Defaults to `true`. + */ + animated?: boolean; + /** + * Container color override. Defaults to `theme.colors.surface` when + * collapsed and `theme.colors.surfaceContainer` when expanded. */ containerColor?: ColorValue; style?: StyleProp>; @@ -56,6 +73,9 @@ export type Props = { }; const { rail, colors } = NavigationRailTokens; +const scrimAlpha = tokens.md.sys.scrim.alpha; + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable); const justifyContent = { top: 'flex-start', @@ -84,8 +104,17 @@ const justifyContent = { * expanded={expanded} * header={ * <> - * setExpanded((e) => !e)} /> - * {}} /> + * setExpanded((e) => !e)} + * /> + * {}} + * /> * * } * > @@ -118,10 +147,12 @@ const justifyContent = { * * ## Theming * Customize by overriding these `theme.colors` roles: - * - `surface`: container + * - `surface`: collapsed container + * - `surfaceContainer`: expanded container * - `secondaryContainer` / `onSecondaryContainer`: active indicator / active icon * - `secondary`: active label (collapsed), focus indicator * - `onSurfaceVariant`: inactive icon and label + * - `scrim`: backdrop behind the floating rail (`overlay`) */ const NavigationRail = ({ children, @@ -129,6 +160,9 @@ const NavigationRail = ({ expandedWidth = rail.expandedMinWidth, alignment = 'top', header, + overlay = false, + onDismiss, + animated = true, containerColor, style, testID = 'navigation-rail', @@ -136,74 +170,125 @@ const NavigationRail = ({ }: Props) => { const theme = useInternalTheme(themeOverrides); const reduceMotion = useReduceMotion(); + const { width: windowWidth } = useWindowDimensions(); - const targetWidth = expanded - ? clampExpandedWidth(expandedWidth) - : rail.collapsedWidth; - const width = useSharedValue(targetWidth); + const targetWidth = clampExpandedWidth(expandedWidth); + const width = expanded ? targetWidth : rail.collapsedWidth; + const floating = overlay && expanded; + const endRadius = floating ? resolveCornerRadius(theme, rail.modalShape) : 0; + const backgroundColor = + containerColor ?? + theme.colors[expanded ? colors.expandedContainer : colors.container]; - React.useEffect(() => { - width.value = reduceMotion - ? targetWidth - : withSpring( - targetWidth, - toRawSpring(theme.motion.spring.default.spatial) - ); - }, [targetWidth, reduceMotion, theme, width]); - - const widthStyle = useAnimatedStyle(() => ({ width: width.value })); + const context = React.useMemo( + () => ({ expanded, expandedWidth: targetWidth, animated }), + [expanded, targetWidth, animated] + ); - return ( + const panel = ( - - {header ? ( - - {header} - - ) : null} - - - {children} - - - + {header ? {header} : null} + + + {children} + + ); + + if (!overlay) { + return panel; + } + + return ( + + + {panel} + + ); }; const styles = StyleSheet.create({ - container: { + anchor: { + width: rail.collapsedWidth, height: '100%', - overflow: 'hidden', + zIndex: 1, }, - content: { - flex: 1, + scrim: { + position: 'absolute', + top: 0, + bottom: 0, + start: 0, + }, + scrimShown: { + opacity: scrimAlpha, + pointerEvents: 'auto', + }, + scrimHidden: { + opacity: 0, + pointerEvents: 'none', + }, + panel: { + height: '100%', paddingTop: rail.topSpace, + overflow: 'hidden', + }, + floating: { + position: 'absolute', + top: 0, + bottom: 0, + start: 0, }, header: { - alignItems: 'center', + alignItems: 'flex-start', gap: rail.itemSpace, marginBottom: rail.headerSpace, - }, - headerExpanded: { - alignItems: 'flex-start', paddingHorizontal: rail.itemHorizontalPadding, }, items: { @@ -212,11 +297,6 @@ const styles = StyleSheet.create({ itemsContent: { flexGrow: 1, gap: rail.itemSpace, - }, - itemsCollapsed: { - alignItems: 'center', - }, - itemsExpanded: { paddingHorizontal: rail.itemHorizontalPadding, }, }); diff --git a/src/components/NavigationRail/NavigationRailItem.tsx b/src/components/NavigationRail/NavigationRailItem.tsx index d150f4e3f1..40fc3cbe5f 100644 --- a/src/components/NavigationRail/NavigationRailItem.tsx +++ b/src/components/NavigationRail/NavigationRailItem.tsx @@ -14,9 +14,9 @@ import Animated, { withSpring, } from 'react-native-reanimated'; -import { ExpandedContext } from './context'; +import { NavigationRailContext } from './context'; import { NavigationRailTokens } from './tokens'; -import { resolveItemColors } from './utils'; +import { getTransition, resolveItemColors } from './utils'; import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { tokens } from '../../theme/tokens'; @@ -82,7 +82,16 @@ export type Props = { theme?: ThemeProp; }; -const { item } = NavigationRailTokens; +const { rail, item } = NavigationRailTokens; + +// Badges hang off the icon's trailing edge, far enough out to keep +// `badgeInset` clear of the collapsed indicator's edge. +const badgeEnd = -( + item.collapsed.indicatorWidth - + item.expanded.leading - + item.iconSize - + item.badgeInset +); const { opacity: stateOpacity, focusIndicator } = tokens.md.sys.state; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion @@ -90,7 +99,8 @@ const webNoOutline = { outline: 'none' } as unknown as ViewStyle; /** * A destination inside a `NavigationRail`. Renders as a stacked icon and - * label in the collapsed rail and as a full-width row in the expanded rail. + * label in the collapsed rail and as a full-width row in the expanded rail, + * morphing between the two when the rail expands or collapses. * * ## Usage * ```js @@ -126,16 +136,50 @@ const NavigationRailItem = ({ theme: themeOverrides, }: Props) => { const theme = useInternalTheme(themeOverrides); - const expanded = React.useContext(ExpandedContext); + const { expanded, expandedWidth, animated } = React.useContext( + NavigationRailContext + ); const reduceMotion = useReduceMotion(); const [focused, setFocused] = React.useState(false); - const colors = resolveItemColors({ theme, active, expanded }); + const colors = resolveItemColors({ theme, active }); const indicatorRadius = resolveCornerRadius(theme, item.indicatorShape); const contentOpacity = disabled ? stateOpacity.disabled : stateOpacity.enabled; const hasLabel = !!label; + const stacked = hasLabel && !expanded; + + // Collapsed labeled items center the indicator + label block in the min height. + const height = stacked + ? item.collapsed.minHeight + : item.expanded.indicatorHeight; + const pillHeight = stacked + ? item.collapsed.indicatorHeight + : item.expanded.indicatorHeight; + const labelBlock = + theme.fonts[item.collapsed.labelTypescale].lineHeight + + item.collapsed.iconLabelGap; + const pillTop = stacked ? (height - pillHeight - labelBlock) / 2 : 0; + // Fixed label widths keep text measured once; the item clips the overflow. + const rowLabelWidth = + expandedWidth - + 2 * rail.itemHorizontalPadding - + item.expanded.leading - + item.iconSize - + item.expanded.iconLabelGap - + item.expanded.trailing; + + const layoutTransition = getTransition(theme, ['height', 'paddingTop'], { + instant: !animated || reduceMotion, + }); + const fadeTransition = getTransition(theme, ['opacity'], { + instant: !animated, + }); + const fade = (shown: boolean) => [ + shown ? styles.shown : styles.hidden, + fadeTransition, + ]; const selection = useSharedValue(active ? 1 : 0); const pressed = useSharedValue(false); @@ -143,10 +187,11 @@ const NavigationRailItem = ({ React.useEffect(() => { const target = active ? 1 : 0; - selection.value = reduceMotion - ? target - : withSpring(target, toRawSpring(theme.motion.spring.fast.spatial)); - }, [active, reduceMotion, theme, selection]); + selection.value = + !animated || reduceMotion + ? target + : withSpring(target, toRawSpring(theme.motion.spring.fast.spatial)); + }, [active, animated, reduceMotion, theme, selection]); const indicatorStyle = useAnimatedStyle(() => ({ opacity: selection.value, @@ -165,189 +210,202 @@ const NavigationRailItem = ({ if (!disabled && isKeyboardFocusEvent(e)) setFocused(true); }; + const dot = typeof badge === 'boolean'; const badgeNode = - badge === false ? null : ( - - {typeof badge === 'boolean' ? undefined : badge} - - ); - - const iconNode = ( - - ); + badge === false ? null : {dot ? undefined : badge}; - const labelNode = hasLabel ? ( + const renderLabel = (row: boolean) => ( {label} - ) : null; - - const indicatorNode = ( - <> - - - {focused ? ( - - ) : null} - ); return ( - { - pressed.value = true; - }} - onPressOut={() => { - pressed.value = false; - }} - onHoverIn={() => { - hovered.value = true; - }} - onHoverOut={() => { - hovered.value = false; - }} - onFocus={onFocus} - onBlur={() => setFocused(false)} - role="tab" - aria-selected={active} - aria-disabled={disabled} - aria-label={ariaLabel} - testID={testID} - style={[ - expanded - ? [styles.expandedItem, { borderRadius: indicatorRadius }] - : [styles.collapsedItem, hasLabel && styles.collapsedItemLabeled], - Platform.OS === 'web' ? webNoOutline : null, - style, - ]} - theme={theme} + - {expanded ? ( - - {indicatorNode} - {iconNode} - {labelNode} - {badgeNode} - - ) : ( - - { + pressed.value = true; + }} + onPressOut={() => { + pressed.value = false; + }} + onHoverIn={() => { + hovered.value = true; + }} + onHoverOut={() => { + hovered.value = false; + }} + onFocus={onFocus} + onBlur={() => setFocused(false)} + role="tab" + aria-selected={active} + aria-disabled={disabled} + aria-label={ariaLabel} + testID={testID} + style={[styles.touchable, Platform.OS === 'web' ? webNoOutline : null]} + theme={theme} + > + + - {indicatorNode} - {iconNode} - {badgeNode} + testID={`${testID}-indicator`} + /> + + {focused ? ( + + ) : null} + + + {badgeNode ? ( + + {badgeNode} + + ) : null} - {labelNode} - - )} - + {hasLabel ? ( + <> + + {renderLabel(false)} + + + {renderLabel(true)} + + + ) : null} + {badgeNode ? ( + + {badgeNode} + + ) : null} + + + ); }; NavigationRailItem.displayName = 'NavigationRail.Item'; const styles = StyleSheet.create({ - collapsedItem: { - width: item.collapsed.indicatorWidth, - justifyContent: 'center', - }, - collapsedItemLabeled: { - width: '100%', - minHeight: item.collapsed.minHeight, - }, - expandedItem: { - height: item.expanded.indicatorHeight, - }, - column: { - alignItems: 'center', - pointerEvents: 'none', - }, - row: { + touchable: { flex: 1, - flexDirection: 'row', - alignItems: 'center', - paddingStart: item.expanded.leading, - paddingEnd: item.expanded.trailing, - gap: item.expanded.iconLabelGap, - pointerEvents: 'none', }, - collapsedIndicator: { - width: item.collapsed.indicatorWidth, - height: item.collapsed.indicatorWidth, - alignItems: 'center', + pill: { justifyContent: 'center', - }, - collapsedIndicatorLabeled: { - height: item.collapsed.indicatorHeight, + paddingStart: item.expanded.leading, + pointerEvents: 'none', }, fill: { ...StyleSheet.absoluteFill, }, + shown: { + opacity: 1, + }, + hidden: { + opacity: 0, + }, focusRing: { margin: -focusIndicator.outerOffset, borderWidth: focusIndicator.thickness, }, - labelCollapsed: { + stackedLabel: { + position: 'absolute', + top: '100%', + start: 0, + width: item.collapsed.indicatorWidth, marginTop: item.collapsed.iconLabelGap, - textAlign: 'center', + alignItems: 'center', }, - labelExpanded: { - flex: 1, + rowLabel: { + position: 'absolute', + top: 0, + bottom: 0, + start: item.expanded.leading + item.iconSize + item.expanded.iconLabelGap, + justifyContent: 'center', }, - badge: { - alignSelf: 'center', + iconAnchor: { + alignSelf: 'flex-start', }, - badgeAnchor: { + iconBadge: { position: 'absolute', top: 0, - start: (item.collapsed.indicatorWidth + item.iconSize) / 2 - 4, + end: badgeEnd, + alignItems: 'flex-end', + }, + rowBadge: { + position: 'absolute', + top: 0, + bottom: 0, + end: item.expanded.trailing, + justifyContent: 'center', + alignItems: 'flex-end', }, }); diff --git a/src/components/NavigationRail/NavigationRailModal.tsx b/src/components/NavigationRail/NavigationRailModal.tsx index 8d524b88dc..d07e9734fd 100644 --- a/src/components/NavigationRail/NavigationRailModal.tsx +++ b/src/components/NavigationRail/NavigationRailModal.tsx @@ -1,22 +1,24 @@ import * as React from 'react'; -import { StyleSheet } from 'react-native'; -import type { ViewStyle } from 'react-native'; +import { Pressable, StyleSheet } from 'react-native'; -import { cubicBezier } from 'react-native-reanimated'; -import type { AnimatedStyle } from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; import NavigationRail from './NavigationRail'; import type { Props as NavigationRailProps } from './NavigationRail'; import { NavigationRailTokens } from './tokens'; -import { clampExpandedWidth } from './utils'; +import { clampExpandedWidth, getTransition } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; +import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; +import { tokens } from '../../theme/tokens'; import { resolveCornerRadius } from '../../theme/utils/shape'; -import Modal from '../Modal'; +import { addEventListener } from '../../utils/addEventListener'; +import { BackHandler } from '../../utils/BackHandler/BackHandler'; +import Surface from '../Surface'; export type Props = Omit< NavigationRailProps, - 'expanded' | 'containerColor' | 'style' + 'expanded' | 'overlay' | 'onDismiss' | 'containerColor' | 'style' > & { /** * Whether the modal rail is visible. @@ -27,7 +29,8 @@ export type Props = Omit< */ onDismiss?: () => void; /** - * Determines whether tapping the scrim dismisses the rail. + * Determines whether tapping the scrim or pressing the hardware back + * button dismisses the rail. */ dismissable?: boolean; /** @@ -38,11 +41,15 @@ export type Props = Omit< }; const { rail, colors } = NavigationRailTokens; +const scrimAlpha = tokens.md.sys.scrim.alpha; + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable); /** * An expanded navigation rail shown above the content with a scrim, for - * layouts where the rail is not permanently visible. Wrap it in a `Portal` - * to render above other components. + * layouts where the rail is not permanently visible. Slides in from the + * start edge and out again on dismiss. Wrap it in a `Portal` to render above + * other components. * * ## Usage * ```js @@ -74,9 +81,10 @@ const { rail, colors } = NavigationRailTokens; const NavigationRailModal = ({ visible, onDismiss, - dismissable, - overlayAccessibilityLabel, + dismissable = true, + overlayAccessibilityLabel = 'Close navigation rail', expandedWidth = rail.expandedMinWidth, + animated = true, style, testID = 'navigation-rail-modal', theme: themeOverrides, @@ -84,64 +92,138 @@ const NavigationRailModal = ({ }: Props) => { const theme = useInternalTheme(themeOverrides); const { direction } = useLocale(); + const reduceMotion = useReduceMotion(); + const [mounted, setMounted] = React.useState(visible); const [shown, setShown] = React.useState(false); + if (visible && !mounted) { + setMounted(true); + } + + const { duration, easing } = theme.motion; + const enter = { + duration: duration.medium4, + easing: easing.emphasizedDecelerate, + }; + const exit = { + duration: duration.short4, + easing: easing.emphasizedAccelerate, + }; + const motion = { ...(shown ? enter : exit), instant: !animated }; + React.useEffect(() => { const timeout = setTimeout(() => setShown(visible), 0); return () => clearTimeout(timeout); }, [visible]); + React.useEffect(() => { + if (visible || !mounted) return undefined; + const timeout = setTimeout( + () => setMounted(false), + animated ? exit.duration : 0 + ); + return () => clearTimeout(timeout); + }, [visible, mounted, animated, exit.duration]); + + React.useEffect(() => { + if (!visible || !dismissable) return undefined; + const subscription = addEventListener( + BackHandler, + 'hardwareBackPress', + () => { + onDismiss?.(); + return true; + } + ); + return () => subscription.remove(); + }, [visible, dismissable, onDismiss]); + + if (!mounted) { + return null; + } + const width = clampExpandedWidth(expandedWidth); const offscreen = direction === 'rtl' ? width : -width; - - // Duration follows the Modal's fade, which `Surface` applies last. - const slideStyle: AnimatedStyle = { - transform: [{ translateX: shown ? 0 : offscreen }], - transitionProperty: 'transform', - transitionTimingFunction: cubicBezier( - ...(shown - ? theme.motion.easing.emphasizedDecelerate - : theme.motion.easing.emphasizedAccelerate) - ), - }; + const endRadius = resolveCornerRadius(theme, rail.modalShape); return ( - - - + + + + ); }; NavigationRailModal.displayName = 'NavigationRail.Modal'; const styles = StyleSheet.create({ - wrapper: { - marginTop: 0, - marginBottom: 0, - alignItems: 'flex-start', + panel: { + position: 'absolute', + top: 0, + bottom: 0, + start: 0, + }, + scrim: { + opacity: scrimAlpha, + }, + shown: { + opacity: 1, }, - content: { - flex: 1, + hidden: { + opacity: 0, }, }); diff --git a/src/components/NavigationRail/context.ts b/src/components/NavigationRail/context.ts index 3a0b4cff86..efeb6db5ba 100644 --- a/src/components/NavigationRail/context.ts +++ b/src/components/NavigationRail/context.ts @@ -1,7 +1,18 @@ import * as React from 'react'; +import { NavigationRailTokens } from './tokens'; + /** - * Whether the enclosing rail is expanded. Items read this to switch between - * the stacked (collapsed) and row (expanded) layouts. + * Expansion state of the enclosing rail. Items read `expanded` to switch + * layouts, `expandedWidth` to size the row label once (so text is not + * re-measured while the rail width animates) and `animated` to skip motion. */ -export const ExpandedContext = React.createContext(false); +export const NavigationRailContext = React.createContext<{ + expanded: boolean; + expandedWidth: number; + animated: boolean; +}>({ + expanded: false, + expandedWidth: NavigationRailTokens.rail.expandedMinWidth, + animated: true, +}); diff --git a/src/components/NavigationRail/tokens.ts b/src/components/NavigationRail/tokens.ts index 7347935968..ac2f3e0e0e 100644 --- a/src/components/NavigationRail/tokens.ts +++ b/src/components/NavigationRail/tokens.ts @@ -23,6 +23,7 @@ const rail = { const item = { iconSize: 24, indicatorShape: 'full', + badgeInset: 8, collapsed: { minHeight: 64, indicatorWidth: 56, @@ -40,13 +41,14 @@ const item = { } as const satisfies { iconSize: number; indicatorShape: ShapeToken; + badgeInset: number; collapsed: Record; expanded: Record; }; const colors = { container: 'surface', - modalContainer: 'surfaceContainer', + expandedContainer: 'surfaceContainer', activeIcon: 'onSecondaryContainer', activeLabel: 'secondary', activeExpandedLabel: 'onSecondaryContainer', diff --git a/src/components/NavigationRail/utils.ts b/src/components/NavigationRail/utils.ts index af5a9abcf0..7dab85bccf 100644 --- a/src/components/NavigationRail/utils.ts +++ b/src/components/NavigationRail/utils.ts @@ -1,6 +1,10 @@ import type { ColorValue } from 'react-native'; +import { cubicBezier } from 'react-native-reanimated'; +import type { CSSTransitionProperties } from 'react-native-reanimated'; + import { NavigationRailTokens } from './tokens'; +import type { EasingConfig } from '../../theme/types'; import type { InternalTheme } from '../../types'; const { rail, colors } = NavigationRailTokens; @@ -8,6 +12,7 @@ const { rail, colors } = NavigationRailTokens; export type ItemColors = { icon: ColorValue; label: ColorValue; + expandedLabel: ColorValue; indicator: ColorValue; stateLayer: ColorValue; focusIndicator: ColorValue; @@ -20,19 +25,16 @@ export type ItemColors = { export const resolveItemColors = ({ theme, active = false, - expanded = false, }: { theme: InternalTheme; active?: boolean; - expanded?: boolean; }): ItemColors => { const c = theme.colors; - const activeLabel = expanded - ? colors.activeExpandedLabel - : colors.activeLabel; return { icon: c[active ? colors.activeIcon : colors.inactiveIcon], - label: c[active ? activeLabel : colors.inactiveLabel], + label: c[active ? colors.activeLabel : colors.inactiveLabel], + expandedLabel: + c[active ? colors.activeExpandedLabel : colors.inactiveLabel], indicator: c[colors.activeIndicator], stateLayer: c[colors.stateLayer], focusIndicator: c[colors.focusIndicator], @@ -44,3 +46,21 @@ export const resolveItemColors = ({ */ export const clampExpandedWidth = (width: number): number => Math.min(Math.max(width, rail.expandedMinWidth), rail.expandedMaxWidth); + +/** + * Rail motion as a CSS transition. Defaults to the 300ms emphasized curve used + * for expanding; the modal rail passes MD3 enter/exit durations and easings. + */ +export const getTransition = ( + theme: InternalTheme, + properties: CSSTransitionProperties['transitionProperty'], + { + duration = theme.motion.duration.medium2, + easing = theme.motion.easing.emphasized, + instant = false, + }: { duration?: number; easing?: EasingConfig; instant?: boolean } = {} +): CSSTransitionProperties => ({ + transitionProperty: properties, + transitionDuration: instant ? 0 : duration, + transitionTimingFunction: cubicBezier(...easing), +}); From 20921972070e04631bbf1b2ae6ebec3761daa781 Mon Sep 17 00:00:00 2001 From: marius-ck Date: Tue, 8 Sep 2026 11:44:05 +0300 Subject: [PATCH 3/4] feat: add tests for navigation rail --- docs/6.x/docs/guides/migration.md | 19 + .../NavigationRail/NavigationRail.test.tsx | 214 + .../NavigationRailModal.test.tsx | 100 + .../NavigationRail.test.tsx.snap | 3460 +++++++++++++++++ .../NavigationRailModal.test.tsx.snap | 779 ++++ .../__tests__/NavigationRail/utils.test.tsx | 35 + 6 files changed, 4607 insertions(+) create mode 100644 src/components/__tests__/NavigationRail/NavigationRail.test.tsx create mode 100644 src/components/__tests__/NavigationRail/NavigationRailModal.test.tsx create mode 100644 src/components/__tests__/NavigationRail/__snapshots__/NavigationRail.test.tsx.snap create mode 100644 src/components/__tests__/NavigationRail/__snapshots__/NavigationRailModal.test.tsx.snap create mode 100644 src/components/__tests__/NavigationRail/utils.test.tsx diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index a4d7123a09..972764e703 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -86,6 +86,25 @@ The `style` props for `Appbar` and `Appbar.Header` no longer accept `Animated.Va The `style.elevation` property is no longer supported. Use the `elevated` prop to control Appbar elevation. +### Drawer + +`NavigationRail` is the Material Design 3 replacement for `Drawer.CollapsedItem` and the drawer-based side navigation. It supports a collapsed (96dp) and expanded (220–360dp) layout, a header slot for a menu button and `FAB`, and a modal variant. + +```diff +- +- +- +- ++ ++ ++ ++ +``` + +- **`focusedIcon` / `unfocusedIcon`** → **`activeIcon` / `icon`** +- Expanded rows replace `Drawer.Item`; toggle them with the `expanded` prop instead of rendering a different component. +- Use `NavigationRail.Modal` inside a `Portal` where a temporary drawer was used before. + ### Surface - The `elevation` prop no longer accepts a React Native `Animated.Value`. Any `elevation` changes are animated automatically. diff --git a/src/components/__tests__/NavigationRail/NavigationRail.test.tsx b/src/components/__tests__/NavigationRail/NavigationRail.test.tsx new file mode 100644 index 0000000000..355ae01b27 --- /dev/null +++ b/src/components/__tests__/NavigationRail/NavigationRail.test.tsx @@ -0,0 +1,214 @@ +import { Text } from 'react-native'; + +import { describe, expect, it, jest } from '@jest/globals'; + +import { render, screen, userEvent } from '../../../test-utils'; +import NavigationRail from '../../NavigationRail'; + +const items = ( + <> + + + + +); + +describe('NavigationRail render', () => { + it('renders collapsed', async () => { + expect( + (await render({items})).toJSON() + ).toMatchSnapshot(); + }); + + it('renders expanded', async () => { + expect( + (await render({items})).toJSON() + ).toMatchSnapshot(); + }); + + it('renders header and alignment', async () => { + expect( + ( + await render( + Header}> + {items} + + ) + ).toJSON() + ).toMatchSnapshot(); + }); + + it('renders icon-only items', async () => { + expect( + ( + await render( + + + + ) + ).toJSON() + ).toMatchSnapshot(); + }); +}); + +describe('NavigationRail layout', () => { + it('uses collapsed width by default', async () => { + await render({items}); + + expect(screen.getByTestId('navigation-rail')).toHaveStyle({ + width: 96, + }); + }); + + it('keeps the collapsed footprint and fades the scrim in overlay mode', async () => { + const onDismiss = jest.fn(); + const { rerender } = await render( + + {items} + + ); + + expect(screen.getByTestId('navigation-rail-scrim')).toHaveStyle({ + opacity: 0, + }); + + await rerender( + + {items} + + ); + + expect(screen.getByTestId('navigation-rail')).toHaveStyle({ width: 220 }); + expect(screen.getByTestId('navigation-rail').parent).toHaveStyle({ + width: 96, + }); + expect(screen.getByTestId('navigation-rail-scrim')).toHaveStyle({ + opacity: 0.32, + }); + + await userEvent.setup().press(screen.getByTestId('navigation-rail-scrim')); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('clamps the expanded width to the spec range', async () => { + await render( + + {items} + + ); + + expect(screen.getByTestId('navigation-rail')).toHaveStyle({ + width: 360, + }); + }); +}); + +describe('NavigationRail.Item', () => { + it('shows the active icon only when active', async () => { + await render( + + + + + ); + + expect(screen.getByTestId('active')).toBeSelected(); + expect(screen.getByTestId('inactive')).not.toBeSelected(); + expect(screen.getByTestId('active-indicator')).toHaveStyle({ + opacity: 1, + }); + expect(screen.getByTestId('inactive-indicator')).toHaveStyle({ + opacity: 0, + }); + }); + + it('shows the stacked label collapsed and the row label expanded', async () => { + const { rerender } = await render( + + + + ); + + expect(screen.getByTestId('navigation-rail-item-label')).toBeOnTheScreen(); + expect( + screen.queryByTestId('navigation-rail-item-label-expanded') + ).toBeNull(); + + await rerender( + + + + ); + + expect(screen.queryByTestId('navigation-rail-item-label')).toBeNull(); + expect( + screen.getByTestId('navigation-rail-item-label-expanded') + ).toBeOnTheScreen(); + }); + + it('uses the label as accessibility label', async () => { + await render( + + + + ); + + expect(screen.getByRole('tab', { name: 'Inbox' })).toBeOnTheScreen(); + }); + + it('calls onPress', async () => { + const user = userEvent.setup(); + const onPress = jest.fn(); + await render( + + + + ); + + await user.press(screen.getByRole('tab')); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('does not call onPress when disabled', async () => { + const user = userEvent.setup(); + const onPress = jest.fn(); + await render( + + + + ); + + await user.press(screen.getByRole('tab')); + + expect(onPress).not.toHaveBeenCalled(); + expect(screen.getByRole('tab')).toBeDisabled(); + }); + + it('renders badge text', async () => { + await render( + + + + ); + + expect(screen.getByText('12')).toBeOnTheScreen(); + }); +}); diff --git a/src/components/__tests__/NavigationRail/NavigationRailModal.test.tsx b/src/components/__tests__/NavigationRail/NavigationRailModal.test.tsx new file mode 100644 index 0000000000..c341253112 --- /dev/null +++ b/src/components/__tests__/NavigationRail/NavigationRailModal.test.tsx @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; + +import { act, render, screen, userEvent } from '../../../test-utils'; +import NavigationRail from '../../NavigationRail'; +import Portal from '../../Portal/Portal'; + +const renderModal = (visible: boolean, onDismiss = jest.fn()) => + render( + + + + + + + ); + +describe('NavigationRail.Modal', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders when visible', async () => { + expect((await renderModal(true)).toJSON()).toMatchSnapshot(); + }); + + it('renders nothing when hidden', async () => { + await renderModal(false); + + expect(screen.queryByTestId('navigation-rail-modal')).toBeNull(); + }); + + it('slides out before unmounting on dismiss', async () => { + jest.useFakeTimers(); + const { rerender } = await renderModal(true); + + await rerender( + + + + + + ); + + expect(screen.getByTestId('navigation-rail-modal-surface')).toHaveStyle({ + transform: [{ translateX: -220 }], + }); + expect(screen.getByTestId('navigation-rail-modal-backdrop')).toHaveStyle({ + opacity: 0, + }); + + await act(() => { + jest.advanceTimersByTime(200); + }); + + expect(screen.queryByTestId('navigation-rail-modal')).toBeNull(); + }); + + it('unmounts immediately on dismiss when not animated', async () => { + jest.useFakeTimers(); + const { rerender } = await render( + + + + + + ); + + await rerender( + + + + + + ); + await act(() => { + jest.advanceTimersByTime(0); + }); + + expect(screen.queryByTestId('navigation-rail-modal')).toBeNull(); + }); + + it('renders items in the expanded layout', async () => { + await renderModal(true); + + expect(screen.getByTestId('navigation-rail-modal-rail')).toHaveStyle({ + width: 220, + }); + expect(screen.getAllByRole('tab')).toHaveLength(2); + }); + + it('dismisses on scrim press', async () => { + const user = userEvent.setup(); + const onDismiss = jest.fn(); + await renderModal(true, onDismiss); + + await user.press(screen.getByTestId('navigation-rail-modal-backdrop')); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/__tests__/NavigationRail/__snapshots__/NavigationRail.test.tsx.snap b/src/components/__tests__/NavigationRail/__snapshots__/NavigationRail.test.tsx.snap new file mode 100644 index 0000000000..a4fd6fd70a --- /dev/null +++ b/src/components/__tests__/NavigationRail/__snapshots__/NavigationRail.test.tsx.snap @@ -0,0 +1,3460 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NavigationRail render renders collapsed 1`] = ` + + + + + + + + + + + inbox + + + + 3 + + + + + + Inbox + + + + + Inbox + + + + + 3 + + + + + + + + + + + + + send + + + + + + + + Sent + + + + + Sent + + + + + + + + + + + + + + + + delete + + + + + Trash + + + + + Trash + + + + + + + + +`; + +exports[`NavigationRail render renders expanded 1`] = ` + + + + + + + + + + + inbox + + + + 3 + + + + + + Inbox + + + + + Inbox + + + + + 3 + + + + + + + + + + + + + send + + + + + + + + Sent + + + + + Sent + + + + + + + + + + + + + + + + delete + + + + + Trash + + + + + Trash + + + + + + + + +`; + +exports[`NavigationRail render renders header and alignment 1`] = ` + + + + Header + + + + + + + + + + + + inbox + + + + 3 + + + + + + Inbox + + + + + Inbox + + + + + 3 + + + + + + + + + + + + + send + + + + + + + + Sent + + + + + Sent + + + + + + + + + + + + + + + + delete + + + + + Trash + + + + + Trash + + + + + + + + +`; + +exports[`NavigationRail render renders icon-only items 1`] = ` + + + + + + + + + + + inbox + + + + + + + + +`; diff --git a/src/components/__tests__/NavigationRail/__snapshots__/NavigationRailModal.test.tsx.snap b/src/components/__tests__/NavigationRail/__snapshots__/NavigationRailModal.test.tsx.snap new file mode 100644 index 0000000000..87aa2cb1e7 --- /dev/null +++ b/src/components/__tests__/NavigationRail/__snapshots__/NavigationRailModal.test.tsx.snap @@ -0,0 +1,779 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NavigationRail.Modal renders when visible 1`] = ` + + + + + + + + + + + + + + + + inbox + + + + + Inbox + + + + + Inbox + + + + + + + + + + + + + send + + + + + Sent + + + + + Sent + + + + + + + + + + + +`; diff --git a/src/components/__tests__/NavigationRail/utils.test.tsx b/src/components/__tests__/NavigationRail/utils.test.tsx new file mode 100644 index 0000000000..14fd1cf5fa --- /dev/null +++ b/src/components/__tests__/NavigationRail/utils.test.tsx @@ -0,0 +1,35 @@ +import { describe, expect, it } from '@jest/globals'; + +import { getTheme } from '../../../core/theming'; +import { + clampExpandedWidth, + resolveItemColors, +} from '../../NavigationRail/utils'; + +describe('resolveItemColors', () => { + const theme = getTheme(); + + it('returns inactive colors by default', () => { + expect(resolveItemColors({ theme })).toMatchObject({ + icon: theme.colors.onSurfaceVariant, + label: theme.colors.onSurfaceVariant, + indicator: theme.colors.secondaryContainer, + }); + }); + + it('uses secondary for the active collapsed label and the icon color when expanded', () => { + expect(resolveItemColors({ theme, active: true })).toMatchObject({ + icon: theme.colors.onSecondaryContainer, + label: theme.colors.secondary, + expandedLabel: theme.colors.onSecondaryContainer, + }); + }); +}); + +describe('clampExpandedWidth', () => { + it('keeps widths inside the spec range', () => { + expect(clampExpandedWidth(100)).toBe(220); + expect(clampExpandedWidth(300)).toBe(300); + expect(clampExpandedWidth(1000)).toBe(360); + }); +}); From 0ba38eca235c92c013ed60cc052916070067819a Mon Sep 17 00:00:00 2001 From: marius-ck Date: Tue, 8 Sep 2026 11:54:24 +0300 Subject: [PATCH 4/4] fix: multiple import from the same file --- docs/src/data/themeColors.ts | 5 ++++- src/components/NavigationRail/NavigationRail.tsx | 2 +- src/components/NavigationRail/NavigationRailItem.tsx | 2 +- src/components/NavigationRail/utils.ts | 3 +-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts index 9702e70af6..14fd738d57 100644 --- a/docs/src/data/themeColors.ts +++ b/docs/src/data/themeColors.ts @@ -265,9 +265,12 @@ export const themeColors = { }, }, NavigationRail: { - '-': { + collapsed: { backgroundColor: 'theme.colors.surface', }, + expanded: { + backgroundColor: 'theme.colors.surfaceContainer', + }, }, 'NavigationRail.Item': { active: { diff --git a/src/components/NavigationRail/NavigationRail.tsx b/src/components/NavigationRail/NavigationRail.tsx index b242be009c..9117305c89 100644 --- a/src/components/NavigationRail/NavigationRail.tsx +++ b/src/components/NavigationRail/NavigationRail.tsx @@ -18,8 +18,8 @@ import { clampExpandedWidth, getTransition } from './utils'; import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { tokens } from '../../theme/tokens'; +import type { ThemeProp } from '../../theme/types'; import { resolveCornerRadius } from '../../theme/utils/shape'; -import type { ThemeProp } from '../../types'; export type Props = { /** diff --git a/src/components/NavigationRail/NavigationRailItem.tsx b/src/components/NavigationRail/NavigationRailItem.tsx index 40fc3cbe5f..360da691d4 100644 --- a/src/components/NavigationRail/NavigationRailItem.tsx +++ b/src/components/NavigationRail/NavigationRailItem.tsx @@ -21,8 +21,8 @@ import { useInternalTheme } from '../../core/theming'; import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext'; import { tokens } from '../../theme/tokens'; import { toRawSpring } from '../../theme/tokens/sys/motion'; +import type { ThemeProp } from '../../theme/types'; import { resolveCornerRadius } from '../../theme/utils/shape'; -import type { ThemeProp } from '../../types'; import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent'; import Badge from '../Badge'; import Icon from '../Icon'; diff --git a/src/components/NavigationRail/utils.ts b/src/components/NavigationRail/utils.ts index 7dab85bccf..4b3c2835ff 100644 --- a/src/components/NavigationRail/utils.ts +++ b/src/components/NavigationRail/utils.ts @@ -4,8 +4,7 @@ import { cubicBezier } from 'react-native-reanimated'; import type { CSSTransitionProperties } from 'react-native-reanimated'; import { NavigationRailTokens } from './tokens'; -import type { EasingConfig } from '../../theme/types'; -import type { InternalTheme } from '../../types'; +import type { EasingConfig, InternalTheme } from '../../theme/types'; const { rail, colors } = NavigationRailTokens;