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

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