@teardown/navigation 2.0.80 → 2.0.82
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/package.json +2 -2
- package/src/hooks/index.ts +14 -0
- package/src/hooks/scoped-hooks.ts +125 -0
- package/src/index.ts +35 -1
- package/src/primitives/define-layout.ts +179 -5
- package/src/primitives/define-screen.ts +144 -5
- package/src/primitives/index.ts +6 -0
- package/src/route-builder/create-root-layout.ts +42 -0
- package/src/route-builder/create-route-tree.ts +128 -0
- package/src/route-builder/index.ts +7 -0
- package/src/route-builder/route-builder-d.test.ts +100 -0
- package/src/route-builder/route-builder.test.ts +276 -0
- package/src/router/create-router.tsx +134 -0
- package/src/router/index.ts +7 -2
- package/src/types/index.ts +14 -0
- package/src/types/route-builder-types.ts +103 -0
- package/src/types/type-utils.ts +37 -10
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createRootLayout - Factory for creating the root layout
|
|
3
|
+
* The root layout is the top-level navigator with no parent
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { defineLayout, type EnhancedLayoutDefinition, type NavigatorType } from "../primitives/define-layout";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Root layout configuration
|
|
10
|
+
*/
|
|
11
|
+
export interface RootLayoutConfig<TType extends NavigatorType> {
|
|
12
|
+
/** Navigator type */
|
|
13
|
+
type: TType;
|
|
14
|
+
/** Default screen options for children */
|
|
15
|
+
screenOptions?: object;
|
|
16
|
+
/** Initial route name */
|
|
17
|
+
initialRouteName?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create the root layout (no parent)
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* const rootLayout = createRootLayout({ type: 'stack' });
|
|
26
|
+
*
|
|
27
|
+
* // Then add children
|
|
28
|
+
* const routeTree = createRouteTree({
|
|
29
|
+
* root: rootLayout.addChildren([homeScreen, settingsScreen]),
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function createRootLayout<TType extends NavigatorType>(
|
|
34
|
+
config: RootLayoutConfig<TType>
|
|
35
|
+
): EnhancedLayoutDefinition<TType, "", "/", Record<string, never>, Record<string, never>, unknown> {
|
|
36
|
+
return defineLayout({
|
|
37
|
+
type: config.type,
|
|
38
|
+
path: "",
|
|
39
|
+
screenOptions: config.screenOptions,
|
|
40
|
+
initialRouteName: config.initialRouteName,
|
|
41
|
+
}) as EnhancedLayoutDefinition<TType, "", "/", Record<string, never>, Record<string, never>, unknown>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createRouteTree - Creates a validated route tree from a root layout
|
|
3
|
+
* The route tree is used by the router to render the navigation hierarchy
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AnyRouteDefinition, ExtractRouteParams, ExtractRoutePaths } from "../types/route-builder-types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Route tree structure
|
|
10
|
+
*/
|
|
11
|
+
export interface RouteTree<TRoot extends AnyRouteDefinition> {
|
|
12
|
+
/** Root layout */
|
|
13
|
+
root: TRoot;
|
|
14
|
+
/** All route paths (union type) */
|
|
15
|
+
routePaths: ExtractRoutePaths<TRoot>;
|
|
16
|
+
/** Map of path -> params */
|
|
17
|
+
routeParams: ExtractRouteParams<TRoot>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Configuration for creating a route tree
|
|
22
|
+
*/
|
|
23
|
+
export interface CreateRouteTreeConfig<TRoot extends AnyRouteDefinition> {
|
|
24
|
+
/** Root layout with children */
|
|
25
|
+
root: TRoot;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create a validated route tree from root layout
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```tsx
|
|
33
|
+
* const routeTree = createRouteTree({
|
|
34
|
+
* root: rootLayout.addChildren([
|
|
35
|
+
* usersLayout.addChildren([userProfile, userPosts]),
|
|
36
|
+
* settingsScreen,
|
|
37
|
+
* ]),
|
|
38
|
+
* });
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function createRouteTree<TRoot extends AnyRouteDefinition>(
|
|
42
|
+
config: CreateRouteTreeConfig<TRoot>
|
|
43
|
+
): RouteTree<TRoot> {
|
|
44
|
+
// Validate tree structure at runtime
|
|
45
|
+
validateRouteTree(config.root);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
root: config.root,
|
|
49
|
+
routePaths: extractRoutePaths(config.root) as ExtractRoutePaths<TRoot>,
|
|
50
|
+
routeParams: extractRouteParams(config.root) as ExtractRouteParams<TRoot>,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate route tree structure
|
|
56
|
+
* Checks for duplicate paths and other issues
|
|
57
|
+
*/
|
|
58
|
+
function validateRouteTree(node: AnyRouteDefinition, seen = new Set<string>()): void {
|
|
59
|
+
const fullPath = node.fullPath;
|
|
60
|
+
|
|
61
|
+
// Skip empty paths (root layout)
|
|
62
|
+
if (fullPath && fullPath !== "/") {
|
|
63
|
+
if (seen.has(fullPath)) {
|
|
64
|
+
throw new Error(`Duplicate route path: ${fullPath}`);
|
|
65
|
+
}
|
|
66
|
+
seen.add(fullPath);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Recursively validate children
|
|
70
|
+
if ("children" in node && Array.isArray(node.children)) {
|
|
71
|
+
for (const child of node.children) {
|
|
72
|
+
validateRouteTree(child as AnyRouteDefinition, seen);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Extract all route paths from a route tree
|
|
79
|
+
*/
|
|
80
|
+
function extractRoutePaths(node: AnyRouteDefinition): string[] {
|
|
81
|
+
const paths: string[] = [];
|
|
82
|
+
|
|
83
|
+
if (node.fullPath) {
|
|
84
|
+
paths.push(node.fullPath);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if ("children" in node && Array.isArray(node.children)) {
|
|
88
|
+
for (const child of node.children) {
|
|
89
|
+
paths.push(...extractRoutePaths(child as AnyRouteDefinition));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return paths;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extract route params map from a route tree
|
|
98
|
+
*/
|
|
99
|
+
function extractRouteParams(node: AnyRouteDefinition): Record<string, unknown> {
|
|
100
|
+
const params: Record<string, unknown> = {};
|
|
101
|
+
|
|
102
|
+
if (node.fullPath) {
|
|
103
|
+
params[node.fullPath] = node.allParams;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if ("children" in node && Array.isArray(node.children)) {
|
|
107
|
+
for (const child of node.children) {
|
|
108
|
+
Object.assign(params, extractRouteParams(child as AnyRouteDefinition));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return params;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get all route definitions from a route tree as a flat array
|
|
117
|
+
*/
|
|
118
|
+
export function flattenRouteTree(node: AnyRouteDefinition): AnyRouteDefinition[] {
|
|
119
|
+
const routes: AnyRouteDefinition[] = [node];
|
|
120
|
+
|
|
121
|
+
if ("children" in node && Array.isArray(node.children)) {
|
|
122
|
+
for (const child of node.children) {
|
|
123
|
+
routes.push(...flattenRouteTree(child as AnyRouteDefinition));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return routes;
|
|
128
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route builder module for @teardown/navigation
|
|
3
|
+
* Provides utilities for building type-safe route trees
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { createRootLayout, type RootLayoutConfig } from "./create-root-layout";
|
|
7
|
+
export { type CreateRouteTreeConfig, createRouteTree, flattenRouteTree, type RouteTree } from "./create-route-tree";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-level tests for route builder types
|
|
3
|
+
* These tests verify that the type utilities correctly compute paths and params
|
|
4
|
+
*
|
|
5
|
+
* Note: These are compile-time type tests. The runtime assertions are minimal
|
|
6
|
+
* because the main goal is to verify TypeScript type inference is correct.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import type { AccumulateParams, ComputeFullPath, StripRouteGroups } from "../types/route-builder-types";
|
|
11
|
+
|
|
12
|
+
// Helper type for testing - checks if two types are exactly equal
|
|
13
|
+
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
|
14
|
+
|
|
15
|
+
// Type assertion helper - verifies type at compile time
|
|
16
|
+
function assertType<T>(_value: T): void {}
|
|
17
|
+
|
|
18
|
+
describe("Route Builder Types - Compile Time", () => {
|
|
19
|
+
describe("StripRouteGroups", () => {
|
|
20
|
+
it("strips route group prefix", () => {
|
|
21
|
+
type Result = StripRouteGroups<"(auth)/login">;
|
|
22
|
+
const isEqual: Equals<Result, "login"> = true;
|
|
23
|
+
expect(isEqual).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("strips standalone route group", () => {
|
|
27
|
+
type Result = StripRouteGroups<"(auth)">;
|
|
28
|
+
const isEqual: Equals<Result, ""> = true;
|
|
29
|
+
expect(isEqual).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("preserves paths without route groups", () => {
|
|
33
|
+
type Result = StripRouteGroups<"users">;
|
|
34
|
+
const isEqual: Equals<Result, "users"> = true;
|
|
35
|
+
expect(isEqual).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("strips nested route groups", () => {
|
|
39
|
+
type Result = StripRouteGroups<"(admin)/(dashboard)/settings">;
|
|
40
|
+
const isEqual: Equals<Result, "settings"> = true;
|
|
41
|
+
expect(isEqual).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("ComputeFullPath", () => {
|
|
46
|
+
it("computes root path for empty path with no parent", () => {
|
|
47
|
+
type Result = ComputeFullPath<"", undefined>;
|
|
48
|
+
const isEqual: Equals<Result, "/"> = true;
|
|
49
|
+
expect(isEqual).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("computes full path for simple segment with no parent", () => {
|
|
53
|
+
type Result = ComputeFullPath<"users", undefined>;
|
|
54
|
+
const isEqual: Equals<Result, "/users"> = true;
|
|
55
|
+
expect(isEqual).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("computes full path with parent", () => {
|
|
59
|
+
type Parent = { fullPath: "/users" };
|
|
60
|
+
type Result = ComputeFullPath<"[userId]", Parent>;
|
|
61
|
+
const isEqual: Equals<Result, "/users/[userId]"> = true;
|
|
62
|
+
expect(isEqual).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("computes full path with root parent", () => {
|
|
66
|
+
type Parent = { fullPath: "/" };
|
|
67
|
+
type Result = ComputeFullPath<"users", Parent>;
|
|
68
|
+
const isEqual: Equals<Result, "/users"> = true;
|
|
69
|
+
expect(isEqual).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("strips route groups from computed path", () => {
|
|
73
|
+
type Parent = { fullPath: "/" };
|
|
74
|
+
type Result = ComputeFullPath<"(auth)/login", Parent>;
|
|
75
|
+
const isEqual: Equals<Result, "/login"> = true;
|
|
76
|
+
expect(isEqual).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("AccumulateParams", () => {
|
|
81
|
+
it("extracts params from single segment", () => {
|
|
82
|
+
type Result = AccumulateParams<"[userId]", undefined>;
|
|
83
|
+
assertType<Result>({ userId: "123" });
|
|
84
|
+
expect(true).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("accumulates params from parent", () => {
|
|
88
|
+
type Parent = { allParams: { userId: string } };
|
|
89
|
+
type Result = AccumulateParams<"[postId]", Parent>;
|
|
90
|
+
assertType<Result>({ userId: "123", postId: "456" });
|
|
91
|
+
expect(true).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns empty for static paths", () => {
|
|
95
|
+
type Result = AccumulateParams<"about", undefined>;
|
|
96
|
+
const result: Result = {};
|
|
97
|
+
expect(Object.keys(result).length).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime tests for route builder
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "bun:test";
|
|
6
|
+
import { defineLayout, defineScreen } from "../primitives";
|
|
7
|
+
import { createRootLayout } from "./create-root-layout";
|
|
8
|
+
import { createRouteTree, flattenRouteTree } from "./create-route-tree";
|
|
9
|
+
|
|
10
|
+
// Dummy component for tests
|
|
11
|
+
const DummyComponent = () => null;
|
|
12
|
+
|
|
13
|
+
describe("Route Builder Runtime", () => {
|
|
14
|
+
describe("createRootLayout", () => {
|
|
15
|
+
it("creates a root layout with fullPath /", () => {
|
|
16
|
+
const root = createRootLayout({ type: "stack" });
|
|
17
|
+
|
|
18
|
+
expect(root.path).toBe("");
|
|
19
|
+
expect(root.fullPath).toBe("/");
|
|
20
|
+
expect(root.__brand).toBe("TeardownLayout");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("supports different navigator types", () => {
|
|
24
|
+
const stack = createRootLayout({ type: "stack" });
|
|
25
|
+
const tabs = createRootLayout({ type: "tabs" });
|
|
26
|
+
const drawer = createRootLayout({ type: "drawer" });
|
|
27
|
+
|
|
28
|
+
expect(stack.type).toBe("stack");
|
|
29
|
+
expect(tabs.type).toBe("tabs");
|
|
30
|
+
expect(drawer.type).toBe("drawer");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("passes through screen options", () => {
|
|
34
|
+
const root = createRootLayout({
|
|
35
|
+
type: "stack",
|
|
36
|
+
screenOptions: { headerShown: false },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(root.screenOptions).toEqual({ headerShown: false });
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("defineLayout with path", () => {
|
|
44
|
+
it("computes fullPath correctly", () => {
|
|
45
|
+
const root = createRootLayout({ type: "stack" });
|
|
46
|
+
const users = defineLayout({
|
|
47
|
+
type: "stack",
|
|
48
|
+
path: "users",
|
|
49
|
+
getParentRoute: () => root,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(users.fullPath).toBe("/users");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("strips route groups from fullPath", () => {
|
|
56
|
+
const root = createRootLayout({ type: "stack" });
|
|
57
|
+
const auth = defineLayout({
|
|
58
|
+
type: "stack",
|
|
59
|
+
path: "(auth)",
|
|
60
|
+
getParentRoute: () => root,
|
|
61
|
+
});
|
|
62
|
+
const login = defineScreen({
|
|
63
|
+
path: "login",
|
|
64
|
+
getParentRoute: () => auth,
|
|
65
|
+
component: DummyComponent,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(login.fullPath).toBe("/login");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("chains nested layouts correctly", () => {
|
|
72
|
+
const root = createRootLayout({ type: "stack" });
|
|
73
|
+
const users = defineLayout({
|
|
74
|
+
type: "stack",
|
|
75
|
+
path: "users",
|
|
76
|
+
getParentRoute: () => root,
|
|
77
|
+
});
|
|
78
|
+
const settings = defineLayout({
|
|
79
|
+
type: "tabs",
|
|
80
|
+
path: "settings",
|
|
81
|
+
getParentRoute: () => users,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(settings.fullPath).toBe("/users/settings");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("defineScreen with path", () => {
|
|
89
|
+
it("computes fullPath from parent chain", () => {
|
|
90
|
+
const root = createRootLayout({ type: "stack" });
|
|
91
|
+
const users = defineLayout({
|
|
92
|
+
type: "stack",
|
|
93
|
+
path: "users",
|
|
94
|
+
getParentRoute: () => root,
|
|
95
|
+
});
|
|
96
|
+
const profile = defineScreen({
|
|
97
|
+
path: "[userId]",
|
|
98
|
+
getParentRoute: () => users,
|
|
99
|
+
component: DummyComponent,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(profile.fullPath).toBe("/users/[userId]");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("creates screen without parent at root", () => {
|
|
106
|
+
const home = defineScreen({
|
|
107
|
+
path: "home",
|
|
108
|
+
component: DummyComponent,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(home.fullPath).toBe("/home");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("handles nested params correctly", () => {
|
|
115
|
+
const root = createRootLayout({ type: "stack" });
|
|
116
|
+
const users = defineLayout({
|
|
117
|
+
type: "stack",
|
|
118
|
+
path: "users/[userId]",
|
|
119
|
+
getParentRoute: () => root,
|
|
120
|
+
});
|
|
121
|
+
const posts = defineScreen({
|
|
122
|
+
path: "posts/[postId]",
|
|
123
|
+
getParentRoute: () => users,
|
|
124
|
+
component: DummyComponent,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(posts.fullPath).toBe("/users/[userId]/posts/[postId]");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("addChildren", () => {
|
|
132
|
+
it("returns new layout with children", () => {
|
|
133
|
+
const root = createRootLayout({ type: "stack" });
|
|
134
|
+
const screen1 = defineScreen({ path: "a", component: DummyComponent });
|
|
135
|
+
const screen2 = defineScreen({ path: "b", component: DummyComponent });
|
|
136
|
+
|
|
137
|
+
const withChildren = root.addChildren([screen1, screen2]);
|
|
138
|
+
|
|
139
|
+
expect(withChildren.children).toHaveLength(2);
|
|
140
|
+
expect(withChildren.children[0].path).toBe("a");
|
|
141
|
+
expect(withChildren.children[1].path).toBe("b");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("preserves original layout properties", () => {
|
|
145
|
+
const root = createRootLayout({
|
|
146
|
+
type: "tabs",
|
|
147
|
+
screenOptions: { headerShown: false },
|
|
148
|
+
});
|
|
149
|
+
const screen = defineScreen({ path: "home", component: DummyComponent });
|
|
150
|
+
|
|
151
|
+
const withChildren = root.addChildren([screen]);
|
|
152
|
+
|
|
153
|
+
expect(withChildren.type).toBe("tabs");
|
|
154
|
+
expect(withChildren.screenOptions).toEqual({ headerShown: false });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("supports nested addChildren", () => {
|
|
158
|
+
const root = createRootLayout({ type: "stack" });
|
|
159
|
+
const usersLayout = defineLayout({
|
|
160
|
+
type: "stack",
|
|
161
|
+
path: "users",
|
|
162
|
+
getParentRoute: () => root,
|
|
163
|
+
});
|
|
164
|
+
const profile = defineScreen({
|
|
165
|
+
path: "[userId]",
|
|
166
|
+
getParentRoute: () => usersLayout,
|
|
167
|
+
component: DummyComponent,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const tree = root.addChildren([usersLayout.addChildren([profile])]);
|
|
171
|
+
|
|
172
|
+
expect(tree.children).toHaveLength(1);
|
|
173
|
+
expect((tree.children[0] as typeof usersLayout).children).toHaveLength(1);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("createRouteTree", () => {
|
|
178
|
+
it("creates a route tree from root", () => {
|
|
179
|
+
const root = createRootLayout({ type: "stack" });
|
|
180
|
+
const home = defineScreen({ path: "home", component: DummyComponent });
|
|
181
|
+
|
|
182
|
+
const tree = createRouteTree({
|
|
183
|
+
root: root.addChildren([home]),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(tree.root).toBeDefined();
|
|
187
|
+
expect(Array.isArray(tree.routePaths)).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("validates no duplicate paths", () => {
|
|
191
|
+
const root = createRootLayout({ type: "stack" });
|
|
192
|
+
const screen1 = defineScreen({ path: "same", component: DummyComponent });
|
|
193
|
+
const screen2 = defineScreen({ path: "same", component: DummyComponent });
|
|
194
|
+
|
|
195
|
+
expect(() => {
|
|
196
|
+
createRouteTree({ root: root.addChildren([screen1, screen2]) });
|
|
197
|
+
}).toThrow("Duplicate route path");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("extracts all route paths", () => {
|
|
201
|
+
const root = createRootLayout({ type: "stack" });
|
|
202
|
+
const home = defineScreen({ path: "home", component: DummyComponent });
|
|
203
|
+
const about = defineScreen({ path: "about", component: DummyComponent });
|
|
204
|
+
|
|
205
|
+
const tree = createRouteTree({
|
|
206
|
+
root: root.addChildren([home, about]),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const paths = tree.routePaths as string[];
|
|
210
|
+
expect(paths).toContain("/");
|
|
211
|
+
expect(paths).toContain("/home");
|
|
212
|
+
expect(paths).toContain("/about");
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("flattenRouteTree", () => {
|
|
217
|
+
it("flattens nested route tree", () => {
|
|
218
|
+
const root = createRootLayout({ type: "stack" });
|
|
219
|
+
const usersLayout = defineLayout({
|
|
220
|
+
type: "stack",
|
|
221
|
+
path: "users",
|
|
222
|
+
getParentRoute: () => root,
|
|
223
|
+
});
|
|
224
|
+
const profile = defineScreen({
|
|
225
|
+
path: "[userId]",
|
|
226
|
+
getParentRoute: () => usersLayout,
|
|
227
|
+
component: DummyComponent,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const tree = root.addChildren([usersLayout.addChildren([profile])]);
|
|
231
|
+
const flat = flattenRouteTree(tree);
|
|
232
|
+
|
|
233
|
+
expect(flat.length).toBe(3); // root, usersLayout, profile
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("scoped hooks", () => {
|
|
238
|
+
it("provides useParams hook on screen", () => {
|
|
239
|
+
const screen = defineScreen({
|
|
240
|
+
path: "[userId]",
|
|
241
|
+
component: DummyComponent,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(typeof screen.useParams).toBe("function");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("provides useNavigate hook on screen", () => {
|
|
248
|
+
const screen = defineScreen({
|
|
249
|
+
path: "home",
|
|
250
|
+
component: DummyComponent,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(typeof screen.useNavigate).toBe("function");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("provides useRouteContext hook on screen", () => {
|
|
257
|
+
const screen = defineScreen({
|
|
258
|
+
path: "home",
|
|
259
|
+
component: DummyComponent,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(typeof screen.useRouteContext).toBe("function");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("provides hooks on layout", () => {
|
|
266
|
+
const layout = defineLayout({
|
|
267
|
+
type: "stack",
|
|
268
|
+
path: "users",
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(typeof layout.useParams).toBe("function");
|
|
272
|
+
expect(typeof layout.useNavigate).toBe("function");
|
|
273
|
+
expect(typeof layout.useRouteContext).toBe("function");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -8,6 +8,10 @@ import { NavigationContainer } from "@react-navigation/native";
|
|
|
8
8
|
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
|
9
9
|
import type React from "react";
|
|
10
10
|
import type { ComponentType, ReactNode } from "react";
|
|
11
|
+
import { useEffect } from "react";
|
|
12
|
+
import { initializeScopedHooks } from "../hooks/scoped-hooks";
|
|
13
|
+
import type { RouteTree } from "../route-builder";
|
|
14
|
+
import type { AnyRouteDefinition } from "../types/route-builder-types";
|
|
11
15
|
import type { FlatRouteTree, NavigatorNode, ScreenEntry } from "./types";
|
|
12
16
|
import { isHierarchicalRouteTree } from "./types";
|
|
13
17
|
|
|
@@ -121,6 +125,31 @@ export interface TeardownRouterOptions<T extends NavigatorNode | FlatRouteTree =
|
|
|
121
125
|
navigationContainerProps?: Omit<React.ComponentProps<typeof NavigationContainer>, "children">;
|
|
122
126
|
}
|
|
123
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Options for creating a Teardown router from a RouteTree
|
|
130
|
+
*/
|
|
131
|
+
export interface RouteTreeRouterOptions<TRoot extends AnyRouteDefinition> {
|
|
132
|
+
/**
|
|
133
|
+
* The route tree created with createRouteTree()
|
|
134
|
+
*/
|
|
135
|
+
routeTree: RouteTree<TRoot>;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Deep linking configuration
|
|
139
|
+
*/
|
|
140
|
+
linking?: LinkingOptions<Record<string, unknown>>;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Navigation theme
|
|
144
|
+
*/
|
|
145
|
+
theme?: Theme;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Custom navigation container wrapper
|
|
149
|
+
*/
|
|
150
|
+
navigationContainerProps?: Omit<React.ComponentProps<typeof NavigationContainer>, "children">;
|
|
151
|
+
}
|
|
152
|
+
|
|
124
153
|
/**
|
|
125
154
|
* Creates a navigator component based on type
|
|
126
155
|
*/
|
|
@@ -335,3 +364,108 @@ export type ExtractRoutePaths<T extends NavigatorNode> = T extends NavigatorNode
|
|
|
335
364
|
* Type helper to extract screen definitions from a route tree
|
|
336
365
|
*/
|
|
337
366
|
export type ExtractScreens<T extends NavigatorNode> = T extends NavigatorNode ? T["screens"] : never;
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Creates a Teardown Router from a RouteTree (enhanced API)
|
|
370
|
+
*
|
|
371
|
+
* This is the new API that uses the route builder pattern with
|
|
372
|
+
* createRootLayout, defineLayout, and defineScreen.
|
|
373
|
+
*
|
|
374
|
+
* @example
|
|
375
|
+
* ```tsx
|
|
376
|
+
* import { createTeardownRouterFromTree, createRouteTree, createRootLayout, defineScreen } from "@teardown/navigation";
|
|
377
|
+
*
|
|
378
|
+
* const root = createRootLayout({ type: 'stack' });
|
|
379
|
+
* const home = defineScreen({ path: 'home', component: HomeScreen, getParentRoute: () => root });
|
|
380
|
+
*
|
|
381
|
+
* const routeTree = createRouteTree({
|
|
382
|
+
* root: root.addChildren([home]),
|
|
383
|
+
* });
|
|
384
|
+
*
|
|
385
|
+
* export const Router = createTeardownRouterFromTree({ routeTree });
|
|
386
|
+
* ```
|
|
387
|
+
*/
|
|
388
|
+
export function createTeardownRouterFromTree<TRoot extends AnyRouteDefinition>(
|
|
389
|
+
options: RouteTreeRouterOptions<TRoot>
|
|
390
|
+
): ComponentType<{ children?: ReactNode }> {
|
|
391
|
+
const { routeTree, linking, theme, navigationContainerProps } = options;
|
|
392
|
+
|
|
393
|
+
function TeardownRouterFromTree(): React.JSX.Element {
|
|
394
|
+
// Initialize scoped hooks with React Navigation
|
|
395
|
+
useEffect(() => {
|
|
396
|
+
try {
|
|
397
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
398
|
+
const { useRoute, useNavigation } = require("@react-navigation/native");
|
|
399
|
+
initializeScopedHooks(useRoute, useNavigation);
|
|
400
|
+
} catch {
|
|
401
|
+
console.warn("[@teardown/navigation] @react-navigation/native not found");
|
|
402
|
+
}
|
|
403
|
+
}, []);
|
|
404
|
+
|
|
405
|
+
// Build navigator tree from route tree
|
|
406
|
+
const navigatorNode = buildNavigatorFromRouteTree(routeTree.root);
|
|
407
|
+
|
|
408
|
+
return (
|
|
409
|
+
<NavigationContainer linking={linking} theme={theme} {...navigationContainerProps}>
|
|
410
|
+
<RenderNavigator node={navigatorNode} />
|
|
411
|
+
</NavigationContainer>
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return TeardownRouterFromTree;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Builds a NavigatorNode from a RouteTree root
|
|
420
|
+
*/
|
|
421
|
+
function buildNavigatorFromRouteTree(root: AnyRouteDefinition): NavigatorNode {
|
|
422
|
+
const isLayout = "__brand" in root && root.__brand === "TeardownLayout";
|
|
423
|
+
|
|
424
|
+
if (!isLayout) {
|
|
425
|
+
// Single screen, wrap in a stack
|
|
426
|
+
return {
|
|
427
|
+
type: "stack",
|
|
428
|
+
screens: {
|
|
429
|
+
[(root as { path?: string }).path || "index"]: {
|
|
430
|
+
screen: root as unknown as ScreenEntry["screen"],
|
|
431
|
+
path: (root as { fullPath?: string }).fullPath || "/",
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const layout = root as unknown as {
|
|
438
|
+
type: "stack" | "tabs" | "drawer";
|
|
439
|
+
screenOptions?: object;
|
|
440
|
+
initialRouteName?: string;
|
|
441
|
+
children: AnyRouteDefinition[];
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const screens: Record<string, ScreenEntry> = {};
|
|
445
|
+
const navigators: Record<string, NavigatorNode> = {};
|
|
446
|
+
|
|
447
|
+
for (const child of layout.children || []) {
|
|
448
|
+
const childBrand = "__brand" in child ? child.__brand : undefined;
|
|
449
|
+
const childPath = (child as { path?: string }).path || "index";
|
|
450
|
+
const childFullPath = (child as { fullPath?: string }).fullPath || "/";
|
|
451
|
+
|
|
452
|
+
if (childBrand === "TeardownLayout") {
|
|
453
|
+
// Nested navigator
|
|
454
|
+
navigators[childPath] = buildNavigatorFromRouteTree(child);
|
|
455
|
+
} else {
|
|
456
|
+
// Screen
|
|
457
|
+
screens[childPath] = {
|
|
458
|
+
screen: child as unknown as ScreenEntry["screen"],
|
|
459
|
+
path: childFullPath,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
type: layout.type,
|
|
466
|
+
screens,
|
|
467
|
+
navigators: Object.keys(navigators).length > 0 ? navigators : undefined,
|
|
468
|
+
screenOptions: layout.screenOptions as Record<string, unknown> | undefined,
|
|
469
|
+
initialRouteName: layout.initialRouteName,
|
|
470
|
+
};
|
|
471
|
+
}
|