@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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/dist/components/EdgeBackHandle.d.ts +2 -0
  3. package/dist/components/EdgeBackHandle.d.ts.map +1 -0
  4. package/dist/components/Link.d.ts +61 -0
  5. package/dist/components/Link.d.ts.map +1 -0
  6. package/dist/components/Link.js +54 -0
  7. package/dist/components/Link.js.map +1 -0
  8. package/dist/components/NavigationRoot.d.ts +37 -0
  9. package/dist/components/NavigationRoot.d.ts.map +1 -0
  10. package/dist/components/NavigationRoot.js +41 -0
  11. package/dist/components/NavigationRoot.js.map +1 -0
  12. package/dist/components/ScreenContainer.d.ts +18 -0
  13. package/dist/components/ScreenContainer.d.ts.map +1 -0
  14. package/dist/components/Stack.d.ts +21 -0
  15. package/dist/components/Stack.d.ts.map +1 -0
  16. package/dist/components/Stack.js +39 -0
  17. package/dist/components/Stack.js.map +1 -0
  18. package/dist/define-routes.d.ts +31 -0
  19. package/dist/define-routes.d.ts.map +1 -0
  20. package/dist/define-routes.js +32 -0
  21. package/dist/define-routes.js.map +1 -0
  22. package/dist/hooks/use-hardware-back.d.ts +31 -0
  23. package/dist/hooks/use-hardware-back.d.ts.map +1 -0
  24. package/dist/hooks/use-nav-internal.d.ts +37 -0
  25. package/dist/hooks/use-nav-internal.d.ts.map +1 -0
  26. package/dist/hooks/use-nav-internal.js +12 -0
  27. package/dist/hooks/use-nav-internal.js.map +1 -0
  28. package/dist/hooks/use-nav.d.ts +77 -0
  29. package/dist/hooks/use-nav.d.ts.map +1 -0
  30. package/dist/hooks/use-nav.js +11 -0
  31. package/dist/hooks/use-nav.js.map +1 -0
  32. package/dist/hooks/use-params.d.ts +19 -0
  33. package/dist/hooks/use-params.d.ts.map +1 -0
  34. package/dist/hooks/use-params.js +22 -0
  35. package/dist/hooks/use-params.js.map +1 -0
  36. package/dist/hooks/use-search.d.ts +11 -0
  37. package/dist/hooks/use-search.d.ts.map +1 -0
  38. package/dist/hooks/use-search.js +14 -0
  39. package/dist/hooks/use-search.js.map +1 -0
  40. package/dist/href.d.ts +40 -0
  41. package/dist/href.d.ts.map +1 -0
  42. package/dist/href.js +14 -0
  43. package/dist/href.js.map +1 -0
  44. package/dist/index.d.ts +21 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +15 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/internal/screen-width.d.ts +16 -0
  49. package/dist/internal/screen-width.d.ts.map +1 -0
  50. package/dist/navigator/core.d.ts +51 -0
  51. package/dist/navigator/core.d.ts.map +1 -0
  52. package/dist/navigator/core.js +149 -0
  53. package/dist/navigator/core.js.map +1 -0
  54. package/dist/register.d.ts +38 -0
  55. package/dist/register.d.ts.map +1 -0
  56. package/dist/register.js +2 -0
  57. package/dist/register.js.map +1 -0
  58. package/dist/types.d.ts +162 -0
  59. package/dist/types.d.ts.map +1 -0
  60. package/dist/types.js +9 -0
  61. package/dist/types.js.map +1 -0
  62. package/package.json +39 -0
  63. package/src/components/EdgeBackHandle.tsx +161 -0
  64. package/src/components/Link.tsx +113 -0
  65. package/src/components/NavigationRoot.tsx +85 -0
  66. package/src/components/ScreenContainer.tsx +101 -0
  67. package/src/components/Stack.tsx +99 -0
  68. package/src/define-routes.ts +33 -0
  69. package/src/hooks/use-hardware-back.ts +50 -0
  70. package/src/hooks/use-nav-internal.ts +47 -0
  71. package/src/hooks/use-nav.ts +118 -0
  72. package/src/hooks/use-params.ts +23 -0
  73. package/src/hooks/use-search.ts +15 -0
  74. package/src/href.ts +58 -0
  75. package/src/index.ts +38 -0
  76. package/src/internal/screen-width.ts +34 -0
  77. package/src/navigator/core.ts +386 -0
  78. package/src/register.ts +41 -0
  79. 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
+ }