@sigx/lynx-navigation 0.1.0 → 0.1.1
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 +1 -1
- package/README.md +355 -0
- package/dist/components/Drawer.d.ts +58 -0
- package/dist/components/Drawer.d.ts.map +1 -0
- package/dist/components/Drawer.js +76 -0
- package/dist/components/Drawer.js.map +1 -0
- package/dist/components/EdgeBackHandle.js +144 -0
- package/dist/components/EdgeBackHandle.js.map +1 -0
- package/dist/components/EntryScope.d.ts +26 -0
- package/dist/components/EntryScope.d.ts.map +1 -0
- package/dist/components/EntryScope.js +33 -0
- package/dist/components/EntryScope.js.map +1 -0
- package/dist/components/Header.d.ts +7 -0
- package/dist/components/Header.d.ts.map +1 -0
- package/dist/components/Header.js +103 -0
- package/dist/components/Header.js.map +1 -0
- package/dist/components/Link.js +1 -4
- package/dist/components/Link.js.map +1 -1
- package/dist/components/NavigationRoot.d.ts +1 -1
- package/dist/components/NavigationRoot.d.ts.map +1 -1
- package/dist/components/NavigationRoot.js +29 -3
- package/dist/components/NavigationRoot.js.map +1 -1
- package/dist/components/Screen.d.ts +98 -0
- package/dist/components/Screen.d.ts.map +1 -0
- package/dist/components/Screen.js +94 -0
- package/dist/components/Screen.js.map +1 -0
- package/dist/components/ScreenContainer.d.ts.map +1 -1
- package/dist/components/ScreenContainer.js +77 -0
- package/dist/components/ScreenContainer.js.map +1 -0
- package/dist/components/Stack.d.ts.map +1 -1
- package/dist/components/Stack.js +60 -24
- package/dist/components/Stack.js.map +1 -1
- package/dist/components/TabBar.d.ts +40 -0
- package/dist/components/TabBar.d.ts.map +1 -0
- package/dist/components/TabBar.js +63 -0
- package/dist/components/TabBar.js.map +1 -0
- package/dist/components/Tabs.d.ts +101 -0
- package/dist/components/Tabs.d.ts.map +1 -0
- package/dist/components/Tabs.js +135 -0
- package/dist/components/Tabs.js.map +1 -0
- package/dist/hooks/use-focus.d.ts +46 -0
- package/dist/hooks/use-focus.d.ts.map +1 -0
- package/dist/hooks/use-focus.js +77 -0
- package/dist/hooks/use-focus.js.map +1 -0
- package/dist/hooks/use-hardware-back.js +50 -0
- package/dist/hooks/use-hardware-back.js.map +1 -0
- package/dist/hooks/use-linking-nav.d.ts +92 -0
- package/dist/hooks/use-linking-nav.d.ts.map +1 -0
- package/dist/hooks/use-linking-nav.js +109 -0
- package/dist/hooks/use-linking-nav.js.map +1 -0
- package/dist/hooks/use-nav-internal.d.ts +38 -1
- package/dist/hooks/use-nav-internal.d.ts.map +1 -1
- package/dist/hooks/use-nav-internal.js +32 -0
- package/dist/hooks/use-nav-internal.js.map +1 -1
- package/dist/hooks/use-nav-serializer.d.ts +83 -0
- package/dist/hooks/use-nav-serializer.d.ts.map +1 -0
- package/dist/hooks/use-nav-serializer.js +181 -0
- package/dist/hooks/use-nav-serializer.js.map +1 -0
- package/dist/hooks/use-nav.js.map +1 -1
- package/dist/hooks/use-screen-options.d.ts +3 -0
- package/dist/hooks/use-screen-options.d.ts.map +1 -0
- package/dist/hooks/use-screen-options.js +43 -0
- package/dist/hooks/use-screen-options.js.map +1 -0
- package/dist/href.d.ts +16 -1
- package/dist/href.d.ts.map +1 -1
- package/dist/href.js +50 -7
- package/dist/href.js.map +1 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/screen-registry.d.ts +49 -0
- package/dist/internal/screen-registry.d.ts.map +1 -0
- package/dist/internal/screen-registry.js +59 -0
- package/dist/internal/screen-registry.js.map +1 -0
- package/dist/internal/screen-width.js +30 -0
- package/dist/internal/screen-width.js.map +1 -0
- package/dist/navigator/core.d.ts +20 -1
- package/dist/navigator/core.d.ts.map +1 -1
- package/dist/navigator/core.js +231 -36
- package/dist/navigator/core.js.map +1 -1
- package/dist/types.d.ts +56 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/url/build.d.ts +16 -0
- package/dist/url/build.d.ts.map +1 -0
- package/dist/url/build.js +30 -0
- package/dist/url/build.js.map +1 -0
- package/dist/url/compile.d.ts +35 -0
- package/dist/url/compile.d.ts.map +1 -0
- package/dist/url/compile.js +83 -0
- package/dist/url/compile.js.map +1 -0
- package/dist/url/format.d.ts +26 -0
- package/dist/url/format.d.ts.map +1 -0
- package/dist/url/format.js +99 -0
- package/dist/url/format.js.map +1 -0
- package/dist/url/index.d.ts +13 -0
- package/dist/url/index.d.ts.map +1 -0
- package/dist/url/index.js +13 -0
- package/dist/url/index.js.map +1 -0
- package/dist/url/parse.d.ts +21 -0
- package/dist/url/parse.d.ts.map +1 -0
- package/dist/url/parse.js +90 -0
- package/dist/url/parse.js.map +1 -0
- package/dist/url/registry.d.ts +41 -0
- package/dist/url/registry.d.ts.map +1 -0
- package/dist/url/registry.js +56 -0
- package/dist/url/registry.js.map +1 -0
- package/dist/url/validate.d.ts +24 -0
- package/dist/url/validate.d.ts.map +1 -0
- package/dist/url/validate.js +37 -0
- package/dist/url/validate.js.map +1 -0
- package/package.json +44 -15
- package/src/components/Drawer.tsx +121 -0
- package/src/components/EdgeBackHandle.tsx +1 -1
- package/src/components/EntryScope.tsx +38 -0
- package/src/components/Header.tsx +124 -0
- package/src/components/NavigationRoot.tsx +9 -1
- package/src/components/Screen.tsx +116 -0
- package/src/components/ScreenContainer.tsx +14 -1
- package/src/components/Stack.tsx +21 -2
- package/src/components/TabBar.tsx +103 -0
- package/src/components/Tabs.tsx +212 -0
- package/src/hooks/use-focus.ts +77 -0
- package/src/hooks/use-linking-nav.ts +159 -0
- package/src/hooks/use-nav-internal.ts +48 -1
- package/src/hooks/use-nav-serializer.ts +239 -0
- package/src/hooks/use-screen-options.ts +48 -0
- package/src/href.ts +68 -11
- package/src/index.ts +29 -0
- package/src/internal/screen-registry.ts +89 -0
- package/src/navigator/core.ts +86 -4
- package/src/types.ts +56 -0
- package/src/url/build.ts +35 -0
- package/src/url/compile.ts +109 -0
- package/src/url/format.ts +92 -0
- package/src/url/index.ts +18 -0
- package/src/url/parse.ts +97 -0
- package/src/url/registry.ts +69 -0
- package/src/url/validate.ts +67 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<Tabs>` — Lynx tab navigator.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
*
|
|
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>
|
|
16
|
+
* </NavigationRoot>
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
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()`.
|
|
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')`)
|
|
27
|
+
*
|
|
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.
|
|
32
|
+
*/
|
|
33
|
+
import {
|
|
34
|
+
component,
|
|
35
|
+
compound,
|
|
36
|
+
defineInjectable,
|
|
37
|
+
defineProvide,
|
|
38
|
+
onUnmounted,
|
|
39
|
+
signal,
|
|
40
|
+
untrack,
|
|
41
|
+
type Define,
|
|
42
|
+
type JSXElement,
|
|
43
|
+
type Signal,
|
|
44
|
+
} from '@sigx/lynx';
|
|
45
|
+
|
|
46
|
+
/** Metadata about a registered `<Tabs.Screen>`. */
|
|
47
|
+
export interface TabInfo {
|
|
48
|
+
/** Stable tab id, used by `setActive`. */
|
|
49
|
+
readonly name: string;
|
|
50
|
+
/** Optional icon node — passed through to the default tab bar. */
|
|
51
|
+
readonly icon?: JSXElement;
|
|
52
|
+
/** Optional human-readable label. Defaults to `name`. */
|
|
53
|
+
readonly label?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Accessibility label announced by screen readers. Falls back to
|
|
56
|
+
* `label`, then `name`. Surfaced as `accessibility-label` on the
|
|
57
|
+
* default `<TabBar>` button.
|
|
58
|
+
*/
|
|
59
|
+
readonly accessibilityLabel?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Reactive controller exposed by `useTabs()`. */
|
|
63
|
+
export interface TabsNav {
|
|
64
|
+
/** Currently-active tab name. Reactive — accessing inside render/effect tracks. */
|
|
65
|
+
readonly active: string;
|
|
66
|
+
/** Switch the active tab. Triggers reactive updates in any consumer. */
|
|
67
|
+
setActive(name: string): void;
|
|
68
|
+
/** Snapshot of registered tabs in registration order. Reactive. */
|
|
69
|
+
readonly tabs: ReadonlyArray<TabInfo>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Access the enclosing Tabs navigator. Throws when called outside `<Tabs>`.
|
|
74
|
+
*/
|
|
75
|
+
export const useTabs = defineInjectable<TabsNav>(() => {
|
|
76
|
+
throw new Error(
|
|
77
|
+
'[lynx-navigation] useTabs() called outside of a <Tabs> component.',
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Internal registrar used by `<Tabs.Screen>` to announce itself to the
|
|
83
|
+
* parent `<Tabs>`. Splitting registration off the public `useTabs()` keeps
|
|
84
|
+
* the public surface read-only.
|
|
85
|
+
*/
|
|
86
|
+
interface TabsRegistrar {
|
|
87
|
+
register(info: TabInfo): void;
|
|
88
|
+
unregister(name: string): void;
|
|
89
|
+
/** Reactive list — mirrors `TabsNav.tabs`, used by `<Tabs.Screen>` to
|
|
90
|
+
* decide whether it's the active tab. */
|
|
91
|
+
readonly tabs: Signal<TabInfo[]>;
|
|
92
|
+
readonly activeSignal: Signal<{ value: string | null }>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const useTabsRegistrar = defineInjectable<TabsRegistrar>(() => {
|
|
96
|
+
throw new Error(
|
|
97
|
+
'[lynx-navigation] <Tabs.Screen> rendered outside a <Tabs> component.',
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
type TabsProps =
|
|
102
|
+
& Define.Prop<'initialTab', string>
|
|
103
|
+
& Define.Slot<'default'>;
|
|
104
|
+
|
|
105
|
+
const _Tabs = component<TabsProps>(({ props, slots }) => {
|
|
106
|
+
// Tabs are stored as a deeply-reactive proxy signal so `tabs` consumers
|
|
107
|
+
// re-render when registration changes. `activeSignal` uses the wrapped
|
|
108
|
+
// `{value}` pattern so we can write a `string | null` without the
|
|
109
|
+
// proxy treating the inner string as an object.
|
|
110
|
+
const tabs = signal<TabInfo[]>([]);
|
|
111
|
+
const activeSignal: Signal<{ value: string | null }> = signal({
|
|
112
|
+
value: props.initialTab ?? null,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const registrar: TabsRegistrar = {
|
|
116
|
+
register(info) {
|
|
117
|
+
// Wrap in untrack so registration writes inside `<Tabs.Screen>`'s
|
|
118
|
+
// setup phase don't notify the same setup effect that issued them
|
|
119
|
+
// — sigx's setup runs in a tracked scope by default.
|
|
120
|
+
untrack(() => {
|
|
121
|
+
const idx = tabs.findIndex((t) => t.name === info.name);
|
|
122
|
+
if (idx === -1) tabs.push(info);
|
|
123
|
+
else tabs[idx] = info;
|
|
124
|
+
if (activeSignal.value === null) {
|
|
125
|
+
activeSignal.value = info.name;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
unregister(name) {
|
|
130
|
+
untrack(() => {
|
|
131
|
+
const idx = tabs.findIndex((t) => t.name === name);
|
|
132
|
+
if (idx !== -1) tabs.splice(idx, 1);
|
|
133
|
+
if (activeSignal.value === name) {
|
|
134
|
+
activeSignal.value = tabs[0]?.name ?? null;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
tabs,
|
|
139
|
+
activeSignal,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const nav: TabsNav = {
|
|
143
|
+
get active() {
|
|
144
|
+
// Empty-tabs state is rare in practice (no <Tabs.Screen> yet) but
|
|
145
|
+
// possible during initial render; expose '' rather than null so
|
|
146
|
+
// consumers can compare strings without narrowing.
|
|
147
|
+
return activeSignal.value ?? '';
|
|
148
|
+
},
|
|
149
|
+
setActive(name) {
|
|
150
|
+
activeSignal.value = name;
|
|
151
|
+
},
|
|
152
|
+
get tabs() {
|
|
153
|
+
return tabs;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
defineProvide(useTabs, () => nav);
|
|
158
|
+
defineProvide(useTabsRegistrar, () => registrar);
|
|
159
|
+
|
|
160
|
+
return () => slots.default?.();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
type TabsScreenProps =
|
|
164
|
+
& Define.Prop<'name', string, true>
|
|
165
|
+
& Define.Prop<'icon', JSXElement>
|
|
166
|
+
& Define.Prop<'label', string>
|
|
167
|
+
& Define.Prop<'accessibilityLabel', string>
|
|
168
|
+
& Define.Slot<'default'>;
|
|
169
|
+
|
|
170
|
+
const TabsScreen = component<TabsScreenProps>(({ props, slots }) => {
|
|
171
|
+
const registrar = useTabsRegistrar();
|
|
172
|
+
// Capture `name` once at setup. Props is reactive in sigx, but using a
|
|
173
|
+
// changing `name` for an already-registered screen would be ambiguous
|
|
174
|
+
// (rename vs re-register?) — pin it and require callers to remount on
|
|
175
|
+
// identity change. This matches React Navigation's contract.
|
|
176
|
+
const name = props.name;
|
|
177
|
+
registrar.register({
|
|
178
|
+
name,
|
|
179
|
+
icon: props.icon,
|
|
180
|
+
label: props.label,
|
|
181
|
+
accessibilityLabel: props.accessibilityLabel,
|
|
182
|
+
});
|
|
183
|
+
onUnmounted(() => registrar.unregister(name));
|
|
184
|
+
|
|
185
|
+
return () => {
|
|
186
|
+
// `display: none` keeps the body mounted so per-tab state survives
|
|
187
|
+
// tab switches. Read activeSignal here so re-activating triggers a
|
|
188
|
+
// re-render with display restored.
|
|
189
|
+
const active = registrar.activeSignal.value === name;
|
|
190
|
+
return (
|
|
191
|
+
<view
|
|
192
|
+
style={{
|
|
193
|
+
display: active ? 'flex' : 'none',
|
|
194
|
+
width: '100%',
|
|
195
|
+
height: '100%',
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
{slots.default?.()}
|
|
199
|
+
</view>
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Compound export. `Tabs` is the parent component; `Tabs.Screen` registers
|
|
206
|
+
* an individual tab. Matches the `Screen` / `Screen.Header` shape used
|
|
207
|
+
* elsewhere in this package and the daisyui `Modal` / `Modal.Header`
|
|
208
|
+
* convention.
|
|
209
|
+
*/
|
|
210
|
+
export const Tabs = compound(_Tabs, {
|
|
211
|
+
Screen: TabsScreen,
|
|
212
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { computed, effect, onUnmounted, type Computed } from '@sigx/lynx';
|
|
2
|
+
import { useNav } from './use-nav.js';
|
|
3
|
+
import { useCurrentEntry } from './use-nav-internal.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reactive "is this screen the focused entry?" signal.
|
|
7
|
+
*
|
|
8
|
+
* Must be called from inside a component rendered as a route by `<Stack>` (or
|
|
9
|
+
* any other navigator that uses `<EntryScope>`); throws otherwise. The
|
|
10
|
+
* returned `Computed` reads `nav.current.key` and compares it to the entry
|
|
11
|
+
* the calling screen was mounted for, so any nav mutation that changes the
|
|
12
|
+
* top entry flips the value.
|
|
13
|
+
*
|
|
14
|
+
* Note: screens stay mounted when something is pushed on top of them — they
|
|
15
|
+
* just lose focus. Pop the new top off and they regain focus.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* const Profile = component(() => {
|
|
20
|
+
* const isFocused = useIsFocused();
|
|
21
|
+
* return () => <text>{isFocused.value ? 'visible' : 'hidden'}</text>;
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export function useIsFocused(): Computed<boolean> {
|
|
26
|
+
const nav = useNav();
|
|
27
|
+
// Capture the entry's key once at setup. The entry object provided
|
|
28
|
+
// through `defineProvide` may carry reactive dependencies; we only care
|
|
29
|
+
// about the immutable key of the entry this screen was mounted for.
|
|
30
|
+
const myKey = useCurrentEntry().key;
|
|
31
|
+
return computed(() => nav.current.key === myKey);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Run `cb` whenever this screen gains focus; run the returned cleanup when it
|
|
36
|
+
* loses focus or unmounts. Mirrors React Navigation's `useFocusEffect`.
|
|
37
|
+
*
|
|
38
|
+
* Lifecycle:
|
|
39
|
+
* - cb runs immediately if the screen is already focused at mount.
|
|
40
|
+
* - When the screen loses focus (something pushed on top), cleanup runs.
|
|
41
|
+
* - When focus returns (the cover is popped), `cb` runs again — yielding a
|
|
42
|
+
* fresh cleanup for the next blur.
|
|
43
|
+
* - On unmount, cleanup runs once if still focused.
|
|
44
|
+
*
|
|
45
|
+
* Common uses: subscribe to a data source while visible, track an analytics
|
|
46
|
+
* "screen view" event, start/stop a polling loop.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```tsx
|
|
50
|
+
* useFocusEffect(() => {
|
|
51
|
+
* const id = setInterval(refresh, 5000);
|
|
52
|
+
* return () => clearInterval(id);
|
|
53
|
+
* });
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export function useFocusEffect(cb: () => void | (() => void)): void {
|
|
57
|
+
const isFocused = useIsFocused();
|
|
58
|
+
let cleanup: (() => void) | void;
|
|
59
|
+
const runner = effect(() => {
|
|
60
|
+
const focused = isFocused.value;
|
|
61
|
+
if (focused) {
|
|
62
|
+
cleanup = cb();
|
|
63
|
+
} else if (typeof cleanup === 'function') {
|
|
64
|
+
const fn = cleanup;
|
|
65
|
+
cleanup = undefined;
|
|
66
|
+
fn();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
onUnmounted(() => {
|
|
70
|
+
if (typeof cleanup === 'function') {
|
|
71
|
+
const fn = cleanup;
|
|
72
|
+
cleanup = undefined;
|
|
73
|
+
fn();
|
|
74
|
+
}
|
|
75
|
+
runner.stop();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { onMounted } from '@sigx/lynx';
|
|
2
|
+
import { Linking } from '@sigx/lynx-linking';
|
|
3
|
+
import { parseHref, type Href } from '../href.js';
|
|
4
|
+
import { useNav, type Nav } from './use-nav.js';
|
|
5
|
+
import { useNavRoutes } from './use-nav-internal.js';
|
|
6
|
+
import type { RouteMap } from '../types.js';
|
|
7
|
+
|
|
8
|
+
export interface UseLinkingNavOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Schemes/prefixes to strip before parsing. Matched in order; the first
|
|
11
|
+
* match wins. Example: `['myapp://', 'https://myapp.com']` lets
|
|
12
|
+
* `https://myapp.com/users/42` parse against the same routes as
|
|
13
|
+
* `/users/42`.
|
|
14
|
+
*
|
|
15
|
+
* After stripping, a leading `/` is added if missing so the result is a
|
|
16
|
+
* valid pathname.
|
|
17
|
+
*/
|
|
18
|
+
prefixes?: string[];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Custom handler invoked instead of the default dispatch. Use this when
|
|
22
|
+
* you need to intercept (e.g. for auth callbacks, analytics) before
|
|
23
|
+
* routing. If you call `nav.push` / `nav.replace` from here, the default
|
|
24
|
+
* dispatch is skipped — return `void`.
|
|
25
|
+
*/
|
|
26
|
+
onURL?: (url: string, nav: Nav) => void;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Called when an incoming URL doesn't match any registered route's `path`
|
|
30
|
+
* template (or fails schema validation). Defaults to a no-op so unknown
|
|
31
|
+
* URLs are dropped silently. Use this to surface "page not found" UX or
|
|
32
|
+
* to forward to a catch-all route.
|
|
33
|
+
*/
|
|
34
|
+
onUnmatched?: (url: string) => void;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether to use `nav.replace` instead of `nav.push` for the cold-start
|
|
38
|
+
* initial URL. Defaults to `true` — restoring an app into a deep link
|
|
39
|
+
* shouldn't leave a stray "initial route" entry beneath it that the back
|
|
40
|
+
* button can return to.
|
|
41
|
+
*
|
|
42
|
+
* Runtime URLs (from `addEventListener`) always `push`.
|
|
43
|
+
*/
|
|
44
|
+
replaceInitial?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Bridge `@sigx/lynx-linking` URL events into a `@sigx/lynx-navigation`
|
|
49
|
+
* navigator. Call once inside a `<NavigationRoot>` subtree.
|
|
50
|
+
*
|
|
51
|
+
* Handles both delivery modes:
|
|
52
|
+
* - **cold start** — `Linking.getInitialURL()` is read on mount and, if
|
|
53
|
+
* present, dispatched (replacing the initial route by default).
|
|
54
|
+
* - **warm start** — `Linking.addEventListener('url', ...)` subscribes for
|
|
55
|
+
* URLs delivered while the app is already running; each one is pushed.
|
|
56
|
+
*
|
|
57
|
+
* URL → route dispatch goes through `parseHref`, which matches the URL's
|
|
58
|
+
* pathname against the route registry seeded by `<NavigationRoot>`. Routes
|
|
59
|
+
* without a `path` template are never matched by deep links — only typed
|
|
60
|
+
* `<Link>` / `nav.push` calls reach them.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```tsx
|
|
64
|
+
* import { useLinkingNav } from '@sigx/lynx-linking/nav';
|
|
65
|
+
*
|
|
66
|
+
* const DeepLinks = component(() => {
|
|
67
|
+
* useLinkingNav({
|
|
68
|
+
* prefixes: ['myapp://', 'https://myapp.com'],
|
|
69
|
+
* onUnmatched: (url) => console.warn('Unknown deep link:', url),
|
|
70
|
+
* });
|
|
71
|
+
* return () => null;
|
|
72
|
+
* });
|
|
73
|
+
*
|
|
74
|
+
* <NavigationRoot routes={routes}>
|
|
75
|
+
* <DeepLinks />
|
|
76
|
+
* <Stack />
|
|
77
|
+
* </NavigationRoot>
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export function useLinkingNav(opts: UseLinkingNavOptions = {}): void {
|
|
81
|
+
const nav = useNav();
|
|
82
|
+
const routes = useNavRoutes();
|
|
83
|
+
|
|
84
|
+
const dispatch = (url: string, kind: 'push' | 'replace'): void => {
|
|
85
|
+
if (opts.onURL) {
|
|
86
|
+
opts.onURL(url, nav);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const stripped = _stripPrefix(url, opts.prefixes);
|
|
90
|
+
const href = parseHref(stripped);
|
|
91
|
+
if (!href) {
|
|
92
|
+
opts.onUnmatched?.(url);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
_navigateToHref(nav, routes, href, kind);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
onMounted(() => {
|
|
99
|
+
const initial = Linking.getInitialURL();
|
|
100
|
+
if (initial) {
|
|
101
|
+
dispatch(initial, opts.replaceInitial === false ? 'push' : 'replace');
|
|
102
|
+
}
|
|
103
|
+
const sub = Linking.addEventListener('url', (e) => dispatch(e.url, 'push'));
|
|
104
|
+
return () => sub.remove();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Strip the first matching prefix from `url`, returning a pathname-like
|
|
110
|
+
* string. If no prefixes are provided, or none match, the original URL is
|
|
111
|
+
* returned unchanged so `parseHref` can still handle scheme-prefixed forms
|
|
112
|
+
* via `@sigx/lynx-linking`'s `parse`.
|
|
113
|
+
*
|
|
114
|
+
* Exported for unit testing — not part of the package public API.
|
|
115
|
+
*/
|
|
116
|
+
export function _stripPrefix(url: string, prefixes?: string[]): string {
|
|
117
|
+
if (!prefixes || prefixes.length === 0) return url;
|
|
118
|
+
for (const prefix of prefixes) {
|
|
119
|
+
if (url.startsWith(prefix)) {
|
|
120
|
+
const rest = url.slice(prefix.length);
|
|
121
|
+
return rest.startsWith('/') ? rest : `/${rest}`;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return url;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Call the right `nav.push` / `nav.replace` overload for `href`. The
|
|
129
|
+
* overloads differ in positional layout: routes with a params schema take
|
|
130
|
+
* `(name, params, search?, options?)`; routes without take `(name, search?,
|
|
131
|
+
* options?)`. Calling the wrong shape silently shifts `search` into the
|
|
132
|
+
* `options` slot, so we look the route up in the registry and branch.
|
|
133
|
+
*
|
|
134
|
+
* Exported for unit testing — not part of the package public API.
|
|
135
|
+
*/
|
|
136
|
+
export function _navigateToHref(
|
|
137
|
+
nav: Nav,
|
|
138
|
+
routes: RouteMap,
|
|
139
|
+
href: Href,
|
|
140
|
+
kind: 'push' | 'replace',
|
|
141
|
+
): void {
|
|
142
|
+
const def = routes[href.route];
|
|
143
|
+
// Defensive: `parseHref` already validated against the registry, so this
|
|
144
|
+
// really shouldn't happen — but if the registry was cleared between
|
|
145
|
+
// parse and dispatch (multi-NavigationRoot scenarios), bail rather than
|
|
146
|
+
// throw.
|
|
147
|
+
if (!def) return;
|
|
148
|
+
const hasParams = !!def.params;
|
|
149
|
+
const action = kind === 'replace' ? nav.replace : nav.push;
|
|
150
|
+
if (hasParams) {
|
|
151
|
+
(action as (n: string, p: unknown, s?: unknown) => void)(
|
|
152
|
+
href.route,
|
|
153
|
+
href.params,
|
|
154
|
+
href.search,
|
|
155
|
+
);
|
|
156
|
+
} else {
|
|
157
|
+
(action as (n: string, s?: unknown) => void)(href.route, href.search);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { defineInjectable, type SharedValue } from '@sigx/lynx';
|
|
2
|
-
import type {
|
|
2
|
+
import type { ScreenRegistry } from '../internal/screen-registry.js';
|
|
3
|
+
import type { RouteMap, StackEntry } from '../types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Internal injectable: the `StackEntry` the calling screen was rendered for.
|
|
7
|
+
*
|
|
8
|
+
* Provided by `<EntryScope>` which `<Stack>` and `<ScreenContainer>` wrap
|
|
9
|
+
* around each screen component mount. Screens use this to derive their own
|
|
10
|
+
* focus state (`useIsFocused`, `useFocusEffect`) without having to track
|
|
11
|
+
* `entry.key` themselves.
|
|
12
|
+
*
|
|
13
|
+
* Default throws so calling `useIsFocused()` outside a screen mounted by a
|
|
14
|
+
* navigator surfaces a clear error rather than silently returning `false`.
|
|
15
|
+
*/
|
|
16
|
+
export const useCurrentEntry = defineInjectable<StackEntry>(() => {
|
|
17
|
+
throw new Error(
|
|
18
|
+
'[lynx-navigation] No screen entry in scope. `useIsFocused` / `useFocusEffect` must be called from a component rendered as a route by <Stack>.',
|
|
19
|
+
);
|
|
20
|
+
});
|
|
3
21
|
|
|
4
22
|
/**
|
|
5
23
|
* Internal injectable: the route registry passed into `<NavigationRoot>`.
|
|
@@ -38,6 +56,17 @@ export interface NavInternals {
|
|
|
38
56
|
cancelBackGesture(): void;
|
|
39
57
|
/** Whether the user opted into the edge-swipe-back gesture. */
|
|
40
58
|
readonly edgeSwipeEnabled: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Cross-entry screen registry controller. `<EntryScope>` calls
|
|
61
|
+
* `register` on mount and `unregister` on unmount. Persistent chrome
|
|
62
|
+
* (HeaderBar / TabBar — later slices) calls `get(entryKey)` to read
|
|
63
|
+
* the focused screen's options + slot fills without remounting itself.
|
|
64
|
+
*/
|
|
65
|
+
readonly screens: {
|
|
66
|
+
register(registry: ScreenRegistry): void;
|
|
67
|
+
unregister(entryKey: string): void;
|
|
68
|
+
get(entryKey: string): ScreenRegistry | undefined;
|
|
69
|
+
};
|
|
41
70
|
}
|
|
42
71
|
|
|
43
72
|
export const useNavInternals = defineInjectable<NavInternals>(() => {
|
|
@@ -45,3 +74,21 @@ export const useNavInternals = defineInjectable<NavInternals>(() => {
|
|
|
45
74
|
'[lynx-navigation] No <NavigationRoot> found in the component tree.',
|
|
46
75
|
);
|
|
47
76
|
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Internal injectable: the calling screen's `ScreenRegistry`.
|
|
80
|
+
*
|
|
81
|
+
* Provided by `<EntryScope>` alongside `useCurrentEntry`. The `<Screen>`
|
|
82
|
+
* component and its slot-filling sub-components write options and slot
|
|
83
|
+
* fills here; the navigator's persistent chrome (HeaderBar, TabBar — later
|
|
84
|
+
* slices) reads from this registry via `getScreenRegistry(key)` on the
|
|
85
|
+
* navigator state, which keys into a cross-entry map.
|
|
86
|
+
*
|
|
87
|
+
* Throws when used outside an EntryScope so calling `<Screen>` at the app
|
|
88
|
+
* root surfaces a clear error rather than silently no-op'ing.
|
|
89
|
+
*/
|
|
90
|
+
export const useScreenRegistry = defineInjectable<ScreenRegistry>(() => {
|
|
91
|
+
throw new Error(
|
|
92
|
+
'[lynx-navigation] No screen registry in scope. `<Screen>` (and `<Screen.Header>`, etc.) must be used inside a route component rendered by `<Stack>`.',
|
|
93
|
+
);
|
|
94
|
+
});
|