@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.
- package/README.md +189 -7
- package/dist/components/EntryScope.d.ts +1 -1
- package/dist/components/EntryScope.d.ts.map +1 -1
- package/dist/components/Layer.d.ts +34 -0
- package/dist/components/Layer.d.ts.map +1 -0
- package/dist/components/Link.d.ts +2 -2
- package/dist/components/Link.d.ts.map +1 -1
- package/dist/components/NavigationRoot.d.ts +2 -2
- package/dist/components/NavigationRoot.d.ts.map +1 -1
- package/dist/components/Screen.d.ts +6 -6
- package/dist/components/Screen.d.ts.map +1 -1
- package/dist/components/Stack.d.ts +83 -13
- package/dist/components/Stack.d.ts.map +1 -1
- package/dist/components/TabBar.d.ts +19 -20
- package/dist/components/TabBar.d.ts.map +1 -1
- package/dist/components/Tabs.d.ts +30 -21
- package/dist/components/Tabs.d.ts.map +1 -1
- package/dist/define-routes.d.ts +1 -1
- package/dist/define-routes.d.ts.map +1 -1
- package/dist/hooks/use-focus.d.ts.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-linking-nav.d.ts +3 -3
- package/dist/hooks/use-linking-nav.d.ts.map +1 -1
- package/dist/hooks/use-nav-internal.d.ts +21 -3
- package/dist/hooks/use-nav-internal.d.ts.map +1 -1
- package/dist/hooks/use-nav-serializer.d.ts +1 -1
- package/dist/hooks/use-nav-serializer.d.ts.map +1 -1
- package/dist/hooks/use-nav.d.ts +38 -3
- package/dist/hooks/use-nav.d.ts.map +1 -1
- package/dist/hooks/use-params.d.ts +1 -1
- package/dist/hooks/use-params.d.ts.map +1 -1
- package/dist/hooks/use-screen-chrome.d.ts +19 -0
- package/dist/hooks/use-screen-chrome.d.ts.map +1 -0
- package/dist/hooks/use-screen-options.d.ts +1 -1
- package/dist/hooks/use-screen-options.d.ts.map +1 -1
- package/dist/hooks/use-search.d.ts +1 -1
- package/dist/hooks/use-search.d.ts.map +1 -1
- package/dist/href.d.ts +2 -2
- package/dist/href.d.ts.map +1 -1
- package/dist/index.d.ts +33 -31
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1160 -29
- package/dist/index.js.map +1 -1
- package/dist/internal/layer-plan.d.ts +69 -0
- package/dist/internal/layer-plan.d.ts.map +1 -0
- package/dist/internal/screen-registry.d.ts +1 -1
- package/dist/internal/screen-registry.d.ts.map +1 -1
- package/dist/internal/screen-width.d.ts +9 -7
- package/dist/internal/screen-width.d.ts.map +1 -1
- package/dist/navigator/core.d.ts +31 -4
- package/dist/navigator/core.d.ts.map +1 -1
- package/dist/register.d.ts +1 -1
- package/dist/register.d.ts.map +1 -1
- package/dist/url/index.d.ts +6 -6
- package/dist/url/index.d.ts.map +1 -1
- package/dist/url/parse.d.ts +1 -1
- package/dist/url/parse.d.ts.map +1 -1
- package/dist/url/registry.d.ts +2 -2
- package/dist/url/registry.d.ts.map +1 -1
- package/dist/url/validate.d.ts +1 -1
- package/dist/url/validate.d.ts.map +1 -1
- package/package.json +11 -10
- package/src/components/Drawer.d.ts +55 -0
- package/src/components/EdgeBackHandle.d.ts +1 -0
- package/src/components/EdgeBackHandle.tsx +2 -2
- package/{dist/components/EntryScope.js → src/components/EntryScope.d.ts} +7 -15
- package/src/components/EntryScope.tsx +15 -4
- package/src/components/Header.d.ts +6 -0
- package/src/components/Header.tsx +3 -3
- package/src/components/Layer.d.ts +33 -0
- package/src/components/Layer.tsx +96 -0
- package/src/components/Link.d.ts +60 -0
- package/src/components/Link.tsx +4 -4
- package/src/components/NavigationRoot.d.ts +36 -0
- package/src/components/NavigationRoot.tsx +6 -6
- package/src/components/Screen.d.ts +97 -0
- package/src/components/Screen.tsx +13 -11
- package/src/components/Stack.d.ts +90 -0
- package/src/components/Stack.tsx +333 -92
- package/src/components/TabBar.d.ts +38 -0
- package/src/components/TabBar.tsx +22 -22
- package/src/components/Tabs.d.ts +109 -0
- package/src/components/Tabs.tsx +54 -22
- package/{dist/define-routes.js → src/define-routes.d.ts} +2 -4
- package/src/define-routes.ts +1 -1
- package/{dist/hooks/use-focus.js → src/hooks/use-focus.d.ts} +3 -39
- package/src/hooks/use-focus.ts +9 -3
- package/src/hooks/use-hardware-back.d.ts +37 -0
- package/src/hooks/use-hardware-back.ts +43 -9
- package/src/hooks/use-linking-nav.d.ts +91 -0
- package/src/hooks/use-linking-nav.ts +4 -4
- package/src/hooks/use-nav-internal.d.ts +91 -0
- package/src/hooks/use-nav-internal.ts +24 -3
- package/src/hooks/use-nav-serializer.d.ts +82 -0
- package/src/hooks/use-nav-serializer.ts +3 -3
- package/src/hooks/use-nav.d.ts +111 -0
- package/src/hooks/use-nav.ts +40 -3
- package/{dist/hooks/use-params.js → src/hooks/use-params.d.ts} +2 -6
- package/src/hooks/use-params.ts +2 -2
- package/src/hooks/use-screen-chrome.d.ts +18 -0
- package/src/hooks/use-screen-chrome.ts +122 -0
- package/src/hooks/use-screen-options.d.ts +2 -0
- package/src/hooks/use-screen-options.ts +3 -3
- package/{dist/hooks/use-search.js → src/hooks/use-search.d.ts} +2 -6
- package/src/hooks/use-search.ts +2 -2
- package/src/href.d.ts +54 -0
- package/src/href.ts +6 -6
- package/src/index.d.ts +39 -0
- package/src/index.ts +33 -31
- package/src/internal/layer-plan.d.ts +68 -0
- package/src/internal/layer-plan.ts +187 -0
- package/{dist/internal/screen-registry.js → src/internal/screen-registry.d.ts} +21 -32
- package/src/internal/screen-registry.ts +1 -1
- package/src/internal/screen-width.d.ts +17 -0
- package/src/internal/screen-width.ts +22 -14
- package/src/navigator/core.d.ts +96 -0
- package/src/navigator/core.ts +90 -10
- package/src/register.d.ts +37 -0
- package/src/register.ts +1 -1
- package/src/types.d.ts +217 -0
- package/src/url/build.d.ts +15 -0
- package/src/url/build.ts +2 -2
- package/src/url/compile.d.ts +34 -0
- package/src/url/format.d.ts +28 -0
- package/src/url/index.ts +6 -6
- package/src/url/parse.d.ts +20 -0
- package/src/url/parse.ts +6 -6
- package/{dist/url/registry.js → src/url/registry.d.ts} +12 -28
- package/src/url/registry.ts +3 -3
- package/src/url/validate.d.ts +23 -0
- package/src/url/validate.ts +1 -1
- package/dist/components/Drawer.js +0 -74
- package/dist/components/Drawer.js.map +0 -1
- package/dist/components/EdgeBackHandle.js +0 -144
- package/dist/components/EdgeBackHandle.js.map +0 -1
- package/dist/components/EntryScope.js.map +0 -1
- package/dist/components/Header.js +0 -103
- package/dist/components/Header.js.map +0 -1
- package/dist/components/Link.js +0 -51
- package/dist/components/Link.js.map +0 -1
- package/dist/components/NavigationRoot.js +0 -67
- package/dist/components/NavigationRoot.js.map +0 -1
- package/dist/components/Screen.js +0 -94
- package/dist/components/Screen.js.map +0 -1
- package/dist/components/ScreenContainer.d.ts +0 -18
- package/dist/components/ScreenContainer.d.ts.map +0 -1
- package/dist/components/ScreenContainer.js +0 -77
- package/dist/components/ScreenContainer.js.map +0 -1
- package/dist/components/Stack.js +0 -75
- package/dist/components/Stack.js.map +0 -1
- package/dist/components/TabBar.js +0 -63
- package/dist/components/TabBar.js.map +0 -1
- package/dist/components/Tabs.js +0 -140
- package/dist/components/Tabs.js.map +0 -1
- package/dist/define-routes.js.map +0 -1
- package/dist/hooks/use-focus.js.map +0 -1
- package/dist/hooks/use-hardware-back.js +0 -50
- package/dist/hooks/use-hardware-back.js.map +0 -1
- package/dist/hooks/use-linking-nav.js +0 -109
- package/dist/hooks/use-linking-nav.js.map +0 -1
- package/dist/hooks/use-nav-internal.js +0 -44
- package/dist/hooks/use-nav-internal.js.map +0 -1
- package/dist/hooks/use-nav-serializer.js +0 -181
- package/dist/hooks/use-nav-serializer.js.map +0 -1
- package/dist/hooks/use-nav.js +0 -11
- package/dist/hooks/use-nav.js.map +0 -1
- package/dist/hooks/use-params.js.map +0 -1
- package/dist/hooks/use-screen-options.js +0 -43
- package/dist/hooks/use-screen-options.js.map +0 -1
- package/dist/hooks/use-search.js.map +0 -1
- package/dist/href.js +0 -57
- package/dist/href.js.map +0 -1
- package/dist/internal/screen-registry.js.map +0 -1
- package/dist/internal/screen-width.js +0 -30
- package/dist/internal/screen-width.js.map +0 -1
- package/dist/navigator/core.js +0 -344
- package/dist/navigator/core.js.map +0 -1
- package/dist/register.js +0 -2
- package/dist/register.js.map +0 -1
- package/dist/types.js +0 -9
- package/dist/types.js.map +0 -1
- package/dist/url/build.js +0 -30
- package/dist/url/build.js.map +0 -1
- package/dist/url/compile.js +0 -83
- package/dist/url/compile.js.map +0 -1
- package/dist/url/format.js +0 -102
- package/dist/url/format.js.map +0 -1
- package/dist/url/index.js +0 -13
- package/dist/url/index.js.map +0 -1
- package/dist/url/parse.js +0 -94
- package/dist/url/parse.js.map +0 -1
- package/dist/url/registry.js.map +0 -1
- package/dist/url/validate.js +0 -37
- package/dist/url/validate.js.map +0 -1
- package/src/components/ScreenContainer.tsx +0 -114
package/src/components/Stack.tsx
CHANGED
|
@@ -1,117 +1,358 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* (
|
|
25
|
-
*
|
|
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
|
-
|
|
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
|
|
119
|
+
const parentInternals = useNavInternals();
|
|
31
120
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
5
|
-
* navigator.
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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
|
|
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>
|