@sigx/lynx-navigation 0.1.3 → 0.4.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 (196) hide show
  1. package/README.md +189 -7
  2. package/dist/components/EntryScope.d.ts +1 -1
  3. package/dist/components/EntryScope.d.ts.map +1 -1
  4. package/dist/components/Layer.d.ts +34 -0
  5. package/dist/components/Layer.d.ts.map +1 -0
  6. package/dist/components/Link.d.ts +2 -2
  7. package/dist/components/Link.d.ts.map +1 -1
  8. package/dist/components/NavigationRoot.d.ts +2 -2
  9. package/dist/components/NavigationRoot.d.ts.map +1 -1
  10. package/dist/components/Screen.d.ts +6 -6
  11. package/dist/components/Screen.d.ts.map +1 -1
  12. package/dist/components/Stack.d.ts +83 -13
  13. package/dist/components/Stack.d.ts.map +1 -1
  14. package/dist/components/TabBar.d.ts +19 -20
  15. package/dist/components/TabBar.d.ts.map +1 -1
  16. package/dist/components/Tabs.d.ts +30 -21
  17. package/dist/components/Tabs.d.ts.map +1 -1
  18. package/dist/define-routes.d.ts +1 -1
  19. package/dist/define-routes.d.ts.map +1 -1
  20. package/dist/hooks/use-focus.d.ts.map +1 -1
  21. package/dist/hooks/use-hardware-back.d.ts +9 -2
  22. package/dist/hooks/use-hardware-back.d.ts.map +1 -1
  23. package/dist/hooks/use-linking-nav.d.ts +3 -3
  24. package/dist/hooks/use-linking-nav.d.ts.map +1 -1
  25. package/dist/hooks/use-nav-internal.d.ts +21 -3
  26. package/dist/hooks/use-nav-internal.d.ts.map +1 -1
  27. package/dist/hooks/use-nav-serializer.d.ts +1 -1
  28. package/dist/hooks/use-nav-serializer.d.ts.map +1 -1
  29. package/dist/hooks/use-nav.d.ts +38 -3
  30. package/dist/hooks/use-nav.d.ts.map +1 -1
  31. package/dist/hooks/use-params.d.ts +1 -1
  32. package/dist/hooks/use-params.d.ts.map +1 -1
  33. package/dist/hooks/use-screen-chrome.d.ts +19 -0
  34. package/dist/hooks/use-screen-chrome.d.ts.map +1 -0
  35. package/dist/hooks/use-screen-options.d.ts +1 -1
  36. package/dist/hooks/use-screen-options.d.ts.map +1 -1
  37. package/dist/hooks/use-search.d.ts +1 -1
  38. package/dist/hooks/use-search.d.ts.map +1 -1
  39. package/dist/href.d.ts +2 -2
  40. package/dist/href.d.ts.map +1 -1
  41. package/dist/index.d.ts +33 -31
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +1160 -29
  44. package/dist/index.js.map +1 -1
  45. package/dist/internal/layer-plan.d.ts +69 -0
  46. package/dist/internal/layer-plan.d.ts.map +1 -0
  47. package/dist/internal/screen-registry.d.ts +1 -1
  48. package/dist/internal/screen-registry.d.ts.map +1 -1
  49. package/dist/internal/screen-width.d.ts +9 -7
  50. package/dist/internal/screen-width.d.ts.map +1 -1
  51. package/dist/navigator/core.d.ts +31 -4
  52. package/dist/navigator/core.d.ts.map +1 -1
  53. package/dist/register.d.ts +1 -1
  54. package/dist/register.d.ts.map +1 -1
  55. package/dist/url/index.d.ts +6 -6
  56. package/dist/url/index.d.ts.map +1 -1
  57. package/dist/url/parse.d.ts +1 -1
  58. package/dist/url/parse.d.ts.map +1 -1
  59. package/dist/url/registry.d.ts +2 -2
  60. package/dist/url/registry.d.ts.map +1 -1
  61. package/dist/url/validate.d.ts +1 -1
  62. package/dist/url/validate.d.ts.map +1 -1
  63. package/package.json +11 -10
  64. package/src/components/Drawer.d.ts +55 -0
  65. package/src/components/EdgeBackHandle.d.ts +1 -0
  66. package/src/components/EdgeBackHandle.tsx +2 -2
  67. package/{dist/components/EntryScope.js → src/components/EntryScope.d.ts} +7 -15
  68. package/src/components/EntryScope.tsx +15 -4
  69. package/src/components/Header.d.ts +6 -0
  70. package/src/components/Header.tsx +3 -3
  71. package/src/components/Layer.d.ts +33 -0
  72. package/src/components/Layer.tsx +96 -0
  73. package/src/components/Link.d.ts +60 -0
  74. package/src/components/Link.tsx +4 -4
  75. package/src/components/NavigationRoot.d.ts +36 -0
  76. package/src/components/NavigationRoot.tsx +6 -6
  77. package/src/components/Screen.d.ts +97 -0
  78. package/src/components/Screen.tsx +13 -11
  79. package/src/components/Stack.d.ts +90 -0
  80. package/src/components/Stack.tsx +333 -92
  81. package/src/components/TabBar.d.ts +38 -0
  82. package/src/components/TabBar.tsx +22 -22
  83. package/src/components/Tabs.d.ts +109 -0
  84. package/src/components/Tabs.tsx +54 -22
  85. package/{dist/define-routes.js → src/define-routes.d.ts} +2 -4
  86. package/src/define-routes.ts +1 -1
  87. package/{dist/hooks/use-focus.js → src/hooks/use-focus.d.ts} +3 -39
  88. package/src/hooks/use-focus.ts +9 -3
  89. package/src/hooks/use-hardware-back.d.ts +37 -0
  90. package/src/hooks/use-hardware-back.ts +43 -9
  91. package/src/hooks/use-linking-nav.d.ts +91 -0
  92. package/src/hooks/use-linking-nav.ts +4 -4
  93. package/src/hooks/use-nav-internal.d.ts +91 -0
  94. package/src/hooks/use-nav-internal.ts +24 -3
  95. package/src/hooks/use-nav-serializer.d.ts +82 -0
  96. package/src/hooks/use-nav-serializer.ts +3 -3
  97. package/src/hooks/use-nav.d.ts +111 -0
  98. package/src/hooks/use-nav.ts +40 -3
  99. package/{dist/hooks/use-params.js → src/hooks/use-params.d.ts} +2 -6
  100. package/src/hooks/use-params.ts +2 -2
  101. package/src/hooks/use-screen-chrome.d.ts +18 -0
  102. package/src/hooks/use-screen-chrome.ts +122 -0
  103. package/src/hooks/use-screen-options.d.ts +2 -0
  104. package/src/hooks/use-screen-options.ts +3 -3
  105. package/{dist/hooks/use-search.js → src/hooks/use-search.d.ts} +2 -6
  106. package/src/hooks/use-search.ts +2 -2
  107. package/src/href.d.ts +54 -0
  108. package/src/href.ts +6 -6
  109. package/src/index.d.ts +39 -0
  110. package/src/index.ts +33 -31
  111. package/src/internal/layer-plan.d.ts +68 -0
  112. package/src/internal/layer-plan.ts +187 -0
  113. package/{dist/internal/screen-registry.js → src/internal/screen-registry.d.ts} +21 -32
  114. package/src/internal/screen-registry.ts +1 -1
  115. package/src/internal/screen-width.d.ts +17 -0
  116. package/src/internal/screen-width.ts +22 -14
  117. package/src/navigator/core.d.ts +96 -0
  118. package/src/navigator/core.ts +90 -10
  119. package/src/register.d.ts +37 -0
  120. package/src/register.ts +1 -1
  121. package/src/types.d.ts +217 -0
  122. package/src/url/build.d.ts +15 -0
  123. package/src/url/build.ts +2 -2
  124. package/src/url/compile.d.ts +34 -0
  125. package/src/url/format.d.ts +28 -0
  126. package/src/url/index.ts +6 -6
  127. package/src/url/parse.d.ts +20 -0
  128. package/src/url/parse.ts +6 -6
  129. package/{dist/url/registry.js → src/url/registry.d.ts} +12 -28
  130. package/src/url/registry.ts +3 -3
  131. package/src/url/validate.d.ts +23 -0
  132. package/src/url/validate.ts +1 -1
  133. package/dist/components/Drawer.js +0 -74
  134. package/dist/components/Drawer.js.map +0 -1
  135. package/dist/components/EdgeBackHandle.js +0 -144
  136. package/dist/components/EdgeBackHandle.js.map +0 -1
  137. package/dist/components/EntryScope.js.map +0 -1
  138. package/dist/components/Header.js +0 -103
  139. package/dist/components/Header.js.map +0 -1
  140. package/dist/components/Link.js +0 -51
  141. package/dist/components/Link.js.map +0 -1
  142. package/dist/components/NavigationRoot.js +0 -67
  143. package/dist/components/NavigationRoot.js.map +0 -1
  144. package/dist/components/Screen.js +0 -94
  145. package/dist/components/Screen.js.map +0 -1
  146. package/dist/components/ScreenContainer.d.ts +0 -18
  147. package/dist/components/ScreenContainer.d.ts.map +0 -1
  148. package/dist/components/ScreenContainer.js +0 -77
  149. package/dist/components/ScreenContainer.js.map +0 -1
  150. package/dist/components/Stack.js +0 -75
  151. package/dist/components/Stack.js.map +0 -1
  152. package/dist/components/TabBar.js +0 -63
  153. package/dist/components/TabBar.js.map +0 -1
  154. package/dist/components/Tabs.js +0 -140
  155. package/dist/components/Tabs.js.map +0 -1
  156. package/dist/define-routes.js.map +0 -1
  157. package/dist/hooks/use-focus.js.map +0 -1
  158. package/dist/hooks/use-hardware-back.js +0 -50
  159. package/dist/hooks/use-hardware-back.js.map +0 -1
  160. package/dist/hooks/use-linking-nav.js +0 -109
  161. package/dist/hooks/use-linking-nav.js.map +0 -1
  162. package/dist/hooks/use-nav-internal.js +0 -44
  163. package/dist/hooks/use-nav-internal.js.map +0 -1
  164. package/dist/hooks/use-nav-serializer.js +0 -181
  165. package/dist/hooks/use-nav-serializer.js.map +0 -1
  166. package/dist/hooks/use-nav.js +0 -11
  167. package/dist/hooks/use-nav.js.map +0 -1
  168. package/dist/hooks/use-params.js.map +0 -1
  169. package/dist/hooks/use-screen-options.js +0 -43
  170. package/dist/hooks/use-screen-options.js.map +0 -1
  171. package/dist/hooks/use-search.js.map +0 -1
  172. package/dist/href.js +0 -57
  173. package/dist/href.js.map +0 -1
  174. package/dist/internal/screen-registry.js.map +0 -1
  175. package/dist/internal/screen-width.js +0 -30
  176. package/dist/internal/screen-width.js.map +0 -1
  177. package/dist/navigator/core.js +0 -344
  178. package/dist/navigator/core.js.map +0 -1
  179. package/dist/register.js +0 -2
  180. package/dist/register.js.map +0 -1
  181. package/dist/types.js +0 -9
  182. package/dist/types.js.map +0 -1
  183. package/dist/url/build.js +0 -30
  184. package/dist/url/build.js.map +0 -1
  185. package/dist/url/compile.js +0 -83
  186. package/dist/url/compile.js.map +0 -1
  187. package/dist/url/format.js +0 -102
  188. package/dist/url/format.js.map +0 -1
  189. package/dist/url/index.js +0 -13
  190. package/dist/url/index.js.map +0 -1
  191. package/dist/url/parse.js +0 -94
  192. package/dist/url/parse.js.map +0 -1
  193. package/dist/url/registry.js.map +0 -1
  194. package/dist/url/validate.js +0 -37
  195. package/dist/url/validate.js.map +0 -1
  196. package/src/components/ScreenContainer.tsx +0 -114
