diff --git a/example/src/Screens/BottomTabs.tsx b/example/src/Screens/BottomTabs.tsx index 2939c74b..b2b4e5ed 100644 --- a/example/src/Screens/BottomTabs.tsx +++ b/example/src/Screens/BottomTabs.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { Platform } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import TouchableBounce from '../Shared/TouchableBounce'; @@ -28,7 +29,10 @@ export default function BottomTabsScreen() { return ( , + tabBarButton: + Platform.OS === 'web' + ? undefined + : (props) => , }} > ; }; -export type BottomTabBarButtonProps = TouchableWithoutFeedbackProps & { +export type BottomTabBarButtonProps = Omit< + TouchableWithoutFeedbackProps, + 'onPress' +> & { + href?: string; children: React.ReactNode; + onPress?: ( + e: React.MouseEvent | GestureResponderEvent + ) => void; }; diff --git a/packages/bottom-tabs/src/views/BottomTabBar.tsx b/packages/bottom-tabs/src/views/BottomTabBar.tsx index 568127fb..1d5ee4ac 100644 --- a/packages/bottom-tabs/src/views/BottomTabBar.tsx +++ b/packages/bottom-tabs/src/views/BottomTabBar.tsx @@ -14,6 +14,7 @@ import { NavigationRouteContext, CommonActions, useTheme, + useLinkBuilder, } from '@react-navigation/native'; import { useSafeArea } from 'react-native-safe-area-context'; @@ -50,6 +51,7 @@ export default function BottomTabBar({ tabStyle, }: Props) { const { colors } = useTheme(); + const buildLink = useLinkBuilder({ navigation }); const [dimensions, setDimensions] = React.useState(() => { const { height = 0, width = 0 } = Dimensions.get('window'); @@ -260,6 +262,7 @@ export default function BottomTabBar({ onPress={onPress} onLongPress={onLongPress} accessibilityLabel={accessibilityLabel} + href={buildLink(route.name, route.params)} testID={options.tabBarTestID} allowFontScaling={allowFontScaling} activeTintColor={activeTintColor} diff --git a/packages/bottom-tabs/src/views/BottomTabItem.tsx b/packages/bottom-tabs/src/views/BottomTabItem.tsx index 4a82b0f4..aa281d86 100644 --- a/packages/bottom-tabs/src/views/BottomTabItem.tsx +++ b/packages/bottom-tabs/src/views/BottomTabItem.tsx @@ -4,11 +4,13 @@ import { TouchableWithoutFeedback, Animated, StyleSheet, + Platform, StyleProp, ViewStyle, TextStyle, + GestureResponderEvent, } from 'react-native'; -import { Route, useTheme } from '@react-navigation/native'; +import { Link, Route, useTheme } from '@react-navigation/native'; import Color from 'color'; import TabBarIcon from './TabBarIcon'; @@ -37,6 +39,10 @@ type Props = { size: number; color: string; }) => React.ReactNode; + /** + * URL to use for the link to the tab. + */ + href?: string; /** * The button for the tab. Uses a `TouchableWithoutFeedback` by default. */ @@ -50,13 +56,16 @@ type Props = { */ testID?: string; /** - * Function to execute on press. + * Function to execute on press in React Native. + * On the web, this will use onClick. */ - onPress: () => void; + onPress: ( + e: React.MouseEvent | GestureResponderEvent + ) => void; /** * Function to execute on long press. */ - onLongPress: () => void; + onLongPress: (e: GestureResponderEvent) => void; /** * Whether the label should be aligned with the icon horizontally. */ @@ -104,11 +113,37 @@ export default function BottomTabBarItem({ route, label, icon, - button = ({ children, style, ...rest }: BottomTabBarButtonProps) => ( - - {children} - - ), + href, + button = ({ + children, + style, + onPress, + href, + ...rest + }: BottomTabBarButtonProps) => { + if (Platform.OS === 'web' && href) { + // React Native Web doesn't forward `onClick` if we use `TouchableWithoutFeedback`. + // We need to use `onClick` to be able to prevent default browser handling of links. + return ( + + {children} + + ); + } else { + return ( + + {children} + + ); + } + }, accessibilityLabel, testID, onPress, @@ -196,6 +231,7 @@ export default function BottomTabBarItem({ : inactiveBackgroundColor; return button({ + href, onPress, onLongPress, testID, @@ -248,4 +284,7 @@ const styles = StyleSheet.create({ fontSize: 12, marginLeft: 20, }, + button: { + display: 'flex', + }, }); diff --git a/packages/core/src/types.tsx b/packages/core/src/types.tsx index 222af6f0..f1f5ff92 100644 --- a/packages/core/src/types.tsx +++ b/packages/core/src/types.tsx @@ -193,6 +193,20 @@ type NavigationHelpersCommon< * Note that this method doesn't re-render screen when the result changes. So don't use it in `render`. */ canGoBack(): boolean; + + /** + * Returns the parent navigator, if any. Reason why the function is called + * dangerouslyGetParent is to warn developers against overusing it to eg. get parent + * of parent and other hard-to-follow patterns. + */ + dangerouslyGetParent | undefined>(): T; + + /** + * Returns the navigator's state. Reason why the function is called + * dangerouslyGetState is to discourage developers to use internal navigation's state. + * Note that this method doesn't re-render screen when the result changes. So don't use it in `render`. + */ + dangerouslyGetState(): State; } & PrivateValueStore; export type NavigationHelpers< @@ -254,20 +268,6 @@ export type NavigationProp< * @param options Options object for the route. */ setOptions(options: Partial): void; - - /** - * Returns the parent navigator, if any. Reason why the function is called - * dangerouslyGetParent is to warn developers against overusing it to eg. get parent - * of parent and other hard-to-follow patterns. - */ - dangerouslyGetParent | undefined>(): T; - - /** - * Returns the navigator's state. Reason why the function is called - * dangerouslyGetState is to discourage developers to use internal navigation's state. - * Note that this method doesn't re-render screen when the result changes. So don't use it in `render`. - */ - dangerouslyGetState(): State; } & EventConsumer> & PrivateValueStore; diff --git a/packages/core/src/useNavigationCache.tsx b/packages/core/src/useNavigationCache.tsx index 6ac5dfc4..41364d29 100644 --- a/packages/core/src/useNavigationCache.tsx +++ b/packages/core/src/useNavigationCache.tsx @@ -7,7 +7,6 @@ import { Router, } from '@react-navigation/routers'; import { NavigationEventEmitter } from './useEventEmitter'; -import NavigationContext from './NavigationContext'; import { NavigationHelpers, NavigationProp } from './types'; @@ -49,12 +48,10 @@ export default function useNavigationCache< // Cache object which holds navigation objects for each screen // We use `React.useMemo` instead of `React.useRef` coz we want to invalidate it when deps change // In reality, these deps will rarely change, if ever - const parentNavigation = React.useContext(NavigationContext); - const cache = React.useMemo( () => ({ current: {} as NavigationCache }), // eslint-disable-next-line react-hooks/exhaustive-deps - [getState, navigation, setOptions, router, emitter, parentNavigation] + [getState, navigation, setOptions, router, emitter] ); const actions = { @@ -99,8 +96,6 @@ export default function useNavigationCache< ...rest, ...helpers, ...emitter.create(route.key), - dangerouslyGetParent: () => parentNavigation as any, - dangerouslyGetState: getState, dispatch, setOptions: (options: object) => setOptions((o) => ({ diff --git a/packages/core/src/useNavigationHelpers.tsx b/packages/core/src/useNavigationHelpers.tsx index 02519850..a98f7571 100644 --- a/packages/core/src/useNavigationHelpers.tsx +++ b/packages/core/src/useNavigationHelpers.tsx @@ -112,6 +112,8 @@ export default function useNavigationHelpers< false ); }, + dangerouslyGetParent: () => parentNavigationHelpers as any, + dangerouslyGetState: getState, } as NavigationHelpers & (NavigationProp | undefined); }, [router, getState, parentNavigationHelpers, emitter.emit, onAction]); diff --git a/packages/drawer/src/views/DrawerItem.tsx b/packages/drawer/src/views/DrawerItem.tsx index 4d44d8c6..40af4b6e 100644 --- a/packages/drawer/src/views/DrawerItem.tsx +++ b/packages/drawer/src/views/DrawerItem.tsx @@ -6,8 +6,10 @@ import { StyleProp, ViewStyle, TextStyle, + Platform, + TouchableWithoutFeedbackProps, } from 'react-native'; -import { useTheme } from '@react-navigation/native'; +import { Link, useTheme } from '@react-navigation/native'; import Color from 'color'; import TouchableItem from './TouchableItem'; @@ -26,6 +28,10 @@ type Props = { size: number; color: string; }) => React.ReactNode; + /** + * URL to use for the link to the tab. + */ + href?: string; /** * Whether to highlight the drawer item as active. */ @@ -60,6 +66,42 @@ type Props = { style?: StyleProp; }; +const Touchable = ({ + children, + style, + onPress, + href, + delayPressIn, + ...rest +}: TouchableWithoutFeedbackProps & { + href?: string; + children: React.ReactNode; + onPress?: () => void; +}) => { + if (Platform.OS === 'web' && href) { + // React Native Web doesn't forward `onClick` if we use `TouchableWithoutFeedback`. + // We need to use `onClick` to be able to prevent default browser handling of links. + return ( + + {children} + + ); + } else { + return ( + + {children} + + ); + } +}; + /** * A component used to show an action item with an icon and a label in a navigation drawer. */ @@ -70,6 +112,7 @@ export default function DrawerItem(props: Props) { icon, label, labelStyle, + href, focused = false, activeTintColor = colors.primary, inactiveTintColor = Color(colors.text).alpha(0.68).rgb().string(), @@ -94,7 +137,7 @@ export default function DrawerItem(props: Props) { {...rest} style={[styles.container, { borderRadius, backgroundColor }, style]} > - {iconNode} @@ -129,7 +173,7 @@ export default function DrawerItem(props: Props) { )} - + ); } @@ -148,4 +192,7 @@ const styles = StyleSheet.create({ label: { marginRight: 32, }, + button: { + display: 'flex', + }, }); diff --git a/packages/drawer/src/views/DrawerItemList.tsx b/packages/drawer/src/views/DrawerItemList.tsx index b008eca5..c2e35406 100644 --- a/packages/drawer/src/views/DrawerItemList.tsx +++ b/packages/drawer/src/views/DrawerItemList.tsx @@ -3,6 +3,7 @@ import { CommonActions, DrawerActions, DrawerNavigationState, + useLinkBuilder, } from '@react-navigation/native'; import DrawerItem from './DrawerItem'; import { @@ -31,6 +32,8 @@ export default function DrawerItemList({ itemStyle, labelStyle, }: Props) { + const buildLink = useLinkBuilder({ navigation }); + return (state.routes.map((route, i) => { const focused = i === state.index; const { title, drawerLabel, drawerIcon } = descriptors[route.key].options; @@ -53,6 +56,7 @@ export default function DrawerItemList({ inactiveBackgroundColor={inactiveBackgroundColor} labelStyle={labelStyle} style={itemStyle} + href={buildLink(route.name, route.params)} onPress={() => { navigation.dispatch({ ...(focused diff --git a/packages/native/src/Link.tsx b/packages/native/src/Link.tsx index e0b1c3fc..5f45fae5 100644 --- a/packages/native/src/Link.tsx +++ b/packages/native/src/Link.tsx @@ -1,46 +1,58 @@ import * as React from 'react'; -import { Text, TextProps, GestureResponderEvent, Platform } from 'react-native'; +import { Text, TextProps, Platform, GestureResponderEvent } from 'react-native'; import useLinkTo from './useLinkTo'; type Props = { to: string; target?: string; + onLink?: ( + e: React.MouseEvent | GestureResponderEvent + ) => void; } & (TextProps & { children: React.ReactNode }); -export default function Link({ to, children, ...rest }: Props) { +export default function Link({ to, children, onLink, ...rest }: Props) { const linkTo = useLinkTo(); - const onPress = (e: GestureResponderEvent | undefined) => { + const onPress = ( + e: React.MouseEvent | GestureResponderEvent + ) => { if ('onPress' in rest) { - rest.onPress?.(e as GestureResponderEvent); + // @ts-ignore + rest.onPress?.(e); } - const event = (e?.nativeEvent as any) as - | React.MouseEvent - | undefined; + let shouldHandle = false; if (Platform.OS !== 'web' || !event) { - linkTo(to); - return; - } - - event.preventDefault(); - - if ( + shouldHandle = event ? !event.defaultPrevented : true; + } else if ( !event.defaultPrevented && // onPress prevented default + // @ts-ignore !(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) && // ignore clicks with modifier keys + // @ts-ignore (event.button == null || event.button === 0) && // ignore everything but left clicks (rest.target == null || rest.target === '_self') // let browser handle "target=_blank" etc. ) { event.preventDefault(); - linkTo(to); + shouldHandle = true; + } + + if (shouldHandle) { + if (onLink) { + onLink(e); + } else { + linkTo(to); + } } }; const props = { href: to, - onPress, accessibilityRole: 'link' as const, + ...Platform.select({ + web: { onClick: onPress } as any, + default: { onPress }, + }), ...rest, }; diff --git a/packages/native/src/LinkingContext.tsx b/packages/native/src/LinkingContext.tsx index 7bdffef7..836d510b 100644 --- a/packages/native/src/LinkingContext.tsx +++ b/packages/native/src/LinkingContext.tsx @@ -1,12 +1,8 @@ import * as React from 'react'; import { LinkingOptions } from './types'; -const LinkingContext = React.createContext<() => LinkingOptions | undefined>( - () => { - throw new Error( - "Couldn't find a linking context. Have you wrapped your app with 'NavigationContainer'?" - ); - } -); +const LinkingContext = React.createContext<{ + options: LinkingOptions | undefined; +}>({ options: undefined }); export default LinkingContext; diff --git a/packages/native/src/NavigationContainer.tsx b/packages/native/src/NavigationContainer.tsx index 215298f7..dfa0ebce 100644 --- a/packages/native/src/NavigationContainer.tsx +++ b/packages/native/src/NavigationContainer.tsx @@ -52,13 +52,7 @@ const NavigationContainer = React.forwardRef(function NavigationContainer( React.useImperativeHandle(ref, () => refContainer.current); - const linkingOptionsRef = React.useRef(linking); - - React.useEffect(() => { - linkingOptionsRef.current = linking; - }); - - const linkingContext = React.useCallback(() => linkingOptionsRef.current, []); + const linkingContext = React.useMemo(() => ({ options: linking }), [linking]); if (!isReady) { // This is temporary until we have Suspense for data-fetching diff --git a/packages/native/src/index.tsx b/packages/native/src/index.tsx index a95cf5eb..770f1fce 100644 --- a/packages/native/src/index.tsx +++ b/packages/native/src/index.tsx @@ -13,3 +13,4 @@ export { default as useTheme } from './theming/useTheme'; export { default as Link } from './Link'; export { default as useLinking } from './useLinking'; export { default as useLinkTo } from './useLinkTo'; +export { default as useLinkBuilder } from './useLinkBuilder'; diff --git a/packages/native/src/useLinkBuilder.tsx b/packages/native/src/useLinkBuilder.tsx new file mode 100644 index 00000000..95dcaef5 --- /dev/null +++ b/packages/native/src/useLinkBuilder.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { + NavigationHelpers, + NavigationProp, + ParamListBase, + EventMapBase, + getPathFromState, +} from '@react-navigation/core'; +import LinkingContext from './LinkingContext'; + +type NavigationObject = + | NavigationHelpers + | NavigationProp; + +type MinimalState = { + index: number; + routes: { name: string; params?: object; state?: MinimalState }[]; +}; + +const getRootState = ( + navigation: NavigationObject, + state: MinimalState +): MinimalState => { + const parent = navigation.dangerouslyGetParent(); + + if (parent) { + const parentState = parent.dangerouslyGetState(); + + return getRootState(parent, { + index: 0, + routes: [ + { + ...parentState.routes[parentState.index], + state: state, + }, + ], + }); + } + + return state; +}; + +/** + * Build destination link for a navigate action. + * Useful for showing anchor tags on the web for buttons that perform navigation. + * + * @param options.navigation Navigation object for the navigator. + */ +export default function useLinkBuilder({ + navigation, +}: { + navigation: NavigationHelpers; +}) { + const linking = React.useContext(LinkingContext); + + const buildLink = React.useCallback( + (name: string, params?: object) => { + const { options } = linking; + + const state = getRootState(navigation, { + index: 0, + routes: [{ name, params }], + }); + + const path = options?.getPathFromState + ? options.getPathFromState(state, options?.config) + : getPathFromState(state, options?.config); + + return path; + }, + [linking, navigation] + ); + + return buildLink; +} diff --git a/packages/native/src/useLinkTo.tsx b/packages/native/src/useLinkTo.tsx index 93bc6f20..da0fbec1 100644 --- a/packages/native/src/useLinkTo.tsx +++ b/packages/native/src/useLinkTo.tsx @@ -1,14 +1,14 @@ import * as React from 'react'; import { - useNavigation, getStateFromPath, getActionFromState, + NavigationContext, } from '@react-navigation/core'; import LinkingContext from './LinkingContext'; export default function useLinkTo() { - const navigation = useNavigation(); - const getOptions = React.useContext(LinkingContext); + const navigation = React.useContext(NavigationContext); + const linking = React.useContext(LinkingContext); const linkTo = React.useCallback( (path: string) => { @@ -16,7 +16,13 @@ export default function useLinkTo() { throw new Error(`The path must start with '/' (${path}).`); } - const options = getOptions(); + if (navigation === undefined) { + throw new Error( + "Couldn't find a navigation object. Is your component inside a screen in a navigator?" + ); + } + + const { options } = linking; const state = options?.getStateFromPath ? options.getStateFromPath(path, options.config) @@ -42,7 +48,7 @@ export default function useLinkTo() { throw new Error('Failed to parse the path to a navigation state.'); } }, - [getOptions, navigation] + [linking, navigation] ); return linkTo;