@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
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sigx/lynx-navigation",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Type-first native stack router for sigx-lynx",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"@sigx/lynx": "^0.1.0",
|
|
17
|
+
"@sigx/lynx-linking": "^0.1.0"
|
|
18
|
+
},
|
|
19
|
+
"peerDependenciesMeta": {
|
|
20
|
+
"@sigx/lynx-linking": {
|
|
21
|
+
"optional": true
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"typescript": "^5.9.3",
|
|
26
|
+
"vitest": "^3.2.4",
|
|
27
|
+
"@sigx/lynx": "^0.1.0",
|
|
28
|
+
"@sigx/lynx-linking": "^0.1.0",
|
|
29
|
+
"@sigx/testing-lynx": "^0.2.4"
|
|
30
|
+
},
|
|
31
|
+
"author": "Andreas Ekdahl",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc --emitDeclarationOnly",
|
|
35
|
+
"dev": "tsc --emitDeclarationOnly --watch",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:types": "tsc --noEmit"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {
|
|
2
|
+
component,
|
|
3
|
+
Gesture,
|
|
4
|
+
runOnBackground,
|
|
5
|
+
useGestureDetector,
|
|
6
|
+
useMainThreadRef,
|
|
7
|
+
type MainThread,
|
|
8
|
+
} from '@sigx/lynx';
|
|
9
|
+
import { withTiming } from '@sigx/motion';
|
|
10
|
+
import { useNavInternals } from '../hooks/use-nav-internal.js';
|
|
11
|
+
import { SCREEN_WIDTH } from '../internal/screen-width.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Edge-pan recognizer for iOS-style swipe-back. Mounts as an absolutely-
|
|
15
|
+
* positioned 20px-wide strip on the left edge of the active screen; only
|
|
16
|
+
* exists when `nav.canGoBack && !transition`.
|
|
17
|
+
*
|
|
18
|
+
* `Gesture.Pan().minDistance(MIN_DISTANCE)` lets quick taps pass through to
|
|
19
|
+
* whatever's behind the strip (back button, screen header, etc.). Only
|
|
20
|
+
* horizontal drags past the threshold activate the gesture.
|
|
21
|
+
*
|
|
22
|
+
* MT/BG split:
|
|
23
|
+
* - All gesture handlers run on MT. They write `progress.current.value`
|
|
24
|
+
* directly per frame (no per-frame bridge crossing) and dispatch
|
|
25
|
+
* `runOnBackground(...)` only at start/commit/cancel — three BG hops
|
|
26
|
+
* per gesture max.
|
|
27
|
+
* - The transition state machine on BG mounts the underneath
|
|
28
|
+
* `<ScreenContainer>` once `beginBackGesture` lands; the gesture's
|
|
29
|
+
* in-flight progress writes are picked up the moment the binding
|
|
30
|
+
* registers (Phase 0.5 polish: pre-mount underneath when canGoBack to
|
|
31
|
+
* eliminate the brief pre-mount latency).
|
|
32
|
+
*
|
|
33
|
+
* Implementation notes (matching `<Draggable>`):
|
|
34
|
+
* - Single `useMainThreadRef` holding an object — primitive refs don't
|
|
35
|
+
* survive worklet capture cleanly in some Lynx versions, while object
|
|
36
|
+
* refs do (the worklet runtime resolves the ref via the
|
|
37
|
+
* `_workletRefMap`).
|
|
38
|
+
* - `e: any` rather than `e: unknown` — type annotations are erased, but
|
|
39
|
+
* SWC's worklet transform has been observed to behave better with the
|
|
40
|
+
* looser annotation. Keeps us aligned with Draggable verbatim.
|
|
41
|
+
* - Empty `onBegin`: load-bearing on iOS — without a registered onBegin
|
|
42
|
+
* callback, `LynxPanGestureHandler` skips the begin path and onStart/
|
|
43
|
+
* onEnd never fire (per Draggable's notes).
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** Fraction of screen width past which a release commits the back nav. */
|
|
47
|
+
const COMMIT_TRANSLATION = 0.33;
|
|
48
|
+
/** px/sec horizontal speed past which a release commits, regardless of distance. */
|
|
49
|
+
const COMMIT_VELOCITY = 300;
|
|
50
|
+
/** Width of the touchable strip on the left edge of every screen. */
|
|
51
|
+
const EDGE_ZONE_WIDTH = 20;
|
|
52
|
+
/** Minimum movement before the gesture activates (lets taps pass through). */
|
|
53
|
+
const MIN_DISTANCE = 8;
|
|
54
|
+
const SNAP_DURATION_SEC = 0.18;
|
|
55
|
+
/**
|
|
56
|
+
* Pre-computed milliseconds for the BG-side `setTimeout`. Module-level so
|
|
57
|
+
* it's in scope for both the MT worklet (`withTiming` argument) and the BG
|
|
58
|
+
* callback wrapped by `runOnBackground` (`setTimeout` argument). Locals
|
|
59
|
+
* declared inside an MT worklet body are MT-only — the BG callback's
|
|
60
|
+
* closure can't see them, hence "ReferenceError: snapMs is not defined".
|
|
61
|
+
*/
|
|
62
|
+
const SNAP_DURATION_MS = Math.round(SNAP_DURATION_SEC * 1000);
|
|
63
|
+
|
|
64
|
+
export const EdgeBackHandle = component(() => {
|
|
65
|
+
const ref = useMainThreadRef<MainThread.Element | null>(null);
|
|
66
|
+
// Per-gesture transient state — captured as a plain closure object
|
|
67
|
+
// rather than a `useMainThreadRef`. Lynx's SWC worklet transform deep-
|
|
68
|
+
// copies plain objects into `_c` once at register time; mutations on MT
|
|
69
|
+
// persist across calls because the same `_c` is bound for the lifetime
|
|
70
|
+
// of the gesture registration. Using a `useMainThreadRef` here was
|
|
71
|
+
// crashing on iOS with `cannot read property 'current' of undefined`
|
|
72
|
+
// — the resolved-ref capture path looked up an empty
|
|
73
|
+
// `_workletRefMap` entry under a race I haven't fully tracked down.
|
|
74
|
+
// Plain object avoids that path entirely.
|
|
75
|
+
const state = {
|
|
76
|
+
startPageX: 0,
|
|
77
|
+
prevPageX: 0,
|
|
78
|
+
prevTime: 0,
|
|
79
|
+
velocity: 0,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const internals = useNavInternals();
|
|
83
|
+
const progress = internals.progress;
|
|
84
|
+
const beginBackGesture = internals.beginBackGesture;
|
|
85
|
+
const commitBackGesture = internals.commitBackGesture;
|
|
86
|
+
const cancelBackGesture = internals.cancelBackGesture;
|
|
87
|
+
|
|
88
|
+
const pan = Gesture.Pan()
|
|
89
|
+
.minDistance(MIN_DISTANCE)
|
|
90
|
+
.onBegin(() => {
|
|
91
|
+
'main thread';
|
|
92
|
+
})
|
|
93
|
+
.onStart((e: any) => {
|
|
94
|
+
'main thread';
|
|
95
|
+
const p = e && e.params;
|
|
96
|
+
const pageX = (p && p.pageX) || 0;
|
|
97
|
+
state.startPageX = pageX;
|
|
98
|
+
state.prevPageX = pageX;
|
|
99
|
+
state.prevTime = Date.now();
|
|
100
|
+
state.velocity = 0;
|
|
101
|
+
runOnBackground(() => {
|
|
102
|
+
beginBackGesture();
|
|
103
|
+
})();
|
|
104
|
+
})
|
|
105
|
+
.onUpdate((e: any) => {
|
|
106
|
+
'main thread';
|
|
107
|
+
if (!progress) return;
|
|
108
|
+
const p = e && e.params;
|
|
109
|
+
const pageX = (p && p.pageX) || 0;
|
|
110
|
+
const dx = pageX - state.startPageX;
|
|
111
|
+
const prog = Math.max(0, Math.min(1, dx / SCREEN_WIDTH));
|
|
112
|
+
progress.current.value = prog;
|
|
113
|
+
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
const dt = now - state.prevTime;
|
|
116
|
+
if (dt > 0) {
|
|
117
|
+
state.velocity =
|
|
118
|
+
((pageX - state.prevPageX) / dt) * 1000;
|
|
119
|
+
}
|
|
120
|
+
state.prevPageX = pageX;
|
|
121
|
+
state.prevTime = now;
|
|
122
|
+
})
|
|
123
|
+
.onEnd((e: any) => {
|
|
124
|
+
'main thread';
|
|
125
|
+
if (!progress) return;
|
|
126
|
+
const p = e && e.params;
|
|
127
|
+
const pageX = (p && p.pageX) || 0;
|
|
128
|
+
const dx = pageX - state.startPageX;
|
|
129
|
+
const fraction = dx / SCREEN_WIDTH;
|
|
130
|
+
const commit =
|
|
131
|
+
fraction > COMMIT_TRANSLATION ||
|
|
132
|
+
state.velocity > COMMIT_VELOCITY;
|
|
133
|
+
|
|
134
|
+
if (commit) {
|
|
135
|
+
withTiming(progress, 1, { duration: SNAP_DURATION_SEC });
|
|
136
|
+
runOnBackground(() => {
|
|
137
|
+
setTimeout(() => commitBackGesture(), SNAP_DURATION_MS);
|
|
138
|
+
})();
|
|
139
|
+
} else {
|
|
140
|
+
withTiming(progress, 0, { duration: SNAP_DURATION_SEC });
|
|
141
|
+
runOnBackground(() => {
|
|
142
|
+
setTimeout(() => cancelBackGesture(), SNAP_DURATION_MS);
|
|
143
|
+
})();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
useGestureDetector(ref, pan);
|
|
148
|
+
|
|
149
|
+
return () => (
|
|
150
|
+
<view
|
|
151
|
+
main-thread:ref={ref}
|
|
152
|
+
style={{
|
|
153
|
+
position: 'absolute',
|
|
154
|
+
top: '0',
|
|
155
|
+
left: '0',
|
|
156
|
+
width: `${EDGE_ZONE_WIDTH}px`,
|
|
157
|
+
bottom: '0',
|
|
158
|
+
}}
|
|
159
|
+
/>
|
|
160
|
+
);
|
|
161
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { component, type Define } from '@sigx/lynx';
|
|
2
|
+
import { useNav } from '../hooks/use-nav.js';
|
|
3
|
+
import { useNavRoutes } from '../hooks/use-nav-internal.js';
|
|
4
|
+
import type { RouteId, RouteParams, RouteSearch } from '../register.js';
|
|
5
|
+
import type { RoutesWithParams } from '../hooks/use-nav.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-route conditional props for `<Link>`.
|
|
9
|
+
*
|
|
10
|
+
* Mapped over `RouteId`, then indexed by `RouteId` to flatten into a union.
|
|
11
|
+
* Each branch enforces the `params`-required-iff-route-has-schema rule:
|
|
12
|
+
*
|
|
13
|
+
* - `<Link to="profile" />` → TS error (profile requires params)
|
|
14
|
+
* - `<Link to="profile" params={...} />` → ok
|
|
15
|
+
* - `<Link to="home" />` → ok (home has no params)
|
|
16
|
+
* - `<Link to="home" params={...} />` → TS error (home accepts no params)
|
|
17
|
+
*
|
|
18
|
+
* Same per-route discrimination as `nav.push`, expressed as a JSX-friendly
|
|
19
|
+
* union rather than overloads.
|
|
20
|
+
*/
|
|
21
|
+
type LinkPropsByRoute = {
|
|
22
|
+
[K in RouteId]: K extends RoutesWithParams
|
|
23
|
+
? { to: K; params: RouteParams<K>; search?: RouteSearch<K> }
|
|
24
|
+
: { to: K; params?: undefined; search?: RouteSearch<K> };
|
|
25
|
+
}[RouteId];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Public type for `<Link>`'s props. The conditional `LinkPropsByRoute` carries
|
|
29
|
+
* the typed `to`/`params`/`search` triple; the rest are simple optionals.
|
|
30
|
+
*
|
|
31
|
+
* `children` is declared explicitly because the public type is exposed via a
|
|
32
|
+
* type-cast (so JSX sees a function-shaped signature). The cast strips sigx's
|
|
33
|
+
* built-in `Define.Slot<'default'>` → JSX-children wiring, so we add a
|
|
34
|
+
* permissive `children?` slot ourselves.
|
|
35
|
+
*/
|
|
36
|
+
export type LinkProps = LinkPropsByRoute & {
|
|
37
|
+
/** Use `replace` instead of `push` (no new history entry). */
|
|
38
|
+
replace?: boolean;
|
|
39
|
+
/** Link content rendered inside the tappable container. */
|
|
40
|
+
children?: unknown;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Loose internal props used by the component implementation. The runtime
|
|
45
|
+
* doesn't enforce the per-route conditional — that's a TS-only constraint
|
|
46
|
+
* surfaced via the public `LinkProps` type. The cast at the bottom of this
|
|
47
|
+
* file rewires the export to the strict type.
|
|
48
|
+
*/
|
|
49
|
+
type LinkPropsLoose =
|
|
50
|
+
& Define.Prop<'to', string, true>
|
|
51
|
+
& Define.Prop<'params', Record<string, unknown> | undefined>
|
|
52
|
+
& Define.Prop<'search', Record<string, unknown> | undefined>
|
|
53
|
+
& Define.Prop<'replace', boolean>
|
|
54
|
+
& Define.Slot<'default'>;
|
|
55
|
+
|
|
56
|
+
const LinkImpl = component<LinkPropsLoose>(({ props, slots }) => {
|
|
57
|
+
const nav = useNav();
|
|
58
|
+
const routes = useNavRoutes();
|
|
59
|
+
|
|
60
|
+
const handlePress = (): void => {
|
|
61
|
+
const route = props.to;
|
|
62
|
+
const routeDef = routes[route];
|
|
63
|
+
if (!routeDef) {
|
|
64
|
+
// Defensive: prop was typed against the registry, so this shouldn't
|
|
65
|
+
// happen at runtime — but if it does (e.g. a stale Link survived a
|
|
66
|
+
// route removal), surface a clear error rather than crashing on
|
|
67
|
+
// the navigator's lookup.
|
|
68
|
+
throw new Error(
|
|
69
|
+
`[lynx-navigation] <Link to='${route}'>: route is not registered.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const hasParams = !!routeDef.params;
|
|
73
|
+
const action = props.replace ? nav.replace : nav.push;
|
|
74
|
+
// Branch on whether the route declares a params schema so positional
|
|
75
|
+
// args land correctly: routes-with-params shift everything one slot
|
|
76
|
+
// (push/replace overload signatures `(name, params, search?, options?)`
|
|
77
|
+
// vs `(name, search?, options?)`). Calling the wrong shape silently
|
|
78
|
+
// puts `search` into the `options` slot.
|
|
79
|
+
if (hasParams) {
|
|
80
|
+
(action as (n: string, p: unknown, s?: unknown) => void)(
|
|
81
|
+
route,
|
|
82
|
+
props.params,
|
|
83
|
+
props.search,
|
|
84
|
+
);
|
|
85
|
+
} else {
|
|
86
|
+
(action as (n: string, s?: unknown) => void)(route, props.search);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return () => (
|
|
91
|
+
// `bindtap` is correct here because the underlying element is a Lynx
|
|
92
|
+
// native `<view>` — sigx component-level events would use `onPress`.
|
|
93
|
+
<view bindtap={handlePress}>{slots.default?.()}</view>
|
|
94
|
+
);
|
|
95
|
+
}, { name: 'Link' });
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Declarative navigation. Same typing as `nav.push` — pass `params` only when
|
|
99
|
+
* the route declares a schema. Wraps a `<view>` that fires `nav.push` (or
|
|
100
|
+
* `nav.replace` if `replace` is set) on tap.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```tsx
|
|
104
|
+
* <Link to="home">Home</Link>
|
|
105
|
+
* <Link to="profile" params={{ id: '42' }}>View profile</Link>
|
|
106
|
+
* <Link to="profile" params={{ id: '42' }} search={{ tab: 'about' }}>About</Link>
|
|
107
|
+
* <Link to="settings" replace>Settings (no back)</Link>
|
|
108
|
+
* ```
|
|
109
|
+
*
|
|
110
|
+
* The cast widens the inferred prop type from the loose impl to the strict
|
|
111
|
+
* `LinkProps` so JSX usage gets per-route discrimination. Runtime is identical.
|
|
112
|
+
*/
|
|
113
|
+
export const Link = LinkImpl as unknown as (props: LinkProps) => unknown;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { component, defineProvide, useSharedValue, type Define } from '@sigx/lynx';
|
|
2
|
+
import { createNavigatorState } from '../navigator/core.js';
|
|
3
|
+
import { useNav } from '../hooks/use-nav.js';
|
|
4
|
+
import { useNavInternals, useNavRoutes } from '../hooks/use-nav-internal.js';
|
|
5
|
+
import type { RouteId } from '../register.js';
|
|
6
|
+
import type { Presentation, RouteMap, StackEntry } from '../types.js';
|
|
7
|
+
|
|
8
|
+
type NavigationRootProps =
|
|
9
|
+
& Define.Prop<'routes', RouteMap, true>
|
|
10
|
+
& Define.Prop<'initialRoute', RouteId>
|
|
11
|
+
& Define.Prop<'initialParams', Record<string, unknown>>
|
|
12
|
+
& Define.Prop<'initialSearch', Record<string, unknown>>
|
|
13
|
+
/**
|
|
14
|
+
* Enable slide-from-right transitions on push/pop. Defaults to true.
|
|
15
|
+
* Tests against `@sigx/testing-lynx` (which doesn't have an MT runtime)
|
|
16
|
+
* should pass `animated={false}` so navigations commit synchronously.
|
|
17
|
+
*/
|
|
18
|
+
& Define.Prop<'animated', boolean>
|
|
19
|
+
/**
|
|
20
|
+
* Enable the iOS-style edge-swipe-back gesture. Defaults to true. Set
|
|
21
|
+
* to false if it conflicts with screen content on the leftmost 20px,
|
|
22
|
+
* or while debugging gesture issues.
|
|
23
|
+
*/
|
|
24
|
+
& Define.Prop<'edgeSwipeEnabled', boolean>
|
|
25
|
+
& Define.Slot<'default'>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Root of a navigator subtree.
|
|
29
|
+
*
|
|
30
|
+
* Creates a fresh `NavigatorState` from `routes` and provides it via
|
|
31
|
+
* `defineProvide`, so descendant `<Stack>` / `<Screen>` components and any
|
|
32
|
+
* `useNav()` / `useParams()` calls resolve through this instance.
|
|
33
|
+
*
|
|
34
|
+
* The bottom-of-stack entry is built from `initialRoute` (defaults to the
|
|
35
|
+
* first key in `routes`). For routes that declare a params schema, you must
|
|
36
|
+
* pass `initialParams` matching that schema.
|
|
37
|
+
*
|
|
38
|
+
* Mirrors the install pattern of `@sigx/router` (see
|
|
39
|
+
* `packages/router/src/router.ts:519-528`), but at component scope rather than
|
|
40
|
+
* `app.use(router)` — no app-wide singleton, so multi-navigator apps and
|
|
41
|
+
* tests get isolated state for free.
|
|
42
|
+
*/
|
|
43
|
+
export const NavigationRoot = component<NavigationRootProps>(({ props, slots }) => {
|
|
44
|
+
const routes = props.routes;
|
|
45
|
+
const initialName: string = props.initialRoute ?? Object.keys(routes)[0];
|
|
46
|
+
if (!routes[initialName]) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`[lynx-navigation] <NavigationRoot> initialRoute='${initialName}' is not in the routes registry.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const initialPresentation: Presentation = routes[initialName].presentation ?? 'card';
|
|
52
|
+
const initial: StackEntry = {
|
|
53
|
+
key: 'root',
|
|
54
|
+
route: initialName,
|
|
55
|
+
params: props.initialParams ?? {},
|
|
56
|
+
search: props.initialSearch ?? {},
|
|
57
|
+
state: undefined,
|
|
58
|
+
presentation: initialPresentation,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// SharedValue driving the slide-from-right push/pop transition. Created
|
|
62
|
+
// unconditionally (hooks must be) but only forwarded into the navigator
|
|
63
|
+
// when animations are enabled — `createNavigatorState` falls back to
|
|
64
|
+
// instant swaps when `progress` is undefined.
|
|
65
|
+
const progressSv = useSharedValue(0);
|
|
66
|
+
const animationsEnabled = props.animated !== false;
|
|
67
|
+
const navState = createNavigatorState({
|
|
68
|
+
routes,
|
|
69
|
+
initial,
|
|
70
|
+
progress: animationsEnabled ? progressSv : undefined,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
defineProvide(useNav, () => navState.nav);
|
|
74
|
+
defineProvide(useNavRoutes, () => navState.routes);
|
|
75
|
+
const edgeSwipeEnabled = props.edgeSwipeEnabled !== false;
|
|
76
|
+
defineProvide(useNavInternals, () => ({
|
|
77
|
+
progress: animationsEnabled ? progressSv : null,
|
|
78
|
+
beginBackGesture: navState._gesture.beginBackGesture,
|
|
79
|
+
commitBackGesture: navState._gesture.commitBackGesture,
|
|
80
|
+
cancelBackGesture: navState._gesture.cancelBackGesture,
|
|
81
|
+
edgeSwipeEnabled,
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
return () => slots.default?.();
|
|
85
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
component,
|
|
3
|
+
useMainThreadRef,
|
|
4
|
+
useAnimatedStyle,
|
|
5
|
+
type ComponentFactory,
|
|
6
|
+
type Define,
|
|
7
|
+
type MainThread,
|
|
8
|
+
type SharedValue,
|
|
9
|
+
} from '@sigx/lynx';
|
|
10
|
+
import type { MapperParams } from '@sigx/lynx';
|
|
11
|
+
import { SCREEN_WIDTH } from '../internal/screen-width.js';
|
|
12
|
+
import type { RouteMap, StackEntry, TransitionKind, TransitionRole } from '../types.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Slide-from-right transition geometry. `SCREEN_WIDTH` is read from
|
|
16
|
+
* `lynx.SystemInfo` at module load so the animation lands the screen at
|
|
17
|
+
* exactly translateX=0 (centered) at progress=1, rather than overshooting
|
|
18
|
+
* into the parent's clip region. `<EdgeBackHandle>` reads the same
|
|
19
|
+
* constant — they have to agree, otherwise the gesture commit threshold
|
|
20
|
+
* and the animation geometry don't line up.
|
|
21
|
+
*/
|
|
22
|
+
const PARALLAX_FACTOR = 0.3;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compute the `translateX` range for a given (role, kind) pair. Progress
|
|
26
|
+
* always runs 0 → 1; the role and kind decide what visual state each end of
|
|
27
|
+
* the progress represents.
|
|
28
|
+
*
|
|
29
|
+
* Slide-from-right semantics:
|
|
30
|
+
* - PUSH: new top slides in from the right; old top parallaxes left.
|
|
31
|
+
* - POP: current top slides out to the right; underneath returns from the
|
|
32
|
+
* parallax-left position.
|
|
33
|
+
*/
|
|
34
|
+
function getRangeParams(
|
|
35
|
+
role: TransitionRole,
|
|
36
|
+
kind: TransitionKind,
|
|
37
|
+
): MapperParams['translateX'] {
|
|
38
|
+
if (kind === 'push') {
|
|
39
|
+
if (role === 'top') {
|
|
40
|
+
return { inputRange: [0, 1], outputRange: [SCREEN_WIDTH, 0] };
|
|
41
|
+
}
|
|
42
|
+
return { inputRange: [0, 1], outputRange: [0, -PARALLAX_FACTOR * SCREEN_WIDTH] };
|
|
43
|
+
}
|
|
44
|
+
// pop
|
|
45
|
+
if (role === 'top') {
|
|
46
|
+
return { inputRange: [0, 1], outputRange: [0, SCREEN_WIDTH] };
|
|
47
|
+
}
|
|
48
|
+
return { inputRange: [0, 1], outputRange: [-PARALLAX_FACTOR * SCREEN_WIDTH, 0] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type ScreenContainerProps =
|
|
52
|
+
& Define.Prop<'entry', StackEntry, true>
|
|
53
|
+
& Define.Prop<'routes', RouteMap, true>
|
|
54
|
+
& Define.Prop<'role', TransitionRole, true>
|
|
55
|
+
& Define.Prop<'kind', TransitionKind, true>
|
|
56
|
+
& Define.Prop<'progress', SharedValue<number>, true>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Animated screen slot — absolutely positioned, MT-bound translateX driven by
|
|
60
|
+
* the navigator's progress SharedValue. Used during transitions to render the
|
|
61
|
+
* top + underneath entries together.
|
|
62
|
+
*
|
|
63
|
+
* Each instance is keyed by `${entry.key}-${role}-${kind}` in the parent so a
|
|
64
|
+
* role/kind change forces a fresh mount with a fresh `useAnimatedStyle`
|
|
65
|
+
* binding (the binding is set at setup and can't be re-keyed mid-life). State
|
|
66
|
+
* loss across transition boundaries is accepted in v0.2; persistent screen
|
|
67
|
+
* state (scroll position, input fields surviving navigations) is a polish
|
|
68
|
+
* item for Phase 0.5+.
|
|
69
|
+
*/
|
|
70
|
+
export const ScreenContainer = component<ScreenContainerProps>(({ props }) => {
|
|
71
|
+
const ref = useMainThreadRef<MainThread.Element | null>(null);
|
|
72
|
+
const params = getRangeParams(props.role, props.kind);
|
|
73
|
+
useAnimatedStyle(ref, props.progress, 'translateX', params);
|
|
74
|
+
|
|
75
|
+
return () => {
|
|
76
|
+
const route = props.routes[props.entry.route];
|
|
77
|
+
if (!route) return null;
|
|
78
|
+
const Comp = route.component as unknown as ComponentFactory<
|
|
79
|
+
Record<string, unknown>,
|
|
80
|
+
unknown,
|
|
81
|
+
unknown
|
|
82
|
+
>;
|
|
83
|
+
if (typeof Comp !== 'function') return null;
|
|
84
|
+
const entryParams = props.entry.params as Record<string, unknown>;
|
|
85
|
+
return (
|
|
86
|
+
<view
|
|
87
|
+
main-thread:ref={ref}
|
|
88
|
+
style={{
|
|
89
|
+
position: 'absolute',
|
|
90
|
+
top: '0',
|
|
91
|
+
left: '0',
|
|
92
|
+
right: '0',
|
|
93
|
+
bottom: '0',
|
|
94
|
+
backgroundColor: '#0f172a',
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<Comp key={props.entry.key} {...entryParams} />
|
|
98
|
+
</view>
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { component, type ComponentFactory, type SharedValue } from '@sigx/lynx';
|
|
2
|
+
import { useNav } from '../hooks/use-nav.js';
|
|
3
|
+
import { useNavInternals, useNavRoutes } from '../hooks/use-nav-internal.js';
|
|
4
|
+
import { ScreenContainer } from './ScreenContainer.js';
|
|
5
|
+
import { EdgeBackHandle } from './EdgeBackHandle.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Stack navigator — renders the topmost stack entry's component at rest, or
|
|
9
|
+
* the top + underneath entries during a transition.
|
|
10
|
+
*
|
|
11
|
+
* **Idle**: just the top entry, full-bleed, no transform. The screen
|
|
12
|
+
* component mounts directly so it can use its own layout (no extra absolute
|
|
13
|
+
* positioning that would break percentage heights).
|
|
14
|
+
*
|
|
15
|
+
* **Transitioning**: two `<ScreenContainer>` instances stacked absolutely,
|
|
16
|
+
* each with an MT-driven `translateX` that reads from the navigator's
|
|
17
|
+
* progress `SharedValue`. The host's BG thread doesn't tick per frame —
|
|
18
|
+
* `useAnimatedStyle` runs the interpolation entirely on MT.
|
|
19
|
+
*
|
|
20
|
+
* `key={top.key}` keeps the idle render's component instance stable across
|
|
21
|
+
* unrelated re-renders. During transitions, composite keys
|
|
22
|
+
* (`${entry.key}-${role}-${kind}`) ensure a fresh mount per role/kind pair so
|
|
23
|
+
* the `useAnimatedStyle` binding is set with the right input/output ranges.
|
|
24
|
+
*/
|
|
25
|
+
export const Stack = component(() => {
|
|
26
|
+
const nav = useNav();
|
|
27
|
+
const routes = useNavRoutes();
|
|
28
|
+
const internals = useNavInternals();
|
|
29
|
+
|
|
30
|
+
return () => {
|
|
31
|
+
const transition = nav.transition;
|
|
32
|
+
const top = nav.current;
|
|
33
|
+
|
|
34
|
+
if (!transition) {
|
|
35
|
+
const route = routes[top.route];
|
|
36
|
+
if (!route) return null;
|
|
37
|
+
const Comp = route.component as unknown as ComponentFactory<
|
|
38
|
+
Record<string, unknown>,
|
|
39
|
+
unknown,
|
|
40
|
+
unknown
|
|
41
|
+
>;
|
|
42
|
+
if (typeof Comp !== 'function') return null;
|
|
43
|
+
const params = top.params as Record<string, unknown>;
|
|
44
|
+
// When canGoBack and edge-swipe is enabled, overlay the gesture
|
|
45
|
+
// handle so the user can pan from the left edge to start a back
|
|
46
|
+
// transition. `position: absolute` doesn't disturb the screen's
|
|
47
|
+
// own layout — the handle only intercepts touches in the leftmost
|
|
48
|
+
// 20px, and only when they pan rightward past `MIN_DISTANCE`.
|
|
49
|
+
if (nav.canGoBack && internals.edgeSwipeEnabled) {
|
|
50
|
+
return (
|
|
51
|
+
<view
|
|
52
|
+
style={{
|
|
53
|
+
position: 'relative',
|
|
54
|
+
width: '100%',
|
|
55
|
+
height: '100%',
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<Comp key={top.key} {...params} />
|
|
59
|
+
<EdgeBackHandle key="edge-back" />
|
|
60
|
+
</view>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return <Comp key={top.key} {...params} />;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Cast progress: TransitionState carries it as `unknown` to avoid
|
|
67
|
+
// pinning the contract to `@sigx/lynx`'s SharedValue at the type
|
|
68
|
+
// level; here at the runtime boundary we know it's a SharedValue<number>.
|
|
69
|
+
const progress = transition.progress as SharedValue<number>;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<view
|
|
73
|
+
style={{
|
|
74
|
+
position: 'relative',
|
|
75
|
+
width: '100%',
|
|
76
|
+
height: '100%',
|
|
77
|
+
overflow: 'hidden',
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<ScreenContainer
|
|
81
|
+
key={`${transition.underneathEntry.key}-underneath-${transition.kind}`}
|
|
82
|
+
entry={transition.underneathEntry}
|
|
83
|
+
routes={routes}
|
|
84
|
+
role="underneath"
|
|
85
|
+
kind={transition.kind}
|
|
86
|
+
progress={progress}
|
|
87
|
+
/>
|
|
88
|
+
<ScreenContainer
|
|
89
|
+
key={`${transition.topEntry.key}-top-${transition.kind}`}
|
|
90
|
+
entry={transition.topEntry}
|
|
91
|
+
routes={routes}
|
|
92
|
+
role="top"
|
|
93
|
+
kind={transition.kind}
|
|
94
|
+
progress={progress}
|
|
95
|
+
/>
|
|
96
|
+
</view>
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RouteMap } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Define a typed route registry.
|
|
5
|
+
*
|
|
6
|
+
* Returns the input verbatim at runtime — the function exists for TypeScript
|
|
7
|
+
* inference. The returned type is narrowed to the literal route map so that
|
|
8
|
+
* downstream APIs (`useNav`, `useParams`, `<Link>`) can extract route names
|
|
9
|
+
* and params/search schemas precisely.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { defineRoutes } from '@sigx/lynx-navigation';
|
|
14
|
+
* import { z } from 'zod';
|
|
15
|
+
*
|
|
16
|
+
* export const routes = defineRoutes({
|
|
17
|
+
* home: { component: lazy(() => import('./Home')) },
|
|
18
|
+
* profile: {
|
|
19
|
+
* params: z.object({ id: z.string() }),
|
|
20
|
+
* component: lazy(() => import('./Profile')),
|
|
21
|
+
* path: '/users/:id',
|
|
22
|
+
* },
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* // Then in app entry:
|
|
26
|
+
* declare module '@sigx/lynx-navigation' {
|
|
27
|
+
* interface Register { routes: typeof routes }
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function defineRoutes<const T extends RouteMap>(routes: T): T {
|
|
32
|
+
return routes;
|
|
33
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { onMounted } from '@sigx/lynx';
|
|
2
|
+
import { BackHandler } from '@sigx/lynx-linking';
|
|
3
|
+
import { useNav } from './use-nav.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wire the Android hardware back button to the active navigator.
|
|
7
|
+
*
|
|
8
|
+
* Listens for `hardwareBackPress` events from `@sigx/lynx-linking`'s
|
|
9
|
+
* `BackHandler` (which the native side dispatches from
|
|
10
|
+
* `MainActivity.onBackPressed`). On press:
|
|
11
|
+
*
|
|
12
|
+
* - If `nav.canGoBack` → `nav.pop()`.
|
|
13
|
+
* - Otherwise → `BackHandler.exitApp()` (Android: `moveTaskToBack(true)`,
|
|
14
|
+
* keeps the bundle warm; iOS: rejects, since iOS doesn't permit
|
|
15
|
+
* programmatic termination).
|
|
16
|
+
*
|
|
17
|
+
* Call this once in any component under `<NavigationRoot>` (typically a
|
|
18
|
+
* thin wrapper sibling to `<Stack />`). iOS doesn't fire the event so the
|
|
19
|
+
* hook is a no-op there.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```tsx
|
|
23
|
+
* const BackHandlerWiring = component(() => {
|
|
24
|
+
* useHardwareBack();
|
|
25
|
+
* return () => null;
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* <NavigationRoot routes={routes}>
|
|
29
|
+
* <BackHandlerWiring />
|
|
30
|
+
* <Stack />
|
|
31
|
+
* </NavigationRoot>
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function useHardwareBack(): void {
|
|
35
|
+
const nav = useNav();
|
|
36
|
+
onMounted(() => {
|
|
37
|
+
const sub = BackHandler.addEventListener(() => {
|
|
38
|
+
if (nav.canGoBack) {
|
|
39
|
+
nav.pop();
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
// At the root — leave the app. Promise is fire-and-forget; we
|
|
43
|
+
// don't await because we want the back press to feel instant
|
|
44
|
+
// (Android starts the move-to-back transition immediately).
|
|
45
|
+
void BackHandler.exitApp();
|
|
46
|
+
return true;
|
|
47
|
+
});
|
|
48
|
+
return () => sub.remove();
|
|
49
|
+
});
|
|
50
|
+
}
|