@tooee/router 0.1.8
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/dist/action-types.d.ts +11 -0
- package/dist/action-types.d.ts.map +1 -0
- package/dist/action-types.js +2 -0
- package/dist/action-types.js.map +1 -0
- package/dist/command-scope.d.ts +2 -0
- package/dist/command-scope.d.ts.map +1 -0
- package/dist/command-scope.js +14 -0
- package/dist/command-scope.js.map +1 -0
- package/dist/context.d.ts +14 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +29 -0
- package/dist/context.js.map +1 -0
- package/dist/create-route.d.ts +3 -0
- package/dist/create-route.d.ts.map +1 -0
- package/dist/create-route.js +4 -0
- package/dist/create-route.js.map +1 -0
- package/dist/create-router.d.ts +3 -0
- package/dist/create-router.d.ts.map +1 -0
- package/dist/create-router.js +79 -0
- package/dist/create-router.js.map +1 -0
- package/dist/focus.d.ts +10 -0
- package/dist/focus.d.ts.map +1 -0
- package/dist/focus.js +21 -0
- package/dist/focus.js.map +1 -0
- package/dist/hooks.d.ts +19 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +59 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +7 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +11 -0
- package/dist/loader.js.map +1 -0
- package/dist/outlet.d.ts +7 -0
- package/dist/outlet.d.ts.map +1 -0
- package/dist/outlet.js +78 -0
- package/dist/outlet.js.map +1 -0
- package/dist/stack.d.ts +3 -0
- package/dist/stack.d.ts.map +1 -0
- package/dist/stack.js +27 -0
- package/dist/stack.js.map +1 -0
- package/dist/state-cache.d.ts +8 -0
- package/dist/state-cache.d.ts.map +1 -0
- package/dist/state-cache.js +16 -0
- package/dist/state-cache.js.map +1 -0
- package/dist/types.d.ts +57 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -0
- package/src/action-types.ts +12 -0
- package/src/command-scope.tsx +15 -0
- package/src/context.tsx +60 -0
- package/src/create-route.ts +7 -0
- package/src/create-router.ts +91 -0
- package/src/focus.tsx +23 -0
- package/src/hooks.ts +85 -0
- package/src/index.ts +33 -0
- package/src/loader.tsx +20 -0
- package/src/outlet.tsx +118 -0
- package/src/stack.ts +27 -0
- package/src/state-cache.ts +19 -0
- package/src/types.ts +46 -0
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import type { StateCache } from "./state-cache.js";
|
|
3
|
+
export interface RouteDefinition<TParams = Record<string, unknown>> {
|
|
4
|
+
id: string;
|
|
5
|
+
parent?: RouteDefinition;
|
|
6
|
+
component: React.ComponentType;
|
|
7
|
+
title?: string | ((opts: {
|
|
8
|
+
params: TParams;
|
|
9
|
+
}) => string);
|
|
10
|
+
loader?: (opts: {
|
|
11
|
+
params: TParams;
|
|
12
|
+
}) => Promise<unknown>;
|
|
13
|
+
pendingComponent?: React.ComponentType;
|
|
14
|
+
errorComponent?: React.ComponentType<{
|
|
15
|
+
error: Error;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export interface StackEntry {
|
|
19
|
+
routeId: string;
|
|
20
|
+
params: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
export interface RouterState {
|
|
23
|
+
stack: StackEntry[];
|
|
24
|
+
}
|
|
25
|
+
export type RouterAction = {
|
|
26
|
+
type: "push";
|
|
27
|
+
routeId: string;
|
|
28
|
+
params?: Record<string, unknown>;
|
|
29
|
+
} | {
|
|
30
|
+
type: "pop";
|
|
31
|
+
} | {
|
|
32
|
+
type: "replace";
|
|
33
|
+
routeId: string;
|
|
34
|
+
params?: Record<string, unknown>;
|
|
35
|
+
} | {
|
|
36
|
+
type: "reset";
|
|
37
|
+
routeId: string;
|
|
38
|
+
params?: Record<string, unknown>;
|
|
39
|
+
};
|
|
40
|
+
export interface RouterOptions {
|
|
41
|
+
routes: RouteDefinition[];
|
|
42
|
+
defaultRoute: string;
|
|
43
|
+
initialParams?: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
export interface RouterInstance {
|
|
46
|
+
push(routeId: string, params?: Record<string, unknown>): void;
|
|
47
|
+
pop(): void;
|
|
48
|
+
replace(routeId: string, params?: Record<string, unknown>): void;
|
|
49
|
+
reset(routeId: string, params?: Record<string, unknown>): void;
|
|
50
|
+
canGoBack(): boolean;
|
|
51
|
+
readonly currentRoute: StackEntry;
|
|
52
|
+
readonly stack: readonly StackEntry[];
|
|
53
|
+
readonly stateCache: StateCache;
|
|
54
|
+
subscribe(listener: () => void): () => void;
|
|
55
|
+
getRouteDefinition(routeId: string): RouteDefinition | undefined;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAElD,MAAM,WAAW,eAAe,CAAC,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAChE,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,CAAC,EAAE,eAAe,CAAA;IACxB,SAAS,EAAE,KAAK,CAAC,aAAa,CAAA;IAC9B,KAAK,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,IAAI,EAAE;QAAE,MAAM,EAAE,OAAO,CAAA;KAAE,KAAK,MAAM,CAAC,CAAA;IACxD,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,MAAM,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IACxD,gBAAgB,CAAC,EAAE,KAAK,CAAC,aAAa,CAAA;IACtC,cAAc,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;QAAE,KAAK,EAAE,KAAK,CAAA;KAAE,CAAC,CAAA;CACvD;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAChC;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,UAAU,EAAE,CAAA;CACpB;AAED,MAAM,MAAM,YAAY,GACpB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GACnE;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,GACf;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GACtE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAA;AAExE,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,eAAe,EAAE,CAAA;IACzB,YAAY,EAAE,MAAM,CAAA;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACxC;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IAC7D,GAAG,IAAI,IAAI,CAAA;IACX,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IAChE,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IAC9D,SAAS,IAAI,OAAO,CAAA;IACpB,QAAQ,CAAC,YAAY,EAAE,UAAU,CAAA;IACjC,QAAQ,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,CAAA;IACrC,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAA;IAC/B,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAA;IAC3C,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAAA;CACjE"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tooee/router",
|
|
3
|
+
"version": "0.1.8",
|
|
4
|
+
"description": "Stack-based router for Tooee terminal apps",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Gareth Andrew",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/gingerhendrix/tooee.git",
|
|
10
|
+
"directory": "packages/router"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/gingerhendrix/tooee",
|
|
13
|
+
"bugs": "https://github.com/gingerhendrix/tooee/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"tui",
|
|
16
|
+
"terminal",
|
|
17
|
+
"cli",
|
|
18
|
+
"opentui",
|
|
19
|
+
"router",
|
|
20
|
+
"navigation"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": {
|
|
26
|
+
"@tooee/source": "./src/index.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@opentui/core": "^0.1.67",
|
|
40
|
+
"@opentui/react": "^0.1.67",
|
|
41
|
+
"@tooee/commands": "0.1.8",
|
|
42
|
+
"@types/bun": "^1.3.5",
|
|
43
|
+
"@types/react": "^19.1.10",
|
|
44
|
+
"typescript": "^5.8.3"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@opentui/core": "^0.1.67",
|
|
48
|
+
"@opentui/react": "^0.1.67",
|
|
49
|
+
"@tooee/commands": "0.1.8",
|
|
50
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface NavigateResult {
|
|
2
|
+
type: "navigate"
|
|
3
|
+
route: string
|
|
4
|
+
params?: Record<string, unknown>
|
|
5
|
+
mode?: "push" | "replace"
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface BackResult {
|
|
9
|
+
type: "back"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ActionNavigationResult = NavigateResult | BackResult
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useCommand } from "@tooee/commands"
|
|
2
|
+
import { useRouter } from "./hooks.js"
|
|
3
|
+
|
|
4
|
+
export function useRouterCommands() {
|
|
5
|
+
const router = useRouter()
|
|
6
|
+
|
|
7
|
+
useCommand({
|
|
8
|
+
id: "router.back",
|
|
9
|
+
title: "Go back",
|
|
10
|
+
hotkey: "backspace",
|
|
11
|
+
modes: ["cursor"],
|
|
12
|
+
when: () => router.canGoBack(),
|
|
13
|
+
handler: () => router.pop(),
|
|
14
|
+
})
|
|
15
|
+
}
|
package/src/context.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useSyncExternalStore } from "react"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
import type { RouterInstance, StackEntry } from "./types.js"
|
|
4
|
+
|
|
5
|
+
// Contexts
|
|
6
|
+
|
|
7
|
+
const RouterInstanceContext = createContext<RouterInstance | null>(null)
|
|
8
|
+
const RouterStackContext = createContext<readonly StackEntry[]>([])
|
|
9
|
+
export const StackEntryIndexContext = createContext<number>(0)
|
|
10
|
+
|
|
11
|
+
// Provider
|
|
12
|
+
|
|
13
|
+
export interface RouterProviderProps {
|
|
14
|
+
router: RouterInstance
|
|
15
|
+
initialRoute?: string
|
|
16
|
+
initialParams?: Record<string, unknown>
|
|
17
|
+
children: ReactNode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function RouterProvider({
|
|
21
|
+
router,
|
|
22
|
+
initialRoute,
|
|
23
|
+
initialParams,
|
|
24
|
+
children,
|
|
25
|
+
}: RouterProviderProps) {
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (initialRoute && router.currentRoute.routeId !== initialRoute) {
|
|
28
|
+
router.reset(initialRoute, initialParams)
|
|
29
|
+
}
|
|
30
|
+
}, []) // only on mount
|
|
31
|
+
|
|
32
|
+
const stack = useSyncExternalStore(
|
|
33
|
+
router.subscribe,
|
|
34
|
+
() => router.stack,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<RouterInstanceContext value={router}>
|
|
39
|
+
<RouterStackContext value={stack}>
|
|
40
|
+
{children}
|
|
41
|
+
</RouterStackContext>
|
|
42
|
+
</RouterInstanceContext>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Internal hooks
|
|
47
|
+
|
|
48
|
+
export function useRouterInstance(): RouterInstance {
|
|
49
|
+
const ctx = useContext(RouterInstanceContext)
|
|
50
|
+
if (!ctx) throw new Error("useRouterInstance must be used within RouterProvider")
|
|
51
|
+
return ctx
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useRouterStack(): readonly StackEntry[] {
|
|
55
|
+
return useContext(RouterStackContext)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function useStackEntryIndex(): number {
|
|
59
|
+
return useContext(StackEntryIndexContext)
|
|
60
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RouterOptions,
|
|
3
|
+
RouterInstance,
|
|
4
|
+
RouterState,
|
|
5
|
+
RouteDefinition,
|
|
6
|
+
StackEntry,
|
|
7
|
+
} from "./types.js"
|
|
8
|
+
import { stackReducer } from "./stack.js"
|
|
9
|
+
import { StateCache } from "./state-cache.js"
|
|
10
|
+
|
|
11
|
+
export function createRouter(options: RouterOptions): RouterInstance {
|
|
12
|
+
const routeMap = new Map<string, RouteDefinition>()
|
|
13
|
+
for (const route of options.routes) {
|
|
14
|
+
routeMap.set(route.id, route)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!routeMap.has(options.defaultRoute)) {
|
|
18
|
+
throw new Error(`Default route "${options.defaultRoute}" not found in routes`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let state: RouterState = {
|
|
22
|
+
stack: [
|
|
23
|
+
{
|
|
24
|
+
routeId: options.defaultRoute,
|
|
25
|
+
params: options.initialParams ?? {},
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const listeners = new Set<() => void>()
|
|
31
|
+
const stateCache = new StateCache()
|
|
32
|
+
|
|
33
|
+
function dispatch(action: Parameters<typeof stackReducer>[1]) {
|
|
34
|
+
if (action.type !== "pop" && !routeMap.has(action.routeId)) {
|
|
35
|
+
throw new Error(`Route "${action.routeId}" not found`)
|
|
36
|
+
}
|
|
37
|
+
const prev = state
|
|
38
|
+
const next = stackReducer(state, action)
|
|
39
|
+
if (next !== state) {
|
|
40
|
+
if (action.type === "pop" && prev.stack.length > 1) {
|
|
41
|
+
const poppedIndex = prev.stack.length - 1
|
|
42
|
+
const poppedEntry = prev.stack[poppedIndex]
|
|
43
|
+
stateCache.clear(`${poppedIndex}:${poppedEntry.routeId}`)
|
|
44
|
+
} else if (action.type === "reset") {
|
|
45
|
+
stateCache.clearAll()
|
|
46
|
+
}
|
|
47
|
+
state = next
|
|
48
|
+
for (const listener of listeners) {
|
|
49
|
+
listener()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const instance: RouterInstance = {
|
|
55
|
+
push(routeId, params) {
|
|
56
|
+
dispatch({ type: "push", routeId, params })
|
|
57
|
+
},
|
|
58
|
+
pop() {
|
|
59
|
+
dispatch({ type: "pop" })
|
|
60
|
+
},
|
|
61
|
+
replace(routeId, params) {
|
|
62
|
+
dispatch({ type: "replace", routeId, params })
|
|
63
|
+
},
|
|
64
|
+
reset(routeId, params) {
|
|
65
|
+
dispatch({ type: "reset", routeId, params })
|
|
66
|
+
},
|
|
67
|
+
canGoBack() {
|
|
68
|
+
return state.stack.length > 1
|
|
69
|
+
},
|
|
70
|
+
get currentRoute(): StackEntry {
|
|
71
|
+
return state.stack[state.stack.length - 1]
|
|
72
|
+
},
|
|
73
|
+
get stack(): readonly StackEntry[] {
|
|
74
|
+
return state.stack
|
|
75
|
+
},
|
|
76
|
+
get stateCache() {
|
|
77
|
+
return stateCache
|
|
78
|
+
},
|
|
79
|
+
subscribe(listener) {
|
|
80
|
+
listeners.add(listener)
|
|
81
|
+
return () => {
|
|
82
|
+
listeners.delete(listener)
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
getRouteDefinition(routeId) {
|
|
86
|
+
return routeMap.get(routeId)
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return instance
|
|
91
|
+
}
|
package/src/focus.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useMemo, useRef } from "react"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
const ScreenFocusContext = createContext({ isFocused: false })
|
|
5
|
+
|
|
6
|
+
export function ScreenFocusProvider({ active, children }: { active: boolean; children: ReactNode }) {
|
|
7
|
+
const value = useMemo(() => ({ isFocused: active }), [active])
|
|
8
|
+
return <ScreenFocusContext value={value}>{children}</ScreenFocusContext>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function useScreenFocus() {
|
|
12
|
+
return useContext(ScreenFocusContext)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useScreenEffect(effect: () => void | (() => void)) {
|
|
16
|
+
const { isFocused } = useScreenFocus()
|
|
17
|
+
const effectRef = useRef(effect)
|
|
18
|
+
effectRef.current = effect
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!isFocused) return
|
|
21
|
+
return effectRef.current()
|
|
22
|
+
}, [isFocused])
|
|
23
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useCallback } from "react"
|
|
2
|
+
import type { RouterInstance, StackEntry } from "./types.js"
|
|
3
|
+
import type { ActionNavigationResult } from "./action-types.js"
|
|
4
|
+
import { useRouterInstance, useRouterStack, useStackEntryIndex } from "./context.js"
|
|
5
|
+
import { useRouteDataContext } from "./loader.js"
|
|
6
|
+
|
|
7
|
+
export function useNavigate() {
|
|
8
|
+
const router = useRouterInstance()
|
|
9
|
+
return {
|
|
10
|
+
push: useCallback(
|
|
11
|
+
(routeId: string, params?: Record<string, unknown>) => router.push(routeId, params),
|
|
12
|
+
[router],
|
|
13
|
+
),
|
|
14
|
+
pop: useCallback(() => router.pop(), [router]),
|
|
15
|
+
replace: useCallback(
|
|
16
|
+
(routeId: string, params?: Record<string, unknown>) => router.replace(routeId, params),
|
|
17
|
+
[router],
|
|
18
|
+
),
|
|
19
|
+
reset: useCallback(
|
|
20
|
+
(routeId: string, params?: Record<string, unknown>) => router.reset(routeId, params),
|
|
21
|
+
[router],
|
|
22
|
+
),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useParams<T = Record<string, unknown>>(): T {
|
|
27
|
+
const stack = useRouterStack()
|
|
28
|
+
const entry = stack[stack.length - 1]
|
|
29
|
+
return (entry?.params ?? {}) as T
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useRouteData<T = unknown>(): T | undefined {
|
|
33
|
+
return useRouteDataContext<T>()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useCurrentRoute(): StackEntry {
|
|
37
|
+
const stack = useRouterStack()
|
|
38
|
+
return stack[stack.length - 1]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useCanGoBack(): boolean {
|
|
42
|
+
const stack = useRouterStack()
|
|
43
|
+
return stack.length > 1
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function useRouter(): RouterInstance {
|
|
47
|
+
return useRouterInstance()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useActionResultHandler() {
|
|
51
|
+
const router = useRouterInstance()
|
|
52
|
+
return useCallback(
|
|
53
|
+
(result: ActionNavigationResult) => {
|
|
54
|
+
if (result.type === "navigate") {
|
|
55
|
+
if (result.mode === "replace") {
|
|
56
|
+
router.replace(result.route, result.params)
|
|
57
|
+
} else {
|
|
58
|
+
router.push(result.route, result.params)
|
|
59
|
+
}
|
|
60
|
+
} else if (result.type === "back") {
|
|
61
|
+
router.pop()
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
[router],
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function useScreenState<T>(): {
|
|
69
|
+
savedState: T | undefined
|
|
70
|
+
saveState: (state: T) => void
|
|
71
|
+
} {
|
|
72
|
+
const router = useRouterInstance()
|
|
73
|
+
const stackIndex = useStackEntryIndex()
|
|
74
|
+
const stack = useRouterStack()
|
|
75
|
+
const entry = stack[stackIndex]
|
|
76
|
+
const key = `${stackIndex}:${entry.routeId}`
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
savedState: router.stateCache.restore<T>(key),
|
|
80
|
+
saveState: useCallback(
|
|
81
|
+
(state: T) => router.stateCache.save(key, state),
|
|
82
|
+
[router.stateCache, key],
|
|
83
|
+
),
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
RouteDefinition,
|
|
3
|
+
StackEntry,
|
|
4
|
+
RouterState,
|
|
5
|
+
RouterAction,
|
|
6
|
+
RouterOptions,
|
|
7
|
+
RouterInstance,
|
|
8
|
+
} from "./types.js"
|
|
9
|
+
|
|
10
|
+
export { createRoute } from "./create-route.js"
|
|
11
|
+
export { createRouter } from "./create-router.js"
|
|
12
|
+
export { stackReducer } from "./stack.js"
|
|
13
|
+
export { RouterProvider } from "./context.js"
|
|
14
|
+
export type { RouterProviderProps } from "./context.js"
|
|
15
|
+
export { Outlet, getRouteChain } from "./outlet.js"
|
|
16
|
+
export {
|
|
17
|
+
useNavigate,
|
|
18
|
+
useParams,
|
|
19
|
+
useRouteData,
|
|
20
|
+
useCurrentRoute,
|
|
21
|
+
useCanGoBack,
|
|
22
|
+
useRouter,
|
|
23
|
+
useScreenState,
|
|
24
|
+
useActionResultHandler,
|
|
25
|
+
} from "./hooks.js"
|
|
26
|
+
export type {
|
|
27
|
+
NavigateResult,
|
|
28
|
+
BackResult,
|
|
29
|
+
ActionNavigationResult,
|
|
30
|
+
} from "./action-types.js"
|
|
31
|
+
export { ScreenFocusProvider, useScreenFocus, useScreenEffect } from "./focus.js"
|
|
32
|
+
export { StateCache } from "./state-cache.js"
|
|
33
|
+
export { useRouterCommands } from "./command-scope.js"
|
package/src/loader.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createContext, useContext } from "react"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
// Context for route loader data
|
|
5
|
+
|
|
6
|
+
const RouteDataContext = createContext<unknown>(undefined)
|
|
7
|
+
|
|
8
|
+
export function RouteDataProvider({
|
|
9
|
+
data,
|
|
10
|
+
children,
|
|
11
|
+
}: {
|
|
12
|
+
data: unknown
|
|
13
|
+
children: ReactNode
|
|
14
|
+
}) {
|
|
15
|
+
return <RouteDataContext value={data}>{children}</RouteDataContext>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useRouteDataContext<T = unknown>(): T | undefined {
|
|
19
|
+
return useContext(RouteDataContext) as T | undefined
|
|
20
|
+
}
|
package/src/outlet.tsx
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createContext, createElement, useContext, useEffect, useState } from "react"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
import type { RouteDefinition, StackEntry } from "./types.js"
|
|
4
|
+
import { useRouterInstance, useRouterStack, StackEntryIndexContext } from "./context.js"
|
|
5
|
+
import { ScreenFocusProvider } from "./focus.js"
|
|
6
|
+
import { RouteDataProvider } from "./loader.js"
|
|
7
|
+
|
|
8
|
+
// Depth tracking context
|
|
9
|
+
|
|
10
|
+
const OutletDepthContext = createContext<number>(0)
|
|
11
|
+
|
|
12
|
+
// Helper: walk parent chain and return [root, ..., leaf]
|
|
13
|
+
|
|
14
|
+
export function getRouteChain(
|
|
15
|
+
routeMap: { get(id: string): RouteDefinition | undefined },
|
|
16
|
+
routeId: string,
|
|
17
|
+
): RouteDefinition[] {
|
|
18
|
+
const chain: RouteDefinition[] = []
|
|
19
|
+
let current = routeMap.get(routeId)
|
|
20
|
+
while (current) {
|
|
21
|
+
chain.unshift(current)
|
|
22
|
+
current = current.parent
|
|
23
|
+
}
|
|
24
|
+
return chain
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// RouteRenderer: handles loader lifecycle for a route
|
|
28
|
+
|
|
29
|
+
function RouteRenderer({
|
|
30
|
+
entry,
|
|
31
|
+
routeDef,
|
|
32
|
+
children,
|
|
33
|
+
}: {
|
|
34
|
+
entry: StackEntry
|
|
35
|
+
routeDef: RouteDefinition
|
|
36
|
+
children: ReactNode
|
|
37
|
+
}) {
|
|
38
|
+
const [data, setData] = useState<unknown>(undefined)
|
|
39
|
+
const [loading, setLoading] = useState(!!routeDef.loader)
|
|
40
|
+
const [error, setError] = useState<Error | null>(null)
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!routeDef.loader) return
|
|
44
|
+
let cancelled = false
|
|
45
|
+
setLoading(true)
|
|
46
|
+
setError(null)
|
|
47
|
+
routeDef
|
|
48
|
+
.loader({ params: entry.params })
|
|
49
|
+
.then((result) => {
|
|
50
|
+
if (!cancelled) { setData(result); setLoading(false) }
|
|
51
|
+
})
|
|
52
|
+
.catch((err) => {
|
|
53
|
+
if (!cancelled) {
|
|
54
|
+
setError(err instanceof Error ? err : new Error(String(err)))
|
|
55
|
+
setLoading(false)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
return () => { cancelled = true }
|
|
59
|
+
}, [entry, routeDef.loader])
|
|
60
|
+
|
|
61
|
+
if (error && routeDef.errorComponent) {
|
|
62
|
+
return createElement(routeDef.errorComponent, { error })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (error) {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (loading) {
|
|
70
|
+
return routeDef.pendingComponent ? createElement(routeDef.pendingComponent) : null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return <RouteDataProvider data={data}>{children}</RouteDataProvider>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Outlet component
|
|
77
|
+
|
|
78
|
+
export function Outlet() {
|
|
79
|
+
const router = useRouterInstance()
|
|
80
|
+
const stack = useRouterStack()
|
|
81
|
+
const depth = useContext(OutletDepthContext)
|
|
82
|
+
|
|
83
|
+
const topEntry = stack[stack.length - 1]
|
|
84
|
+
if (!topEntry) return null
|
|
85
|
+
|
|
86
|
+
const routeDef = router.getRouteDefinition(topEntry.routeId)
|
|
87
|
+
if (!routeDef) return null
|
|
88
|
+
|
|
89
|
+
const chain = getRouteChain(
|
|
90
|
+
{ get: (id: string) => router.getRouteDefinition(id) },
|
|
91
|
+
topEntry.routeId,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const routeAtDepth = chain[depth]
|
|
95
|
+
if (!routeAtDepth) return null
|
|
96
|
+
|
|
97
|
+
const isTopOfStack = depth === chain.length - 1
|
|
98
|
+
|
|
99
|
+
const content = (
|
|
100
|
+
<StackEntryIndexContext value={stack.length - 1}>
|
|
101
|
+
<OutletDepthContext value={depth + 1}>
|
|
102
|
+
<ScreenFocusProvider active={isTopOfStack}>
|
|
103
|
+
{createElement(routeAtDepth.component)}
|
|
104
|
+
</ScreenFocusProvider>
|
|
105
|
+
</OutletDepthContext>
|
|
106
|
+
</StackEntryIndexContext>
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if (routeAtDepth.loader) {
|
|
110
|
+
return (
|
|
111
|
+
<RouteRenderer entry={topEntry} routeDef={routeAtDepth}>
|
|
112
|
+
{content}
|
|
113
|
+
</RouteRenderer>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return content
|
|
118
|
+
}
|
package/src/stack.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { RouterState, RouterAction } from "./types.js"
|
|
2
|
+
|
|
3
|
+
export function stackReducer(state: RouterState, action: RouterAction): RouterState {
|
|
4
|
+
switch (action.type) {
|
|
5
|
+
case "push":
|
|
6
|
+
return {
|
|
7
|
+
stack: [
|
|
8
|
+
...state.stack,
|
|
9
|
+
{ routeId: action.routeId, params: action.params ?? {} },
|
|
10
|
+
],
|
|
11
|
+
}
|
|
12
|
+
case "pop":
|
|
13
|
+
if (state.stack.length <= 1) return state
|
|
14
|
+
return { stack: state.stack.slice(0, -1) }
|
|
15
|
+
case "replace":
|
|
16
|
+
return {
|
|
17
|
+
stack: [
|
|
18
|
+
...state.stack.slice(0, -1),
|
|
19
|
+
{ routeId: action.routeId, params: action.params ?? {} },
|
|
20
|
+
],
|
|
21
|
+
}
|
|
22
|
+
case "reset":
|
|
23
|
+
return {
|
|
24
|
+
stack: [{ routeId: action.routeId, params: action.params ?? {} }],
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export class StateCache {
|
|
2
|
+
private cache = new Map<string, unknown>()
|
|
3
|
+
|
|
4
|
+
save(key: string, state: unknown): void {
|
|
5
|
+
this.cache.set(key, state)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
restore<T>(key: string): T | undefined {
|
|
9
|
+
return this.cache.get(key) as T | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
clear(key: string): void {
|
|
13
|
+
this.cache.delete(key)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
clearAll(): void {
|
|
17
|
+
this.cache.clear()
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type React from "react"
|
|
2
|
+
import type { StateCache } from "./state-cache.js"
|
|
3
|
+
|
|
4
|
+
export interface RouteDefinition<TParams = Record<string, unknown>> {
|
|
5
|
+
id: string
|
|
6
|
+
parent?: RouteDefinition
|
|
7
|
+
component: React.ComponentType
|
|
8
|
+
title?: string | ((opts: { params: TParams }) => string)
|
|
9
|
+
loader?: (opts: { params: TParams }) => Promise<unknown>
|
|
10
|
+
pendingComponent?: React.ComponentType
|
|
11
|
+
errorComponent?: React.ComponentType<{ error: Error }>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StackEntry {
|
|
15
|
+
routeId: string
|
|
16
|
+
params: Record<string, unknown>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RouterState {
|
|
20
|
+
stack: StackEntry[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type RouterAction =
|
|
24
|
+
| { type: "push"; routeId: string; params?: Record<string, unknown> }
|
|
25
|
+
| { type: "pop" }
|
|
26
|
+
| { type: "replace"; routeId: string; params?: Record<string, unknown> }
|
|
27
|
+
| { type: "reset"; routeId: string; params?: Record<string, unknown> }
|
|
28
|
+
|
|
29
|
+
export interface RouterOptions {
|
|
30
|
+
routes: RouteDefinition[]
|
|
31
|
+
defaultRoute: string
|
|
32
|
+
initialParams?: Record<string, unknown>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RouterInstance {
|
|
36
|
+
push(routeId: string, params?: Record<string, unknown>): void
|
|
37
|
+
pop(): void
|
|
38
|
+
replace(routeId: string, params?: Record<string, unknown>): void
|
|
39
|
+
reset(routeId: string, params?: Record<string, unknown>): void
|
|
40
|
+
canGoBack(): boolean
|
|
41
|
+
readonly currentRoute: StackEntry
|
|
42
|
+
readonly stack: readonly StackEntry[]
|
|
43
|
+
readonly stateCache: StateCache
|
|
44
|
+
subscribe(listener: () => void): () => void
|
|
45
|
+
getRouteDefinition(routeId: string): RouteDefinition | undefined
|
|
46
|
+
}
|