@teardown/navigation 2.0.79 → 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.
@@ -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
+ });