@rn-tools/navigation 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,8 @@ A set of useful navigation components for React Native. Built with `react-native
18
18
  - [Tabs](#tabs)
19
19
  - [Guides](#guides)
20
20
  - [Authentication](#authentication)
21
+ - [Deep Links](#deep-links)
22
+ - [Preventing going back](#preventing-going-back)
21
23
 
22
24
  ## Installation
23
25
 
@@ -301,6 +303,8 @@ Use the `Stack.Header` component to render a native header in a screen.
301
303
 
302
304
  Under the hood this is using `react-native-screens` header - [here is a reference for the available props](https://github.com/software-mansion/react-native-screens/blob/main/guides/GUIDE_FOR_LIBRARY_AUTHORS.md#screenstackheaderconfig)
303
305
 
306
+ You can provide custom left, center, and right views in the header by using the `Stack.HeaderLeft`, `Stack.HeaderCenter`, and `Stack.HeaderRight` view container components as children of `Stack.Header`.
307
+
304
308
  **Note:** Wrap your App in a `SafeAreaProvider` to ensure your screen components are rendered correctly with the header
305
309
 
306
310
  **Note:**: The header component **has to be the first child** of a `Stack.Screen` component.
@@ -308,7 +312,7 @@ Under the hood this is using `react-native-screens` header - [here is a referenc
308
312
  ```tsx
309
313
  import { navigation, Stack } from "@rn-tools/navigation";
310
314
  import * as React from "react";
311
- import { Button, View, TextInput } from "react-native";
315
+ import { Button, View, TextInput, Text } from "react-native";
312
316
 
