diff --git a/docs/docs/guides/10-rtl.md b/docs/docs/guides/10-rtl.md new file mode 100644 index 0000000000..bfef7137c9 --- /dev/null +++ b/docs/docs/guides/10-rtl.md @@ -0,0 +1,72 @@ +--- +title: RTL Support +--- + +# RTL Support + +React Native Paper supports right-to-left (RTL) layouts for languages such as Arabic and Hebrew. + +## How it works + +On React Native, the writing direction is normally controlled by `I18nManager.forceRTL`. React Native Paper reads this automatically i.e. no configuration is needed for native apps that already set up RTL via `I18nManager`. + +However, `I18nManager` is a no-op on **React Native Web**, which means RTL layouts break silently in web apps. The `direction` prop on `PaperProvider` (and the `LocaleProvider` component) lets you explicitly control the writing direction so Paper behaves correctly on all platforms. + +:::note +The `direction` prop informs React Native Paper about the text direction in the app i.e. it doesn't change the text direction by itself. If you intend to support RTL languages, it's important to set this prop to the correct value that's configured in the app. If it doesn't match the actual text direction, the layout might be incorrect. +::: + +## Setting direction for the whole app + +Pass the `direction` prop to `PaperProvider`. Defaults to `'rtl'` when `I18nManager.getConstants().isRTL` returns `true`, otherwise `'ltr'`. + +Supported values: + +- `'ltr'`: Left-to-right text direction for languages like English, French etc. +- `'rtl'`: Right-to-left text direction for languages like Arabic, Hebrew etc. + +```js +import * as React from 'react'; +import { PaperProvider } from 'react-native-paper'; +import App from './src/App'; + +export default function Main() { + return ( + + + + ); +} +``` + +## Overriding direction for a subtree + +Use `LocaleProvider` to override the direction for a specific part of the tree without affecting the rest of the app: + +```js +import * as React from 'react'; +import { LocaleProvider } from 'react-native-paper'; + +export default function ArabicSection() { + return ( + + {/* Components here will use RTL layout */} + + ); +} +``` + +## Reading the current direction + +The direction is available in your own components via the `useLocale` hook: + +```js +import * as React from 'react'; +import { useLocale } from 'react-native-paper'; + +function MyComponent() { + const { direction } = useLocale(); + + // Use the direction +} +``` diff --git a/src/components/Appbar/AppbarBackIcon.tsx b/src/components/Appbar/AppbarBackIcon.tsx index 7579a3f1a6..35eb98d18f 100644 --- a/src/components/Appbar/AppbarBackIcon.tsx +++ b/src/components/Appbar/AppbarBackIcon.tsx @@ -1,9 +1,12 @@ import * as React from 'react'; -import { I18nManager, Image, Platform, StyleSheet, View } from 'react-native'; +import { Image, Platform, StyleSheet, View } from 'react-native'; +import { useLocale } from '../../core/locale'; import MaterialCommunityIcon from '../MaterialCommunityIcon'; const AppbarBackIcon = ({ size, color }: { size: number; color: string }) => { + const { direction } = useLocale(); + const isRTL = direction === 'rtl'; const iosIconSize = size - 3; return Platform.OS === 'ios' ? ( @@ -13,7 +16,7 @@ const AppbarBackIcon = ({ size, color }: { size: number; color: string }) => { { width: size, height: size, - transform: [{ scaleX: I18nManager.getConstants().isRTL ? -1 : 1 }], + transform: [{ scaleX: isRTL ? -1 : 1 }], }, ]} > @@ -31,7 +34,7 @@ const AppbarBackIcon = ({ size, color }: { size: number; color: string }) => { name="arrow-left" color={color} size={size} - direction={I18nManager.getConstants().isRTL ? 'rtl' : 'ltr'} + direction={direction} /> ); }; diff --git a/src/components/DataTable/DataTablePagination.tsx b/src/components/DataTable/DataTablePagination.tsx index 6a3fc52b3d..056bb432b9 100644 --- a/src/components/DataTable/DataTablePagination.tsx +++ b/src/components/DataTable/DataTablePagination.tsx @@ -1,14 +1,9 @@ import * as React from 'react'; -import { - I18nManager, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native'; +import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; import type { ThemeProp } from 'src/types'; +import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import Button from '../Button/Button'; import IconButton from '../IconButton/IconButton'; @@ -92,6 +87,7 @@ const PaginationControls = ({ theme: themeOverrides, }: PaginationControlsProps) => { const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); const textColor = theme.colors.onSurface; @@ -104,7 +100,7 @@ const PaginationControls = ({ name="page-first" color={color} size={size} - direction={I18nManager.getConstants().isRTL ? 'rtl' : 'ltr'} + direction={direction} /> )} iconColor={textColor} @@ -120,7 +116,7 @@ const PaginationControls = ({ name="chevron-left" color={color} size={size} - direction={I18nManager.getConstants().isRTL ? 'rtl' : 'ltr'} + direction={direction} /> )} iconColor={textColor} @@ -135,7 +131,7 @@ const PaginationControls = ({ name="chevron-right" color={color} size={size} - direction={I18nManager.getConstants().isRTL ? 'rtl' : 'ltr'} + direction={direction} /> )} iconColor={textColor} @@ -151,7 +147,7 @@ const PaginationControls = ({ name="page-last" color={color} size={size} - direction={I18nManager.getConstants().isRTL ? 'rtl' : 'ltr'} + direction={direction} /> )} iconColor={textColor} diff --git a/src/components/DataTable/DataTableTitle.tsx b/src/components/DataTable/DataTableTitle.tsx index 3f911123b4..b31ec8b899 100644 --- a/src/components/DataTable/DataTableTitle.tsx +++ b/src/components/DataTable/DataTableTitle.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { Animated, GestureResponderEvent, - I18nManager, PixelRatio, Pressable, StyleProp, @@ -11,6 +10,7 @@ import { ViewStyle, } from 'react-native'; +import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; import MaterialCommunityIcon from '../MaterialCommunityIcon'; @@ -91,6 +91,7 @@ const DataTableTitle = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); const { current: spinAnim } = React.useRef( new Animated.Value(sortDirection === 'ascending' ? 0 : 1) ); @@ -118,7 +119,7 @@ const DataTableTitle = ({ name="arrow-up" size={16} color={textColor} - direction={I18nManager.getConstants().isRTL ? 'rtl' : 'ltr'} + direction={direction} /> ) : null; @@ -140,7 +141,7 @@ const DataTableTitle = ({ // if numberOfLines causes wrap, center is lost. Align directly, sensitive to numeric and RTL numberOfLines > 1 ? numeric - ? I18nManager.getConstants().isRTL + ? direction === 'rtl' ? styles.leftText : styles.rightText : styles.centerText diff --git a/src/components/FAB/AnimatedFAB.tsx b/src/components/FAB/AnimatedFAB.tsx index ed4a200b09..7c3cb2761a 100644 --- a/src/components/FAB/AnimatedFAB.tsx +++ b/src/components/FAB/AnimatedFAB.tsx @@ -9,7 +9,6 @@ import { Animated, Easing, GestureResponderEvent, - I18nManager, Platform, ScrollView, StyleProp, @@ -20,6 +19,7 @@ import { } from 'react-native'; import { getCombinedStyles, getFABColors, getLabelSizeWeb } from './utils'; +import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import type { $Omit, $RemoveChildren, ThemeProp } from '../../types'; import type { IconSource } from '../Icon'; @@ -219,12 +219,13 @@ const AnimatedFAB = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); const uppercase: boolean = uppercaseProp ?? false; const isIOS = Platform.OS === 'ios'; const isWeb = Platform.OS === 'web'; const isAnimatedFromRight = animateFrom === 'right'; const isIconStatic = iconMode === 'static'; - const { isRTL } = I18nManager; + const isRTL = direction === 'rtl'; const labelRef = React.useRef(null); const { current: visibility } = React.useRef( new Animated.Value(visible ? 1 : 0) @@ -342,6 +343,7 @@ const AnimatedFAB = ({ const combinedStyles = getCombinedStyles({ isAnimatedFromRight, isIconStatic, + isRTL, distance, animFAB, }); diff --git a/src/components/FAB/utils.ts b/src/components/FAB/utils.ts index ad49fa13be..944d46a2c9 100644 --- a/src/components/FAB/utils.ts +++ b/src/components/FAB/utils.ts @@ -1,17 +1,12 @@ import { MutableRefObject } from 'react'; -import { - Animated, - ColorValue, - I18nManager, - Platform, - ViewStyle, -} from 'react-native'; +import { Animated, ColorValue, Platform, ViewStyle } from 'react-native'; import type { InternalTheme } from '../../types'; type GetCombinedStylesProps = { isAnimatedFromRight: boolean; isIconStatic: boolean; + isRTL: boolean; distance: number; animFAB: Animated.Value; }; @@ -32,11 +27,10 @@ type BaseProps = { export const getCombinedStyles = ({ isAnimatedFromRight, isIconStatic, + isRTL, distance, animFAB, }: GetCombinedStylesProps): CombinedStyles => { - const { isRTL } = I18nManager; - const defaultPositionStyles = { left: -distance, right: undefined }; const combinedStyles: CombinedStyles = { diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index b886e06fe2..a3d2d28570 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -1,12 +1,8 @@ import * as React from 'react'; -import { - I18nManager, - Image, - ImageSourcePropType, - Platform, -} from 'react-native'; +import { Image, ImageSourcePropType, Platform } from 'react-native'; import { accessibilityProps } from './MaterialCommunityIcon'; +import { useLocale } from '../core/locale'; import { Consumer as SettingsConsumer } from '../core/settings'; import { useInternalTheme } from '../core/theming'; import type { ThemeProp } from '../types'; @@ -109,12 +105,11 @@ const Icon = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); + const { direction: layoutDirection } = useLocale(); const direction = typeof source === 'object' && source.direction && source.source ? source.direction === 'auto' - ? I18nManager.getConstants().isRTL - ? 'rtl' - : 'ltr' + ? layoutDirection : source.direction : null; diff --git a/src/components/List/ListAccordion.tsx b/src/components/List/ListAccordion.tsx index 3eaee97d61..d79634d984 100644 --- a/src/components/List/ListAccordion.tsx +++ b/src/components/List/ListAccordion.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { GestureResponderEvent, - I18nManager, NativeSyntheticEvent, StyleProp, StyleSheet, @@ -16,6 +15,7 @@ import { import { ListAccordionGroupContext } from './ListAccordionGroup'; import type { ListChildProps, Style } from './utils'; import { getAccordionColors, getLeftStyles } from './utils'; +import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; import MaterialCommunityIcon from '../MaterialCommunityIcon'; @@ -198,6 +198,7 @@ const ListAccordion = ({ hitSlop, }: Props) => { const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); const [expanded, setExpanded] = React.useState( expandedProp || false ); @@ -316,7 +317,7 @@ const ListAccordion = ({ name={isExpanded ? 'chevron-up' : 'chevron-down'} color={descriptionColor} size={24} - direction={I18nManager.getConstants().isRTL ? 'rtl' : 'ltr'} + direction={direction} /> )} diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx index 47727f7e10..2b53e2f146 100644 --- a/src/components/Menu/Menu.tsx +++ b/src/components/Menu/Menu.tsx @@ -4,7 +4,6 @@ import { Dimensions, Easing, EmitterSubscription, - I18nManager, Keyboard, KeyboardEvent as RNKeyboardEvent, LayoutRectangle, @@ -22,6 +21,7 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import MenuItem from './MenuItem'; +import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import type { MD3Elevation, MD3Theme, ThemeProp } from '../../types'; import { ElevationLevels } from '../../types'; @@ -196,6 +196,7 @@ const Menu = ({ keyboardShouldPersistTaps, }: Props) => { const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); const { colors: md3Colors } = theme as MD3Theme; const insets = useSafeAreaInsets(); const [rendered, setRendered] = React.useState(visible); @@ -626,7 +627,7 @@ const Menu = ({ top: isCoordinate(anchor) ? topTransformation : topTransformation + additionalVerticalValue, - ...(I18nManager.getConstants().isRTL + ...(direction === 'rtl' ? { right: leftTransformation } : { left: leftTransformation }), }; diff --git a/src/components/Portal/Portal.tsx b/src/components/Portal/Portal.tsx index d44caf96d6..39aedc7d08 100644 --- a/src/components/Portal/Portal.tsx +++ b/src/components/Portal/Portal.tsx @@ -4,6 +4,7 @@ import type { InternalTheme } from 'src/types'; import PortalConsumer from './PortalConsumer'; import PortalHost, { PortalContext, PortalMethods } from './PortalHost'; +import { LocaleContext, LocaleProvider } from '../../core/locale'; import { Consumer as SettingsConsumer, Provider as SettingsProvider, @@ -49,19 +50,25 @@ class Portal extends React.Component { const { children, theme } = this.props; return ( - - {(settings) => ( - - {(manager) => ( - - - {children} - - + + {(locale) => ( + + {(settings) => ( + + {(manager) => ( + + + + {children} + + + + )} + )} - + )} - + ); } } diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx index c307df2479..561f68b7af 100644 --- a/src/components/ProgressBar.tsx +++ b/src/components/ProgressBar.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { Animated, - I18nManager, LayoutChangeEvent, Platform, StyleProp, @@ -10,6 +9,7 @@ import { ViewStyle, } from 'react-native'; +import { useLocale } from '../core/locale'; import { useInternalTheme } from '../core/theming'; import type { ThemeProp } from '../types'; @@ -53,7 +53,6 @@ export type Props = React.ComponentPropsWithRef & { const INDETERMINATE_DURATION = 2000; const INDETERMINATE_MAX_WIDTH = 0.6; -const { isRTL } = I18nManager; /** * Progress bar is an indicator used to present progress of some activity in the app. @@ -84,6 +83,8 @@ const ProgressBar = ({ }: Props) => { const isWeb = Platform.OS === 'web'; const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); + const isRTL = direction === 'rtl'; const { current: timer } = React.useRef( new Animated.Value(0) ); diff --git a/src/components/Searchbar.tsx b/src/components/Searchbar.tsx index ce6088ecb1..9213cc70b4 100644 --- a/src/components/Searchbar.tsx +++ b/src/components/Searchbar.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { Animated, GestureResponderEvent, - I18nManager, Platform, StyleProp, StyleSheet, @@ -19,6 +18,7 @@ import type { IconSource } from './Icon'; import IconButton from './IconButton/IconButton'; import MaterialCommunityIcon from './MaterialCommunityIcon'; import Surface from './Surface'; +import { useLocale } from '../core/locale'; import { useInternalTheme } from '../core/theming'; import type { MD3Theme, ThemeProp } from '../types'; import { forwardRef } from '../utils/forwardRef'; @@ -193,6 +193,7 @@ const Searchbar = forwardRef( ref ) => { const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); const { colors, fonts } = theme as MD3Theme; const root = React.useRef(null); @@ -228,6 +229,7 @@ const Searchbar = forwardRef( }; const isBarMode = mode === 'bar'; + const inputTextAlign = direction === 'rtl' ? 'right' : 'left'; const shouldRenderTraileringIcon = isBarMode && traileringIcon && @@ -262,7 +264,7 @@ const Searchbar = forwardRef( name="magnify" color={color} size={size} - direction={I18nManager.getConstants().isRTL ? 'rtl' : 'ltr'} + direction={direction} /> )) } @@ -277,6 +279,7 @@ const Searchbar = forwardRef( color: textColor, ...font, ...Platform.select({ web: { outline: 'none' } }), + textAlign: inputTextAlign, }, isBarMode ? styles.barModeInput : styles.viewModeInput, inputStyle, @@ -323,7 +326,7 @@ const Searchbar = forwardRef( name="close" color={color} size={size} - direction={I18nManager.getConstants().isRTL ? 'rtl' : 'ltr'} + direction={direction} /> )) } @@ -373,7 +376,6 @@ const styles = StyleSheet.create({ fontSize: 18, paddingLeft: 8, alignSelf: 'stretch', - textAlign: I18nManager.getConstants().isRTL ? 'right' : 'left', minWidth: 0, }, barModeInput: { diff --git a/src/components/Snackbar.tsx b/src/components/Snackbar.tsx index 9ee01c7cb2..852dfec20c 100644 --- a/src/components/Snackbar.tsx +++ b/src/components/Snackbar.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { Animated, Easing, - I18nManager, StyleProp, StyleSheet, View, @@ -18,6 +17,7 @@ import IconButton from './IconButton/IconButton'; import MaterialCommunityIcon from './MaterialCommunityIcon'; import Surface from './Surface'; import Text from './Typography/Text'; +import { useLocale } from '../core/locale'; import { useInternalTheme } from '../core/theming'; import type { $Omit, $RemoveChildren, MD3Theme, ThemeProp } from '../types'; @@ -158,6 +158,7 @@ const Snackbar = ({ ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); + const { direction } = useLocale(); const { bottom, right, left } = useSafeAreaInsets(); const { current: opacity } = React.useRef( @@ -349,9 +350,7 @@ const Snackbar = ({ name="close" color={color} size={size} - direction={ - I18nManager.getConstants().isRTL ? 'rtl' : 'ltr' - } + direction={direction} /> ); }) diff --git a/src/components/TextInput/TextInputFlat.tsx b/src/components/TextInput/TextInputFlat.tsx index e1f4c2a6c3..01a400ae2d 100644 --- a/src/components/TextInput/TextInputFlat.tsx +++ b/src/components/TextInput/TextInputFlat.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { - I18nManager, Platform, StyleSheet, TextInput as NativeTextInput, @@ -41,6 +40,7 @@ import { } from './helpers'; import InputLabel from './Label/InputLabel'; import type { ChildTextInputProps, RenderProps } from './types'; +import { useLocale } from '../../core/locale'; const TextInputFlat = ({ disabled = false, @@ -78,6 +78,8 @@ const TextInputFlat = ({ ...rest }: ChildTextInputProps) => { const isAndroid = Platform.OS === 'android'; + const { direction } = useLocale(); + const isRTL = direction === 'rtl'; const { colors, roundness } = theme; const font = theme.fonts.bodyLarge; const hasActiveOutline = parentState.focused || error; @@ -169,11 +171,8 @@ const TextInputFlat = ({ const labelHalfHeight = labelHeight / 2; const baseLabelTranslateX = - (I18nManager.getConstants().isRTL ? 1 : -1) * - (labelHalfWidth - (labelScale * labelWidth) / 2) + - (1 - labelScale) * - (I18nManager.getConstants().isRTL ? -1 : 1) * - paddingLeft; + (isRTL ? 1 : -1) * (labelHalfWidth - (labelScale * labelWidth) / 2) + + (1 - labelScale) * (isRTL ? -1 : 1) * paddingLeft; const minInputHeight = dense ? (label ? MIN_DENSE_HEIGHT_WL : MIN_DENSE_HEIGHT) - LABEL_PADDING_TOP_DENSE @@ -277,13 +276,9 @@ const TextInputFlat = ({ labelScale, wiggleOffsetX: LABEL_WIGGLE_X_OFFSET, topPosition, - paddingLeft: isAndroid - ? I18nManager.isRTL - ? paddingRight - : paddingLeft - : paddingLeft, + paddingLeft: isAndroid ? (isRTL ? paddingRight : paddingLeft) : paddingLeft, paddingRight: isAndroid - ? I18nManager.isRTL + ? isRTL ? paddingLeft : paddingRight : paddingRight, @@ -419,11 +414,7 @@ const TextInputFlat = ({ color: inputTextColor, opacity: disabledOpacity, textAlignVertical: multiline ? 'top' : 'center', - textAlign: textAlign - ? textAlign - : I18nManager.getConstants().isRTL - ? 'right' - : 'left', + textAlign: textAlign ? textAlign : isRTL ? 'right' : 'left', minWidth: Math.min( parentState.labelTextLayout.width + 2 * FLAT_INPUT_OFFSET, MIN_WIDTH diff --git a/src/components/TextInput/TextInputOutlined.tsx b/src/components/TextInput/TextInputOutlined.tsx index 63efb7f14b..1ebc55c278 100644 --- a/src/components/TextInput/TextInputOutlined.tsx +++ b/src/components/TextInput/TextInputOutlined.tsx @@ -4,7 +4,6 @@ import { View, TextInput as NativeTextInput, StyleSheet, - I18nManager, Platform, TextStyle, ColorValue, @@ -41,6 +40,7 @@ import { import InputLabel from './Label/InputLabel'; import LabelBackground from './Label/LabelBackground'; import type { RenderProps, ChildTextInputProps } from './types'; +import { useLocale } from '../../core/locale'; const TextInputOutlined = ({ disabled = false, @@ -80,6 +80,8 @@ const TextInputOutlined = ({ ...rest }: ChildTextInputProps) => { const adornmentConfig = getAdornmentConfig({ left, right }); + const { direction } = useLocale(); + const isRTL = direction === 'rtl'; const { colors, roundness } = theme; const font = theme.fonts.bodyLarge; @@ -132,7 +134,7 @@ const TextInputOutlined = ({ const labelHalfHeight = labelHeight / 2; const baseLabelTranslateX = - (I18nManager.getConstants().isRTL ? 1 : -1) * + (isRTL ? 1 : -1) * (labelHalfWidth - (labelScale * labelWidth) / 2 - (fontSize - MINIMIZED_LABEL_FONT_SIZE) * labelScale); @@ -149,8 +151,7 @@ const TextInputOutlined = ({ if (isAdornmentLeftIcon) { labelTranslationXOffset = - (I18nManager.getConstants().isRTL ? -1 : 1) * ADORNMENT_SIZE + - ADORNMENT_OFFSET; + (isRTL ? -1 : 1) * ADORNMENT_SIZE + ADORNMENT_OFFSET; } const minInputHeight = @@ -403,11 +404,7 @@ const TextInputOutlined = ({ color: inputTextColor, opacity: disabledOpacity, textAlignVertical: multiline ? 'top' : 'center', - textAlign: textAlign - ? textAlign - : I18nManager.getConstants().isRTL - ? 'right' - : 'left', + textAlign: textAlign ? textAlign : isRTL ? 'right' : 'left', paddingHorizontal: INPUT_PADDING_HORIZONTAL, minWidth: Math.min( parentState.labelTextLayout.width + diff --git a/src/components/Typography/AnimatedText.tsx b/src/components/Typography/AnimatedText.tsx index 19872e2d93..5da34b474d 100644 --- a/src/components/Typography/AnimatedText.tsx +++ b/src/components/Typography/AnimatedText.tsx @@ -1,15 +1,9 @@ import * as React from 'react'; import { ReactNode } from 'react'; -import { - Animated, - I18nManager, - StyleProp, - StyleSheet, - TextStyle, - Text, -} from 'react-native'; +import { Animated, StyleProp, StyleSheet, TextStyle, Text } from 'react-native'; import type { VariantProp } from './types'; +import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; import { forwardRef } from '../../utils/forwardRef'; @@ -48,7 +42,7 @@ const AnimatedText = forwardRef>( ref ) { const theme = useInternalTheme(themeOverrides); - const writingDirection = I18nManager.getConstants().isRTL ? 'rtl' : 'ltr'; + const { direction: writingDirection } = useLocale(); if (variant) { const font = theme.fonts[variant]; diff --git a/src/components/Typography/Text.tsx b/src/components/Typography/Text.tsx index c80d4399e4..4ed4ddd529 100644 --- a/src/components/Typography/Text.tsx +++ b/src/components/Typography/Text.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { ReactNode } from 'react'; import { - I18nManager, StyleProp, StyleSheet, Text as NativeText, @@ -10,6 +9,7 @@ import { import AnimatedText from './AnimatedText'; import type { VariantProp } from './types'; +import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; import { forwardRef } from '../../utils/forwardRef'; @@ -87,7 +87,7 @@ const Text = ( const root = React.useRef(null); // FIXME: destructure it in TS 4.6+ const theme = useInternalTheme(initialTheme); - const writingDirection = I18nManager.getConstants().isRTL ? 'rtl' : 'ltr'; + const { direction: writingDirection } = useLocale(); React.useImperativeHandle(ref, () => ({ setNativeProps: (args: Object) => root.current?.setNativeProps(args), diff --git a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap index 82c623d863..e3efd16f62 100644 --- a/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap +++ b/src/components/__tests__/Appbar/__snapshots__/Appbar.test.tsx.snap @@ -236,7 +236,6 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "fontSize": 18, "minWidth": 0, "paddingLeft": 8, - "textAlign": "left", }, { "color": "rgba(73, 69, 79, 1)", @@ -245,6 +244,7 @@ exports[`Appbar does not pass any additional props to Searchbar 1`] = ` "fontWeight": "400", "letterSpacing": 0.15, "lineHeight": 0, + "textAlign": "left", }, { "minHeight": 56, diff --git a/src/components/__tests__/TextInput.test.tsx b/src/components/__tests__/TextInput.test.tsx index 2fbebb326f..2e4c1f0703 100644 --- a/src/components/__tests__/TextInput.test.tsx +++ b/src/components/__tests__/TextInput.test.tsx @@ -1,9 +1,10 @@ /* eslint-disable react-native/no-inline-styles */ import * as React from 'react'; -import { I18nManager, Platform, StyleSheet, Text, View } from 'react-native'; +import { Platform, StyleSheet, Text, View } from 'react-native'; import { fireEvent, render } from '@testing-library/react-native'; +import PaperProvider from '../../core/PaperProvider'; import { DefaultTheme, getTheme, ThemeProvider } from '../../core/theming'; import { red500 } from '../../styles/themes/v2/colors'; import { tokens } from '../../styles/themes/v3/tokens'; @@ -259,28 +260,27 @@ it('renders input placeholder initially with transparent placeholderTextColor', it('correctly applies padding offset to input label on Android when RTL', () => { Platform.OS = 'android'; - I18nManager.isRTL = true; const { getByTestId } = render( - - } - right={ - - } - /> + + + } + right={ + + } + /> + ); expect(getByTestId('text-input-flat-label-active')).toHaveStyle({ paddingLeft: 56, paddingRight: 16, }); - - I18nManager.isRTL = false; }); it('correctly applies padding offset to input label on Android when LTR', () => { diff --git a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap index 1954a404ef..eb09a78f1a 100644 --- a/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Searchbar.test.tsx.snap @@ -195,7 +195,6 @@ exports[`activity indicator snapshot test 1`] = ` "fontSize": 18, "minWidth": 0, "paddingLeft": 8, - "textAlign": "left", }, { "color": "rgba(73, 69, 79, 1)", @@ -204,6 +203,7 @@ exports[`activity indicator snapshot test 1`] = ` "fontWeight": "400", "letterSpacing": 0.15, "lineHeight": 0, + "textAlign": "left", }, { "minHeight": 56, @@ -613,7 +613,6 @@ exports[`renders with placeholder 1`] = ` "fontSize": 18, "minWidth": 0, "paddingLeft": 8, - "textAlign": "left", }, { "color": "rgba(73, 69, 79, 1)", @@ -622,6 +621,7 @@ exports[`renders with placeholder 1`] = ` "fontWeight": "400", "letterSpacing": 0.15, "lineHeight": 0, + "textAlign": "left", }, { "minHeight": 56, @@ -990,7 +990,6 @@ exports[`renders with text 1`] = ` "fontSize": 18, "minWidth": 0, "paddingLeft": 8, - "textAlign": "left", }, { "color": "rgba(73, 69, 79, 1)", @@ -999,6 +998,7 @@ exports[`renders with text 1`] = ` "fontWeight": "400", "letterSpacing": 0.15, "lineHeight": 0, + "textAlign": "left", }, { "minHeight": 56, diff --git a/src/core/PaperProvider.tsx b/src/core/PaperProvider.tsx index 01a80fdcd9..cdb2d5d8a9 100644 --- a/src/core/PaperProvider.tsx +++ b/src/core/PaperProvider.tsx @@ -6,6 +6,7 @@ import { NativeEventSubscription, } from 'react-native'; +import { getDefaultDirection, LocaleProvider, type Direction } from './locale'; import SafeAreaProviderCompat from './SafeAreaProviderCompat'; import { Provider as SettingsProvider, Settings } from './settings'; import { defaultThemes, ThemeProvider } from './theming'; @@ -18,6 +19,7 @@ export type Props = { children: React.ReactNode; theme?: ThemeProp; settings?: Settings; + direction?: Direction; }; const PaperProvider = (props: Props) => { @@ -88,6 +90,8 @@ const PaperProvider = (props: Props) => { const { children, settings } = props; + const direction = props.direction ?? getDefaultDirection(); + const settingsValue = React.useMemo( () => ({ icon: MaterialCommunityIcon, @@ -101,7 +105,9 @@ const PaperProvider = (props: Props) => { - {children} + + {children} + diff --git a/src/core/locale.tsx b/src/core/locale.tsx new file mode 100644 index 0000000000..d22e17b3e3 --- /dev/null +++ b/src/core/locale.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { I18nManager } from 'react-native'; + +/** + * Writing direction of the app. + * Use this to override RTL/LTR on platforms where `I18nManager` is a no-op (e.g. React Native Web). + */ +export type Direction = 'ltr' | 'rtl'; + +export type LocaleContextValue = { + direction: Direction; +}; + +export const LocaleContext = React.createContext({ + direction: I18nManager.getConstants?.().isRTL ? 'rtl' : 'ltr', +}); + +export type LocaleProviderProps = { + direction: Direction; + children: React.ReactNode; +}; + +/** + * Provider component for locale configuration. + */ +export function LocaleProvider({ direction, children }: LocaleProviderProps) { + const value = React.useMemo(() => ({ direction }), [direction]); + return ( + {children} + ); +} + +/** + * Returns the locale context value. Must be used inside a `PaperProvider` (or `LocaleProvider`). + * Falls back to the system direction from `I18nManager` when used outside a provider. + */ +export function useLocale(): LocaleContextValue { + return React.useContext(LocaleContext); +} + +export const getDefaultDirection = (): Direction => + I18nManager.getConstants?.().isRTL ? 'rtl' : 'ltr'; diff --git a/src/index.tsx b/src/index.tsx index ec0c4d4786..761df47a23 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,6 +8,8 @@ export { adaptNavigationTheme, } from './core/theming'; +export { useLocale, LocaleProvider } from './core/locale'; + export * from './styles/themes'; export { default as Provider } from './core/PaperProvider'; diff --git a/src/react-navigation/views/MaterialBottomTabView.tsx b/src/react-navigation/views/MaterialBottomTabView.tsx index 98378e0c79..07d3644d16 100644 --- a/src/react-navigation/views/MaterialBottomTabView.tsx +++ b/src/react-navigation/views/MaterialBottomTabView.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { I18nManager, Platform, StyleSheet } from 'react-native'; +import { Platform, StyleSheet } from 'react-native'; import { CommonActions, @@ -12,6 +12,7 @@ import { import BottomNavigation from '../../components/BottomNavigation/BottomNavigation'; import MaterialCommunityIcon from '../../components/MaterialCommunityIcon'; +import { useLocale } from '../../core/locale'; import type { MaterialBottomTabDescriptorMap, MaterialBottomTabNavigationConfig, @@ -30,6 +31,7 @@ export default function MaterialBottomTabView({ ...rest }: Props) { const buildLink = useLinkBuilder(); + const { direction } = useLocale(); return (