From 14aa95515e4aa2d99bb26090e366c345a93dd197 Mon Sep 17 00:00:00 2001 From: "satyajit.happy" Date: Tue, 11 Jun 2019 12:01:21 +0200 Subject: [PATCH] feat: add typed navigator for better typechecking --- example/StackNavigator.tsx | 20 +++-- example/TabNavigator.tsx | 16 ++-- example/index.tsx | 41 ++++++++--- src/types.tsx | 138 +++++++++++++++++++++++++++++++---- src/useNavigationBuilder.tsx | 18 ++--- 5 files changed, 189 insertions(+), 44 deletions(-) diff --git a/example/StackNavigator.tsx b/example/StackNavigator.tsx index 6df3d3c0..8eef73e6 100644 --- a/example/StackNavigator.tsx +++ b/example/StackNavigator.tsx @@ -14,7 +14,7 @@ import { type Props = { initialRouteName?: string; - children: React.ReactElement[]; + children: React.ReactNode; }; type Action = @@ -28,27 +28,37 @@ export type StackNavigationProp< ParamList extends ParamListBase, RouteName extends keyof ParamList = string > = NavigationProp & { + /** + * Push a new screen onto the stack. + * + * @param name Name of the route for the tab. + * @param [params] Params object for the route. + */ push( ...args: ParamList[RouteName] extends void ? [RouteName] : [RouteName, ParamList[RouteName]] ): void; + + /** + * Pop a screen from the stack. + */ pop(): void; }; const StackRouter = { - normalize({ + initial({ screens, - currentState, + partialState, initialRouteName = Object.keys(screens)[0], }: { screens: { [key: string]: ScreenProps }; - currentState?: InitialState | NavigationState; + partialState?: InitialState | NavigationState; initialRouteName?: string; }): NavigationState { const routeNames = Object.keys(screens); - let state = currentState; + let state = partialState; if (state === undefined) { const index = routeNames.indexOf(initialRouteName); diff --git a/example/TabNavigator.tsx b/example/TabNavigator.tsx index 263f5beb..c6e617c0 100644 --- a/example/TabNavigator.tsx +++ b/example/TabNavigator.tsx @@ -14,7 +14,7 @@ import { type Props = { initialRouteName?: string; - children: React.ReactElement[]; + children: React.ReactNode; }; type Action = { @@ -26,6 +26,12 @@ export type TabNavigationProp< ParamList extends ParamListBase, RouteName extends keyof ParamList = string > = NavigationProp & { + /** + * Jump to an existing tab. + * + * @param name Name of the route for the tab. + * @param [params] Params object for the route. + */ jumpTo( ...args: ParamList[RouteName] extends void ? [RouteName] @@ -34,18 +40,18 @@ export type TabNavigationProp< }; const TabRouter = { - normalize({ + initial({ screens, - currentState, + partialState, initialRouteName = Object.keys(screens)[0], }: { screens: { [key: string]: ScreenProps }; - currentState?: InitialState | NavigationState; + partialState?: InitialState | NavigationState; initialRouteName?: string; }): NavigationState { const routeNames = Object.keys(screens); - let state = currentState; + let state = partialState; if (state === undefined) { const index = routeNames.indexOf(initialRouteName); diff --git a/example/index.tsx b/example/index.tsx index 0a935a6b..90fbd2a4 100644 --- a/example/index.tsx +++ b/example/index.tsx @@ -1,7 +1,12 @@ import shortid from 'shortid'; import * as React from 'react'; import { render } from 'react-dom'; -import { NavigationContainer, Screen, CompositeNavigationProp } from '../src'; +import { + NavigationContainer, + Screen, + CompositeNavigationProp, + TypedNavigator, +} from '../src'; import StackNavigator, { StackNavigationProp } from './StackNavigator'; import TabNavigator, { TabNavigationProp } from './TabNavigator'; @@ -16,6 +21,16 @@ type TabParamList = { fifth: void; }; +const Stack: TypedNavigator = { + Navigator: StackNavigator, + Screen, +}; + +const Tab: TypedNavigator = { + Navigator: TabNavigator, + Screen, +}; + const First = ({ navigation, }: { @@ -125,23 +140,27 @@ const initialState = routes.length function App() { return ( - - + - - + + {() => ( - - - - + + + + )} - - + + ); } diff --git a/src/types.tsx b/src/types.tsx index 47538314..b05f75b5 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -3,9 +3,21 @@ import * as BaseActions from './BaseActions'; export type CommonAction = BaseActions.Action; export type NavigationState = { + /** + * Unique key for the navigation state. + */ key: string; + /** + * Index of the currently focused route. + */ index: number; + /** + * List if valid route names. + */ names: string[]; + /** + * List of rendered routes. + */ routes: Array; }; @@ -15,10 +27,19 @@ export type InitialState = Omit, 'key'> & { state?: InitialState; }; -export type Route = { +export type Route = { + /** + * Unique key for the route. + */ key: string; - name: string; - params?: object; + /** + * User-provided name for the route. + */ + name: RouteName; + /** + * Params for the route. + */ + params?: object | void; }; export type NavigationAction = { @@ -26,35 +47,70 @@ export type NavigationAction = { }; export type Router = { - normalize(options: { + /** + * Initialize full navigation state with a given partial state. + */ + initial(options: { screens: { [key: string]: ScreenProps }; - currentState?: NavigationState | InitialState; + partialState?: NavigationState | InitialState; initialRouteName?: string; }): NavigationState; + + /** + * Take the current state and action, and return a new state. + * If the action cannot be handled, return `null`. + */ reduce( state: NavigationState, action: Action | CommonAction ): NavigationState | null; + + /** + * Action creators for the router. + */ actions: { [key: string]: (...args: any) => Action }; }; export type ParamListBase = { [key: string]: object | void }; class PrivateValueStore { - // TypeScript requires a type to be actually used to be able to infer it. - // This is a hacky way of storing type in a property without surfacing it in intellisense. + /** + * TypeScript requires a type to be actually used to be able to infer it. + * This is a hacky way of storing type in a property without surfacing it in intellisense. + */ // @ts-ignore private __private_value_type?: T; } -export type NavigationHelpers = { +export type NavigationHelpers< + ParamList extends ParamListBase = ParamListBase +> = { + /** + * Dispatch an action to the router. + */ dispatch(action: NavigationAction): void; + + /** + * Navigate to a route in current navigation tree. + * + * @param name Name of the route to navigate to. + * @param [params] Params object for the route. + */ navigate( ...args: ParamList[RouteName] extends void ? [RouteName] : [RouteName, ParamList[RouteName]] ): void; + + /** + * Reset the navigation state to the provided state. + * If a key is provided, the state with matching key will be reset. + */ reset(state: InitialState & { key?: string }): void; + + /** + * Go back to the previous route in history. + */ goBack(): void; } & PrivateValueStore; @@ -62,7 +118,11 @@ export type NavigationProp< ParamList extends ParamListBase, RouteName extends keyof ParamList > = NavigationHelpers & { - state: Route & { name: RouteName } & (ParamList[RouteName] extends void + /** + * State for the child navigator. + */ + state: Route & + (ParamList[RouteName] extends void ? never : { params: ParamList[RouteName] }); }; @@ -77,19 +137,69 @@ export type CompositeNavigationProp< >; export type Descriptor = { + /** + * Render the component associated with this route. + */ render(): React.ReactNode; + + /** + * Options for the route. + */ options: Options; }; export type Options = { + /** + * Title text for the screen. + */ title?: string; + [key: string]: any; }; -export type ScreenProps = { - name: string; +export type ScreenProps< + ParamList extends ParamListBase = ParamListBase, + RouteName extends keyof ParamList = string +> = { + /** + * Route name of this screen. + */ + name: RouteName; + + /** + * Navigator options for this screen. + */ options?: Options; - initialParams?: object; + + /** + * Initial params object for the route. + */ + initialParams?: ParamList[RouteName]; } & ( - | { component: React.ComponentType } - | { children: (props: any) => React.ReactNode }); + | { + /** + * React component to render for this screen. + */ + component: React.ComponentType; + } + | { + /** + * Render callback to render content of this screen. + */ + children: (props: any) => React.ReactNode; + }); + +export type TypedNavigator< + ParamList extends ParamListBase, + Navigator extends React.ComponentType +> = { + Navigator: React.ComponentType< + React.ComponentProps & { + /** + * Route to focus on initial render. + */ + initialRouteName?: keyof ParamList; + } + >; + Screen: React.ComponentType>; +}; diff --git a/src/useNavigationBuilder.tsx b/src/useNavigationBuilder.tsx index c34edf08..da4b46a5 100644 --- a/src/useNavigationBuilder.tsx +++ b/src/useNavigationBuilder.tsx @@ -14,7 +14,7 @@ import * as BaseActions from './BaseActions'; type Options = { initialRouteName?: string; - children: React.ReactElement[]; + children: React.ReactNode; }; export const NavigationHelpersContext = React.createContext< @@ -23,13 +23,13 @@ export const NavigationHelpersContext = React.createContext< export default function useNavigationBuilder(router: Router, options: Options) { const screens = React.Children.map(options.children, child => { - if (child.type !== Screen) { - throw new Error( - "A navigator can only contain 'Screen' components as its direct children" - ); + if (React.isValidElement(child) && child.type === Screen) { + return child.props as ScreenProps; } - return child.props as ScreenProps; + throw new Error( + "A navigator can only contain 'Screen' components as its direct children" + ); }).reduce( (acc, curr) => { acc[curr.name] = curr; @@ -41,7 +41,7 @@ export default function useNavigationBuilder(router: Router, options: Options) { const routeNames = Object.keys(screens); const { - state: currentState = router.normalize({ + state: currentState = router.initial({ screens, initialRouteName: options.initialRouteName, }), @@ -51,9 +51,9 @@ export default function useNavigationBuilder(router: Router, options: Options) { const getState = React.useCallback( (): NavigationState => - router.normalize({ + router.initial({ screens, - currentState: getCurrentState(), + partialState: getCurrentState(), initialRouteName: options.initialRouteName, }), // eslint-disable-next-line react-hooks/exhaustive-deps