@sigx/lynx-navigation 0.1.0 → 0.1.2

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 (139) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +355 -0
  3. package/dist/components/Drawer.d.ts +56 -0
  4. package/dist/components/Drawer.d.ts.map +1 -0
  5. package/dist/components/Drawer.js +74 -0
  6. package/dist/components/Drawer.js.map +1 -0
  7. package/dist/components/EdgeBackHandle.js +144 -0
  8. package/dist/components/EdgeBackHandle.js.map +1 -0
  9. package/dist/components/EntryScope.d.ts +26 -0
  10. package/dist/components/EntryScope.d.ts.map +1 -0
  11. package/dist/components/EntryScope.js +33 -0
  12. package/dist/components/EntryScope.js.map +1 -0
  13. package/dist/components/Header.d.ts +7 -0
  14. package/dist/components/Header.d.ts.map +1 -0
  15. package/dist/components/Header.js +103 -0
  16. package/dist/components/Header.js.map +1 -0
  17. package/dist/components/Link.js +1 -4
  18. package/dist/components/Link.js.map +1 -1
  19. package/dist/components/NavigationRoot.d.ts +1 -1
  20. package/dist/components/NavigationRoot.d.ts.map +1 -1
  21. package/dist/components/NavigationRoot.js +29 -3
  22. package/dist/components/NavigationRoot.js.map +1 -1
  23. package/dist/components/Screen.d.ts +98 -0
  24. package/dist/components/Screen.d.ts.map +1 -0
  25. package/dist/components/Screen.js +94 -0
  26. package/dist/components/Screen.js.map +1 -0
  27. package/dist/components/ScreenContainer.d.ts.map +1 -1
  28. package/dist/components/ScreenContainer.js +77 -0
  29. package/dist/components/ScreenContainer.js.map +1 -0
  30. package/dist/components/Stack.d.ts.map +1 -1
  31. package/dist/components/Stack.js +60 -24
  32. package/dist/components/Stack.js.map +1 -1
  33. package/dist/components/TabBar.d.ts +40 -0
  34. package/dist/components/TabBar.d.ts.map +1 -0
  35. package/dist/components/TabBar.js +63 -0
  36. package/dist/components/TabBar.js.map +1 -0
  37. package/dist/components/Tabs.d.ts +101 -0
  38. package/dist/components/Tabs.d.ts.map +1 -0
  39. package/dist/components/Tabs.js +140 -0
  40. package/dist/components/Tabs.js.map +1 -0
  41. package/dist/hooks/use-focus.d.ts +46 -0
  42. package/dist/hooks/use-focus.d.ts.map +1 -0
  43. package/dist/hooks/use-focus.js +81 -0
  44. package/dist/hooks/use-focus.js.map +1 -0
  45. package/dist/hooks/use-hardware-back.js +50 -0
  46. package/dist/hooks/use-hardware-back.js.map +1 -0
  47. package/dist/hooks/use-linking-nav.d.ts +92 -0
  48. package/dist/hooks/use-linking-nav.d.ts.map +1 -0
  49. package/dist/hooks/use-linking-nav.js +109 -0
  50. package/dist/hooks/use-linking-nav.js.map +1 -0
  51. package/dist/hooks/use-nav-internal.d.ts +38 -1
  52. package/dist/hooks/use-nav-internal.d.ts.map +1 -1
  53. package/dist/hooks/use-nav-internal.js +32 -0
  54. package/dist/hooks/use-nav-internal.js.map +1 -1
  55. package/dist/hooks/use-nav-serializer.d.ts +83 -0
  56. package/dist/hooks/use-nav-serializer.d.ts.map +1 -0
  57. package/dist/hooks/use-nav-serializer.js +181 -0
  58. package/dist/hooks/use-nav-serializer.js.map +1 -0
  59. package/dist/hooks/use-nav.js.map +1 -1
  60. package/dist/hooks/use-screen-options.d.ts +3 -0
  61. package/dist/hooks/use-screen-options.d.ts.map +1 -0
  62. package/dist/hooks/use-screen-options.js +43 -0
  63. package/dist/hooks/use-screen-options.js.map +1 -0
  64. package/dist/href.d.ts +16 -1
  65. package/dist/href.d.ts.map +1 -1
  66. package/dist/href.js +50 -7
  67. package/dist/href.js.map +1 -1
  68. package/dist/index.d.ts +18 -1
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +15 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/internal/screen-registry.d.ts +49 -0
  73. package/dist/internal/screen-registry.d.ts.map +1 -0
  74. package/dist/internal/screen-registry.js +59 -0
  75. package/dist/internal/screen-registry.js.map +1 -0
  76. package/dist/internal/screen-width.js +30 -0
  77. package/dist/internal/screen-width.js.map +1 -0
  78. package/dist/navigator/core.d.ts +20 -1
  79. package/dist/navigator/core.d.ts.map +1 -1
  80. package/dist/navigator/core.js +231 -36
  81. package/dist/navigator/core.js.map +1 -1
  82. package/dist/types.d.ts +56 -0
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/url/build.d.ts +16 -0
  85. package/dist/url/build.d.ts.map +1 -0
  86. package/dist/url/build.js +30 -0
  87. package/dist/url/build.js.map +1 -0
  88. package/dist/url/compile.d.ts +35 -0
  89. package/dist/url/compile.d.ts.map +1 -0
  90. package/dist/url/compile.js +83 -0
  91. package/dist/url/compile.js.map +1 -0
  92. package/dist/url/format.d.ts +29 -0
  93. package/dist/url/format.d.ts.map +1 -0
  94. package/dist/url/format.js +102 -0
  95. package/dist/url/format.js.map +1 -0
  96. package/dist/url/index.d.ts +13 -0
  97. package/dist/url/index.d.ts.map +1 -0
  98. package/dist/url/index.js +13 -0
  99. package/dist/url/index.js.map +1 -0
  100. package/dist/url/parse.d.ts +21 -0
  101. package/dist/url/parse.d.ts.map +1 -0
  102. package/dist/url/parse.js +94 -0
  103. package/dist/url/parse.js.map +1 -0
  104. package/dist/url/registry.d.ts +41 -0
  105. package/dist/url/registry.d.ts.map +1 -0
  106. package/dist/url/registry.js +56 -0
  107. package/dist/url/registry.js.map +1 -0
  108. package/dist/url/validate.d.ts +24 -0
  109. package/dist/url/validate.d.ts.map +1 -0
  110. package/dist/url/validate.js +37 -0
  111. package/dist/url/validate.js.map +1 -0
  112. package/package.json +44 -15
  113. package/src/components/Drawer.tsx +119 -0
  114. package/src/components/EdgeBackHandle.tsx +1 -1
  115. package/src/components/EntryScope.tsx +38 -0
  116. package/src/components/Header.tsx +129 -0
  117. package/src/components/NavigationRoot.tsx +9 -1
  118. package/src/components/Screen.tsx +116 -0
  119. package/src/components/ScreenContainer.tsx +14 -1
  120. package/src/components/Stack.tsx +21 -2
  121. package/src/components/TabBar.tsx +104 -0
  122. package/src/components/Tabs.tsx +216 -0
  123. package/src/hooks/use-focus.ts +88 -0
  124. package/src/hooks/use-linking-nav.ts +159 -0
  125. package/src/hooks/use-nav-internal.ts +48 -1
  126. package/src/hooks/use-nav-serializer.ts +239 -0
  127. package/src/hooks/use-screen-options.ts +48 -0
  128. package/src/href.ts +68 -11
  129. package/src/index.ts +29 -0
  130. package/src/internal/screen-registry.ts +89 -0
  131. package/src/navigator/core.ts +86 -4
  132. package/src/types.ts +56 -0
  133. package/src/url/build.ts +35 -0
  134. package/src/url/compile.ts +109 -0
  135. package/src/url/format.ts +95 -0
  136. package/src/url/index.ts +18 -0
  137. package/src/url/parse.ts +102 -0
  138. package/src/url/registry.ts +69 -0
  139. package/src/url/validate.ts +67 -0
