@sigx/lynx-navigation 0.1.0
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/LICENSE +21 -0
- package/dist/components/EdgeBackHandle.d.ts +2 -0
- package/dist/components/EdgeBackHandle.d.ts.map +1 -0
- package/dist/components/Link.d.ts +61 -0
- package/dist/components/Link.d.ts.map +1 -0
- package/dist/components/Link.js +54 -0
- package/dist/components/Link.js.map +1 -0
- package/dist/components/NavigationRoot.d.ts +37 -0
- package/dist/components/NavigationRoot.d.ts.map +1 -0
- package/dist/components/NavigationRoot.js +41 -0
- package/dist/components/NavigationRoot.js.map +1 -0
- package/dist/components/ScreenContainer.d.ts +18 -0
- package/dist/components/ScreenContainer.d.ts.map +1 -0
- package/dist/components/Stack.d.ts +21 -0
- package/dist/components/Stack.d.ts.map +1 -0
- package/dist/components/Stack.js +39 -0
- package/dist/components/Stack.js.map +1 -0
- package/dist/define-routes.d.ts +31 -0
- package/dist/define-routes.d.ts.map +1 -0
- package/dist/define-routes.js +32 -0
- package/dist/define-routes.js.map +1 -0
- package/dist/hooks/use-hardware-back.d.ts +31 -0
- package/dist/hooks/use-hardware-back.d.ts.map +1 -0
- package/dist/hooks/use-nav-internal.d.ts +37 -0
- package/dist/hooks/use-nav-internal.d.ts.map +1 -0
- package/dist/hooks/use-nav-internal.js +12 -0
- package/dist/hooks/use-nav-internal.js.map +1 -0
- package/dist/hooks/use-nav.d.ts +77 -0
- package/dist/hooks/use-nav.d.ts.map +1 -0
- package/dist/hooks/use-nav.js +11 -0
- package/dist/hooks/use-nav.js.map +1 -0
- package/dist/hooks/use-params.d.ts +19 -0
- package/dist/hooks/use-params.d.ts.map +1 -0
- package/dist/hooks/use-params.js +22 -0
- package/dist/hooks/use-params.js.map +1 -0
- package/dist/hooks/use-search.d.ts +11 -0
- package/dist/hooks/use-search.d.ts.map +1 -0
- package/dist/hooks/use-search.js +14 -0
- package/dist/hooks/use-search.js.map +1 -0
- package/dist/href.d.ts +40 -0
- package/dist/href.d.ts.map +1 -0
- package/dist/href.js +14 -0
- package/dist/href.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/screen-width.d.ts +16 -0
- package/dist/internal/screen-width.d.ts.map +1 -0
- package/dist/navigator/core.d.ts +51 -0
- package/dist/navigator/core.d.ts.map +1 -0
- package/dist/navigator/core.js +149 -0
- package/dist/navigator/core.js.map +1 -0
- package/dist/register.d.ts +38 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +2 -0
- package/dist/register.js.map +1 -0
- package/dist/types.d.ts +162 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/package.json +39 -0
- package/src/components/EdgeBackHandle.tsx +161 -0
- package/src/components/Link.tsx +113 -0
- package/src/components/NavigationRoot.tsx +85 -0
- package/src/components/ScreenContainer.tsx +101 -0
- package/src/components/Stack.tsx +99 -0
- package/src/define-routes.ts +33 -0
- package/src/hooks/use-hardware-back.ts +50 -0
- package/src/hooks/use-nav-internal.ts +47 -0
- package/src/hooks/use-nav.ts +118 -0
- package/src/hooks/use-params.ts +23 -0
- package/src/hooks/use-search.ts +15 -0
- package/src/href.ts +58 -0
- package/src/index.ts +38 -0
- package/src/internal/screen-width.ts +34 -0
- package/src/navigator/core.ts +386 -0
- package/src/register.ts +41 -0
- package/src/types.ts +171 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runOnMainThread,
|
|
3
|
+
signal,
|
|
4
|
+
type Signal,
|
|
5
|
+
type SharedValue,
|
|
6
|
+
} from '@sigx/lynx';
|
|
7
|
+
import { withTiming } from '@sigx/motion';
|
|
8
|
+
import type { Nav } from '../hooks/use-nav.js';
|
|
9
|
+
import type {
|
|
10
|
+
PopOptions,
|
|
11
|
+
Presentation,
|
|
12
|
+
PushOptions,
|
|
13
|
+
RouteMap,
|
|
14
|
+
StackEntry,
|
|
15
|
+
TransitionState,
|
|
16
|
+
} from '../types.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The reactive backing state for one navigator instance.
|
|
20
|
+
*
|
|
21
|
+
* Two reactive signals drive the public surface:
|
|
22
|
+
* - `stack` is the entry array (read via `nav.stack` / `nav.current`).
|
|
23
|
+
* - `transition` is non-null only while a push/pop animation is in flight;
|
|
24
|
+
* `<Stack>` reads it to decide whether to render one screen or two.
|
|
25
|
+
*
|
|
26
|
+
* Pop is committed *after* its slide animation completes — `nav.canGoBack`
|
|
27
|
+
* stays true during the slide, then flips when the entry actually leaves the
|
|
28
|
+
* stack. Push commits its stack mutation immediately and animates the new
|
|
29
|
+
* entry in.
|
|
30
|
+
*/
|
|
31
|
+
export interface NavigatorState {
|
|
32
|
+
readonly nav: Nav;
|
|
33
|
+
readonly routes: RouteMap;
|
|
34
|
+
/**
|
|
35
|
+
* Internal: BG-side gesture-back controller used by `<EdgeBackHandle>`.
|
|
36
|
+
* The `progress` SharedValue is wired here so a gesture worklet can write
|
|
37
|
+
* it directly on MT; the begin/commit/cancel methods set the transition
|
|
38
|
+
* state appropriately without driving their own auto-animation (the
|
|
39
|
+
* gesture worklet is in charge of that).
|
|
40
|
+
*/
|
|
41
|
+
readonly _gesture: {
|
|
42
|
+
beginBackGesture(): void;
|
|
43
|
+
commitBackGesture(): void;
|
|
44
|
+
cancelBackGesture(): void;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Slide-from-right transition timing. Kept as constants so screen options
|
|
50
|
+
* can override per-screen later (Phase 0.5). Duration is in seconds — that's
|
|
51
|
+
* what `@sigx/motion`'s `withTiming` expects (per `with-timing.ts`).
|
|
52
|
+
*/
|
|
53
|
+
const TRANSITION_DURATION_SEC = 0.28;
|
|
54
|
+
|
|
55
|
+
let entryKeyCounter = 0;
|
|
56
|
+
function nextEntryKey(): string {
|
|
57
|
+
entryKeyCounter += 1;
|
|
58
|
+
return `entry-${entryKeyCounter}-${Math.random().toString(36).slice(2, 8)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeEntry(
|
|
62
|
+
name: string,
|
|
63
|
+
params: unknown,
|
|
64
|
+
search: unknown,
|
|
65
|
+
options: PushOptions | undefined,
|
|
66
|
+
routes: RouteMap,
|
|
67
|
+
): StackEntry {
|
|
68
|
+
const route = routes[name];
|
|
69
|
+
const presentation: Presentation =
|
|
70
|
+
options?.presentation ?? route?.presentation ?? 'card';
|
|
71
|
+
return {
|
|
72
|
+
key: nextEntryKey(),
|
|
73
|
+
route: name,
|
|
74
|
+
params: (params ?? {}) as Record<string, unknown>,
|
|
75
|
+
search: (search ?? {}) as Record<string, unknown>,
|
|
76
|
+
state: options?.state,
|
|
77
|
+
presentation,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function unpackArgs(
|
|
82
|
+
name: string,
|
|
83
|
+
args: unknown[],
|
|
84
|
+
routes: RouteMap,
|
|
85
|
+
): { params: unknown; search: unknown; options: PushOptions | undefined } {
|
|
86
|
+
const route = routes[name];
|
|
87
|
+
const requiresParams = !!route?.params;
|
|
88
|
+
if (requiresParams) {
|
|
89
|
+
const [params, search, options] = args as [
|
|
90
|
+
unknown,
|
|
91
|
+
unknown,
|
|
92
|
+
PushOptions | undefined,
|
|
93
|
+
];
|
|
94
|
+
return { params, search, options };
|
|
95
|
+
}
|
|
96
|
+
const [search, options] = args as [unknown, PushOptions | undefined];
|
|
97
|
+
return { params: undefined, search, options };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface CreateNavigatorOptions {
|
|
101
|
+
routes: RouteMap;
|
|
102
|
+
initial: StackEntry;
|
|
103
|
+
/**
|
|
104
|
+
* SharedValue driving push/pop transition progress. Created in
|
|
105
|
+
* `<NavigationRoot>` setup via `useSharedValue(0)` so the bridge
|
|
106
|
+
* plumbing is wired (SharedValue is an MT-bridged ref). When undefined,
|
|
107
|
+
* navigations are instant — used by tests against `@sigx/testing-lynx`
|
|
108
|
+
* that don't have an MT runtime.
|
|
109
|
+
*/
|
|
110
|
+
progress?: SharedValue<number>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Create a navigator. Returns the public `nav` handle plus the routes map.
|
|
115
|
+
* The transition signal lives on `nav` (via `nav.transition`) so `<Stack>`
|
|
116
|
+
* can subscribe to it.
|
|
117
|
+
*/
|
|
118
|
+
export function createNavigatorState(opts: CreateNavigatorOptions): NavigatorState {
|
|
119
|
+
const { routes, initial, progress } = opts;
|
|
120
|
+
|
|
121
|
+
const stackSignal: Signal<StackEntry[]> = signal<StackEntry[]>([initial]);
|
|
122
|
+
// `signal(null)` would wrap as a primitive (no `$set`), so wrap in an
|
|
123
|
+
// object to get the standard `{ value }`-style API. Reading `.value`
|
|
124
|
+
// tracks; writing triggers re-render of `<Stack>`.
|
|
125
|
+
const transitionBox: Signal<{ value: TransitionState | null }> = signal<{
|
|
126
|
+
value: TransitionState | null;
|
|
127
|
+
}>({ value: null });
|
|
128
|
+
|
|
129
|
+
function getStack(): StackEntry[] {
|
|
130
|
+
return stackSignal;
|
|
131
|
+
}
|
|
132
|
+
function setStack(next: StackEntry[]): void {
|
|
133
|
+
stackSignal.$set(next);
|
|
134
|
+
}
|
|
135
|
+
function setTransition(next: TransitionState | null): void {
|
|
136
|
+
transitionBox.value = next;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether a transition is currently in flight. Used to no-op concurrent
|
|
141
|
+
* navigation calls — keeps the state machine simple. A queued/aborted
|
|
142
|
+
* model is a v0.3 polish item.
|
|
143
|
+
*/
|
|
144
|
+
function isTransitioning(): boolean {
|
|
145
|
+
return transitionBox.value !== null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Run the slide animation by hopping a worklet onto the main thread that
|
|
150
|
+
* resets `progress` to 0 and starts a `withTiming` to the target. Then
|
|
151
|
+
* wait the animation duration on BG so we can fire the completion
|
|
152
|
+
* callback (clear transition / commit the popped entry) when the visual
|
|
153
|
+
* animation is done.
|
|
154
|
+
*
|
|
155
|
+
* Why the SV reset lives *inside* the worklet (not on BG before the call):
|
|
156
|
+
* the BG-side render ops (Stack re-render mounting the two
|
|
157
|
+
* `ScreenContainer`s with their `useAnimatedStyle` bindings) and a BG-side
|
|
158
|
+
* SV write (`progress.value = 0`) travel different bridge channels. On
|
|
159
|
+
* subsequent navigations, MT can register the new bindings before the
|
|
160
|
+
* BG-side reset arrives — the bindings snapshot sv at its previous
|
|
161
|
+
* end-state (`1`), and `withTiming(sv, 1, ...)` then animates from 1→1
|
|
162
|
+
* (no visible motion). Resetting inside the worklet guarantees the order
|
|
163
|
+
* `bindings register → sv resets → withTiming starts` happens atomically
|
|
164
|
+
* on MT.
|
|
165
|
+
*
|
|
166
|
+
* Why we don't `await` the worklet's Promise: `withTiming` returns a
|
|
167
|
+
* Promise on MT, but Promises don't serialize across the BG/MT bridge —
|
|
168
|
+
* `runOnMainThread`'s callback fires the moment the worklet *returns*
|
|
169
|
+
* (synchronously, with `undefined` since the Promise can't cross), not
|
|
170
|
+
* when the underlying animation finishes. We time the BG-side wait
|
|
171
|
+
* against the duration we passed to MT instead.
|
|
172
|
+
*/
|
|
173
|
+
async function animateProgress(
|
|
174
|
+
target: number,
|
|
175
|
+
durationSec: number,
|
|
176
|
+
): Promise<void> {
|
|
177
|
+
if (!progress) return;
|
|
178
|
+
const sv = progress;
|
|
179
|
+
const runner = runOnMainThread((t: number, d: number) => {
|
|
180
|
+
'main thread';
|
|
181
|
+
// MT-side direct write — `sv.value` is a BG-side getter/setter
|
|
182
|
+
// that emits a "read-only on BG" warning when set; the actual
|
|
183
|
+
// MT field (which `withTiming`'s animate() reads as the start
|
|
184
|
+
// value) is `sv.current.value`. See `packages/runtime-lynx/src/
|
|
185
|
+
// animated/shared-value.ts:14-44`.
|
|
186
|
+
sv.current.value = 0;
|
|
187
|
+
withTiming(sv, t, { duration: d });
|
|
188
|
+
});
|
|
189
|
+
runner(target, durationSec);
|
|
190
|
+
await new Promise<void>((resolve) => {
|
|
191
|
+
setTimeout(resolve, Math.round(durationSec * 1000));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const push: Nav['push'] = ((name: string, ...args: unknown[]) => {
|
|
196
|
+
if (isTransitioning()) return;
|
|
197
|
+
const { params, search, options } = unpackArgs(name, args, routes);
|
|
198
|
+
if (!routes[name]) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`[lynx-navigation] push('${name}'): route is not registered. ` +
|
|
201
|
+
`Known routes: ${Object.keys(routes).join(', ') || '(none)'}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
const newEntry = makeEntry(name, params, search, options, routes);
|
|
205
|
+
const cur = getStack();
|
|
206
|
+
const prevTop = cur[cur.length - 1];
|
|
207
|
+
|
|
208
|
+
// Append eagerly — UX-wise the user just initiated a forward nav, so
|
|
209
|
+
// the new entry should be queryable immediately (`nav.current` =
|
|
210
|
+
// newEntry). The slide animation overlays the visual transition.
|
|
211
|
+
setStack([...cur, newEntry]);
|
|
212
|
+
|
|
213
|
+
const animated = options?.animated !== false && !!progress;
|
|
214
|
+
if (!animated) return;
|
|
215
|
+
|
|
216
|
+
setTransition({
|
|
217
|
+
kind: 'push',
|
|
218
|
+
topEntry: newEntry,
|
|
219
|
+
underneathEntry: prevTop,
|
|
220
|
+
progress,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
animateProgress(1, TRANSITION_DURATION_SEC).then(
|
|
224
|
+
() => setTransition(null),
|
|
225
|
+
() => setTransition(null), // best-effort cleanup on animation rejection
|
|
226
|
+
);
|
|
227
|
+
}) as Nav['push'];
|
|
228
|
+
|
|
229
|
+
const replace: Nav['replace'] = ((name: string, ...args: unknown[]) => {
|
|
230
|
+
if (isTransitioning()) return;
|
|
231
|
+
const { params, search, options } = unpackArgs(name, args, routes);
|
|
232
|
+
if (!routes[name]) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`[lynx-navigation] replace('${name}'): route is not registered.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const entry = makeEntry(name, params, search, options, routes);
|
|
238
|
+
const cur = getStack();
|
|
239
|
+
// Replace doesn't animate in v1 — it's a swap, not a forward/back nav.
|
|
240
|
+
// Adding a fade-or-slide variant is a screen-option in Phase 0.5.
|
|
241
|
+
setStack([...cur.slice(0, cur.length - 1), entry]);
|
|
242
|
+
}) as Nav['replace'];
|
|
243
|
+
|
|
244
|
+
function pop(count: number = 1, options?: PopOptions): void {
|
|
245
|
+
if (isTransitioning()) return;
|
|
246
|
+
const cur = getStack();
|
|
247
|
+
const target = Math.max(1, cur.length - Math.max(1, count));
|
|
248
|
+
if (target === cur.length) return;
|
|
249
|
+
|
|
250
|
+
const animated =
|
|
251
|
+
options?.animated !== false && !!progress && count === 1 && cur.length >= 2;
|
|
252
|
+
if (!animated) {
|
|
253
|
+
setStack(cur.slice(0, target));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Single-step animated pop: keep the popped entry on the stack until
|
|
258
|
+
// the slide finishes, so `<Stack>` can render both screens during the
|
|
259
|
+
// animation. The stack mutation happens on completion.
|
|
260
|
+
const popping = cur[cur.length - 1];
|
|
261
|
+
const next = cur[cur.length - 2];
|
|
262
|
+
setTransition({
|
|
263
|
+
kind: 'pop',
|
|
264
|
+
topEntry: popping,
|
|
265
|
+
underneathEntry: next,
|
|
266
|
+
progress,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
animateProgress(1, TRANSITION_DURATION_SEC).then(
|
|
270
|
+
() => {
|
|
271
|
+
setStack(cur.slice(0, cur.length - 1));
|
|
272
|
+
setTransition(null);
|
|
273
|
+
},
|
|
274
|
+
() => {
|
|
275
|
+
// On animation failure, snap to the destination state anyway —
|
|
276
|
+
// leaving the popped entry rendered would be more confusing
|
|
277
|
+
// than skipping the animation.
|
|
278
|
+
setStack(cur.slice(0, cur.length - 1));
|
|
279
|
+
setTransition(null);
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function popTo(name: string): void {
|
|
285
|
+
if (isTransitioning()) return;
|
|
286
|
+
const cur = getStack();
|
|
287
|
+
for (let i = cur.length - 1; i >= 0; i--) {
|
|
288
|
+
if (cur[i].route === name) {
|
|
289
|
+
if (i === cur.length - 1) return;
|
|
290
|
+
setStack(cur.slice(0, i + 1));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function popToRoot(): void {
|
|
297
|
+
if (isTransitioning()) return;
|
|
298
|
+
const cur = getStack();
|
|
299
|
+
if (cur.length <= 1) return;
|
|
300
|
+
setStack([cur[0]]);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function reset(state: { stack: ReadonlyArray<StackEntry> }): void {
|
|
304
|
+
if (state.stack.length === 0) {
|
|
305
|
+
throw new Error('[lynx-navigation] reset() called with empty stack.');
|
|
306
|
+
}
|
|
307
|
+
setStack([...state.stack]);
|
|
308
|
+
setTransition(null);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function dismiss(): void {
|
|
312
|
+
if (isTransitioning()) return;
|
|
313
|
+
const cur = getStack();
|
|
314
|
+
let i = cur.length - 1;
|
|
315
|
+
while (i > 0 && cur[i].presentation !== 'card') {
|
|
316
|
+
i--;
|
|
317
|
+
}
|
|
318
|
+
if (i < cur.length - 1) {
|
|
319
|
+
setStack(cur.slice(0, i + 1));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Set up a gesture-driven pop transition. Same shape as `pop()` sets but
|
|
325
|
+
* does NOT call `animateProgress` — the gesture worklet writes the
|
|
326
|
+
* progress SV directly per frame, then animates to commit/cancel
|
|
327
|
+
* endpoints on release before invoking `commitBackGesture` or
|
|
328
|
+
* `cancelBackGesture` via `runOnBackground`.
|
|
329
|
+
*/
|
|
330
|
+
function beginBackGesture(): void {
|
|
331
|
+
if (isTransitioning()) return;
|
|
332
|
+
const cur = getStack();
|
|
333
|
+
if (cur.length < 2) return;
|
|
334
|
+
const popping = cur[cur.length - 1];
|
|
335
|
+
const next = cur[cur.length - 2];
|
|
336
|
+
setTransition({
|
|
337
|
+
kind: 'pop',
|
|
338
|
+
topEntry: popping,
|
|
339
|
+
underneathEntry: next,
|
|
340
|
+
progress: progress as unknown,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function commitBackGesture(): void {
|
|
345
|
+
const cur = getStack();
|
|
346
|
+
if (cur.length >= 2) {
|
|
347
|
+
setStack(cur.slice(0, cur.length - 1));
|
|
348
|
+
}
|
|
349
|
+
setTransition(null);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function cancelBackGesture(): void {
|
|
353
|
+
setTransition(null);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const nav: Nav = {
|
|
357
|
+
push,
|
|
358
|
+
replace,
|
|
359
|
+
pop,
|
|
360
|
+
popTo,
|
|
361
|
+
popToRoot,
|
|
362
|
+
reset,
|
|
363
|
+
dismiss,
|
|
364
|
+
get current() {
|
|
365
|
+
return stackSignal[stackSignal.length - 1];
|
|
366
|
+
},
|
|
367
|
+
get stack() {
|
|
368
|
+
return stackSignal;
|
|
369
|
+
},
|
|
370
|
+
get canGoBack() {
|
|
371
|
+
return stackSignal.length > 1;
|
|
372
|
+
},
|
|
373
|
+
get parent() {
|
|
374
|
+
return null;
|
|
375
|
+
},
|
|
376
|
+
get transition() {
|
|
377
|
+
return transitionBox.value;
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
nav,
|
|
383
|
+
routes,
|
|
384
|
+
_gesture: { beginBackGesture, commitBackGesture, cancelBackGesture },
|
|
385
|
+
};
|
|
386
|
+
}
|
package/src/register.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ParamsOf, RouteMap, SearchOf } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Module-augmentation surface for the user's typed route map.
|
|
5
|
+
*
|
|
6
|
+
* Apps register their routes by augmenting this interface — the rest of the
|
|
7
|
+
* library's typed APIs (useNav, useParams, useSearch, <Link>) read from
|
|
8
|
+
* `RegisteredRoutes` so all inference is global.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* // In your app's main.ts (or a routes.ts that's imported early):
|
|
13
|
+
* import type { routes } from './routes';
|
|
14
|
+
*
|
|
15
|
+
* declare module '@sigx/lynx-navigation' {
|
|
16
|
+
* interface Register { routes: typeof routes }
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* If `Register.routes` is not augmented the library falls back to a permissive
|
|
21
|
+
* `RouteMap` so non-augmented usage still type-checks (just without precise
|
|
22
|
+
* inference). The recommended pattern is always to augment.
|
|
23
|
+
*/
|
|
24
|
+
export interface Register {
|
|
25
|
+
// Intentionally empty — users augment with `routes: typeof routes`.
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The user's registered route map, or a permissive fallback when not
|
|
30
|
+
* augmented. All higher-level types derive from this.
|
|
31
|
+
*/
|
|
32
|
+
export type RegisteredRoutes = Register extends { routes: infer R } ? R : RouteMap;
|
|
33
|
+
|
|
34
|
+
/** Union of registered route names (string literal union when registered). */
|
|
35
|
+
export type RouteId = keyof RegisteredRoutes & string;
|
|
36
|
+
|
|
37
|
+
/** Params type for a registered route name. */
|
|
38
|
+
export type RouteParams<K extends RouteId> = ParamsOf<RegisteredRoutes[K]>;
|
|
39
|
+
|
|
40
|
+
/** Search type for a registered route name. */
|
|
41
|
+
export type RouteSearch<K extends RouteId> = SearchOf<RegisteredRoutes[K]>;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for @sigx/lynx-navigation.
|
|
3
|
+
*
|
|
4
|
+
* The type machinery here is the differentiating DX: route names, params, and
|
|
5
|
+
* search are all inferred end-to-end from the user's `defineRoutes` call so
|
|
6
|
+
* `nav.push('profile', { id: 42 })` is a TS error if `id` is typed as string.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Minimal Standard Schema spec subset — see https://standardschema.dev.
|
|
11
|
+
* Inlined so we don't depend on `@standard-schema/spec` for the type spike.
|
|
12
|
+
* Compatible with Zod, Valibot, ArkType, etc.
|
|
13
|
+
*/
|
|
14
|
+
export interface StandardSchemaV1<Input = unknown, Output = Input> {
|
|
15
|
+
readonly '~standard': {
|
|
16
|
+
readonly version: 1;
|
|
17
|
+
readonly vendor: string;
|
|
18
|
+
readonly types?: { readonly input: Input; readonly output: Output };
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Infer the validated output type of a Standard Schema, falling back to
|
|
24
|
+
* `unknown` for non-schema values.
|
|
25
|
+
*/
|
|
26
|
+
export type InferOutput<S> = S extends StandardSchemaV1<unknown, infer O> ? O : unknown;
|
|
27
|
+
|
|
28
|
+
/** Empty record — what `ParamsOf` returns when a route declares no schema. */
|
|
29
|
+
export type EmptyParams = Record<string, never>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How a route entry is presented on the stack.
|
|
33
|
+
* `card` is the default push; `modal`/`fullScreen` slide up; `transparent-modal`
|
|
34
|
+
* preserves the underlying screen visible (e.g. for popovers).
|
|
35
|
+
*/
|
|
36
|
+
export type Presentation = 'card' | 'modal' | 'fullScreen' | 'transparent-modal';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A route definition entry.
|
|
40
|
+
*
|
|
41
|
+
* Users construct this via `defineRoutes({...})`. The `params` and `search`
|
|
42
|
+
* schemas drive runtime validation AND TS inference for `useParams`,
|
|
43
|
+
* `useSearch`, `nav.push`, `<Link>`, etc.
|
|
44
|
+
*
|
|
45
|
+
* `component` accepts an eager component factory or a lazy import — both shapes
|
|
46
|
+
* resolve through sigx's `<Suspense>` boundary at render time.
|
|
47
|
+
*/
|
|
48
|
+
export interface RouteDefinition<
|
|
49
|
+
Params extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined,
|
|
50
|
+
Search extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined,
|
|
51
|
+
> {
|
|
52
|
+
/** Component factory or lazy importer. */
|
|
53
|
+
component: ComponentLike;
|
|
54
|
+
/** Standard-Schema validator for path params. Optional. */
|
|
55
|
+
params?: Params;
|
|
56
|
+
/** Standard-Schema validator for query/search params. Optional. */
|
|
57
|
+
search?: Search;
|
|
58
|
+
/** Optional URL pattern for deep-link serialization (e.g. `/users/:id`). */
|
|
59
|
+
path?: string;
|
|
60
|
+
/** Default presentation when this route is pushed. */
|
|
61
|
+
presentation?: Presentation;
|
|
62
|
+
/** Nested routes — share the URL/path namespace and may inherit options. */
|
|
63
|
+
children?: Record<string, RouteDefinition>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The component shape we accept on a route. Kept structural so we don't pull
|
|
68
|
+
* `ComponentFactory` from sigx at type level (avoids a hard dep on sigx purely
|
|
69
|
+
* for types in the spike). Refined to a real ComponentFactory in Phase 0.1
|
|
70
|
+
* runtime work.
|
|
71
|
+
*/
|
|
72
|
+
export type ComponentLike =
|
|
73
|
+
| ((...args: any[]) => unknown)
|
|
74
|
+
| (() => Promise<{ default: (...args: any[]) => unknown }>);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Map of route definitions, as returned by `defineRoutes`. Keys are route
|
|
78
|
+
* names; values are typed RouteDefinitions.
|
|
79
|
+
*/
|
|
80
|
+
export type RouteMap = Record<string, RouteDefinition>;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Extract params type from a single RouteDefinition.
|
|
84
|
+
* Falls back to `EmptyParams` when the route declares no schema.
|
|
85
|
+
*
|
|
86
|
+
* We use a structural `params: infer S` match (without an `extends
|
|
87
|
+
* StandardSchemaV1` constraint on `S`) because TS conditional types treat the
|
|
88
|
+
* generic-defaulted `StandardSchemaV1<unknown, unknown>` as invariant in this
|
|
89
|
+
* position — a schema typed `StandardSchemaV1<{id:string}>` does not match
|
|
90
|
+
* `extends StandardSchemaV1` reliably under `<const T>` inference. `InferOutput`
|
|
91
|
+
* gracefully handles non-schema `S` by returning `unknown`.
|
|
92
|
+
*/
|
|
93
|
+
export type ParamsOf<R> = R extends { params: infer S } ? InferOutput<S> : EmptyParams;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extract search type from a single RouteDefinition.
|
|
97
|
+
* Falls back to `EmptyParams` when the route declares no schema.
|
|
98
|
+
*/
|
|
99
|
+
export type SearchOf<R> = R extends { search: infer S } ? InferOutput<S> : EmptyParams;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether a route requires a `params` argument when calling `nav.push` etc.
|
|
103
|
+
* True iff the route definition has a `params` field.
|
|
104
|
+
*/
|
|
105
|
+
export type RouteRequiresParams<R> = R extends { params: object } ? true : false;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Per-entry state stored on the stack signal.
|
|
109
|
+
*
|
|
110
|
+
* `key` is unique per entry — needed because the same route can appear more
|
|
111
|
+
* than once (e.g. profile A → message → profile A again). Focus state and
|
|
112
|
+
* scroll position are keyed by `key`, not by route name.
|
|
113
|
+
*/
|
|
114
|
+
export interface StackEntry<R extends string = string, P = unknown, S = unknown> {
|
|
115
|
+
readonly key: string;
|
|
116
|
+
readonly route: R;
|
|
117
|
+
readonly params: P;
|
|
118
|
+
readonly search: S;
|
|
119
|
+
/** User state — survives suspend/restore. */
|
|
120
|
+
state: unknown;
|
|
121
|
+
readonly presentation: Presentation;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Options accepted by `nav.push` / `nav.replace`. */
|
|
125
|
+
export interface PushOptions {
|
|
126
|
+
/** Override the route's default presentation for this navigation. */
|
|
127
|
+
presentation?: Presentation;
|
|
128
|
+
/** User state to attach to the new entry. Survives suspend/restore. */
|
|
129
|
+
state?: unknown;
|
|
130
|
+
/**
|
|
131
|
+
* Skip the slide animation (instant swap). Defaults to true on platforms
|
|
132
|
+
* where `useAnimatedStyle` isn't available (test renderer); defaults to
|
|
133
|
+
* false on real Lynx. Tests can force `false` to keep assertions
|
|
134
|
+
* deterministic.
|
|
135
|
+
*/
|
|
136
|
+
animated?: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Options accepted by `nav.pop`. */
|
|
140
|
+
export interface PopOptions {
|
|
141
|
+
/** Skip the slide animation (instant swap). See `PushOptions.animated`. */
|
|
142
|
+
animated?: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Direction of an in-flight transition.
|
|
147
|
+
* - `push`: a new entry is animating in (progress 0 → 1).
|
|
148
|
+
* - `pop`: the current top is animating out (progress 0 → 1, then committed).
|
|
149
|
+
*/
|
|
150
|
+
export type TransitionKind = 'push' | 'pop';
|
|
151
|
+
|
|
152
|
+
/** Role of a screen during a transition — determines its transform formula. */
|
|
153
|
+
export type TransitionRole = 'top' | 'underneath';
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Snapshot of an in-flight transition. Stored on the navigator state so the
|
|
157
|
+
* `<Stack>` component knows to render two entries (`topEntry` above
|
|
158
|
+
* `underneathEntry`) and bind their transforms to `progress`.
|
|
159
|
+
*
|
|
160
|
+
* `progress` is a `SharedValue<number>` (re-exported as `unknown` here to
|
|
161
|
+
* avoid a hard dep on `@sigx/lynx`'s SharedValue type at the contract level —
|
|
162
|
+
* the runtime `<Stack>` casts as needed). The value runs 0 → 1 in both push
|
|
163
|
+
* and pop, with the role/kind pair determining the visual direction.
|
|
164
|
+
*/
|
|
165
|
+
export interface TransitionState {
|
|
166
|
+
readonly kind: TransitionKind;
|
|
167
|
+
readonly topEntry: StackEntry;
|
|
168
|
+
readonly underneathEntry: StackEntry;
|
|
169
|
+
/** Animation progress signal — typed loosely; cast at the runtime boundary. */
|
|
170
|
+
readonly progress: unknown;
|
|
171
|
+
}
|