@solidrt/router 0.0.62
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/AGENTS.md +96 -0
- package/README.md +257 -0
- package/package.json +21 -0
- package/src/index.ts +17 -0
- package/src/route.ts +332 -0
- package/src/router.tsx +450 -0
- package/src/stack.ts +249 -0
package/src/router.tsx
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// The Solid binding over route.ts and stack.ts: the state in a signal, the
|
|
2
|
+
// navigation, the back step, blocking, links in and the location out, the
|
|
3
|
+
// JSX tree (`<Route>`) and the components that render the matched routes.
|
|
4
|
+
// Everything here is built on what core offers every app (onBack, onLink,
|
|
5
|
+
// env.launchLink, reportLocation from srt:dev); core knows nothing of routes.
|
|
6
|
+
import { createSignal, createMemo, createContext, createEffect, useContext, untrack, onCleanup, children, Show, For } from "@solidrt/core"
|
|
7
|
+
import { env, onBack, onLink } from "@solidrt/core"
|
|
8
|
+
import { reportLocation } from "srt:dev"
|
|
9
|
+
import { createRootRoute, createRoute, formatPath, linkToPath, matchPath, place } from "./route"
|
|
10
|
+
import type { AnyRoute, Match, ParamsOf, RawParams, Route as RouteValue } from "./route"
|
|
11
|
+
import { back as stepBack, changed, currentPath, findTabs, initialState, push, replace, reset, resolve } from "./stack"
|
|
12
|
+
import type { Entries, NavState, TabsInfo } from "./stack"
|
|
13
|
+
|
|
14
|
+
/** The route value (see route.ts); `Route` the component is the JSX form. */
|
|
15
|
+
export type Route<P = {}> = RouteValue<P>
|
|
16
|
+
|
|
17
|
+
/** Where the app is: the top of the stack, matched. */
|
|
18
|
+
export interface Location {
|
|
19
|
+
/** The path, root-first with params filled in; the stack's own currency. */
|
|
20
|
+
path: string
|
|
21
|
+
/** The matched routes root-first; the last is the screen. */
|
|
22
|
+
matches: Match[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A navigation target: a path (as a link or a tool would give it), a route
|
|
27
|
+
* with no params, or a route with the params its pattern needs. The route
|
|
28
|
+
* forms are checked at compile time; the path form is matched at run time.
|
|
29
|
+
*/
|
|
30
|
+
export type NavTarget<R extends AnyRoute = AnyRoute> = string | Route<{}> | { route: R; params: ParamsOf<R> }
|
|
31
|
+
|
|
32
|
+
export interface NavigateOptions {
|
|
33
|
+
/** Swap the current entry instead of pushing over it. */
|
|
34
|
+
replace?: boolean
|
|
35
|
+
/**
|
|
36
|
+
* Start the stack over at this entry: the app's home after a flow is
|
|
37
|
+
* finished (a scan dialed, a login done), where back should leave the app
|
|
38
|
+
* rather than revisit the flow. With tabs, every tab returns to its root.
|
|
39
|
+
*/
|
|
40
|
+
reset?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A guard on leaving the current entry: false keeps the app where it is;
|
|
45
|
+
* true, or a promise resolving to true, lets the navigation through. The
|
|
46
|
+
* promise form holds it while the user answers a dialog.
|
|
47
|
+
*/
|
|
48
|
+
export type Blocker = () => boolean | Promise<boolean>
|
|
49
|
+
|
|
50
|
+
export interface RouterOptions {
|
|
51
|
+
/** The root of the route tree (createRootRoute). */
|
|
52
|
+
tree: AnyRoute
|
|
53
|
+
/**
|
|
54
|
+
* Where the app starts: a path, or a saved `entries()` to resume with. A
|
|
55
|
+
* launch link (env.launchLink) wins over it: the user just asked for that
|
|
56
|
+
* screen. Defaults to "/".
|
|
57
|
+
*/
|
|
58
|
+
initial?: string | Entries
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The tabs of a tabs route, from `useTabs()`. */
|
|
62
|
+
export interface Tabs {
|
|
63
|
+
/** The tabs' root paths, in declaration order; the first is home. */
|
|
64
|
+
paths: string[]
|
|
65
|
+
/** The tab that shows, as its root path. Reactive. */
|
|
66
|
+
active(): string
|
|
67
|
+
/**
|
|
68
|
+
* Show a tab: another tab as it was left, the active one back at its
|
|
69
|
+
* root. Resolves false when a blocker held it.
|
|
70
|
+
*/
|
|
71
|
+
select(path: string): Promise<boolean>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface Router {
|
|
75
|
+
/**
|
|
76
|
+
* The stack as plain data, bottom first: an array of paths, or with
|
|
77
|
+
* tabs the parent stack and the tabs. Save it in onSuspend. Reactive.
|
|
78
|
+
*/
|
|
79
|
+
entries(): Entries
|
|
80
|
+
/** The matched top of the stack, or null when nothing matches. Reactive. */
|
|
81
|
+
location(): Location | null
|
|
82
|
+
/**
|
|
83
|
+
* Push a target onto the stack (or replace the top, or reset to it).
|
|
84
|
+
* Returns false, and warns, when no route matches; a blocker may cancel it
|
|
85
|
+
* later, which the returned promise reports.
|
|
86
|
+
*/
|
|
87
|
+
navigate(target: NavTarget, options?: NavigateOptions): Promise<boolean>
|
|
88
|
+
/** Pop the stack. Resolves false at the root (nothing to pop) or when blocked. */
|
|
89
|
+
back(): Promise<boolean>
|
|
90
|
+
/** The path a target names; what a link to it would carry. */
|
|
91
|
+
href(target: NavTarget): string
|
|
92
|
+
/** @internal the state as stack.ts sees it */
|
|
93
|
+
state(): NavState
|
|
94
|
+
/** @internal the tree's tabs route, or null */
|
|
95
|
+
tabs: TabsInfo | null
|
|
96
|
+
/** @internal the tab that shows, or null without tabs */
|
|
97
|
+
activeTab(): string | null
|
|
98
|
+
/** @internal a tab's own location: the top of its stack, matched */
|
|
99
|
+
tabLocation(tab: string): () => Location | null
|
|
100
|
+
/** @internal the blockers registered by useBlocker, per scope (null: the parent stack) */
|
|
101
|
+
blockers: Map<string | null, Set<Blocker>>
|
|
102
|
+
/** @internal the tree */
|
|
103
|
+
tree: AnyRoute
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The router's state, created once per app (at module scope or in the app
|
|
108
|
+
* component) and rendered by `<Router router={...}>`. Navigation is a
|
|
109
|
+
* signal write, so Solid's transition semantics apply: a screen whose async
|
|
110
|
+
* reads are pending keeps the previous one on screen, `isPending(() =>
|
|
111
|
+
* router.location()` says so.
|
|
112
|
+
*/
|
|
113
|
+
export function createRouter(options: RouterOptions): Router {
|
|
114
|
+
let tree = options.tree
|
|
115
|
+
let tabs = findTabs(tree)
|
|
116
|
+
let launch = env.launchLink
|
|
117
|
+
let [state, setState] = createSignal<NavState>(
|
|
118
|
+
initialState(tree, tabs, launch != null ? launch : options.initial, linkToPath),
|
|
119
|
+
)
|
|
120
|
+
let located = (path: string | undefined): Location | null => {
|
|
121
|
+
if (path === undefined) return null
|
|
122
|
+
let matches = matchPath(tree, path)
|
|
123
|
+
return matches ? { path, matches } : null
|
|
124
|
+
}
|
|
125
|
+
let location = createMemo<Location | null>(() => located(currentPath(state(), tabs)))
|
|
126
|
+
let tabLocations = new Map<string, () => Location | null>()
|
|
127
|
+
for (let root of tabs?.roots ?? []) {
|
|
128
|
+
tabLocations.set(
|
|
129
|
+
root,
|
|
130
|
+
createMemo<Location | null>(() => {
|
|
131
|
+
let stack = state().tabs?.stacks[root]
|
|
132
|
+
return located(stack?.[stack.length - 1])
|
|
133
|
+
}),
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
let blockers = new Map<string | null, Set<Blocker>>()
|
|
137
|
+
|
|
138
|
+
let href = (target: NavTarget): string => {
|
|
139
|
+
if (typeof target === "string") return linkToPath(target)
|
|
140
|
+
if ("route" in target) return formatPath(target.route, target.params as Record<string, unknown>)
|
|
141
|
+
return formatPath(target)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Runs the blockers of the given scopes; a sync false cancels at once,
|
|
145
|
+
// promises are awaited together and any false cancels.
|
|
146
|
+
let allowed = async (scopes: Array<string | null>): Promise<boolean> => {
|
|
147
|
+
let pending: Promise<boolean>[] = []
|
|
148
|
+
for (let scope of scopes) {
|
|
149
|
+
for (let block of blockers.get(scope) ?? []) {
|
|
150
|
+
let verdict = block()
|
|
151
|
+
if (verdict === false) return false
|
|
152
|
+
if (verdict !== true) pending.push(verdict)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (pending.length === 0) return true
|
|
156
|
+
return (await Promise.all(pending)).every(Boolean)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let router: Router = {
|
|
160
|
+
entries: () => {
|
|
161
|
+
let s = state()
|
|
162
|
+
return s.tabs ? { stack: s.stack, tabs: s.tabs } : s.stack
|
|
163
|
+
},
|
|
164
|
+
location,
|
|
165
|
+
href,
|
|
166
|
+
state,
|
|
167
|
+
tabs,
|
|
168
|
+
activeTab: () => state().tabs?.active ?? null,
|
|
169
|
+
tabLocation: (tab) => tabLocations.get(tab) ?? (() => null),
|
|
170
|
+
blockers,
|
|
171
|
+
tree,
|
|
172
|
+
async navigate(target, options = {}) {
|
|
173
|
+
let path = href(target)
|
|
174
|
+
let to = resolve(tree, tabs, path)
|
|
175
|
+
if (!to) {
|
|
176
|
+
console.warn(`Router: no route matches "${path}"`)
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
let apply = (s: NavState) => (options.reset ? reset(tabs, to) : options.replace ? replace(s, tabs, to) : push(s, tabs, to))
|
|
180
|
+
// The blockers asked are those of the stacks the move changes; the
|
|
181
|
+
// state is read again after they answer, in case it moved meanwhile.
|
|
182
|
+
let before = untrack(state)
|
|
183
|
+
if (!(await allowed(changed(before, apply(before))))) return false
|
|
184
|
+
setState((s) => apply(s))
|
|
185
|
+
return true
|
|
186
|
+
},
|
|
187
|
+
async back() {
|
|
188
|
+
let before = untrack(state)
|
|
189
|
+
let next = stepBack(before, tabs)
|
|
190
|
+
if (!next) return false
|
|
191
|
+
if (!(await allowed(changed(before, next)))) return false
|
|
192
|
+
setState((s) => stepBack(s, tabs) ?? s)
|
|
193
|
+
return true
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
return router
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const RouterContext = createContext<Router>()
|
|
200
|
+
// How deep in the matched chain the surrounding route is; an Outlet renders
|
|
201
|
+
// the next one.
|
|
202
|
+
const DepthContext = createContext<number>(0)
|
|
203
|
+
// The location a subtree renders from: the app's, or inside a tab that
|
|
204
|
+
// tab's own, so a hidden tab keeps its params and screen while another
|
|
205
|
+
// shows. `tab` is the blocker scope.
|
|
206
|
+
interface Scope {
|
|
207
|
+
location: () => Location | null
|
|
208
|
+
tab: string | null
|
|
209
|
+
}
|
|
210
|
+
const ScopeContext = createContext<Scope>()
|
|
211
|
+
|
|
212
|
+
export type RouterProps =
|
|
213
|
+
/** A router made with createRouter, over a tree of route values. */
|
|
214
|
+
| { router: Router; initial?: never; children?: never }
|
|
215
|
+
/**
|
|
216
|
+
* A tree of `<Route>` elements, in matching order, under a root with no
|
|
217
|
+
* component; the router is made here and reached through the hooks.
|
|
218
|
+
*/
|
|
219
|
+
| { router?: never; initial?: string | Entries; children: any }
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Renders the matched routes and owns the app's step of the back stack, the
|
|
223
|
+
* links in and the location out. Mount it once, inside the window. Its
|
|
224
|
+
* `onBack` registers first, so every screen and dialog above it gets the
|
|
225
|
+
* event before the stack pops.
|
|
226
|
+
*/
|
|
227
|
+
export function Router(props: RouterProps) {
|
|
228
|
+
let router = untrack(
|
|
229
|
+
() =>
|
|
230
|
+
props.router ??
|
|
231
|
+
createRouter({ tree: createRootRoute({ children: routesOf(props.children) }), initial: props.initial }),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
onBack((e) => {
|
|
235
|
+
// Nothing to pop at the root: the platform's default (background on
|
|
236
|
+
// Android, exit elsewhere) runs unless something above prevented it.
|
|
237
|
+
if (stepBack(untrack(router.state), router.tabs) === null) return
|
|
238
|
+
e.preventDefault()
|
|
239
|
+
void router.back()
|
|
240
|
+
})
|
|
241
|
+
onLink((link) => {
|
|
242
|
+
void router.navigate(linkToPath(link))
|
|
243
|
+
})
|
|
244
|
+
// The location out: tooling reads it, and a hot reload re-enters it.
|
|
245
|
+
createEffect(
|
|
246
|
+
() => router.location()?.path ?? null,
|
|
247
|
+
(path) => reportLocation(path),
|
|
248
|
+
)
|
|
249
|
+
onCleanup(() => reportLocation(null))
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<RouterContext value={router}>
|
|
253
|
+
<ScopeContext value={{ location: router.location, tab: null }}>
|
|
254
|
+
<RouteView depth={0} />
|
|
255
|
+
</ScopeContext>
|
|
256
|
+
</RouterContext>
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface RouteProps {
|
|
261
|
+
/** This route's own path below its parent (see createRoute); `/` without one. */
|
|
262
|
+
path?: string
|
|
263
|
+
/** What renders here; without one, an outlet alone. */
|
|
264
|
+
component?: () => any
|
|
265
|
+
/** Each child is a tab with a stack of its own (see createRoute). */
|
|
266
|
+
tabs?: boolean
|
|
267
|
+
/**
|
|
268
|
+
* A route value to place here instead: typed or validated params live on
|
|
269
|
+
* the value (createRoute), the JSX gives it its place in the tree. Not
|
|
270
|
+
* with `path`, `component` or `tabs`.
|
|
271
|
+
*/
|
|
272
|
+
route?: AnyRoute
|
|
273
|
+
/** Nested `<Route>` elements, in matching order. */
|
|
274
|
+
children?: any
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* One route of a JSX tree, under `<Router>` or another `<Route>`. Not an
|
|
279
|
+
* element: it evaluates to a route value that its parent places.
|
|
280
|
+
*/
|
|
281
|
+
export function Route(props: RouteProps): any {
|
|
282
|
+
return untrack(() => {
|
|
283
|
+
let below = routesOf(props.children)
|
|
284
|
+
if (props.route) {
|
|
285
|
+
if (props.path !== undefined || props.component !== undefined || props.tabs !== undefined) {
|
|
286
|
+
throw new Error("<Route route={...}> takes no path, component or tabs: they are the route's own")
|
|
287
|
+
}
|
|
288
|
+
place(props.route, below)
|
|
289
|
+
return props.route
|
|
290
|
+
}
|
|
291
|
+
return createRoute({ path: props.path ?? "/", component: props.component, tabs: props.tabs, children: below })
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// The route values a `children` slot holds, resolved once; anything else
|
|
296
|
+
// there is a mistake (a `<Route>` is not an element and cannot mix with
|
|
297
|
+
// them).
|
|
298
|
+
function routesOf(slot: any): AnyRoute[] {
|
|
299
|
+
let list = children(() => slot).toArray()
|
|
300
|
+
for (let item of list) {
|
|
301
|
+
if (typeof item !== "object" || item === null || !("segments" in item)) {
|
|
302
|
+
throw new Error("<Router> and <Route> take only <Route> elements as children")
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return list as unknown as AnyRoute[]
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// The route at `depth` of the scope's match chain. Keyed on the route, not
|
|
309
|
+
// the match: params changing under the same route update reactively through
|
|
310
|
+
// useParams, without a remount. The tabs route is the exception: once
|
|
311
|
+
// mounted at its depth it stays, hidden while another route shows there (a
|
|
312
|
+
// full-window screen over the tab bar), so the tabs keep their state.
|
|
313
|
+
function RouteView(props: { depth: number }) {
|
|
314
|
+
let router = useContext(RouterContext)
|
|
315
|
+
let scope = useContext(ScopeContext)
|
|
316
|
+
let route = createMemo(() => scope.location()?.matches[props.depth]?.route ?? null)
|
|
317
|
+
let tabsRoute = router.tabs?.route ?? null
|
|
318
|
+
let tabsSeen = createMemo(() => tabsRoute !== null && route() === tabsRoute)
|
|
319
|
+
let seen = false
|
|
320
|
+
let tabsMounted = createMemo(() => (seen ||= tabsSeen()))
|
|
321
|
+
let render = (r: AnyRoute) => (r.component ? <r.component /> : <Outlet />)
|
|
322
|
+
return (
|
|
323
|
+
<DepthContext value={props.depth}>
|
|
324
|
+
<Show when={tabsMounted()}>
|
|
325
|
+
<view flex={1} flexDirection="column" display={tabsSeen() ? "flex" : "none"}>
|
|
326
|
+
{render(tabsRoute!)}
|
|
327
|
+
</view>
|
|
328
|
+
</Show>
|
|
329
|
+
<Show when={tabsSeen() ? null : route()} keyed>
|
|
330
|
+
{render}
|
|
331
|
+
</Show>
|
|
332
|
+
</DepthContext>
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Where a layout route's matched child renders. Under the tabs route it
|
|
338
|
+
* holds every tab visited so far, each in its own full-size view, the
|
|
339
|
+
* inactive ones hidden.
|
|
340
|
+
*/
|
|
341
|
+
export function Outlet() {
|
|
342
|
+
let router = useContext(RouterContext)
|
|
343
|
+
let depth = useContext(DepthContext)
|
|
344
|
+
let scope = useContext(ScopeContext)
|
|
345
|
+
let here = untrack(() => scope.location()?.matches[depth]?.route ?? null)
|
|
346
|
+
if (here !== null && here === router.tabs?.route) return <TabsOutlet depth={depth} />
|
|
347
|
+
return <RouteView depth={depth + 1} />
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// The tabs under the tabs route at `depth`: mounted on first visit, kept
|
|
351
|
+
// after, hidden while inactive. A hidden tab is laid out to nothing, so it
|
|
352
|
+
// is never painted, hit or focused; its reactive code keeps running.
|
|
353
|
+
function TabsOutlet(props: { depth: number }) {
|
|
354
|
+
let router = useContext(RouterContext)
|
|
355
|
+
let seen: string[] = []
|
|
356
|
+
let visited = createMemo(() => {
|
|
357
|
+
let active = router.activeTab()
|
|
358
|
+
if (active !== null && !seen.includes(active)) seen = [...seen, active]
|
|
359
|
+
return seen
|
|
360
|
+
})
|
|
361
|
+
return (
|
|
362
|
+
<For each={visited()}>
|
|
363
|
+
{(tab: string) => (
|
|
364
|
+
<view flex={1} flexDirection="column" display={router.activeTab() === tab ? "flex" : "none"}>
|
|
365
|
+
<ScopeContext value={{ location: router.tabLocation(tab), tab }}>
|
|
366
|
+
<RouteView depth={props.depth + 1} />
|
|
367
|
+
</ScopeContext>
|
|
368
|
+
</view>
|
|
369
|
+
)}
|
|
370
|
+
</For>
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The router this component renders under. */
|
|
375
|
+
export function useRouter(): Router {
|
|
376
|
+
return useContext(RouterContext)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The current location, reactive; null when nothing matches. Inside a tab
|
|
381
|
+
* it is that tab's own, whether or not the tab shows.
|
|
382
|
+
*/
|
|
383
|
+
export function useLocation(): () => Location | null {
|
|
384
|
+
return useContext(ScopeContext).location
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The params of the current location. With a route (the one this component
|
|
389
|
+
* renders at, or an ancestor) they are typed by it; without one they are
|
|
390
|
+
* the screen's raw strings, as a JSX `<Route path>` carries them. Reactive:
|
|
391
|
+
* a navigation to the same route with other params updates it in place.
|
|
392
|
+
*/
|
|
393
|
+
export function useParams(): () => RawParams
|
|
394
|
+
export function useParams<R extends AnyRoute>(route: R): () => ParamsOf<R>
|
|
395
|
+
export function useParams(route?: AnyRoute): () => any {
|
|
396
|
+
let scope = useContext(ScopeContext)
|
|
397
|
+
return createMemo(() => {
|
|
398
|
+
let matches = scope.location()?.matches ?? []
|
|
399
|
+
let match = (route && matches.find((m) => m.route === route)) ?? matches[matches.length - 1]
|
|
400
|
+
return match?.params ?? {}
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** `navigate` bound to the router this component renders under. */
|
|
405
|
+
export function useNavigate(): (target: NavTarget, options?: NavigateOptions) => Promise<boolean> {
|
|
406
|
+
let router = useContext(RouterContext)
|
|
407
|
+
return (target, options) => router.navigate(target, options)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The tabs of the tree's tabs route, for the component that draws the tab
|
|
412
|
+
* bar (and for a screen to learn whether its tab shows). Throws without a
|
|
413
|
+
* tabs route.
|
|
414
|
+
*/
|
|
415
|
+
export function useTabs(): Tabs {
|
|
416
|
+
let router = useContext(RouterContext)
|
|
417
|
+
let tabs = router.tabs
|
|
418
|
+
if (!tabs) throw new Error("useTabs: the route tree has no tabs route")
|
|
419
|
+
return {
|
|
420
|
+
paths: tabs.roots,
|
|
421
|
+
active: () => router.activeTab() ?? tabs.roots[0]!,
|
|
422
|
+
select: (path) => router.navigate(path),
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* A link: its path (for display or a tool) and a `go()` for a press handler.
|
|
428
|
+
* Styling is the caller's; a components Link wraps it.
|
|
429
|
+
*/
|
|
430
|
+
export function createLink(target: NavTarget, options?: NavigateOptions): { href: string; go: () => Promise<boolean> } {
|
|
431
|
+
let router = useContext(RouterContext)
|
|
432
|
+
return { href: router.href(target), go: () => router.navigate(target, options) }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Guards leaving the current entry while this component is mounted: back,
|
|
437
|
+
* navigate and replace ask `block` first when they change the stack this
|
|
438
|
+
* component is in (a tab's own, or the parent stack). Return false to stay,
|
|
439
|
+
* true to go, or a promise (a confirm dialog) that decides later. A
|
|
440
|
+
* blocked back is prevented, so it never falls through to the platform
|
|
441
|
+
* default. Showing another tab changes no stack, so it asks no blocker.
|
|
442
|
+
*/
|
|
443
|
+
export function useBlocker(block: Blocker): void {
|
|
444
|
+
let router = useContext(RouterContext)
|
|
445
|
+
let scope = useContext(ScopeContext).tab
|
|
446
|
+
let set = router.blockers.get(scope)
|
|
447
|
+
if (!set) router.blockers.set(scope, (set = new Set()))
|
|
448
|
+
set.add(block)
|
|
449
|
+
onCleanup(() => set.delete(block))
|
|
450
|
+
}
|
package/src/stack.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// The navigation state and its transitions, headless: a parent stack, and
|
|
2
|
+
// with a tabs route in the tree a stack per tab under it. Plain functions
|
|
3
|
+
// over plain data, no signals; router.tsx keeps the state in a signal and
|
|
4
|
+
// applies these. Tested under bun without a client (tests/).
|
|
5
|
+
//
|
|
6
|
+
// Without tabs the state is the one stack of paths it always was. With
|
|
7
|
+
// tabs, the parent stack holds the full-window screens and one marker
|
|
8
|
+
// entry, the tabs route's own path, that stands for the tab bar screen;
|
|
9
|
+
// which tab shows and what each tab has pushed is in `tabs`. This is the
|
|
10
|
+
// navigator tree every native router keeps (a root stack, tabs, a stack per
|
|
11
|
+
// tab), written as data.
|
|
12
|
+
import { formatPath, formatPattern, matchPath, tabsRoutes } from "./route"
|
|
13
|
+
import type { AnyRoute, Match } from "./route"
|
|
14
|
+
|
|
15
|
+
export interface TabsState {
|
|
16
|
+
/** The tab that shows: its root path. */
|
|
17
|
+
active: string
|
|
18
|
+
/** Each tab's stack, bottom first; the bottom is always the tab's root. */
|
|
19
|
+
stacks: Record<string, string[]>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface NavState {
|
|
23
|
+
/** The parent stack, bottom first; `tabs.path` in it is the tab bar screen. */
|
|
24
|
+
stack: string[]
|
|
25
|
+
tabs: TabsState | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The stack as plain data: an array of paths without tabs, the parent stack
|
|
30
|
+
* plus the tabs with them. What `entries()` returns and `initial` takes.
|
|
31
|
+
*/
|
|
32
|
+
export type Entries = string[] | { stack: string[]; tabs: TabsState }
|
|
33
|
+
|
|
34
|
+
/** The tree's tabs route, resolved once. */
|
|
35
|
+
export interface TabsInfo {
|
|
36
|
+
route: AnyRoute
|
|
37
|
+
/** The route's own path: the parent-stack entry that stands for the tab bar. */
|
|
38
|
+
path: string
|
|
39
|
+
/** The tabs' root paths, in declaration order; the first is home. */
|
|
40
|
+
roots: string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function findTabs(root: AnyRoute): TabsInfo | null {
|
|
44
|
+
let found = tabsRoutes(root)
|
|
45
|
+
if (found.length === 0) return null
|
|
46
|
+
if (found.length > 1) {
|
|
47
|
+
throw new Error(`Router: one tabs route per tree ("${formatPattern(found[0]!)}" and "${formatPattern(found[1]!)}")`)
|
|
48
|
+
}
|
|
49
|
+
let route = found[0]!
|
|
50
|
+
return { route, path: formatPath(route), roots: route.children.map((tab) => formatPath(tab)) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A path resolved against the tree: where it lands. */
|
|
54
|
+
export interface Target {
|
|
55
|
+
path: string
|
|
56
|
+
matches: Match[]
|
|
57
|
+
/** The tab it falls under (its root path), or null for the parent stack. */
|
|
58
|
+
tab: string | null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Where `path` lands, or null when nothing matches. A path that names the
|
|
63
|
+
* tabs route itself, with no tab under it, lands on the first tab's root.
|
|
64
|
+
*/
|
|
65
|
+
export function resolve(tree: AnyRoute, tabs: TabsInfo | null, path: string): Target | null {
|
|
66
|
+
let matches = matchPath(tree, path)
|
|
67
|
+
if (!matches) return null
|
|
68
|
+
if (!tabs) return { path, matches, tab: null }
|
|
69
|
+
let at = matches.findIndex((m) => m.route === tabs.route)
|
|
70
|
+
if (at < 0) return { path, matches, tab: null }
|
|
71
|
+
let tab = matches[at + 1]
|
|
72
|
+
if (!tab) return resolve(tree, tabs, tabs.roots[0]!)
|
|
73
|
+
return { path, matches, tab: formatPath(tab.route) }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Every tab on its root, `active` showing.
|
|
77
|
+
function allRoots(tabs: TabsInfo, active: string): TabsState {
|
|
78
|
+
let stacks: Record<string, string[]> = {}
|
|
79
|
+
for (let root of tabs.roots) stacks[root] = [root]
|
|
80
|
+
return { active, stacks }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The parent stack with the tab bar on top: cut back to the marker when it
|
|
84
|
+
// is there (the screens above it leave), appended when it is not. The same
|
|
85
|
+
// array when it already is on top.
|
|
86
|
+
function showTabs(stack: string[], marker: string): string[] {
|
|
87
|
+
let at = stack.lastIndexOf(marker)
|
|
88
|
+
if (at === stack.length - 1) return stack
|
|
89
|
+
return at >= 0 ? stack.slice(0, at + 1) : [...stack, marker]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// `state` with tab `tab` showing and its stack replaced.
|
|
93
|
+
function withTab(state: NavState, tabs: TabsInfo, tab: string, stack: string[]): NavState {
|
|
94
|
+
let current = state.tabs ?? allRoots(tabs, tab)
|
|
95
|
+
let same = current.stacks[tab] === stack
|
|
96
|
+
return {
|
|
97
|
+
stack: showTabs(state.stack, tabs.path),
|
|
98
|
+
tabs: { active: tab, stacks: same ? current.stacks : { ...current.stacks, [tab]: stack } },
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A tab's root as a target is the tab bar's tap: a tab that does not show
|
|
103
|
+
// is shown as it was left, the one that shows goes back to its root. The
|
|
104
|
+
// stack for the target's tab, or null when the target is a deeper screen.
|
|
105
|
+
function toRoot(state: NavState, tabs: TabsInfo, target: Target): string[] | null {
|
|
106
|
+
if (target.path !== target.tab) return null
|
|
107
|
+
let current = state.tabs?.stacks[target.tab!] ?? [target.tab!]
|
|
108
|
+
let showing = state.tabs?.active === target.tab && state.stack[state.stack.length - 1] === tabs.path
|
|
109
|
+
return showing && current.length > 1 ? [target.tab!] : current
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `target` pushed: onto the parent stack, or onto its tab, which then shows. */
|
|
113
|
+
export function push(state: NavState, tabs: TabsInfo | null, target: Target): NavState {
|
|
114
|
+
if (!tabs || target.tab === null) return { ...state, stack: [...state.stack, target.path] }
|
|
115
|
+
let current = state.tabs?.stacks[target.tab] ?? [target.tab]
|
|
116
|
+
return withTab(state, tabs, target.tab, toRoot(state, tabs, target) ?? [...current, target.path])
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `target` swapped for the top of the stack that holds it: the parent
|
|
121
|
+
* stack's top for a full-window screen (the tab bar itself when that is
|
|
122
|
+
* showing), the tab's top for a screen in a tab, which then shows. A tab's
|
|
123
|
+
* root is never swapped out, and a tab's root as the target is a tap on
|
|
124
|
+
* its tab, as for push.
|
|
125
|
+
*/
|
|
126
|
+
export function replace(state: NavState, tabs: TabsInfo | null, target: Target): NavState {
|
|
127
|
+
if (!tabs || target.tab === null) return { ...state, stack: [...state.stack.slice(0, -1), target.path] }
|
|
128
|
+
let current = state.tabs?.stacks[target.tab] ?? [target.tab]
|
|
129
|
+
let next =
|
|
130
|
+
toRoot(state, tabs, target) ?? (current.length > 1 ? [...current.slice(0, -1), target.path] : [target.tab, target.path])
|
|
131
|
+
return withTab(state, tabs, target.tab, next)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The state started over at `target`: every tab on its root, and the
|
|
136
|
+
* target's tab root beneath it when it is in a tab. A full-window target
|
|
137
|
+
* stands alone, so back leaves the app (a flow that ends outside the tabs).
|
|
138
|
+
*/
|
|
139
|
+
export function reset(tabs: TabsInfo | null, target: Target): NavState {
|
|
140
|
+
if (!tabs) return { stack: [target.path], tabs: null }
|
|
141
|
+
if (target.tab === null) return { stack: [target.path], tabs: allRoots(tabs, tabs.roots[0]!) }
|
|
142
|
+
let all = allRoots(tabs, target.tab)
|
|
143
|
+
all.stacks[target.tab] = target.path === target.tab ? [target.tab] : [target.tab, target.path]
|
|
144
|
+
return { stack: [tabs.path], tabs: all }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* One step back: the tab bar showing pops its active tab, or switches a
|
|
149
|
+
* non-first tab at its root to the first; otherwise the parent stack pops.
|
|
150
|
+
* Null when there is nothing to pop.
|
|
151
|
+
*/
|
|
152
|
+
export function back(state: NavState, tabs: TabsInfo | null): NavState | null {
|
|
153
|
+
if (tabs && state.tabs && state.stack[state.stack.length - 1] === tabs.path) {
|
|
154
|
+
let active = state.tabs.active
|
|
155
|
+
let current = state.tabs.stacks[active] ?? [active]
|
|
156
|
+
if (current.length > 1) return withTab(state, tabs, active, current.slice(0, -1))
|
|
157
|
+
if (active !== tabs.roots[0]) return { ...state, tabs: { ...state.tabs, active: tabs.roots[0]! } }
|
|
158
|
+
}
|
|
159
|
+
if (state.stack.length > 1) return { ...state, stack: state.stack.slice(0, -1) }
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The path that shows: the parent stack's top, or the active tab's. */
|
|
164
|
+
export function currentPath(state: NavState, tabs: TabsInfo | null): string | undefined {
|
|
165
|
+
let top = state.stack[state.stack.length - 1]
|
|
166
|
+
if (tabs && state.tabs && top === tabs.path) {
|
|
167
|
+
let stack = state.tabs.stacks[state.tabs.active]
|
|
168
|
+
return stack?.[stack.length - 1]
|
|
169
|
+
}
|
|
170
|
+
return top
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The scopes whose stacks `next` changes from `prev`: null for the parent
|
|
175
|
+
* stack, a tab's root for its stack. What a navigation leaves, so whose
|
|
176
|
+
* blockers it asks; a bare tab switch changes no stack and asks none.
|
|
177
|
+
*/
|
|
178
|
+
export function changed(prev: NavState, next: NavState): Array<string | null> {
|
|
179
|
+
let out: Array<string | null> = []
|
|
180
|
+
if (prev.stack !== next.stack) out.push(null)
|
|
181
|
+
if (prev.tabs && next.tabs && prev.tabs.stacks !== next.tabs.stacks) {
|
|
182
|
+
for (let tab of Object.keys(next.tabs.stacks)) {
|
|
183
|
+
if (prev.tabs.stacks[tab] !== next.tabs.stacks[tab]) out.push(tab)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return out
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The state `initial` names, or the launch link's. A path lands where a
|
|
191
|
+
* push from nothing would, a tab's root beneath a screen in a tab; a list
|
|
192
|
+
* of paths is pushed in order; an `entries()` object is taken as saved
|
|
193
|
+
* when every entry still fits the tree, and not at all otherwise (what
|
|
194
|
+
* the app should show instead is the app's call, not a repair). Paths
|
|
195
|
+
* that match nothing are dropped with a warning, and with nothing left
|
|
196
|
+
* the app starts at "/".
|
|
197
|
+
*/
|
|
198
|
+
export function initialState(
|
|
199
|
+
tree: AnyRoute,
|
|
200
|
+
tabs: TabsInfo | null,
|
|
201
|
+
initial: string | Entries | undefined,
|
|
202
|
+
normalize: (path: string) => string,
|
|
203
|
+
): NavState {
|
|
204
|
+
let target = (path: string): Target | null => {
|
|
205
|
+
let t = resolve(tree, tabs, normalize(path))
|
|
206
|
+
if (!t) console.warn(`Router: no route matches "${path}"; dropped from the initial stack`)
|
|
207
|
+
return t
|
|
208
|
+
}
|
|
209
|
+
if (initial !== undefined && !Array.isArray(initial) && typeof initial !== "string") {
|
|
210
|
+
if (fits(tree, tabs, initial, normalize)) return { stack: initial.stack, tabs: tabs ? initial.tabs : null }
|
|
211
|
+
initial = undefined
|
|
212
|
+
}
|
|
213
|
+
let paths = initial === undefined ? ["/"] : typeof initial === "string" ? [initial] : initial
|
|
214
|
+
let state: NavState | null = null
|
|
215
|
+
for (let path of paths) {
|
|
216
|
+
let t = target(path)
|
|
217
|
+
if (!t) continue
|
|
218
|
+
state = state ? push(state, tabs, t) : reset(tabs, t)
|
|
219
|
+
}
|
|
220
|
+
return state ?? { stack: ["/"], tabs: tabs ? allRoots(tabs, tabs.roots[0]!) : null }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Whether a saved `entries()` object still describes this tree: a parent
|
|
224
|
+
// entry is the tabs marker or lands outside the tabs, every tab is there
|
|
225
|
+
// with its root at the bottom and its entries landing in it, and `active`
|
|
226
|
+
// is a tab. The first misfit is named in a warning.
|
|
227
|
+
function fits(
|
|
228
|
+
tree: AnyRoute,
|
|
229
|
+
tabs: TabsInfo | null,
|
|
230
|
+
saved: { stack: string[]; tabs: TabsState },
|
|
231
|
+
normalize: (path: string) => string,
|
|
232
|
+
): boolean {
|
|
233
|
+
let misfit = (what: string): false => {
|
|
234
|
+
console.warn(`Router: the saved stack does not fit the route tree (${what}); not resumed`)
|
|
235
|
+
return false
|
|
236
|
+
}
|
|
237
|
+
let lands = (path: string, tab: string | null) => resolve(tree, tabs, normalize(path))?.tab === tab
|
|
238
|
+
for (let path of saved.stack) {
|
|
239
|
+
if (!(tabs && path === tabs.path) && !lands(path, null)) return misfit(`"${path}"`)
|
|
240
|
+
}
|
|
241
|
+
if (!tabs) return saved.stack.length > 0 || misfit("empty")
|
|
242
|
+
if (!tabs.roots.includes(saved.tabs.active)) return misfit(`active tab "${saved.tabs.active}"`)
|
|
243
|
+
for (let root of tabs.roots) {
|
|
244
|
+
let entries = saved.tabs.stacks[root]
|
|
245
|
+
if (!entries || entries[0] !== root) return misfit(`tab "${root}"`)
|
|
246
|
+
for (let path of entries) if (!lands(path, root)) return misfit(`"${path}" in tab "${root}"`)
|
|
247
|
+
}
|
|
248
|
+
return saved.stack.length > 0 || misfit("empty")
|
|
249
|
+
}
|