@symbiote-native/navigation 2.1.0 → 2.2.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 +42 -12
- package/build/angular/drawer/index.js +23 -9
- package/build/angular/index.d.ts +4 -4
- package/build/angular/injectors/inject-navigation.js +1 -1
- package/build/angular/navigation-scope.directive.js +1 -1
- package/build/angular/search-bar-ref.directive.js +1 -1
- package/build/angular/stack/index.js +20 -6
- package/build/angular/tabs/index.js +21 -7
- package/build/core/drawer-options/index.js +19 -5
- package/build/core/drawer-router-state/index.js +8 -2
- package/build/core/guards.js +3 -1
- package/build/core/header-bar-buttons.js +14 -4
- package/build/core/index.d.ts +4 -4
- package/build/core/index.js +2 -2
- package/build/core/navigator-state/index.js +12 -4
- package/build/core/render-drawer.js +4 -1
- package/build/core/render-stack.js +13 -4
- package/build/core/screen-options.js +1 -1
- package/build/core/search-bar-commands.js +1 -1
- package/build/core/state-persistence/index.js +6 -2
- package/build/core/tab-router-state/index.js +8 -2
- package/build/react/drawer/index.js +36 -13
- package/build/react/index.d.ts +7 -7
- package/build/react/index.js +2 -2
- package/build/react/stack/index.js +34 -8
- package/build/react/tabs/index.js +14 -4
- package/build/solid/drawer/index.d.ts +22 -0
- package/build/solid/drawer/index.js +394 -0
- package/build/solid/drawer-screen-props.d.ts +13 -0
- package/build/solid/drawer-screen-props.js +2 -0
- package/build/solid/host.d.ts +2 -0
- package/build/solid/host.js +23 -0
- package/build/solid/index.d.ts +19 -0
- package/build/solid/index.js +24 -0
- package/build/solid/linking.d.ts +3 -0
- package/build/solid/linking.js +55 -0
- package/build/solid/navigation-context.d.ts +13 -0
- package/build/solid/navigation-context.js +37 -0
- package/build/solid/primitives/create-focus-effect.d.ts +1 -0
- package/build/solid/primitives/create-focus-effect.js +30 -0
- package/build/solid/primitives/create-is-focused.d.ts +2 -0
- package/build/solid/primitives/create-is-focused.js +31 -0
- package/build/solid/primitives/create-navigation-state.d.ts +3 -0
- package/build/solid/primitives/create-navigation-state.js +30 -0
- package/build/solid/primitives/index.d.ts +12 -0
- package/build/solid/primitives/index.js +8 -0
- package/build/solid/primitives/use-drawer-navigation.d.ts +5 -0
- package/build/solid/primitives/use-drawer-navigation.js +15 -0
- package/build/solid/primitives/use-navigation.d.ts +8 -0
- package/build/solid/primitives/use-navigation.js +23 -0
- package/build/solid/primitives/use-route.d.ts +3 -0
- package/build/solid/primitives/use-route.js +14 -0
- package/build/solid/primitives/use-stack-navigation.d.ts +5 -0
- package/build/solid/primitives/use-stack-navigation.js +19 -0
- package/build/solid/primitives/use-tab-navigation.d.ts +5 -0
- package/build/solid/primitives/use-tab-navigation.js +15 -0
- package/build/solid/screen-props.d.ts +19 -0
- package/build/solid/screen-props.js +9 -0
- package/build/solid/screen-registry.d.ts +31 -0
- package/build/solid/screen-registry.js +86 -0
- package/build/solid/screen.d.ts +6 -0
- package/build/solid/screen.js +69 -0
- package/build/solid/stack/index.d.ts +15 -0
- package/build/solid/stack/index.js +348 -0
- package/build/solid/tab-screen-props.d.ts +13 -0
- package/build/solid/tab-screen-props.js +3 -0
- package/build/solid/tabs/index.d.ts +14 -0
- package/build/solid/tabs/index.js +228 -0
- package/build/svelte/descriptor-subtree.js +3 -1
- package/build/svelte/drawer/index.svelte +163 -25
- package/build/svelte/drawer-screen.svelte +2 -1
- package/build/svelte/index.d.ts +3 -3
- package/build/svelte/navigation-scope.svelte +4 -1
- package/build/svelte/runes/use-navigation.svelte.js +5 -1
- package/build/svelte/stack/index.svelte +43 -6
- package/build/svelte/stack/stack-screen.svelte +66 -9
- package/build/svelte/tabs/index.svelte +42 -20
- package/build/vue/composables/use-navigation.js +5 -1
- package/build/vue/drawer/index.js +30 -8
- package/build/vue/index.d.ts +3 -3
- package/build/vue/navigation-context.js +1 -1
- package/build/vue/stack/index.js +17 -6
- package/build/vue/tabs/index.js +18 -5
- package/build-ngc/angular/drawer/index.js +31 -11
- package/build-ngc/angular/drawer/index.js.map +1 -1
- package/build-ngc/angular/index.d.ts +4 -4
- package/build-ngc/angular/index.js.map +1 -1
- package/build-ngc/angular/injectors/inject-focus-effect.js.map +1 -1
- package/build-ngc/angular/injectors/inject-is-focused.js.map +1 -1
- package/build-ngc/angular/injectors/inject-navigation-state.js.map +1 -1
- package/build-ngc/angular/injectors/inject-navigation.js +1 -1
- package/build-ngc/angular/injectors/inject-navigation.js.map +1 -1
- package/build-ngc/angular/linking.js.map +1 -1
- package/build-ngc/angular/navigation-context.service.js.map +1 -1
- package/build-ngc/angular/navigation-scope.directive.js +1 -1
- package/build-ngc/angular/navigation-scope.directive.js.map +1 -1
- package/build-ngc/angular/screen.directive.js.map +1 -1
- package/build-ngc/angular/search-bar-ref.directive.js +1 -1
- package/build-ngc/angular/search-bar-ref.directive.js.map +1 -1
- package/build-ngc/angular/stack/index.js +26 -8
- package/build-ngc/angular/stack/index.js.map +1 -1
- package/build-ngc/angular/tab-screen.directive.js.map +1 -1
- package/build-ngc/angular/tabs/index.js +21 -7
- package/build-ngc/angular/tabs/index.js.map +1 -1
- package/build-ngc/core/constants.js.map +1 -1
- package/build-ngc/core/drawer-options/index.js +19 -5
- package/build-ngc/core/drawer-options/index.js.map +1 -1
- package/build-ngc/core/drawer-router-state/index.js +8 -2
- package/build-ngc/core/drawer-router-state/index.js.map +1 -1
- package/build-ngc/core/guards.js +3 -1
- package/build-ngc/core/guards.js.map +1 -1
- package/build-ngc/core/header-bar-buttons.js +14 -4
- package/build-ngc/core/header-bar-buttons.js.map +1 -1
- package/build-ngc/core/index.d.ts +4 -4
- package/build-ngc/core/index.js +2 -2
- package/build-ngc/core/index.js.map +1 -1
- package/build-ngc/core/linking-config/index.js.map +1 -1
- package/build-ngc/core/navigation-events/index.js.map +1 -1
- package/build-ngc/core/navigator-handles/index.js.map +1 -1
- package/build-ngc/core/navigator-state/index.js +12 -4
- package/build-ngc/core/navigator-state/index.js.map +1 -1
- package/build-ngc/core/render-drawer.js +4 -1
- package/build-ngc/core/render-drawer.js.map +1 -1
- package/build-ngc/core/render-stack.js +13 -4
- package/build-ngc/core/render-stack.js.map +1 -1
- package/build-ngc/core/render-tabs.js.map +1 -1
- package/build-ngc/core/screen-options.js +1 -1
- package/build-ngc/core/screen-options.js.map +1 -1
- package/build-ngc/core/search-bar-commands.js +1 -1
- package/build-ngc/core/search-bar-commands.js.map +1 -1
- package/build-ngc/core/stack-router-state/index.js.map +1 -1
- package/build-ngc/core/state-persistence/index.js +6 -2
- package/build-ngc/core/state-persistence/index.js.map +1 -1
- package/build-ngc/core/tab-router-state/index.js +8 -2
- package/build-ngc/core/tab-router-state/index.js.map +1 -1
- package/package.json +23 -9
- package/src/angular/drawer/index.ts +69 -25
- package/src/angular/index.ts +20 -4
- package/src/angular/injectors/inject-focus-effect.ts +8 -2
- package/src/angular/injectors/inject-is-focused.ts +6 -4
- package/src/angular/injectors/inject-navigation-state.ts +7 -4
- package/src/angular/injectors/inject-navigation.ts +12 -3
- package/src/angular/linking.ts +4 -1
- package/src/angular/navigation-context.service.ts +5 -2
- package/src/angular/navigation-scope.directive.ts +6 -2
- package/src/angular/screen.directive.ts +13 -3
- package/src/angular/search-bar-ref.directive.ts +10 -2
- package/src/angular/stack/index.ts +68 -22
- package/src/angular/tab-screen.directive.ts +3 -1
- package/src/angular/tabs/index.ts +36 -12
- package/src/core/constants.ts +6 -3
- package/src/core/drawer-options/index.ts +40 -13
- package/src/core/drawer-router-state/index.ts +11 -3
- package/src/core/guards.ts +3 -1
- package/src/core/header-bar-buttons.ts +41 -12
- package/src/core/index.ts +18 -4
- package/src/core/linking-config/index.ts +31 -10
- package/src/core/navigation-events/index.ts +8 -2
- package/src/core/navigator-handles/index.ts +8 -3
- package/src/core/navigator-props.ts +13 -4
- package/src/core/navigator-state/index.ts +24 -7
- package/src/core/render-drawer.ts +24 -9
- package/src/core/render-stack.ts +52 -13
- package/src/core/render-tabs.ts +24 -6
- package/src/core/screen-options.ts +15 -5
- package/src/core/search-bar-commands.ts +12 -3
- package/src/core/stack-router-state/index.ts +5 -2
- package/src/core/state-persistence/index.ts +13 -3
- package/src/core/tab-router-state/index.ts +10 -3
- package/src/react/drawer/index.ts +408 -344
- package/src/react/drawer-screen.ts +8 -2
- package/src/react/hooks/use-focus-effect.ts +8 -2
- package/src/react/hooks/use-is-focused.ts +6 -2
- package/src/react/hooks/use-navigation.ts +8 -2
- package/src/react/index.ts +37 -7
- package/src/react/linking.ts +4 -1
- package/src/react/navigation-context.ts +6 -2
- package/src/react/screen.ts +12 -3
- package/src/react/stack/index.ts +294 -231
- package/src/react/tab-screen.ts +3 -1
- package/src/react/tabs/index.ts +188 -158
- package/src/solid/drawer/index.ts +570 -0
- package/src/solid/drawer-screen-props.ts +24 -0
- package/src/solid/host.ts +29 -0
- package/src/solid/index.ts +87 -0
- package/src/solid/linking.ts +74 -0
- package/src/solid/navigation-context.ts +56 -0
- package/src/solid/primitives/create-focus-effect.ts +41 -0
- package/src/solid/primitives/create-is-focused.ts +41 -0
- package/src/solid/primitives/create-navigation-state.ts +44 -0
- package/src/solid/primitives/index.ts +12 -0
- package/src/solid/primitives/use-drawer-navigation.ts +25 -0
- package/src/solid/primitives/use-navigation.ts +44 -0
- package/src/solid/primitives/use-route.ts +18 -0
- package/src/solid/primitives/use-stack-navigation.ts +29 -0
- package/src/solid/primitives/use-tab-navigation.ts +25 -0
- package/src/solid/screen-props.ts +51 -0
- package/src/solid/screen-registry.ts +159 -0
- package/src/solid/screen.ts +80 -0
- package/src/solid/stack/index.ts +528 -0
- package/src/solid/tab-screen-props.ts +21 -0
- package/src/solid/tabs/index.ts +327 -0
- package/src/svelte/attachments.ts +3 -1
- package/src/svelte/descriptor-subtree.ts +3 -1
- package/src/svelte/drawer/index.svelte +163 -25
- package/src/svelte/drawer-screen-props.ts +8 -2
- package/src/svelte/drawer-screen.svelte +2 -1
- package/src/svelte/fabric-tree.test-helper.ts +37 -3
- package/src/svelte/index.ts +15 -3
- package/src/svelte/linking.svelte.ts +11 -5
- package/src/svelte/navigation-scope.svelte +4 -1
- package/src/svelte/runes/use-drawer-navigation.svelte.ts +3 -1
- package/src/svelte/runes/use-focus-effect.svelte.ts +8 -2
- package/src/svelte/runes/use-navigation-state.svelte.ts +3 -1
- package/src/svelte/runes/use-navigation.svelte.ts +13 -3
- package/src/svelte/runes/use-stack-navigation.svelte.ts +3 -1
- package/src/svelte/screen-props.ts +7 -2
- package/src/svelte/screen-registry.ts +9 -3
- package/src/svelte/stack/index.svelte +43 -6
- package/src/svelte/stack/stack-screen.svelte +66 -9
- package/src/svelte/svelte-compile.test-helper.ts +16 -12
- package/src/svelte/tab-screen-props.ts +3 -1
- package/src/svelte/tabs/index.svelte +42 -20
- package/src/vue/composables/use-navigation-state.ts +7 -4
- package/src/vue/composables/use-navigation.ts +13 -3
- package/src/vue/drawer/index.ts +122 -36
- package/src/vue/drawer-screen.ts +12 -5
- package/src/vue/index.ts +15 -3
- package/src/vue/linking.ts +7 -2
- package/src/vue/navigation-context.ts +17 -5
- package/src/vue/screen.ts +7 -2
- package/src/vue/stack/index.ts +82 -34
- package/src/vue/tab-screen.ts +3 -1
- package/src/vue/tabs/index.ts +51 -13
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Stack, the Solid lifecycle half. The route-stack transitions (navigator-state) and the
|
|
2
|
+
// options/props folds (screen-options, render-stack) live in @symbiote-native/navigation core,
|
|
3
|
+
// shared verbatim with the React/Vue/Svelte/Angular adapters; here Solid supplies the lifecycle -
|
|
4
|
+
// a signal for the pushed-route stack, a route-key counter, a callback `ref` for the push/pop/
|
|
5
|
+
// replace handle - plus the descriptor bridge for the header config leaf. Pushing/popping a route
|
|
6
|
+
// is an ordinary child insert/remove: RNSScreenStack diffs its RNSScreen children natively, so no
|
|
7
|
+
// imperative native command is needed here. Neither this nor the Screen marker imports
|
|
8
|
+
// react-native-screens' own React components (they are React internally and crash a non-React
|
|
9
|
+
// adapter); the native views are driven through the ViewConfig ../../register registers. See
|
|
10
|
+
// CLAUDE.md <third_party_rn_packages_are_react_only>.
|
|
11
|
+
//
|
|
12
|
+
// FOUR THINGS DIFFER FROM EVERY OTHER ADAPTER, and each is a place a naive port freezes:
|
|
13
|
+
//
|
|
14
|
+
// 1. THE REGISTRY IS EMPTY WHEN THIS BODY RUNS. Solid cannot inspect children (screen-registry.ts),
|
|
15
|
+
// and the markers only register once the collector Provider evaluates them - after the body has
|
|
16
|
+
// returned. So nothing is computed eagerly from the registry: the initial state is seeded lazily
|
|
17
|
+
// by `seed()` the first time a non-empty registry is observed.
|
|
18
|
+
//
|
|
19
|
+
// 2. ROUTES ARE KEYED BY `route.key`, NOT BY THE ROUTE OBJECT. `setParams` produces a NEW route
|
|
20
|
+
// object under the same key. A `<For each={routes()}>` keys on object identity and would tear a
|
|
21
|
+
// screen's whole subtree down and rebuild it on every setParams - losing native-owned state and
|
|
22
|
+
// every ref. Iterating the KEYS (a memo with an element-wise `equals`) makes a params change
|
|
23
|
+
// invisible to the list and visible only to the leaf accessors that read it.
|
|
24
|
+
//
|
|
25
|
+
// 3. THE SCREEN IS BUILT BEHIND AN EXPLICIT REBUILD BOUNDARY. Three things legitimately change a
|
|
26
|
+
// route's native SHAPE rather than its props: `screenViewName` (RN treats RNSModalScreen as a
|
|
27
|
+
// different view from RNSScreen), `inModal` (a modal nests an inner stack purely to host the
|
|
28
|
+
// header - isHeaderInModal in core/render-stack.ts), and whether the header config carries a
|
|
29
|
+
// search-bar child. Each is read in a `createMemo` over those discriminators ALONE with the
|
|
30
|
+
// build `untrack`ed - the shape .claude/rules/solid-descriptor-bridge.md §5 prescribes. Without
|
|
31
|
+
// the untrack the memo subscribes to every prop the build reads and re-creates the native screen
|
|
32
|
+
// on each header-title change. The screen COMPONENT is its own, narrower boundary inside that.
|
|
33
|
+
//
|
|
34
|
+
// 4. FOCUS/STATE EMISSION IS DEFERRED BY A MICROTASK. A screen's subtree - and the subscriptions
|
|
35
|
+
// createIsFocused/createNavigationState set up inside it - is built by the render effects of the
|
|
36
|
+
// very update that changed the state, so an inline emit would reach zero subscribers.
|
|
37
|
+
import { For, createEffect, createMemo, createSignal, onCleanup, untrack, } from 'solid-js';
|
|
38
|
+
import { descriptorToSolid } from '@symbiote-native/solid';
|
|
39
|
+
// `createComponent` comes from solid-js, not from the renderer: they are the SAME runtime call
|
|
40
|
+
// (untrack + invoke), but the renderer's is typed to return a host node, which a Solid component
|
|
41
|
+
// legitimately does not have to (a screen may render nothing). `insert`/`insertNode` are the
|
|
42
|
+
// renderer's own, since they mutate the engine tree.
|
|
43
|
+
import { createComponent } from 'solid-js';
|
|
44
|
+
import { insert, insertNode } from '@symbiote-native/solid/renderer';
|
|
45
|
+
import { Platform, debugNodeId, dlog, isSymbioteNode, } from '@symbiote-native/engine';
|
|
46
|
+
import { NAVIGATION_EVENT_BLUR, NAVIGATION_EVENT_FOCUS, NAVIGATION_EVENT_STATE, RNS_SCREEN_CONTENT_WRAPPER_VIEW_NAME, RNS_SCREEN_STACK_VIEW_NAME, RNS_SCREEN_VIEW_NAME, SCREEN_ON_APPEAR, SCREEN_ON_DISAPPEAR, SCREEN_ON_DISMISSED, SCREEN_ON_HEADER_BACK_BUTTON_CLICKED, SCREEN_ON_WILL_APPEAR, SCREEN_ON_WILL_DISAPPEAR, STACK_ON_FINISH_TRANSITIONING, buildSearchBarHandle, buildSearchBarPassthrough, createInitialNavigatorState, createNavigationEmitter, navigatorReducer, reconcileStackRoutes, resolveScreenRenderPlan, resolveStackProps, } from '../../core/index.js';
|
|
47
|
+
import { hostElement } from '../host.js';
|
|
48
|
+
import { NavigationScopeProvider, useNavigationScope, } from '../navigation-context.js';
|
|
49
|
+
import { ScreenCollectorProvider, createScreenSignal, toRegistry, } from '../screen-registry.js';
|
|
50
|
+
import { Screen } from '../screen.js';
|
|
51
|
+
// backTitleVisible defaults to `true` on both platforms per the codegen spec's own default
|
|
52
|
+
// (CT.WithDefault<boolean, 'true'>) - no ios/android divergence in v1 scope.
|
|
53
|
+
const NAVIGATOR_PLATFORM = {
|
|
54
|
+
defaultHeaderBackTitleVisible: true,
|
|
55
|
+
};
|
|
56
|
+
const EMPTY_STATE = { routes: [] };
|
|
57
|
+
// Route keys must be unique per navigator INSTANCE. React/Vue take that from useId(); Solid has no
|
|
58
|
+
// equivalent, so a module counter stands in - read once, at construction.
|
|
59
|
+
let navigatorSequence = 0;
|
|
60
|
+
function sameShape(a, b) {
|
|
61
|
+
return (a.viewName === b.viewName &&
|
|
62
|
+
a.inModal === b.inModal &&
|
|
63
|
+
a.hasSearchBar === b.hasSearchBar);
|
|
64
|
+
}
|
|
65
|
+
function sameKeys(a, b) {
|
|
66
|
+
return a.length === b.length && a.every((key, index) => key === b[index]);
|
|
67
|
+
}
|
|
68
|
+
function resolveScreenOptions(entry, args, screenOptions) {
|
|
69
|
+
const own = typeof entry.options === 'function' ? entry.options(args) : entry.options;
|
|
70
|
+
return { ...screenOptions, ...own };
|
|
71
|
+
}
|
|
72
|
+
function StackImpl(props) {
|
|
73
|
+
// Read BEFORE this Stack provides its own per-screen scope below - becomes the `parent` link a
|
|
74
|
+
// nested screen's useNavigation().getParent() walks. undefined when this Stack is the root.
|
|
75
|
+
const parentScope = useNavigationScope();
|
|
76
|
+
const { screens, collector } = createScreenSignal('stack');
|
|
77
|
+
const registry = createMemo(() => toRegistry(screens()));
|
|
78
|
+
navigatorSequence += 1;
|
|
79
|
+
const routeIdPrefix = `stack-${navigatorSequence}`;
|
|
80
|
+
let routeSequence = 0;
|
|
81
|
+
function createRoute(name, params) {
|
|
82
|
+
routeSequence += 1;
|
|
83
|
+
return { key: `${routeIdPrefix}-${name}-${routeSequence}`, name, params };
|
|
84
|
+
}
|
|
85
|
+
// One emitter per route.key, created lazily and pruned once the route is popped (below).
|
|
86
|
+
const emitters = new Map();
|
|
87
|
+
function emitterFor(routeKey) {
|
|
88
|
+
let emitter = emitters.get(routeKey);
|
|
89
|
+
if (emitter === undefined) {
|
|
90
|
+
emitter = createNavigationEmitter();
|
|
91
|
+
emitters.set(routeKey, emitter);
|
|
92
|
+
}
|
|
93
|
+
return emitter;
|
|
94
|
+
}
|
|
95
|
+
const [dispatched, setDispatched] = createSignal(null);
|
|
96
|
+
// The initial push, deferred until the markers have registered (header note 1). Cached in a plain
|
|
97
|
+
// local rather than a memo: it must run EXACTLY once - a memo re-running would mint a second
|
|
98
|
+
// route key for the same screen and silently reset the stack.
|
|
99
|
+
let seeded;
|
|
100
|
+
function seed(entries) {
|
|
101
|
+
if (seeded !== undefined)
|
|
102
|
+
return seeded;
|
|
103
|
+
// Deliberately NOT seeded from an empty registry, even when `initialRouteName` names the route:
|
|
104
|
+
// <For> maps a key exactly once, so a route minted before its marker exists would be handed a
|
|
105
|
+
// build with no component and stay blank forever. Waiting one update costs nothing - the
|
|
106
|
+
// markers register during the same flush this navigator is created in.
|
|
107
|
+
if (entries.size === 0) {
|
|
108
|
+
dlog('Stack: no <Stack.Screen> children registered');
|
|
109
|
+
return EMPTY_STATE;
|
|
110
|
+
}
|
|
111
|
+
const initialRouteName = props.initialRouteName ?? entries.keys().next().value;
|
|
112
|
+
if (initialRouteName === undefined)
|
|
113
|
+
return EMPTY_STATE;
|
|
114
|
+
seeded = createInitialNavigatorState(createRoute(initialRouteName, entries.get(initialRouteName)?.initialParams));
|
|
115
|
+
return seeded;
|
|
116
|
+
}
|
|
117
|
+
// A <Stack.Screen> marker can vanish while its route is still in the pushed history, which would
|
|
118
|
+
// leave that entry with nothing to render (reconcileStackRoutes' header). Reconciling on READ
|
|
119
|
+
// keeps the repair out of any write path; the next dispatch then persists it.
|
|
120
|
+
const currentState = createMemo(() => {
|
|
121
|
+
const entries = registry();
|
|
122
|
+
const state = dispatched();
|
|
123
|
+
if (state === null)
|
|
124
|
+
return seed(entries);
|
|
125
|
+
return reconcileStackRoutes(state, [...entries.keys()]);
|
|
126
|
+
});
|
|
127
|
+
function dispatch(action) {
|
|
128
|
+
setDispatched(navigatorReducer(currentState(), action));
|
|
129
|
+
}
|
|
130
|
+
const handle = {
|
|
131
|
+
push: (name, params) => dispatch({ type: 'push', route: createRoute(name, params) }),
|
|
132
|
+
pop: count => dispatch({ type: 'pop', count }),
|
|
133
|
+
popToTop: () => dispatch({ type: 'popToTop' }),
|
|
134
|
+
popTo: key => dispatch({ type: 'popTo', key }),
|
|
135
|
+
replace: (name, params) => dispatch({ type: 'replace', route: createRoute(name, params) }),
|
|
136
|
+
setParams: (params, key) => dispatch({ type: 'setParams', key, params }),
|
|
137
|
+
reset: nextState => dispatch({ type: 'reset', state: nextState }),
|
|
138
|
+
canGoBack: () => currentState().routes.length > 1,
|
|
139
|
+
};
|
|
140
|
+
props.ref?.(handle);
|
|
141
|
+
// Broadcasts the router state to every still-live route's emitter (createNavigationState's
|
|
142
|
+
// source) and prunes emitters for popped routes. Deferred by a microtask - header note 4.
|
|
143
|
+
createEffect(() => {
|
|
144
|
+
const current = currentState();
|
|
145
|
+
queueMicrotask(() => {
|
|
146
|
+
for (const route of current.routes) {
|
|
147
|
+
emitterFor(route.key).emit(NAVIGATION_EVENT_STATE, current);
|
|
148
|
+
}
|
|
149
|
+
for (const routeKey of [...emitters.keys()]) {
|
|
150
|
+
if (!current.routes.some(route => route.key === routeKey)) {
|
|
151
|
+
emitters.delete(routeKey);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
// Header note 2: keyed on route KEYS, so a setParams (new route object, same key) never reaches
|
|
157
|
+
// <For> and never rebuilds a screen subtree.
|
|
158
|
+
const routeKeys = createMemo(() => currentState().routes.map(route => route.key), [], { equals: sameKeys });
|
|
159
|
+
function renderRoute(routeKey, index) {
|
|
160
|
+
const route = createMemo(() => currentState().routes.find(candidate => candidate.key === routeKey));
|
|
161
|
+
const entry = createMemo(() => {
|
|
162
|
+
const current = route();
|
|
163
|
+
return current === undefined ? undefined : registry().get(current.name);
|
|
164
|
+
});
|
|
165
|
+
const emitter = emitterFor(routeKey);
|
|
166
|
+
// The host node the app's search-bar ref last saw. `spread` re-runs a bag's `ref` on EVERY prop
|
|
167
|
+
// change, and an app expects one attach per node the way React's ref contract gives it.
|
|
168
|
+
let lastSearchBarNode = null;
|
|
169
|
+
// Keeping the PREVIOUS plan when the route or its marker vanishes: <For> disposes this item in
|
|
170
|
+
// the same update, but memo evaluation order inside one batch is not ours to choose, so the
|
|
171
|
+
// live prop accessors below must never observe an undefined plan mid-teardown.
|
|
172
|
+
const plan = createMemo(previous => {
|
|
173
|
+
const current = route();
|
|
174
|
+
const registered = entry();
|
|
175
|
+
if (current === undefined || registered === undefined)
|
|
176
|
+
return previous;
|
|
177
|
+
const merged = resolveScreenOptions(registered, { route: current, navigation: handle }, props.screenOptions);
|
|
178
|
+
const searchBarOptions = merged.headerSearchBarOptions;
|
|
179
|
+
return resolveScreenRenderPlan({
|
|
180
|
+
screenId: current.key,
|
|
181
|
+
index: index(),
|
|
182
|
+
routeCount: currentState().routes.length,
|
|
183
|
+
options: merged,
|
|
184
|
+
platform: NAVIGATOR_PLATFORM,
|
|
185
|
+
isAndroid: Platform.OS === 'android',
|
|
186
|
+
screenPassthrough: {
|
|
187
|
+
[SCREEN_ON_DISMISSED]: () => dispatch({ type: 'pop', count: 1 }),
|
|
188
|
+
[SCREEN_ON_HEADER_BACK_BUTTON_CLICKED]: () => dispatch({ type: 'pop', count: 1 }),
|
|
189
|
+
// onAppear/onDisappear are the definitive visibility boundary (post-transition), so
|
|
190
|
+
// 'focus'/'blur' fire exactly once per transition; onWillAppear/onWillDisappear fire
|
|
191
|
+
// BEFORE the animation and would double-invoke createFocusEffect, so they only log.
|
|
192
|
+
[SCREEN_ON_WILL_APPEAR]: () => dlog(`Stack: route "${current.name}" will appear at t=${Date.now()}`),
|
|
193
|
+
[SCREEN_ON_APPEAR]: () => {
|
|
194
|
+
dlog(`Stack: route "${current.name}" appeared (focus) at t=${Date.now()}`);
|
|
195
|
+
emitter.emit(NAVIGATION_EVENT_FOCUS);
|
|
196
|
+
},
|
|
197
|
+
[SCREEN_ON_WILL_DISAPPEAR]: () => dlog(`Stack: route "${current.name}" will disappear at t=${Date.now()}`),
|
|
198
|
+
[SCREEN_ON_DISAPPEAR]: () => {
|
|
199
|
+
dlog(`Stack: route "${current.name}" disappeared (blur) at t=${Date.now()}`);
|
|
200
|
+
emitter.emit(NAVIGATION_EVENT_BLUR);
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
searchBarPassthrough: searchBarOptions === undefined
|
|
204
|
+
? undefined
|
|
205
|
+
: {
|
|
206
|
+
...buildSearchBarPassthrough(searchBarOptions, message => dlog(`Stack: route "${current.name}" ${message}`)),
|
|
207
|
+
// The imperative ref (ISearchBarCommands): `spread` calls a `ref` found in the bag
|
|
208
|
+
// with the host node, so it rides down inside passthrough untouched.
|
|
209
|
+
ref: (element) => {
|
|
210
|
+
const node = isSymbioteNode(element) ? element : null;
|
|
211
|
+
if (node === lastSearchBarNode)
|
|
212
|
+
return;
|
|
213
|
+
lastSearchBarNode = node;
|
|
214
|
+
dlog(`Stack: search bar ref, node=${node === null ? 'null' : debugNodeId(node)}`);
|
|
215
|
+
searchBarOptions.ref?.(node === null ? null : buildSearchBarHandle(() => node));
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
}, undefined);
|
|
220
|
+
// NOT an early `return undefined` when the plan is still unresolved. <For> maps a key exactly
|
|
221
|
+
// ONCE, so bailing here would strand the route with nothing to paint for the rest of its life -
|
|
222
|
+
// and an unresolved plan is the NORMAL first state, since the markers register after the
|
|
223
|
+
// navigator's body has run (header note 1).
|
|
224
|
+
const shape = createMemo(() => {
|
|
225
|
+
const current = plan();
|
|
226
|
+
if (current === undefined)
|
|
227
|
+
return undefined;
|
|
228
|
+
return {
|
|
229
|
+
viewName: current.screenViewName,
|
|
230
|
+
inModal: current.inModal,
|
|
231
|
+
hasSearchBar: current.searchBarProps !== undefined,
|
|
232
|
+
};
|
|
233
|
+
}, undefined, {
|
|
234
|
+
equals: (a, b) => a === b || (a !== undefined && b !== undefined && sameShape(a, b)),
|
|
235
|
+
});
|
|
236
|
+
function buildScreen(current) {
|
|
237
|
+
// Re-read rather than closed over: this runs on every rebuild boundary crossing, and the
|
|
238
|
+
// fallbacks below keep the live accessors from ever seeing `undefined` mid-teardown.
|
|
239
|
+
const builtPlan = plan();
|
|
240
|
+
const builtRoute = route();
|
|
241
|
+
const builtEntry = entry();
|
|
242
|
+
if (builtPlan === undefined ||
|
|
243
|
+
builtRoute === undefined ||
|
|
244
|
+
builtEntry === undefined) {
|
|
245
|
+
dlog(`Stack: no screen registered for route key "${routeKey}"`);
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
const livePlan = () => plan() ?? builtPlan;
|
|
249
|
+
const liveRoute = () => route() ?? builtRoute;
|
|
250
|
+
const initialComponent = builtEntry.component;
|
|
251
|
+
const scope = () => ({
|
|
252
|
+
route: liveRoute(),
|
|
253
|
+
navigation: handle,
|
|
254
|
+
emitter,
|
|
255
|
+
parent: parentScope?.(),
|
|
256
|
+
});
|
|
257
|
+
// The narrower boundary inside the shape one: a screen swapping its `component` rebuilds only
|
|
258
|
+
// the body, never the native screen.
|
|
259
|
+
const component = createMemo(() => entry()?.component ?? initialComponent);
|
|
260
|
+
// Must not be flattened away (collapsable: false, supplied by contentWrapperProps) -
|
|
261
|
+
// react-native-screens' native side finds THIS view type by class check to register a
|
|
262
|
+
// formSheet's content for sizing (RNS_SCREEN_CONTENT_WRAPPER_VIEW_NAME in core/constants.ts).
|
|
263
|
+
const contentWrapper = hostElement(RNS_SCREEN_CONTENT_WRAPPER_VIEW_NAME, () => livePlan().contentWrapperProps);
|
|
264
|
+
insert(contentWrapper, createComponent(NavigationScopeProvider, {
|
|
265
|
+
value: scope,
|
|
266
|
+
// The screen component is created HERE, inside the Provider's own children resolution,
|
|
267
|
+
// not in a memo declared above it - a Solid computation captures its owner at CREATION,
|
|
268
|
+
// so a component built outside this getter would run with the enclosing owner and see no
|
|
269
|
+
// navigation scope at all (useRoute would throw). Reading `component()` tracked makes the
|
|
270
|
+
// getter the rebuild boundary for a component swap; `untrack` around the build keeps the
|
|
271
|
+
// screen's own prop reads out of that boundary. No route/navigation props: the screen
|
|
272
|
+
// reads both through the primitives off the scope provided here.
|
|
273
|
+
get children() {
|
|
274
|
+
const Component = component();
|
|
275
|
+
return untrack(() => createComponent(Component, {}));
|
|
276
|
+
},
|
|
277
|
+
}));
|
|
278
|
+
// The header config (and its RNSSearchBar child, when the screen has one) is a pure
|
|
279
|
+
// Descriptor leaf, so it goes through the shared bridge verbatim, exactly like React's and
|
|
280
|
+
// Vue's do. Its child COUNT is part of `shape` above, which is what keeps the bridge's
|
|
281
|
+
// shape-stability contract satisfied when an app adds or removes headerSearchBarOptions.
|
|
282
|
+
const headerConfig = descriptorToSolid(() => livePlan().headerConfig);
|
|
283
|
+
const screen = hostElement(current.viewName, () => livePlan().screenProps);
|
|
284
|
+
if (!current.inModal) {
|
|
285
|
+
insertNode(screen, headerConfig);
|
|
286
|
+
insertNode(screen, contentWrapper);
|
|
287
|
+
return screen;
|
|
288
|
+
}
|
|
289
|
+
// A modal/formSheet screen has no UINavigationController of its own on iOS - nest an inner
|
|
290
|
+
// RNSScreenStack/RNSScreen purely to host the native header bar. Skipping this leaves
|
|
291
|
+
// RNSScreenStackHeaderConfig with no navigation controller to attach to, so the header
|
|
292
|
+
// silently never renders.
|
|
293
|
+
const innerStack = hostElement(RNS_SCREEN_STACK_VIEW_NAME, () => ({
|
|
294
|
+
style: livePlan().innerStackStyle,
|
|
295
|
+
}));
|
|
296
|
+
// activityState mirrors the outer Screen's - RNSScreen.mm treats an unset/inactive nested
|
|
297
|
+
// screen as not yet pushed, leaving it parked at its pre-push transition position (off past
|
|
298
|
+
// the bottom edge) instead of its real, presented frame.
|
|
299
|
+
const innerScreen = hostElement(RNS_SCREEN_VIEW_NAME, () => ({
|
|
300
|
+
style: livePlan().innerScreenStyle,
|
|
301
|
+
activityState: livePlan().activityState,
|
|
302
|
+
}));
|
|
303
|
+
insertNode(innerScreen, headerConfig);
|
|
304
|
+
insertNode(innerScreen, contentWrapper);
|
|
305
|
+
insertNode(innerStack, innerScreen);
|
|
306
|
+
insertNode(screen, innerStack);
|
|
307
|
+
return screen;
|
|
308
|
+
}
|
|
309
|
+
return createMemo(() => {
|
|
310
|
+
const current = shape();
|
|
311
|
+
if (current === undefined)
|
|
312
|
+
return undefined;
|
|
313
|
+
return untrack(() => buildScreen(current));
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
// Investigation instrumentation (flicker-on-focus bug): STACK_ON_FINISH_TRANSITIONING is the
|
|
317
|
+
// native signal that the WHOLE push/pop animation finished, as opposed to the per-screen
|
|
318
|
+
// onAppear/onDisappear. Kept behind DEBUG, never removed.
|
|
319
|
+
const stackProps = resolveStackProps({
|
|
320
|
+
passthrough: {
|
|
321
|
+
[STACK_ON_FINISH_TRANSITIONING]: () => dlog(`Stack: onFinishTransitioning at t=${Date.now()}`),
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
const root = hostElement(RNS_SCREEN_STACK_VIEW_NAME, () => stackProps);
|
|
325
|
+
// The <For> component is created EAGERLY and handed to `insert` as a value, never as
|
|
326
|
+
// `() => createComponent(For, …)`. The latter would read `each` inside insert's own render
|
|
327
|
+
// effect, so every route change would re-create the whole list instead of letting For's keyed
|
|
328
|
+
// mapping reuse the screens it already built.
|
|
329
|
+
insert(root, createComponent(For, {
|
|
330
|
+
get each() {
|
|
331
|
+
return routeKeys();
|
|
332
|
+
},
|
|
333
|
+
children: renderRoute,
|
|
334
|
+
}));
|
|
335
|
+
onCleanup(() => emitters.clear());
|
|
336
|
+
// `props.children` is READ here and nowhere else: reading it is what CREATES the markers, and
|
|
337
|
+
// they must be created inside this Provider so their collectStackScreen() finds the collector on
|
|
338
|
+
// the owner chain. They paint nothing, so the array is effectively just `root` - and `root` is a
|
|
339
|
+
// pre-built node reference, so a marker list that changes re-runs this getter without disturbing
|
|
340
|
+
// the navigator tree.
|
|
341
|
+
return createComponent(ScreenCollectorProvider, {
|
|
342
|
+
value: collector,
|
|
343
|
+
get children() {
|
|
344
|
+
return [props.children, root];
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
export const Stack = Object.assign(StackImpl, { Screen });
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Component } from 'solid-js';
|
|
2
|
+
import type { IRoute, ITabNavigatorHandle, ITabOptions } from '../core';
|
|
3
|
+
export type ITabScreenOptionsArgs = {
|
|
4
|
+
route: IRoute<unknown>;
|
|
5
|
+
navigation: ITabNavigatorHandle;
|
|
6
|
+
};
|
|
7
|
+
export type ITabScreenOptionsResolver = (args: ITabScreenOptionsArgs) => ITabOptions;
|
|
8
|
+
export type ITabScreenProps = {
|
|
9
|
+
name: string;
|
|
10
|
+
component: Component;
|
|
11
|
+
options?: ITabOptions | ITabScreenOptionsResolver;
|
|
12
|
+
initialParams?: unknown;
|
|
13
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { JSX } from '@symbiote-native/solid/jsx-runtime';
|
|
2
|
+
import type { ITabNavigatorHandle, ITabOptions } from '../../core';
|
|
3
|
+
import { TabScreen } from '../screen';
|
|
4
|
+
export type { ITabNavigatorHandle } from '../../core';
|
|
5
|
+
export type ITabProps = {
|
|
6
|
+
initialRouteName?: string;
|
|
7
|
+
screenOptions?: ITabOptions;
|
|
8
|
+
ref?: (handle: ITabNavigatorHandle) => void;
|
|
9
|
+
children?: JSX.Element;
|
|
10
|
+
};
|
|
11
|
+
declare function TabImpl(props: ITabProps): JSX.Element;
|
|
12
|
+
export declare const Tab: typeof TabImpl & {
|
|
13
|
+
Screen: typeof TabScreen;
|
|
14
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Tab, the Solid lifecycle half. The focused-index router (tab-router-state) and the tab-bar
|
|
2
|
+
// Descriptor builder (render-tabs) live in @symbiote-native/navigation core, shared verbatim with
|
|
3
|
+
// every other adapter; here Solid supplies the lifecycle - a signal for the dispatched router
|
|
4
|
+
// state, a callback `ref` for the jumpTo/setParams handle - plus the descriptor bridge for the
|
|
5
|
+
// tab-bar leaf. Unlike Stack, a bottom-tabs bar is a PURE-JS UI painting ordinary
|
|
6
|
+
// `symbiote-view`/`symbiote-text` primitives, so there is no react-native-screens ViewConfig to
|
|
7
|
+
// register here.
|
|
8
|
+
//
|
|
9
|
+
// TWO SOLID-ONLY REBUILD BOUNDARIES, both invisible in every other adapter:
|
|
10
|
+
//
|
|
11
|
+
// 1. THE TAB BAR'S SHAPE FOLLOWS ITS DATA. renderTabBar emits one child per item, and each item
|
|
12
|
+
// emits an icon wrapper only when it has an icon or a badge - so adding a screen, or a badge
|
|
13
|
+
// appearing, CHANGES the Descriptor's shape, which descriptorToSolid's shape guard rejects by
|
|
14
|
+
// design (it builds once and updates props in place). `barSignature` captures exactly the parts
|
|
15
|
+
// of the data the shape depends on, and the bar is rebuilt when that string changes and only
|
|
16
|
+
// then. Every other adapter re-renders the whole bar every time and never meets this.
|
|
17
|
+
//
|
|
18
|
+
// 2. ONLY THE FOCUSED SCREEN IS MOUNTED, so a focus change is a genuine rebuild while a
|
|
19
|
+
// `setParams` on the focused route must NOT be. The content memo therefore depends on the
|
|
20
|
+
// focused route KEY and its component identity alone, with the build `untrack`ed
|
|
21
|
+
// (.claude/rules/solid-descriptor-bridge.md §5); the route object itself reaches the screen
|
|
22
|
+
// through the scope accessor, which is what keeps useRoute() live without rebuilding anything.
|
|
23
|
+
import { createEffect, createMemo, createSignal, untrack } from 'solid-js';
|
|
24
|
+
import { descriptorToSolid } from '@symbiote-native/solid';
|
|
25
|
+
// createComponent from solid-js, insert from the renderer - see stack/index.ts's note.
|
|
26
|
+
import { createComponent } from 'solid-js';
|
|
27
|
+
import { insert } from '@symbiote-native/solid/renderer';
|
|
28
|
+
import { dlog } from '@symbiote-native/engine';
|
|
29
|
+
import { NAVIGATION_EVENT_BLUR, NAVIGATION_EVENT_FOCUS, createInitialTabState, createNavigationEmitter, diffFocusedRoute, isFocusedRoute, reconcileTabRoutes, renderTabBar, tabRouterReducer, } from '../../core/index.js';
|
|
30
|
+
import { hostElement } from '../host.js';
|
|
31
|
+
import { NavigationScopeProvider, useNavigationScope, } from '../navigation-context.js';
|
|
32
|
+
import { ScreenCollectorProvider, createScreenSignal, toRegistry, } from '../screen-registry.js';
|
|
33
|
+
import { TabScreen } from '../screen.js';
|
|
34
|
+
const TAB_CONTENT_STYLE = { flex: 1 };
|
|
35
|
+
const TAB_ROOT_STYLE = { flex: 1 };
|
|
36
|
+
let navigatorSequence = 0;
|
|
37
|
+
function resolveTabOptions(entry, args, screenOptions) {
|
|
38
|
+
const own = typeof entry.options === 'function' ? entry.options(args) : entry.options;
|
|
39
|
+
return { ...screenOptions, ...own };
|
|
40
|
+
}
|
|
41
|
+
// A Descriptor icon is spliced into the bar verbatim, so only its ROOT type is visible here - an
|
|
42
|
+
// app that swaps one Descriptor icon for another of the same type but a different child count
|
|
43
|
+
// would still trip the bridge's shape guard, with the guard's own message. That is the honest
|
|
44
|
+
// boundary: this signature covers the shape decisions render-tabs.ts itself makes.
|
|
45
|
+
function iconKind(icon) {
|
|
46
|
+
if (icon === undefined)
|
|
47
|
+
return '-';
|
|
48
|
+
return typeof icon === 'string' ? 'glyph' : icon.type;
|
|
49
|
+
}
|
|
50
|
+
function barSignature(items) {
|
|
51
|
+
return items
|
|
52
|
+
.map(item => `${item.key}:${iconKind(item.icon)}:${item.badge === undefined ? 0 : 1}`)
|
|
53
|
+
.join('|');
|
|
54
|
+
}
|
|
55
|
+
function TabImpl(props) {
|
|
56
|
+
// Read BEFORE this Tab provides its own per-screen scope - becomes the `parent` link a nested
|
|
57
|
+
// screen's useNavigation().getParent() walks. undefined when this Tab is the nesting root.
|
|
58
|
+
const parentScope = useNavigationScope();
|
|
59
|
+
const { screens, collector } = createScreenSignal('tab');
|
|
60
|
+
const registry = createMemo(() => toRegistry(screens()));
|
|
61
|
+
navigatorSequence += 1;
|
|
62
|
+
const routeIdPrefix = `tab-${navigatorSequence}`;
|
|
63
|
+
// Route keys are derived from the screen NAME, never a counter: a Tab's route list is rebuilt
|
|
64
|
+
// from the registry on every registration change, and a counter would mint a fresh key each time,
|
|
65
|
+
// re-mounting every screen for an unrelated marker edit.
|
|
66
|
+
const routes = createMemo(() => [...registry().entries()].map(([name, entry]) => ({
|
|
67
|
+
key: `${routeIdPrefix}-${name}`,
|
|
68
|
+
name,
|
|
69
|
+
params: entry.initialParams,
|
|
70
|
+
})));
|
|
71
|
+
// `null` means nothing has dispatched yet, which is what keeps initialRouteName honored when the
|
|
72
|
+
// markers arrive after the body has already run (screen-registry.ts).
|
|
73
|
+
const [dispatched, setDispatched] = createSignal(null);
|
|
74
|
+
const state = createMemo(() => {
|
|
75
|
+
const current = routes();
|
|
76
|
+
const previous = dispatched();
|
|
77
|
+
return previous === null
|
|
78
|
+
? createInitialTabState(current, props.initialRouteName)
|
|
79
|
+
: reconcileTabRoutes(previous, current);
|
|
80
|
+
});
|
|
81
|
+
function dispatch(action) {
|
|
82
|
+
setDispatched(tabRouterReducer(state(), action));
|
|
83
|
+
}
|
|
84
|
+
const jumpTo = (name, params) => dispatch({ type: 'jumpTo', name, params });
|
|
85
|
+
const setParams = (params, key) => dispatch({ type: 'setParams', key, params });
|
|
86
|
+
const handle = { jumpTo, setParams };
|
|
87
|
+
props.ref?.(handle);
|
|
88
|
+
// One emitter per route.key, created lazily and cached for the navigator's whole lifetime. Emitter
|
|
89
|
+
// IDENTITY (stable, looked up by key) is deliberately decoupled from emit TIMING (deferred below):
|
|
90
|
+
// a scheme that recreated the emitter on each focus change raced the build of the very screen that
|
|
91
|
+
// was supposed to subscribe to it.
|
|
92
|
+
const emitters = new Map();
|
|
93
|
+
function emitterFor(routeKey) {
|
|
94
|
+
let emitter = emitters.get(routeKey);
|
|
95
|
+
if (emitter === undefined) {
|
|
96
|
+
emitter = createNavigationEmitter();
|
|
97
|
+
emitters.set(routeKey, emitter);
|
|
98
|
+
}
|
|
99
|
+
return emitter;
|
|
100
|
+
}
|
|
101
|
+
const focusedKey = createMemo(() => {
|
|
102
|
+
const current = state();
|
|
103
|
+
return current.routes[current.index]?.key;
|
|
104
|
+
});
|
|
105
|
+
// Tab paints its own bar in pure JS - there is no native onAppear/onDisappear to hook (unlike
|
|
106
|
+
// Stack's RNSScreen), so focus/blur is synthesized here. The emit is deferred to a microtask,
|
|
107
|
+
// which resolves only after Solid's whole effect flush has drained - including the build of the
|
|
108
|
+
// newly focused screen and the subscriptions createIsFocused/createFocusEffect register inside
|
|
109
|
+
// it. Emitting inline would reach zero subscribers.
|
|
110
|
+
let lastFocusedKey;
|
|
111
|
+
createEffect(() => {
|
|
112
|
+
const nextKey = focusedKey();
|
|
113
|
+
const { blurKey, focusKey } = diffFocusedRoute(lastFocusedKey, nextKey);
|
|
114
|
+
if (blurKey === undefined && focusKey === undefined)
|
|
115
|
+
return;
|
|
116
|
+
lastFocusedKey = nextKey;
|
|
117
|
+
queueMicrotask(() => {
|
|
118
|
+
if (blurKey !== undefined) {
|
|
119
|
+
dlog(`Tab: route "${blurKey}" blurred at t=${Date.now()}`);
|
|
120
|
+
emitterFor(blurKey).emit(NAVIGATION_EVENT_BLUR);
|
|
121
|
+
}
|
|
122
|
+
if (focusKey !== undefined) {
|
|
123
|
+
dlog(`Tab: route "${focusKey}" focused at t=${Date.now()}`);
|
|
124
|
+
emitterFor(focusKey).emit(NAVIGATION_EVENT_FOCUS);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
const items = createMemo(() => {
|
|
129
|
+
const current = state();
|
|
130
|
+
const entries = registry();
|
|
131
|
+
return current.routes.map((route, index) => {
|
|
132
|
+
const entry = entries.get(route.name);
|
|
133
|
+
const focused = isFocusedRoute(index, current.index);
|
|
134
|
+
if (entry === undefined) {
|
|
135
|
+
dlog(`Tab: no screen registered for route name "${route.name}"`);
|
|
136
|
+
return { key: route.key, focused, label: route.name, passthrough: {} };
|
|
137
|
+
}
|
|
138
|
+
const options = resolveTabOptions(entry, { route, navigation: handle }, props.screenOptions);
|
|
139
|
+
return {
|
|
140
|
+
key: route.key,
|
|
141
|
+
focused,
|
|
142
|
+
label: options.tabBarLabel ?? options.title ?? route.name,
|
|
143
|
+
icon: options.tabBarIcon,
|
|
144
|
+
badge: options.tabBarBadge,
|
|
145
|
+
activeTintColor: options.tabBarActiveTintColor,
|
|
146
|
+
inactiveTintColor: options.tabBarInactiveTintColor,
|
|
147
|
+
passthrough: {
|
|
148
|
+
onPress: () => jumpTo(route.name),
|
|
149
|
+
accessibilityRole: 'tab',
|
|
150
|
+
accessibilityState: { selected: focused },
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
const focusedOptions = createMemo(() => {
|
|
156
|
+
const current = state();
|
|
157
|
+
const route = current.routes[current.index];
|
|
158
|
+
const entry = route === undefined ? undefined : registry().get(route.name);
|
|
159
|
+
if (entry === undefined || route === undefined)
|
|
160
|
+
return props.screenOptions;
|
|
161
|
+
return resolveTabOptions(entry, { route, navigation: handle }, props.screenOptions);
|
|
162
|
+
});
|
|
163
|
+
// Header note 1: the bar is rebuilt only when its Descriptor SHAPE would change; every tint,
|
|
164
|
+
// label and badge-value change flows through descriptorToSolid's prop diff on the live nodes.
|
|
165
|
+
const barShape = createMemo(() => barSignature(items()));
|
|
166
|
+
const tabBar = createMemo(() => {
|
|
167
|
+
barShape();
|
|
168
|
+
return untrack(() => descriptorToSolid(() => renderTabBar({
|
|
169
|
+
items: items(),
|
|
170
|
+
style: focusedOptions()?.tabBarStyle,
|
|
171
|
+
passthrough: {},
|
|
172
|
+
})));
|
|
173
|
+
});
|
|
174
|
+
// Header note 2. Only the focused route's screen is ever mounted (unlike Stack, which keeps every
|
|
175
|
+
// pushed route alive), so the previous screen's whole subtree - and every listener it registered
|
|
176
|
+
// - is torn down by Solid's ordinary disposal when focus moves on.
|
|
177
|
+
const focusedComponent = createMemo(() => {
|
|
178
|
+
const current = state();
|
|
179
|
+
const route = current.routes[current.index];
|
|
180
|
+
return route === undefined
|
|
181
|
+
? undefined
|
|
182
|
+
: registry().get(route.name)?.component;
|
|
183
|
+
});
|
|
184
|
+
const content = createMemo(() => {
|
|
185
|
+
const routeKey = focusedKey();
|
|
186
|
+
const Component = focusedComponent();
|
|
187
|
+
return untrack(() => {
|
|
188
|
+
if (routeKey === undefined || Component === undefined)
|
|
189
|
+
return undefined;
|
|
190
|
+
const scope = () => ({
|
|
191
|
+
route: routeFor(routeKey),
|
|
192
|
+
navigation: handle,
|
|
193
|
+
emitter: emitterFor(routeKey),
|
|
194
|
+
parent: parentScope?.(),
|
|
195
|
+
});
|
|
196
|
+
return createComponent(NavigationScopeProvider, {
|
|
197
|
+
value: scope,
|
|
198
|
+
// No route/navigation props: the screen reads both through the primitives off the scope
|
|
199
|
+
// provided here (useRoute / useTabNavigation).
|
|
200
|
+
get children() {
|
|
201
|
+
return createComponent(Component, {});
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
// Reads the CURRENT object for a key rather than closing over the one that was focused at build
|
|
207
|
+
// time - the accessor is what makes `setParams` reach a mounted screen's useRoute().
|
|
208
|
+
function routeFor(routeKey) {
|
|
209
|
+
const found = state().routes.find(route => route.key === routeKey);
|
|
210
|
+
return found ?? { key: routeKey, name: '', params: undefined };
|
|
211
|
+
}
|
|
212
|
+
const contentHost = hostElement('symbiote-view', () => ({
|
|
213
|
+
style: TAB_CONTENT_STYLE,
|
|
214
|
+
}));
|
|
215
|
+
insert(contentHost, content);
|
|
216
|
+
const root = hostElement('symbiote-view', () => ({ style: TAB_ROOT_STYLE }));
|
|
217
|
+
// ONE array insert rather than two: a second `insert` on the same parent would find the first
|
|
218
|
+
// child already there and REPLACE it (solid-js/universal's insertExpression), while an array is
|
|
219
|
+
// reconciled by identity - contentHost keeps its node, only the bar is swapped when it rebuilds.
|
|
220
|
+
insert(root, () => [contentHost, tabBar()]);
|
|
221
|
+
return createComponent(ScreenCollectorProvider, {
|
|
222
|
+
value: collector,
|
|
223
|
+
get children() {
|
|
224
|
+
return [props.children, root];
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
export const Tab = Object.assign(TabImpl, { Screen: TabScreen });
|
|
@@ -15,7 +15,9 @@ import { mountDescriptorChildren } from '@symbiote-native/svelte/native-view-bri
|
|
|
15
15
|
const TEXT_CHILD_MARK = '#';
|
|
16
16
|
function shapeOf(children) {
|
|
17
17
|
return children
|
|
18
|
-
.map(child => typeof child === 'string'
|
|
18
|
+
.map(child => typeof child === 'string'
|
|
19
|
+
? TEXT_CHILD_MARK
|
|
20
|
+
: `${child.type}(${shapeOf(child.children)})`)
|
|
19
21
|
.join(',');
|
|
20
22
|
}
|
|
21
23
|
export function createDescriptorSubtreeSync() {
|