@sigx/lynx-navigation 0.1.2 → 0.2.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.
@@ -4,31 +4,32 @@
4
4
  * Usage:
5
5
  *
6
6
  * ```tsx
7
- * <NavigationRoot routes={routes}>
8
- * <Tabs initialTab="feed">
9
- * <Tabs.Screen name="feed" icon={<FeedIcon />} label="Feed">
10
- * <FeedView />
11
- * </Tabs.Screen>
12
- * <Tabs.Screen name="me" icon={<MeIcon />} label="Profile">
13
- * <ProfileView />
14
- * </Tabs.Screen>
15
- * </Tabs>
7
+ * <NavigationRoot routes={routes} initialRoute="root">
8
+ * <Stack />
16
9
  * </NavigationRoot>
17
- * ```
18
10
  *
19
- * Scope of this slice (v0.1): pure UI primitive. Each tab's body stays
20
- * mounted for state preservation (the inactive ones render with
21
- * `display: 'none'`). Active tab is reactive via `useTabs()`.
11
+ * // The route "root" component renders:
12
+ * <Tabs initialTab="feed">
13
+ * <Tabs.Screen name="feed" icon={<FeedIcon />} label="Feed">
14
+ * <Stack initialRoute="feedHome" />
15
+ * </Tabs.Screen>
16
+ * <Tabs.Screen name="me" icon={<MeIcon />} label="Profile">
17
+ * <Stack initialRoute="profileHome" />
18
+ * </Tabs.Screen>
19
+ * <TabBar />
20
+ * </Tabs>
21
+ * ```
22
22
  *
23
- * Out of scope (deferred to a nested-navigators slice):
24
- * - Per-tab `<Stack>` with its own navigator state machine
25
- * - `nav.parent` chain into the Tabs nav
26
- * - Named navigators (`useNav('root')`)
23
+ * Tab bodies stay mounted across switches (the inactive ones render with
24
+ * `display: 'none'`), so each tab's nested `<Stack>` keeps its history when
25
+ * the user flips back to it. The active tab is reactive via `useTabs()`.
27
26
  *
28
- * Those build on multi-navigator-state plumbing that isn't ready yet.
29
- * For now, the inner content of a `<Tabs.Screen>` shares the same nav as
30
- * its outer `<NavigationRoot>` usable for shallow tab apps, but full
31
- * nested routing comes later.
27
+ * Per-tab stacks: each `<Tabs.Screen>` can host a `<Stack initialRoute="…">`
28
+ * which mints its own navigator. `useNav()` inside that subtree resolves to
29
+ * the tab's stack, so `nav.push('card-route', …)` stays inside the tab.
30
+ * Routes presented as `modal` / `fullScreen` / `transparent-modal` escalate
31
+ * up `nav.parent` to the root navigator automatically — they overlay the
32
+ * tabs UI (TabBar included) and dismiss back into the originating tab.
32
33
  */
33
34
  import {
34
35
  component,
@@ -98,6 +99,19 @@ const useTabsRegistrar = defineInjectable<TabsRegistrar>(() => {
98
99
  );
99
100
  });
100
101
 
102
+ /**
103
+ * @internal
104
+ * Provided by each `<Tabs.Screen>` so a nested `<Stack initialRoute>` can
105
+ * discover *which* tab it's hosted by, and gate its focus state on that
106
+ * tab being active. Throws when called outside a `<Tabs.Screen>` body so
107
+ * the gate degrades to "always active" via the caller's try/catch.
108
+ */
109
+ export const useTabScreenName = defineInjectable<string>(() => {
110
+ throw new Error(
111
+ '[lynx-navigation] useTabScreenName() called outside a <Tabs.Screen> body.',
112
+ );
113
+ });
114
+
101
115
  type TabsProps =
102
116
  & Define.Prop<'initialTab', string>
103
117
  & Define.Slot<'default'>;
@@ -186,6 +200,10 @@ const TabsScreen = component<TabsScreenProps>(({ props, slots }) => {
186
200
  });
187
201
  onUnmounted(() => registrar.unregister(name));
188
202
 
203
+ // Expose this screen's tab name so a nested `<Stack initialRoute>` body
204
+ // can gate its locally-focused state on `tabs.active === name`.
205
+ defineProvide(useTabScreenName, () => name);
206
+
189
207
  return () => {
190
208
  // `display: none` keeps the body mounted so per-tab state survives
191
209
  // tab switches. Read activeSignal here so re-activating triggers a
@@ -34,7 +34,13 @@ export function useIsFocused(): Computed<boolean> {
34
34
  // through `defineProvide` may carry reactive dependencies; we only care
35
35
  // about the immutable key of the entry this screen was mounted for.
36
36
  const myKey = useCurrentEntry().key;
37
- return computed(() => nav.current.key === myKey);
37
+ // AND in `nav.isLocallyFocused` so a screen in a nested stack (e.g. a
38
+ // per-tab `<Stack>`) reports unfocused when its enclosing tab is
39
+ // inactive, or when a modal on the root nav covers everything — even
40
+ // though it's still the top of its own (paused) stack. Root nav's
41
+ // `isLocallyFocused` is permanently true, so this reduces to the
42
+ // previous behavior for un-nested apps.
43
+ return computed(() => nav.current.key === myKey && nav.isLocallyFocused);
38
44
  }
39
45
 
40
46
  /**
@@ -1,19 +1,26 @@
1
1
  import { onMounted } from '@sigx/lynx';
2
2
  import { BackHandler } from '@sigx/lynx-linking';
3
- import { useNav } from './use-nav.js';
3
+ import { useNav, type Nav } from './use-nav.js';
4
4
 
5
5
  /**
6
6
  * Wire the Android hardware back button to the active navigator.
7
7
  *
8
8
  * Listens for `hardwareBackPress` events from `@sigx/lynx-linking`'s
9
9
  * `BackHandler` (which the native side dispatches from
10
- * `MainActivity.onBackPressed`). On press:
10
+ * `MainActivity.onBackPressed`). On press the handler walks to the
11
+ * deepest currently-focused navigator (per-tab `<Stack>`s register with
12
+ * their parent), then walks back up the `parent` chain looking for the
13
+ * first nav that `canGoBack`:
11
14
  *
12
- * - If `nav.canGoBack` → `nav.pop()`.
15
+ * - If any nav in the chain can go back → `nav.pop()` on that nav.
13
16
  * - Otherwise → `BackHandler.exitApp()` (Android: `moveTaskToBack(true)`,
14
17
  * keeps the bundle warm; iOS: rejects, since iOS doesn't permit
15
18
  * programmatic termination).
16
19
  *
20
+ * The traversal means you only need to call this once at the root — a
21
+ * back press from inside a tab pops that tab's nested stack first, only
22
+ * exiting the app once every level is at its base entry.
23
+ *
17
24
  * Call this once in any component under `<NavigationRoot>` (typically a
18
25
  * thin wrapper sibling to `<Stack />`). iOS doesn't fire the event so the
19
26
  * hook is a no-op there.
@@ -35,13 +42,40 @@ export function useHardwareBack(): void {
35
42
  const nav = useNav();
36
43
  onMounted(() => {
37
44
  const sub = BackHandler.addEventListener(() => {
38
- if (nav.canGoBack) {
39
- nav.pop();
40
- return true;
45
+ // Walk down to the deepest focused nav. Per-tab `<Stack>`s
46
+ // register themselves via `parent._children.add(nav)`; only one
47
+ // child per level is `isLocallyFocused` at a time, so the
48
+ // traversal is unambiguous. Falls back to the starting nav if
49
+ // no nested stacks are wired up.
50
+ let active: Nav = nav;
51
+ // Loop instead of recursion so a deeply-nested tree doesn't blow
52
+ // the stack on a synchronous back press.
53
+ outer: while (active._children.size > 0) {
54
+ for (const child of active._children) {
55
+ if (child.isLocallyFocused) {
56
+ active = child;
57
+ continue outer;
58
+ }
59
+ }
60
+ // No focused child at this level — stop drilling.
61
+ break;
62
+ }
63
+ // Walk back up the chain looking for the first nav that has
64
+ // something to pop. This is what makes "back press in trips
65
+ // tab with empty inner stack" fall through to root (which might
66
+ // have a modal on top) before exiting.
67
+ let cur: Nav | null = active;
68
+ while (cur) {
69
+ if (cur.canGoBack) {
70
+ cur.pop();
71
+ return true;
72
+ }
73
+ cur = cur.parent;
41
74
  }
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).
75
+ // At the root with nothing to pop — leave the app. Promise is
76
+ // fire-and-forget; we don't await because we want the back
77
+ // press to feel instant (Android starts the move-to-back
78
+ // transition immediately).
45
79
  void BackHandler.exitApp();
46
80
  return true;
47
81
  });
@@ -93,9 +93,46 @@ export interface Nav {
93
93
  /** Whether the user can go back from the current entry. Reactive. */
94
94
  readonly canGoBack: boolean;
95
95
 
96
- /** Parent navigator (e.g. the Tabs above this Stack), or null at the root. */
96
+ /**
97
+ * Parent navigator (e.g. the root nav above a per-tab `<Stack>`), or null
98
+ * at the root. Set when a `<Stack>` mints its own navigator via
99
+ * `<Stack initialRoute="…">` — that stack's `useNav()` returns a nav
100
+ * whose `parent` is the enclosing nav.
101
+ *
102
+ * `push` calls for routes whose resolved presentation is non-`card`
103
+ * (`modal` / `fullScreen` / `transparent-modal`) escalate up the
104
+ * `parent` chain automatically — you don't normally need to reach
105
+ * through `parent` to present modals. `parent` is exposed as an escape
106
+ * hatch for power users (e.g. imperative `parent.pop()` from a child
107
+ * stack). Avoid pushing card routes onto `parent` directly — that
108
+ * defeats per-tab stack isolation.
109
+ */
97
110
  readonly parent: Nav | null;
98
111
 
112
+ /**
113
+ * Whether this navigator is part of the currently-focused chain. True
114
+ * for the root nav at all times; for a nested nav (e.g. a per-tab
115
+ * stack), true only when its host entry is the top of `parent`, the
116
+ * parent itself is locally focused, and any extra gate (e.g. the
117
+ * enclosing tab is active) reports active.
118
+ *
119
+ * Reactive. `useIsFocused()` ANDs `nav.current.key === myKey` with
120
+ * `nav.isLocallyFocused`.
121
+ */
122
+ readonly isLocallyFocused: boolean;
123
+
124
+ /**
125
+ * @internal
126
+ * Set of child navigators (per-tab `<Stack>` instances) that have
127
+ * registered themselves under this nav. Used by `useHardwareBack` to
128
+ * find the deepest currently-focused nav and route the back press
129
+ * there before falling back up the chain.
130
+ *
131
+ * Not part of the public API — leading-underscore marks it as
132
+ * implementation detail.
133
+ */
134
+ readonly _children: Set<Nav>;
135
+
99
136
  /**
100
137
  * In-flight transition, or null when navigation is at rest. Reactive —
101
138
  * `<Stack>` reads this to decide whether to render one screen or two
@@ -64,6 +64,14 @@ export interface NavigatorState {
64
64
  unregister(entryKey: string): void;
65
65
  get(entryKey: string): ScreenRegistry | undefined;
66
66
  };
67
+ /**
68
+ * Internal: set `nav.isLocallyFocused` from outside.
69
+ *
70
+ * `<Stack>` calls this when its host entry's locally-focused state
71
+ * changes (top of parent + parent focused + enclosing tab active). For
72
+ * the root nav this stays `true` for the lifetime of the navigator.
73
+ */
74
+ readonly _setLocallyFocused: (focused: boolean) => void;
67
75
  }
68
76
 
69
77
  /**
@@ -146,6 +154,24 @@ export interface CreateNavigatorOptions {
146
154
  * that don't have an MT runtime.
147
155
  */
148
156
  progress?: SharedValue<number>;
157
+ /**
158
+ * Parent navigator. Set when this navigator is nested under another
159
+ * (e.g. a per-tab `<Stack initialRoute>` under root). Drives the
160
+ * `nav.parent` getter and the modal-escalation behaviour of `push`:
161
+ * a push of a route whose resolved presentation is not `'card'`
162
+ * recurses via `parent.push(...)`, walking up the chain until it
163
+ * lands on a navigator with no parent (the root).
164
+ *
165
+ * Leave undefined for the root navigator.
166
+ */
167
+ parent?: Nav | null;
168
+ /**
169
+ * Whether this navigator is considered "locally focused" at creation
170
+ * time. Defaults to true for the root nav; nested stacks pass `false`
171
+ * here and then flip the flag via `_setLocallyFocused` once their
172
+ * host-entry/tab-active state is computed.
173
+ */
174
+ initialLocallyFocused?: boolean;
149
175
  }
150
176
 
151
177
  /**
@@ -154,9 +180,13 @@ export interface CreateNavigatorOptions {
154
180
  * can subscribe to it.
155
181
  */
156
182
  export function createNavigatorState(opts: CreateNavigatorOptions): NavigatorState {
157
- const { routes, initial, progress } = opts;
183
+ const { routes, initial, progress, parent = null } = opts;
158
184
 
159
185
  const stackSignal: Signal<StackEntry[]> = signal<StackEntry[]>([initial]);
186
+ const focusedBox: Signal<{ value: boolean }> = signal<{ value: boolean }>({
187
+ value: opts.initialLocallyFocused ?? true,
188
+ });
189
+ const children = new Set<Nav>();
160
190
  // `signal(null)` would wrap as a primitive (no `$set`), so wrap in an
161
191
  // object to get the standard `{ value }`-style API. Reading `.value`
162
192
  // tracks; writing triggers re-render of `<Stack>`.
@@ -231,14 +261,33 @@ export function createNavigatorState(opts: CreateNavigatorOptions): NavigatorSta
231
261
  }
232
262
 
233
263
  const push: Nav['push'] = ((name: string, ...args: unknown[]) => {
234
- if (isTransitioning()) return;
235
- const { params, search, options } = unpackArgs(name, args, routes);
236
264
  if (!routes[name]) {
237
265
  throw new Error(
238
266
  `[lynx-navigation] push('${name}'): route is not registered. ` +
239
267
  `Known routes: ${Object.keys(routes).join(', ') || '(none)'}`,
240
268
  );
241
269
  }
270
+ const { params, search, options } = unpackArgs(name, args, routes);
271
+
272
+ // Escalate non-card presentations up the parent chain. Modals,
273
+ // fullScreen, and transparent-modal routes belong on the root
274
+ // navigator so they overlay tab UI and persistent chrome. We resolve
275
+ // the presentation the same way `makeEntry` does so the escalation
276
+ // decision matches what would actually be shown.
277
+ const resolvedPresentation =
278
+ (options?.presentation ?? routes[name].presentation ?? 'card') as Presentation;
279
+ if (resolvedPresentation !== 'card' && parent) {
280
+ // Walk straight to the root — every navigator with a parent
281
+ // delegates non-card pushes upward, so a chain of any depth
282
+ // collapses to a single push on the topmost nav.
283
+ // Forward original args verbatim so overloads (`push(name)`,
284
+ // `push(name, params)`, `push(name, params, search)`,
285
+ // `push(name, params, search, options)`) keep their meaning.
286
+ (parent.push as (n: string, ...a: unknown[]) => void)(name, ...args);
287
+ return;
288
+ }
289
+
290
+ if (isTransitioning()) return;
242
291
  preloadRouteComponent(routes[name].component);
243
292
  const newEntry = makeEntry(name, params, search, options, routes);
244
293
  const cur = getStack();
@@ -411,18 +460,38 @@ export function createNavigatorState(opts: CreateNavigatorOptions): NavigatorSta
411
460
  return stackSignal.length > 1;
412
461
  },
413
462
  get parent() {
414
- return null;
463
+ return parent;
464
+ },
465
+ get isLocallyFocused() {
466
+ return focusedBox.value;
467
+ },
468
+ get _children() {
469
+ return children;
415
470
  },
416
471
  get transition() {
417
472
  return transitionBox.value;
418
473
  },
419
474
  };
420
475
 
476
+ if (parent) {
477
+ // Register with parent so root-level traversals (hardware back,
478
+ // future deepest-focused queries) can reach this nav. The matching
479
+ // `_children.delete(nav)` happens when the owning `<Stack>` unmounts;
480
+ // see Stack.tsx.
481
+ parent._children.add(nav);
482
+ }
483
+
484
+ function setLocallyFocused(focused: boolean): void {
485
+ if (focusedBox.value === focused) return;
486
+ focusedBox.value = focused;
487
+ }
488
+
421
489
  return {
422
490
  nav,
423
491
  routes,
424
492
  _gesture: { beginBackGesture, commitBackGesture, cancelBackGesture },
425
493
  _screens: createScreenRegistries(),
494
+ _setLocallyFocused: setLocallyFocused,
426
495
  };
427
496
  }
428
497