313
317
  export function HeaderExample() {
314
318
  return (
@@ -333,7 +337,11 @@ function MyScreenWithHeader() {
333
337
  backTitle="Custom back title"
334
338
  backTitleFontSize={16}
335
339
  hideBackButton={false}
336
- />
340
+ >
341
+ <Stack.HeaderRight>
342
+ <Text>Custom right text!</Text>
343
+ </Stack.HeaderRight>
344
+ </Stack.Header>
337
345
 
338
346
  <View
339
347
  style={{
@@ -356,7 +364,7 @@ function MyScreenWithHeader() {
356
364
 
357
365
  ## Components
358
366
 
359
- The `Navigator` components in the previous examples are fairly straightforward wrappers around other lower level `Stack` and `Tabs` components.
367
+ The `Navigator` components in the previous examples are fairly straightforward wrappers around other lower level `Stack` and `Tabs` components.
360
368
 
361
369
  If you need to customize behaviour, design a component API you prefer to use, or just enjoy writing your own components, you can use these implementations as a reference to build your own.
362
370
 
@@ -462,7 +470,6 @@ let TabNavigator = React.memo(function TabNavigator({
462
470
  </Tabs.Root>
463
471
  );
464
472
  });
465
-
466
473
  ```
467
474
 
468
475
  - `Tabs.Root` - The root component for a tabs navigator.
@@ -475,8 +482,6 @@ let TabNavigator = React.memo(function TabNavigator({
475
482
  - `Tabs.Tab` - A tab in a tabs navigator
476
483
  - This is a Pressable component that switches the active screen
477
484
 
478
-
479
-
480
485
  ## Guides
481
486
 
482
487
  ### Authentication
@@ -522,3 +527,219 @@ let useUser = () => {
522
527
  return user;
523
528
  };
524
529
  ```
530
+
531
+ ### Deep Links
532
+
533
+ This section will cover how to respond to deep links in your app. Deep links usually have some extra setup required - use Expo's [Deep Linking Guide](https://docs.expo.dev/guides/deep-linking/) to get started.
534
+
535
+ Once you are able to receive deep links, use the `DeepLinks` component exported from this library to handle them. In this example we will have a basic 3 tab view. We want to response to the link `home/items/:id` by navigating to the home tab and then pushing a detail screen with the corresponding id.
536
+
537
+ The deep link component takes an array of handlers which are functions that will be invoked when their `path` matches the deep link that was opened. The handler function will receive the params from the deep link - these use the same token syntax as libraries like `react-router` and `express` for path params.
538
+
539
+
540
+ ```tsx
541
+ import { DeepLinks, navigation, Stack, Tabs } from "@rn-tools/navigation";
542
+ import * as React from "react";
543
+ import { View, Text, TouchableOpacity } from "react-native";
544
+ import * as Linking from "expo-linking";
545
+
546
+ export function DeepLinksExample() {
547
+ // You'll likely want to use Expo's Linking API to get the current URL and path
548
+ // let url = Linking.useURL()
549
+ // let { path } = Linking.parse(url)
550
+
551
+ // But it's easier to test hardcoded strings for the sake of this example
552
+ let path = "/testing/home/item/4";
553
+
554
+ return (
555
+ <DeepLinks
556
+ path={path}
557
+ handlers={[
558
+ {
559
+ path: "/testing/home/item/:itemId",
560
+ handler: (params: { itemId: string }) => {
561
+ let itemId = params.itemId;
562
+
563
+ // Go to home tab
564
+ navigation.setTabIndex(0);
565
+
566
+ // Push the screen we want
567
+ navigation.pushScreen(
568
+ <Stack.Screen>
569
+ <MyScreen title={`Item: ${itemId}`} />
570
+ </Stack.Screen>
571
+ );
572
+ },
573
+ },
574
+ ]}
575
+ >
576
+ <MyTabs />
577
+ </DeepLinks>
578
+ );
579
+ }
580
+
581
+ function MyTabs() {
582
+ return (
583
+ <Tabs.Navigator
584
+ tabbarPosition="bottom"
585
+ screens={[
586
+ {
587
+ key: "1",
588
+ screen: (
589
+ <Stack.Navigator
590
+ rootScreen={<MyScreen bg="red" title="Home screen" isRoot />}
591
+ />
592
+ ),
593
+ tab: ({ isActive }) => <MyTab text="Home" isActive={isActive} />,
594
+ },
595
+ {
596
+ key: "2",
597
+ screen: (
598
+ <Stack.Navigator
599
+ rootScreen={<MyScreen bg="blue" title="Search screen" isRoot />}
600
+ />
601
+ ),
602
+ tab: ({ isActive }) => <MyTab text="Search" isActive={isActive} />,
603
+ },
604
+ {
605
+ key: "3",
606
+ screen: (
607
+ <Stack.Navigator
608
+ rootScreen={
609
+ <MyScreen bg="purple" title="Settings screen" isRoot />
610
+ }
611
+ />
612
+ ),
613
+ tab: ({ isActive }) => <MyTab text="Settings" isActive={isActive} />,
614
+ },
615
+ ]}
616
+ />
617
+ );
618
+ }
619
+
620
+ function MyTab({ isActive, text }: { isActive?: boolean; text: string }) {
621
+ return (
622
+ <View
623
+ style={{
624
+ padding: 16,
625
+ justifyContent: "center",
626
+ alignItems: "center",
627
+ }}
628
+ >
629
+ <Text style={{ fontSize: 12, fontWeight: isActive ? "bold" : "normal" }}>
630
+ {text}
631
+ </Text>
632
+ </View>
633
+ );
634
+ }
635
+
636
+ function MyScreen({
637
+ bg = "white",
638
+ title = "",
639
+ isRoot = false,
640
+ }: {
641
+ title?: string;
642
+ bg?: string;
643
+ isRoot?: boolean;
644
+ }) {
645
+ return (
646
+ <View style={{ flex: 1, backgroundColor: bg }}>
647
+ <View className="flex-1 items-center justify-center gap-4">
648
+ <Text style={{ fontSize: 26, fontWeight: "semibold" }}>{title}</Text>
649
+
650
+ {!isRoot && (
651
+ <TouchableOpacity
652
+ onPress={() => {
653
+ navigation.popScreen();
654
+ }}
655
+ >
656
+ <Text>Pop</Text>
657
+ </TouchableOpacity>
658
+ )}
659
+ </View>
660
+ </View>
661
+ );
662
+ }
663
+ ```
664
+
665
+ ### Preventing going back
666
+
667
+ If you want to prevent users from popping a screen and potentially losing unsaved data, you can stop the screen from being dismissed by a gesture or pressing the back button.
668
+
669
+ **Note:**: The native header component does not provide a reliable way to prevent going back on iOS, so you'll have to provide your own custom back button by using the `Stack.HeaderLeft` component
670
+
671
+ ```tsx
672
+ import { navigation, Stack } from "@rn-tools/navigation";
673
+ import * as React from "react";
674
+ import {
675
+ Text,
676
+ TextInput,
677
+ TouchableOpacity,
678
+ Button,
679
+ View,
680
+ Alert,
681
+ } from "react-native";
682
+
683
+ export function PreventGoingBack() {
684
+ return (
685
+ <Button
686
+ title="Push screen"
687
+ onPress={() => navigation.pushScreen(<MyScreen />)}
688
+ />
689
+ );
690
+ }
691
+
692
+ function MyScreen() {
693
+ let [input, setInput] = React.useState("");
694
+
695
+ let canGoBack = input.length === 0;
696
+
697
+ let onPressBackButton = React.useCallback(() => {
698
+ if (canGoBack) {
699
+ navigation.popScreen();
700
+ } else {
701
+ Alert.alert("Are you sure you want to go back?", "", [
702
+ {
703
+ text: "Cancel",
704
+ style: "cancel",
705
+ },
706
+ {
707
+ text: "Yes",
708
+ onPress: () => navigation.popScreen(),
709
+ },
710
+ ]);
711
+ }
712
+ }, [canGoBack]);
713
+
714
+ return (
715
+ <Stack.Screen
716
+ preventNativeDismiss={!canGoBack}
717
+ nativeBackButtonDismissalEnabled={!canGoBack}
718
+ gestureEnabled={canGoBack}
719
+ >
720
+ <Stack.Header title="Prevent going back">
721
+ <Stack.HeaderLeft>
722
+ <TouchableOpacity
723
+ onPress={onPressBackButton}
724
+ style={{ opacity: canGoBack ? 1 : 0.4 }}
725
+ >
726
+ <Text>Back</Text>
727
+ </TouchableOpacity>
728
+ </Stack.HeaderLeft>
729
+ </Stack.Header>
730
+ <View style={{ paddingVertical: 48, paddingHorizontal: 16, gap: 16 }}>
731
+ <Text style={{ fontSize: 22, fontWeight: "medium" }}>
732
+ Enter some text and try to go back
733
+ </Text>
734
+ <TextInput
735
+ value={input}
736
+ onChangeText={setInput}
737
+ placeholder="Enter some text"
738
+ onSubmitEditing={() => setInput("")}
739
+ />
740
+ <Button title="Submit" onPress={() => setInput("")} />
741
+ </View>
742
+ </Stack.Screen>
743
+ );
744
+ }
745
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rn-tools/navigation",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "main": "./src/index.ts",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -34,6 +34,7 @@
34
34
  "react-native-screens": "*"
35
35
  },
36
36
  "dependencies": {
37
+ "path-to-regexp": "^6.2.2",
37
38
  "zustand": "^4.5.2"
38
39
  },
39
40
  "jest": {
@@ -0,0 +1,39 @@
1
+ import { match } from "path-to-regexp";
2
+ import * as React from "react";
3
+
4
+ export type DeepLinkHandler<T> = {
5
+ path: string;
6
+ handler: (params: T) => void;
7
+ };
8
+
9
+ function buildMatchers<T>(handlers: DeepLinkHandler<T>[]) {
10
+ return handlers.map(({ path, handler }) => {
11
+ let fn = match(path, { decode: decodeURIComponent });
12
+ return { fn, handler };
13
+ });
14
+ }
15
+
16
+ type DeepLinksProps<T> = {
17
+ path: string;
18
+ handlers: DeepLinkHandler<T>[];
19
+ children: React.ReactNode;
20
+ };
21
+
22
+ export function DeepLinks<T>({ path, handlers, children }: DeepLinksProps<T>) {
23
+ let matchers = React.useRef(buildMatchers(handlers));
24
+
25
+ React.useLayoutEffect(() => {
26
+ matchers.current = buildMatchers(handlers);
27
+ }, [handlers]);
28
+
29
+ React.useEffect(() => {
30
+ matchers.current.forEach(({ fn, handler }) => {
31
+ let match = fn(path);
32
+ if (match) {
33
+ return setImmediate(() => handler(match.params as T));
34
+ }
35
+ });
36
+ }, [path]);
37
+
38
+ return <>{children}</>;
39
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./navigation";
2
2
  export * from "./stack";
3
- export * from "./tabs";
3
+ export * from "./tabs";
4
+ export * from './deep-links'
@@ -103,6 +103,12 @@ type SetTabIndexAction = {
103
103
  tabId: string;
104
104
  };
105
105
 
106
+ type PopActiveTabAction = {
107
+ type: "POP_ACTIVE_TAB";
108
+ tabId: string;
109
+ index: number;
110
+ };
111
+
106
112
  type RegisterTabAction = {
107
113
  type: "REGISTER_TAB";
108
114
  depth: number;
@@ -126,7 +132,8 @@ type TabActions =
126
132
  | SetTabIndexAction
127
133
  | RegisterTabAction
128
134
  | UnregisterTabAction
129
- | TabBackAction;
135
+ | TabBackAction
136
+ | PopActiveTabAction;
130
137
 
131
138
  type SetDebugModeAction = {
132
139
  type: "SET_DEBUG_MODE";
@@ -375,28 +382,35 @@ export function reducer(
375
382
  }
376
383
  );
377
384
 
378
- if (tab.activeIndex === index) {
379
- let tabKey = serializeTabIndexKey(tabId, index);
380
- let stackIds = renderCharts.stacksByTabIndex[tabKey];
385
+ return nextState;
386
+ }
387
+
388
+ case "POP_ACTIVE_TAB": {
389
+ let { tabId, index } = action;
390
+ let { renderCharts } = context;
391
+
392
+ let tabKey = serializeTabIndexKey(tabId, index);
393
+ let stackIds = renderCharts.stacksByTabIndex[tabKey];
381
394
 
382
- if (stackIds?.length > 0) {
383
- stackIds.forEach((stackId) => {
384
- let stack = nextState.stacks.lookup[stackId];
385
- let screenIdsToRemove = stack.screens;
395
+ let nextState: NavigationState = Object.assign({}, state);
386
396
 
387
- let nextScreensLookup = Object.assign({}, nextState.screens.lookup);
397
+ if (stackIds?.length > 0) {
398
+ stackIds.forEach((stackId) => {
399
+ let stack = nextState.stacks.lookup[stackId];
400
+ let screenIdsToRemove = stack.screens;
388
401
 
389
- screenIdsToRemove.forEach((id) => {
390
- delete nextScreensLookup[id];
391
- });
402
+ let nextScreensLookup = Object.assign({}, nextState.screens.lookup);
392
403
 
393
- nextState.stacks.lookup[stackId].screens = [];
394
- nextState.screens.ids = nextState.screens.ids.filter(
395
- (id) => !screenIdsToRemove.includes(id)
396
- );
397
- nextState.screens.lookup = nextScreensLookup;
404
+ screenIdsToRemove.forEach((id) => {
405
+ delete nextScreensLookup[id];
398
406
  });
399
- }
407
+
408
+ nextState.stacks.lookup[stackId].screens = [];
409
+ nextState.screens.ids = nextState.screens.ids.filter(
410
+ (id) => !screenIdsToRemove.includes(id)
411
+ );
412
+ nextState.screens.lookup = nextScreensLookup;
413
+ });
400
414
  }
401
415
 
402
416
  return nextState;
@@ -442,7 +456,7 @@ export function reducer(
442
456
  let { tabId } = action;
443
457
  let nextState: NavigationState = Object.assign({}, state);
444
458
 
445
- let tab = nextState.tabs.lookup[tabId];
459
+ let tab = nextState.tabs.lookup[tabId];
446
460
 
447
461
  let lastActiveIndex = tab.history[tab.history.length - 1];
448
462
 
@@ -12,9 +12,9 @@ import type { PushScreenOptions } from "./types";
12
12
 
13
13
  /**
14
14
  * Ideas:
15
- * - pull in safe area provider and use as default props to tabs
16
15
  * - lifecycles / screen tracking
17
- * - testing - internal and jest plugin
16
+ * - testing guide
17
+ * - routing example -> fragments to ids
18
18
  */
19
19
 
20
20
  export function createNavigation() {
package/src/stack.tsx CHANGED
@@ -6,7 +6,9 @@ import {
6
6
  StyleSheet,
7
7
  useWindowDimensions,
8
8
  View,
9
+ type ImageProps,
9
10
  type LayoutRectangle,
11
+ type ViewProps,
10
12
  type ViewStyle,
11
13
  } from "react-native";
12
14
  import {
@@ -14,7 +16,11 @@ import {
14
16
  Screen as RNScreen,
15
17
  ScreenProps as RNScreenProps,
16
18
  ScreenStackHeaderConfig as RNScreenStackHeaderConfig,
19
+ ScreenStackHeaderLeftView as RNScreenStackHeaderLeftView,
20
+ ScreenStackHeaderRightView as RNScreenStackHeaderRightView,
21
+ ScreenStackHeaderCenterView as RNScreenStackHeaderCenterView,
17
22
  ScreenStackHeaderConfigProps as RNScreenStackHeaderConfigProps,
23
+ ScreenStackHeaderBackButtonImage as RNScreenStackHeaderBackButtonImage,
18
24
  } from "react-native-screens";
19
25
  import ScreenStackNativeComponent from "react-native-screens/src/fabric/ScreenStackNativeComponent";
20
26
 
@@ -249,6 +255,30 @@ let StackScreenHeader = React.memo(function StackScreenHeader({
249
255
  );
250
256
  });
251
257
 
258
+ let StackScreenHeaderLeft = React.memo(function StackScreenHeaderLeft({
259
+ ...props
260
+ }: ViewProps) {
261
+ return <RNScreenStackHeaderLeftView {...props} />;
262
+ });
263
+
264
+ let StackScreenHeaderCenter = React.memo(function StackScreenHeaderCenter({
265
+ ...props
266
+ }: ViewProps) {
267
+ return <RNScreenStackHeaderCenterView {...props} />;
268
+ });
269
+
270
+ let StackScreenHeaderRight = React.memo(function StackScreenHeaderRight({
271
+ ...props
272
+ }: ViewProps) {
273
+ return <RNScreenStackHeaderRightView {...props} />;
274
+ });
275
+
276
+ let ScreenStackHeaderBackButtonImage = React.memo(
277
+ function ScreenStackHeaderBackButtonImage(props: ImageProps) {
278
+ return <RNScreenStackHeaderBackButtonImage {...props} />;
279
+ }
280
+ );
281
+
252
282
  type StackNavigatorProps = Omit<StackRootProps, "children"> & {
253
283
  rootScreen: React.ReactElement<unknown>;
254
284
  };
@@ -272,6 +302,10 @@ export let Stack = {
272
302
  Screens: StackScreens,
273
303
  Screen: StackScreen,
274
304
  Header: StackScreenHeader,
305
+ HeaderLeft: StackScreenHeaderLeft,
306
+ HeaderCenter: StackScreenHeaderCenter,
307
+ HeaderRight: StackScreenHeaderRight,
308
+ HeaderBackImage: ScreenStackHeaderBackButtonImage,
275
309
  Slot: StackSlot,
276
310
  Navigator: StackNavigator,
277
311
  };
package/src/tabs.tsx CHANGED
@@ -225,7 +225,11 @@ let TabsTab = React.memo(function TabsTab({
225
225
 
226
226
  let onPress: () => void = React.useCallback(() => {
227
227
  dispatch({ type: "SET_TAB_INDEX", tabId, index });
228
- }, [tabId, index, dispatch]);
228
+
229
+ if (isActive) {
230
+ dispatch({ type: "POP_ACTIVE_TAB", tabId, index });
231
+ }
232
+ }, [tabId, index, dispatch, isActive]);
229
233
 
230
234
  let style = React.useMemo(() => {
231
235
  let baseStyle = props.style || defaultTabStyle;