feat: add typed navigator for better typechecking

This commit is contained in:
satyajit.happy
2019-06-11 12:01:21 +02:00
parent b2214774d6
commit 14aa95515e
5 changed files with 189 additions and 44 deletions

View File

@@ -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<ParamList, RouteName> & {
/**
* Push a new screen onto the stack.
*
* @param name Name of the route for the tab.
* @param [params] Params object for the route.
*/
push<RouteName extends keyof ParamList>(
...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);

View File

@@ -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<ParamList, RouteName> & {
/**
* Jump to an existing tab.
*
* @param name Name of the route for the tab.
* @param [params] Params object for the route.
*/
jumpTo<RouteName extends keyof ParamList>(
...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);

View File

@@ -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<StackParamList, typeof StackNavigator> = {
Navigator: StackNavigator,
Screen,
};
const Tab: TypedNavigator<TabParamList, typeof TabNavigator> = {
Navigator: TabNavigator,
Screen,
};
const First = ({
navigation,
}: {
@@ -125,23 +140,27 @@ const initialState = routes.length
function App() {
return (
<NavigationContainer initialState={initialState}>
<StackNavigator>
<Screen
<Stack.Navigator>
<Stack.Screen
name="first"
component={First}
options={{ title: 'Foo' }}
initialParams={{ author: 'Jane' }}
/>
<Screen name="second" component={Second} options={{ title: 'Bar' }} />
<Screen name="third" options={{ title: 'Baz' }}>
<Stack.Screen
name="second"
component={Second}
options={{ title: 'Bar' }}
/>
<Stack.Screen name="third" options={{ title: 'Baz' }}>
{() => (
<TabNavigator initialRouteName="fifth">
<Screen name="fourth" component={Fourth} />
<Screen name="fifth" component={Fifth} />
</TabNavigator>
<Tab.Navigator initialRouteName="fifth">
<Tab.Screen name="fourth" component={Fourth} />
<Tab.Screen name="fifth" component={Fifth} />
</Tab.Navigator>
)}
</Screen>
</StackNavigator>
</Stack.Screen>
</Stack.Navigator>
</NavigationContainer>
);
}

View File

@@ -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<Route & { state?: NavigationState }>;
};
@@ -15,10 +27,19 @@ export type InitialState = Omit<Omit<NavigationState, 'names'>, 'key'> & {
state?: InitialState;
};
export type Route = {
export type Route<RouteName = string> = {
/**
* 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<Action extends NavigationAction = NavigationAction> = {
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<T> {
// 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<ParamList extends ParamListBase = {}> = {
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<RouteName extends keyof ParamList>(
...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<ParamList>;
@@ -62,7 +118,11 @@ export type NavigationProp<
ParamList extends ParamListBase,
RouteName extends keyof ParamList
> = NavigationHelpers<ParamList> & {
state: Route & { name: RouteName } & (ParamList[RouteName] extends void
/**
* State for the child navigator.
*/
state: Route<RouteName> &
(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<any> }
| { children: (props: any) => React.ReactNode });
| {
/**
* React component to render for this screen.
*/
component: React.ComponentType<any>;
}
| {
/**
* Render callback to render content of this screen.
*/
children: (props: any) => React.ReactNode;
});
export type TypedNavigator<
ParamList extends ParamListBase,
Navigator extends React.ComponentType<any>
> = {
Navigator: React.ComponentType<
React.ComponentProps<Navigator> & {
/**
* Route to focus on initial render.
*/
initialRouteName?: keyof ParamList;
}
>;
Screen: React.ComponentType<ScreenProps<ParamList, keyof ParamList>>;
};

View File

@@ -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