@symbiote-native/navigation 2.0.3 → 2.1.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/build/angular/drawer/index.js +6 -4
- package/build/angular/stack/index.d.ts +2 -0
- package/build/angular/stack/index.js +23 -3
- package/build/angular/tabs/index.d.ts +1 -1
- package/build/angular/tabs/index.js +20 -7
- package/build/core/index.d.ts +2 -1
- package/build/core/index.js +5 -1
- package/build/core/stack-router-state/index.d.ts +2 -0
- package/build/core/stack-router-state/index.js +44 -0
- package/build/core/tab-router-state/index.d.ts +1 -0
- package/build/core/tab-router-state/index.js +19 -0
- package/build/react/drawer/index.js +23 -13
- package/build/react/stack/index.js +13 -2
- package/build/react/tabs/index.js +23 -9
- package/build/svelte/attachments.d.ts +5 -0
- package/build/svelte/attachments.js +61 -0
- package/build/svelte/descriptor-subtree.d.ts +3 -0
- package/build/svelte/descriptor-subtree.js +37 -0
- package/build/svelte/drawer/drawer-props.d.ts +14 -0
- package/build/svelte/drawer/drawer-props.js +3 -0
- package/build/svelte/drawer/index.d.ts +10 -0
- package/build/svelte/drawer/index.js +6 -0
- package/build/svelte/drawer/index.svelte +421 -0
- package/build/svelte/drawer/index.svelte.d.ts +10 -0
- package/build/svelte/drawer-screen-props.d.ts +13 -0
- package/build/svelte/drawer-screen-props.js +4 -0
- package/build/svelte/drawer-screen.svelte +24 -0
- package/build/svelte/drawer-screen.svelte.d.ts +4 -0
- package/build/svelte/index.d.ts +21 -0
- package/build/svelte/index.js +20 -0
- package/build/svelte/linking.svelte.d.ts +2 -0
- package/build/svelte/linking.svelte.js +55 -0
- package/build/svelte/navigation-context.d.ts +14 -0
- package/build/svelte/navigation-context.js +44 -0
- package/build/svelte/navigation-scope.svelte +24 -0
- package/build/svelte/navigation-scope.svelte.d.ts +9 -0
- package/build/svelte/registry-host.d.ts +1 -0
- package/build/svelte/registry-host.js +26 -0
- package/build/svelte/runes/index.d.ts +12 -0
- package/build/svelte/runes/index.js +12 -0
- package/build/svelte/runes/use-drawer-navigation.svelte.d.ts +6 -0
- package/build/svelte/runes/use-drawer-navigation.svelte.js +19 -0
- package/build/svelte/runes/use-focus-effect.svelte.d.ts +1 -0
- package/build/svelte/runes/use-focus-effect.svelte.js +31 -0
- package/build/svelte/runes/use-is-focused.svelte.d.ts +3 -0
- package/build/svelte/runes/use-is-focused.svelte.js +34 -0
- package/build/svelte/runes/use-navigation-state.svelte.d.ts +4 -0
- package/build/svelte/runes/use-navigation-state.svelte.js +39 -0
- package/build/svelte/runes/use-navigation.svelte.d.ts +9 -0
- package/build/svelte/runes/use-navigation.svelte.js +22 -0
- package/build/svelte/runes/use-route.svelte.d.ts +4 -0
- package/build/svelte/runes/use-route.svelte.js +12 -0
- package/build/svelte/runes/use-stack-navigation.svelte.d.ts +6 -0
- package/build/svelte/runes/use-stack-navigation.svelte.js +22 -0
- package/build/svelte/runes/use-tab-navigation.svelte.d.ts +6 -0
- package/build/svelte/runes/use-tab-navigation.svelte.js +19 -0
- package/build/svelte/screen-props.d.ts +21 -0
- package/build/svelte/screen-props.js +6 -0
- package/build/svelte/screen-registry.d.ts +17 -0
- package/build/svelte/screen-registry.js +57 -0
- package/build/svelte/screen.svelte +28 -0
- package/build/svelte/screen.svelte.d.ts +4 -0
- package/build/svelte/stack/index.d.ts +14 -0
- package/build/svelte/stack/index.js +7 -0
- package/build/svelte/stack/index.svelte +224 -0
- package/build/svelte/stack/index.svelte.d.ts +15 -0
- package/build/svelte/stack/stack-props.d.ts +20 -0
- package/build/svelte/stack/stack-props.js +4 -0
- package/build/svelte/stack/stack-screen.svelte +148 -0
- package/build/svelte/stack/stack-screen.svelte.d.ts +4 -0
- package/build/svelte/tab-screen-props.d.ts +13 -0
- package/build/svelte/tab-screen-props.js +4 -0
- package/build/svelte/tab-screen.svelte +26 -0
- package/build/svelte/tab-screen.svelte.d.ts +4 -0
- package/build/svelte/tabs/index.d.ts +8 -0
- package/build/svelte/tabs/index.js +6 -0
- package/build/svelte/tabs/index.svelte +247 -0
- package/build/svelte/tabs/index.svelte.d.ts +8 -0
- package/build/svelte/tabs/tab-props.d.ts +7 -0
- package/build/svelte/tabs/tab-props.js +3 -0
- package/build/vue/drawer/index.js +6 -5
- package/build/vue/stack/index.js +26 -12
- package/build/vue/tabs/index.js +48 -35
- package/build-ngc/angular/drawer/index.js +9 -7
- package/build-ngc/angular/drawer/index.js.map +1 -1
- package/build-ngc/angular/drawer-screen.directive.js +3 -3
- package/build-ngc/angular/navigation-context.service.js +3 -3
- package/build-ngc/angular/navigation-scope.directive.js +3 -3
- package/build-ngc/angular/screen.directive.js +3 -3
- package/build-ngc/angular/search-bar-ref.directive.js +3 -3
- package/build-ngc/angular/stack/index.d.ts +2 -0
- package/build-ngc/angular/stack/index.js +26 -6
- package/build-ngc/angular/stack/index.js.map +1 -1
- package/build-ngc/angular/tab-screen.directive.js +3 -3
- package/build-ngc/angular/tabs/index.d.ts +1 -1
- package/build-ngc/angular/tabs/index.js +23 -10
- package/build-ngc/angular/tabs/index.js.map +1 -1
- package/build-ngc/core/index.d.ts +2 -1
- package/build-ngc/core/index.js +5 -1
- package/build-ngc/core/index.js.map +1 -1
- package/build-ngc/core/stack-router-state/index.d.ts +2 -0
- package/build-ngc/core/stack-router-state/index.js +45 -0
- package/build-ngc/core/stack-router-state/index.js.map +1 -0
- package/build-ngc/core/tab-router-state/index.d.ts +1 -0
- package/build-ngc/core/tab-router-state/index.js +19 -0
- package/build-ngc/core/tab-router-state/index.js.map +1 -1
- package/package.json +28 -14
- package/src/angular/drawer/index.ts +6 -4
- package/src/angular/stack/index.ts +23 -2
- package/src/angular/tabs/index.ts +19 -5
- package/src/core/index.ts +11 -1
- package/src/core/stack-router-state/index.ts +49 -0
- package/src/core/tab-router-state/index.ts +25 -0
- package/src/react/drawer/index.ts +28 -13
- package/src/react/stack/index.ts +15 -1
- package/src/react/tabs/index.ts +43 -10
- package/src/svelte/attachments.ts +68 -0
- package/src/svelte/descriptor-subtree.ts +49 -0
- package/src/svelte/drawer/drawer-props.ts +28 -0
- package/src/svelte/drawer/index.svelte +421 -0
- package/src/svelte/drawer/index.ts +10 -0
- package/src/svelte/drawer-screen-props.ts +20 -0
- package/src/svelte/drawer-screen.svelte +24 -0
- package/src/svelte/fabric-tree.test-helper.ts +61 -0
- package/src/svelte/index.ts +71 -0
- package/src/svelte/linking.svelte.ts +66 -0
- package/src/svelte/navigation-context.ts +62 -0
- package/src/svelte/navigation-scope.svelte +24 -0
- package/src/svelte/registry-host.ts +26 -0
- package/src/svelte/runes/index.ts +17 -0
- package/src/svelte/runes/use-drawer-navigation.svelte.ts +29 -0
- package/src/svelte/runes/use-focus-effect.svelte.ts +35 -0
- package/src/svelte/runes/use-is-focused.svelte.ts +38 -0
- package/src/svelte/runes/use-navigation-state.svelte.ts +46 -0
- package/src/svelte/runes/use-navigation.svelte.ts +39 -0
- package/src/svelte/runes/use-route.svelte.ts +16 -0
- package/src/svelte/runes/use-stack-navigation.svelte.ts +32 -0
- package/src/svelte/runes/use-tab-navigation.svelte.ts +29 -0
- package/src/svelte/screen-props.ts +45 -0
- package/src/svelte/screen-registry.ts +92 -0
- package/src/svelte/screen.svelte +28 -0
- package/src/svelte/stack/index.svelte +224 -0
- package/src/svelte/stack/index.ts +11 -0
- package/src/svelte/stack/stack-props.ts +31 -0
- package/src/svelte/stack/stack-screen.svelte +148 -0
- package/src/svelte/svelte-ambient.d.ts +6 -0
- package/src/svelte/svelte-compile.test-helper.ts +143 -0
- package/src/svelte/tab-screen-props.ts +20 -0
- package/src/svelte/tab-screen.svelte +26 -0
- package/src/svelte/tabs/index.svelte +247 -0
- package/src/svelte/tabs/index.ts +10 -0
- package/src/svelte/tabs/tab-props.ts +13 -0
- package/src/vue/drawer/index.ts +6 -5
- package/src/vue/stack/index.ts +28 -11
- package/src/vue/tabs/index.ts +68 -65
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
// Tab, the Svelte lifecycle half. The focused-index router (tab-router-state) and the tab-bar
|
|
3
|
+
// Descriptor builder (render-tabs) live in @symbiote-native/navigation core, shared verbatim
|
|
4
|
+
// with the React/Vue/Angular entries; here Svelte supplies the lifecycle - `$state.raw` for the
|
|
5
|
+
// router (its twin of useReducer), a module counter for route-key generation, `export
|
|
6
|
+
// function`s for the jumpTo/setParams handle - plus the Descriptor bridge for the tab-bar leaf.
|
|
7
|
+
// Unlike Stack, a bottom-tabs bar is a PURE-JS UI: it paints ordinary `symbiote-view`/
|
|
8
|
+
// `symbiote-text` primitives via the shared render fn, so there is no react-native-screens
|
|
9
|
+
// ViewConfig to register here - Tab needs no `../../register` import.
|
|
10
|
+
//
|
|
11
|
+
// Screens are discovered through the context collector (../screen-registry.ts), not by reading
|
|
12
|
+
// `children` - see stack/index.svelte's header for why, and ../registry-host.ts for where the
|
|
13
|
+
// markers are rendered.
|
|
14
|
+
let tabInstanceCounter = 0;
|
|
15
|
+
|
|
16
|
+
const TAB_ROOT_PROPS: Record<string, unknown> = { style: { flex: 1 } };
|
|
17
|
+
const TAB_CONTENT_PROPS: Record<string, unknown> = { style: { flex: 1 } };
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<script lang="ts">
|
|
21
|
+
import type { Component } from 'svelte';
|
|
22
|
+
import { onDestroy, tick } from 'svelte';
|
|
23
|
+
import { dlog } from '@symbiote-native/engine';
|
|
24
|
+
import type { ShimElement } from '@symbiote-native/svelte/native-view-bridge';
|
|
25
|
+
import {
|
|
26
|
+
NAVIGATION_EVENT_BLUR,
|
|
27
|
+
NAVIGATION_EVENT_FOCUS,
|
|
28
|
+
createInitialTabState,
|
|
29
|
+
createNavigationEmitter,
|
|
30
|
+
diffFocusedRoute,
|
|
31
|
+
isFocusedRoute,
|
|
32
|
+
reconcileTabRoutes,
|
|
33
|
+
renderTabBar,
|
|
34
|
+
tabRouterReducer,
|
|
35
|
+
} from '../../core';
|
|
36
|
+
import type {
|
|
37
|
+
IDescriptor,
|
|
38
|
+
INavigationEmitter,
|
|
39
|
+
IRoute,
|
|
40
|
+
ITabBarItemView,
|
|
41
|
+
ITabNavigatorHandle,
|
|
42
|
+
ITabOptions,
|
|
43
|
+
ITabRouterAction,
|
|
44
|
+
ITabRouterState,
|
|
45
|
+
} from '../../core';
|
|
46
|
+
import { createDescriptorSubtreeSync } from '../descriptor-subtree';
|
|
47
|
+
import { getNavigationScope } from '../navigation-context';
|
|
48
|
+
import type { INavigationScopeValue } from '../navigation-context';
|
|
49
|
+
import NavigationScope from '../navigation-scope.svelte';
|
|
50
|
+
import { SCREEN_REGISTRY_HOST_PROPS } from '../registry-host';
|
|
51
|
+
import { setScreenCollector, toRegistry, withoutScreen } from '../screen-registry';
|
|
52
|
+
import type { IRegisteredScreen } from '../screen-registry';
|
|
53
|
+
import type { ITabScreenProps } from '../tab-screen-props';
|
|
54
|
+
import type { ITabProps } from './tab-props';
|
|
55
|
+
|
|
56
|
+
let { initialRouteName, screenOptions, children }: ITabProps = $props();
|
|
57
|
+
|
|
58
|
+
// Read BEFORE this Tab establishes its own per-screen NavigationScope - becomes the `parent`
|
|
59
|
+
// link a nested screen's useNavigation().getParent() walks. undefined at the nesting root.
|
|
60
|
+
const parentScope = getNavigationScope();
|
|
61
|
+
|
|
62
|
+
const routeIdPrefix = `tab-${(tabInstanceCounter += 1)}`;
|
|
63
|
+
|
|
64
|
+
let screens = $state.raw<IRegisteredScreen<ITabScreenProps['options']>[]>([]);
|
|
65
|
+
setScreenCollector<ITabScreenProps['options']>({
|
|
66
|
+
kind: 'tab',
|
|
67
|
+
register: screen => {
|
|
68
|
+
screens = [...screens, screen];
|
|
69
|
+
},
|
|
70
|
+
unregister: screen => {
|
|
71
|
+
screens = withoutScreen(screens, screen);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const registry = $derived(toRegistry(screens));
|
|
76
|
+
|
|
77
|
+
// A tab's route list is a PROJECTION of the registry, not navigation history the way Stack's
|
|
78
|
+
// is: a marker registering or unregistering must add or drop its tab. Unlike Stack, a route key
|
|
79
|
+
// here is derived from the route NAME rather than an incrementing sequence, so re-deriving this
|
|
80
|
+
// list on every registry change re-keys nothing.
|
|
81
|
+
const registeredRoutes = $derived.by<IRoute<unknown>[]>(() =>
|
|
82
|
+
[...registry.entries()].map(([name, entry]) => ({
|
|
83
|
+
key: `${routeIdPrefix}-${name}`,
|
|
84
|
+
name,
|
|
85
|
+
params: entry.initialParams,
|
|
86
|
+
})),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// The dispatched half is the only STATE here; the registry half is re-derived and reconciled
|
|
90
|
+
// against it on every change (reconcileTabRoutes, core - it is what preserves each surviving
|
|
91
|
+
// route's key/params and keeps the focus on the same route NAME). Nothing needs memoizing
|
|
92
|
+
// inside the derivation, so there is no state write during a derivation to avoid in the first
|
|
93
|
+
// place. Until something dispatches there is nothing to reconcile against, hence the seed.
|
|
94
|
+
let dispatchedState = $state.raw<ITabRouterState | null>(null);
|
|
95
|
+
|
|
96
|
+
const state = $derived.by<ITabRouterState>(() => {
|
|
97
|
+
if (registeredRoutes.length === 0) dlog('Tab: no <Tab.Screen> children registered');
|
|
98
|
+
const dispatched = dispatchedState;
|
|
99
|
+
return dispatched === null
|
|
100
|
+
? createInitialTabState(registeredRoutes, initialRouteName)
|
|
101
|
+
: reconcileTabRoutes(dispatched, registeredRoutes);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function dispatch(action: ITabRouterAction): void {
|
|
105
|
+
dispatchedState = tabRouterReducer(state, action);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function jumpTo(name: string, params?: unknown): void {
|
|
109
|
+
dispatch({ type: 'jumpTo', name, params });
|
|
110
|
+
}
|
|
111
|
+
export function setParams(params: unknown, key: string): void {
|
|
112
|
+
dispatch({ type: 'setParams', key, params });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const handle: ITabNavigatorHandle = { jumpTo, setParams };
|
|
116
|
+
|
|
117
|
+
// One emitter per route.key, created lazily and cached for the navigator's whole lifetime -
|
|
118
|
+
// mirrors Stack's own `emitters` map. This decouples emitter IDENTITY (stable, looked up by
|
|
119
|
+
// key, read below when a route's NavigationScope is built) from emit TIMING (must wait until
|
|
120
|
+
// the focused screen has actually mounted and subscribed).
|
|
121
|
+
const emitters = new Map<string, INavigationEmitter>();
|
|
122
|
+
function emitterFor(routeKey: string): INavigationEmitter {
|
|
123
|
+
let emitter = emitters.get(routeKey);
|
|
124
|
+
if (emitter === undefined) {
|
|
125
|
+
emitter = createNavigationEmitter();
|
|
126
|
+
emitters.set(routeKey, emitter);
|
|
127
|
+
}
|
|
128
|
+
return emitter;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Tab paints its own bar in pure JS - there is no native onAppear/onDisappear to hook (unlike
|
|
132
|
+
// Stack's RNSScreen), so focus/blur is synthesized here. Keyed on the route KEY (not the route
|
|
133
|
+
// object) so a setParams-only change doesn't spuriously re-fire focus/blur.
|
|
134
|
+
//
|
|
135
|
+
// The bookkeeping updates immediately, but the actual emit is deferred to `tick()` - Svelte's
|
|
136
|
+
// twin of Vue's nextTick, and needed for the same reason: the newly-focused screen's own
|
|
137
|
+
// `$effect`s (its useIsFocused/useFocusEffect subscriptions) are created during THIS flush,
|
|
138
|
+
// after this effect was, so emitting synchronously here would reach zero subscribers.
|
|
139
|
+
let previousFocusedKey: string | undefined;
|
|
140
|
+
$effect(() => {
|
|
141
|
+
const nextKey = state.routes[state.index]?.key;
|
|
142
|
+
const { blurKey, focusKey } = diffFocusedRoute(previousFocusedKey, nextKey);
|
|
143
|
+
if (blurKey === undefined && focusKey === undefined) return;
|
|
144
|
+
previousFocusedKey = nextKey;
|
|
145
|
+
void tick().then(() => {
|
|
146
|
+
if (blurKey !== undefined) {
|
|
147
|
+
dlog(`Tab: route "${blurKey}" blurred at t=${Date.now()}`);
|
|
148
|
+
emitterFor(blurKey).emit(NAVIGATION_EVENT_BLUR);
|
|
149
|
+
}
|
|
150
|
+
if (focusKey !== undefined) {
|
|
151
|
+
dlog(`Tab: route "${focusKey}" focused at t=${Date.now()}`);
|
|
152
|
+
emitterFor(focusKey).emit(NAVIGATION_EVENT_FOCUS);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
onDestroy(() => {
|
|
158
|
+
if (previousFocusedKey !== undefined) {
|
|
159
|
+
emitterFor(previousFocusedKey).emit(NAVIGATION_EVENT_BLUR);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
function resolveTabOptions(
|
|
164
|
+
entry: IRegisteredScreen<ITabScreenProps['options']> | undefined,
|
|
165
|
+
route: IRoute<unknown>,
|
|
166
|
+
): ITabOptions {
|
|
167
|
+
const own =
|
|
168
|
+
entry === undefined
|
|
169
|
+
? undefined
|
|
170
|
+
: typeof entry.options === 'function'
|
|
171
|
+
? entry.options({ route, navigation: handle })
|
|
172
|
+
: entry.options;
|
|
173
|
+
return { ...screenOptions, ...own };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const items = $derived.by<ITabBarItemView[]>(() =>
|
|
177
|
+
state.routes.map((route, index) => {
|
|
178
|
+
const entry = registry.get(route.name);
|
|
179
|
+
const focused = isFocusedRoute(index, state.index);
|
|
180
|
+
if (entry === undefined) {
|
|
181
|
+
dlog(`Tab: no screen registered for route name "${route.name}"`);
|
|
182
|
+
return { key: route.key, focused, label: route.name, passthrough: {} };
|
|
183
|
+
}
|
|
184
|
+
const options = resolveTabOptions(entry, route);
|
|
185
|
+
return {
|
|
186
|
+
key: route.key,
|
|
187
|
+
focused,
|
|
188
|
+
label: options.tabBarLabel ?? options.title ?? route.name,
|
|
189
|
+
icon: options.tabBarIcon,
|
|
190
|
+
badge: options.tabBarBadge,
|
|
191
|
+
activeTintColor: options.tabBarActiveTintColor,
|
|
192
|
+
inactiveTintColor: options.tabBarInactiveTintColor,
|
|
193
|
+
passthrough: {
|
|
194
|
+
onPress: () => jumpTo(route.name),
|
|
195
|
+
accessibilityRole: 'tab',
|
|
196
|
+
accessibilityState: { selected: focused },
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const focusedRoute = $derived(state.routes[state.index]);
|
|
203
|
+
const focusedOptions = $derived(
|
|
204
|
+
focusedRoute === undefined
|
|
205
|
+
? screenOptions
|
|
206
|
+
: resolveTabOptions(registry.get(focusedRoute.name), focusedRoute),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const tabBar = $derived.by<IDescriptor>(() =>
|
|
210
|
+
renderTabBar({ items, style: focusedOptions?.tabBarStyle, passthrough: {} }),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Only the focused route's screen is ever mounted (unlike Stack, which keeps every pushed route
|
|
214
|
+
// alive), so a fresh NavigationScope per focus change is sufficient - the previous screen's
|
|
215
|
+
// whole subtree (and any listeners it registered) is torn down by an ordinary unmount when
|
|
216
|
+
// focus moves on.
|
|
217
|
+
const focusedScreen = $derived.by<{ scope: INavigationScopeValue; component: Component } | undefined>(
|
|
218
|
+
() => {
|
|
219
|
+
if (focusedRoute === undefined) return undefined;
|
|
220
|
+
const entry = registry.get(focusedRoute.name);
|
|
221
|
+
if (entry === undefined) return undefined;
|
|
222
|
+
return {
|
|
223
|
+
component: entry.component,
|
|
224
|
+
scope: {
|
|
225
|
+
route: focusedRoute,
|
|
226
|
+
navigation: handle,
|
|
227
|
+
emitter: emitterFor(focusedRoute.key),
|
|
228
|
+
parent: parentScope?.current,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// The bar's ROOT stays a literal template tag (so `bind:this` has a statically known tag) and
|
|
235
|
+
// only its children go through the Descriptor bridge - the uniform shape every Svelte component
|
|
236
|
+
// consuming a `render-*.ts` uses (svelte-adapter-dom-shim skill §19). The item count varies with
|
|
237
|
+
// the registry, hence the shape-change-tolerant wrapper rather than the raw bridge.
|
|
238
|
+
let tabBarHost = $state.raw<ShimElement | null>(null);
|
|
239
|
+
const syncTabBarChildren = createDescriptorSubtreeSync();
|
|
240
|
+
$effect(() => {
|
|
241
|
+
const host = tabBarHost;
|
|
242
|
+
const barChildren = tabBar.children;
|
|
243
|
+
syncTabBarChildren(host, barChildren);
|
|
244
|
+
});
|
|
245
|
+
</script>
|
|
246
|
+
|
|
247
|
+
<symbiote-view p={TAB_ROOT_PROPS}><symbiote-text p={SCREEN_REGISTRY_HOST_PROPS}>{@render children?.()}</symbiote-text><symbiote-view p={TAB_CONTENT_PROPS}>{#if focusedScreen !== undefined}{@const FocusedComponent = focusedScreen.component}<NavigationScope value={focusedScreen.scope}><FocusedComponent /></NavigationScope>{/if}</symbiote-view><symbiote-view p={tabBar.props} bind:this={tabBarHost}></symbiote-view></symbiote-view>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// `Tab.Screen` alongside the standalone `TabScreen` export, matching React's and Vue's barrels -
|
|
2
|
+
// see ../stack/index.ts for why Object.assign is the right mechanism on a compiled Svelte
|
|
3
|
+
// component.
|
|
4
|
+
import TabImpl from './index.svelte';
|
|
5
|
+
import TabScreen from '../tab-screen.svelte';
|
|
6
|
+
|
|
7
|
+
export const Tab = Object.assign(TabImpl, { Screen: TabScreen });
|
|
8
|
+
|
|
9
|
+
export type { ITabNavigatorHandle } from '../../core';
|
|
10
|
+
export type { ITabProps } from './tab-props';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Tab's own public prop surface. Plain `.ts` for the tsc-cannot-see-a-`.svelte`-named-export
|
|
2
|
+
// reason ../screen-props.ts documents.
|
|
3
|
+
|
|
4
|
+
import type { Snippet } from 'svelte';
|
|
5
|
+
import type { ITabOptions } from '../../core';
|
|
6
|
+
|
|
7
|
+
// React's `children?: ReactNode` / Vue's default slot become a Svelte Snippet holding the
|
|
8
|
+
// `<Tab.Screen>` markers.
|
|
9
|
+
export type ITabProps = {
|
|
10
|
+
initialRouteName?: string;
|
|
11
|
+
screenOptions?: ITabOptions;
|
|
12
|
+
children?: Snippet;
|
|
13
|
+
};
|
package/src/vue/drawer/index.ts
CHANGED
|
@@ -263,13 +263,14 @@ const DrawerImpl = defineComponent<IDrawerProps>(
|
|
|
263
263
|
dispatch({ type: 'toggleDrawer' });
|
|
264
264
|
},
|
|
265
265
|
jumpTo: (name: string) => {
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
266
|
+
// Both sides of the dispatch, because an unregistered name is a documented reducer no-op
|
|
267
|
+
// that hands the SAME state back: animating off the pre-dispatch snapshot alone would
|
|
268
|
+
// slide the panel shut while the router still says isOpen. The ref's `.value` mutates
|
|
269
|
+
// SYNCHRONOUSLY inside dispatch (unlike React's isOpenRef, refreshed only at the top of
|
|
270
|
+
// the next render), so the second read is already the reducer's own answer.
|
|
270
271
|
const wasOpen = state.value.isOpen;
|
|
271
272
|
dispatch({ type: 'jumpTo', name });
|
|
272
|
-
if (wasOpen) animateProgressTo(false);
|
|
273
|
+
if (wasOpen && !state.value.isOpen) animateProgressTo(false);
|
|
273
274
|
},
|
|
274
275
|
};
|
|
275
276
|
expose(handle);
|
package/src/vue/stack/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
createNavigationEmitter,
|
|
36
36
|
isRecord,
|
|
37
37
|
navigatorReducer,
|
|
38
|
+
reconcileStackRoutes,
|
|
38
39
|
resolveScreenRenderPlan,
|
|
39
40
|
resolveStackProps,
|
|
40
41
|
} from '../../core';
|
|
@@ -173,8 +174,22 @@ const StackImpl = defineComponent<IStackProps>(
|
|
|
173
174
|
|
|
174
175
|
const state = shallowRef<INavigatorState>(initialState);
|
|
175
176
|
|
|
177
|
+
// The screen names the render pass below last saw in the slot. A PLAIN local, not a ref: it is
|
|
178
|
+
// a cache of what was just rendered, never a reactive source, so refreshing it from inside the
|
|
179
|
+
// render closure is not a state write during render (which would re-trigger the very render
|
|
180
|
+
// effect that produced it).
|
|
181
|
+
let registeredNames: readonly string[] = [];
|
|
182
|
+
|
|
183
|
+
// A <Stack.Screen> marker can vanish from the slot while its route is still in the pushed
|
|
184
|
+
// history, which would leave that entry with nothing to render (reconcileStackRoutes' header).
|
|
185
|
+
// Reconciling on READ rather than writing the ref keeps the repair out of the render pass; the
|
|
186
|
+
// dispatch below then persists it, so the next push builds on the pruned history.
|
|
187
|
+
function currentState(): INavigatorState {
|
|
188
|
+
return reconcileStackRoutes(state.value, registeredNames);
|
|
189
|
+
}
|
|
190
|
+
|
|
176
191
|
function dispatch(action: Parameters<typeof navigatorReducer>[1]): void {
|
|
177
|
-
state.value = navigatorReducer(
|
|
192
|
+
state.value = navigatorReducer(currentState(), action);
|
|
178
193
|
}
|
|
179
194
|
|
|
180
195
|
const handle: INavigatorHandle = {
|
|
@@ -185,7 +200,7 @@ const StackImpl = defineComponent<IStackProps>(
|
|
|
185
200
|
replace: (name, params) => dispatch({ type: 'replace', route: createRoute(name, params) }),
|
|
186
201
|
setParams: (params, key) => dispatch({ type: 'setParams', key, params }),
|
|
187
202
|
reset: nextState => dispatch({ type: 'reset', state: nextState }),
|
|
188
|
-
canGoBack: () =>
|
|
203
|
+
canGoBack: () => currentState().routes.length > 1,
|
|
189
204
|
};
|
|
190
205
|
expose(handle);
|
|
191
206
|
|
|
@@ -195,18 +210,20 @@ const StackImpl = defineComponent<IStackProps>(
|
|
|
195
210
|
// useEffect, which must be deferred past render to avoid a descendant setState-during-render):
|
|
196
211
|
// Vue's provide/inject + shallowRef.value writes are safe to perform directly inside the
|
|
197
212
|
// render closure below, so the broadcast happens right there, once per render.
|
|
198
|
-
function broadcastState(): void {
|
|
199
|
-
for (const route of
|
|
200
|
-
emitterFor(route.key).emit(NAVIGATION_EVENT_STATE,
|
|
213
|
+
function broadcastState(current: INavigatorState): void {
|
|
214
|
+
for (const route of current.routes) {
|
|
215
|
+
emitterFor(route.key).emit(NAVIGATION_EVENT_STATE, current);
|
|
201
216
|
}
|
|
202
217
|
for (const routeKey of emitters.keys()) {
|
|
203
|
-
if (!
|
|
218
|
+
if (!current.routes.some(route => route.key === routeKey)) emitters.delete(routeKey);
|
|
204
219
|
}
|
|
205
220
|
}
|
|
206
221
|
|
|
207
222
|
return () => {
|
|
208
|
-
broadcastState();
|
|
209
223
|
const registry = collectRegistry(slots.default?.() ?? []);
|
|
224
|
+
registeredNames = [...registry.keys()];
|
|
225
|
+
const current = currentState();
|
|
226
|
+
broadcastState(current);
|
|
210
227
|
const screenOptions = asScreenOptions(attrs.screenOptions);
|
|
211
228
|
|
|
212
229
|
// Investigation instrumentation (flicker-on-focus bug): STACK_ON_FINISH_TRANSITIONING is
|
|
@@ -221,7 +238,7 @@ const StackImpl = defineComponent<IStackProps>(
|
|
|
221
238
|
},
|
|
222
239
|
});
|
|
223
240
|
|
|
224
|
-
const children =
|
|
241
|
+
const children = current.routes.map((route, index) => {
|
|
225
242
|
const entry = registry.get(route.name);
|
|
226
243
|
if (entry === undefined) {
|
|
227
244
|
dlog(`Stack: no screen registered for route name "${route.name}"`);
|
|
@@ -230,12 +247,12 @@ const StackImpl = defineComponent<IStackProps>(
|
|
|
230
247
|
|
|
231
248
|
const screenOptionsArgs: IScreenOptionsArgs = { route, navigation: handle };
|
|
232
249
|
const mergedOptions = resolveScreenOptions(entry, screenOptionsArgs, screenOptions);
|
|
233
|
-
const activityState = computeActivityState(index,
|
|
250
|
+
const activityState = computeActivityState(index, current.routes.length);
|
|
234
251
|
// Investigation instrumentation (flicker-on-focus bug): fires on EVERY Stack render, not
|
|
235
252
|
// just on transitions, so the log stream shows whether a route's activityState/index ever
|
|
236
253
|
// changes outside of a push/pop dispatch. Kept behind DEBUG, never removed.
|
|
237
254
|
dlog(
|
|
238
|
-
`Stack: render route "${route.name}" index=${index}/${
|
|
255
|
+
`Stack: render route "${route.name}" index=${index}/${current.routes.length - 1} ` +
|
|
239
256
|
`activityState=${activityState} at t=${Date.now()}`,
|
|
240
257
|
);
|
|
241
258
|
|
|
@@ -245,7 +262,7 @@ const StackImpl = defineComponent<IStackProps>(
|
|
|
245
262
|
const plan = resolveScreenRenderPlan({
|
|
246
263
|
screenId: route.key,
|
|
247
264
|
index,
|
|
248
|
-
routeCount:
|
|
265
|
+
routeCount: current.routes.length,
|
|
249
266
|
options: mergedOptions,
|
|
250
267
|
platform: NAVIGATOR_PLATFORM,
|
|
251
268
|
isAndroid: Platform.OS === 'android',
|
package/src/vue/tabs/index.ts
CHANGED
|
@@ -1,22 +1,13 @@
|
|
|
1
1
|
// Tab, the Vue lifecycle half. The focused-index router (tab-router-state) and the tab-bar
|
|
2
2
|
// Descriptor builder (render-tabs) live in @symbiote-native/navigation core, shared verbatim with
|
|
3
|
-
// the React/Angular adapters; here Vue supplies the lifecycle - a
|
|
4
|
-
// (Vue's twin of useReducer), useId for route-key generation, expose() for the
|
|
5
|
-
// handle - plus the descriptor bridge for the tab-bar leaf,
|
|
3
|
+
// the React/Angular adapters; here Vue supplies the lifecycle - a shallowRef for the dispatched
|
|
4
|
+
// router state (Vue's twin of useReducer), useId for route-key generation, expose() for the
|
|
5
|
+
// jumpTo/setParams handle - plus the descriptor bridge for the tab-bar leaf, like Stack's header
|
|
6
6
|
// config (stack.ts). Unlike Stack, a bottom-tabs bar is a PURE-JS UI: it paints ordinary
|
|
7
7
|
// `symbiote-view`/`symbiote-text` primitives via the shared render fn, so there is no
|
|
8
8
|
// react-native-screens ViewConfig to register here - Tab needs no `../register` import.
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
defineComponent,
|
|
12
|
-
h,
|
|
13
|
-
nextTick,
|
|
14
|
-
onMounted,
|
|
15
|
-
onUnmounted,
|
|
16
|
-
shallowRef,
|
|
17
|
-
useId,
|
|
18
|
-
watch,
|
|
19
|
-
} from '@vue/runtime-core';
|
|
10
|
+
import { defineComponent, h, nextTick, onUnmounted, shallowRef, useId } from '@vue/runtime-core';
|
|
20
11
|
import type { VNode } from '@vue/runtime-core';
|
|
21
12
|
import { descriptorToVue, normalizeVueAttrs } from '@symbiote-native/vue';
|
|
22
13
|
import { dlog } from '@symbiote-native/engine';
|
|
@@ -28,6 +19,7 @@ import {
|
|
|
28
19
|
diffFocusedRoute,
|
|
29
20
|
isFocusedRoute,
|
|
30
21
|
isRecord,
|
|
22
|
+
reconcileTabRoutes,
|
|
31
23
|
renderTabBar,
|
|
32
24
|
tabRouterReducer,
|
|
33
25
|
} from '../../core';
|
|
@@ -37,6 +29,8 @@ import type {
|
|
|
37
29
|
ITabBarItemView,
|
|
38
30
|
ITabNavigatorHandle,
|
|
39
31
|
ITabOptions,
|
|
32
|
+
ITabRouterAction,
|
|
33
|
+
ITabRouterState,
|
|
40
34
|
} from '../../core';
|
|
41
35
|
import { NavigationScope, injectNavigationScope } from '../navigation-context';
|
|
42
36
|
import { TabScreen } from '../tab-screen';
|
|
@@ -130,12 +124,30 @@ const TabImpl = defineComponent<ITabProps>(
|
|
|
130
124
|
const initialRoutes = buildRoutes(initialRegistry);
|
|
131
125
|
if (initialRoutes.length === 0) dlog('Tab: no <Tab.Screen> children registered');
|
|
132
126
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
127
|
+
// A <Tab.Screen> can appear or disappear after mount (a marker behind a v-if, a data-driven
|
|
128
|
+
// screen list), so the route list is re-collected from the slot on every render and the
|
|
129
|
+
// DISPATCHED state is reconciled against it (reconcileTabRoutes, core - it preserves each
|
|
130
|
+
// surviving route's key and accumulated params and keeps focus on the same route NAME) rather
|
|
131
|
+
// than staying frozen at whatever setup() first saw. Slot vnodes are not reactive state, so
|
|
132
|
+
// the render fn is the only place that can observe the registry change: the reconciliation
|
|
133
|
+
// happens there, as a pure local, and never writes back - a state write inside a derivation is
|
|
134
|
+
// an error in Vue, not a style choice. `null` means nothing has dispatched yet, which is what
|
|
135
|
+
// keeps initialRouteName honored when the markers arrive after the first render.
|
|
136
|
+
const dispatchedState = shallowRef<ITabRouterState | null>(null);
|
|
137
|
+
// Non-reactive cache of the route list the last render derived: dispatch() reduces over the
|
|
138
|
+
// RECONCILED state, so it needs the same list that paint used. A ref here would be a reactive
|
|
139
|
+
// write from the render fn, exactly what the comment above rules out.
|
|
140
|
+
let latestRoutes: readonly IRoute<unknown>[] = initialRoutes;
|
|
141
|
+
|
|
142
|
+
function resolveState(routes: readonly IRoute<unknown>[]): ITabRouterState {
|
|
143
|
+
const dispatched = dispatchedState.value;
|
|
144
|
+
return dispatched === null
|
|
145
|
+
? createInitialTabState(routes, asString(attrs.initialRouteName))
|
|
146
|
+
: reconcileTabRoutes(dispatched, routes);
|
|
147
|
+
}
|
|
136
148
|
|
|
137
|
-
function dispatch(action:
|
|
138
|
-
|
|
149
|
+
function dispatch(action: ITabRouterAction): void {
|
|
150
|
+
dispatchedState.value = tabRouterReducer(resolveState(latestRoutes), action);
|
|
139
151
|
}
|
|
140
152
|
|
|
141
153
|
const jumpTo = (name: string, params?: unknown): void =>
|
|
@@ -146,16 +158,6 @@ const TabImpl = defineComponent<ITabProps>(
|
|
|
146
158
|
const handle: ITabNavigatorHandle = { jumpTo, setParams };
|
|
147
159
|
expose(handle);
|
|
148
160
|
|
|
149
|
-
// Tab paints its own bar in pure JS - there is no native onAppear/onDisappear to hook (unlike
|
|
150
|
-
// Stack's RNSScreen), so focus/blur is synthesized here: emit 'focus' once the newly-focused
|
|
151
|
-
// route's content has mounted, 'blur' when it's about to be replaced or Tab itself unmounts.
|
|
152
|
-
// Keyed on the route KEY (not the route object) so a setParams-only change (new route object,
|
|
153
|
-
// same key) doesn't spuriously re-fire focus/blur - mirrors tabs.ts's React twin exactly,
|
|
154
|
-
// just expressed as a `watch` over the focused key instead of a useEffect dependency array.
|
|
155
|
-
function focusedKeyOf(current: typeof state.value): string | undefined {
|
|
156
|
-
return current.routes[current.index]?.key;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
161
|
// One emitter per route.key, created lazily and cached for the navigator's whole lifetime -
|
|
160
162
|
// mirrors stack.ts's own `emitters` map. This decouples emitter IDENTITY (stable, looked up
|
|
161
163
|
// by key, read by the render closure below when it builds a route's NavigationScope) from
|
|
@@ -179,43 +181,39 @@ const TabImpl = defineComponent<ITabProps>(
|
|
|
179
181
|
// Stack's RNSScreen), so focus/blur is synthesized here: emit 'focus' once the newly-focused
|
|
180
182
|
// route's content has mounted, 'blur' when it's about to be replaced or Tab itself unmounts.
|
|
181
183
|
// Keyed on the route KEY (not the route object) so a setParams-only change (new route object,
|
|
182
|
-
// same key) doesn't spuriously re-fire focus/blur
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
emitterFor(focusedRouteKey).emit(NAVIGATION_EVENT_FOCUS);
|
|
190
|
-
}
|
|
191
|
-
});
|
|
192
|
-
|
|
184
|
+
// same key) doesn't spuriously re-fire focus/blur.
|
|
185
|
+
//
|
|
186
|
+
// Driven from the render pass rather than a `watch` because the focused key follows the
|
|
187
|
+
// SLOT-collected route list too - a marker disappearing can move focus, and slot vnodes are
|
|
188
|
+
// not reactive state, so no watcher source can see that; only a render can. The diff therefore
|
|
189
|
+
// lives where the state is derived, and stays a plain local, never a reactive write.
|
|
190
|
+
//
|
|
193
191
|
// The bookkeeping (which key is focused) updates immediately so the render closure below
|
|
194
192
|
// always reads the right route; the actual emit is deferred to nextTick(), which resolves
|
|
195
193
|
// only after the CURRENT flush cycle fully drains - including the newly-focused screen's own
|
|
196
|
-
// onMounted (subscribing its useIsFocused/useFocusEffect listeners).
|
|
197
|
-
//
|
|
198
|
-
// effect, not after an arbitrary sibling
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
nextKey
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
194
|
+
// onMounted (subscribing its useIsFocused/useFocusEffect listeners). Emitting inline would
|
|
195
|
+
// reach zero subscribers; flush:'post' would not have been enough either, since it only
|
|
196
|
+
// orders a callback after THIS component's own render effect, not after an arbitrary sibling
|
|
197
|
+
// post-flush job (a just-mounted child's onMounted) queued after it in the same batch.
|
|
198
|
+
let focusedRouteKey: string | undefined;
|
|
199
|
+
|
|
200
|
+
function syncFocus(current: ITabRouterState): void {
|
|
201
|
+
const nextKey = current.routes[current.index]?.key;
|
|
202
|
+
const { blurKey, focusKey } = diffFocusedRoute(focusedRouteKey, nextKey);
|
|
203
|
+
if (blurKey === undefined && focusKey === undefined) return;
|
|
204
|
+
focusedRouteKey = nextKey;
|
|
205
|
+
nextTick(() => {
|
|
206
|
+
if (blurKey !== undefined) {
|
|
207
|
+
dlog(`Tab: route "${blurKey}" blurred at t=${Date.now()}`);
|
|
208
|
+
emitterFor(blurKey).emit(NAVIGATION_EVENT_BLUR);
|
|
209
|
+
}
|
|
210
|
+
if (focusKey !== undefined) {
|
|
211
|
+
dlog(`Tab: route "${focusKey}" focused at t=${Date.now()}`);
|
|
212
|
+
emitterFor(focusKey).emit(NAVIGATION_EVENT_FOCUS);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
219
217
|
onUnmounted(() => {
|
|
220
218
|
if (focusedRouteKey !== undefined) emitterFor(focusedRouteKey).emit(NAVIGATION_EVENT_BLUR);
|
|
221
219
|
});
|
|
@@ -226,11 +224,16 @@ const TabImpl = defineComponent<ITabProps>(
|
|
|
226
224
|
|
|
227
225
|
if (registry.size === 0) dlog('Tab: no <Tab.Screen> children registered');
|
|
228
226
|
|
|
229
|
-
const
|
|
227
|
+
const routes = buildRoutes(registry);
|
|
228
|
+
latestRoutes = routes;
|
|
229
|
+
const state = resolveState(routes);
|
|
230
|
+
syncFocus(state);
|
|
231
|
+
|
|
232
|
+
const focusedRoute: IRoute<unknown> | undefined = state.routes[state.index];
|
|
230
233
|
|
|
231
|
-
const items: ITabBarItemView[] = state.
|
|
234
|
+
const items: ITabBarItemView[] = state.routes.map((route, index) => {
|
|
232
235
|
const entry = registry.get(route.name);
|
|
233
|
-
const focused = isFocusedRoute(index, state.
|
|
236
|
+
const focused = isFocusedRoute(index, state.index);
|
|
234
237
|
if (entry === undefined) {
|
|
235
238
|
dlog(`Tab: no screen registered for route name "${route.name}"`);
|
|
236
239
|
return { key: route.key, focused, label: route.name, passthrough: {} };
|