@sigx/lynx-navigation 0.1.3 → 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.
- package/README.md +64 -2
- package/dist/components/Stack.d.ts +56 -11
- package/dist/components/Stack.d.ts.map +1 -1
- package/dist/components/Stack.js +160 -14
- package/dist/components/Stack.js.map +1 -1
- package/dist/components/Tabs.d.ts +30 -21
- package/dist/components/Tabs.d.ts.map +1 -1
- package/dist/components/Tabs.js +35 -21
- package/dist/components/Tabs.js.map +1 -1
- package/dist/hooks/use-focus.d.ts.map +1 -1
- package/dist/hooks/use-focus.js +7 -1
- package/dist/hooks/use-focus.js.map +1 -1
- package/dist/hooks/use-hardware-back.d.ts +9 -2
- package/dist/hooks/use-hardware-back.d.ts.map +1 -1
- package/dist/hooks/use-hardware-back.js +42 -8
- package/dist/hooks/use-hardware-back.js.map +1 -1
- package/dist/hooks/use-nav.d.ts +36 -1
- package/dist/hooks/use-nav.d.ts.map +1 -1
- package/dist/hooks/use-nav.js.map +1 -1
- package/dist/navigator/core.d.ts +26 -0
- package/dist/navigator/core.d.ts.map +1 -1
- package/dist/navigator/core.js +44 -5
- package/dist/navigator/core.js.map +1 -1
- package/package.json +6 -6
- package/src/components/Stack.tsx +212 -15
- package/src/components/Tabs.tsx +39 -21
- package/src/hooks/use-focus.ts +7 -1
- package/src/hooks/use-hardware-back.ts +43 -9
- package/src/hooks/use-nav.ts +38 -1
- package/src/navigator/core.ts +73 -4
package/src/components/Tabs.tsx
CHANGED
|
@@ -4,31 +4,32 @@
|
|
|
4
4
|
* Usage:
|
|
5
5
|
*
|
|
6
6
|
* ```tsx
|
|
7
|
-
* <NavigationRoot routes={routes}>
|
|
8
|
-
* <
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
package/src/hooks/use-focus.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
43
|
-
// don't await because we want the back
|
|
44
|
-
// (Android starts the move-to-back
|
|
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
|
});
|
package/src/hooks/use-nav.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
package/src/navigator/core.ts
CHANGED
|
@@ -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
|
|
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
|
|