@@ -0,0 +1,216 @@
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
+ // Silently ignore unknown names rather than writing them and
151
+ // hiding every tab body. Surfacing as a no-op gives consumers a
152
+ // predictable failure mode for typos / dynamic name sources.
153
+ if (!tabs.some((t) => t.name === name)) return;
154
+ activeSignal.value = name;
155
+ },
156
+ get tabs() {
157
+ return tabs;
158
+ },
159
+ };
160
+
161
+ defineProvide(useTabs, () => nav);
162
+ defineProvide(useTabsRegistrar, () => registrar);
163
+
164
+ return () => slots.default?.();
165
+ });
166
+
167
+ type TabsScreenProps =
168
+ & Define.Prop<'name', string, true>
169
+ & Define.Prop<'icon', JSXElement>
170
+ & Define.Prop<'label', string>
171
+ & Define.Prop<'accessibilityLabel', string>
172
+ & Define.Slot<'default'>;
173
+
174
+ const TabsScreen = component<TabsScreenProps>(({ props, slots }) => {
175
+ const registrar = useTabsRegistrar();
176
+ // Capture `name` once at setup. Props is reactive in sigx, but using a
177
+ // changing `name` for an already-registered screen would be ambiguous
178
+ // (rename vs re-register?) — pin it and require callers to remount on
179
+ // identity change. This matches React Navigation's contract.
180
+ const name = props.name;
181
+ registrar.register({
182
+ name,
183
+ icon: props.icon,
184
+ label: props.label,
185
+ accessibilityLabel: props.accessibilityLabel,
186
+ });
187
+ onUnmounted(() => registrar.unregister(name));
188
+
189
+ return () => {
190
+ // `display: none` keeps the body mounted so per-tab state survives
191
+ // tab switches. Read activeSignal here so re-activating triggers a
192
+ // re-render with display restored.
193
+ const active = registrar.activeSignal.value === name;
194
+ return (
195
+ <view
196
+ style={{
197
+ display: active ? 'flex' : 'none',
198
+ width: '100%',
199
+ height: '100%',
200
+ }}
201
+ >
202
+ {slots.default?.()}
203
+ </view>
204
+ );
205
+ };
206
+ });
207
+
208
+ /**
209
+ * Compound export. `Tabs` is the parent component; `Tabs.Screen` registers
210
+ * an individual tab. Matches the `Screen` / `Screen.Header` shape used
211
+ * elsewhere in this package and the daisyui `Modal` / `Modal.Header`
212
+ * convention.
213
+ */
214
+ export const Tabs = compound(_Tabs, {
215
+ Screen: TabsScreen,
216
+ });
@@ -0,0 +1,88 @@
1
+ import {
2
+ computed,
3
+ effect,
4
+ onUnmounted,
5
+ untrack,
6
+ type Computed,
7
+ } from '@sigx/lynx';
8
+ import { useNav } from './use-nav.js';
9
+ import { useCurrentEntry } from './use-nav-internal.js';
10
+
11
+ /**
12
+ * Reactive "is this screen the focused entry?" signal.
13
+ *
14
+ * Must be called from inside a component rendered as a route by `<Stack>` (or
15
+ * any other navigator that uses `<EntryScope>`); throws otherwise. The
16
+ * returned `Computed` reads `nav.current.key` and compares it to the entry
17
+ * the calling screen was mounted for, so any nav mutation that changes the
18
+ * top entry flips the value.
19
+ *
20
+ * Note: screens stay mounted when something is pushed on top of them — they
21
+ * just lose focus. Pop the new top off and they regain focus.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * const Profile = component(() => {
26
+ * const isFocused = useIsFocused();
27
+ * return () => <text>{isFocused.value ? 'visible' : 'hidden'}</text>;
28
+ * });
29
+ * ```
30
+ */
31
+ export function useIsFocused(): Computed<boolean> {
32
+ const nav = useNav();
33
+ // Capture the entry's key once at setup. The entry object provided
34
+ // through `defineProvide` may carry reactive dependencies; we only care
35
+ // about the immutable key of the entry this screen was mounted for.
36
+ const myKey = useCurrentEntry().key;
37
+ return computed(() => nav.current.key === myKey);
38
+ }
39
+
40
+ /**
41
+ * Run `cb` whenever this screen gains focus; run the returned cleanup when it
42
+ * loses focus or unmounts. Mirrors React Navigation's `useFocusEffect`.
43
+ *
44
+ * Lifecycle:
45
+ * - cb runs immediately if the screen is already focused at mount.
46
+ * - When the screen loses focus (something pushed on top), cleanup runs.
47
+ * - When focus returns (the cover is popped), `cb` runs again — yielding a
48
+ * fresh cleanup for the next blur.
49
+ * - On unmount, cleanup runs once if still focused.
50
+ *
51
+ * Common uses: subscribe to a data source while visible, track an analytics
52
+ * "screen view" event, start/stop a polling loop.
53
+ *
54
+ * @example
55
+ * ```tsx
56
+ * useFocusEffect(() => {
57
+ * const id = setInterval(refresh, 5000);
58
+ * return () => clearInterval(id);
59
+ * });
60
+ * ```
61
+ */
62
+ export function useFocusEffect(cb: () => void | (() => void)): void {
63
+ const isFocused = useIsFocused();
64
+ let cleanup: (() => void) | void;
65
+ const runner = effect(() => {
66
+ const focused = isFocused.value;
67
+ // Always tear down any previous focus session before starting a new
68
+ // one (or before going dormant on blur). Wrap `cb` in `untrack` so
69
+ // signals read inside the user-provided callback can't retrigger the
70
+ // outer effect and stack subscriptions.
71
+ if (typeof cleanup === 'function') {
72
+ const fn = cleanup;
73
+ cleanup = undefined;
74
+ fn();
75
+ }
76
+ if (focused) {
77
+ cleanup = untrack(() => cb());
78
+ }
79
+ });
80
+ onUnmounted(() => {
81
+ if (typeof cleanup === 'function') {
82
+ const fn = cleanup;
83
+ cleanup = undefined;
84
+ fn();
85
+ }
86
+ runner.stop();
87
+ });
88
+ }
@@ -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-navigation';
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 { RouteMap } from '../types.js';
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
+ });