@@ -1,117 +1,358 @@
1
- import { component, type ComponentFactory, type SharedValue } from '@sigx/lynx';
2
- import { Suspense, isLazyComponent } from '@sigx/lynx';
3
- import { useNav } from '../hooks/use-nav.js';
4
- import { useNavInternals, useNavRoutes } from '../hooks/use-nav-internal.js';
5
- import { ScreenContainer } from './ScreenContainer.js';
6
- import { EdgeBackHandle } from './EdgeBackHandle.js';
7
- import { EntryScope } from './EntryScope.js';
1
+ import {
2
+ component,
3
+ defineProvide,
4
+ effect,
5
+ onUnmounted,
6
+ untrack,
7
+ useSharedValue,
8
+ type Define,
9
+ } from '@sigx/lynx';
10
+ import { createNavigatorState } from '../navigator/core';
11
+ import { useNav, type Nav } from '../hooks/use-nav';
12
+ import {
13
+ useCurrentEntry,
14
+ useNavInternals,
15
+ useNavRoutes,
16
+ type NavInternals,
17
+ } from '../hooks/use-nav-internal';
18
+ import type { Presentation, StackEntry } from '../types';
19
+ import { animationVariant, computeLayers, isOverlayPresentation } from '../internal/layer-plan';
20
+ import { EdgeBackHandle } from './EdgeBackHandle';
21
+ import { Layer } from './Layer';
22
+ import { useTabScreenName, useTabs } from './Tabs';
23
+
24
+ type StackProps =
25
+ /**
26
+ * Mint a nested navigator with this route at its base. When set, the
27
+ * `<Stack>` becomes the owner of a new `NavigatorState` and provides
28
+ * `useNav` / `useNavInternals` / `useNavRoutes` to its subtree, so
29
+ * `nav.push('card-route', …)` from inside the stack stays *inside* it
30
+ * (e.g. for per-tab stacks). Routes presented as `modal` / `fullScreen` /
31
+ * `transparent-modal` automatically escalate to the parent navigator
32
+ * via `nav.parent`, walking up until they reach the root — so modals
33
+ * still overlay the whole app.
34
+ *
35
+ * Omit to render the *enclosing* navigator's stack (the default — this
36
+ * is how `<NavigationRoot> → <Stack />` works).
37
+ */
38
+ & Define.Prop<'initialRoute', string>
39
+ /** Initial params for the nested-stack base entry. */
40
+ & Define.Prop<'initialParams', Record<string, unknown>>
41
+ /** Initial search for the nested-stack base entry. */
42
+ & Define.Prop<'initialSearch', Record<string, unknown>>
43
+ /**
44
+ * Optional chrome rendered *above* the active screen, **inside this
45
+ * Stack's nav scope**. The intended use is `<Header />`, which needs
46
+ * to resolve `useNav()` to the per-stack nav (not the enclosing one)
47
+ * so it can react to pushes inside this stack — e.g. show a back
48
+ * button when a card is pushed onto a per-tab stack.
49
+ *
50
+ * Without this, a `<Header />` placed as a sibling of `<Stack>`
51
+ * would see the enclosing nav and never update when pushes happen
52
+ * inside the nested stack.
53
+ */
54
+ & Define.Slot<'default'>;
55
+
56
+ let _nestedKeyCounter = 0;
8
57
 
