diff --git a/docs/6.x/docs/guides/migration.md b/docs/6.x/docs/guides/migration.md index a401ad8de8..372bac9e3d 100644 --- a/docs/6.x/docs/guides/migration.md +++ b/docs/6.x/docs/guides/migration.md @@ -135,6 +135,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/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..14fd738d57 100644 --- a/docs/src/data/themeColors.ts +++ b/docs/src/data/themeColors.ts @@ -264,6 +264,31 @@ export const themeColors = { backgroundColor: 'theme.colors.backdrop', }, }, + NavigationRail: { + collapsed: { + backgroundColor: 'theme.colors.surface', + }, + expanded: { + backgroundColor: 'theme.colors.surfaceContainer', + }, + }, + '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..2e3dbc3be7 --- /dev/null +++ b/example/src/Examples/NavigationRailExample.tsx @@ -0,0 +1,201 @@ +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; +import type { ViewStyle } 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'; +import Animated, { cubicBezier } from 'react-native-reanimated'; +import type { AnimatedStyle } from 'react-native-reanimated'; + +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, 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 }) => ( + setActive(key)} + /> + )); + + return ( + + setExpanded(false)} + header={ + <> + + setExpanded((value) => !value)} + /> + + {}} + /> + + } + > + {renderItems()} + + + + {active} + ( + + + + )} + onPress={() => setExpanded((value) => !value)} + /> + ( + + + + )} + onPress={() => setAnimated((value) => !value)} + /> + ( + + + + )} + onPress={() => setOverlay((value) => !value)} + /> + ( + + + + )} + onPress={() => setLabeled((value) => !value)} + /> + + {alignments.map((option) => ( + setAlignment(option)} + > + {option} + + ))} + + + + + + setModalVisible(false)} + alignment={alignment} + animated={animated} + 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..9117305c89 --- /dev/null +++ b/src/components/NavigationRail/NavigationRail.tsx @@ -0,0 +1,307 @@ +import * as React from 'react'; +import { + Pressable, + ScrollView, + StyleSheet, + useWindowDimensions, + View, +} from 'react-native'; +import type { ColorValue, StyleProp, ViewStyle } from 'react-native'; + +import Animated from 'react-native-reanimated'; +import type { AnimatedStyle } from 'react-native-reanimated'; + +import { NavigationRailContext } from './context'; +import { NavigationRailTokens } from './tokens'; +import type { Alignment } from './tokens'; +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'; + +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; + /** + * Whether the expanded rail floats above the content behind a scrim instead + * of pushing it. The rail keeps its collapsed footprint in the layout. + */ + overlay?: boolean; + /** + * Called when the scrim is pressed. Only used with `overlay`. + */ + onDismiss?: () => 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>; + /** + * TestID used for testing purposes. + */ + testID?: string; + /** + * @optional + */ + theme?: ThemeProp; +}; + +const { rail, colors } = NavigationRailTokens; +const scrimAlpha = tokens.md.sys.scrim.alpha; + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable); + +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`: 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, + expanded = false, + expandedWidth = rail.expandedMinWidth, + alignment = 'top', + header, + overlay = false, + onDismiss, + animated = true, + containerColor, + style, + testID = 'navigation-rail', + theme: themeOverrides, +}: Props) => { + const theme = useInternalTheme(themeOverrides); + const reduceMotion = useReduceMotion(); + const { width: windowWidth } = useWindowDimensions(); + + 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]; + + const context = React.useMemo( + () => ({ expanded, expandedWidth: targetWidth, animated }), + [expanded, targetWidth, animated] + ); + + const panel = ( + + {header ? {header} : null} + + + {children} + + + + ); + + if (!overlay) { + return panel; + } + + return ( + + + {panel} + + ); +}; + +const styles = StyleSheet.create({ + anchor: { + width: rail.collapsedWidth, + height: '100%', + zIndex: 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: 'flex-start', + gap: rail.itemSpace, + marginBottom: rail.headerSpace, + paddingHorizontal: rail.itemHorizontalPadding, + }, + items: { + flex: 1, + }, + itemsContent: { + flexGrow: 1, + gap: rail.itemSpace, + 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..360da691d4 --- /dev/null +++ b/src/components/NavigationRail/NavigationRailItem.tsx @@ -0,0 +1,412 @@ +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 { NavigationRailContext } from './context'; +import { NavigationRailTokens } from './tokens'; +import { getTransition, 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 type { ThemeProp } from '../../theme/types'; +import { resolveCornerRadius } from '../../theme/utils/shape'; +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 { 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 +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, + * morphing between the two when the rail expands or collapses. + * + * ## 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, expandedWidth, animated } = React.useContext( + NavigationRailContext + ); + const reduceMotion = useReduceMotion(); + const [focused, setFocused] = React.useState(false); + + 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); + const hovered = useSharedValue(false); + + React.useEffect(() => { + const target = active ? 1 : 0; + selection.value = + !animated || reduceMotion + ? target + : withSpring(target, toRawSpring(theme.motion.spring.fast.spatial)); + }, [active, animated, 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 dot = typeof badge === 'boolean'; + const badgeNode = + badge === false ? null : {dot ? undefined : badge}; + + const renderLabel = (row: boolean) => ( + + {label} + + ); + + 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={[styles.touchable, Platform.OS === 'web' ? webNoOutline : null]} + theme={theme} + > + + + + {focused ? ( + + ) : null} + + + {badgeNode ? ( + + {badgeNode} + + ) : null} + + {hasLabel ? ( + <> + + {renderLabel(false)} + + + {renderLabel(true)} + + + ) : null} + {badgeNode ? ( + + {badgeNode} + + ) : null} + + + + ); +}; + +NavigationRailItem.displayName = 'NavigationRail.Item'; + +const styles = StyleSheet.create({ + touchable: { + flex: 1, + }, + pill: { + justifyContent: 'center', + paddingStart: item.expanded.leading, + pointerEvents: 'none', + }, + fill: { + ...StyleSheet.absoluteFill, + }, + shown: { + opacity: 1, + }, + hidden: { + opacity: 0, + }, + focusRing: { + margin: -focusIndicator.outerOffset, + borderWidth: focusIndicator.thickness, + }, + stackedLabel: { + position: 'absolute', + top: '100%', + start: 0, + width: item.collapsed.indicatorWidth, + marginTop: item.collapsed.iconLabelGap, + alignItems: 'center', + }, + rowLabel: { + position: 'absolute', + top: 0, + bottom: 0, + start: item.expanded.leading + item.iconSize + item.expanded.iconLabelGap, + justifyContent: 'center', + }, + iconAnchor: { + alignSelf: 'flex-start', + }, + iconBadge: { + position: 'absolute', + top: 0, + end: badgeEnd, + alignItems: 'flex-end', + }, + rowBadge: { + position: 'absolute', + top: 0, + bottom: 0, + end: item.expanded.trailing, + justifyContent: 'center', + alignItems: 'flex-end', + }, +}); + +export default NavigationRailItem; diff --git a/src/components/NavigationRail/NavigationRailModal.tsx b/src/components/NavigationRail/NavigationRailModal.tsx new file mode 100644 index 0000000000..d07e9734fd --- /dev/null +++ b/src/components/NavigationRail/NavigationRailModal.tsx @@ -0,0 +1,230 @@ +import * as React from 'react'; +import { Pressable, StyleSheet } from 'react-native'; + +import Animated from 'react-native-reanimated'; + +import NavigationRail from './NavigationRail'; +import type { Props as NavigationRailProps } from './NavigationRail'; +import { NavigationRailTokens } from './tokens'; +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 { addEventListener } from '../../utils/addEventListener'; +import { BackHandler } from '../../utils/BackHandler/BackHandler'; +import Surface from '../Surface'; + +export type Props = Omit< + NavigationRailProps, + 'expanded' | 'overlay' | 'onDismiss' | '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 or pressing the hardware back + * button dismisses the rail. + */ + dismissable?: boolean; + /** + * Accessibility label for the scrim. + */ + overlayAccessibilityLabel?: string; + style?: NavigationRailProps['style']; +}; + +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. Slides in from the + * start edge and out again on dismiss. 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 = true, + overlayAccessibilityLabel = 'Close navigation rail', + expandedWidth = rail.expandedMinWidth, + animated = true, + style, + testID = 'navigation-rail-modal', + theme: themeOverrides, + ...rest +}: 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; + const endRadius = resolveCornerRadius(theme, rail.modalShape); + + return ( + + + + + + + ); +}; + +NavigationRailModal.displayName = 'NavigationRail.Modal'; + +const styles = StyleSheet.create({ + panel: { + position: 'absolute', + top: 0, + bottom: 0, + start: 0, + }, + scrim: { + opacity: scrimAlpha, + }, + shown: { + opacity: 1, + }, + hidden: { + opacity: 0, + }, +}); + +export default NavigationRailModal; diff --git a/src/components/NavigationRail/context.ts b/src/components/NavigationRail/context.ts new file mode 100644 index 0000000000..efeb6db5ba --- /dev/null +++ b/src/components/NavigationRail/context.ts @@ -0,0 +1,18 @@ +import * as React from 'react'; + +import { NavigationRailTokens } from './tokens'; + +/** + * 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 NavigationRailContext = React.createContext<{ + expanded: boolean; + expandedWidth: number; + animated: boolean; +}>({ + expanded: false, + expandedWidth: NavigationRailTokens.rail.expandedMinWidth, + animated: true, +}); 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..ac2f3e0e0e --- /dev/null +++ b/src/components/NavigationRail/tokens.ts @@ -0,0 +1,62 @@ +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', + badgeInset: 8, + 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; + badgeInset: number; + collapsed: Record; + expanded: Record; +}; + +const colors = { + container: 'surface', + expandedContainer: '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..4b3c2835ff --- /dev/null +++ b/src/components/NavigationRail/utils.ts @@ -0,0 +1,65 @@ +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, InternalTheme } from '../../theme/types'; + +const { rail, colors } = NavigationRailTokens; + +export type ItemColors = { + icon: ColorValue; + label: ColorValue; + expandedLabel: 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, +}: { + theme: InternalTheme; + active?: boolean; +}): ItemColors => { + const c = theme.colors; + return { + icon: c[active ? colors.activeIcon : colors.inactiveIcon], + 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], + }; +}; + +/** + * 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); + +/** + * 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), +}); 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); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index f46d8e22d8..048bef5a02 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';