@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.
Files changed (66) hide show
  1. package/dist/action-types.d.ts +11 -0
  2. package/dist/action-types.d.ts.map +1 -0
  3. package/dist/action-types.js +2 -0
  4. package/dist/action-types.js.map +1 -0
  5. package/dist/command-scope.d.ts +2 -0
  6. package/dist/command-scope.d.ts.map +1 -0
  7. package/dist/command-scope.js +14 -0
  8. package/dist/command-scope.js.map +1 -0
  9. package/dist/context.d.ts +14 -0
  10. package/dist/context.d.ts.map +1 -0
  11. package/dist/context.js +29 -0
  12. package/dist/context.js.map +1 -0
  13. package/dist/create-route.d.ts +3 -0
  14. package/dist/create-route.d.ts.map +1 -0
  15. package/dist/create-route.js +4 -0
  16. package/dist/create-route.js.map +1 -0
  17. package/dist/create-router.d.ts +3 -0
  18. package/dist/create-router.d.ts.map +1 -0
  19. package/dist/create-router.js +79 -0
  20. package/dist/create-router.js.map +1 -0
  21. package/dist/focus.d.ts +10 -0
  22. package/dist/focus.d.ts.map +1 -0
  23. package/dist/focus.js +21 -0
  24. package/dist/focus.js.map +1 -0
  25. package/dist/hooks.d.ts +19 -0
  26. package/dist/hooks.d.ts.map +1 -0
  27. package/dist/hooks.js +59 -0
  28. package/dist/hooks.js.map +1 -0
  29. package/dist/index.d.ts +13 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +10 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/loader.d.ts +7 -0
  34. package/dist/loader.d.ts.map +1 -0
  35. package/dist/loader.js +11 -0
  36. package/dist/loader.js.map +1 -0
  37. package/dist/outlet.d.ts +7 -0
  38. package/dist/outlet.d.ts.map +1 -0
  39. package/dist/outlet.js +78 -0
  40. package/dist/outlet.js.map +1 -0
  41. package/dist/stack.d.ts +3 -0
  42. package/dist/stack.d.ts.map +1 -0
  43. package/dist/stack.js +27 -0
  44. package/dist/stack.js.map +1 -0
  45. package/dist/state-cache.d.ts +8 -0
  46. package/dist/state-cache.d.ts.map +1 -0
  47. package/dist/state-cache.js +16 -0
  48. package/dist/state-cache.js.map +1 -0
  49. package/dist/types.d.ts +57 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +2 -0
  52. package/dist/types.js.map +1 -0
  53. package/package.json +52 -0
  54. package/src/action-types.ts +12 -0
  55. package/src/command-scope.tsx +15 -0
  56. package/src/context.tsx +60 -0
  57. package/src/create-route.ts +7 -0
  58. package/src/create-router.ts +91 -0
  59. package/src/focus.tsx +23 -0
  60. package/src/hooks.ts +85 -0
  61. package/src/index.ts +33 -0
  62. package/src/loader.tsx +20 -0
  63. package/src/outlet.tsx +118 -0
  64. package/src/stack.ts +27 -0
  65. package/src/state-cache.ts +19 -0
  66. package/src/types.ts +46 -0
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -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
+ }
@@ -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,7 @@
1
+ import type { RouteDefinition } from "./types.js"
2
+
3
+ export function createRoute<TParams = Record<string, unknown>>(
4
+ options: RouteDefinition<TParams>,
5
+ ): RouteDefinition<TParams> {
6
+ return options
7
+ }
@@ -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
+ }