9
58
  /**
10
59
  * Stack navigator — renders the topmost stack entry's component at rest, or
11
60
  * the top + underneath entries during a transition.
12
61
  *
13
- * **Idle**: just the top entry, full-bleed, no transform. The screen
14
- * component mounts directly so it can use its own layout (no extra absolute
15
- * positioning that would break percentage heights).
62
+ * Two modes:
63
+ *
64
+ * **Bound** (no `initialRoute`): renders the enclosing navigator's stack.
65
+ * This is the shape used directly under `<NavigationRoot>` and is what
66
+ * single-stack apps want.
67
+ *
68
+ * **Nested-owner** (`initialRoute="…"`): mints a fresh `NavigatorState` with
69
+ * its own progress `SharedValue` and edge-back gesture, and provides
70
+ * `useNav` / `useNavInternals` / `useNavRoutes` to its subtree. `useNav()`
71
+ * inside this stack returns the nested nav; `nav.parent` points to the
72
+ * enclosing one. Per-tab stacks are the canonical use case:
73
+ *
74
+ * ```tsx
75
+ * <Tabs initialTab="trips">
76
+ * <Tabs.Screen name="trips"><Stack initialRoute="tripsHome" /></Tabs.Screen>
77
+ * <Tabs.Screen name="map"><Stack initialRoute="mapHome" /></Tabs.Screen>
78
+ * </Tabs>
79
+ * ```
16
80
  *
17
- * **Transitioning**: two `<ScreenContainer>` instances stacked absolutely,
18
- * each with an MT-driven `translateX` that reads from the navigator's
19
- * progress `SharedValue`. The host's BG thread doesn't tick per frame
20
- * `useAnimatedStyle` runs the interpolation entirely on MT.
81
+ * Modal/fullScreen pushes escalate up the parent chain automatically — so
82
+ * `nav.push('newTrip')` from inside Trips (where `newTrip` is `modal`)
83
+ * walks to root and overlays the whole UI. `replace` stays strictly local
84
+ * (asymmetric with `push`) so a modal `replace` never wipes the root stack.
21
85
  *
22
- * `key={top.key}` keeps the idle render's component instance stable across
23
- * unrelated re-renders. During transitions, composite keys
24
- * (`${entry.key}-${role}-${kind}`) ensure a fresh mount per role/kind pair so
25
- * the `useAnimatedStyle` binding is set with the right input/output ranges.
86
+ * **Render strategy.** Stack always emits the same JSX shape a
87
+ * relative wrapper containing one `<Layer>` per entry returned by
88
+ * `computeLayers(stack, transition, progress)`. Each Layer is an
89
+ * absolutely-positioned host view with optional MT-bound translate
90
+ * animation. The pure layer-plan function decides:
91
+ *
92
+ * - **Idle.** Topmost non-overlay base + any overlays above it. All
93
+ * static (no transform). Overlays (`modal` / `fullScreen` /
94
+ * `transparent-modal`) keep their underneath mounted; cards
95
+ * replace their underneath in the base layer.
96
+ * - **Card transition.** Both top and underneath animate (slide-in
97
+ * + parallax). After settle, idle rules apply — the underneath
98
+ * unmounts because the new top is the sole base.
99
+ * - **Overlay transition.** The full idle layer stack up through
100
+ * the underneath stays static; only the animated top has a
101
+ * transform. After settle, the overlay either joins the static
102
+ * idle stack (push) or unmounts (pop).
103
+ *
104
+ * Layer keys are `layer-${entry.key}-${animationVariant}`. The variant
105
+ * suffix forces a remount when an entry transitions from animated to
106
+ * static (or vice versa) — `useAnimatedStyle` binds once at setup and
107
+ * can't switch its mapper at runtime. Modal underneath layers never
108
+ * animate, so their key is stable across the modal lifecycle and the
109
+ * subtree's state (per-tab Stack navigators, scroll positions,
110
+ * in-flight inputs) survives.
26
111
  */
27
- export const Stack = component(() => {
28
- const nav = useNav();
112
+ export const Stack = component<StackProps>(({ props, slots }) => {
113
+ // Capture enclosing scope's nav + routes + internals BEFORE any of the
114
+ // defineProvide calls below override them for descendants. These are
115
+ // always the "outer" values regardless of whether this Stack is bound
116
+ // or nested-owner.
117
+ const parentNav = useNav();
29
118
  const routes = useNavRoutes();
30
- const internals = useNavInternals();
119
+ const parentInternals = useNavInternals();
31
120
 
32
- return () => {
33
- const transition = nav.transition;
34
- const top = nav.current;
121
+ // Decide mode at setup. `props.initialRoute` is captured once — the
122
+ // alternative (reactive switch between bound and nested-owner) would
123
+ // need to dispose and recreate the inner nav, which would lose all
124
+ // pushed state. Reasonable to pin it.
125
+ const initialName = props.initialRoute;
126
+ const isNested = typeof initialName === 'string' && initialName.length > 0;
127
+
128
+ let nav: Nav;
129
+ let internals: NavInternals;
35
130
 
36
- if (!transition) {
37
- const route = routes[top.route];
38
- if (!route) return null;
39
- const Comp = route.component as unknown as ComponentFactory<
40
- Record<string, unknown>,
41
- unknown,
42
- unknown
43
- >;
44
- if (typeof Comp !== 'function') return null;
45
- const params = top.params as Record<string, unknown>;
46
- // Wrap lazy routes that declare a `fallback` in <Suspense> so the
47
- // chunk-load shows the user-provided spinner instead of throwing
48
- // up to the nearest outer boundary (which may be wrong layer or
49
- // missing entirely).
50
- const body = isLazyComponent(Comp) && route.fallback
51
- ? (
52
- <Suspense fallback={route.fallback as never}>
53
- <Comp {...params} />
54
- </Suspense>
55
- )
56
- : <Comp {...params} />;
57
- // When canGoBack and edge-swipe is enabled, overlay the gesture
58
- // handle so the user can pan from the left edge to start a back
59
- // transition. `position: absolute` doesn't disturb the screen's
60
- // own layout — the handle only intercepts touches in the leftmost
61
- // 20px, and only when they pan rightward past `MIN_DISTANCE`.
62
- if (nav.canGoBack && internals.edgeSwipeEnabled) {
63
- return (
64
- <view
65
- style={{
66
- position: 'relative',
67
- width: '100%',
68
- height: '100%',
69
- }}
70
- >
71
- <EntryScope key={top.key} entry={top}>
72
- {body}
73
- </EntryScope>
74
- <EdgeBackHandle key="edge-back" />
75
- </view>
76
- );
77
- }
78
- return (
79
- <EntryScope key={top.key} entry={top}>
80
- {body}
81
- </EntryScope>
131
+ if (isNested) {
132
+ if (!routes[initialName]) {
133
+ throw new Error(
134
+ `[lynx-navigation] <Stack initialRoute='${initialName}'>: ` +
135
+ `route is not registered. Known routes: ` +
136
+ `${Object.keys(routes).join(', ') || '(none)'}`,
82
137
  );
83
138
  }
84
139
 
85
- // Cast progress: TransitionState carries it as `unknown` to avoid
86
- // pinning the contract to `@sigx/lynx`'s SharedValue at the type
87
- // level; here at the runtime boundary we know it's a SharedValue<number>.
88
- const progress = transition.progress as SharedValue<number>;
140
+ // Host entry the parent's current top *when this Stack mounts*.
141
+ // Used by the focus chain so the nested nav is only "locally
142
+ // focused" while its host entry is still the top of the parent.
143
+ // Wrapped in try/catch because `<Stack initialRoute>` *may* be
144
+ // placed outside an EntryScope (e.g. directly under
145
+ // `<NavigationRoot>`); in that case there's no host-entry gate to
146
+ // apply and we just rely on `parent.isLocallyFocused`.
147
+ let hostEntryKey: string | null = null;
148
+ try {
149
+ hostEntryKey = useCurrentEntry().key;
150
+ } catch {
151
+ hostEntryKey = null;
152
+ }
89
153
 
90
- return (
154
+ // Enclosing tab name (if any). Lets the focus chain gate on tab
155
+ // active state — Trips' inner stack reports `isLocallyFocused: false`
156
+ // while the user is on the Map tab, even though it's the top of
157
+ // its own stack.
158
+ let tabName: string | null = null;
159
+ let tabsHandle: ReturnType<typeof useTabs> | null = null;
160
+ try {
161
+ tabName = useTabScreenName();
162
+ tabsHandle = useTabs();
163
+ } catch {
164
+ tabName = null;
165
+ tabsHandle = null;
166
+ }
167
+
168
+ // Inherit animation enablement from the parent — if the root was
169
+ // created with `animated={false}` (tests), nested stacks should
170
+ // also commit instantly so test assertions don't have to wait on
171
+ // a SharedValue that won't tick.
172
+ const animationsEnabled = parentInternals.progress !== null;
173
+ const progressSv = useSharedValue(0);
174
+
175
+ const presentation =
176
+ (routes[initialName].presentation ?? 'card') as Presentation;
177
+ // Counter-derived suffix keeps base-entry keys unique across
178
+ // concurrent nested stacks in a tab app. Plain `Math.random` would
179
+ // do but a counter is deterministic for test snapshots.
180
+ _nestedKeyCounter += 1;
181
+ const initial: StackEntry = {
182
+ key: `nested-${initialName}-${_nestedKeyCounter}`,
183
+ route: initialName,
184
+ params: props.initialParams ?? {},
185
+ search: props.initialSearch ?? {},
186
+ state: undefined,
187
+ presentation,
188
+ };
189
+
190
+ const navState = createNavigatorState({
191
+ routes,
192
+ initial,
193
+ progress: animationsEnabled ? progressSv : undefined,
194
+ parent: parentNav,
195
+ // Start un-focused; the effect below flips this once we observe
196
+ // the parent's current entry / tab-active state.
197
+ initialLocallyFocused: false,
198
+ });
199
+
200
+ nav = navState.nav;
201
+ internals = {
202
+ progress: animationsEnabled ? progressSv : null,
203
+ beginBackGesture: navState._gesture.beginBackGesture,
204
+ commitBackGesture: navState._gesture.commitBackGesture,
205
+ cancelBackGesture: navState._gesture.cancelBackGesture,
206
+ edgeSwipeEnabled:
207
+ // Gate on animationsEnabled too — if there's no progress
208
+ // SharedValue (e.g. parent is `animated={false}`), the edge
209
+ // swipe gesture would call `beginBackGesture()` with a null
210
+ // progress and leave the stack in an inconsistent state.
211
+ animationsEnabled && parentInternals.edgeSwipeEnabled,
212
+ screens: navState._screens,
213
+ };
214
+
215
+ // Reactive focus chain: this nav is locally focused iff
216
+ // 1. (no host entry captured) OR parent.current.key === hostEntryKey
217
+ // 2. parent.isLocallyFocused
218
+ // 3. (no enclosing tab) OR tabs.active === tabName
219
+ // Effect re-runs on any of those changing — parent's stack
220
+ // mutating, parent's own focus flipping, or the tab switching.
221
+ const focusRunner = effect(() => {
222
+ const hostMatch =
223
+ hostEntryKey === null || parentNav.current.key === hostEntryKey;
224
+ const parentFocused = parentNav.isLocallyFocused;
225
+ const tabActive =
226
+ tabName === null || tabsHandle === null
227
+ ? true
228
+ : tabsHandle.active === tabName;
229
+ const focused = hostMatch && parentFocused && tabActive;
230
+ // Write outside the read-tracking window — `_setLocallyFocused`
231
+ // bumps a signal that no consumer in *this* setup reads, but
232
+ // it's good hygiene anyway.
233
+ untrack(() => navState._setLocallyFocused(focused));
234
+ });
235
+
236
+ onUnmounted(() => {
237
+ focusRunner.stop();
238
+ parentNav._children.delete(nav);
239
+ });
240
+
241
+ defineProvide(useNav, () => nav);
242
+ defineProvide(useNavRoutes, () => routes);
243
+ defineProvide(useNavInternals, () => internals);
244
+ } else {
245
+ nav = parentNav;
246
+ internals = parentInternals;
247
+ }
248
+
249
+ // Per-stack chrome (slots.default) renders *inside* this Stack's
250
+ // nav scope so a `<Header />` placed there resolves `useNav()` to
251
+ // the per-stack nav. Wrapping the active body in a flex-column
252
+ // with the slot above does that without disturbing layer-fill
253
+ // semantics — the slot takes natural height, the body keeps
254
+ // flex-fill.
255
+ const flexColumnFill = {
256
+ flexGrow: 1,
257
+ flexShrink: 1,
258
+ flexBasis: 0,
259
+ minHeight: 0,
260
+ display: 'flex',
261
+ flexDirection: 'column',
262
+ } as const;
263
+
264
+ return () => {
265
+ const chrome = slots.default?.();
266
+ const layers = computeLayers(nav.stack, nav.transition, internals.progress);
267
+
268
+ const renderLayerNode = (layer: typeof layers[number] | undefined) =>
269
+ layer ? (
270
+ <Layer
271
+ key={`layer-${layer.entry.key}-${animationVariant(layer.animation)}`}
272
+ entry={layer.entry}
273
+ routes={routes}
274
+ animation={layer.animation}
275
+ />
276
+ ) : null;
277
+ // sigx's reconciler treats a single array-valued JSX child as
278
+ // one "slot": when the array's *length* changes between
279
+ // renders, keyed children inside can be remounted even if
280
+ // their keys are stable. To make stacked-overlay state
281
+ // preservation work (modal A still mounted after modal B
282
+ // pushes on top), each layer is emitted as its own separate
283
+ // JSX child slot rather than as an array. The slots are
284
+ // position-stable across renders — the only thing that
285
+ // changes is a slot turning from `null` to a Layer (mount) or
286
+ // vice versa (unmount). MAX_LAYERS caps the supported stack
287
+ // depth; in practice apps rarely stack more than 2-3 overlays.
288
+ // If you hit the cap, increase the constant — the unrolled
289
+ // shape is just verbose, not algorithmically limited.
290
+
291
+ // Edge-swipe handle on top, gated on:
292
+ // - `internals.edgeSwipeEnabled` — opt-out flag (also off
293
+ // when the navigator has no progress SharedValue, i.e.
294
+ // animations disabled — no in-flight gesture to animate).
295
+ // - `nav.canGoBack` — something to pop back to.
296
+ // - `!nav.transition` — no animation already running.
297
+ // - The current top is a card (not an overlay). Edge-swipe
298
+ // is the iOS-style horizontal pop gesture for card stacks;
299
+ // using it to dismiss a modal would be the wrong axis +
300
+ // the wrong dismissal semantic.
301
+ //
302
+ // The handle only intercepts touches in the leftmost 20px and
303
+ // ignores small drags, so placing it last (highest z) doesn't
304
+ // disturb screen touches.
305
+ const top = nav.current;
306
+ const edgeHandle = (
307
+ internals.edgeSwipeEnabled
308
+ && nav.canGoBack
309
+ && !nav.transition
310
+ && !isOverlayPresentation(top.presentation)
311
+ )
312
+ ? <EdgeBackHandle key="edge-back" />
313
+ : null;
314
+
315
+ const body = (
91
316
  <view
92
317
  style={{
93
318
  position: 'relative',
94
319
  width: '100%',
95
- height: '100%',
320
+ // Flex-fill so the layer container has a real
321
+ // height — `<Layer>`s anchor via `position:
322
+ // absolute; top/right/bottom/left: 0`, which
323
+ // needs a sized relative parent.
324
+ ...flexColumnFill,
325
+ // Clip any animated layer that translates off-
326
+ // screen so the slide doesn't bleed past the
327
+ // Stack's bounds.
96
328
  overflow: 'hidden',
97
329
  }}
98
330
  >
99
- <ScreenContainer
100
- key={`${transition.underneathEntry.key}-underneath-${transition.kind}`}
101
- entry={transition.underneathEntry}
102
- routes={routes}
103
- role="underneath"
104
- kind={transition.kind}
105
- progress={progress}
106
- />
107
- <ScreenContainer
108
- key={`${transition.topEntry.key}-top-${transition.kind}`}
109
- entry={transition.topEntry}
110
- routes={routes}
111
- role="top"
112
- kind={transition.kind}
113
- progress={progress}
114
- />
331
+ {renderLayerNode(layers[0])}
332
+ {renderLayerNode(layers[1])}
333
+ {renderLayerNode(layers[2])}
334
+ {renderLayerNode(layers[3])}
335
+ {renderLayerNode(layers[4])}
336
+ {renderLayerNode(layers[5])}
337
+ {renderLayerNode(layers[6])}
338
+ {renderLayerNode(layers[7])}
339
+ {renderLayerNode(layers[8])}
340
+ {renderLayerNode(layers[9])}
341
+ {renderLayerNode(layers[10])}
342
+ {renderLayerNode(layers[11])}
343
+ {renderLayerNode(layers[12])}
344
+ {renderLayerNode(layers[13])}
345
+ {renderLayerNode(layers[14])}
346
+ {renderLayerNode(layers[15])}
347
+ {edgeHandle}
348
+ </view>
349
+ );
350
+
351
+ if (chrome == null) return body as never;
352
+ return (
353
+ <view style={flexColumnFill}>
354
+ {chrome}
355
+ <view style={flexColumnFill}>{body}</view>
115
356
  </view>
116
357
  );
117
358
  };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `<TabBar>` — headless default chrome for `<Tabs>`.
3
+ *
4
+ * Renders the active-tab buttons reading from the enclosing `useTabs()`
5
+ * navigator. Intentionally **unstyled** — this lives in the (theme-less)
6
+ * navigation package, so it ships pure structure + accessibility wiring.
7
+ * Themed chrome belongs in a UI-kit package: see `<NavTabBar />` in
8
+ * `@sigx/lynx-daisyui` for the daisy-themed equivalent.
9
+ *
10
+ * Use this directly only if you want to handle styling yourself via the
11
+ * `renderTab` prop. For a "looks like a tab bar out of the box" component,
12
+ * pull `<NavTabBar />` from `@sigx/lynx-daisyui` (or your own UI kit).
13
+ *
14
+ * Customization:
15
+ * - `renderTab`: a function `(info, ctx) => JSX` that fully replaces the
16
+ * default button rendering for each tab. `ctx.active` tells the
17
+ * consumer whether this tab is currently focused; `ctx.onPress`
18
+ * activates the tab. **Recommended** for any visual treatment.
19
+ *
20
+ * Accessibility (baked into the default button — the one structural
21
+ * concern this component keeps):
22
+ * - `accessibility-label` from `info.accessibilityLabel ?? info.label ?? info.name`.
23
+ * - `accessibility-element="true"` so screen readers see the whole pill.
24
+ * - `accessibility-trait="button"` and a `selected` flag on the active
25
+ * one so VoiceOver/TalkBack announces focus state on tab switch.
26
+ */
27
+ import { type JSXElement } from '@sigx/lynx';
28
+ import { type TabInfo } from './Tabs';
29
+ /** Rendering context passed to a `renderTab` consumer. */
30
+ export interface TabRenderContext {
31
+ /** True when this tab is currently active. Reactive — re-runs render on change. */
32
+ readonly active: boolean;
33
+ /** Activates this tab. Use as a `bindtap` handler on the rendered node. */
34
+ onPress(): void;
35
+ }
36
+ export declare const TabBar: import("@sigx/runtime-core").ComponentFactory<{
37
+ renderTab?: ((info: TabInfo, ctx: TabRenderContext) => JSXElement) | undefined;
38
+ }, void, {}>;
@@ -1,36 +1,35 @@
1
1
  /**
2
- * `<TabBar>` — default chrome for `<Tabs>`.
2
+ * `<TabBar>` — headless default chrome for `<Tabs>`.
3
3
  *
4
- * Renders a row of tab buttons reading from the enclosing `useTabs()`
5
- * navigator. Active tab is highlighted via the `active` prop on each
6
- * default button (consumers can re-style via `renderTab`).
4
+ * Renders the active-tab buttons reading from the enclosing `useTabs()`
5
+ * navigator. Intentionally **unstyled** this lives in the (theme-less)
6
+ * navigation package, so it ships pure structure + accessibility wiring.
7
+ * Themed chrome belongs in a UI-kit package: see `<NavTabBar />` in
8
+ * `@sigx/lynx-daisyui` for the daisy-themed equivalent.
7
9
  *
8
- * Customization knobs:
10
+ * Use this directly only if you want to handle styling yourself via the
11
+ * `renderTab` prop. For a "looks like a tab bar out of the box" component,
12
+ * pull `<NavTabBar />` from `@sigx/lynx-daisyui` (or your own UI kit).
13
+ *
14
+ * Customization:
9
15
  * - `renderTab`: a function `(info, ctx) => JSX` that fully replaces the
10
16
  * default button rendering for each tab. `ctx.active` tells the
11
17
  * consumer whether this tab is currently focused; `ctx.onPress`
12
- * activates the tab.
13
- *
14
- * Accessibility:
15
- * - Each default button gets `accessibility-label` from
16
- * `info.accessibilityLabel ?? info.label ?? info.name`.
17
- * - Each default button gets `accessibility-element="true"` so screen
18
- * readers see the whole pill, not just the inner `<text>`.
19
- * - Each default button gets `accessibility-trait="button"` and a
20
- * `selected` flag on the active one so VoiceOver/TalkBack announces
21
- * focus state on tab switch.
18
+ * activates the tab. **Recommended** for any visual treatment.
22
19
  *
23
- * Placement: mount inside `<Tabs>` alongside the `<Tabs.Screen>`s. Order
24
- * matters visually (place above or below the screen bodies depending on
25
- * the layout), and `<Tabs.Screen>` bodies all stack with `display:flex` so
26
- * the TabBar should be at a deterministic position in the JSX.
20
+ * Accessibility (baked into the default button — the one structural
21
+ * concern this component keeps):
22
+ * - `accessibility-label` from `info.accessibilityLabel ?? info.label ?? info.name`.
23
+ * - `accessibility-element="true"` so screen readers see the whole pill.
24
+ * - `accessibility-trait="button"` and a `selected` flag on the active
25
+ * one so VoiceOver/TalkBack announces focus state on tab switch.
27
26
  */
28
27
  import {
29
28
  component,
30
29
  type Define,
31
30
  type JSXElement,
32
31
  } from '@sigx/lynx';
33
- import { useTabs, type TabInfo } from './Tabs.js';
32
+ import { useTabs, type TabInfo } from './Tabs';
34
33
 
35
34
  /** Rendering context passed to a `renderTab` consumer. */
36
35
  export interface TabRenderContext {
@@ -46,8 +45,9 @@ type TabBarProps =
46
45
  /**
47
46
  * Default per-tab button. Plain `<view>` with a `<text>` inside, an
48
47
  * `accessibility-*` cluster for screen readers, and a tap handler. No
49
- * styling beyond a minimal active-state marker — consumers that want
50
- * branded chrome pass `renderTab`.
48
+ * styling beyond a minimal active-state opacity hint — consumers that
49
+ * want branded chrome pass `renderTab` or use a UI-kit-provided tab bar
50
+ * (e.g. `<NavTabBar />` from `@sigx/lynx-daisyui`).
51
51
  */
52
52
  const DefaultTabButton = component<
53
53
  & Define.Prop<'info', TabInfo, true>